From 2c377dcb7ef8fb3242e67e7cb43cfe91bd4c0f1d Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 28 Jun 2026 23:24:43 +0530 Subject: [PATCH 01/43] ci(release): gate signed release on the release environment (#2249) The Desktop release fires on any desktop-v* tag push and consumes the repo-level signing secrets, so any write-access collaborator can cut a signed prod build. Reference the `release` environment from the build jobs so a designated reviewer must approve before the secrets are used. Takes effect once required reviewers are configured on that environment. Co-authored-by: Claude Opus 4.8 --- .github/workflows/frontend-release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index 93b8361a8..281532985 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -41,6 +41,11 @@ jobs: matrix: os: [macos-latest, windows-latest, ubuntu-latest] runs-on: ${{ matrix.os }} + # Gates the signed release behind the `release` environment's required + # reviewers: any write-access user can push a desktop-v* tag, but only + # designated approvers can let the build that consumes the signing secrets + # proceed. Configure reviewers in repo Settings > Environments > release. + environment: release permissions: contents: write defaults: @@ -157,6 +162,7 @@ jobs: # finishes before publish-feed, but the feed is not gated on it. release-intel: runs-on: macos-13 + environment: release # same approval gate as the matrix release job permissions: contents: write defaults: From ff23e66055667e2e430616a540cbdd32151dbad2 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 28 Jun 2026 23:24:57 +0530 Subject: [PATCH 02/43] chore(release): stamp desktop app version 0.10.0 (#2248) Stable release builds read the version from frontend/package.json (the release workflow does not stamp from the git tag, unlike nightly), so the 0.10.0 prod cut needs this committed at the tagged commit. Otherwise the app ships as 0.0.0 and the stable update channel never advances. Co-authored-by: Claude Opus 4.8 --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index bca608324..a70a05a57 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "agent-orchestrator", "productName": "Agent Orchestrator", - "version": "0.0.0", + "version": "0.10.0", "private": true, "description": "Electron + TypeScript frontend for the agent-orchestrator rewrite", "author": "Agent Orchestrator", From 965e3887928fb6b0b2e980da9c4ef50336eb7d49 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Sun, 28 Jun 2026 23:36:06 +0530 Subject: [PATCH 03/43] fix(update): bundle app-update.yml so electron-updater can install (forge omits it) (#2244) * fix(update): generate app-update.yml at build time; drop runtime setFeedURL electron-forge does not emit app-update.yml; electron-updater requires it at process.resourcesPath to resolve the GitHub release feed. Without it every packaged app threw ENOENT on the first download attempt, making updates detected but never installed. Two-part fix: - forge.config.ts: add postPackage hook that writes app-update.yml into each platform's Resources dir (baked from AO_RELEASE_REPO so fork builds point at the fork, prod at AgentWrapper/agent-orchestrator). - auto-updater.ts: remove setFeedURL + repo() + DEFAULT_RELEASE_REPO; configureFeed now only sets channel + allowDowngrade. electron-updater auto-loads the bundled yml on the first checkForUpdates call. Co-Authored-By: Claude Opus 4.8 * chore: format with prettier [skip ci] * fix(update): generate app-update.yml before signing, not after The postPackage hook wrote app-update.yml into Contents/Resources AFTER osxSign sealed the bundle, so the added file was unsealed and macOS reported the app as "damaged" (codesign: "a sealed resource is missing or invalid"). Generate it in a prePackage hook and ship it via packagerConfig.extraResource, so it is copied into the bundle and signed as part of the seal. The generated app-update.yml is gitignored. Co-Authored-By: Claude Opus 4.8 * chore: format with prettier [skip ci] * ci(release): move Intel leg to macos-15-intel and gate the feed on it macos-13 is deprecated and has no runner capacity (multi-hour queues), so the detached release-intel leg never completed. Switch it to the supported macos-15-intel image in both the release and nightly workflows. Now that the Intel leg gets a runner and builds + signs the x64 installer reliably (verified on fork v0.10.10), re-couple it into publish-feed (needs: [release, release-intel]) so latest-mac.yml / nightly-mac.yml always carry the x64 entry and Intel macOS users receive auto-updates. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: github-actions[bot] --- .github/workflows/frontend-nightly.yml | 14 +++++++------- .github/workflows/frontend-release.yml | 17 +++++++++-------- frontend/.gitignore | 1 + frontend/forge.config.ts | 24 +++++++++++++++++++++++- frontend/src/main/auto-updater.ts | 20 +++++--------------- 5 files changed, 45 insertions(+), 31 deletions(-) create mode 100644 frontend/.gitignore diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index b58e1524c..ec3c400ab 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -93,15 +93,15 @@ jobs: # macOS signing + notarization env is exported by the "macOS signing # setup" step above via $GITHUB_ENV; the cert is in the keychain. - # Dedicated Intel (darwin-x64) nightly build leg, decoupled from the release - # matrix so the scarce macos-13 runner does not block publish-feed. Mirrors - # the release job's macOS steps exactly. publish-feed stays needs: release - # only (three fast legs); the x64 feed entry appears if this job finishes - # first, but the feed is not gated on it. + # Dedicated Intel (darwin-x64) nightly build leg. Kept separate from the + # release matrix (only a macOS host can build the x64 installer); publish-feed + # now waits on it so the x64 entry is present in nightly-mac.yml and Intel + # nightly users get auto-updates. Mirrors the release job's macOS steps exactly. + # Runner is macos-15-intel (macos-13 deprecated/no capacity; see release wf). release-intel: needs: guard if: needs.guard.outputs.should_build == 'true' - runs-on: macos-13 + runs-on: macos-15-intel permissions: contents: write defaults: @@ -161,7 +161,7 @@ jobs: # assets. The nightly version is only stamped in-memory on the build runners, # so derive the tag from the guard job's computed version, not package.json. publish-feed: - needs: [guard, release] + needs: [guard, release, release-intel] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index 281532985..06af8c680 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -92,7 +92,7 @@ jobs: # release the publish step just created, with --clobber (so re-runs replace). # # Arch-coverage note: each runner only builds its host arch. macos-latest - # (Apple silicon) produces darwin-arm64; macos-13 (Intel x64) produces + # (Apple silicon) produces darwin-arm64; macos-15-intel (Intel x64) produces # darwin-x64. Both macOS runners run this step (see the `if` below), so the # arm64 and x64 stable aliases are now both built (spec §8). # The Linux stable name is undecided (spec §11.3), so we build the deb/rpm @@ -155,13 +155,14 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Dedicated Intel (darwin-x64) build leg, decoupled from the release matrix so - # the scarce macos-13 runner does not block publish-feed. publish-feed needs - # only the three fast legs (release); this job uploads the x64 installer and - # stable alias independently. feed.mjs includes the x64 entry if this job - # finishes before publish-feed, but the feed is not gated on it. + # Dedicated Intel (darwin-x64) build leg. Kept a separate job (not in the + # release matrix) because only a macOS host can build the x64 installer, but + # publish-feed now waits on it (needs: [release, release-intel]) so the x64 + # entry is always present in latest-mac.yml and Intel users get auto-updates. + # Runner is macos-15-intel: macos-13 is deprecated/has no capacity (multi-hour + # queues), so this leg uses the supported Intel x64 image. release-intel: - runs-on: macos-13 + runs-on: macos-15-intel environment: release # same approval gate as the matrix release job permissions: contents: write @@ -216,7 +217,7 @@ jobs: # assets and upload them to the same release. Runs once, on Linux: it only # hashes already-built artifacts, so it needs no per-OS runner. publish-feed: - needs: release + needs: [release, release-intel] runs-on: ubuntu-latest permissions: contents: write diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..8f18fb362 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1 @@ +app-update.yml diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index 7c3cd5e42..fa7fd35aa 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -2,6 +2,7 @@ import type { ForgeConfig } from "@electron-forge/shared-types"; import { VitePlugin } from "@electron-forge/plugin-vite"; import MakerNSIS from "./makers/maker-nsis"; import MakerAppImage from "./makers/maker-appimage"; +import { writeFileSync } from "node:fs"; // Default GitHub release target (production). aoagents was the temporary rewrite // home; releases land on AgentWrapper (spec §1.1). @@ -30,7 +31,7 @@ const config: ForgeConfig = { // (.icns on macOS, .ico on Windows); Linux menu icons come from the // deb/rpm makers below, and the runtime window icon from src/main.ts. icon: "assets/icon", - extraResource: ["daemon", "assets/icon.png"], + extraResource: ["daemon", "assets/icon.png", "app-update.yml"], // Notarization. Two paths: // - CI: an App Store Connect API key. APPLE_API_KEY is a PATH to the .p8 // (the workflow decodes APPLE_API_KEY_BASE64 to a temp file), plus the @@ -53,6 +54,27 @@ const config: ForgeConfig = { } : undefined, }, + hooks: { + // electron-forge does not generate app-update.yml (electron-builder does); + // electron-updater reads it from the app's Resources dir at runtime to know + // which GitHub repo to pull from, else it throws ENOENT during download. + // Generate it in prePackage (BEFORE osxSign) and ship it via extraResource + // above, so it is copied into the bundle and SIGNED as part of the seal. + // Writing it after signing (a postPackage hook) adds an unsealed resource + // and macOS reports the app as "damaged". owner/repo are baked from + // AO_RELEASE_REPO at build time. + prePackage: async () => { + const { owner, name } = parseReleaseRepo(process.env.AO_RELEASE_REPO); + const yml = [ + "provider: github", + `owner: ${owner}`, + `repo: ${name}`, + "updaterCacheDirName: agent-orchestrator-updater", + "", + ].join("\n"); + writeFileSync("app-update.yml", yml); + }, + }, rebuildConfig: {}, makers: [ // Windows installer: NSIS via electron-builder (see makers/maker-nsis.ts). diff --git a/frontend/src/main/auto-updater.ts b/frontend/src/main/auto-updater.ts index ea6057998..bf8fceb82 100644 --- a/frontend/src/main/auto-updater.ts +++ b/frontend/src/main/auto-updater.ts @@ -10,22 +10,12 @@ import { type UpdateStatus, } from "./update-settings"; -// Default release repo, mirroring backend cli.releaseRepo. Override via env for -// fork test builds (AO_RELEASE_REPO=owner/repo). -const DEFAULT_RELEASE_REPO = "AgentWrapper/agent-orchestrator"; - -function repo(): { owner: string; name: string } { - const [owner, name] = (process.env.AO_RELEASE_REPO || DEFAULT_RELEASE_REPO).split("/"); - if (owner && name) return { owner, name }; - const [defOwner, defName] = DEFAULT_RELEASE_REPO.split("/"); - return { owner: defOwner, name: defName }; -} - -// configureFeed points electron-updater at the GitHub Releases feed for the -// chosen channel. Shared by the launch auto-check and the manual Settings flow. +// configureFeed sets the update channel on electron-updater. The repo/owner +// are loaded automatically from app-update.yml (written by forge.config.ts's +// postPackage hook into the app's Resources dir at build time). No runtime env +// or setFeedURL call is needed; electron-updater reads the bundled yml on first +// checkForUpdates. function configureFeed(channel: UpdateChannel): void { - const { owner, name } = repo(); - autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); autoUpdater.channel = channel; // "latest" | "nightly" autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch } From 6b56b1c2453304bcee58d60349dd9d9a7dc6b0f7 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Mon, 29 Jun 2026 03:37:49 +0530 Subject: [PATCH 04/43] fix(update): enable allowPrerelease on the nightly channel (#2265) Nightly builds publish as GitHub prereleases. configureFeed set channel and allowDowngrade but never allowPrerelease, which defaults to false, so electron-updater only inspected the latest NON-prerelease release (e.g. v0.10.0) and 404d looking for nightly-mac.yml there. The nightly channel therefore never resolved, regardless of whether a nightly was published. Set allowPrerelease = (channel === nightly): nightly scans prereleases and finds the nightly feed; stable stays on non-prereleases only. Co-authored-by: Claude Opus 4.8 --- frontend/src/main/auto-updater.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/main/auto-updater.ts b/frontend/src/main/auto-updater.ts index bf8fceb82..1c8527b15 100644 --- a/frontend/src/main/auto-updater.ts +++ b/frontend/src/main/auto-updater.ts @@ -17,6 +17,11 @@ import { // checkForUpdates. function configureFeed(channel: UpdateChannel): void { autoUpdater.channel = channel; // "latest" | "nightly" + // Nightly builds ship as GitHub *prereleases*. With allowPrerelease false + // (the default) electron-updater only inspects the latest NON-prerelease + // release and looks for nightly-mac.yml there, which 404s. Enable prerelease + // scanning on the nightly channel only; stable must never pull prereleases. + autoUpdater.allowPrerelease = channel === "nightly"; autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch } From 92a52f09929e78305b0962387638049b1c0ab9d2 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Mon, 29 Jun 2026 03:58:19 +0530 Subject: [PATCH 05/43] ci: retry transient macOS sign/notary flakes in the publish step (#2266) Releases fail intermittently on transient macOS issues that hit EITHER macOS leg, not just Intel: Apple notary -1009 connection-offline (seen on macos-latest/arm64) and osx-sign keychain races, code object is not signed at all (seen on macos-15-intel). Both pass on a manual re-run. Wrap npm run publish in a 3x retry with backoff in all four release/nightly publish steps so these self-heal in-place instead of failing the run (and, for nightly, skipping the whole feed). Keeps the Intel leg coupled so the x64 feed entry stays guaranteed. Co-authored-by: Claude Opus 4.8 --- .github/workflows/frontend-nightly.yml | 22 ++++++++++++++++++++-- .github/workflows/frontend-release.yml | 22 ++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index ec3c400ab..b56de27d1 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -85,7 +85,16 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} @@ -135,7 +144,16 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index 06af8c680..ccec8e3cb 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -76,7 +76,16 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} @@ -191,7 +200,16 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish - run: npm run publish + run: | + # Retry transient macOS sign/notary flakes (Apple notary -1009, + # osx-sign keychain races) so the build self-heals instead of failing. + for i in 1 2 3; do + if npm run publish; then exit 0; fi + echo "::warning::publish attempt $i/3 failed (likely a transient macOS sign/notary flake)" + [ "$i" -lt 3 ] && sleep 30 + done + echo "::error::publish failed after 3 attempts" >&2 + exit 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AO_RELEASE_REPO: ${{ github.repository }} From 2cd3b88cfac03b6b972d084cfc492f48d1969217 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Mon, 29 Jun 2026 15:44:30 +0530 Subject: [PATCH 06/43] fix(ci): Windows publish shell + fresh-install smoke check (#2267) (#2269) * fix(ci): pin publish step to bash so the retry loop runs on Windows The 3x retry wrapper added in #2266 is bash syntax, but the release matrix Publish step inherited the runner default shell, which is PowerShell on windows-latest. That made every Windows publish fail with a ParserError. Pin shell: bash on all four publish steps. Co-Authored-By: Claude Opus 4.8 * fix(ci): point fresh-install smoke check at an empty release repo (#2267) The container check asserts `ao start` fails cleanly on a fresh box with no published asset. Now that AgentWrapper publishes a linux-x64 AppImage, an unpinned smoke binary downloads it and exits 0, tripping the assertion. Build the smoke binary against a release repo with no assets so the fetch path deterministically 404s and start exits non-zero with a clear error, preserving the test's intent without depending on what the real repo publishes. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/frontend-nightly.yml | 2 ++ .github/workflows/frontend-release.yml | 2 ++ test/cli/Dockerfile | 8 +++++++- test/cli/install-check.sh | 13 +++++++------ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index b56de27d1..c441b0bd7 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -85,6 +85,7 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish + shell: bash run: | # Retry transient macOS sign/notary flakes (Apple notary -1009, # osx-sign keychain races) so the build self-heals instead of failing. @@ -144,6 +145,7 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish + shell: bash run: | # Retry transient macOS sign/notary flakes (Apple notary -1009, # osx-sign keychain races) so the build self-heals instead of failing. diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index ccec8e3cb..e588a7f0d 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -76,6 +76,7 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish + shell: bash run: | # Retry transient macOS sign/notary flakes (Apple notary -1009, # osx-sign keychain races) so the build self-heals instead of failing. @@ -200,6 +201,7 @@ jobs: apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }} apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }} - name: Publish + shell: bash run: | # Retry transient macOS sign/notary flakes (Apple notary -1009, # osx-sign keychain races) so the build self-heals instead of failing. diff --git a/test/cli/Dockerfile b/test/cli/Dockerfile index ce947d148..43cb951b9 100644 --- a/test/cli/Dockerfile +++ b/test/cli/Dockerfile @@ -18,7 +18,13 @@ RUN cd backend && go mod download COPY backend ./backend # Pure-Go SQLite (modernc) builds fine with CGO disabled -> a static binary. -RUN cd backend && CGO_ENABLED=0 go build -trimpath -o /out/ao ./cmd/ao +# Build against a release repo with NO published assets so `ao start` reaches the +# fetch path and fails cleanly (404) on a fresh box. The real AgentWrapper repo +# now publishes a linux-x64 AppImage, so an unpinned binary would download it and +# exit 0, defeating the fresh-install assertion below. See install-check.sh. +RUN cd backend && CGO_ENABLED=0 go build -trimpath \ + -ldflags "-X github.com/aoagents/agent-orchestrator/backend/internal/cli.releaseRepo=AgentWrapper/ao-fresh-install-fixture" \ + -o /out/ao ./cmd/ao # ---- stage 2: a clean machine with NO Go toolchain, just like an end user ---- FROM debian:bookworm-slim AS run diff --git a/test/cli/install-check.sh b/test/cli/install-check.sh index 3af4756c3..96c2e60a1 100755 --- a/test/cli/install-check.sh +++ b/test/cli/install-check.sh @@ -22,12 +22,13 @@ echo "ao binary : $(command -v "$AO_BIN")" # `ao start` is now the desktop-app launcher: it resolves an installed app or # fetches the release, then opens it (it no longer runs a daemon). On a fresh -# container there is no installed app, so start reaches the fetch path. With no -# published asset for this platform it must exit non-zero with a clear `ao -# start:` error (an unreachable/404 download on amd64, or an unsupported-arch -# error on arm64), never a panic or a silent success. The full launcher -# behaviour is covered by the Go e2e suite; this only proves the fresh-box path -# is sane on whatever arch the runner uses. +# container there is no installed app, so start reaches the fetch path. The smoke +# binary is built against a release repo with no published assets (see +# Dockerfile), so the fetch deterministically 404s and start must exit non-zero +# with a clear `ao start:` error (an unreachable/404 download on amd64, or an +# unsupported-arch error on arm64), never a panic or a silent success. The full +# launcher behaviour is covered by the Go e2e suite; this only proves the +# fresh-box path is sane on whatever arch the runner uses. if err="$("$AO_BIN" start 2>&1)"; then fail "start unexpectedly succeeded on a fresh machine with no installed app" fi From d87b763e34aeb5dd1d070a020c0c399f78ad5c38 Mon Sep 17 00:00:00 2001 From: PRADEEP SAHU Date: Mon, 29 Jun 2026 16:16:58 +0530 Subject: [PATCH 07/43] UI : use React.Memo position for SelectContent to prevent scroll reset (#2261) * fix(ui): use popper position for SelectContent to prevent scroll reset * fix(ui): use React.memo to prevent scroll resets without breaking popper position --- .../src/renderer/components/CreateProjectAgentSheet.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index ea4a7339b..0f9a75f18 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -1,6 +1,6 @@ import * as Dialog from "@radix-ui/react-dialog"; import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { memo, useEffect, useState } from "react"; import { AGENT_OPTIONS } from "../lib/agent-options"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; @@ -108,7 +108,7 @@ export function CreateProjectAgentSheet({ ); } -export function RequiredAgentField({ +export const RequiredAgentField = memo(function RequiredAgentField({ id, invalid = false, label, @@ -142,4 +142,4 @@ export function RequiredAgentField({ ); -} +}); From cb456bb288744778e21377407e4eabd6a3898235 Mon Sep 17 00:00:00 2001 From: neversettle <41864816+neversettle17-101@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:20:28 +0530 Subject: [PATCH 09/43] feat: show multi-PR review status (#2193) * feat: support multi-pr review runs * fix: avoid review state status stutter * feat: batch review delivery by trigger * test: update review inspector mocks * chore: fold review batch migration into 0020 * fix: submit multi-pr reviews as one batch * fix: make queued reviews autonomous * fix: clarify multi-pr review submit prompt * feat: show multi-pr review status * chore: format with prettier [skip ci] * feat: simplify multi-pr review summary * chore: format with prettier [skip ci] * fix: match multi-pr review card design * chore: format with prettier [skip ci] * fix: remove review session label --------- Co-authored-by: github-actions[bot] Co-authored-by: Vaibhaav --- backend/internal/cli/review.go | 95 +++++-- backend/internal/cli/review_test.go | 26 ++ backend/internal/domain/review.go | 14 +- backend/internal/httpd/apispec/openapi.yaml | 75 +++++- .../internal/httpd/apispec/specgen/build.go | 15 +- backend/internal/httpd/controllers/reviews.go | 81 ++++-- .../httpd/controllers/reviews_test.go | 87 ++++++- backend/internal/lifecycle/manager_test.go | 65 ++++- backend/internal/lifecycle/reactions.go | 59 ++++- backend/internal/ports/reviewer.go | 12 + backend/internal/review/launcher.go | 4 + backend/internal/review/planner.go | 95 +++++++ backend/internal/review/planner_test.go | 68 +++++ backend/internal/review/prompt.go | 43 +++- backend/internal/review/prompt_test.go | 37 +++ backend/internal/review/review.go | 238 +++++++++++------- backend/internal/review/review_test.go | 148 +++++++++-- backend/internal/service/review/review.go | 174 +++++++++++-- .../internal/service/review/review_test.go | 175 ++++++++++++- backend/internal/storage/sqlite/gen/models.go | 1 + .../internal/storage/sqlite/gen/review.sql.go | 83 +++++- .../0020_review_run_unique_pr_sha.sql | 88 +++++++ .../storage/sqlite/queries/review.sql | 20 +- .../storage/sqlite/store/review_store.go | 35 ++- .../storage/sqlite/store/review_store_test.go | 70 +++++- frontend/src/api/schema.ts | 37 ++- .../components/SessionInspector.test.tsx | 91 ++++++- .../renderer/components/SessionInspector.tsx | 210 +++++++++------- frontend/src/renderer/styles.css | 200 ++++++++++++++- 29 files changed, 1985 insertions(+), 361 deletions(-) create mode 100644 backend/internal/review/planner.go create mode 100644 backend/internal/review/planner_test.go create mode 100644 backend/internal/review/prompt_test.go create mode 100644 backend/internal/storage/sqlite/migrations/0020_review_run_unique_pr_sha.sql diff --git a/backend/internal/cli/review.go b/backend/internal/cli/review.go index fa9046f96..20207978f 100644 --- a/backend/internal/cli/review.go +++ b/backend/internal/cli/review.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "errors" "fmt" "io" @@ -15,29 +16,42 @@ import ( // reviewRun mirrors the daemon's domain.ReviewRun for the CLI client. type reviewRun struct { - ID string `json:"id"` - SessionID string `json:"sessionId"` - Harness string `json:"harness"` - PRURL string `json:"prUrl"` - TargetSHA string `json:"targetSha"` - Status string `json:"status"` - Verdict string `json:"verdict"` - Body string `json:"body"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + SessionID string `json:"sessionId"` + BatchID string `json:"batchId"` + Harness string `json:"harness"` + PRURL string `json:"prUrl"` + TargetSHA string `json:"targetSha"` + Status string `json:"status"` + Verdict string `json:"verdict"` + Body string `json:"body"` + GithubReviewID string `json:"githubReviewId"` + CreatedAt time.Time `json:"createdAt"` + DeliveredAt *time.Time `json:"deliveredAt,omitempty"` } // reviewRunResponse mirrors controllers.ReviewRunResponse. type reviewRunResponse struct { - Review reviewRun `json:"review"` - ReviewerHandleID string `json:"reviewerHandleId"` + Review reviewRun `json:"review"` + Reviews []reviewRun `json:"reviews"` + ReviewerHandleID string `json:"reviewerHandleId"` +} + +// submitReviewItem mirrors controllers.SubmitReviewItem. +type submitReviewItem struct { + RunID string `json:"runId"` + Verdict string `json:"verdict"` + Body string `json:"body,omitempty"` + GithubReviewID string `json:"githubReviewId,omitempty"` } // submitReviewRequest mirrors controllers.SubmitReviewInput. type submitReviewRequest struct { - RunID string `json:"runId"` - Verdict string `json:"verdict"` - Body string `json:"body"` - GithubReviewID string `json:"githubReviewId"` + RunID string `json:"runId,omitempty"` + Verdict string `json:"verdict,omitempty"` + Body string `json:"body,omitempty"` + GithubReviewID string `json:"githubReviewId,omitempty"` + Reviews []submitReviewItem `json:"reviews,omitempty"` } type reviewSubmitOptions struct { @@ -46,6 +60,7 @@ type reviewSubmitOptions struct { verdict string body string reviewID string + reviews string } func newReviewCommand(ctx *commandContext) *cobra.Command { @@ -77,6 +92,7 @@ func newReviewSubmitCommand(ctx *commandContext) *cobra.Command { cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)") cmd.Flags().StringVar(&opts.body, "body", "", "Review body: a path to a Markdown file, or - to read from stdin (so nothing is written into the worktree)") cmd.Flags().StringVar(&opts.reviewID, "review-id", "", "Id of the GitHub PR review just posted (the .id from the gh api POST that created the review)") + cmd.Flags().StringVar(&opts.reviews, "reviews", "", "JSON review results array or object: a path, or - to read from stdin") return cmd } @@ -88,6 +104,9 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re if session == "" { return usageError{errors.New("usage: worker session id is required (positional or --session)")} } + if strings.TrimSpace(opts.reviews) != "" { + return c.submitReviewBatch(cmd, session, opts) + } runID := strings.TrimSpace(opts.runID) if runID == "" { return usageError{errors.New("usage: --run is required")} @@ -121,3 +140,49 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re _, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded %s review for %s\n", res.Review.Verdict, session) return err } + +func (c *commandContext) submitReviewBatch(cmd *cobra.Command, session string, opts reviewSubmitOptions) error { + if strings.TrimSpace(opts.runID) != "" || strings.TrimSpace(opts.verdict) != "" || strings.TrimSpace(opts.body) != "" || strings.TrimSpace(opts.reviewID) != "" { + return usageError{errors.New("usage: --reviews cannot be combined with --run, --verdict, --body, or --review-id")} + } + reviews, err := readReviewItems(cmd, strings.TrimSpace(opts.reviews)) + if err != nil { + return err + } + path := "sessions/" + url.PathEscape(session) + "/reviews/submit" + var res reviewRunResponse + if err := c.postJSON(cmd.Context(), path, submitReviewRequest{Reviews: reviews}, &res); err != nil { + return err + } + count := len(res.Reviews) + if count == 0 { + count = len(reviews) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "recorded %d review(s) for %s\n", count, session) + return err +} + +func readReviewItems(cmd *cobra.Command, path string) ([]submitReviewItem, error) { + var raw []byte + var err error + if path == "-" { + raw, err = io.ReadAll(cmd.InOrStdin()) + } else { + raw, err = os.ReadFile(path) + } + if err != nil { + return nil, usageError{fmt.Errorf("read review results: %w", err)} + } + var req submitReviewRequest + if err := json.Unmarshal(raw, &req); err == nil && len(req.Reviews) > 0 { + return req.Reviews, nil + } + var reviews []submitReviewItem + if err := json.Unmarshal(raw, &reviews); err != nil { + return nil, usageError{fmt.Errorf("decode review results JSON: %w", err)} + } + if len(reviews) == 0 { + return nil, usageError{errors.New("usage: --reviews requires at least one review result")} + } + return reviews, nil +} diff --git a/backend/internal/cli/review_test.go b/backend/internal/cli/review_test.go index b3033de12..4c3a14816 100644 --- a/backend/internal/cli/review_test.go +++ b/backend/internal/cli/review_test.go @@ -104,6 +104,32 @@ func TestReviewSubmitAcceptsUnderscoreFlags(t *testing.T) { } } +func TestReviewSubmitBatchReadsReviewsFromStdin(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := reviewServer(t, http.StatusOK, `{"reviews":[{"id":"run-1","verdict":"changes_requested"},{"id":"run-2","verdict":"approved"}]}`) + writeRunFileFor(t, cfg, srv) + + deps := aliveDeps() + deps.In = strings.NewReader(`{"reviews":[{"runId":"run-1","verdict":"changes_requested","body":"fix auth","githubReviewId":"101"},{"runId":"run-2","verdict":"approved","body":"looks good"}]}`) + out, errOut, err := executeCLI(t, deps, "review", "submit", "mer-1", "--reviews", "-") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if !strings.Contains(out, "recorded 2 review(s) for mer-1") { + t.Fatalf("stdout = %q", out) + } + var req submitReviewRequest + if err := json.Unmarshal([]byte(capture.body), &req); err != nil { + t.Fatalf("decode body: %v", err) + } + if len(req.Reviews) != 2 || req.Reviews[0].RunID != "run-1" || req.Reviews[0].GithubReviewID != "101" || req.Reviews[1].Verdict != "approved" { + t.Fatalf("request = %+v", req) + } + if req.RunID != "" || req.Verdict != "" { + t.Fatalf("batch request should not also set legacy fields: %+v", req) + } +} + func TestReviewSubmitUsesSessionFlag(t *testing.T) { cfg := setConfigEnv(t) srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`) diff --git a/backend/internal/domain/review.go b/backend/internal/domain/review.go index 6750a16d2..749cb6c24 100644 --- a/backend/internal/domain/review.go +++ b/backend/internal/domain/review.go @@ -29,11 +29,15 @@ type Review struct { // ReviewRun is one review pass against a worker's PR. type ReviewRun struct { - ID string `json:"id"` - ReviewID string `json:"reviewId"` - SessionID SessionID `json:"sessionId"` - Harness ReviewerHarness `json:"harness"` - PRURL string `json:"prUrl"` + ID string `json:"id"` + ReviewID string `json:"reviewId"` + SessionID SessionID `json:"sessionId"` + // BatchID groups review runs created by one trigger so worker feedback can + // be delivered once after the whole trigger batch is terminal. Empty marks + // legacy/single-run delivery. + BatchID string `json:"batchId"` + Harness ReviewerHarness `json:"harness"` + PRURL string `json:"prUrl"` // TargetSHA is the PR head commit this pass reviewed. TargetSHA string `json:"targetSha"` Status ReviewRunStatus `json:"status"` diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2a3de1a9b..1b76c9285 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1292,13 +1292,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReviewRunResponse' + $ref: '#/components/schemas/TriggerReviewResponse' description: OK "201": content: application/json: schema: - $ref: '#/components/schemas/ReviewRunResponse' + $ref: '#/components/schemas/TriggerReviewResponse' description: Created "404": content: @@ -1719,7 +1719,7 @@ components: type: string reviews: items: - $ref: '#/components/schemas/ReviewRun' + $ref: '#/components/schemas/PRReviewState' type: array required: - reviewerHandleId @@ -1855,6 +1855,33 @@ components: - id - projectId type: object + PRReviewState: + properties: + latestRun: + $ref: '#/components/schemas/ReviewRun' + prNumber: + type: integer + prUrl: + type: string + status: + enum: + - needs_review + - running + - up_to_date + - changes_requested + - ineligible + type: string + targetSha: + type: string + title: + type: string + required: + - prUrl + - prNumber + - title + - targetSha + - status + type: object Project: properties: agent: @@ -2016,6 +2043,8 @@ components: type: object ReviewRun: properties: + batchId: + type: string body: type: string createdAt: @@ -2048,6 +2077,7 @@ components: - id - reviewId - sessionId + - batchId - harness - prUrl - targetSha @@ -2063,8 +2093,13 @@ components: $ref: '#/components/schemas/ReviewRun' reviewerHandleId: type: string + reviews: + items: + $ref: '#/components/schemas/ReviewRun' + type: array required: - review + - reviews - reviewerHandleId type: object RoleOverride: @@ -2467,6 +2502,26 @@ components: - projectId type: object SubmitReviewInput: + properties: + body: + description: Review body recorded by AO. Required for changes_requested. + type: string + githubReviewId: + description: Id of the GitHub PR review the reviewer posted, if any. + type: string + reviews: + description: Batched review results recorded by one reviewer CLI command. + items: + $ref: '#/components/schemas/SubmitReviewItem' + type: array + runId: + description: Review run id being completed. + type: string + verdict: + description: 'Review verdict: approved or changes_requested.' + type: string + type: object + SubmitReviewItem: properties: body: description: Review body recorded by AO. Required for changes_requested. @@ -2483,8 +2538,18 @@ components: required: - runId - verdict - - body - - githubReviewId + type: object + TriggerReviewResponse: + properties: + reviewerHandleId: + type: string + reviews: + items: + $ref: '#/components/schemas/PRReviewState' + type: array + required: + - reviewerHandleId + - reviews type: object WorkspaceRepo: properties: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 713e1a156..21570e358 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -183,11 +183,14 @@ var schemaNames = map[string]string{ "ControllersResolveCommentsRequest": "ResolveCommentsRequest", "ControllersResolveCommentsResponse": "ResolveCommentsResponse", // httpd/controllers — review wire envelopes - "ControllersListReviewsResponse": "ListReviewsResponse", - "ControllersReviewRunResponse": "ReviewRunResponse", - "ControllersSubmitReviewInput": "SubmitReviewInput", + "ControllersListReviewsResponse": "ListReviewsResponse", + "ControllersReviewRunResponse": "ReviewRunResponse", + "ControllersTriggerReviewResponse": "TriggerReviewResponse", + "ControllersSubmitReviewItem": "SubmitReviewItem", + "ControllersSubmitReviewInput": "SubmitReviewInput", // domain review entities - "DomainReviewRun": "ReviewRun", + "DomainReviewRun": "ReviewRun", + "ReviewPRReviewState": "PRReviewState", // httpd/controllers: import wire envelopes "ControllersImportStatusResponse": "ImportStatusResponse", "ControllersImportRunResponse": "ImportRunResponse", @@ -378,8 +381,8 @@ func reviewOperations() []operation { summary: "Trigger a code review of a worker's PR", pathParams: []any{controllers.SessionIDParam{}}, resps: []respUnit{ - {http.StatusOK, controllers.ReviewRunResponse{}}, - {http.StatusCreated, controllers.ReviewRunResponse{}}, + {http.StatusOK, controllers.TriggerReviewResponse{}}, + {http.StatusCreated, controllers.TriggerReviewResponse{}}, {http.StatusUnprocessableEntity, envelope.APIError{}}, {http.StatusNotFound, envelope.APIError{}}, {http.StatusNotImplemented, envelope.APIError{}}, diff --git a/backend/internal/httpd/controllers/reviews.go b/backend/internal/httpd/controllers/reviews.go index e629670ff..1790e3c1d 100644 --- a/backend/internal/httpd/controllers/reviews.go +++ b/backend/internal/httpd/controllers/reviews.go @@ -10,6 +10,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + reviewcore "github.com/aoagents/agent-orchestrator/backend/internal/review" reviewsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/review" ) @@ -17,23 +18,40 @@ import ( // reviewerHandleId is the live reviewer pane's runtime handle, for the UI to // attach its terminal over /mux (empty when no reviewer has run). type ListReviewsResponse struct { - ReviewerHandleID string `json:"reviewerHandleId"` - Reviews []domain.ReviewRun `json:"reviews"` + ReviewerHandleID string `json:"reviewerHandleId"` + Reviews []reviewcore.PRReviewState `json:"reviews"` } -// ReviewRunResponse is the body of trigger (200/201) and submit (200). It -// carries the run plus the reviewer pane handle so the UI can attach a terminal. +// ReviewRunResponse is the body of submit (200). It carries the run plus the +// reviewer pane handle so the UI can attach a terminal. type ReviewRunResponse struct { - Review domain.ReviewRun `json:"review"` - ReviewerHandleID string `json:"reviewerHandleId"` + Review domain.ReviewRun `json:"review"` + Reviews []domain.ReviewRun `json:"reviews"` + ReviewerHandleID string `json:"reviewerHandleId"` +} + +// TriggerReviewResponse is the body of trigger (200/201). reviews carries the +// PR-scoped review state after the trigger. +type TriggerReviewResponse struct { + ReviewerHandleID string `json:"reviewerHandleId"` + Reviews []reviewcore.PRReviewState `json:"reviews"` +} + +// SubmitReviewItem is one review result in a batched submit request. +type SubmitReviewItem struct { + RunID string `json:"runId" description:"Review run id being completed."` + Verdict string `json:"verdict" description:"Review verdict: approved or changes_requested."` + Body string `json:"body,omitempty" description:"Review body recorded by AO. Required for changes_requested."` + GithubReviewID string `json:"githubReviewId,omitempty" description:"Id of the GitHub PR review the reviewer posted, if any."` } // SubmitReviewInput is the body of POST /api/v1/sessions/{sessionId}/reviews/submit. type SubmitReviewInput struct { - RunID string `json:"runId" description:"Review run id being completed."` - Verdict string `json:"verdict" description:"Review verdict: approved or changes_requested."` - Body string `json:"body" description:"Review body recorded by AO. Required for changes_requested."` - GithubReviewID string `json:"githubReviewId" description:"Id of the GitHub PR review the reviewer posted, if any."` + RunID string `json:"runId,omitempty" description:"Review run id being completed."` + Verdict string `json:"verdict,omitempty" description:"Review verdict: approved or changes_requested."` + Body string `json:"body,omitempty" description:"Review body recorded by AO. Required for changes_requested."` + GithubReviewID string `json:"githubReviewId,omitempty" description:"Id of the GitHub PR review the reviewer posted, if any."` + Reviews []SubmitReviewItem `json:"reviews,omitempty" description:"Batched review results recorded by one reviewer CLI command."` } // ReviewsController owns the session-scoped /reviews routes. A nil Svc returns 501. @@ -58,11 +76,11 @@ func (c *ReviewsController) list(w http.ResponseWriter, r *http.Request) { writeReviewError(w, r, err) return } - runs := res.Runs - if runs == nil { - runs = []domain.ReviewRun{} + reviews := res.Reviews + if reviews == nil { + reviews = []reviewcore.PRReviewState{} } - envelope.WriteJSON(w, http.StatusOK, ListReviewsResponse{ReviewerHandleID: res.ReviewerHandleID, Reviews: runs}) + envelope.WriteJSON(w, http.StatusOK, ListReviewsResponse{ReviewerHandleID: res.ReviewerHandleID, Reviews: reviews}) } func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { @@ -81,7 +99,14 @@ func (c *ReviewsController) trigger(w http.ResponseWriter, r *http.Request) { if res.Created { status = http.StatusCreated } - envelope.WriteJSON(w, status, ReviewRunResponse{Review: res.Run, ReviewerHandleID: res.ReviewerHandleID}) + reviews := res.Reviews + if reviews == nil { + reviews = []reviewcore.PRReviewState{} + } + envelope.WriteJSON(w, status, TriggerReviewResponse{ + ReviewerHandleID: res.ReviewerHandleID, + Reviews: reviews, + }) } func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { @@ -94,12 +119,34 @@ func (c *ReviewsController) submit(w http.ResponseWriter, r *http.Request) { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_BODY", "Invalid request body", nil) return } - run, err := c.Svc.Submit(r.Context(), sessionID(r), in.RunID, domain.ReviewVerdict(in.Verdict), in.Body, in.GithubReviewID) + reviews := make([]reviewsvc.SubmittedReview, 0, len(in.Reviews)) + if len(in.Reviews) > 0 { + for _, item := range in.Reviews { + reviews = append(reviews, reviewsvc.SubmittedReview{ + RunID: item.RunID, + Verdict: domain.ReviewVerdict(item.Verdict), + Body: item.Body, + GithubReviewID: item.GithubReviewID, + }) + } + } else { + reviews = append(reviews, reviewsvc.SubmittedReview{ + RunID: in.RunID, + Verdict: domain.ReviewVerdict(in.Verdict), + Body: in.Body, + GithubReviewID: in.GithubReviewID, + }) + } + runs, err := c.Svc.SubmitMany(r.Context(), sessionID(r), reviews) if err != nil { writeReviewError(w, r, err) return } - envelope.WriteJSON(w, http.StatusOK, ReviewRunResponse{Review: run}) + first := domain.ReviewRun{} + if len(runs) > 0 { + first = runs[0] + } + envelope.WriteJSON(w, http.StatusOK, ReviewRunResponse{Review: first, Reviews: runs}) } func writeReviewError(w http.ResponseWriter, r *http.Request, err error) { diff --git a/backend/internal/httpd/controllers/reviews_test.go b/backend/internal/httpd/controllers/reviews_test.go index 674105eb4..123c45420 100644 --- a/backend/internal/httpd/controllers/reviews_test.go +++ b/backend/internal/httpd/controllers/reviews_test.go @@ -20,12 +20,18 @@ import ( type fakeReviewService struct { triggerErr error + trigger reviewcore.TriggerResult + list reviewcore.SessionReviews + submitted []reviewsvc.SubmittedReview } func (f *fakeReviewService) Trigger(context.Context, domain.SessionID) (reviewcore.TriggerResult, error) { if f.triggerErr != nil { return reviewcore.TriggerResult{}, f.triggerErr } + if f.trigger.ReviewerHandleID != "" || f.trigger.Run.ID != "" || f.trigger.Reviews != nil || f.trigger.CreatedRuns != nil { + return f.trigger, nil + } return reviewcore.TriggerResult{Run: domain.ReviewRun{ID: "run-1"}, Created: true}, nil } @@ -33,8 +39,17 @@ func (f *fakeReviewService) Submit(context.Context, domain.SessionID, string, do return domain.ReviewRun{}, nil } +func (f *fakeReviewService) SubmitMany(_ context.Context, _ domain.SessionID, reviews []reviewsvc.SubmittedReview) ([]domain.ReviewRun, error) { + f.submitted = append([]reviewsvc.SubmittedReview(nil), reviews...) + runs := make([]domain.ReviewRun, 0, len(reviews)) + for _, review := range reviews { + runs = append(runs, domain.ReviewRun{ID: review.RunID, Verdict: review.Verdict, Body: review.Body, GithubReviewID: review.GithubReviewID}) + } + return runs, nil +} + func (f *fakeReviewService) List(context.Context, domain.SessionID) (reviewcore.SessionReviews, error) { - return reviewcore.SessionReviews{}, nil + return f.list, nil } func newReviewTestServer(t *testing.T, svc reviewsvc.Manager) *httptest.Server { @@ -59,3 +74,73 @@ func TestReviewsTrigger_MissingReviewerBinaryReturns422WithCause(t *testing.T) { t.Fatalf("message = %q, want reviewer binary cause", got.Message) } } + +func TestReviewsListIncludesReviewStates(t *testing.T) { + srv := newReviewTestServer(t, &fakeReviewService{list: reviewcore.SessionReviews{ + ReviewerHandleID: "review-mer-1", + Runs: []domain.ReviewRun{{ID: "run-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1"}}, + Reviews: []reviewcore.PRReviewState{{PRURL: "https://github.com/o/r/pull/1", PRNumber: 1, TargetSHA: "sha1", Status: reviewcore.ReviewStateUpToDate}}, + }}) + + body, status, headers := doRequest(t, srv, "GET", "/api/v1/sessions/mer-1/reviews", "") + assertJSON(t, headers) + if status != http.StatusOK { + t.Fatalf("status = %d body=%s", status, body) + } + if !strings.Contains(string(body), `"reviews"`) || !strings.Contains(string(body), `"up_to_date"`) || !strings.Contains(string(body), `"reviewerHandleId":"review-mer-1"`) { + t.Fatalf("body missing review states/handle: %s", body) + } + if strings.Contains(string(body), `"items"`) || strings.Contains(string(body), `"reviewItems"`) || strings.Contains(string(body), `"reviewRuns"`) { + t.Fatalf("body contains deprecated review item aliases: %s", body) + } +} + +func TestReviewsTriggerIncludesBatchFields(t *testing.T) { + run1 := domain.ReviewRun{ID: "run-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1"} + run2 := domain.ReviewRun{ID: "run-2", PRURL: "https://github.com/o/r/pull/2", TargetSHA: "sha2"} + srv := newReviewTestServer(t, &fakeReviewService{trigger: reviewcore.TriggerResult{ + Run: run1, + ReviewerHandleID: "review-mer-1", + Created: true, + CreatedRuns: []domain.ReviewRun{run1, run2}, + Reviews: []reviewcore.PRReviewState{ + {PRURL: run1.PRURL, PRNumber: 1, TargetSHA: run1.TargetSHA, Status: reviewcore.ReviewStateRunning, LatestRun: &run1}, + {PRURL: run2.PRURL, PRNumber: 2, TargetSHA: run2.TargetSHA, Status: reviewcore.ReviewStateRunning, LatestRun: &run2}, + }, + }}) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/sessions/mer-1/reviews/trigger", "") + assertJSON(t, headers) + if status != http.StatusCreated { + t.Fatalf("status = %d body=%s", status, body) + } + for _, want := range []string{`"reviews"`, `"running"`, `"run-1"`, `"run-2"`, `"reviewerHandleId":"review-mer-1"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + for _, unwanted := range []string{`"reviewItems"`, `"items"`, `"createdReviews"`, `"createdRuns"`, `"reviewRuns"`, `"review":`} { + if strings.Contains(string(body), unwanted) { + t.Fatalf("body contains deprecated field %s: %s", unwanted, body) + } + } +} + +func TestReviewsSubmitAcceptsBatchedReviews(t *testing.T) { + svc := &fakeReviewService{} + srv := newReviewTestServer(t, svc) + + body, status, headers := doRequest(t, srv, "POST", "/api/v1/sessions/mer-1/reviews/submit", `{"reviews":[{"runId":"run-1","verdict":"changes_requested","body":"fix auth","githubReviewId":"101"},{"runId":"run-2","verdict":"approved"}]}`) + assertJSON(t, headers) + if status != http.StatusOK { + t.Fatalf("status = %d body=%s", status, body) + } + if len(svc.submitted) != 2 || svc.submitted[0].RunID != "run-1" || svc.submitted[1].Verdict != domain.VerdictApproved { + t.Fatalf("submitted = %+v", svc.submitted) + } + for _, want := range []string{`"reviews"`, `"run-1"`, `"run-2"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } +} diff --git a/backend/internal/lifecycle/manager_test.go b/backend/internal/lifecycle/manager_test.go index 4ccbe21de..c8526c466 100644 --- a/backend/internal/lifecycle/manager_test.go +++ b/backend/internal/lifecycle/manager_test.go @@ -543,8 +543,10 @@ func TestApplyReviewResultSendsAndDedupsThroughPRSignature(t *testing.T) { t.Fatalf("outcome/messages = %q/%v, want sent once", outcome, msg.msgs) } got := msg.msgs[0] - if !strings.Contains(got, "[AO reviewer]") || !strings.Contains(got, "fix the bug") || !strings.Contains(got, "98[2J765") { - t.Fatalf("AO review nudge missing label/body/review id: %q", got) + for _, want := range []string{"[AO reviewer]", "PR: " + result.PRURL, "Verdict: changes_requested", "Review body:\nfix the bug", "GitHub review: 98[2J765"} { + if !strings.Contains(got, want) { + t.Fatalf("AO review nudge missing %q: %q", want, got) + } } if strings.Contains(got, "\x1b") { t.Fatalf("AO review nudge should sanitize control bytes: %q", got) @@ -572,6 +574,65 @@ func TestApplyReviewResultSendsAndDedupsThroughPRSignature(t *testing.T) { } } +func TestApplyReviewBatchSendsCombinedAndDedups(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = working("mer-1") + msg := &fakeMessenger{} + m := New(st, msg) + results := []ReviewResult{ + {RunID: "run-2", BatchID: "batch-1", WorkerID: "mer-1", PRURL: "https://github.com/o/r/pull/2", TargetSHA: "sha2", Verdict: domain.VerdictChangesRequested, Body: "fix tests", GithubReviewID: "102"}, + {RunID: "run-1", BatchID: "batch-1", WorkerID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", Verdict: domain.VerdictChangesRequested, Body: "fix auth", GithubReviewID: "101"}, + } + + outcome, err := m.ApplyReviewBatch(ctx, "mer-1", "batch-1", results) + if err != nil { + t.Fatalf("ApplyReviewBatch: %v", err) + } + if outcome != ReviewDeliverySent || len(msg.msgs) != 1 { + t.Fatalf("outcome/messages = %q/%v, want sent once", outcome, msg.msgs) + } + got := msg.msgs[0] + for _, want := range []string{ + "submitted 2 review(s) requesting changes", + "PR: https://github.com/o/r/pull/1", + "GitHub review: 101", + "Review body:\nfix auth", + "PR: https://github.com/o/r/pull/2", + "GitHub review: 102", + "Review body:\nfix tests", + } { + if !strings.Contains(got, want) { + t.Fatalf("batch nudge missing %q: %q", want, got) + } + } + if st.signatures["https://github.com/o/r/pull/1"] == "" { + t.Fatal("batch nudge did not persist signature on anchor PR") + } + + outcome, err = m.ApplyReviewBatch(ctx, "mer-1", "batch-1", results) + if err != nil { + t.Fatalf("repeat ApplyReviewBatch: %v", err) + } + if outcome != ReviewDeliverySent || len(msg.msgs) != 1 { + t.Fatalf("repeat should suppress duplicate send, outcome=%q msgs=%v", outcome, msg.msgs) + } +} + +func TestApplyReviewBatchNoopsWithoutDeliverableResults(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = working("mer-1") + msg := &fakeMessenger{} + m := New(st, msg) + + outcome, err := m.ApplyReviewBatch(ctx, "mer-1", "batch-1", nil) + if err != nil { + t.Fatalf("ApplyReviewBatch: %v", err) + } + if outcome != ReviewDeliveryNoop || len(msg.msgs) != 0 || st.signatureWrites != 0 { + t.Fatalf("empty batch should no-op, outcome=%q msgs=%v signatureWrites=%d", outcome, msg.msgs, st.signatureWrites) + } +} + func TestApplyReviewResultNoopsWhenIrrelevant(t *testing.T) { deliveredAt := time.Unix(100, 0).UTC() tests := []struct { diff --git a/backend/internal/lifecycle/reactions.go b/backend/internal/lifecycle/reactions.go index 526119568..3b115afe2 100644 --- a/backend/internal/lifecycle/reactions.go +++ b/backend/internal/lifecycle/reactions.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "sort" "strings" "sync" "time" @@ -33,6 +34,7 @@ const ( // review_run row. type ReviewResult struct { RunID string + BatchID string WorkerID domain.SessionID PRURL string TargetSHA string @@ -42,6 +44,56 @@ type ReviewResult struct { DeliveredAt *time.Time } +// ApplyReviewBatch reacts to one reviewer CLI submission after the review +// service has decided which current-head changes-requested results are +// deliverable. +func (m *Manager) ApplyReviewBatch(ctx context.Context, workerID domain.SessionID, batchID string, results []ReviewResult) (ReviewDeliveryOutcome, error) { + if batchID == "" || len(results) == 0 { + return ReviewDeliveryNoop, nil + } + rec, ok, err := m.store.GetSession(ctx, workerID) + if err != nil || !ok { + return ReviewDeliveryNoop, err + } + if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput { + return ReviewDeliveryNoop, nil + } + if m.messenger == nil { + return ReviewDeliveryNoop, nil + } + sort.Slice(results, func(i, j int) bool { + if results[i].PRURL != results[j].PRURL { + return results[i].PRURL < results[j].PRURL + } + return results[i].RunID < results[j].RunID + }) + var msg strings.Builder + fmt.Fprintf(&msg, "[AO reviewer] AO's internal code reviewer submitted %d review(s) requesting changes.\n", len(results)) + var sigParts []string + for i, r := range results { + fmt.Fprintf(&msg, "\nReview %d\nPR: %s\nVerdict: %s", i+1, domain.SanitizeControlChars(r.PRURL), domain.SanitizeControlChars(string(r.Verdict))) + if r.TargetSHA != "" { + fmt.Fprintf(&msg, "\nHead commit: %s", domain.SanitizeControlChars(r.TargetSHA)) + } + if r.GithubReviewID != "" { + safeReviewID := domain.SanitizeControlChars(r.GithubReviewID) + fmt.Fprintf(&msg, "\nGitHub review: %s", safeReviewID) + fmt.Fprintf(&msg, "\nOnce you have addressed it, reply on GitHub review %s with how you addressed it, then resolve the review comment threads you addressed.", safeReviewID) + } + if r.Body != "" { + fmt.Fprintf(&msg, "\n\nReview body:\n%s\n", domain.SanitizeControlChars(r.Body)) + } + sigParts = append(sigParts, strings.Join([]string{r.RunID, r.PRURL, r.TargetSHA, r.GithubReviewID, r.Body}, "\x00")) + } + anchorPR := results[0].PRURL + key := "review-batch:" + anchorPR + ":" + batchID + sig := strings.Join(sigParts, "\x01") + if err := m.sendOnce(ctx, workerID, anchorPR, key, sig, msg.String(), reviewMaxNudge); err != nil { + return ReviewDeliveryNoop, err + } + return ReviewDeliverySent, nil +} + type reactionState struct { mu sync.Mutex seen map[string]string @@ -154,13 +206,14 @@ func (m *Manager) ApplyReviewResult(ctx context.Context, workerID domain.Session if m.messenger == nil { return ReviewDeliveryNoop, nil } - msg := "[AO reviewer] AO's internal code reviewer requested changes on your PR. Review the feedback below and address it." + msg := fmt.Sprintf("[AO reviewer] AO's internal code reviewer submitted a review.\n\nPR: %s\nVerdict: %s", domain.SanitizeControlChars(r.PRURL), domain.SanitizeControlChars(string(r.Verdict))) if r.GithubReviewID != "" { safeReviewID := domain.SanitizeControlChars(r.GithubReviewID) - msg += fmt.Sprintf(" This feedback is GitHub review %s. Once you have addressed it, reply on that review referencing id %s with how you addressed it, then resolve the review comment threads you addressed.", safeReviewID, safeReviewID) + msg += fmt.Sprintf("\nGitHub review: %s", safeReviewID) + msg += fmt.Sprintf("\n\nOnce you have addressed it, reply on GitHub review %s with how you addressed it, then resolve the review comment threads you addressed.", safeReviewID) } if r.Body != "" { - msg += "\n\n" + domain.SanitizeControlChars(r.Body) + msg += "\n\nReview body:\n" + domain.SanitizeControlChars(r.Body) } key := "review:" + r.PRURL + ":ao:" + r.RunID sig := strings.Join([]string{r.TargetSHA, r.RunID, r.GithubReviewID, r.Body}, "\x00") diff --git a/backend/internal/ports/reviewer.go b/backend/internal/ports/reviewer.go index a42df4d72..21bb4ffe1 100644 --- a/backend/internal/ports/reviewer.go +++ b/backend/internal/ports/reviewer.go @@ -37,6 +37,11 @@ type ReviewInvocation struct { PRURL string // TargetSHA is the PR head commit under review. TargetSHA string + // ReviewQueue lists all review tasks created by the same trigger so a shared + // reviewer pane can review multiple PRs and submit the results together. + ReviewQueue []ReviewTask + // ReviewIndex is this invocation's zero-based position in ReviewQueue. + ReviewIndex int // WorkspacePath is the worker's checkout the reviewer reads. WorkspacePath string // Prompt and SystemPrompt are the review instructions AO authored centrally, @@ -48,6 +53,13 @@ type ReviewInvocation struct { SystemPrompt string } +// ReviewTask is one PR/run in a multi-PR review trigger queue. +type ReviewTask struct { + RunID string + PRURL string + TargetSHA string +} + // ReviewCommandSpec is how to launch a reviewer: the argv and any extra env the // adapter needs. AO supplies the workspace and review-tracking env around it. type ReviewCommandSpec struct { diff --git a/backend/internal/review/launcher.go b/backend/internal/review/launcher.go index e980f0b16..d9639f0bd 100644 --- a/backend/internal/review/launcher.go +++ b/backend/internal/review/launcher.go @@ -31,6 +31,8 @@ type LaunchSpec struct { WorkspacePath string PRURL string TargetSHA string + ReviewQueue []ports.ReviewTask + ReviewIndex int } // reviewerRuntime is the runtime surface the launcher needs: create a pane, @@ -73,6 +75,8 @@ func (l *agentLauncher) invocation(spec LaunchSpec) ports.ReviewInvocation { WorkerSessionID: spec.WorkerID, PRURL: spec.PRURL, TargetSHA: spec.TargetSHA, + ReviewQueue: spec.ReviewQueue, + ReviewIndex: spec.ReviewIndex, WorkspacePath: spec.WorkspacePath, Prompt: prompt, SystemPrompt: systemPrompt, diff --git a/backend/internal/review/planner.go b/backend/internal/review/planner.go new file mode 100644 index 000000000..a2cbdd6fa --- /dev/null +++ b/backend/internal/review/planner.go @@ -0,0 +1,95 @@ +package review + +import ( + "sort" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// StateStatus is the per-PR review planning state. +type StateStatus string + +const ( + // ReviewStateNeedsReview means an eligible PR has no current AO approval or running pass. + ReviewStateNeedsReview StateStatus = "needs_review" + // ReviewStateRunning means a review run is already active for the PR's current head. + ReviewStateRunning StateStatus = "running" + // ReviewStateUpToDate means AO approved the PR's current head. + ReviewStateUpToDate StateStatus = "up_to_date" + // ReviewStateChangesRequested means AO requested changes on the PR's current head. + ReviewStateChangesRequested StateStatus = "changes_requested" + // ReviewStateIneligible means the PR is draft, closed, merged, or missing required facts. + ReviewStateIneligible StateStatus = "ineligible" +) + +// PRReviewState is one PR-scoped review decision for a worker session. +type PRReviewState struct { + PRURL string `json:"prUrl"` + PRNumber int `json:"prNumber"` + Title string `json:"title"` + TargetSHA string `json:"targetSha"` + Status StateStatus `json:"status" enum:"needs_review,running,up_to_date,changes_requested,ineligible"` + LatestRun *domain.ReviewRun `json:"latestRun,omitempty"` +} + +// Plan computes per-PR review work from the currently observed PRs and existing +// review runs. It is pure so the trigger path and API list path share exactly +// the same eligibility/status rules. +func Plan(prs []domain.PullRequest, runs []domain.ReviewRun) []PRReviewState { + latest := latestRunsByPRAndSHA(runs) + reviews := make([]PRReviewState, 0, len(prs)) + for _, pr := range prs { + review := PRReviewState{ + PRURL: pr.URL, + PRNumber: pr.Number, + Title: pr.Title, + TargetSHA: pr.HeadSHA, + Status: ReviewStateNeedsReview, + } + if pr.URL == "" || pr.HeadSHA == "" || pr.Draft || pr.Merged || pr.Closed { + review.Status = ReviewStateIneligible + if run, ok := latest[review.PRURL+"\x00"+review.TargetSHA]; ok { + review.LatestRun = &run + } + reviews = append(reviews, review) + continue + } + if run, ok := latest[review.PRURL+"\x00"+review.TargetSHA]; ok { + review.LatestRun = &run + switch { + case run.Status == domain.ReviewRunRunning: + review.Status = ReviewStateRunning + case run.Verdict == domain.VerdictApproved: + review.Status = ReviewStateUpToDate + case run.Verdict == domain.VerdictChangesRequested: + review.Status = ReviewStateChangesRequested + case run.Status == domain.ReviewRunFailed: + review.Status = ReviewStateNeedsReview + default: + review.Status = ReviewStateNeedsReview + } + } + reviews = append(reviews, review) + } + sort.SliceStable(reviews, func(i, j int) bool { + if reviews[i].PRNumber != reviews[j].PRNumber { + return reviews[i].PRNumber < reviews[j].PRNumber + } + return reviews[i].PRURL < reviews[j].PRURL + }) + return reviews +} + +func latestRunsByPRAndSHA(runs []domain.ReviewRun) map[string]domain.ReviewRun { + latest := make(map[string]domain.ReviewRun) + for _, run := range runs { + if run.PRURL == "" || run.TargetSHA == "" { + continue + } + key := run.PRURL + "\x00" + run.TargetSHA + if existing, ok := latest[key]; !ok || run.CreatedAt.After(existing.CreatedAt) { + latest[key] = run + } + } + return latest +} diff --git a/backend/internal/review/planner_test.go b/backend/internal/review/planner_test.go new file mode 100644 index 000000000..9b655f2a3 --- /dev/null +++ b/backend/internal/review/planner_test.go @@ -0,0 +1,68 @@ +package review + +import ( + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestPlanStatuses(t *testing.T) { + now := time.Unix(100, 0).UTC() + tests := []struct { + name string + pr domain.PullRequest + runs []domain.ReviewRun + want StateStatus + }{ + {name: "open needs review", pr: planPR("pr1", 1, "sha1"), want: ReviewStateNeedsReview}, + {name: "draft ineligible", pr: withDraft(planPR("pr1", 1, "sha1")), want: ReviewStateIneligible}, + {name: "merged ineligible", pr: withMerged(planPR("pr1", 1, "sha1")), want: ReviewStateIneligible}, + {name: "closed ineligible", pr: withClosed(planPR("pr1", 1, "sha1")), want: ReviewStateIneligible}, + {name: "approved current sha up to date", pr: planPR("pr1", 1, "sha1"), runs: []domain.ReviewRun{ + {ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, CreatedAt: now}, + }, want: ReviewStateUpToDate}, + {name: "changes requested current sha", pr: planPR("pr1", 1, "sha1"), runs: []domain.ReviewRun{ + {ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, CreatedAt: now}, + }, want: ReviewStateChangesRequested}, + {name: "running current sha", pr: planPR("pr1", 1, "sha1"), runs: []domain.ReviewRun{ + {ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning, CreatedAt: now}, + }, want: ReviewStateRunning}, + {name: "different sha needs review", pr: planPR("pr1", 1, "sha2"), runs: []domain.ReviewRun{ + {ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, CreatedAt: now}, + }, want: ReviewStateNeedsReview}, + {name: "failed current sha retryable", pr: planPR("pr1", 1, "sha1"), runs: []domain.ReviewRun{ + {ID: "run-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunFailed, CreatedAt: now}, + }, want: ReviewStateNeedsReview}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Plan([]domain.PullRequest{tt.pr}, tt.runs) + if len(got) != 1 { + t.Fatalf("review states = %d, want 1", len(got)) + } + if got[0].Status != tt.want { + t.Fatalf("status = %s, want %s; item=%+v", got[0].Status, tt.want, got[0]) + } + }) + } +} + +func planPR(url string, n int, sha string) domain.PullRequest { + return domain.PullRequest{URL: url, Number: n, HeadSHA: sha} +} + +func withDraft(pr domain.PullRequest) domain.PullRequest { + pr.Draft = true + return pr +} + +func withMerged(pr domain.PullRequest) domain.PullRequest { + pr.Merged = true + return pr +} + +func withClosed(pr domain.PullRequest) domain.PullRequest { + pr.Closed = true + return pr +} diff --git a/backend/internal/review/prompt.go b/backend/internal/review/prompt.go index 09e513079..42dfca8c0 100644 --- a/backend/internal/review/prompt.go +++ b/backend/internal/review/prompt.go @@ -1,6 +1,9 @@ package review -import "fmt" +import ( + "fmt" + "strings" +) // reviewTexts returns the user-facing prompt and the system prompt to deliver to // a reviewer, authored in one place — the reviewer analogue of @@ -14,14 +17,18 @@ import "fmt" func reviewTexts(spec LaunchSpec) (prompt, systemPrompt string) { systemPrompt = `## Code reviewer role -You are an AO code reviewer. You review a single pull request's changes in the current checkout — do not start unrelated work. Inspect what the PR changed by diffing the checkout against the PR's base branch, and review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. +You are an AO code reviewer. You review the requested pull request changes in the current checkout — do not start unrelated work. Inspect what each PR changed by diffing the checkout against the PR's base branch, and review for correctness bugs, missing error handling, security issues, test coverage, and clear deviations from the surrounding code's conventions. Prefer a few high-confidence findings over nitpicks. Post your review as a comment on the pull request, stating clearly whether it needs changes or is ready, with inline comments for specific findings. Do not push commits, edit files, or modify the branch — review only.` - prompt = fmt.Sprintf(`Review pull request %s (head commit %s). + queueText := reviewQueueText(spec) + prompt = fmt.Sprintf(`Review the requested pull request(s) for worker session %s. +%s + +Complete every review task in the queue autonomously. Do not ask the user whether to continue to the next PR, and do not stop after the first PR unless the provider or checkout is genuinely unusable for every queued task. Do these steps in order: -1. Post your review on the pull request and capture its id in one call. Post with `+"`gh api`"+` rather than `+"`gh pr review`"+`: it is the only way to attach inline comments, and its response carries the created review's id, so AO can tell the worker exactly which review to address. Send the review as a JSON body so the inline comments form a proper array of objects: +1. For each PR below, post a separate review on that pull request and capture its id in one call. Post with `+"`gh api`"+` rather than `+"`gh pr review`"+`: it is the only way to attach inline comments, and its response carries the created review's id, so AO can tell the worker exactly which review to address. Send the review as a JSON body so the inline comments form a proper array of objects: gh api --method POST repos/{owner}/{repo}/pulls/{number}/reviews --input - --jq '.id' <<'JSON' { "event": "COMMENT", "body": "", @@ -31,13 +38,29 @@ Do these steps in order: - Substitute the PR's owner/repo/number. Add one object to "comments" per inline finding; omit the field for a review with no inline comments. - Always use "event": "COMMENT": reviews are posted from the PR author's own account, and GitHub rejects both APPROVE and REQUEST_CHANGES on your own PR. State in the body whether you are requesting changes or approving; the machine-readable verdict goes to AO in step 2. - The printed number is the review id. If the call fails on the provider, leave the id empty. -2. Record the result with AO, passing your full review on stdin with --body - so nothing is ever written into the worktree (a file there could be committed onto the worker's branch): +2. After every PR has its own GitHub review from step 1, record AO's bookkeeping for those already-posted reviews using one command. Pass JSON on stdin so nothing is ever written into the worktree (a file there could be committed onto the worker's branch). Include one object per PR/run from the queue: - ao review submit --session %s --run %s --verdict --review-id --body - <<'MD' - - MD + ao review submit --session %s --reviews - <<'JSON' + { + "reviews": [ + { "runId": "", "verdict": "", "githubReviewId": "", "body": "" } + ] + } + JSON -Only if step 1 genuinely fails on the provider, still run step 2 (without --review-id) so the result is recorded.`, - spec.PRURL, spec.TargetSHA, spec.WorkerID, spec.RunID) +Only if step 1 genuinely fails on the provider for a PR, still include that run in step 2 with an empty githubReviewId so the result is recorded.`, + spec.WorkerID, queueText, spec.WorkerID) return prompt, systemPrompt } + +func reviewQueueText(spec LaunchSpec) string { + if len(spec.ReviewQueue) <= 1 { + return fmt.Sprintf("\nReview task queue:\n* 1. %s (head commit %s, run %s)\n", spec.PRURL, spec.TargetSHA, spec.RunID) + } + var b strings.Builder + fmt.Fprintf(&b, "\nAO created %d review tasks for this worker session. Review every queued PR, then submit all results together.\n\nReview task queue:\n", len(spec.ReviewQueue)) + for i, task := range spec.ReviewQueue { + fmt.Fprintf(&b, "* %d. %s (head commit %s, run %s)\n", i+1, task.PRURL, task.TargetSHA, task.RunID) + } + return b.String() +} diff --git a/backend/internal/review/prompt_test.go b/backend/internal/review/prompt_test.go new file mode 100644 index 000000000..c2428782e --- /dev/null +++ b/backend/internal/review/prompt_test.go @@ -0,0 +1,37 @@ +package review + +import ( + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestReviewTextsIncludesMultiPRQueue(t *testing.T) { + spec := launchSpec() + spec.RunID = "run-2" + spec.PRURL = "https://github.com/o/r/pull/2" + spec.TargetSHA = "sha2" + spec.ReviewIndex = 1 + spec.ReviewQueue = []ports.ReviewTask{ + {RunID: "run-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1"}, + {RunID: "run-2", PRURL: "https://github.com/o/r/pull/2", TargetSHA: "sha2"}, + } + + prompt, _ := reviewTexts(spec) + for _, want := range []string{ + "AO created 2 review tasks", + "Review every queued PR, then submit all results together", + "Complete every review task in the queue autonomously", + "Do not ask the user whether to continue to the next PR", + "* 1. https://github.com/o/r/pull/1 (head commit sha1, run run-1)", + "* 2. https://github.com/o/r/pull/2 (head commit sha2, run run-2)", + "After every PR has its own GitHub review from step 1", + "ao review submit --session mer-1 --reviews -", + `"reviews": [`, + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} diff --git a/backend/internal/review/review.go b/backend/internal/review/review.go index 1b9816402..46d47b67c 100644 --- a/backend/internal/review/review.go +++ b/backend/internal/review/review.go @@ -18,6 +18,7 @@ import ( "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // ErrInvalid and ErrNotFound let the transport layer map failures to 422/404. @@ -34,9 +35,9 @@ type Store interface { InsertReviewRun(ctx stdctx.Context, r domain.ReviewRun) error UpdateReviewRunResult(ctx stdctx.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) SupersedeReviewRun(ctx stdctx.Context, id, body string) (bool, error) - SupersedeStaleRunningReviewRuns(ctx stdctx.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error) + SupersedeStaleRunningReviewRuns(ctx stdctx.Context, sessionID domain.SessionID, prURL, targetSHA, body string) (int64, error) GetReviewRun(ctx stdctx.Context, id string) (domain.ReviewRun, bool, error) - GetReviewRunBySessionAndSHA(ctx stdctx.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error) + GetReviewRunBySessionPRAndSHA(ctx stdctx.Context, id domain.SessionID, prURL, targetSHA string) (domain.ReviewRun, bool, error) ListReviewRunsBySession(ctx stdctx.Context, id domain.SessionID) ([]domain.ReviewRun, error) } @@ -134,6 +135,8 @@ type TriggerResult struct { Run domain.ReviewRun ReviewerHandleID string Created bool + Reviews []PRReviewState + CreatedRuns []domain.ReviewRun } // SessionReviews is a worker's review state: the live reviewer handle plus its @@ -141,14 +144,12 @@ type TriggerResult struct { type SessionReviews struct { ReviewerHandleID string Runs []domain.ReviewRun + Reviews []PRReviewState } -// Trigger starts (or reuses) a review of a worker's PR at its current head: -// - if a non-failed run already exists for this commit, it is returned unchanged; -// - otherwise, if a live reviewer pane exists, it is messaged to review the -// new commit; if not, a fresh reviewer is spawned; -// - the run is recorded before launch so startup failures leave a visible -// failed pass instead of an empty gap. +// Trigger starts reviews for every PR on the worker session that needs review. +// It reuses running/up-to-date runs, retries failed/current changes-requested +// heads, and uses one reviewer pane for every new run in the batch. func (e *Engine) Trigger(ctx stdctx.Context, workerID domain.SessionID) (TriggerResult, error) { if workerID == "" { return TriggerResult{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) @@ -175,39 +176,21 @@ func (e *Engine) Trigger(ctx stdctx.Context, workerID domain.SessionID) (Trigger return TriggerResult{}, fmt.Errorf("%w: worker session %q has no workspace to review", ErrInvalid, workerID) } - pr, err := e.workerPR(ctx, workerID) + prs, err := e.prs.ListPRsBySession(ctx, workerID) if err != nil { return TriggerResult{}, err } - targetSHA := pr.HeadSHA - - review, hasReview, err := e.store.GetReviewBySession(ctx, workerID) + if len(prs) == 0 { + return TriggerResult{}, fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) + } + runs, err := e.store.ListReviewRunsBySession(ctx, workerID) if err != nil { return TriggerResult{}, err } + reviews := Plan(prs, runs) - // Idempotency: a pass for this commit is reusable while it is still running - // or once it carries a verdict. The fallback branch below is defensive for - // any non-running, non-failed row that somehow lacks a verdict; normal - // Submit paths complete a run only with a valid verdict (#342). - if existing, ok, err := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); err != nil { - return TriggerResult{}, err - } else if ok && (existing.Status == domain.ReviewRunRunning || existing.Verdict != domain.VerdictNone) { - return TriggerResult{Run: existing, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil - } else if ok && existing.Status != domain.ReviewRunFailed { - superseded, err := e.store.SupersedeReviewRun(ctx, existing.ID, "superseded by a new review trigger") - if err != nil { - return TriggerResult{}, err - } - if !superseded { - if latest, ok, err := e.store.GetReviewRun(ctx, existing.ID); err != nil { - return TriggerResult{}, err - } else if ok { - return TriggerResult{Run: latest, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil - } - } - } - if _, err := e.store.SupersedeStaleRunningReviewRuns(ctx, workerID, targetSHA, "superseded by a review trigger for a newer commit"); err != nil { + reviewRow, hasReview, err := e.store.GetReviewBySession(ctx, workerID) + if err != nil { return TriggerResult{}, err } @@ -217,78 +200,156 @@ func (e *Engine) Trigger(ctx stdctx.Context, workerID domain.SessionID) (Trigger } now := e.clock() - runID := e.newID() - spec := LaunchSpec{ - RunID: runID, - WorkerID: workerID, - Harness: harness, - WorkspacePath: worker.Metadata.WorkspacePath, - PRURL: pr.URL, - TargetSHA: targetSHA, - } - - review, err = e.upsertReview(ctx, worker, harness, pr.URL, review.ReviewerHandleID, now) + reviewRow, err = e.upsertReview(ctx, worker, harness, reviewRow.ReviewerHandleID, now) if err != nil { return TriggerResult{}, err } - run := domain.ReviewRun{ - ID: runID, - ReviewID: review.ID, - SessionID: workerID, - Harness: harness, - PRURL: pr.URL, - TargetSHA: targetSHA, - Status: domain.ReviewRunRunning, - Verdict: domain.VerdictNone, - CreatedAt: now, - } - if err := e.store.InsertReviewRun(ctx, run); err != nil { - if errors.Is(err, domain.ErrDuplicateReviewRun) { - if existing, ok, getErr := e.store.GetReviewRunBySessionAndSHA(ctx, workerID, targetSHA); getErr != nil { - return TriggerResult{}, getErr - } else if ok { - return TriggerResult{Run: existing, ReviewerHandleID: review.ReviewerHandleID, Created: false}, nil + + var created []domain.ReviewRun + batchID := "" + for _, reviewState := range reviews { + if reviewState.Status != ReviewStateNeedsReview && reviewState.Status != ReviewStateChangesRequested { + continue + } + if reviewState.LatestRun != nil && reviewState.LatestRun.Status != domain.ReviewRunFailed && reviewState.LatestRun.Status != domain.ReviewRunRunning && reviewState.LatestRun.Verdict == domain.VerdictNone { + superseded, err := e.store.SupersedeReviewRun(ctx, reviewState.LatestRun.ID, "superseded by a new review trigger") + if err != nil { + return TriggerResult{}, err + } + if !superseded { + if latest, ok, err := e.store.GetReviewRun(ctx, reviewState.LatestRun.ID); err != nil { + return TriggerResult{}, err + } else if ok { + reviews = replaceReviewLatestRun(reviews, reviewState.PRURL, reviewState.TargetSHA, latest) + continue + } } } - return TriggerResult{}, err + if _, err := e.store.SupersedeStaleRunningReviewRuns(ctx, workerID, reviewState.PRURL, reviewState.TargetSHA, "superseded by a review trigger for a newer commit"); err != nil { + return TriggerResult{}, err + } + if batchID == "" { + batchID = e.newID() + } + run := domain.ReviewRun{ + ID: e.newID(), + ReviewID: reviewRow.ID, + SessionID: workerID, + BatchID: batchID, + Harness: harness, + PRURL: reviewState.PRURL, + TargetSHA: reviewState.TargetSHA, + Status: domain.ReviewRunRunning, + Verdict: domain.VerdictNone, + CreatedAt: now, + } + if err := e.store.InsertReviewRun(ctx, run); err != nil { + if errors.Is(err, domain.ErrDuplicateReviewRun) { + if existing, ok, getErr := e.store.GetReviewRunBySessionPRAndSHA(ctx, workerID, reviewState.PRURL, reviewState.TargetSHA); getErr != nil { + return TriggerResult{}, getErr + } else if ok { + reviews = replaceReviewLatestRun(reviews, reviewState.PRURL, reviewState.TargetSHA, existing) + continue + } + } + return TriggerResult{}, err + } + created = append(created, run) + reviews = replaceReviewLatestRun(reviews, reviewState.PRURL, reviewState.TargetSHA, run) + } + if len(created) == 0 { + return TriggerResult{Run: firstReusableRun(reviews), ReviewerHandleID: reviewRow.ReviewerHandleID, Created: false, Reviews: reviews}, nil } - failRun := func(err error) error { - if _, updateErr := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, err.Error(), ""); updateErr != nil { - return updateErr + failRuns := func(start int, err error) error { + for _, run := range created[start:] { + if _, updateErr := e.store.UpdateReviewRunResult(ctx, run.ID, domain.ReviewRunFailed, domain.VerdictNone, err.Error(), ""); updateErr != nil { + return updateErr + } } return err } - // Reuse a live reviewer pane if there is one; otherwise spawn a fresh one. handleID := "" - if hasReview && review.ReviewerHandleID != "" { - alive, err := e.launcher.Alive(ctx, review.ReviewerHandleID) + queue := reviewQueue(created) + if hasReview && reviewRow.ReviewerHandleID != "" { + alive, err := e.launcher.Alive(ctx, reviewRow.ReviewerHandleID) if err != nil { - return TriggerResult{}, failRun(err) + return TriggerResult{}, failRuns(0, err) } if alive { - if err := e.launcher.Notify(ctx, review.ReviewerHandleID, spec); err != nil { - return TriggerResult{}, failRun(fmt.Errorf("notify reviewer: %w", err)) - } - handleID = review.ReviewerHandleID + handleID = reviewRow.ReviewerHandleID } } if handleID == "" { - h, err := e.launcher.Spawn(ctx, spec) + h, err := e.launcher.Spawn(ctx, reviewLaunchSpec(worker, harness, created[0], queue, 0)) if err != nil { - return TriggerResult{}, failRun(fmt.Errorf("launch reviewer: %w", err)) + return TriggerResult{}, failRuns(0, fmt.Errorf("launch reviewer: %w", err)) } handleID = h + } else { + if err := e.launcher.Notify(ctx, handleID, reviewLaunchSpec(worker, harness, created[0], queue, 0)); err != nil { + return TriggerResult{}, failRuns(0, fmt.Errorf("notify reviewer: %w", err)) + } } - - // The reviewer is running; now record the pass. - review, err = e.upsertReview(ctx, worker, harness, pr.URL, handleID, now) + reviewRow, err = e.upsertReview(ctx, worker, harness, handleID, now) if err != nil { return TriggerResult{}, err } - run.ReviewID = review.ID - return TriggerResult{Run: run, ReviewerHandleID: handleID, Created: true}, nil + for i := range created { + created[i].ReviewID = reviewRow.ID + } + return TriggerResult{Run: created[0], ReviewerHandleID: handleID, Created: true, Reviews: reviews, CreatedRuns: created}, nil +} + +func reviewLaunchSpec(worker domain.SessionRecord, harness domain.ReviewerHarness, run domain.ReviewRun, queue []ports.ReviewTask, index int) LaunchSpec { + return LaunchSpec{ + RunID: run.ID, + WorkerID: worker.ID, + Harness: harness, + WorkspacePath: worker.Metadata.WorkspacePath, + PRURL: run.PRURL, + TargetSHA: run.TargetSHA, + ReviewQueue: queue, + ReviewIndex: index, + } +} + +func reviewQueue(runs []domain.ReviewRun) []ports.ReviewTask { + queue := make([]ports.ReviewTask, 0, len(runs)) + for _, run := range runs { + queue = append(queue, ports.ReviewTask{ + RunID: run.ID, + PRURL: run.PRURL, + TargetSHA: run.TargetSHA, + }) + } + return queue +} + +func replaceReviewLatestRun(reviews []PRReviewState, prURL, targetSHA string, run domain.ReviewRun) []PRReviewState { + for i := range reviews { + if reviews[i].PRURL == prURL && reviews[i].TargetSHA == targetSHA { + reviews[i].LatestRun = &run + if run.Status == domain.ReviewRunRunning { + reviews[i].Status = ReviewStateRunning + } + break + } + } + return reviews +} + +func firstReusableRun(reviews []PRReviewState) domain.ReviewRun { + // Legacy compatibility only: in the multi-PR model the authoritative state + // is Reviews. When no run is created, this field is just a best-effort + // non-empty run for older clients. + for _, review := range reviews { + if review.LatestRun != nil { + return *review.LatestRun + } + } + return domain.ReviewRun{} } // List returns a worker's review state: the live reviewer handle and its passes. @@ -306,18 +367,11 @@ func (e *Engine) List(ctx stdctx.Context, workerID domain.SessionID) (SessionRev } else if ok { handle = review.ReviewerHandleID } - return SessionReviews{ReviewerHandleID: handle, Runs: runs}, nil -} - -func (e *Engine) workerPR(ctx stdctx.Context, workerID domain.SessionID) (domain.PullRequest, error) { prs, err := e.prs.ListPRsBySession(ctx, workerID) if err != nil { - return domain.PullRequest{}, err + return SessionReviews{}, err } - if len(prs) == 0 { - return domain.PullRequest{}, fmt.Errorf("%w: worker %q has no PR to review", ErrInvalid, workerID) - } - return prs[0], nil + return SessionReviews{ReviewerHandleID: handle, Runs: runs, Reviews: Plan(prs, runs)}, nil } // reviewerHarness resolves which harness reviews the worker's PR: a configured @@ -335,7 +389,7 @@ func (e *Engine) reviewerHarness(ctx stdctx.Context, worker domain.SessionRecord return cfg.ResolveReviewerHarness(worker.Harness), nil } -func (e *Engine) upsertReview(ctx stdctx.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, prURL, handleID string, now time.Time) (domain.Review, error) { +func (e *Engine) upsertReview(ctx stdctx.Context, worker domain.SessionRecord, harness domain.ReviewerHarness, handleID string, now time.Time) (domain.Review, error) { existing, ok, err := e.store.GetReviewBySession(ctx, worker.ID) if err != nil { return domain.Review{}, err @@ -345,7 +399,7 @@ func (e *Engine) upsertReview(ctx stdctx.Context, worker domain.SessionRecord, h SessionID: worker.ID, ProjectID: worker.ProjectID, Harness: harness, - PRURL: prURL, + PRURL: "", ReviewerHandleID: handleID, CreatedAt: now, UpdatedAt: now, diff --git a/backend/internal/review/review_test.go b/backend/internal/review/review_test.go index 003b19b5b..455ae690d 100644 --- a/backend/internal/review/review_test.go +++ b/backend/internal/review/review_test.go @@ -43,6 +43,16 @@ func (f *fakeStore) InsertReviewRun(_ context.Context, r domain.ReviewRun) error f.runs = append(f.runs, winner) return f.insertErr } + for _, existing := range f.runs { + if existing.SessionID == r.SessionID && + existing.PRURL == r.PRURL && + existing.TargetSHA == r.TargetSHA && + existing.TargetSHA != "" && + existing.Status != domain.ReviewRunFailed && + existing.Verdict != domain.VerdictChangesRequested { + return domain.ErrDuplicateReviewRun + } + } f.runs = append(f.runs, r) return nil } @@ -74,10 +84,10 @@ func (f *fakeStore) SupersedeReviewRun(_ context.Context, id, body string) (bool } return false, nil } -func (f *fakeStore) SupersedeStaleRunningReviewRuns(_ context.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error) { +func (f *fakeStore) SupersedeStaleRunningReviewRuns(_ context.Context, sessionID domain.SessionID, prURL, targetSHA, body string) (int64, error) { var n int64 for i := range f.runs { - if f.runs[i].SessionID == sessionID && f.runs[i].TargetSHA != targetSHA && f.runs[i].Status == domain.ReviewRunRunning && f.runs[i].Verdict == domain.VerdictNone { + if f.runs[i].SessionID == sessionID && f.runs[i].PRURL == prURL && f.runs[i].TargetSHA != targetSHA && f.runs[i].Status == domain.ReviewRunRunning && f.runs[i].Verdict == domain.VerdictNone { f.runs[i].Status = domain.ReviewRunFailed f.runs[i].Body = body n++ @@ -93,9 +103,9 @@ func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun } return domain.ReviewRun{}, false, nil } -func (f *fakeStore) GetReviewRunBySessionAndSHA(_ context.Context, _ domain.SessionID, sha string) (domain.ReviewRun, bool, error) { +func (f *fakeStore) GetReviewRunBySessionPRAndSHA(_ context.Context, sessionID domain.SessionID, prURL, sha string) (domain.ReviewRun, bool, error) { for i := len(f.runs) - 1; i >= 0; i-- { - if f.runs[i].TargetSHA == sha { + if f.runs[i].SessionID == sessionID && f.runs[i].PRURL == prURL && f.runs[i].TargetSHA == sha { return f.runs[i], true, nil } } @@ -136,12 +146,15 @@ type fakeLauncher struct { notified bool gotSpec LaunchSpec gotHandle string + specs []LaunchSpec + handles []string } func (f *fakeLauncher) Spawn(_ context.Context, spec LaunchSpec) (string, error) { f.spawned = true f.spawnCount++ f.gotSpec = spec + f.specs = append(f.specs, spec) if f.spawnErr != nil { return "", f.spawnErr } @@ -151,6 +164,8 @@ func (f *fakeLauncher) Notify(_ context.Context, handleID string, spec LaunchSpe f.notified = true f.gotHandle = handleID f.gotSpec = spec + f.handles = append(f.handles, handleID) + f.specs = append(f.specs, spec) return f.notifyErr } func (f *fakeLauncher) Alive(_ context.Context, _ string) (bool, error) { @@ -176,7 +191,7 @@ func newEngineForTest(store Store, sessions Sessions, prs PRs, projects Projects } func prAt(sha string) fakePRs { - return fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1", HeadSHA: sha}}} + return fakePRs{prs: []domain.PullRequest{{URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: sha}}} } // --- tests --- @@ -259,7 +274,7 @@ func TestTriggerFallsBackToExistingRunOnUniqueConflict(t *testing.T) { if res.Created { t.Fatalf("expected Created=false on unique conflict: %+v", res) } - if res.Run.TargetSHA != "sha1" || res.Run.ID != "winner-id-1" { + if res.Run.TargetSHA != "sha1" || !strings.HasPrefix(res.Run.ID, "winner-") { t.Fatalf("expected the recorded winner run, got %+v", res.Run) } if launcher.spawnCount != 0 { @@ -271,7 +286,7 @@ func TestTriggerIsIdempotentForSameCommit(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, runs: []domain.ReviewRun{{ - ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", + ID: "run-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, }}, } @@ -296,7 +311,7 @@ func TestTriggerIsIdempotentForSameCommit(t *testing.T) { func TestTriggerReusesRunningRowWithNoVerdict(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}}, } launcher := &fakeLauncher{alive: false, handle: "review-mer-2"} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) @@ -319,7 +334,7 @@ func TestTriggerReusesRunningRowWithNoVerdict(t *testing.T) { func TestTriggerSupersedesNonRunningRowWithNoVerdict(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunComplete}}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", Status: domain.ReviewRunComplete}}, } launcher := &fakeLauncher{alive: true, handle: "review-mer-1"} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) @@ -342,7 +357,7 @@ func TestTriggerSupersedesNonRunningRowWithNoVerdict(t *testing.T) { func TestTriggerNotifiesLiveReviewerOnNewCommit(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-0", SessionID: "mer-1", TargetSHA: "sha0", Status: domain.ReviewRunComplete}}, + runs: []domain.ReviewRun{{ID: "run-0", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha0", Status: domain.ReviewRunComplete}}, } launcher := &fakeLauncher{alive: true} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) @@ -365,7 +380,7 @@ func TestTriggerNotifiesLiveReviewerOnNewCommit(t *testing.T) { func TestTriggerSupersedesOlderRunningRunOnNewCommit(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-old", SessionID: "mer-1", TargetSHA: "sha0", Status: domain.ReviewRunRunning}}, + runs: []domain.ReviewRun{{ID: "run-old", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha0", Status: domain.ReviewRunRunning}}, } launcher := &fakeLauncher{alive: true, handle: "review-mer-1"} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) @@ -388,7 +403,7 @@ func TestTriggerSupersedesOlderRunningRunOnNewCommit(t *testing.T) { func TestTriggerSpawnsWhenReviewerDead(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-0", SessionID: "mer-1", TargetSHA: "sha0", Status: domain.ReviewRunComplete}}, + runs: []domain.ReviewRun{{ID: "run-0", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha0", Status: domain.ReviewRunComplete}}, } launcher := &fakeLauncher{alive: false, handle: "review-mer-1"} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) @@ -424,7 +439,7 @@ func TestTriggerLaunchFailureRecordsFailedRun(t *testing.T) { func TestTriggerRetriesAfterFailedRunForSameCommit(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-failed", ReviewID: "rev-1", SessionID: "mer-1", TargetSHA: "sha1", Status: domain.ReviewRunFailed}}, + runs: []domain.ReviewRun{{ID: "run-failed", ReviewID: "rev-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", Status: domain.ReviewRunFailed}}, } launcher := &fakeLauncher{handle: "review-mer-1"} eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) @@ -441,6 +456,111 @@ func TestTriggerRetriesAfterFailedRunForSameCommit(t *testing.T) { } } +func TestTriggerCreatesRunsForMultipleEligiblePRsWithOneReviewer(t *testing.T) { + store := &fakeStore{} + launcher := &fakeLauncher{handle: "review-mer-1"} + prs := fakePRs{prs: []domain.PullRequest{ + {URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: "sha1"}, + {URL: "https://github.com/o/r/pull/2", Number: 2, HeadSHA: "sha2"}, + }} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prs, fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if !res.Created || len(res.CreatedRuns) != 2 || len(store.runs) != 2 { + t.Fatalf("created batch = %+v runs=%+v", res, store.runs) + } + if res.CreatedRuns[0].BatchID == "" || res.CreatedRuns[0].BatchID != res.CreatedRuns[1].BatchID { + t.Fatalf("created runs should share one batch id: %+v", res.CreatedRuns) + } + if launcher.spawnCount != 1 || len(launcher.handles) != 0 { + t.Fatalf("expected one spawn and no extra notify, launcher=%+v", launcher) + } + if len(launcher.specs) != 1 { + t.Fatalf("launch specs = %d, want 1: %+v", len(launcher.specs), launcher.specs) + } + spec := launcher.specs[0] + if spec.ReviewIndex != 0 || len(spec.ReviewQueue) != 2 { + t.Fatalf("spec queue context = index %d queue %+v", spec.ReviewIndex, spec.ReviewQueue) + } + if spec.ReviewQueue[0].PRURL != "https://github.com/o/r/pull/1" || spec.ReviewQueue[1].PRURL != "https://github.com/o/r/pull/2" { + t.Fatalf("spec queue URLs = %+v", spec.ReviewQueue) + } + if store.review == nil || store.review.ReviewerHandleID != "review-mer-1" || store.review.PRURL != "" { + t.Fatalf("review row = %+v, want shared handle and no behavioral pr_url", store.review) + } +} + +func TestTriggerAllowsTwoPRsWithSameHeadSHA(t *testing.T) { + store := &fakeStore{} + launcher := &fakeLauncher{handle: "review-mer-1"} + prs := fakePRs{prs: []domain.PullRequest{ + {URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: "same"}, + {URL: "https://github.com/o/r/pull/2", Number: 2, HeadSHA: "same"}, + }} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prs, fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if len(res.CreatedRuns) != 2 { + t.Fatalf("created runs = %d, want 2: %+v", len(res.CreatedRuns), res.CreatedRuns) + } + if store.runs[0].PRURL == store.runs[1].PRURL || store.runs[0].TargetSHA != store.runs[1].TargetSHA { + t.Fatalf("runs should differ by PR only: %+v", store.runs) + } +} + +func TestTriggerSkipsApprovedAndRunningCurrentHead(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{ + {ID: "approved", ReviewID: "rev-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved, CreatedAt: time.Unix(1, 0)}, + {ID: "running", ReviewID: "rev-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/2", TargetSHA: "sha2", Status: domain.ReviewRunRunning, CreatedAt: time.Unix(2, 0)}, + }, + } + launcher := &fakeLauncher{alive: true} + prs := fakePRs{prs: []domain.PullRequest{ + {URL: "https://github.com/o/r/pull/1", Number: 1, HeadSHA: "sha1"}, + {URL: "https://github.com/o/r/pull/2", Number: 2, HeadSHA: "sha2"}, + }} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prs, fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if res.Created || len(res.CreatedRuns) != 0 || launcher.spawned || launcher.notified { + t.Fatalf("expected no new work: res=%+v launcher=%+v", res, launcher) + } + if len(res.Reviews) != 2 || res.Reviews[0].Status != ReviewStateUpToDate || res.Reviews[1].Status != ReviewStateRunning { + t.Fatalf("review states = %+v", res.Reviews) + } +} + +func TestTriggerCreatesRunForChangesRequestedCurrentHead(t *testing.T) { + store := &fakeStore{ + review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, + runs: []domain.ReviewRun{{ + ID: "changes", ReviewID: "rev-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1", + Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, CreatedAt: time.Unix(1, 0), + }}, + } + launcher := &fakeLauncher{alive: true, handle: "review-mer-1"} + eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, launcher) + + res, err := eng.Trigger(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Trigger: %v", err) + } + if !res.Created || len(res.CreatedRuns) != 1 || !launcher.notified || launcher.spawned { + t.Fatalf("expected rerun on changes_requested current head: res=%+v launcher=%+v", res, launcher) + } +} + func TestTriggerUsesConfiguredReviewerHarness(t *testing.T) { store := &fakeStore{} projects := fakeProjects{cfg: domain.ProjectConfig{Reviewers: []domain.ReviewerConfig{{Harness: domain.ReviewerHarness("greptile")}}}} @@ -474,7 +594,7 @@ func TestTriggerRejectsBadWorkerState(t *testing.T) { func TestListReturnsHandleAndRuns(t *testing.T) { store := &fakeStore{ review: &domain.Review{ID: "rev-1", SessionID: "mer-1", ReviewerHandleID: "review-mer-1"}, - runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", TargetSHA: "sha1"}}, + runs: []domain.ReviewRun{{ID: "run-1", SessionID: "mer-1", PRURL: "https://github.com/o/r/pull/1", TargetSHA: "sha1"}}, } eng := newEngineForTest(store, fakeSessions{rec: liveWorker(), ok: true}, prAt("sha1"), fakeProjects{}, &fakeLauncher{}) got, err := eng.List(context.Background(), "mer-1") diff --git a/backend/internal/service/review/review.go b/backend/internal/service/review/review.go index be23d3a83..61f09e3b7 100644 --- a/backend/internal/service/review/review.go +++ b/backend/internal/service/review/review.go @@ -27,6 +27,7 @@ var ( type Manager interface { Trigger(ctx context.Context, workerID domain.SessionID) (reviewcore.TriggerResult, error) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body, githubReviewID string) (domain.ReviewRun, error) + SubmitMany(ctx context.Context, workerID domain.SessionID, reviews []SubmittedReview) ([]domain.ReviewRun, error) List(ctx context.Context, workerID domain.SessionID) (reviewcore.SessionReviews, error) } @@ -45,12 +46,14 @@ type Store interface { GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, bool, error) UpdateReviewRunResult(ctx context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) MarkReviewRunDelivered(ctx context.Context, id string, deliveredAt time.Time) (bool, error) + ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error) } // Reducer is the lifecycle reaction boundary used after a review result has // been persisted. type Reducer interface { ApplyReviewResult(ctx context.Context, workerID domain.SessionID, result lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) + ApplyReviewBatch(ctx context.Context, workerID domain.SessionID, batchID string, results []lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) } // Option customizes the review service. @@ -68,7 +71,11 @@ func WithClock(clock func() time.Time) Option { // New wraps a core review engine as the API-facing service. func New(engine *reviewcore.Engine, store Store, opts ...Option) *Service { - s := &Service{engine: engine, store: store, clock: func() time.Time { return time.Now().UTC() }} + s := &Service{ + engine: engine, + store: store, + clock: func() time.Time { return time.Now().UTC() }, + } for _, opt := range opts { opt(s) } @@ -80,11 +87,76 @@ func (s *Service) Trigger(ctx context.Context, workerID domain.SessionID) (revie return s.engine.Trigger(ctx, workerID) } +// SubmittedReview is one review result supplied by the reviewer CLI. +type SubmittedReview struct { + RunID string + Verdict domain.ReviewVerdict + Body string + GithubReviewID string +} + // Submit records a reviewer's result for a specific worker review pass. func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID string, verdict domain.ReviewVerdict, body, githubReviewID string) (domain.ReviewRun, error) { - if workerID == "" { - return domain.ReviewRun{}, fmt.Errorf("%w: worker session id is required", ErrInvalid) + runs, err := s.SubmitMany(ctx, workerID, []SubmittedReview{{ + RunID: runID, + Verdict: verdict, + Body: body, + GithubReviewID: githubReviewID, + }}) + if err != nil { + return domain.ReviewRun{}, err } + if len(runs) == 0 { + return domain.ReviewRun{}, fmt.Errorf("%w: no review result submitted", ErrInvalid) + } + return runs[0], nil +} + +// SubmitMany records one reviewer CLI submission containing results for one or +// more PR-scoped runs. Delivery is scoped to the runs in this submission, so a +// missing/stuck result for another PR in the same trigger cannot block feedback. +func (s *Service) SubmitMany(ctx context.Context, workerID domain.SessionID, reviews []SubmittedReview) ([]domain.ReviewRun, error) { + if workerID == "" { + return nil, fmt.Errorf("%w: worker session id is required", ErrInvalid) + } + if len(reviews) == 0 { + return nil, fmt.Errorf("%w: at least one review result is required", ErrInvalid) + } + if s.store == nil { + return nil, fmt.Errorf("review service store is not configured") + } + runs := make([]domain.ReviewRun, 0, len(reviews)) + for _, review := range reviews { + run, err := s.submitOne(ctx, workerID, review) + if err != nil { + return nil, err + } + runs = append(runs, run) + } + if s.lifecycle == nil { + return runs, nil + } + delivered, err := s.deliverSubmitted(ctx, workerID, runs) + if err != nil { + return nil, err + } + byID := make(map[string]domain.ReviewRun, len(delivered)) + for _, run := range delivered { + byID[run.ID] = run + } + for i, run := range runs { + if deliveredRun, ok := byID[run.ID]; ok { + runs[i] = deliveredRun + } + } + return runs, nil +} + +func (s *Service) submitOne(ctx context.Context, workerID domain.SessionID, review SubmittedReview) (domain.ReviewRun, error) { + runID := review.RunID + verdict := review.Verdict + body := review.Body + githubReviewID := review.GithubReviewID if runID == "" { return domain.ReviewRun{}, fmt.Errorf("%w: review run id is required", ErrInvalid) } @@ -94,9 +166,6 @@ func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID s if verdict == domain.VerdictChangesRequested && body == "" { return domain.ReviewRun{}, fmt.Errorf("%w: a changes_requested review requires a body", ErrInvalid) } - if s.store == nil { - return domain.ReviewRun{}, fmt.Errorf("review service store is not configured") - } run, ok, err := s.store.GetReviewRun(ctx, runID) if err != nil { return domain.ReviewRun{}, err @@ -136,35 +205,92 @@ func (s *Service) Submit(ctx context.Context, workerID domain.SessionID, runID s default: return domain.ReviewRun{}, fmt.Errorf("%w: review run %q is not running", ErrInvalid, runID) } + return run, nil +} - if s.lifecycle == nil { - return run, nil - } - outcome, err := s.lifecycle.ApplyReviewResult(ctx, workerID, lifecycle.ReviewResult{ - RunID: run.ID, - WorkerID: workerID, - PRURL: run.PRURL, - TargetSHA: run.TargetSHA, - Verdict: run.Verdict, - Body: run.Body, - GithubReviewID: run.GithubReviewID, - DeliveredAt: run.DeliveredAt, - }) +func (s *Service) deliverSubmitted(ctx context.Context, workerID domain.SessionID, runs []domain.ReviewRun) ([]domain.ReviewRun, error) { + deliverable, err := s.deliverableRuns(ctx, workerID, runs) if err != nil { - return domain.ReviewRun{}, err + return nil, err } - if outcome == lifecycle.ReviewDeliverySent { - deliveredAt := s.clock() + if len(deliverable) == 0 { + return nil, nil + } + results := reviewResults(workerID, deliverable) + var outcome lifecycle.ReviewDeliveryOutcome + if len(results) == 1 && results[0].BatchID == "" { + outcome, err = s.lifecycle.ApplyReviewResult(ctx, workerID, results[0]) + } else { + outcome, err = s.lifecycle.ApplyReviewBatch(ctx, workerID, results[0].BatchID, results) + } + if err != nil { + return nil, err + } + if outcome != lifecycle.ReviewDeliverySent { + return nil, nil + } + deliveredAt := s.clock() + delivered := make([]domain.ReviewRun, 0, len(deliverable)) + for _, run := range deliverable { updated, err := s.store.MarkReviewRunDelivered(ctx, run.ID, deliveredAt) if err != nil { - return domain.ReviewRun{}, err + return nil, err } if updated { run.Status = domain.ReviewRunDelivered run.DeliveredAt = &deliveredAt + delivered = append(delivered, run) } } - return run, nil + return delivered, nil +} + +func (s *Service) deliverableRuns(ctx context.Context, workerID domain.SessionID, runs []domain.ReviewRun) ([]domain.ReviewRun, error) { + currentHeads, err := s.currentHeadsByPR(ctx, workerID) + if err != nil { + return nil, err + } + deliverable := make([]domain.ReviewRun, 0, len(runs)) + for _, run := range runs { + if run.Status != domain.ReviewRunComplete || run.Verdict != domain.VerdictChangesRequested || run.DeliveredAt != nil { + continue + } + if run.BatchID != "" && currentHeads[run.PRURL] != run.TargetSHA { + continue + } + deliverable = append(deliverable, run) + } + return deliverable, nil +} + +func reviewResults(workerID domain.SessionID, runs []domain.ReviewRun) []lifecycle.ReviewResult { + results := make([]lifecycle.ReviewResult, 0, len(runs)) + for _, run := range runs { + results = append(results, lifecycle.ReviewResult{ + RunID: run.ID, + BatchID: run.BatchID, + WorkerID: workerID, + PRURL: run.PRURL, + TargetSHA: run.TargetSHA, + Verdict: run.Verdict, + Body: run.Body, + GithubReviewID: run.GithubReviewID, + DeliveredAt: run.DeliveredAt, + }) + } + return results +} + +func (s *Service) currentHeadsByPR(ctx context.Context, workerID domain.SessionID) (map[string]string, error) { + prs, err := s.store.ListPRsBySession(ctx, workerID) + if err != nil { + return nil, err + } + current := make(map[string]string, len(prs)) + for _, pr := range prs { + current[pr.URL] = pr.HeadSHA + } + return current, nil } // List returns a worker's review state. diff --git a/backend/internal/service/review/review_test.go b/backend/internal/service/review/review_test.go index c9e4c4553..259c47f56 100644 --- a/backend/internal/service/review/review_test.go +++ b/backend/internal/service/review/review_test.go @@ -11,18 +11,45 @@ import ( ) type fakeStore struct { - run domain.ReviewRun - ok bool + run domain.ReviewRun + ok bool + batchRuns []domain.ReviewRun + prs []domain.PullRequest updateCalls int markCalls int + markedIDs []string } -func (f *fakeStore) GetReviewRun(context.Context, string) (domain.ReviewRun, bool, error) { - return f.run, f.ok, nil +func (f *fakeStore) GetReviewRun(_ context.Context, id string) (domain.ReviewRun, bool, error) { + for _, run := range f.batchRuns { + if run.ID == id { + return run, true, nil + } + } + if f.ok && f.run.ID == id { + return f.run, true, nil + } + return domain.ReviewRun{}, false, nil } -func (f *fakeStore) UpdateReviewRunResult(_ context.Context, _ string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) { +func (f *fakeStore) UpdateReviewRunResult(_ context.Context, id string, status domain.ReviewRunStatus, verdict domain.ReviewVerdict, body, githubReviewID string) (bool, error) { + for i := range f.batchRuns { + if f.batchRuns[i].ID == id { + if f.batchRuns[i].Status != domain.ReviewRunRunning { + return false, nil + } + f.updateCalls++ + f.batchRuns[i].Status = status + f.batchRuns[i].Verdict = verdict + f.batchRuns[i].Body = body + f.batchRuns[i].GithubReviewID = githubReviewID + if f.run.ID == id { + f.run = f.batchRuns[i] + } + return true, nil + } + } if f.run.Status != domain.ReviewRunRunning { return false, nil } @@ -34,21 +61,44 @@ func (f *fakeStore) UpdateReviewRunResult(_ context.Context, _ string, status do return true, nil } -func (f *fakeStore) MarkReviewRunDelivered(_ context.Context, _ string, deliveredAt time.Time) (bool, error) { +func (f *fakeStore) MarkReviewRunDelivered(_ context.Context, id string, deliveredAt time.Time) (bool, error) { f.markCalls++ - if f.run.Status != domain.ReviewRunComplete || f.run.DeliveredAt != nil { + f.markedIDs = append(f.markedIDs, id) + if f.run.ID == id && f.run.Status == domain.ReviewRunComplete && f.run.DeliveredAt == nil { + f.run.Status = domain.ReviewRunDelivered + f.run.DeliveredAt = &deliveredAt + } + for i := range f.batchRuns { + if f.batchRuns[i].ID == id && f.batchRuns[i].Status == domain.ReviewRunComplete && f.batchRuns[i].DeliveredAt == nil { + f.batchRuns[i].Status = domain.ReviewRunDelivered + f.batchRuns[i].DeliveredAt = &deliveredAt + return true, nil + } + } + if f.run.ID != id || f.run.Status != domain.ReviewRunDelivered { return false, nil } - f.run.Status = domain.ReviewRunDelivered - f.run.DeliveredAt = &deliveredAt return true, nil } +func (f *fakeStore) ListReviewRunsByBatch(context.Context, domain.SessionID, string) ([]domain.ReviewRun, error) { + out := append([]domain.ReviewRun(nil), f.batchRuns...) + return out, nil +} + +func (f *fakeStore) ListPRsBySession(context.Context, domain.SessionID) ([]domain.PullRequest, error) { + out := append([]domain.PullRequest(nil), f.prs...) + return out, nil +} + type fakeReducer struct { - outcome lifecycle.ReviewDeliveryOutcome - err error - calls int - got lifecycle.ReviewResult + outcome lifecycle.ReviewDeliveryOutcome + err error + calls int + batchCalls int + got lifecycle.ReviewResult + gotBatchID string + gotBatch []lifecycle.ReviewResult } func (f *fakeReducer) ApplyReviewResult(_ context.Context, _ domain.SessionID, result lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) { @@ -57,6 +107,13 @@ func (f *fakeReducer) ApplyReviewResult(_ context.Context, _ domain.SessionID, r return f.outcome, f.err } +func (f *fakeReducer) ApplyReviewBatch(_ context.Context, _ domain.SessionID, batchID string, results []lifecycle.ReviewResult) (lifecycle.ReviewDeliveryOutcome, error) { + f.batchCalls++ + f.gotBatchID = batchID + f.gotBatch = append([]lifecycle.ReviewResult(nil), results...) + return f.outcome, f.err +} + func TestSubmitPersistsThenAppliesThenStampsDelivered(t *testing.T) { now := time.Unix(100, 0).UTC() st := &fakeStore{ok: true, run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}} @@ -78,6 +135,98 @@ func TestSubmitPersistsThenAppliesThenStampsDelivered(t *testing.T) { } } +func TestSubmitBatchRunDoesNotWaitForOtherRunningRuns(t *testing.T) { + now := time.Unix(100, 0).UTC() + st := &fakeStore{ + ok: true, + run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}, + batchRuns: []domain.ReviewRun{ + {ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}, + {ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning}, + }, + prs: []domain.PullRequest{{URL: "pr1", HeadSHA: "sha1"}, {URL: "pr2", HeadSHA: "sha2"}}, + } + reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent} + svc := New(nil, st, WithLifecycleReducer(reducer), WithClock(func() time.Time { return now })) + + run, err := svc.Submit(context.Background(), "mer-1", "run-1", domain.VerdictChangesRequested, "fix pr1", "101") + if err != nil { + t.Fatalf("Submit: %v", err) + } + if run.Status != domain.ReviewRunDelivered || run.DeliveredAt == nil || !run.DeliveredAt.Equal(now) { + t.Fatalf("first submit status = %+v, want delivered", run) + } + if reducer.batchCalls != 1 || len(reducer.gotBatch) != 1 || reducer.gotBatch[0].RunID != "run-1" || st.markCalls != 1 { + t.Fatalf("submitted run should deliver independently: batchCalls=%d got=%+v markCalls=%d", reducer.batchCalls, reducer.gotBatch, st.markCalls) + } +} + +func TestSubmitManySendsCombinedChangesRequested(t *testing.T) { + now := time.Unix(100, 0).UTC() + st := &fakeStore{ + ok: true, + batchRuns: []domain.ReviewRun{ + {ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}, + {ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning}, + {ID: "run-3", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr3", TargetSHA: "sha3", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved}, + {ID: "run-4", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr4", TargetSHA: "old", Status: domain.ReviewRunComplete, Verdict: domain.VerdictChangesRequested, Body: "stale"}, + {ID: "run-5", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr5", TargetSHA: "sha5", Status: domain.ReviewRunFailed}, + }, + prs: []domain.PullRequest{ + {URL: "pr1", HeadSHA: "sha1"}, + {URL: "pr2", HeadSHA: "sha2"}, + {URL: "pr3", HeadSHA: "sha3"}, + {URL: "pr4", HeadSHA: "new"}, + {URL: "pr5", HeadSHA: "sha5"}, + }, + } + reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent} + svc := New(nil, st, WithLifecycleReducer(reducer), WithClock(func() time.Time { return now })) + + runs, err := svc.SubmitMany(context.Background(), "mer-1", []SubmittedReview{ + {RunID: "run-1", Verdict: domain.VerdictChangesRequested, Body: "fix pr1", GithubReviewID: "101"}, + {RunID: "run-2", Verdict: domain.VerdictChangesRequested, Body: "fix pr2", GithubReviewID: "102"}, + {RunID: "run-3", Verdict: domain.VerdictApproved}, + }) + if err != nil { + t.Fatalf("SubmitMany: %v", err) + } + if reducer.batchCalls != 1 || reducer.gotBatchID != "batch-1" { + t.Fatalf("batch delivery calls/id = %d/%q", reducer.batchCalls, reducer.gotBatchID) + } + if len(reducer.gotBatch) != 2 || reducer.gotBatch[0].RunID != "run-1" || reducer.gotBatch[1].RunID != "run-2" { + t.Fatalf("delivered batch = %+v, want run-1 and run-2 only", reducer.gotBatch) + } + if st.markCalls != 2 { + t.Fatalf("markCalls = %d, want 2", st.markCalls) + } + if runs[0].Status != domain.ReviewRunDelivered || runs[0].DeliveredAt == nil || !runs[0].DeliveredAt.Equal(now) || + runs[1].Status != domain.ReviewRunDelivered || runs[1].DeliveredAt == nil || !runs[1].DeliveredAt.Equal(now) { + t.Fatalf("submitted runs not stamped delivered: %+v", runs) + } +} + +func TestSubmitBatchApprovedOnlySendsNothing(t *testing.T) { + st := &fakeStore{ + ok: true, + run: domain.ReviewRun{ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning}, + batchRuns: []domain.ReviewRun{ + {ID: "run-1", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunComplete, Verdict: domain.VerdictApproved}, + {ID: "run-2", SessionID: "mer-1", BatchID: "batch-1", PRURL: "pr2", TargetSHA: "sha2", Status: domain.ReviewRunRunning}, + }, + prs: []domain.PullRequest{{URL: "pr1", HeadSHA: "sha1"}, {URL: "pr2", HeadSHA: "sha2"}}, + } + reducer := &fakeReducer{outcome: lifecycle.ReviewDeliverySent} + svc := New(nil, st, WithLifecycleReducer(reducer)) + + if _, err := svc.Submit(context.Background(), "mer-1", "run-2", domain.VerdictApproved, "", "102"); err != nil { + t.Fatalf("Submit: %v", err) + } + if reducer.batchCalls != 0 || st.markCalls != 0 { + t.Fatalf("approved-only batch should not deliver: batchCalls=%d markCalls=%d", reducer.batchCalls, st.markCalls) + } +} + func TestSubmitDeliveryFailureLeavesCompletedUndeliveredForRetry(t *testing.T) { sendErr := errors.New("dead pane") st := &fakeStore{ok: true, run: domain.ReviewRun{ID: "run-1", SessionID: "mer-1", PRURL: "pr1", TargetSHA: "sha1", Status: domain.ReviewRunRunning}} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index f3ca38655..ccc4bf74a 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -147,6 +147,7 @@ type ReviewRun struct { CreatedAt time.Time GithubReviewID string DeliveredAt sql.NullTime + BatchID string } type Session struct { diff --git a/backend/internal/storage/sqlite/gen/review.sql.go b/backend/internal/storage/sqlite/gen/review.sql.go index f9d08f344..16a939aee 100644 --- a/backend/internal/storage/sqlite/gen/review.sql.go +++ b/backend/internal/storage/sqlite/gen/review.sql.go @@ -35,7 +35,7 @@ func (q *Queries) GetReviewBySession(ctx context.Context, sessionID domain.Sessi } const getReviewRun = `-- name: GetReviewRun :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id FROM review_run WHERE id = ? ` @@ -55,22 +55,24 @@ func (q *Queries) GetReviewRun(ctx context.Context, id string) (ReviewRun, error &i.CreatedAt, &i.GithubReviewID, &i.DeliveredAt, + &i.BatchID, ) return i, err } -const getReviewRunBySessionAndSHA = `-- name: GetReviewRunBySessionAndSHA :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at -FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1 +const getReviewRunBySessionPRAndSHA = `-- name: GetReviewRunBySessionPRAndSHA :one +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id +FROM review_run WHERE session_id = ? AND pr_url = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1 ` -type GetReviewRunBySessionAndSHAParams struct { +type GetReviewRunBySessionPRAndSHAParams struct { SessionID domain.SessionID + PRURL string TargetSha string } -func (q *Queries) GetReviewRunBySessionAndSHA(ctx context.Context, arg GetReviewRunBySessionAndSHAParams) (ReviewRun, error) { - row := q.db.QueryRowContext(ctx, getReviewRunBySessionAndSHA, arg.SessionID, arg.TargetSha) +func (q *Queries) GetReviewRunBySessionPRAndSHA(ctx context.Context, arg GetReviewRunBySessionPRAndSHAParams) (ReviewRun, error) { + row := q.db.QueryRowContext(ctx, getReviewRunBySessionPRAndSHA, arg.SessionID, arg.PRURL, arg.TargetSha) var i ReviewRun err := row.Scan( &i.ID, @@ -85,19 +87,21 @@ func (q *Queries) GetReviewRunBySessionAndSHA(ctx context.Context, arg GetReview &i.CreatedAt, &i.GithubReviewID, &i.DeliveredAt, + &i.BatchID, ) return i, err } const insertReviewRun = `-- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, github_review_id, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO review_run (id, review_id, session_id, batch_id, harness, pr_url, target_sha, status, verdict, body, github_review_id, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertReviewRunParams struct { ID string ReviewID string SessionID domain.SessionID + BatchID string Harness domain.ReviewerHarness PRURL string TargetSha string @@ -113,6 +117,7 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams arg.ID, arg.ReviewID, arg.SessionID, + arg.BatchID, arg.Harness, arg.PRURL, arg.TargetSha, @@ -125,8 +130,55 @@ func (q *Queries) InsertReviewRun(ctx context.Context, arg InsertReviewRunParams return err } +const listReviewRunsByBatch = `-- name: ListReviewRunsByBatch :many +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id +FROM review_run WHERE session_id = ? AND batch_id = ? ORDER BY created_at ASC, id ASC +` + +type ListReviewRunsByBatchParams struct { + SessionID domain.SessionID + BatchID string +} + +func (q *Queries) ListReviewRunsByBatch(ctx context.Context, arg ListReviewRunsByBatchParams) ([]ReviewRun, error) { + rows, err := q.db.QueryContext(ctx, listReviewRunsByBatch, arg.SessionID, arg.BatchID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ReviewRun{} + for rows.Next() { + var i ReviewRun + if err := rows.Scan( + &i.ID, + &i.ReviewID, + &i.SessionID, + &i.Harness, + &i.PRURL, + &i.TargetSha, + &i.Status, + &i.Verdict, + &i.Body, + &i.CreatedAt, + &i.GithubReviewID, + &i.DeliveredAt, + &i.BatchID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listReviewRunsBySession = `-- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id FROM review_run WHERE session_id = ? ORDER BY created_at DESC ` @@ -152,6 +204,7 @@ func (q *Queries) ListReviewRunsBySession(ctx context.Context, sessionID domain. &i.CreatedAt, &i.GithubReviewID, &i.DeliveredAt, + &i.BatchID, ); err != nil { return nil, err } @@ -201,17 +254,23 @@ func (q *Queries) SupersedeReviewRun(ctx context.Context, arg SupersedeReviewRun } const supersedeStaleRunningReviewRuns = `-- name: SupersedeStaleRunningReviewRuns :execrows -UPDATE review_run SET status = 'failed', body = ? WHERE session_id = ? AND target_sha != ? AND status = 'running' AND verdict = '' +UPDATE review_run SET status = 'failed', body = ? WHERE session_id = ? AND pr_url = ? AND target_sha != ? AND status = 'running' AND verdict = '' ` type SupersedeStaleRunningReviewRunsParams struct { Body string SessionID domain.SessionID + PRURL string TargetSha string } func (q *Queries) SupersedeStaleRunningReviewRuns(ctx context.Context, arg SupersedeStaleRunningReviewRunsParams) (int64, error) { - result, err := q.db.ExecContext(ctx, supersedeStaleRunningReviewRuns, arg.Body, arg.SessionID, arg.TargetSha) + result, err := q.db.ExecContext(ctx, supersedeStaleRunningReviewRuns, + arg.Body, + arg.SessionID, + arg.PRURL, + arg.TargetSha, + ) if err != nil { return 0, err } diff --git a/backend/internal/storage/sqlite/migrations/0020_review_run_unique_pr_sha.sql b/backend/internal/storage/sqlite/migrations/0020_review_run_unique_pr_sha.sql new file mode 100644 index 000000000..97768c8a6 --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0020_review_run_unique_pr_sha.sql @@ -0,0 +1,88 @@ +-- Review runs are PR-scoped. Two PRs in one worker session can legitimately +-- share a head SHA, so the idempotency index must include pr_url. Completed +-- changes_requested runs are intentionally excluded so the same head can be +-- reviewed again after the worker applies feedback. Runs created by one trigger +-- also share batch_id so one reviewer CLI submission can notify the worker about +-- multiple PRs at once. + +-- +goose Up +-- +goose StatementBegin +ALTER TABLE review_run ADD COLUMN batch_id TEXT NOT NULL DEFAULT ''; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP INDEX idx_review_run_session_sha; +-- +goose StatementEnd + +-- +goose StatementBegin +DELETE FROM review_run +WHERE target_sha != '' + AND pr_url != '' + AND status != 'failed' + AND verdict != 'changes_requested' + AND rowid NOT IN ( + SELECT rowid FROM ( + SELECT rowid, + ROW_NUMBER() OVER ( + PARTITION BY session_id, pr_url, target_sha + ORDER BY CASE status WHEN 'complete' THEN 0 WHEN 'delivered' THEN 0 WHEN 'running' THEN 1 ELSE 2 END, + created_at DESC, + rowid DESC + ) AS rn + FROM review_run + WHERE target_sha != '' AND pr_url != '' AND status != 'failed' AND verdict != 'changes_requested' + ) + WHERE rn = 1 + ); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE UNIQUE INDEX idx_review_run_session_pr_sha + ON review_run (session_id, pr_url, target_sha) + WHERE target_sha != '' AND status != 'failed' AND verdict != 'changes_requested'; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX idx_review_run_session_batch + ON review_run (session_id, batch_id, created_at) + WHERE batch_id != ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX idx_review_run_session_batch; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP INDEX idx_review_run_session_pr_sha; +-- +goose StatementEnd + +-- +goose StatementBegin +DELETE FROM review_run +WHERE target_sha != '' + AND status != 'failed' + AND rowid NOT IN ( + SELECT rowid FROM ( + SELECT rowid, + ROW_NUMBER() OVER ( + PARTITION BY session_id, target_sha + ORDER BY CASE status WHEN 'complete' THEN 0 WHEN 'delivered' THEN 0 WHEN 'running' THEN 1 ELSE 2 END, + created_at DESC, + rowid DESC + ) AS rn + FROM review_run + WHERE target_sha != '' AND status != 'failed' + ) + WHERE rn = 1 + ); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE UNIQUE INDEX idx_review_run_session_sha + ON review_run (session_id, target_sha) + WHERE target_sha != '' AND status != 'failed'; +-- +goose StatementEnd + +-- +goose StatementBegin +ALTER TABLE review_run DROP COLUMN batch_id; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/review.sql b/backend/internal/storage/sqlite/queries/review.sql index 2dbc3a087..335f87cdf 100644 --- a/backend/internal/storage/sqlite/queries/review.sql +++ b/backend/internal/storage/sqlite/queries/review.sql @@ -12,8 +12,8 @@ SELECT id, session_id, project_id, harness, pr_url, reviewer_handle_id, created_ FROM review WHERE session_id = ?; -- name: InsertReviewRun :exec -INSERT INTO review_run (id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, github_review_id, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT INTO review_run (id, review_id, session_id, batch_id, harness, pr_url, target_sha, status, verdict, body, github_review_id, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- name: UpdateReviewRunResult :execrows UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WHERE id = ? AND status = 'running'; @@ -22,19 +22,23 @@ UPDATE review_run SET status = ?, verdict = ?, body = ?, github_review_id = ? WH UPDATE review_run SET status = 'failed', body = ? WHERE id = ? AND verdict = '' AND status != 'failed'; -- name: SupersedeStaleRunningReviewRuns :execrows -UPDATE review_run SET status = 'failed', body = ? WHERE session_id = ? AND target_sha != ? AND status = 'running' AND verdict = ''; +UPDATE review_run SET status = 'failed', body = ? WHERE session_id = ? AND pr_url = ? AND target_sha != ? AND status = 'running' AND verdict = ''; -- name: MarkReviewRunDelivered :execrows UPDATE review_run SET status = 'delivered', delivered_at = ? WHERE id = ? AND status = 'complete' AND delivered_at IS NULL; -- name: GetReviewRun :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id FROM review_run WHERE id = ?; --- name: GetReviewRunBySessionAndSHA :one -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at -FROM review_run WHERE session_id = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1; +-- name: GetReviewRunBySessionPRAndSHA :one +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id +FROM review_run WHERE session_id = ? AND pr_url = ? AND target_sha = ? ORDER BY created_at DESC LIMIT 1; -- name: ListReviewRunsBySession :many -SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id FROM review_run WHERE session_id = ? ORDER BY created_at DESC; + +-- name: ListReviewRunsByBatch :many +SELECT id, review_id, session_id, harness, pr_url, target_sha, status, verdict, body, created_at, github_review_id, delivered_at, batch_id +FROM review_run WHERE session_id = ? AND batch_id = ? ORDER BY created_at ASC, id ASC; diff --git a/backend/internal/storage/sqlite/store/review_store.go b/backend/internal/storage/sqlite/store/review_store.go index cf5fd6460..9a62925ff 100644 --- a/backend/internal/storage/sqlite/store/review_store.go +++ b/backend/internal/storage/sqlite/store/review_store.go @@ -41,7 +41,7 @@ func (s *Store) GetReviewBySession(ctx context.Context, id domain.SessionID) (do } // InsertReviewRun records a new review pass. A unique-constraint hit on the -// (session_id, target_sha) index (migration 0013) is surfaced as the sentinel +// (session_id, pr_url, target_sha) index (migration 0020) is surfaced as the sentinel // domain.ErrDuplicateReviewRun so the engine can fall back to the existing run. func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { s.writeMu.Lock() @@ -50,6 +50,7 @@ func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { ID: r.ID, ReviewID: r.ReviewID, SessionID: r.SessionID, + BatchID: r.BatchID, Harness: r.Harness, PRURL: r.PRURL, TargetSha: r.TargetSHA, @@ -60,7 +61,7 @@ func (s *Store) InsertReviewRun(ctx context.Context, r domain.ReviewRun) error { CreatedAt: r.CreatedAt, }) if isSQLiteUnique(err) { - return fmt.Errorf("insert review run for session %s sha %s: %w", r.SessionID, r.TargetSHA, domain.ErrDuplicateReviewRun) + return fmt.Errorf("insert review run for session %s pr %s sha %s: %w", r.SessionID, r.PRURL, r.TargetSHA, domain.ErrDuplicateReviewRun) } return err } @@ -100,12 +101,13 @@ func (s *Store) SupersedeReviewRun(ctx context.Context, id, body string) (bool, // SupersedeStaleRunningReviewRuns marks older running unverdicted passes for a // worker failed before starting a review for a newer commit. -func (s *Store) SupersedeStaleRunningReviewRuns(ctx context.Context, sessionID domain.SessionID, targetSHA, body string) (int64, error) { +func (s *Store) SupersedeStaleRunningReviewRuns(ctx context.Context, sessionID domain.SessionID, prURL, targetSHA, body string) (int64, error) { s.writeMu.Lock() defer s.writeMu.Unlock() return s.qw.SupersedeStaleRunningReviewRuns(ctx, gen.SupersedeStaleRunningReviewRunsParams{ Body: body, SessionID: sessionID, + PRURL: prURL, TargetSha: targetSHA, }) } @@ -137,16 +139,17 @@ func (s *Store) GetReviewRun(ctx context.Context, id string) (domain.ReviewRun, return reviewRunFromRow(row), true, nil } -// GetReviewRunBySessionAndSHA returns the most recent review pass for a worker -// session at a specific commit, ok=false if none. It lets a repeat trigger for -// the same PR head short-circuit to the existing run. -func (s *Store) GetReviewRunBySessionAndSHA(ctx context.Context, id domain.SessionID, targetSHA string) (domain.ReviewRun, bool, error) { - row, err := s.qr.GetReviewRunBySessionAndSHA(ctx, gen.GetReviewRunBySessionAndSHAParams{SessionID: id, TargetSha: targetSHA}) +// GetReviewRunBySessionPRAndSHA returns the most recent review pass for a +// worker session, PR, and commit, ok=false if none. It lets a repeat trigger for +// the same PR head short-circuit to the existing run without colliding with +// another PR that happens to share the same head commit. +func (s *Store) GetReviewRunBySessionPRAndSHA(ctx context.Context, id domain.SessionID, prURL, targetSHA string) (domain.ReviewRun, bool, error) { + row, err := s.qr.GetReviewRunBySessionPRAndSHA(ctx, gen.GetReviewRunBySessionPRAndSHAParams{SessionID: id, PRURL: prURL, TargetSha: targetSHA}) if errors.Is(err, sql.ErrNoRows) { return domain.ReviewRun{}, false, nil } if err != nil { - return domain.ReviewRun{}, false, fmt.Errorf("get review run for session %s sha %s: %w", id, targetSHA, err) + return domain.ReviewRun{}, false, fmt.Errorf("get review run for session %s pr %s sha %s: %w", id, prURL, targetSHA, err) } return reviewRunFromRow(row), true, nil } @@ -164,6 +167,19 @@ func (s *Store) ListReviewRunsBySession(ctx context.Context, id domain.SessionID return out, nil } +// ListReviewRunsByBatch returns all passes in one trigger-created batch, oldest first. +func (s *Store) ListReviewRunsByBatch(ctx context.Context, id domain.SessionID, batchID string) ([]domain.ReviewRun, error) { + rows, err := s.qr.ListReviewRunsByBatch(ctx, gen.ListReviewRunsByBatchParams{SessionID: id, BatchID: batchID}) + if err != nil { + return nil, fmt.Errorf("list review runs for session %s batch %s: %w", id, batchID, err) + } + out := make([]domain.ReviewRun, 0, len(rows)) + for _, row := range rows { + out = append(out, reviewRunFromRow(row)) + } + return out, nil +} + func reviewFromRow(r gen.Review) domain.Review { return domain.Review{ ID: r.ID, @@ -187,6 +203,7 @@ func reviewRunFromRow(r gen.ReviewRun) domain.ReviewRun { ID: r.ID, ReviewID: r.ReviewID, SessionID: r.SessionID, + BatchID: r.BatchID, Harness: r.Harness, PRURL: r.PRURL, TargetSHA: r.TargetSha, diff --git a/backend/internal/storage/sqlite/store/review_store_test.go b/backend/internal/storage/sqlite/store/review_store_test.go index 399438261..e8ea62bb3 100644 --- a/backend/internal/storage/sqlite/store/review_store_test.go +++ b/backend/internal/storage/sqlite/store/review_store_test.go @@ -9,7 +9,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -func TestInsertReviewRunDuplicateSHAMapsToSentinel(t *testing.T) { +func TestInsertReviewRunDuplicatePRSHAMapsToSentinel(t *testing.T) { s := newTestStore(t) ctx := context.Background() seedProject(t, s, "mer") @@ -26,21 +26,28 @@ func TestInsertReviewRunDuplicateSHAMapsToSentinel(t *testing.T) { } run := domain.ReviewRun{ ID: "run-1", ReviewID: "rev-1", SessionID: rec.ID, Harness: domain.ReviewerClaudeCode, - TargetSHA: "sha1", Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, CreatedAt: now, + PRURL: "https://example/pr/1", TargetSHA: "sha1", Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, CreatedAt: now, } if err := s.InsertReviewRun(ctx, run); err != nil { t.Fatalf("first insert: %v", err) } - // A second run for the same (session_id, target_sha) hits the partial unique - // index (migration 0013) and must surface as the sentinel so the engine can - // fall back to the existing run. + // A second run for the same (session_id, pr_url, target_sha) hits the + // partial unique index (migration 0020) and must surface as the sentinel so + // the engine can fall back to the existing run. dup := run dup.ID = "run-2" if err := s.InsertReviewRun(ctx, dup); !errors.Is(err, domain.ErrDuplicateReviewRun) { t.Fatalf("duplicate insert err = %v, want ErrDuplicateReviewRun", err) } + otherPR := run + otherPR.ID = "run-other-pr" + otherPR.PRURL = "https://example/pr/2" + if err := s.InsertReviewRun(ctx, otherPR); err != nil { + t.Fatalf("same sha on different PR should insert: %v", err) + } + if ok, err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunFailed, domain.VerdictNone, "claude: not found", ""); err != nil { t.Fatalf("mark failed: %v", err) } else if !ok { @@ -60,6 +67,42 @@ func TestInsertReviewRunDuplicateSHAMapsToSentinel(t *testing.T) { } } +func TestInsertReviewRunAllowsRerunAfterChangesRequested(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + rec, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Fatalf("create session: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + if err := s.UpsertReview(ctx, domain.Review{ + ID: "rev-1", SessionID: rec.ID, ProjectID: rec.ProjectID, + Harness: domain.ReviewerClaudeCode, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("upsert review: %v", err) + } + run := domain.ReviewRun{ + ID: "run-1", ReviewID: "rev-1", SessionID: rec.ID, Harness: domain.ReviewerClaudeCode, + PRURL: "https://example/pr/1", TargetSHA: "sha1", Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, CreatedAt: now, + } + if err := s.InsertReviewRun(ctx, run); err != nil { + t.Fatalf("first insert: %v", err) + } + if ok, err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictChangesRequested, "please fix", "rev-1"); err != nil { + t.Fatalf("mark changes requested: %v", err) + } else if !ok { + t.Fatal("mark changes requested: got ok=false") + } + + rerun := run + rerun.ID = "run-2" + rerun.CreatedAt = now.Add(time.Second) + if err := s.InsertReviewRun(ctx, rerun); err != nil { + t.Fatalf("rerun after changes_requested insert: %v", err) + } +} + func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { s := newTestStore(t) ctx := context.Background() @@ -102,7 +145,7 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { // A run inserts running and updates to complete/changes_requested. if err := s.InsertReviewRun(ctx, domain.ReviewRun{ - ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, Harness: domain.ReviewerHarness("greptile"), + ID: "run-1", ReviewID: got.ID, SessionID: rec.ID, BatchID: "batch-1", Harness: domain.ReviewerHarness("greptile"), PRURL: got.PRURL, TargetSHA: "sha1", Status: domain.ReviewRunRunning, Verdict: domain.VerdictNone, CreatedAt: now, }); err != nil { @@ -118,18 +161,18 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if err != nil || !ok { t.Fatalf("get run: ok=%v err=%v", ok, err) } - if gotRun.ID != "run-1" || gotRun.SessionID != rec.ID || gotRun.TargetSHA != "sha1" { + if gotRun.ID != "run-1" || gotRun.SessionID != rec.ID || gotRun.BatchID != "batch-1" || gotRun.TargetSHA != "sha1" { t.Fatalf("get run = %+v", gotRun) } - bySHA, ok, err := s.GetReviewRunBySessionAndSHA(ctx, rec.ID, "sha1") + bySHA, ok, err := s.GetReviewRunBySessionPRAndSHA(ctx, rec.ID, got.PRURL, "sha1") if err != nil || !ok { t.Fatalf("by sha: ok=%v err=%v", ok, err) } if bySHA.Status != domain.ReviewRunComplete || bySHA.Verdict != domain.VerdictChangesRequested || bySHA.Body != "please fix" || bySHA.GithubReviewID != "rev-987" { t.Fatalf("run result not persisted: %+v", bySHA) } - if _, ok, _ := s.GetReviewRunBySessionAndSHA(ctx, rec.ID, "other"); ok { + if _, ok, _ := s.GetReviewRunBySessionPRAndSHA(ctx, rec.ID, got.PRURL, "other"); ok { t.Fatal("unexpected run for a different sha") } @@ -140,6 +183,13 @@ func TestReviewUpsertReusesRowAndRunRoundTrip(t *testing.T) { if len(runs) != 1 || runs[0].ID != "run-1" { t.Fatalf("list runs = %+v", runs) } + batchRuns, err := s.ListReviewRunsByBatch(ctx, rec.ID, "batch-1") + if err != nil { + t.Fatalf("list batch runs: %v", err) + } + if len(batchRuns) != 1 || batchRuns[0].ID != "run-1" || batchRuns[0].BatchID != "batch-1" { + t.Fatalf("batch runs = %+v", batchRuns) + } if ok, err := s.UpdateReviewRunResult(ctx, "run-1", domain.ReviewRunComplete, domain.VerdictApproved, "again", ""); err != nil { t.Fatalf("second update: %v", err) @@ -154,7 +204,7 @@ func TestReviewGettersMissing(t *testing.T) { if _, ok, err := s.GetReviewBySession(ctx, "mer-1"); err != nil || ok { t.Fatalf("missing review: ok=%v err=%v", ok, err) } - if _, ok, err := s.GetReviewRunBySessionAndSHA(ctx, "mer-1", "sha1"); err != nil || ok { + if _, ok, err := s.GetReviewRunBySessionPRAndSHA(ctx, "mer-1", "pr1", "sha1"); err != nil || ok { t.Fatalf("missing run: ok=%v err=%v", ok, err) } if _, ok, err := s.GetReviewRun(ctx, "run-missing"); err != nil || ok { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index ff6d8c967..93d2a7395 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -595,7 +595,7 @@ export interface components { }; ListReviewsResponse: { reviewerHandleId: string; - reviews: components["schemas"]["ReviewRun"][]; + reviews: components["schemas"]["PRReviewState"][]; }; ListSessionPRsResponse: { prs: components["schemas"]["SessionPRSummary"][]; @@ -648,6 +648,15 @@ export interface components { projectId: string; projectName?: string; }; + PRReviewState: { + latestRun?: components["schemas"]["ReviewRun"]; + prNumber: number; + prUrl: string; + /** @enum {string} */ + status: "needs_review" | "running" | "up_to_date" | "changes_requested" | "ineligible"; + targetSha: string; + title: string; + }; Project: { agent?: string; config?: components["schemas"]["ProjectConfig"]; @@ -711,6 +720,7 @@ export interface components { sessionId: string; }; ReviewRun: { + batchId: string; body: string; /** Format: date-time */ createdAt: string; @@ -729,6 +739,7 @@ export interface components { ReviewRunResponse: { review: components["schemas"]["ReviewRun"]; reviewerHandleId: string; + reviews: components["schemas"]["ReviewRun"][]; }; RoleOverride: { agent?: string; @@ -877,14 +888,30 @@ export interface components { }; SubmitReviewInput: { /** @description Review body recorded by AO. Required for changes_requested. */ - body: string; + body?: string; /** @description Id of the GitHub PR review the reviewer posted, if any. */ - githubReviewId: string; + githubReviewId?: string; + /** @description Batched review results recorded by one reviewer CLI command. */ + reviews?: components["schemas"]["SubmitReviewItem"][]; + /** @description Review run id being completed. */ + runId?: string; + /** @description Review verdict: approved or changes_requested. */ + verdict?: string; + }; + SubmitReviewItem: { + /** @description Review body recorded by AO. Required for changes_requested. */ + body?: string; + /** @description Id of the GitHub PR review the reviewer posted, if any. */ + githubReviewId?: string; /** @description Review run id being completed. */ runId: string; /** @description Review verdict: approved or changes_requested. */ verdict: string; }; + TriggerReviewResponse: { + reviewerHandleId: string; + reviews: components["schemas"]["PRReviewState"][]; + }; WorkspaceRepo: { name: string; relativePath: string; @@ -2532,7 +2559,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReviewRunResponse"]; + "application/json": components["schemas"]["TriggerReviewResponse"]; }; }; /** @description Created */ @@ -2541,7 +2568,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReviewRunResponse"]; + "application/json": components["schemas"]["TriggerReviewResponse"]; }; }; /** @description Not Found */ diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index 41365e2a7..7c7eddb28 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -56,7 +56,7 @@ function renderWithQuery(children: ReactNode) { return render({children}); } -function mockCommonGets(reviews: unknown[] = [], reviewerHandleId = "") { +function mockCommonGets(_unusedRuns: unknown[] = [], reviewerHandleId = "", reviews: unknown[] = []) { getMock.mockImplementation(async (path: string) => { if (path === "/api/v1/sessions/{sessionId}/reviews") { return { data: { reviewerHandleId, reviews } }; @@ -94,6 +94,24 @@ const approvedReview = { createdAt: "2026-06-16T10:06:00Z", }; +const failedReview = { + ...approvedReview, + id: "run-failed", + status: "failed", + verdict: "", + body: "reviewer crashed", +}; + +const reviewState = (n: number, status: string, targetSha = `sha-${n}`) => ({ + prUrl: `https://example.com/pr/${n}`, + prNumber: n, + title: `Reviewable change ${n}`, + targetSha, + status, + latestRun: + status === "up_to_date" ? { ...approvedReview, prUrl: `https://example.com/pr/${n}`, targetSha } : undefined, +}); + beforeEach(() => { getMock.mockReset(); postMock.mockReset(); @@ -155,12 +173,13 @@ describe("SessionInspector reviews tab", () => { const openReviewsTab = async () => userEvent.click(screen.getByRole("tab", { name: /Reviews/ })); it("triggers a review and opens the returned reviewer terminal", async () => { - mockCommonGets(); + mockCommonGets([], "", [reviewState(3, "needs_review")]); + const runningReview = { ...approvedReview, status: "running", verdict: "", body: "" }; postMock.mockResolvedValue({ response: { status: 201 }, data: { reviewerHandleId: "reviewer-pane", - review: { ...approvedReview, status: "running", verdict: "", body: "" }, + reviews: [{ ...reviewState(3, "running"), latestRun: runningReview }], }, }); const onOpenReviewerTerminal = vi.fn(); @@ -180,11 +199,34 @@ describe("SessionInspector reviews tab", () => { expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" }); }); - it("shows an up-to-date notice instead of opening the terminal when the backend reuses a run", async () => { - mockCommonGets([approvedReview], "reviewer-pane"); + it("shows eligible and up-to-date PR review rows", async () => { + mockCommonGets([approvedReview], "reviewer-pane", [ + reviewState(3, "needs_review", "abc123"), + reviewState(4, "up_to_date", "def456"), + ]); + + renderWithQuery(); + await openReviewsTab(); + + expect(await screen.findByText("Reviewable change 3")).toBeInTheDocument(); + expect(screen.getByText("#3")).toBeInTheDocument(); + expect(screen.getByText("Reviewable change 4")).toBeInTheDocument(); + expect(screen.getByText("#4")).toBeInTheDocument(); + expect(screen.getAllByText("Not run")).not.toHaveLength(0); + expect(screen.getAllByText("Approved")).not.toHaveLength(0); + expect(screen.getByRole("button", { name: "Re-run review" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Run" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Re-run" })).not.toBeInTheDocument(); + }); + + it("shows a no-needed-reviews notice instead of opening the terminal when the backend reuses runs", async () => { + mockCommonGets([approvedReview], "reviewer-pane", [reviewState(3, "up_to_date")]); postMock.mockResolvedValue({ response: { status: 200 }, - data: { reviewerHandleId: "reviewer-pane", review: approvedReview }, + data: { + reviewerHandleId: "reviewer-pane", + reviews: [], + }, }); const onOpenReviewerTerminal = vi.fn(); @@ -195,12 +237,15 @@ describe("SessionInspector reviews tab", () => { await userEvent.click(await screen.findByRole("button", { name: /re-run review/i })); - expect(await screen.findByText("Review is already up to date for this commit.")).toBeInTheDocument(); + expect(await screen.findByText("No needed reviews were started.")).toBeInTheDocument(); expect(onOpenReviewerTerminal).not.toHaveBeenCalled(); }); - it("shows an approved review and opens its terminal", async () => { - mockCommonGets([approvedReview], "reviewer-pane"); + it("shows one shared terminal action", async () => { + mockCommonGets([approvedReview], "reviewer-pane", [ + reviewState(3, "running", "abc123"), + reviewState(4, "up_to_date", "def456"), + ]); const onOpenReviewerTerminal = vi.fn(); renderWithQuery( @@ -208,12 +253,38 @@ describe("SessionInspector reviews tab", () => { ); await openReviewsTab(); - await waitFor(() => expect(screen.getAllByText("Approved").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("Open terminal")).toHaveLength(1)); + expect(screen.getAllByRole("button", { name: /review/i })).toHaveLength(1); await userEvent.click(screen.getByRole("button", { name: /open terminal/i })); expect(onOpenReviewerTerminal).toHaveBeenCalledWith({ handleId: "reviewer-pane", harness: "codex" }); }); + it("shows the reviewer identity and aggregate verdict", async () => { + mockCommonGets([approvedReview], "reviewer-pane", [reviewState(3, "changes_requested", "abc123")]); + + renderWithQuery(); + await openReviewsTab(); + + expect(await screen.findByText("codex")).toBeInTheDocument(); + expect(screen.getByText("reviewer")).toBeInTheDocument(); + expect(screen.queryByText("sess-1")).not.toBeInTheDocument(); + expect(screen.queryByText("review session")).not.toBeInTheDocument(); + expect(screen.getAllByText("Changes requested")).not.toHaveLength(0); + }); + + it("shows failed latest runs as failed and still allows rerun", async () => { + mockCommonGets([failedReview], "reviewer-pane", [ + { ...reviewState(3, "needs_review", "abc123"), latestRun: failedReview }, + ]); + + renderWithQuery(); + await openReviewsTab(); + + expect(await screen.findAllByText("Failed")).not.toHaveLength(0); + expect(screen.getByRole("button", { name: "Re-run review" })).toBeEnabled(); + }); + it("shows the no-PR empty state when the session has no PRs", async () => { mockCommonGets(); renderWithQuery(); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 83464c435..02c69f690 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -1,15 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; -import { - AlertCircle, - ArrowUpRight, - CheckCircle2, - CircleMinus, - GitPullRequest, - Play, - Shield, - Terminal, -} from "lucide-react"; +import { ArrowUpRight, GitPullRequest, Play, Shield, Terminal } from "lucide-react"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; @@ -26,7 +17,7 @@ import { cn } from "../lib/utils"; import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay"; type ProjectConfig = components["schemas"]["ProjectConfig"]; -type ReviewRun = components["schemas"]["ReviewRun"]; +type PRReviewState = components["schemas"]["PRReviewState"]; type ReviewsResponse = components["schemas"]["ListReviewsResponse"]; type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void; @@ -387,7 +378,8 @@ function ReviewsView({ enabled: hasPr, refetchInterval: (query) => { const data = query.state.data as ReviewsResponse | undefined; - return data?.reviews.some((review) => review.status === "running") ? 2500 : false; + const reviews = data?.reviews ?? []; + return reviews.some((review) => review.status === "running") ? 2500 : false; }, queryFn: async () => { const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/reviews", { @@ -422,16 +414,18 @@ function ReviewsView({ onSuccess: ({ data, reused }) => { void queryClient.invalidateQueries({ queryKey: ["session-reviews", session.id] }); void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); - if (reused) { - setReviewNotice("Review is already up to date for this commit."); + const started = data?.reviews?.find((review) => review.status === "running" && review.latestRun); + if (reused || !started?.latestRun) { + setReviewNotice("No needed reviews were started."); return; } if (data?.reviewerHandleId) { - onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness: data.review.harness || "reviewer" }); + const harness = started.latestRun.harness || "reviewer"; + onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness }); } }, }); - const reviews = reviewsQuery.data?.reviews ?? []; + const reviewStates = reviewsQuery.data?.reviews ?? []; return (
@@ -444,7 +438,7 @@ function ReviewsView({ onOpenTerminal={onOpenReviewerTerminal} onTrigger={() => triggerReview.mutate()} reviewerHandleId={reviewsQuery.data?.reviewerHandleId ?? ""} - reviews={reviews} + reviewStates={reviewStates} notice={reviewNotice} session={session} /> @@ -461,7 +455,7 @@ function projectConfig(project: components["schemas"]["ProjectOrDegraded"] | und function ReviewPanel({ session, config, - reviews, + reviewStates, reviewerHandleId, isLoading, isTriggering, @@ -472,7 +466,7 @@ function ReviewPanel({ }: { session: WorkspaceSession; config?: ProjectConfig; - reviews: ReviewRun[]; + reviewStates: PRReviewState[]; reviewerHandleId: string; isLoading: boolean; isTriggering: boolean; @@ -488,108 +482,142 @@ function ReviewPanel({ return

Loading reviews...

; } - const latest = latestReview(reviews); + const latest = reviewStates.find((review) => review.latestRun)?.latestRun; const harness = latest?.harness || config?.reviewers?.[0]?.harness || session.provider || "reviewer"; + const terminalEnabled = Boolean(reviewerHandleId && onOpenTerminal); + const aggregateVerdict = sessionReviewVerdict(reviewStates); + const runAction = reviewSessionRunAction(reviewStates, isTriggering); + const runDisabled = + isTriggering || + reviewStates.length === 0 || + reviewStates.some((reviewState) => reviewState.status === "running") || + reviewStates.every((reviewState) => reviewState.status === "ineligible"); return (
{error ?

{apiErrorMessage(error, "Review request failed")}

: null} {notice ?

{notice}

: null} - -
- ); -} - -function latestReview(reviews: ReviewRun[]): ReviewRun | undefined { - return [...reviews].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; -} - -function ReviewerCard({ - harness, - review, - handleId, - isTriggering, - onTrigger, - onOpenTerminal, -}: { - harness: string; - review?: ReviewRun; - handleId: string; - isTriggering: boolean; - onTrigger: () => void; - onOpenTerminal?: OpenReviewerTerminal; -}) { - const status = reviewStatus(review); - const terminalEnabled = Boolean(handleId && onOpenTerminal); - const runLabel = review ? "Re-run review" : "Run review"; - - return ( -
-
-
-
- - {status.icon} - {status.label} - +
+
-
- - {review ? ( +
+
+ Pull requests + + {aggregateVerdict.label} + +
+
+ {reviewStates.length === 0 ?

No review state loaded yet.

: null} + {reviewStates.map((reviewState) => ( + + ))} +
+
+ - ) : null} +
); } -function reviewStatus(review?: ReviewRun): { +function ReviewStateRow({ reviewState }: { reviewState: PRReviewState }) { + const verdict = reviewVerdict(reviewState); + const title = reviewState.title?.trim() || `PR #${reviewState.prNumber}`; + return ( +
+
+ +
+
+
+ {verdict.label} +
+ ); +} + +function sessionReviewVerdict(reviewStates: PRReviewState[]): { label: string; tone: "neutral" | "running" | "success" | "danger"; - icon: ReactNode; } { - if (!review) return { label: "Not run", tone: "neutral", icon: null }; - if (review.status === "running") { - return { label: "Running", tone: "running", icon:
); } @@ -316,7 +327,7 @@ function BoardPRSummary({ className, pr }: { className?: string; pr: SessionPRSu {diffSummary ? {diffSummary} : null} - +
); } diff --git a/frontend/src/renderer/lib/pr-display.test.ts b/frontend/src/renderer/lib/pr-display.test.ts index fd50f591a..3a904f5ec 100644 --- a/frontend/src/renderer/lib/pr-display.test.ts +++ b/frontend/src/renderer/lib/pr-display.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SessionPRSummary } from "../hooks/useSessionScmSummary"; -import { prAttentionItems, prDiffSummary, prStatusRows } from "./pr-display"; +import { prAttentionItems, prBrowserUrl, prDiffSummary, prStatusRows } from "./pr-display"; const summary = (overrides: Partial = {}): SessionPRSummary => ({ url: "https://github.com/acme/repo/pull/7", @@ -56,6 +56,19 @@ describe("prDiffSummary", () => { }); }); +describe("prBrowserUrl", () => { + it("normalizes issue-shaped GitHub PR URLs to the pull request page", () => { + expect( + prBrowserUrl( + summary({ + url: "https://github.com/acme/repo/issues/7", + htmlUrl: "https://github.com/acme/repo/issues/7", + }), + ), + ).toBe("https://github.com/acme/repo/pull/7"); + }); +}); + describe("prAttentionItems", () => { it("returns no attention for clean open PRs", () => { expect(prAttentionItems(summary())).toEqual([]); @@ -100,6 +113,150 @@ describe("prAttentionItems", () => { }); }); + it("links failing CI checks to their provider URLs", () => { + const items = prAttentionItems( + summary({ + ci: { + state: "failing", + failingChecks: [ + { name: "unit", status: "failed", conclusion: "failure", url: "https://checks.example/unit" }, + { name: "lint", status: "failed", conclusion: "failure", url: "https://checks.example/lint" }, + { name: "build", status: "failed", conclusion: "failure", url: "https://checks.example/build" }, + { name: "types", status: "failed", conclusion: "failure", url: "https://checks.example/types" }, + ], + }, + }), + ); + + const ciItem = items.find((item) => item.kind === "ci_failing"); + expect(ciItem?.links).toEqual([ + { label: "unit", href: "https://checks.example/unit", title: "failure" }, + { label: "lint", href: "https://checks.example/lint", title: "failure" }, + { label: "build", href: "https://checks.example/build", title: "failure" }, + ]); + expect(ciItem?.overflowLabel).toBe("+1 check"); + }); + + it("prefers the submitted review summary over inline comments", () => { + const items = prAttentionItems( + summary({ + review: { + decision: "changes_requested", + hasUnresolvedHumanComments: true, + unresolvedBy: [ + { + reviewerId: "alice", + count: 2, + reviewUrl: "https://github.com/acme/repo/pull/7#pullrequestreview-1", + links: [ + { url: "https://github.com/acme/repo/pull/7#discussion_r1", file: "main.go", line: 12 }, + { url: "https://github.com/acme/repo/pull/7#discussion_r2", file: "test.go", line: 20 }, + ], + }, + ], + }, + }), + ); + + expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({ + label: "alice +1", + href: "https://github.com/acme/repo/pull/7#pullrequestreview-1", + title: "Open requested-changes review from alice", + }); + }); + + it("falls back to the first inline comment when no review summary exists", () => { + const items = prAttentionItems( + summary({ + review: { + decision: "changes_requested", + hasUnresolvedHumanComments: true, + unresolvedBy: [ + { + reviewerId: "alice", + count: 2, + links: [ + { url: "https://github.com/acme/repo/pull/7#discussion_r1", file: "main.go", line: 12 }, + { url: "https://github.com/acme/repo/pull/7#discussion_r2", file: "test.go", line: 20 }, + ], + }, + ], + }, + }), + ); + + expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({ + label: "alice +1", + href: "https://github.com/acme/repo/pull/7#discussion_r1", + title: "2 unresolved comments from alice", + }); + }); + + it("falls back to the PR page when review summary and inline comment URLs are missing", () => { + const items = prAttentionItems( + summary({ + url: "https://github.com/acme/repo/issues/7", + htmlUrl: "https://github.com/acme/repo/issues/7", + review: { + decision: "changes_requested", + hasUnresolvedHumanComments: true, + unresolvedBy: [{ reviewerId: "alice", count: 1, links: [] }], + }, + }), + ); + + expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({ + label: "alice", + href: "https://github.com/acme/repo/pull/7", + title: "Open pull request for alice", + }); + }); + + it("shows bot reviewers with a bot label", () => { + const items = prAttentionItems( + summary({ + review: { + decision: "changes_requested", + hasUnresolvedHumanComments: false, + unresolvedBy: [ + { + reviewerId: "copilot", + count: 0, + reviewUrl: "https://github.com/acme/repo/pull/7#pullrequestreview-2", + isBot: true, + links: [], + }, + ], + }, + }), + ); + + expect(items.find((item) => item.kind === "review_changes_requested")?.links[0]).toMatchObject({ + label: "copilot bot", + href: "https://github.com/acme/repo/pull/7#pullrequestreview-2", + title: "Open requested-changes review from copilot bot", + }); + }); + + it("links merge conflicts to GitHub's conflict resolution page", () => { + const items = prAttentionItems( + summary({ + url: "https://github.com/acme/repo/issues/7", + htmlUrl: "https://github.com/acme/repo/issues/7", + mergeability: { + state: "conflicting", + reasons: [], + prUrl: "https://github.com/acme/repo/issues/7", + }, + }), + ); + + expect(items.find((item) => item.kind === "merge_conflict")?.links[0]).toMatchObject({ + label: "conflicts", + href: "https://github.com/acme/repo/pull/7/conflicts", + }); + }); + it("suppresses attention once the PR is closed or merged", () => { expect( prAttentionItems( diff --git a/frontend/src/renderer/lib/pr-display.ts b/frontend/src/renderer/lib/pr-display.ts index b533c0d46..7ce2c3f45 100644 --- a/frontend/src/renderer/lib/pr-display.ts +++ b/frontend/src/renderer/lib/pr-display.ts @@ -46,6 +46,10 @@ export function comparePRDisplaySummaries(a: SessionPRSummary, b: SessionPRSumma return prStateRank[a.state] - prStateRank[b.state] || a.number - b.number; } +export function prBrowserUrl(pr: SessionPRSummary): string { + return prBaseUrl(pr) ?? pr.htmlUrl ?? pr.url; +} + export function sessionPRDisplaySummaries( session: WorkspaceSession, summaries: SessionPRSummary[] = [], @@ -175,11 +179,10 @@ export function prAttentionItems(pr: SessionPRSummary): PRAttentionItem[] { } if (pr.review.decision === "changes_requested" || pr.review.hasUnresolvedHumanComments) { const reviewers = pr.review.unresolvedBy.slice(0, 3); - const links = reviewers.map((reviewer) => ({ - label: reviewerLabel(reviewer), - href: reviewer.links.find((link) => link.url)?.url || undefined, - title: `${reviewer.count} unresolved ${pluralize("comment", reviewer.count)}`, - })); + const links = reviewers.map((reviewer) => reviewAttentionLink(pr, reviewer)); + if (links.length === 0 && pr.review.decision === "changes_requested") { + links.push({ label: "PR", href: prBrowserUrl(pr), title: "Open pull request" }); + } items.push({ kind: "review_changes_requested", title: "Address requested changes", @@ -331,18 +334,23 @@ function mergeAttention( fallback: string, tone: PRDisplayTone, ): PRAttentionItem { + const href = + kind === "merge_conflict" ? mergeConflictUrl(pr) : pr.mergeability.prUrl || pr.htmlUrl || pr.url || undefined; const fileLinks = (pr.mergeability.conflictFiles ?? []).slice(0, 3).map((file) => ({ label: file.path, - href: file.url || pr.mergeability.prUrl || undefined, + href: file.url || href, + title: kind === "merge_conflict" ? "Open merge conflicts" : undefined, })); const reasonLinks = - fileLinks.length > 0 + fileLinks.length > 0 || kind === "merge_conflict" ? [] : pr.mergeability.reasons.slice(0, 3).map((reason) => ({ label: mergeReasonLabel(reason), - href: pr.mergeability.prUrl || undefined, + href, })); - const links = fileLinks.length > 0 ? fileLinks : reasonLinks; + const fallbackLink = + kind === "merge_conflict" && href ? [{ label: "conflicts", href, title: "Open merge conflicts" }] : []; + const links = fileLinks.length > 0 ? fileLinks : reasonLinks.length > 0 ? reasonLinks : fallbackLink; return { kind, title, @@ -356,11 +364,78 @@ function mergeAttention( }; } -function reviewerLabel(reviewer: SessionPRSummary["review"]["unresolvedBy"][number]): string { - if (reviewer.count <= 1) { - return reviewer.reviewerId; +function mergeConflictUrl(pr: SessionPRSummary): string | undefined { + return prSubpageUrl(pr, "conflicts") ?? pr.mergeability.prUrl ?? prBrowserUrl(pr); +} + +function prBaseUrl(pr: SessionPRSummary): string | undefined { + return prURL(pr); +} + +function prSubpageUrl(pr: SessionPRSummary, subpage: "conflicts"): string | undefined { + const base = prURL(pr); + return base ? `${base}/${subpage}` : undefined; +} + +function prURL(pr: SessionPRSummary): string | undefined { + const raw = pr.htmlUrl || pr.mergeability.prUrl || pr.url; + if (!raw) { + return undefined; } - return `${reviewer.reviewerId} +${reviewer.count - 1}`; + try { + const url = new URL(raw); + const match = url.pathname.match(/^(\/[^/]+\/[^/]+)\/(?:pull|issues)\/(\d+)(?:\/.*)?$/); + if (!match) { + return undefined; + } + url.pathname = `${match[1]}/pull/${match[2]}`; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return undefined; + } +} + +function reviewerLabel(reviewer: SessionPRSummary["review"]["unresolvedBy"][number]): string { + const name = reviewerDisplayName(reviewer); + if (reviewer.count <= 1) { + return name; + } + return `${name} +${reviewer.count - 1}`; +} + +function reviewerDisplayName(reviewer: SessionPRSummary["review"]["unresolvedBy"][number]): string { + return reviewer.isBot ? `${reviewer.reviewerId} bot` : reviewer.reviewerId; +} + +function reviewAttentionLink( + pr: SessionPRSummary, + reviewer: SessionPRSummary["review"]["unresolvedBy"][number], +): PRAttentionLink { + const inlineURL = reviewer.links.find((link) => link.url)?.url; + if (reviewer.reviewUrl) { + return { + label: reviewerLabel(reviewer), + href: reviewer.reviewUrl, + title: `Open requested-changes review from ${reviewerDisplayName(reviewer)}`, + }; + } + if (inlineURL) { + return { + label: reviewerLabel(reviewer), + href: inlineURL, + title: + reviewer.count > 0 + ? `${reviewer.count} unresolved ${pluralize("comment", reviewer.count)} from ${reviewerDisplayName(reviewer)}` + : `Open review comments from ${reviewerDisplayName(reviewer)}`, + }; + } + return { + label: reviewerLabel(reviewer), + href: prBrowserUrl(pr), + title: `Open pull request for ${reviewerDisplayName(reviewer)}`, + }; } function mergeReasonLabel(reason: string): string { From 2c4bf55e9515ba05f97b9a6c23237d285b2541d2 Mon Sep 17 00:00:00 2001 From: swyam sharma <84120511+areycruzer@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:14:46 +0530 Subject: [PATCH 12/43] fix(spawn): fail fast when tmux is missing (#2259) Co-authored-by: Swyam Sharma --- backend/internal/cli/doctor.go | 2 +- backend/internal/cli/doctor_test.go | 20 +++++++-- backend/internal/ports/outbound.go | 3 ++ backend/internal/service/session/service.go | 2 + .../internal/service/session/service_test.go | 1 + backend/internal/session_manager/manager.go | 26 ++++++++--- .../internal/session_manager/manager_test.go | 43 +++++++++++++++++-- 7 files changed, 82 insertions(+), 15 deletions(-) diff --git a/backend/internal/cli/doctor.go b/backend/internal/cli/doctor.go index 96492e32a..248730d5e 100644 --- a/backend/internal/cli/doctor.go +++ b/backend/internal/cli/doctor.go @@ -307,7 +307,7 @@ func (c *commandContext) checkTerminalRuntime(ctx context.Context) doctorCheck { func (c *commandContext) checkTmux(ctx context.Context) doctorCheck { path, err := c.deps.LookPath("tmux") if err != nil || path == "" { - return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH"} + return doctorCheck{Level: doctorWarn, Section: doctorSectionTools, Name: "tmux", Message: "not found in PATH; required on macOS/Linux to start sessions"} } reqCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() diff --git a/backend/internal/cli/doctor_test.go b/backend/internal/cli/doctor_test.go index daf7c9499..7686dfb76 100644 --- a/backend/internal/cli/doctor_test.go +++ b/backend/internal/cli/doctor_test.go @@ -247,12 +247,18 @@ func TestDoctorJSONOutputIsDecodable(t *testing.T) { clearDoctorGitHubEnv(t) out, errOut, err := executeCLI(t, Deps{ LookPath: func(name string) (string, error) { - if name == "git" { + switch name { + case "git": return "/bin/git", nil + case "tmux": + return "/bin/tmux", nil } return "", errors.New("missing") }, - CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/tmux" { + return []byte("tmux 3.3a\n"), nil + } return []byte("git version 2.43.0\n"), nil }, ProcessAlive: func(int) bool { return false }, @@ -277,12 +283,18 @@ func TestDoctorTextOutputIsGrouped(t *testing.T) { clearDoctorGitHubEnv(t) out, errOut, err := executeCLI(t, Deps{ LookPath: func(name string) (string, error) { - if name == "git" { + switch name { + case "git": return "/bin/git", nil + case "tmux": + return "/bin/tmux", nil } return "", errors.New("missing") }, - CommandOutput: func(context.Context, string, ...string) ([]byte, error) { + CommandOutput: func(_ context.Context, name string, _ ...string) ([]byte, error) { + if name == "/bin/tmux" { + return []byte("tmux 3.3a\n"), nil + } return []byte("git version 2.43.0\n"), nil }, ProcessAlive: func(int) bool { return false }, diff --git a/backend/internal/ports/outbound.go b/backend/internal/ports/outbound.go index 359cd5f8c..f88e1b783 100644 --- a/backend/internal/ports/outbound.go +++ b/backend/internal/ports/outbound.go @@ -164,6 +164,9 @@ var ( // conflict markers for manual resolution. Adapters wrap this sentinel via // fmt.Errorf so callers can match it with errors.Is. ErrPreservedConflict = errors.New("workspace: preserved apply produced conflicts") + // ErrRuntimePrerequisite reports a missing host prerequisite for the selected + // runtime before a session can be created. + ErrRuntimePrerequisite = errors.New("runtime: prerequisite missing") ) // WorkspaceConfig is the spec for creating or restoring a session's workspace. diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 740c9e43c..32d338897 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -481,6 +481,8 @@ func toAPIError(err error) error { return apierr.Invalid("INVALID_BRANCH", err.Error(), nil) case errors.Is(err, ports.ErrAgentBinaryNotFound): return apierr.Invalid("AGENT_BINARY_NOT_FOUND", err.Error(), nil) + case errors.Is(err, ports.ErrRuntimePrerequisite): + return apierr.Invalid("RUNTIME_PREREQUISITE_MISSING", err.Error(), nil) default: return err } diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 405e17964..c5b1b1ca6 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -534,6 +534,7 @@ func TestToAPIErrorMapsWorkspaceBranchSentinels(t *testing.T) { {"not fetched", fmt.Errorf("spawn mer-1: workspace: %w: \"x\" has no local head", ports.ErrWorkspaceBranchNotFetched), apierr.KindInvalid, "BRANCH_NOT_FETCHED"}, {"invalid branch", fmt.Errorf("spawn mer-1: workspace: %w: \"bad!!\" (exit 1)", ports.ErrWorkspaceBranchInvalid), apierr.KindInvalid, "INVALID_BRANCH"}, {"agent binary not found", fmt.Errorf("spawn mer-1: %w", ports.ErrAgentBinaryNotFound), apierr.KindInvalid, "AGENT_BINARY_NOT_FOUND"}, + {"runtime prerequisite missing", fmt.Errorf("spawn: %w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite), apierr.KindInvalid, "RUNTIME_PREREQUISITE_MISSING"}, {"unknown harness", fmt.Errorf("spawn: %w: %q", sessionmanager.ErrUnknownHarness, "bogus"), apierr.KindInvalid, "UNKNOWN_HARNESS"}, {"missing harness", fmt.Errorf("spawn: %w: configure project worker.agent or pass --harness", sessionmanager.ErrMissingHarness), apierr.KindInvalid, "AGENT_REQUIRED"}, } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 48d93fe26..a4efb7eb3 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -202,6 +202,10 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess return domain.SessionRecord{}, fmt.Errorf("spawn: %w: %q", ErrUnknownHarness, cfg.Harness) } + if err := m.validateRuntimePrerequisites(); err != nil { + return domain.SessionRecord{}, fmt.Errorf("spawn: %w", err) + } + prompt, systemPrompt, err := m.buildSpawnTexts(ctx, cfg) if err != nil { return domain.SessionRecord{}, fmt.Errorf("spawn: prompt: %w", err) @@ -237,19 +241,19 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // post-create commands (e.g. `pnpm install`) before the agent launches. if err := m.provisionWorkspace(ctx, project, ws.Path); err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: provision: %w", id, err) } agent, ok := m.agents.Agent(cfg.Harness) if !ok { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) } if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } agentConfig := effectiveAgentConfig(cfg.Kind, project.Config) @@ -264,7 +268,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess }) if err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: launch command: %w", id, err) } // Pre-flight: confirm argv[0] actually exists on PATH (or as an absolute @@ -273,7 +277,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess // unresolved binary would leak through as a "live" session that never ran. if err := m.validateAgentBinary(argv); err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) } handle, err := m.runtime.Create(ctx, ports.RuntimeConfig{ @@ -284,7 +288,7 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess }) if err != nil { _ = m.workspace.Destroy(ctx, ws) - m.markSpawnFailedTerminated(ctx, id) + m.rollbackSpawnSeedRow(ctx, id) return domain.SessionRecord{}, fmt.Errorf("spawn %s: runtime: %w", id, err) } @@ -1281,6 +1285,16 @@ func (m *Manager) validateAgentBinary(argv []string) error { return nil } +func (m *Manager) validateRuntimePrerequisites() error { + if runtime.GOOS == "windows" { + return nil + } + if path, err := m.lookPath("tmux"); err != nil || path == "" { + return fmt.Errorf("%w: tmux required on macOS/Linux but not in PATH", ports.ErrRuntimePrerequisite) + } + return nil +} + func runtimeHandle(meta domain.SessionMetadata) ports.RuntimeHandle { return ports.RuntimeHandle{ID: meta.RuntimeHandleID} } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 82ff2c491..d12943c37 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -465,8 +466,8 @@ func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) { if ws.destroyed != 1 { t.Fatal("workspace should roll back") } - if !st.sessions["mer-1"].IsTerminated { - t.Fatal("orphaned spawn should be terminated") + if rec, present := st.sessions["mer-1"]; present { + t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec) } } @@ -1098,6 +1099,9 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) { rt := &fakeRuntime{} ws := &fakeWorkspace{} notFound := func(name string) (string, error) { + if name == "tmux" { + return "/bin/tmux", nil + } return "", fmt.Errorf("exec: %q: not found", name) } m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: notFound}) @@ -1112,8 +1116,39 @@ func TestSpawn_RejectsMissingAgentBinary(t *testing.T) { if ws.destroyed != 1 { t.Fatal("workspace must be torn down when the pre-launch binary check fails") } - if !st.sessions["mer-1"].IsTerminated { - t.Fatal("the orphan row should be marked terminated after the failed spawn") + if rec, present := st.sessions["mer-1"]; present { + t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec) + } +} + +func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows uses ConPTY, not tmux") + } + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{} + ws := &fakeWorkspace{} + lookPath := func(name string) (string, error) { + if name == "tmux" { + return "", fmt.Errorf("exec: %q: not found", name) + } + return "/bin/true", nil + } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath}) + + _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}) + if !errors.Is(err, ports.ErrRuntimePrerequisite) || !strings.Contains(err.Error(), "tmux required") { + t.Fatalf("err = %v, want missing tmux prerequisite", err) + } + if len(st.sessions) != 0 { + t.Fatalf("no session row should be created before runtime prerequisites pass, got %d", len(st.sessions)) + } + if ws.lastCfg.SessionID != "" || ws.destroyed != 0 { + t.Fatal("workspace must not be created when tmux is missing") + } + if rt.created != 0 { + t.Fatal("runtime must not be created when tmux is missing") } } From de9e90df45fa3522ab9d4e542392199cdda9f7b4 Mon Sep 17 00:00:00 2001 From: Dushyant Singh Hada Date: Tue, 30 Jun 2026 11:23:54 +0530 Subject: [PATCH 13/43] fix: resolve duplicate migration version 20 collision (#2294) Two PRs (#2193, #2200) merged ~2h apart each added a migration file numbered 0020, since each branch only saw one such file at CI time and neither was rebased against the other before merge. Renumbers 0020_pr_reviews.sql to 0021_pr_reviews.sql. TestMigrationVersionsAreUnique already existed and is correct; it didn't catch this because it only runs against each PR's branch state, not the merged result of both PRs together. --- .../migrations/{0020_pr_reviews.sql => 0021_pr_reviews.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/internal/storage/sqlite/migrations/{0020_pr_reviews.sql => 0021_pr_reviews.sql} (100%) diff --git a/backend/internal/storage/sqlite/migrations/0020_pr_reviews.sql b/backend/internal/storage/sqlite/migrations/0021_pr_reviews.sql similarity index 100% rename from backend/internal/storage/sqlite/migrations/0020_pr_reviews.sql rename to backend/internal/storage/sqlite/migrations/0021_pr_reviews.sql From 194bb28c8ca4c7420ad626ba2efa8199e29b455c Mon Sep 17 00:00:00 2001 From: NIKHIL ACHALE <96230495+nikhilachale@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:27:50 +0530 Subject: [PATCH 14/43] feat(icon): apply runtime app icon on macOS (#2296) --- frontend/assets/icon.png | Bin 243716 -> 229248 bytes frontend/src/main.ts | 12 ++++++++++++ 2 files changed, 12 insertions(+) diff --git a/frontend/assets/icon.png b/frontend/assets/icon.png index 7c65c07868595e28b0e494b786172bb3c49faa6a..59b25614acde64df349e4043655331008e507b6a 100644 GIT binary patch literal 229248 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 &mA`|EeZDYttXM$$_Yb54C=l{rhQz?ikW; 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 z

fZ-ZRyWToTHvVE4KYX`uD#6tQT=oB=HWQYss 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 DH~vsP literal 243716 zcmV)PK()V#P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw0000) zWmrjOO-%qQ0000800D<-00aO40096102%-Q00003paB2_0000100961paK8{00001 z00062paTE|0000100062000003;4B#002M$NklC>u48w*d#2}({loA7 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 rq*sacW{RP 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 U4%9CfdH?_b07*qoM6N<$f`FiJCIA2c diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8e88420a2..c408e41c9 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,6 +5,7 @@ import { dialog, ipcMain, net, + nativeImage, Notification as ElectronNotification, protocol, shell, @@ -156,6 +157,16 @@ function windowIconPath(): string | undefined { return existsSync(candidate) ? candidate : undefined; } +function applyRuntimeAppIcon(): void { + if (process.platform !== "darwin") return; + const iconPath = windowIconPath(); + if (!iconPath) return; + const icon = nativeImage.createFromPath(iconPath); + if (!icon.isEmpty()) { + app.dock.setIcon(icon); + } +} + function setDaemonStatus(nextStatus: DaemonStatus): void { daemonStatus = nextStatus; mainWindow?.webContents.send("daemon:status", daemonStatus); @@ -952,6 +963,7 @@ app.whenReady().then(async () => { } registerRendererProtocol(); + applyRuntimeAppIcon(); createWindow(); void startDaemon(); initAutoUpdates(); From 7c4a77d7cc2278bab6f080e097ce628673d3fa23 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Tue, 30 Jun 2026 16:26:48 +0530 Subject: [PATCH 15/43] feat(spawn): add required --name flag for sidebar display name (#2302) * feat(spawn): add required --name flag for sidebar display name Co-Authored-By: Claude Opus 4.8 * feat(spawn): require --name for sidebar label; cap at 20 chars Add a required --name flag to `ao spawn` that sets the session's sidebar display name. The CLI rejects a missing or >20-character name before contacting the daemon. The daemon's POST /sessions keeps displayName optional (the desktop new-task dialog omits it and the read model falls back to the session id) but enforces the same 20-character cap when present, so a direct API call cannot exceed it. The value flows CLI -> SpawnSessionRequest -> SpawnConfig -> session record, and the existing read-model fallback (displayName ?? issueId ?? id) renders it in the sidebar unchanged. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- backend/internal/cli/dto_drift_e2e_test.go | 4 ++ backend/internal/cli/spawn.go | 37 ++++++++++++++----- backend/internal/cli/spawn_test.go | 24 ++++++++++-- backend/internal/httpd/apispec/openapi.yaml | 3 ++ backend/internal/httpd/controllers/dto.go | 4 ++ .../internal/httpd/controllers/sessions.go | 17 +++++++-- .../httpd/controllers/sessions_test.go | 19 +++++++++- backend/internal/ports/session.go | 3 ++ backend/internal/session_manager/manager.go | 15 ++++---- frontend/src/api/schema.ts | 1 + 10 files changed, 102 insertions(+), 25 deletions(-) diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 5c7e924ac..743a398d7 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -184,6 +184,7 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { "--branch", "feat/x", "--prompt", "hi", "--issue", "ISS-1", + "--name", "my worker", }) if err := root.Execute(); err != nil { t.Fatalf("spawn execute: %v\noutput: %s", err, out.String()) @@ -205,6 +206,9 @@ func TestE2E_SpawnAndProjectAddDTORoundTrip(t *testing.T) { if got.IssueID != "ISS-1" { t.Errorf("IssueID = %q, want %q", got.IssueID, "ISS-1") } + if got.DisplayName != "my worker" { + t.Errorf("DisplayName = %q, want %q (CLI json:\"displayName\" vs SpawnSessionRequest)", got.DisplayName, "my worker") + } if !bytes.Contains(out.Bytes(), []byte("spawned session")) { t.Errorf("output missing %q; got: %s", "spawned session", out.String()) } diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index d19d540d0..920b2b363 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -5,6 +5,8 @@ import ( "fmt" "net/url" "runtime" + "strings" + "unicode/utf8" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -12,12 +14,17 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux" ) +// maxDisplayNameLen caps the sidebar label set by `--name`. Mirrored by the +// daemon's spawn handler so a direct API call is held to the same limit. +const maxDisplayNameLen = 20 + type spawnOptions struct { project string harness string branch string prompt string issue string + name string claimPR string noTakeover bool } @@ -25,11 +32,12 @@ type spawnOptions struct { // spawnRequest mirrors the daemon's SpawnSessionRequest body for // POST /api/v1/sessions. The CLI keeps its own copy so it need not import httpd. type spawnRequest struct { - ProjectID string `json:"projectId"` - IssueID string `json:"issueId,omitempty"` - Harness string `json:"harness,omitempty"` - Branch string `json:"branch,omitempty"` - Prompt string `json:"prompt,omitempty"` + ProjectID string `json:"projectId"` + IssueID string `json:"issueId,omitempty"` + Harness string `json:"harness,omitempty"` + Branch string `json:"branch,omitempty"` + Prompt string `json:"prompt,omitempty"` + DisplayName string `json:"displayName"` } type spawnResult struct { @@ -52,6 +60,13 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { if opts.project == "" { return usageError{fmt.Errorf("--project is required")} } + name := strings.TrimSpace(opts.name) + if name == "" { + return usageError{fmt.Errorf("--name is required")} + } + if utf8.RuneCountInString(name) > maxDisplayNameLen { + return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)} + } if opts.noTakeover && opts.claimPR == "" { return usageError{fmt.Errorf("--no-takeover requires --claim-pr")} } @@ -67,11 +82,12 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } } req := spawnRequest{ - ProjectID: opts.project, - IssueID: opts.issue, - Harness: opts.harness, - Branch: opts.branch, - Prompt: opts.prompt, + ProjectID: opts.project, + IssueID: opts.issue, + Harness: opts.harness, + Branch: opts.branch, + Prompt: opts.prompt, + DisplayName: name, } var res spawnResult if err := ctx.postJSON(cmd.Context(), "sessions", req, &res); err != nil { @@ -125,6 +141,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao//root)") f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") + f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)") f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session") f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)") return cmd diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index 2abfcfaa4..68bf78b44 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -66,7 +66,7 @@ func TestSpawnClaimPRWiring(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142", "--no-takeover") + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover") if err != nil { t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) } @@ -108,7 +108,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--claim-pr", "142") + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142") if err == nil { t.Fatal("expected spawn claim failure") } @@ -126,8 +126,26 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { } func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) { - _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--no-takeover") + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", "worker", "--no-takeover") if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--no-takeover requires --claim-pr") { t.Fatalf("err=%v exit=%d", err, ExitCode(err)) } } + +// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name +// before touching the network, mirroring the --project guard. +func TestSpawnCommand_RequiresName(t *testing.T) { + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo") + if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") { + t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err)) + } +} + +// TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name +// longer than 20 characters without contacting the daemon. +func TestSpawnCommand_RejectsOverlongName(t *testing.T) { + _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo", "--name", strings.Repeat("x", 21)) + if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "20 characters or fewer") { + t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err)) + } +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 738a9dcef..c194ffa0b 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -2464,6 +2464,9 @@ components: properties: branch: type: string + displayName: + maxLength: 20 + type: string harness: enum: - claude-code diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index fca0790d2..ab859ef7a 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -146,6 +146,10 @@ type SpawnSessionRequest struct { Harness domain.AgentHarness `json:"harness,omitempty" enum:"claude-code,codex,aider,opencode,grok,droid,amp,agy,crush,cursor,qwen,copilot,goose,auggie,continue,devin,cline,kimi,kiro,kilocode,vibe,pi,autohand"` Branch string `json:"branch,omitempty"` Prompt string `json:"prompt,omitempty" maxLength:"4096"` + // DisplayName is the sidebar label for the session, capped at 20 characters. + // `ao spawn --name` always sets it; other clients (e.g. the desktop new-task + // dialog) may omit it and fall back to the session id in the read model. + DisplayName string `json:"displayName,omitempty" maxLength:"20"` } // SessionResponse is the { session } body shared by session create/get. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index d10e72d4b..348f65ff1 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "unicode/utf8" "github.com/go-chi/chi/v5" @@ -22,8 +23,9 @@ import ( ) const ( - maxPromptLen = 4096 - maxMessageLen = 4096 + maxPromptLen = 4096 + maxMessageLen = 4096 + maxDisplayNameLen = 20 ) var errPreviewFileNotFound = errors.New("preview file not found") @@ -120,10 +122,19 @@ func (c *SessionsController) spawn(w http.ResponseWriter, r *http.Request) { envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "PROMPT_TOO_LONG", "prompt is too long", nil) return } + // displayName is optional at the API (the desktop new-task dialog omits it + // and the read model falls back to the session id). `ao spawn` makes it + // required CLI-side. When present, it is held to the same length cap here so + // a direct API call cannot exceed it. + displayName := strings.TrimSpace(in.DisplayName) + if utf8.RuneCountInString(displayName) > maxDisplayNameLen { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "DISPLAY_NAME_TOO_LONG", "displayName must be 20 characters or fewer", nil) + return + } if in.Kind == "" { in.Kind = domain.KindWorker } - sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt}) + sess, err := c.Svc.Spawn(r.Context(), ports.SpawnConfig{ProjectID: in.ProjectID, IssueID: in.IssueID, Kind: in.Kind, Harness: in.Harness, Branch: in.Branch, Prompt: in.Prompt, DisplayName: displayName}) if err != nil { envelope.WriteError(w, r, err) return diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index ab5455245..ae0fe53da 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -61,7 +61,7 @@ func (f *fakeSessionService) Spawn(_ context.Context, cfg ports.SpawnConfig) (do return domain.Session{}, f.spawnErr } now := time.Now().UTC() - s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} + s := domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-2"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind, Harness: cfg.Harness, DisplayName: cfg.DisplayName, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} f.sessions[s.ID] = s return s, nil } @@ -269,7 +269,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { t.Fatalf("list leaked prompt: %s", body) } - body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`) + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix","displayName":"my worker"}`) if status != http.StatusCreated { t.Fatalf("POST session = %d, want 201; body=%s", status, body) } @@ -280,6 +280,9 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { if spawned.Session.ID != "ao-2" || spawned.Session.IssueID != "ISS-1" || spawned.Session.Harness != "codex" { t.Fatalf("spawned = %#v", spawned) } + if spawned.Session.DisplayName != "my worker" { + t.Fatalf("spawned displayName = %q, want %q", spawned.Session.DisplayName, "my worker") + } body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions/ao-2", "") if status != http.StatusOK { @@ -679,6 +682,18 @@ func TestSessionsAPI_SpawnBranchNotFetchedReturnsTypedError(t *testing.T) { assertErrorCode(t, body, status, http.StatusBadRequest, "BRANCH_NOT_FETCHED") } +// TestSessionsAPI_SpawnRejectsOverlongDisplayName asserts the spawn endpoint +// caps displayName at 20 characters even though the field itself is optional +// (the desktop new-task dialog omits it). `ao spawn` enforces the same limit +// CLI-side before the request is sent. +func TestSessionsAPI_SpawnRejectsOverlongDisplayName(t *testing.T) { + srv := newSessionTestServer(t, newFakeSessionService()) + + overlong := strings.Repeat("x", 21) + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","harness":"codex","displayName":"`+overlong+`"}`) + assertErrorCode(t, body, status, http.StatusBadRequest, "DISPLAY_NAME_TOO_LONG") +} + func TestSessionsAPI_RenameNotFound(t *testing.T) { srv := newSessionTestServer(t, newFakeSessionService()) diff --git a/backend/internal/ports/session.go b/backend/internal/ports/session.go index 0c28f1792..035ad249e 100644 --- a/backend/internal/ports/session.go +++ b/backend/internal/ports/session.go @@ -18,4 +18,7 @@ type SpawnConfig struct { Harness domain.AgentHarness Branch string Prompt string + // DisplayName is the user-facing sidebar label. Empty falls back to the + // session id in the read model (e.g. orchestrator sessions). + DisplayName string } diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index a4efb7eb3..9eee7a573 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -922,13 +922,14 @@ func (m *Manager) cleanupRecords(ctx context.Context, project domain.ProjectID) func seedRecord(cfg ports.SpawnConfig, now time.Time) domain.SessionRecord { return domain.SessionRecord{ - ProjectID: cfg.ProjectID, - IssueID: cfg.IssueID, - Kind: cfg.Kind, - CreatedAt: now, - UpdatedAt: now, - Harness: cfg.Harness, - Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, + ProjectID: cfg.ProjectID, + IssueID: cfg.IssueID, + Kind: cfg.Kind, + CreatedAt: now, + UpdatedAt: now, + Harness: cfg.Harness, + DisplayName: cfg.DisplayName, + Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, } } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8a3b89818..9ee643dad 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -880,6 +880,7 @@ export interface components { }; SpawnSessionRequest: { branch?: string; + displayName?: string; /** @enum {string} */ harness?: "claude-code" | "codex" | "aider" | "opencode" | "grok" | "droid" | "amp" | "agy" | "crush" | "cursor" | "qwen" | "copilot" | "goose" | "auggie" | "continue" | "devin" | "cline" | "kimi" | "kiro" | "kilocode" | "vibe" | "pi" | "autohand"; issueId?: string; From 78cbe229285666c4956b084850a5e8b6d79eec15 Mon Sep 17 00:00:00 2001 From: Apoorv Singh <68725597+aprv10@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:36:57 +0530 Subject: [PATCH 16/43] fix(new-task): use project default agent and expose all agents in spawn dialog (#2291) --- .../components/NewTaskDialog.test.tsx | 76 ++++++++++++++----- .../src/renderer/components/NewTaskDialog.tsx | 67 +++++++++------- 2 files changed, 100 insertions(+), 43 deletions(-) diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx index 456a2919e..d7e480f5a 100644 --- a/frontend/src/renderer/components/NewTaskDialog.test.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -1,15 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NewTaskDialog } from "./NewTaskDialog"; -const { postMock } = vi.hoisted(() => ({ +const { getMock, postMock } = vi.hoisted(() => ({ + getMock: vi.fn(), postMock: vi.fn(), })); vi.mock("../lib/api-client", () => ({ apiClient: { - POST: postMock, + GET: (...args: unknown[]) => getMock(...args), + POST: (...args: unknown[]) => postMock(...args), }, apiErrorMessage: (error: unknown, fallback = "Request failed") => { if (typeof error === "object" && error !== null && "message" in error) { @@ -19,27 +22,48 @@ vi.mock("../lib/api-client", () => ({ }, })); +function renderDialog() { + const onCreated = vi.fn(); + const onOpenChange = vi.fn(); + render( + + + , + ); + return { onCreated, onOpenChange }; +} + +function spawnBody() { + return (postMock.mock.calls[0][1] as { body: Record }).body; +} + beforeEach(() => { - postMock.mockReset(); - postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); + getMock.mockReset().mockResolvedValue({ + data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, + error: undefined, + }); + postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); }); -describe("NewTaskDialog", () => { - it("starts a worker task with the entered title and brief", async () => { - const onCreated = vi.fn(); - const onOpenChange = vi.fn(); - render(); +afterEach(() => vi.restoreAllMocks()); - await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer"); - await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); - await userEvent.click(screen.getByRole("button", { name: "Start task" })); +describe("NewTaskDialog", () => { + it("preselects the project's default agent and omits harness so the daemon applies it", async () => { + const { onCreated, onOpenChange } = renderDialog(); + const user = userEvent.setup(); + + await screen.findByText("claude-code"); + + await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); + await user.click(screen.getByRole("button", { name: "Start task" })); await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", { body: { projectId: "proj-1", kind: "worker", - harness: "codex", + harness: undefined, issueId: "Fix fallback renderer", prompt: "Restore the fallback renderer after WebGL init fails.", branch: undefined, @@ -49,10 +73,28 @@ describe("NewTaskDialog", () => { expect(onOpenChange).toHaveBeenCalledWith(false); }); - it("requires both title and brief", async () => { - render(); + it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => { + renderDialog(); + const user = userEvent.setup(); + await screen.findByText("claude-code"); - await userEvent.click(screen.getByRole("button", { name: "Start task" })); + await user.type(screen.getByLabelText("Title"), "T"); + await user.type(screen.getByLabelText("Brief"), "B"); + + await user.click(screen.getByRole("combobox", { name: "Agent" })); + await user.click(await screen.findByRole("option", { name: "cursor" })); + + await user.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(spawnBody().harness).toBe("cursor"); + }); + + it("requires both title and brief", async () => { + renderDialog(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Start task" })); expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument(); expect(postMock).not.toHaveBeenCalled(); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index 385eb4076..cef37d5b8 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -1,12 +1,16 @@ import * as Dialog from "@radix-ui/react-dialog"; +import { useQuery } from "@tanstack/react-query"; import { Loader2, X } from "lucide-react"; import { type FormEvent, useEffect, useId, useState } from "react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { RequiredAgentField } from "./CreateProjectAgentSheet"; +import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import type { AgentProvider } from "../types/workspace"; +type Project = components["schemas"]["Project"]; + type NewTaskDialogProps = { open: boolean; projectId?: string; @@ -14,35 +18,51 @@ type NewTaskDialogProps = { onOpenChange: (open: boolean) => void; }; -const AGENTS: Array<{ value: AgentProvider; label: string }> = [ - { value: "codex", label: "Codex" }, - { value: "claude-code", label: "Claude Code" }, - { value: "opencode", label: "OpenCode" }, - { value: "aider", label: "Aider" }, -]; - export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) { const titleId = useId(); const promptId = useId(); const branchId = useId(); + const agentId = useId(); const [title, setTitle] = useState(""); const [prompt, setPrompt] = useState(""); const [branch, setBranch] = useState(""); - const [agent, setAgent] = useState("codex"); + const [agent, setAgent] = useState(""); + const [agentTouched, setAgentTouched] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(); + const projectQuery = useQuery({ + queryKey: ["project", projectId], + enabled: open && Boolean(projectId), + queryFn: async () => { + const { data, error: apiError } = await apiClient.GET("/api/v1/projects/{id}", { + params: { path: { id: projectId as string } }, + }); + if (apiError) throw new Error(apiErrorMessage(apiError)); + if (data?.status !== "ok") throw new Error("Project config is unavailable."); + return data.project as Project; + }, + }); + const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? ""; + useEffect(() => { if (!open) { setTitle(""); setPrompt(""); setBranch(""); - setAgent("codex"); + setAgent(""); + setAgentTouched(false); setError(undefined); setIsSubmitting(false); } }, [open]); + useEffect(() => { + if (open && !agentTouched) { + setAgent(defaultWorkerAgent); + } + }, [open, agentTouched, defaultWorkerAgent]); + const submit = async (event: FormEvent) => { event.preventDefault(); if (!projectId || isSubmitting) return; @@ -62,7 +82,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT body: { projectId, kind: "worker", - harness: agent, + harness: agentTouched && agent ? (agent as AgentProvider) : undefined, issueId: cleanTitle, prompt: cleanPrompt, branch: cleanBranch || undefined, @@ -130,21 +150,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT

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�_O 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%0AL3fjOsqe8Y2D(~Ik~%our)1v8kzYH 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

-
- - -
+ { + setAgent(value); + setAgentTouched(true); + }} + />
); -}); +}); \ No newline at end of file diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 9ecb1cbaa..26776054d 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -170,7 +170,7 @@ describe("ProjectSettingsForm", () => { expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); }); - it("requires worker and orchestrator agents for existing projects missing role config", async () => { + it("defaults worker and orchestrator to claude-code for projects missing role config", async () => { getMock.mockResolvedValue({ data: { status: "ok", @@ -189,15 +189,25 @@ describe("ProjectSettingsForm", () => { renderSettings(); - expect(await screen.findByText("Worker and orchestrator agents are required.")).toBeInTheDocument(); - expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Select worker agent"); - expect(screen.getByRole("combobox", { name: "Default orchestrator agent" })).toHaveTextContent( - "Select orchestrator agent", - ); + const workerAgent = await screen.findByRole("combobox", { name: "Default worker agent" }); + const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" }); + expect(workerAgent).toHaveTextContent("claude-code"); + expect(orchestratorAgent).toHaveTextContent("claude-code"); + expect(screen.queryByText("Worker and orchestrator agents are required.")).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); - expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2); - expect(putMock).not.toHaveBeenCalled(); + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + expect(putMock).toHaveBeenCalledWith("/api/v1/projects/{id}/config", { + params: { path: { id: "proj-1" } }, + body: { + config: { + defaultBranch: "main", + worker: { agent: "claude-code" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }); + expect(await screen.findByText("Saved.")).toBeInTheDocument(); }); -}); +}); \ No newline at end of file diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 9b746b7cb..ea51b7fd4 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; @@ -69,15 +70,13 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", sessionPrefix: config.sessionPrefix ?? "", - workerAgent: config.worker?.agent ?? "", - orchestratorAgent: config.orchestrator?.agent ?? "", + workerAgent: config.worker?.agent || DEFAULT_PROJECT_AGENT, + orchestratorAgent: config.orchestrator?.agent || DEFAULT_PROJECT_AGENT, model: config.agentConfig?.model ?? "", permissions: config.agentConfig?.permissions ?? "", reviewerHarness: config.reviewers?.[0]?.harness ?? "", }); const [savedAt, setSavedAt] = useState(null); - const [validationError, setValidationError] = useState(null); - const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; const mutation = useMutation({ mutationFn: async () => { @@ -104,7 +103,6 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje }, onSuccess: () => { setSavedAt(Date.now()); - setValidationError(null); void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); onSaved(); }, @@ -116,11 +114,6 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje onSubmit={(event) => { event.preventDefault(); setSavedAt(null); - if (missingRequiredAgent) { - setValidationError("Worker and orchestrator agents are required."); - return; - } - setValidationError(null); mutation.mutate(); }} > @@ -171,7 +164,6 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje value={form.workerAgent} placeholder="Select worker agent" label="Default worker agent" - invalid={validationError !== null && form.workerAgent === ""} onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))} /> setForm((f) => ({ ...f, orchestratorAgent: v }))} /> - {missingRequiredAgent && ( -

Worker and orchestrator agents are required.

- )} {mutation.isPending ? "Saving…" : "Save changes"} - {validationError && {validationError}} {mutation.isError && ( {mutation.error instanceof Error ? mutation.error.message : "Save failed"} @@ -313,4 +300,4 @@ function CenteredNote({ children }: { children: React.ReactNode }) { // rather than an empty {} the daemon would persist. function blankToUndefined(obj: T): T | undefined { return Object.values(obj).some((v) => v !== undefined) ? obj : undefined; -} +} \ No newline at end of file diff --git a/frontend/src/renderer/lib/agent-options.ts b/frontend/src/renderer/lib/agent-options.ts index d26c9a4c0..7cdc56eee 100644 --- a/frontend/src/renderer/lib/agent-options.ts +++ b/frontend/src/renderer/lib/agent-options.ts @@ -23,3 +23,7 @@ export const AGENT_OPTIONS = [ "pi", "autohand", ] as const; + +// The agent new projects use by default, and the fallback for worker/orchestrator +// role fields that have no explicit configuration. Users can change it per project. +export const DEFAULT_PROJECT_AGENT: (typeof AGENT_OPTIONS)[number] = "claude-code"; \ No newline at end of file From e74fb65a853f64bc4c8bfec2720cc078b572a3a5 Mon Sep 17 00:00:00 2001 From: Anirudh Sharma <78658727+anirudh5harma@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:25:18 +0530 Subject: [PATCH 23/43] feat(review): support additional reviewer harnesses (#2306) * feat(review): support more reviewer harnesses * fix(review): preserve reviewer daemon context * fix(review): allow printf-piped review commands in reviewer allowlists The review prompt now pipes JSON into `gh api` and `ao review submit` via `printf '%s' ... | cmd` (heredocs break in interactive PTY panes). Widen the claude-code allowlist with `Bash(printf:*)` and the opencode permission policy with `printf * | gh api *` / `printf * | ao review submit *` so those piped commands pass each tool's allowlist. Cover both with tests that model each tool's matching semantics. Co-Authored-By: Claude Opus 4.8 * chore: format with prettier [skip ci] --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: github-actions[bot] --- .../reviewer/claudecode/claudecode.go | 6 +- .../reviewer/claudecode/claudecode_test.go | 70 ++++++++ .../internal/adapters/reviewer/codex/codex.go | 78 +++++++++ .../adapters/reviewer/codex/codex_test.go | 77 +++++++++ .../adapters/reviewer/opencode/opencode.go | 56 +++++++ .../reviewer/opencode/opencode_test.go | 156 ++++++++++++++++++ .../internal/adapters/reviewer/registry.go | 7 +- backend/internal/domain/projectconfig_test.go | 13 +- backend/internal/domain/reviewerharness.go | 6 +- backend/internal/review/prompt.go | 14 +- backend/internal/review/prompt_test.go | 2 + .../components/CreateProjectAgentSheet.tsx | 2 +- .../components/ProjectSettingsForm.test.tsx | 31 +++- .../components/ProjectSettingsForm.tsx | 4 +- frontend/src/renderer/lib/agent-options.ts | 2 +- 15 files changed, 501 insertions(+), 23 deletions(-) create mode 100644 backend/internal/adapters/reviewer/codex/codex.go create mode 100644 backend/internal/adapters/reviewer/codex/codex_test.go create mode 100644 backend/internal/adapters/reviewer/opencode/opencode.go create mode 100644 backend/internal/adapters/reviewer/opencode/opencode_test.go diff --git a/backend/internal/adapters/reviewer/claudecode/claudecode.go b/backend/internal/adapters/reviewer/claudecode/claudecode.go index e9c567905..159d67a67 100644 --- a/backend/internal/adapters/reviewer/claudecode/claudecode.go +++ b/backend/internal/adapters/reviewer/claudecode/claudecode.go @@ -37,12 +37,14 @@ var _ ports.Reviewer = (*Reviewer)(nil) // system entirely and ignores allow/deny rules — it launches in the default // mode where these rules are honored: allow rules auto-approve without // prompting, so the reviewer can read the checkout and run the few commands it -// needs (git diff/log/show to inspect the PR, gh to post the review, and -// `ao review submit` to record the verdict) without stalling. +// needs (git diff/log/show to inspect the PR, printf to pipe review JSON into +// the downstream commands without writing a worktree file, gh to post the +// review, and `ao review submit` to record the verdict) without stalling. var reviewerAllowedTools = []string{ "Read", "Grep", "Glob", + "Bash(printf:*)", "Bash(gh:*)", "Bash(git diff:*)", "Bash(git log:*)", diff --git a/backend/internal/adapters/reviewer/claudecode/claudecode_test.go b/backend/internal/adapters/reviewer/claudecode/claudecode_test.go index 53b1bb5f4..50a394837 100644 --- a/backend/internal/adapters/reviewer/claudecode/claudecode_test.go +++ b/backend/internal/adapters/reviewer/claudecode/claudecode_test.go @@ -2,6 +2,7 @@ package claudecode import ( "context" + "strings" "testing" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -61,6 +62,75 @@ func TestReviewCommandLaunchesReadOnlyOffBypass(t *testing.T) { } } +func TestAllowlistCoversPromptRequiredPipedCommands(t *testing.T) { + agent := &captureAgent{} + r := &Reviewer{agent: agent} + + if _, err := r.ReviewCommand(context.Background(), ports.ReviewInvocation{ + ReviewerID: "review-w1", + WorkspacePath: "/ws/w1", + Prompt: "review it", + SystemPrompt: "you are a reviewer", + }); err != nil { + t.Fatalf("ReviewCommand: %v", err) + } + + if !contains(agent.got.AllowedTools, "Bash(printf:*)") { + t.Fatalf("allowlist missing printf for piped review commands: %#v", agent.got.AllowedTools) + } + + for _, cmd := range []string{ + "printf '%s' '{ \"event\": \"COMMENT\", \"body\": \"x\" }' | gh api --method POST repos/o/r/pulls/1/reviews --input - --jq '.id'", + "printf '%s' '{ \"reviews\": [] }' | ao review submit --session sess-1 --reviews -", + } { + if !compoundCommandCovered(agent.got.AllowedTools, cmd) { + t.Fatalf("allowlist does not cover prompt-required command %q with tools %#v", cmd, agent.got.AllowedTools) + } + } + + disallowed := "printf x | rm -rf /" + if compoundCommandCovered(agent.got.AllowedTools, disallowed) { + t.Fatalf("allowlist unexpectedly covers disallowed command %q with tools %#v", disallowed, agent.got.AllowedTools) + } +} + +func compoundCommandCovered(allowedTools []string, cmd string) bool { + for _, segment := range splitPipedCommand(cmd) { + if !bashSegmentCovered(allowedTools, segment) { + return false + } + } + return true +} + +func splitPipedCommand(cmd string) []string { + rawSegments := strings.Split(cmd, "|") + segments := make([]string, 0, len(rawSegments)) + for _, segment := range rawSegments { + if trimmed := strings.TrimSpace(segment); trimmed != "" { + segments = append(segments, trimmed) + } + } + return segments +} + +func bashSegmentCovered(allowedTools []string, segment string) bool { + for _, tool := range allowedTools { + cmd, ok := strings.CutPrefix(tool, "Bash(") + if !ok { + continue + } + cmd, ok = strings.CutSuffix(cmd, ":*)") + if !ok { + continue + } + if strings.HasPrefix(segment, cmd) { + return true + } + } + return false +} + func contains(values []string, needle string) bool { for _, v := range values { if v == needle { diff --git a/backend/internal/adapters/reviewer/codex/codex.go b/backend/internal/adapters/reviewer/codex/codex.go new file mode 100644 index 000000000..a166b78c7 --- /dev/null +++ b/backend/internal/adapters/reviewer/codex/codex.go @@ -0,0 +1,78 @@ +// Package codex adapts the codex worker agent for code-review sessions. +package codex + +import ( + "context" + "encoding/json" + "fmt" + "os" + + workeragent "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Reviewer is the codex code-review adapter. +type Reviewer struct { + agent ports.Agent +} + +// New builds the codex reviewer adapter. +func New() *Reviewer { + return &Reviewer{agent: workeragent.New()} +} + +// Harness identifies this reviewer in the reviewer registry. +func (r *Reviewer) Harness() domain.ReviewerHarness { + return domain.ReviewerCodex +} + +var _ ports.Reviewer = (*Reviewer)(nil) + +// ReviewCommand launches the reviewer with an enforced read-only filesystem +// sandbox. Auto approval lets the headless session request the narrowly needed +// network access for posting the review and reporting its result. +func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { + argv, err := r.agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: inv.ReviewerID, + WorkspacePath: inv.WorkspacePath, + Prompt: inv.Prompt, + SystemPrompt: inv.SystemPrompt, + Permissions: ports.PermissionModeAuto, + }) + if err != nil { + return ports.ReviewCommandSpec{}, err + } + extra := []string{"--sandbox", "read-only"} + // Shell commands inherit only Codex's core environment by default. Preserve + // the AO location overrides the reviewer needs to submit to this daemon. + for _, name := range []string{"AO_PORT", "AO_DATA_DIR", "AO_RUN_FILE"} { + value := os.Getenv(name) + if value == "" { + continue + } + encoded, err := json.Marshal(value) + if err != nil { + return ports.ReviewCommandSpec{}, fmt.Errorf("encode %s: %w", name, err) + } + extra = append(extra, "-c", "shell_environment_policy.set."+name+"="+string(encoded)) + } + return ports.ReviewCommandSpec{Argv: insertBeforePrompt(argv, extra...)}, nil +} + +// ReviewMessage returns the centrally-authored task for an existing pane. +func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) { + return inv.Prompt, nil +} + +func insertBeforePrompt(argv []string, extra ...string) []string { + for i, arg := range argv { + if arg == "--" { + out := make([]string, 0, len(argv)+len(extra)) + out = append(out, argv[:i]...) + out = append(out, extra...) + return append(out, argv[i:]...) + } + } + return append(argv, extra...) +} diff --git a/backend/internal/adapters/reviewer/codex/codex_test.go b/backend/internal/adapters/reviewer/codex/codex_test.go new file mode 100644 index 000000000..9912c6c24 --- /dev/null +++ b/backend/internal/adapters/reviewer/codex/codex_test.go @@ -0,0 +1,77 @@ +package codex + +import ( + "context" + "slices" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type captureAgent struct { + got ports.LaunchConfig +} + +func (a *captureAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} +func (a *captureAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) { + a.got = cfg + return []string{"agent", "--", cfg.Prompt}, nil +} +func (a *captureAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} +func (a *captureAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil } +func (a *captureAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} +func (a *captureAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +func TestReviewCommandUsesReadOnlySandbox(t *testing.T) { + t.Setenv("AO_PORT", "3103") + t.Setenv("AO_DATA_DIR", "/tmp/ao data") + t.Setenv("AO_RUN_FILE", "/tmp/ao data/running.json") + agent := &captureAgent{} + r := &Reviewer{agent: agent} + + got, err := r.ReviewCommand(context.Background(), ports.ReviewInvocation{ + ReviewerID: "review-w1", + WorkspacePath: "/ws/w1", + Prompt: "review it", + SystemPrompt: "review only", + }) + if err != nil { + t.Fatalf("ReviewCommand: %v", err) + } + + want := []string{ + "agent", + "--sandbox", "read-only", + "-c", `shell_environment_policy.set.AO_PORT="3103"`, + "-c", `shell_environment_policy.set.AO_DATA_DIR="/tmp/ao data"`, + "-c", `shell_environment_policy.set.AO_RUN_FILE="/tmp/ao data/running.json"`, + "--", "review it", + } + if !slices.Equal(got.Argv, want) { + t.Fatalf("argv = %#v, want %#v", got.Argv, want) + } + if agent.got.Permissions != ports.PermissionModeAuto { + t.Fatalf("permissions = %q, want auto", agent.got.Permissions) + } + if agent.got.SystemPrompt != "review only" { + t.Fatalf("system prompt = %q", agent.got.SystemPrompt) + } +} + +func TestReviewMessageReturnsTaskPrompt(t *testing.T) { + got, err := (&Reviewer{}).ReviewMessage(context.Background(), ports.ReviewInvocation{Prompt: "next review"}) + if err != nil { + t.Fatalf("ReviewMessage: %v", err) + } + if got != "next review" { + t.Fatalf("message = %q", got) + } +} diff --git a/backend/internal/adapters/reviewer/opencode/opencode.go b/backend/internal/adapters/reviewer/opencode/opencode.go new file mode 100644 index 000000000..2ffd3b5a3 --- /dev/null +++ b/backend/internal/adapters/reviewer/opencode/opencode.go @@ -0,0 +1,56 @@ +// Package opencode adapts the opencode worker agent for code-review sessions. +package opencode + +import ( + "context" + "strings" + + workeragent "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const reviewerConfig = `{"permission":{"*":"deny","read":"allow","glob":"allow","grep":"allow","bash":{"*":"deny","gh api *":"allow","git diff*":"allow","git log*":"allow","git show*":"allow","git status*":"allow","ao review submit *":"allow","printf * | gh api *":"allow","printf * | ao review submit *":"allow"}}}` + +// Reviewer is the opencode code-review adapter. +type Reviewer struct { + agent ports.Agent +} + +// New builds the opencode reviewer adapter. +func New() *Reviewer { + return &Reviewer{agent: workeragent.New()} +} + +// Harness identifies this reviewer in the reviewer registry. +func (r *Reviewer) Harness() domain.ReviewerHarness { + return domain.ReviewerOpenCode +} + +var _ ports.Reviewer = (*Reviewer)(nil) + +// ReviewCommand launches the reviewer with an inline permission policy that +// permits inspection and the two reporting commands while denying edits and +// every other tool. The system role is folded into the initial prompt because +// the worker CLI has no separate system-prompt flag. +func (r *Reviewer) ReviewCommand(ctx context.Context, inv ports.ReviewInvocation) (ports.ReviewCommandSpec, error) { + prompt := strings.TrimSpace(inv.SystemPrompt + "\n\n" + inv.Prompt) + argv, err := r.agent.GetLaunchCommand(ctx, ports.LaunchConfig{ + SessionID: inv.ReviewerID, + WorkspacePath: inv.WorkspacePath, + Prompt: prompt, + Permissions: ports.PermissionModeAuto, + }) + if err != nil { + return ports.ReviewCommandSpec{}, err + } + return ports.ReviewCommandSpec{ + Argv: argv, + Env: map[string]string{"OPENCODE_CONFIG_CONTENT": reviewerConfig}, + }, nil +} + +// ReviewMessage returns the centrally-authored task for an existing pane. +func (r *Reviewer) ReviewMessage(_ context.Context, inv ports.ReviewInvocation) (string, error) { + return inv.Prompt, nil +} diff --git a/backend/internal/adapters/reviewer/opencode/opencode_test.go b/backend/internal/adapters/reviewer/opencode/opencode_test.go new file mode 100644 index 000000000..9086ed58a --- /dev/null +++ b/backend/internal/adapters/reviewer/opencode/opencode_test.go @@ -0,0 +1,156 @@ +package opencode + +import ( + "context" + "encoding/json" + "regexp" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type captureAgent struct { + got ports.LaunchConfig +} + +func (a *captureAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} +func (a *captureAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) { + a.got = cfg + return []string{"agent", "--prompt", cfg.Prompt}, nil +} +func (a *captureAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} +func (a *captureAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil } +func (a *captureAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} +func (a *captureAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +func TestReviewCommandUsesReadOnlyPermissionPolicy(t *testing.T) { + agent := &captureAgent{} + r := &Reviewer{agent: agent} + + got, err := r.ReviewCommand(context.Background(), ports.ReviewInvocation{ + ReviewerID: "review-w1", + WorkspacePath: "/ws/w1", + Prompt: "review it", + SystemPrompt: "review only", + }) + if err != nil { + t.Fatalf("ReviewCommand: %v", err) + } + + if agent.got.Prompt != "review only\n\nreview it" { + t.Fatalf("prompt = %q", agent.got.Prompt) + } + if agent.got.Permissions != ports.PermissionModeAuto { + t.Fatalf("permissions = %q, want auto", agent.got.Permissions) + } + config := map[string]any{} + if err := json.Unmarshal([]byte(got.Env["OPENCODE_CONFIG_CONTENT"]), &config); err != nil { + t.Fatalf("inline config is invalid JSON: %v", err) + } + permission := config["permission"].(map[string]any) + if permission["*"] != "deny" || permission["read"] != "allow" { + t.Fatalf("permission policy = %#v", permission) + } + bash := permission["bash"].(map[string]any) + if bash["*"] != "deny" || bash["gh api *"] != "allow" || bash["ao review submit *"] != "allow" { + t.Fatalf("bash policy = %#v", bash) + } +} + +func TestBashAllowlistCoversPromptRequiredCommands(t *testing.T) { + bash := reviewerConfigBashPolicy(t) + + tests := []struct { + name string + command string + allowed bool + }{ + { + name: "github review creation", + command: `printf '%s' '{ "event": "COMMENT", "body": "x" }' | gh api --method POST repos/o/r/pulls/1/reviews --input - --jq '.id'`, + allowed: true, + }, + { + name: "local review submit", + command: `printf '%s' '{ "reviews": [] }' | ao review submit --session sess-1 --reviews -`, + allowed: true, + }, + { + name: "arbitrary shell command", + command: `rm -rf /`, + allowed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := bashAllowsCommand(t, bash, tt.command); got != tt.allowed { + t.Fatalf("bashAllowsCommand(%q) = %v, want %v", tt.command, got, tt.allowed) + } + }) + } +} + +func TestReviewMessageReturnsTaskPrompt(t *testing.T) { + got, err := (&Reviewer{}).ReviewMessage(context.Background(), ports.ReviewInvocation{Prompt: "next review"}) + if err != nil { + t.Fatalf("ReviewMessage: %v", err) + } + if got != "next review" { + t.Fatalf("message = %q", got) + } +} + +func reviewerConfigBashPolicy(t *testing.T) map[string]string { + t.Helper() + + var config struct { + Permission struct { + Bash map[string]string `json:"bash"` + } `json:"permission"` + } + if err := json.Unmarshal([]byte(reviewerConfig), &config); err != nil { + t.Fatalf("reviewerConfig is invalid JSON: %v", err) + } + if len(config.Permission.Bash) == 0 { + t.Fatal("reviewerConfig permission.bash is empty") + } + return config.Permission.Bash +} + +func bashAllowsCommand(t *testing.T, bash map[string]string, command string) bool { + t.Helper() + + for pattern, action := range bash { + if action == "deny" { + continue + } + if simplePicomatchGlobMatches(t, pattern, command) { + return true + } + } + return false +} + +func simplePicomatchGlobMatches(t *testing.T, pattern, command string) bool { + t.Helper() + + parts := strings.Split(pattern, "*") + for i, part := range parts { + parts[i] = regexp.QuoteMeta(part) + } + re, err := regexp.Compile("^" + strings.Join(parts, ".*") + "$") + if err != nil { + t.Fatalf("compile pattern %q: %v", pattern, err) + } + return re.MatchString(command) +} diff --git a/backend/internal/adapters/reviewer/registry.go b/backend/internal/adapters/reviewer/registry.go index b5fbcb7d0..dccc77e36 100644 --- a/backend/internal/adapters/reviewer/registry.go +++ b/backend/internal/adapters/reviewer/registry.go @@ -1,13 +1,14 @@ // Package reviewer is the single source of truth for the code-review adapters // the daemon ships. It mirrors the worker agent registry but is a separate set: -// adding a reviewer (claude-code today, greptile tomorrow) is one edit here and -// does not widen the worker AgentHarness vocabulary. +// adding a reviewer here does not widen the worker AgentHarness vocabulary. package reviewer import ( "fmt" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer/claudecode" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer/codex" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/reviewer/opencode" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -23,6 +24,8 @@ type Adapter interface { func Constructors() []Adapter { return []Adapter{ claudecode.New(), + codex.New(), + opencode.New(), } } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 4ad643d1c..2542c5653 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -24,8 +24,10 @@ func TestProjectConfigValidate(t *testing.T) { {"symlink embedded parent", ProjectConfig{Symlinks: []string{"a/../../b"}}, true}, {"symlink bare ..", ProjectConfig{Symlinks: []string{".."}}, true}, {"good reviewers", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerClaudeCode}}}, false}, + {"good codex reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerCodex}}}, false}, + {"good opencode reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerOpenCode}}}, false}, {"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true}, - {"worker harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessCodex)}}}, true}, + {"worker-only harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessAider)}}}, true}, {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, } for _, tt := range tests { @@ -80,10 +82,17 @@ func TestResolveReviewerHarness(t *testing.T) { t.Fatalf("configured reviewer = %q, want claude-code", got) } - // No reviewer configured: always use claude-code. + // No reviewer configured: always use claude-code, regardless of the worker + // harness (see #2241). if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessClaudeCode); got != ReviewerClaudeCode { t.Fatalf("default = %q, want reviewer claude-code", got) } + if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessCodex); got != ReviewerClaudeCode { + t.Fatalf("default = %q, want reviewer claude-code", got) + } + if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessOpenCode); got != ReviewerClaudeCode { + t.Fatalf("default = %q, want reviewer claude-code", got) + } // A worker harness that is not claude-code also falls back to claude-code. if got := (ProjectConfig{}).ResolveReviewerHarness(HarnessAider); got != FallbackReviewerHarness { diff --git a/backend/internal/domain/reviewerharness.go b/backend/internal/domain/reviewerharness.go index 760be13ec..ed1c69027 100644 --- a/backend/internal/domain/reviewerharness.go +++ b/backend/internal/domain/reviewerharness.go @@ -4,19 +4,23 @@ package domain // from AgentHarness on purpose: a reviewer-only tool (e.g. the Greptile CLI) // must not become a valid worker, and a worker harness does not automatically // become a valid reviewer. The two sets are maintained independently and only -// happen to share ids where the same tool serves both roles (claude-code). +// happen to share ids where the same tool serves both roles. type ReviewerHarness string // Supported reviewer harnesses. Add a reviewer-only tool here (and register its // adapter) without widening the worker AgentHarness set. const ( ReviewerClaudeCode ReviewerHarness = "claude-code" + ReviewerCodex ReviewerHarness = "codex" + ReviewerOpenCode ReviewerHarness = "opencode" ) // AllReviewerHarnesses is the canonical set used to validate a configured // reviewer harness. var AllReviewerHarnesses = []ReviewerHarness{ ReviewerClaudeCode, + ReviewerCodex, + ReviewerOpenCode, } // IsKnown reports whether h is one of the supported reviewer harnesses. diff --git a/backend/internal/review/prompt.go b/backend/internal/review/prompt.go index 42dfca8c0..a7af5b76a 100644 --- a/backend/internal/review/prompt.go +++ b/backend/internal/review/prompt.go @@ -30,23 +30,15 @@ Complete every review task in the queue autonomously. Do not ask the user whethe Do these steps in order: 1. For each PR below, post a separate review on that pull request and capture its id in one call. Post with `+"`gh api`"+` rather than `+"`gh pr review`"+`: it is the only way to attach inline comments, and its response carries the created review's id, so AO can tell the worker exactly which review to address. Send the review as a JSON body so the inline comments form a proper array of objects: - gh api --method POST repos/{owner}/{repo}/pulls/{number}/reviews --input - --jq '.id' <<'JSON' - { "event": "COMMENT", "body": "", - "comments": [ { "path": "", "line": , "body": "" } ] } - JSON + printf '%%s' '{ "event": "COMMENT", "body": "", "comments": [ { "path": "", "line": , "body": "" } ] }' | gh api --method POST repos/{owner}/{repo}/pulls/{number}/reviews --input - --jq '.id' - Substitute the PR's owner/repo/number. Add one object to "comments" per inline finding; omit the field for a review with no inline comments. + - Keep the JSON on one line and shell-escape any single quotes in review text before passing it to printf; do not use a heredoc because reviewer panes run through an interactive PTY. - Always use "event": "COMMENT": reviews are posted from the PR author's own account, and GitHub rejects both APPROVE and REQUEST_CHANGES on your own PR. State in the body whether you are requesting changes or approving; the machine-readable verdict goes to AO in step 2. - The printed number is the review id. If the call fails on the provider, leave the id empty. 2. After every PR has its own GitHub review from step 1, record AO's bookkeeping for those already-posted reviews using one command. Pass JSON on stdin so nothing is ever written into the worktree (a file there could be committed onto the worker's branch). Include one object per PR/run from the queue: - ao review submit --session %s --reviews - <<'JSON' - { - "reviews": [ - { "runId": "", "verdict": "", "githubReviewId": "", "body": "" } - ] - } - JSON + printf '%%s' '{ "reviews": [ { "runId": "", "verdict": "", "githubReviewId": "", "body": "" } ] }' | ao review submit --session %s --reviews - Only if step 1 genuinely fails on the provider for a PR, still include that run in step 2 with an empty githubReviewId so the result is recorded.`, spec.WorkerID, queueText, spec.WorkerID) diff --git a/backend/internal/review/prompt_test.go b/backend/internal/review/prompt_test.go index c2428782e..ba3a472a4 100644 --- a/backend/internal/review/prompt_test.go +++ b/backend/internal/review/prompt_test.go @@ -27,6 +27,8 @@ func TestReviewTextsIncludesMultiPRQueue(t *testing.T) { "* 1. https://github.com/o/r/pull/1 (head commit sha1, run run-1)", "* 2. https://github.com/o/r/pull/2 (head commit sha2, run run-2)", "After every PR has its own GitHub review from step 1", + "printf '%s'", + "do not use a heredoc", "ao review submit --session mer-1 --reviews -", `"reviews": [`, } { diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 76f6054ed..a2c52fe73 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -142,4 +142,4 @@ export const RequiredAgentField = memo(function RequiredAgentField({
); -}); \ No newline at end of file +}); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 26776054d..41bb524ac 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -170,6 +170,35 @@ describe("ProjectSettingsForm", () => { expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); }); + it("offers every supported reviewer harness", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + const reviewerAgent = await screen.findByRole("combobox", { name: "Default reviewer agent" }); + await userEvent.click(reviewerAgent); + for (const option of ["claude-code (default)", "claude-code", "codex", "opencode"]) { + expect(await screen.findByRole("option", { name: option })).toBeInTheDocument(); + } + }); + it("defaults worker and orchestrator to claude-code for projects missing role config", async () => { getMock.mockResolvedValue({ data: { @@ -210,4 +239,4 @@ describe("ProjectSettingsForm", () => { }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index ea51b7fd4..c6be21a19 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -21,7 +21,7 @@ const PERMISSION_MODE_OPTIONS = [ { value: "bypass-permissions", label: "Bypass permissions" }, ] as const; -const REVIEWER_OPTIONS = ["claude-code"] as const; +const REVIEWER_OPTIONS = ["claude-code", "codex", "opencode"] as const; const projectQueryKey = (id: string) => ["project", id] as const; @@ -300,4 +300,4 @@ function CenteredNote({ children }: { children: React.ReactNode }) { // rather than an empty {} the daemon would persist. function blankToUndefined(obj: T): T | undefined { return Object.values(obj).some((v) => v !== undefined) ? obj : undefined; -} \ No newline at end of file +} diff --git a/frontend/src/renderer/lib/agent-options.ts b/frontend/src/renderer/lib/agent-options.ts index 7cdc56eee..39ade7eae 100644 --- a/frontend/src/renderer/lib/agent-options.ts +++ b/frontend/src/renderer/lib/agent-options.ts @@ -26,4 +26,4 @@ export const AGENT_OPTIONS = [ // The agent new projects use by default, and the fallback for worker/orchestrator // role fields that have no explicit configuration. Users can change it per project. -export const DEFAULT_PROJECT_AGENT: (typeof AGENT_OPTIONS)[number] = "claude-code"; \ No newline at end of file +export const DEFAULT_PROJECT_AGENT: (typeof AGENT_OPTIONS)[number] = "claude-code"; From 6186458df7afb65ea7e0637c82fcb8be7825b4b3 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Thu, 2 Jul 2026 22:03:13 +0530 Subject: [PATCH 24/43] docs(bug-triage): retarget skill at the Go rewrite and upstream repo (#2339) * docs(bug-triage): retarget skill at the Go rewrite and upstream repo Rewrite the bug-triage skill for this repository: file issues/PRs on the upstream AgentWrapper/agent-orchestrator, swap the Zellij runtime notes for tmux (ConPTY on Windows), refresh the label list to match upstream, and drop ReverbCode-specific naming. Port 3001, ~/.ao paths, and the CLI/daemon layout are unchanged and were re-verified against backend/. Co-Authored-By: Claude Opus 4.8 * chore: format with prettier [skip ci] --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: github-actions[bot] --- skills/bug-triage/SKILL.md | 197 ++++++++++++++++++++----------------- 1 file changed, 104 insertions(+), 93 deletions(-) diff --git a/skills/bug-triage/SKILL.md b/skills/bug-triage/SKILL.md index 92f5306d3..408b62bca 100644 --- a/skills/bug-triage/SKILL.md +++ b/skills/bug-triage/SKILL.md @@ -6,53 +6,56 @@ trigger: User reports a bug, or asks to triage/file an issue for a reported prob # Bug Triage Skill -Triage bugs into well-structured GitHub issues on the ReverbCode repo. +Triage bugs into well-structured GitHub issues on the upstream **`AgentWrapper/agent-orchestrator`** repo (issues are enabled there; the `origin` fork is not the issue tracker). -> **ReverbCode is Go + Electron.** The backend is a Go daemon (`backend/`) -> exposing a loopback HTTP API on `127.0.0.1:3001`; the frontend is an Electron + -> React supervisor (`frontend/`). There is **no** pm2/tmux/Node runtime here — -> the daemon owns lifecycle and sessions run under the **Zellij** runtime -> adapter. Triage against _this_ stack, not the old TypeScript agent-orchestrator. +> **Agent Orchestrator (AO) is Go + Electron.** The backend is a Go daemon +> (`backend/`) exposing a loopback HTTP API on `127.0.0.1:3001`; the frontend is an +> Electron + React supervisor (`frontend/`). There is **no** pm2/tmux-per-session +> Node runtime here: the daemon owns lifecycle and terminals run under the **tmux** +> runtime adapter (ConPTY on Windows). Triage against _this_ Go rewrite, not the old +> TypeScript agent-orchestrator implementation. ## ⚠️ Which `ao` are you running? -**`ReverbCode` ships no `ao` on your PATH.** A bare `ao` very likely resolves to a -**different** AO install — e.g. an old npm build at `~/.nvm/.../bin/ao` that talks -to port **:3000**. Triaging with the wrong binary produces bugs that don't exist -in ReverbCode (and miss ones that do). +**A bare `ao` on your PATH may resolve to a different AO install** (for example an +old npm build at `~/.nvm/.../bin/ao` that talks to port **:3000**). Triaging with +the wrong binary produces bugs that don't exist in this rewrite (and misses ones +that do). Before any diagnostics: ```bash -which -a ao # see every ao on PATH — expect surprises -ao status 2>/dev/null # if this shows port 3000, it is NOT ReverbCode +which -a ao # see every ao on PATH; expect surprises +ao status 2>/dev/null # if this shows port 3000, it is NOT this rewrite ``` -Use a ReverbCode binary explicitly: +Use a rewrite binary explicitly: ```bash -# Option A — build from this repo (preferred during triage) +# Option A: build from this repo (preferred during triage) cd backend && go build -o /tmp/ao ./cmd/ao /tmp/ao status # must report port: 3001 -# Option B — the packaged app's bundled daemon +# Option B: the packaged app's bundled daemon "/Applications/Agent Orchestrator.app/Contents/Resources/daemon/ao" status ``` **Confirm `ao status` reports `port: 3001` before trusting any output.** Throughout -this skill, `ao` means _your verified ReverbCode binary_ (`/tmp/ao` or the bundled +this skill, `ao` means _your verified rewrite binary_ (`/tmp/ao` or the bundled one), never a bare PATH lookup. > Note: spawned sessions get a PATH pin so the _session's_ `ao` resolves to the > daemon's own executable (see `hookPATH` in > `backend/internal/session_manager/manager.go`). That pin only applies inside -> sessions — your interactive shell is still on its own PATH, so pin it yourself. +> sessions; your interactive shell is still on its own PATH, so pin it yourself. ## 1. Pre-flight -- **Pull latest code:** `git pull origin main`. Stale code = bad triage. -- **Target repo:** Always file on **`aoagents/ReverbCode`** (the product repo, not - a fork). ReverbCode is the product, not a thin fork of upstream. +- **Pull latest code:** `git fetch upstream && git log --oneline upstream/main -5`. + Stale code means bad triage. (`upstream` = `AgentWrapper/agent-orchestrator`.) +- **Target repo:** Always file on **`AgentWrapper/agent-orchestrator`** (the upstream + product repo, where issues live). Never file on the `origin` fork or on + `aoagents/*`. - **Verify your binary:** confirm `ao status` shows port **3001** (see warning above). - **Record source:** chat URL, reporter name, attachments. @@ -63,7 +66,7 @@ one), never a bare PATH lookup. | Source | How to gather | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | | **Discord/Slack thread** | Read full thread. Extract: reporter name, original description (the thread starter, not whoever tagged you), screenshots, follow-ups | -| **GitHub issue** | `gh issue view --repo aoagents/ReverbCode --json body,comments` | +| **GitHub issue** | `gh issue view --repo AgentWrapper/agent-orchestrator --json body,comments` | | **Live observation** | Pull live state via the daemon: `ao status`, `ao session ls`, `ao session get ` | ### 2b. Minimum viable report gate @@ -81,7 +84,7 @@ If insufficient, ask: ### 2c. Local diagnostics (if bug is on same machine) Gather everything yourself before asking the reporter. Use your **verified** -ReverbCode binary (`/tmp/ao` here) for every `ao` call: +rewrite binary (`/tmp/ao` here) for every `ao` call: ```bash # Environment @@ -98,7 +101,7 @@ tail -n 100 ~/.ao/daemon.log # daemon log # Sessions & runtime /tmp/ao session ls # all sessions and their state /tmp/ao session get # one session: spawn config, runtime, lifecycle -zellij list-sessions # Zellij runtime sessions backing terminals +tmux ls # tmux runtime sessions backing terminals (macOS/Linux) # Durable state (SQLite at ~/.ao/data) sqlite3 ~/.ao/data/ao.db '.tables' # inspect schema/rows if state looks wrong @@ -115,49 +118,52 @@ handshake is `~/.ao/running.json`. ### 3a. Trace the code path -**Always trace the actual code** — don't surface-level diagnose. A symptom that +**Always trace the actual code**; do not surface-level diagnose. A symptom that looks like a simple `ao stop` issue is often a lifecycle/session-manager problem -one layer down. ReverbCode's layers: +one layer down. The layers: - CLI (Cobra, thin client over daemon HTTP): `backend/internal/cli/`, entrypoint `backend/cmd/ao/main.go` - Daemon (loopback HTTP on :3001): `backend/internal/daemon/daemon.go`, controllers under `backend/internal/httpd/controllers/` - Sessions & lifecycle: `backend/internal/session_manager/manager.go` -- Runtime adapter (Zellij): `backend/internal/adapters/runtime/` +- Runtime adapter (tmux / ConPTY): `backend/internal/adapters/runtime/` + (`tmux/`, `conpty/`, `ptyexec/`, selected by `runtimeselect/`) - Agent harness adapters: `backend/internal/adapters/agent//` - Terminal mux: `backend/internal/terminal/` - Agent hooks: `backend/internal/cli/hooks.go` ```bash -git fetch origin main && git log --oneline origin/main -5 # current HEAD +git fetch upstream main && git log --oneline upstream/main -5 # current HEAD # Record the commit hash you're analyzing against ``` -**Git archaeology** — find which commits introduced/removed specific code: +**Git archaeology**: find which commits introduced/removed specific code: ```bash git log --oneline -S 'exact-string' -- git show -- | grep -B 5 -A 10 'pattern' ``` -**Research dependencies** (Zellij, the agent harness binary, Electron, React, the -SQLite driver) — check installed vs latest version, search their issue trackers, -check changelogs. Root cause is sometimes in a dependency, not ReverbCode. +**Research dependencies** (tmux, the agent harness binary, Electron, React, the +SQLite driver): check installed vs latest version, search their issue trackers, +check changelogs. Root cause is sometimes in a dependency, not AO itself. ### 3b. Cross-platform check AO targets **macOS, Linux, and Windows**. If env info indicates Windows (or is unknown), check for these patterns: -- **Path separators** — hardcoded `/` or `\`; use `filepath.Join`, not string concat -- **Shell syntax** — PowerShell lacks `&&`, `$VAR`, `$(cat ...)`, `/dev/null`, here-docs -- **`runtime.GOOS == "windows"` scattered inline** — centralize platform checks -- **Process-tree kills** — POSIX process groups vs Windows job objects -- **`localhost`** — Windows resolves to `::1` first; the daemon binds the explicit +- **Path separators**: hardcoded `/` or `\`; use `filepath.Join`, not string concat +- **Shell syntax**: PowerShell lacks `&&`, `$VAR`, `$(cat ...)`, `/dev/null`, here-docs +- **`runtime.GOOS == "windows"` scattered inline**: centralize platform checks +- **Runtime backend**: tmux on macOS/Linux vs ConPTY on Windows + (`runtimeselect` picks per platform); Windows has no tmux +- **Process-tree kills**: POSIX process groups vs Windows job objects +- **`localhost`**: Windows resolves to `::1` first; the daemon binds the explicit loopback host (see `config.LoopbackHost`) to avoid IPv4/IPv6 stalls -- **Case-insensitive filesystems** — don't compare paths with raw `==` -- **PATH / binary resolution** — `.exe`/`PATHEXT` lookup, the session PATH pin +- **Case-insensitive filesystems**: do not compare paths with raw `==` +- **PATH / binary resolution**: `.exe`/`PATHEXT` lookup, the session PATH pin (`hookPATH` in `backend/internal/session_manager/manager.go`) Key files: `backend/internal/config/config.go`, `backend/internal/session_manager/manager.go`, @@ -167,26 +173,26 @@ Key files: `backend/internal/config/config.go`, `backend/internal/session_manage Stop and ask for more info if: -- **3 failed hypotheses** — traced 3 code paths, none explain it -- **Root cause is a dependency** — file with the dependency reference, don't guess a local fix -- **UI-only bug** and you can't screenshot — ask reporter to describe -- **Can't reproduce** — ask for different config/sequence +- **3 failed hypotheses**: traced 3 code paths, none explain it +- **Root cause is a dependency**: file with the dependency reference, don't guess a local fix +- **UI-only bug** and you can't screenshot, ask reporter to describe +- **Can't reproduce**: ask for different config/sequence ## 4. Search for Duplicates Search with multiple strategies, always using `--state all` (closed bugs regress): ```bash -gh issue list --repo aoagents/ReverbCode --state all --search "" -gh issue list --repo aoagents/ReverbCode --state all --search "" -gh issue list --repo aoagents/ReverbCode --state all --search "" -gh pr list --repo aoagents/ReverbCode --state all --search "" +gh issue list --repo AgentWrapper/agent-orchestrator --state all --search "" +gh issue list --repo AgentWrapper/agent-orchestrator --state all --search "" +gh issue list --repo AgentWrapper/agent-orchestrator --state all --search "" +gh pr list --repo AgentWrapper/agent-orchestrator --state all --search "" ``` ### Duplicate found → comment on existing issue ```bash -gh issue comment --repo aoagents/ReverbCode --body "$(cat <<'EOF' +gh issue comment --repo AgentWrapper/agent-orchestrator --body "$(cat <<'EOF' ## New Report **Reported by:** @ in [chat]() **Date:** | **Checkout:** `` @@ -204,7 +210,7 @@ EOF - [ ] Reporter attribution correct (original reporter, not who tagged you) - [ ] Commit hash recorded - [ ] AO version recorded (`ao version`) -- [ ] Reproduced against ReverbCode (:3001 / Go code path), not another AO install +- [ ] Reproduced against the rewrite (:3001 / Go code path), not another AO install - [ ] Root cause confidence scored (see 5c) - [ ] Related issues cross-linked - [ ] Reproduction steps are concrete @@ -217,23 +223,23 @@ EOF ```bash SLUG="descriptive-slug" # Create asset branch -gh api -X POST repos/aoagents/ReverbCode/git/refs \ +gh api -X POST repos/AgentWrapper/agent-orchestrator/git/refs \ -f ref="refs/heads/issue-assets-${SLUG}" \ - -f sha=$(git rev-parse origin/main) + -f sha=$(git rev-parse upstream/main) # Upload (portable base64) IMG_B64=$(base64 < /path/to/screenshot.png | tr -d '\n') -gh api -X PUT "repos/aoagents/ReverbCode/contents/.issue-assets/${SLUG}/name.png" \ +gh api -X PUT "repos/AgentWrapper/agent-orchestrator/contents/.issue-assets/${SLUG}/name.png" \ -f message="chore: upload screenshot" \ -f content="$IMG_B64" \ -f branch="issue-assets-${SLUG}" -# Use: ![screenshot](https://raw.githubusercontent.com/aoagents/ReverbCode/issue-assets-/.issue-assets/) +# Use: ![screenshot](https://raw.githubusercontent.com/AgentWrapper/agent-orchestrator/issue-assets-/.issue-assets/) ``` ### 5c. Create the issue ```bash -gh issue create --repo aoagents/ReverbCode --title "" --body "$(cat <<'EOF' +gh issue create --repo AgentWrapper/agent-orchestrator --title "<title>" --body "$(cat <<'EOF' ## Bug <summary> @@ -260,16 +266,19 @@ EOF **Check which labels actually exist first**, then apply only those: ```bash -gh label list --repo aoagents/ReverbCode # source of truth — apply only these -gh issue edit <number> --repo aoagents/ReverbCode --add-label "bug" +gh label list --repo AgentWrapper/agent-orchestrator # source of truth; apply only these +gh issue edit <number> --repo AgentWrapper/agent-orchestrator --add-label "bug" ``` -The repo currently carries `bug`, `enhancement`, `priority: critical/high/medium/low`, -lane labels (`daemon`, `frontend`, `storage`, `coding-agents`, `lcm-sm`, `scm`, -`core`, `port`, `adapter`, `domain`), and workflow labels (`needs-triage`, -`needs-review`, `blocked`). **Do not invent labels** — if a priority or confidence -label you want doesn't exist, **state it in the issue body instead** (e.g. -"**Priority:** high — core feature broken, no workaround" / "**Confidence:** medium"). +The repo currently carries `bug`, `enhancement`, `documentation`, `question`, +`priority: critical/high/medium/low` (plus the hyphen-free `priority:high` / +`priority:medium` variants), status labels (`todo`, `in-progress`, `review`, +`blocked`, `verify-if-fixed`, `to-reproduce`, `to-explore`), `sub-issue`, +`ready-for-agent`, `good-first-issue`, `help wanted`, and `upstream complication` +(for bugs rooted in Claude Code / Codex / tmux etc.). **Do not invent labels**: if +a priority or confidence label you want doesn't exist, **state it in the issue body +instead** (e.g. "**Priority:** high: core feature broken, no workaround" / +"**Confidence:** medium"). | Priority | Criteria | | -------------------- | ----------------------------------- | @@ -292,27 +301,27 @@ Search by subsystem and add a `## Related` section to the issue body: ``` ## Related -- [#20](url) — stale session blocking ao start (same subsystem) -- [#35](url) — same race condition +- [#20](url): stale session blocking ao start (same subsystem) +- [#35](url): same race condition ``` ### 5f. Push a fix PR (always attempt) -ReverbCode is a Go repo — fixes go through a local branch, build, and `gh pr create`. -There is no remote-patch script. +This is a Go repo: fixes go through a local branch, build, and `gh pr create` +against upstream. There is no remote-patch script. Branch off `upstream/main`. - **Unclear fix:** Don't push a guess. Document and flag in the issue. - **Trivial, verifiable fix** (you can build and test it yourself): ```bash - git checkout -b fix/<slug> origin/main + git fetch upstream main && git checkout -b fix/<slug> upstream/main # make the edit cd backend && go build ./... && go test ./... # must pass before pushing git commit -am "fix(<scope>): <summary> Fixes #<n>" git push -u origin fix/<slug> - gh pr create --repo aoagents/ReverbCode --fill \ + gh pr create --repo AgentWrapper/agent-orchestrator --fill \ --title "fix(<scope>): <summary>" \ --body "Fixes #<n> @@ -324,13 +333,13 @@ There is no remote-patch script. ``` - **Non-trivial fix** (broad change, needs iteration, or you can't fully verify): - spawn a ReverbCode worker session to do the work in its own worktree instead of - pushing a guess: + spawn a worker session to do the work in its own worktree instead of pushing a + guess: ```bash - ao spawn --project reverbcode --prompt "Fix #<n>: <one-line problem statement>. \ - Root cause: <file:line + mechanism>. Suggested approach: <approach>. \ - Build with 'cd backend && go build ./... && go test ./...' before opening a PR against aoagents/ReverbCode." + ao spawn --project agent-orchestrator --prompt "Fix #<n>: <one-line problem statement>. \ + Root cause: <file:line + mechanism>. Suggested approach: <approach>. Branch off upstream/main. \ + Build with 'cd backend && go build ./... && go test ./...' before opening a PR against AgentWrapper/agent-orchestrator." ``` Note the issue with which path you took (PR or spawned worker). @@ -351,7 +360,7 @@ any priority/confidence stated in the body), root cause summary. | **CLI** (`ao start/stop/spawn`) | Version, install method, OS, which binary | `backend/internal/cli/`, `backend/cmd/ao/main.go` | | **Daemon / HTTP API** | `ao status`, port, daemon.log | `backend/internal/daemon/daemon.go`, `backend/internal/httpd/controllers/` | | **Sessions / Lifecycle** | Session ID, spawn config, runtime, state | `backend/internal/session_manager/manager.go` | -| **Runtime (Zellij)** | Zellij version, `zellij list-sessions` | `backend/internal/adapters/runtime/` | +| **Runtime (tmux / ConPTY)** | tmux version, `tmux ls` (macOS/Linux) | `backend/internal/adapters/runtime/` | | **Terminal mux** | Runtime type, shell, attach behavior | `backend/internal/terminal/` | | **Agent harness** | Harness name + version | `backend/internal/adapters/agent/<harness>/` | | **Storage** | DB state, migrations | `backend/internal/storage/sqlite/`, `~/.ao/data/ao.db` | @@ -360,10 +369,10 @@ any priority/confidence stated in the body), root cause summary. **Misrouting patterns:** -- Terminal bugs → Zellij runtime adapter vs the terminal mux vs the Electron xterm - surface. Trace where bytes flow (daemon → mux → frontend). +- Terminal bugs → tmux/ConPTY runtime adapter vs the terminal mux vs the Electron + xterm surface. Trace where bytes flow (daemon → mux → frontend). - "Session stuck" → lifecycle/session-manager state vs agent harness process vs - Zellij runtime connection. + tmux runtime connection. - "Config not saving" → config loading (`backend/internal/config/config.go`) vs project registration vs SQLite write (`~/.ao/data/ao.db`). - "Command does nothing / wrong port" → you're on the wrong `ao` binary (:3000 vs @@ -372,47 +381,49 @@ any priority/confidence stated in the body), root cause summary. ### B. Remote Code Inspection (no local clone) ```bash -gh api repos/aoagents/ReverbCode/git/trees/main?recursive=1 --jq '.tree[].path' # list files -gh api repos/aoagents/ReverbCode/contents/{path} --jq '.content' | python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode(sys.stdin.read()))" # read file -gh search code "term" --repo aoagents/ReverbCode --json path --jq '.[].path' # search code -gh api "repos/aoagents/ReverbCode/commits?path={path}&per_page=10" --jq '.[] | "\(.sha[0:8]) \(.commit.message | split("\n")[0])"' # file history +gh api repos/AgentWrapper/agent-orchestrator/git/trees/main?recursive=1 --jq '.tree[].path' # list files +gh api repos/AgentWrapper/agent-orchestrator/contents/{path} --jq '.content' | python3 -c "import base64,sys; sys.stdout.buffer.write(base64.b64decode(sys.stdin.read()))" # read file +gh search code "term" --repo AgentWrapper/agent-orchestrator --json path --jq '.[].path' # search code +gh api "repos/AgentWrapper/agent-orchestrator/commits?path={path}&per_page=10" --jq '.[] | "\(.sha[0:8]) \(.commit.message | split("\n")[0])"' # file history ``` ### C. Build / Version Diagnostics -ReverbCode is built from source, not published to npm. Pin the binary under test -and reproduce against a known build: +AO is built from source in this rewrite, not published to npm. Pin the binary under +test and reproduce against a known build: ```bash cd backend && go build -o /tmp/ao ./cmd/ao # build the binary under test /tmp/ao version # record version/commit go version # toolchain (build issues are often here) -git log --oneline origin/main -1 # the commit you're analyzing against +git log --oneline upstream/main -1 # the commit you're analyzing against ``` To bisect a regression, build `ao` at two commits and compare behavior: ```bash -git stash; git checkout <good-sha>; (cd backend && go build -o /tmp/ao-good ./cmd/ao) -git checkout <bad-sha>; (cd backend && go build -o /tmp/ao-bad ./cmd/ao) -git checkout - ; git stash pop +git checkout <good-sha>; (cd backend && go build -o /tmp/ao-good ./cmd/ao) +git checkout <bad-sha>; (cd backend && go build -o /tmp/ao-bad ./cmd/ao) +git checkout - # run the repro against /tmp/ao-good vs /tmp/ao-bad ``` ## Formatting Rules -- **Linkify all issue/PR refs:** `[#123](https://github.com/aoagents/ReverbCode/issues/123)`, `[PR #456](url)`. Never bare `#123`. +- **Linkify all issue/PR refs:** `[#123](https://github.com/AgentWrapper/agent-orchestrator/issues/123)`, `[PR #456](url)`. Never bare `#123`. ## Pitfalls - **Wrong `ao` binary.** A bare `ao` may be a different AO install (old npm build on - :3000). Always pin a ReverbCode binary and confirm `ao status` shows port **3001**. -- **Verify the bug reproduces against ReverbCode (:3001 / Go code path) before - filing** — symptoms first seen in another AO install may not reproduce here. + :3000). Always pin a rewrite binary and confirm `ao status` shows port **3001**. +- **Verify the bug reproduces against the rewrite (:3001 / Go code path) before + filing** (symptoms first seen in another AO install may not reproduce here). +- **File on upstream, not the fork.** Issues go to `AgentWrapper/agent-orchestrator`; + `origin` is a personal fork with no issue tracker. - **Reporter ≠ person who tagged you.** Always attribute to the original reporter. -- **Record the commit hash** you analyzed — code changes fast. -- **GitHub issue is mandatory** — every triaged bug gets one, even if fix is trivial. -- **Only apply labels that exist** (`gh label list --repo aoagents/ReverbCode`). +- **Record the commit hash** you analyzed; code changes fast. +- **GitHub issue is mandatory**: every triaged bug gets one, even if fix is trivial. +- **Only apply labels that exist** (`gh label list --repo AgentWrapper/agent-orchestrator`). State priority/confidence in the body when no matching label exists. - **Build before you push.** `cd backend && go build ./... && go test ./...` must pass; never open a PR with an unverified Go change. From e56248759ef41dd5330d7d66ce36e70572a404f3 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Thu, 2 Jul 2026 22:49:29 +0530 Subject: [PATCH 25/43] ci(prettier): make workflow check-only instead of auto-committing (#2356) Convert the Prettier workflow from auto-formatting and pushing commits back to the PR branch into a check-only job. It now runs `prettier@3 --check .`, which annotates and fails on unformatted files but never writes, commits, or pushes anything. Drops the auto-commit step and narrows permissions to `contents: read`. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> --- .github/workflows/prettier.yml | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index b9a10b8df..5dbea5388 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -1,11 +1,10 @@ name: Prettier -# Auto-formats the codebase on every push and commits the result back. -# Formatting is a CI concern — developers never need to run Prettier locally -# and formatted output never shows up as local uncommitted changes. -# -# GitHub Actions does not re-trigger workflows on commits made with GITHUB_TOKEN, -# so there is no feedback loop risk. +# Check-only: reports formatting issues without modifying the branch. +# The job fails (and annotates unformatted files) when `prettier --check` +# finds files that are not formatted, but it never writes, commits, or +# pushes anything back to the PR. Developers run `npx prettier@3 --write .` +# locally to fix the reported files. on: push: @@ -18,21 +17,9 @@ jobs: format: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.ref }} - token: ${{ secrets.GITHUB_TOKEN }} - - name: Format with Prettier - run: npx --yes prettier@3 --write . - - - name: Commit formatted files - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git diff --quiet && exit 0 - git add -A - git commit -m "chore: format with prettier [skip ci]" - git push + - name: Check formatting with Prettier + run: npx --yes prettier@3 --check . From fadbdd26cdbf02ada061777f860bfb1423c7eedb Mon Sep 17 00:00:00 2001 From: VenkataSakethDakuri <126963412+VenkataSakethDakuri@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:36:14 +0530 Subject: [PATCH 26/43] Fix and enrich the orchestrator coordinator system prompt so it matches the current CLI and teaches harness selection + command discovery (#2361) * Prompt changed for correct agent spawning * Fix formatting issue --- backend/internal/session_manager/manager.go | 7 ++++++- backend/internal/session_manager/manager_test.go | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4ae540c72..6a9c887e4 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -1030,11 +1030,16 @@ func orchestratorPrompt(project domain.ProjectID) string { You are the human-facing coordinator for project %s. Coordinate work for the human, keep the project moving, and avoid doing implementation yourself unless it is necessary. Spawn worker sessions for implementation with: -`+"`ao spawn --project %s --prompt \"<clear worker task>\"`"+` +`+"`ao spawn --project %s --name \"<label, max 20 chars>\" --prompt \"<clear worker task>\"`"+` +Both --project and --name are required. + +To run a worker on a specific agent, add `+"`--agent <name>`"+` (an alias for `+"`--harness`"+`) — for example `+"`--agent codex`"+` or `+"`--agent claude-code`"+`. If you omit it, the project's default worker agent is used. Run `+"`ao spawn --help`"+` for the full list of agents and every flag. Message workers with `+"`ao send`"+`, for example: `+"`ao send --session <worker-session-id> --message \"<your message>\"`"+` +To discover any other AO command, run `+"`ao --help`"+` (and `+"`ao <command> --help`"+` for details on one). + Use workers for focused implementation tasks, track their progress, synthesize their results, and only step into implementation directly for true emergencies or small coordination fixes.`, project, project) } diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 0ea59273d..e310bf9dd 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -833,8 +833,11 @@ func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) { systemPrompt := agent.lastLaunch.SystemPrompt for _, want := range []string{ "You are the human-facing coordinator for project mer", - `ao spawn --project mer --prompt "<clear worker task>"`, + `ao spawn --project mer --name "<label, max 20 chars>" --prompt "<clear worker task>"`, + "`--agent <name>`", + "`ao spawn --help`", "`ao send`", + "`ao --help`", "avoid doing implementation yourself unless it is necessary", } { if !strings.Contains(systemPrompt, want) { From 7e409d438b0831bc83c871f30abfdde9ebce0a0e Mon Sep 17 00:00:00 2001 From: Dushyant Singh Hada <dushyanthada90@gmail.com> Date: Fri, 3 Jul 2026 15:38:43 +0530 Subject: [PATCH 27/43] fix: clear stale preview URL from SQLite and prevent stale white screen on preview clear (#2358) * fix: remove auto-switch-to-summary effect, keep browser tab on clear * fix: clear browser preview when session is terminated --- backend/internal/preview/poller.go | 17 +++++- backend/internal/preview/poller_test.go | 41 ++++++++++++++ frontend/src/main/browser-view-host.test.ts | 2 + frontend/src/main/browser-view-host.ts | 4 ++ .../src/renderer/components/SessionView.tsx | 1 + .../renderer/hooks/useBrowserView.test.tsx | 56 +++++++++++++++++++ frontend/src/renderer/hooks/useBrowserView.ts | 27 +++++++-- 7 files changed, 143 insertions(+), 5 deletions(-) diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go index 6825840e3..b06c82a09 100644 --- a/backend/internal/preview/poller.go +++ b/backend/internal/preview/poller.go @@ -44,6 +44,10 @@ type entryState struct { path string modUnix int64 size int64 + // cleared is set when the poller itself cleared the preview URL because the + // workspace entry was missing. When the file reappears, shouldRefresh uses + // this to re-discover even though the revision was bumped by the clear. + cleared bool } // NewPoller constructs a preview poller over the supplied session source and setter. @@ -112,6 +116,13 @@ func (p *Poller) Poll(ctx context.Context) error { } entry, ok := DiscoverEntry(sess.Metadata.WorkspacePath) if !ok { + if isWorkspacePreviewURL(sess.Metadata.PreviewURL, sess.ID) { + if _, err := p.setter.SetPreview(ctx, sess.ID, ""); err != nil { + p.logger.Error("preview poller: failed to clear stale preview", + "session", sess.ID, "err", err) + } + p.seen[sess.ID] = entryState{cleared: true} + } continue } state := stateFor(entry) @@ -140,7 +151,11 @@ func (p *Poller) Poll(ctx context.Context) error { func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore bool) bool { current := strings.TrimSpace(sess.Metadata.PreviewURL) if current == "" { - return !seenBefore && sess.Metadata.PreviewRevision == 0 + if !seenBefore { + return sess.Metadata.PreviewRevision == 0 + } + previous := p.seen[sess.ID] + return previous.cleared } if current == target || isWorkspacePreviewURL(current, sess.ID) { return true diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go index c9427eaf5..3090383b6 100644 --- a/backend/internal/preview/poller_test.go +++ b/backend/internal/preview/poller_test.go @@ -118,6 +118,47 @@ func TestPollerRefreshesOnlyWhenEntrypointChanges(t *testing.T) { } } +func TestPollerRediscoverEntryAfterDeleteAndRecreate(t *testing.T) { + workspace := t.TempDir() + entry := filepath.Join(workspace, "index.html") + writeFile(t, entry, "<main>v1</main>") + svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "")}} + poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()}) + + // First poll discovers the entry and sets the preview. + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("first Poll: %v", err) + } + wantURL := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html" + assertSets(t, svc.sets, previewSet{id: "ao-1", url: wantURL}) + + // Delete the entry — poller must clear the preview and mark the session cleared. + if err := os.Remove(entry); err != nil { + t.Fatalf("remove index.html: %v", err) + } + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("second Poll (delete): %v", err) + } + if len(svc.sets) != 2 { + t.Fatalf("sets after delete = %#v, want clear + set", svc.sets) + } + if svc.sets[1].url != "" { + t.Fatalf("second set.url = %q, want empty (clear)", svc.sets[1].url) + } + + // Recreate the entry — poller must re-discover. + writeFile(t, entry, "<main>v2</main>") + if err := poller.Poll(context.Background()); err != nil { + t.Fatalf("third Poll (recreate): %v", err) + } + if len(svc.sets) != 3 { + t.Fatalf("sets after recreate = %#v, want 3 sets (discover + clear + rediscover)", svc.sets) + } + if svc.sets[2].url != wantURL { + t.Fatalf("third set.url = %q, want %q", svc.sets[2].url, wantURL) + } +} + func TestPollerDoesNotRestoreClearedPreviewAfterRestart(t *testing.T) { workspace := t.TempDir() writeFile(t, filepath.Join(workspace, "index.html"), "<main>hello</main>") diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts index 8fd9d8cfc..a242c23ab 100644 --- a/frontend/src/main/browser-view-host.test.ts +++ b/frontend/src/main/browser-view-host.test.ts @@ -14,6 +14,7 @@ function setupHost() { const webContents = { canGoBack: () => false, canGoForward: () => false, + clearHistory: () => undefined, getTitle: () => "", getURL: () => currentURL, goBack: () => undefined, @@ -120,6 +121,7 @@ describe("dispose after the window is destroyed", () => { webContents: { canGoBack: () => false, canGoForward: () => false, + clearHistory: () => undefined, getTitle: () => "", getURL: () => "", goBack: () => undefined, diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts index d4397d0b8..193ddf47b 100644 --- a/frontend/src/main/browser-view-host.ts +++ b/frontend/src/main/browser-view-host.ts @@ -27,6 +27,7 @@ type BrowserWebContents = Pick< WebContents, | "canGoBack" | "canGoForward" + | "clearHistory" | "getTitle" | "getURL" | "goBack" @@ -190,7 +191,10 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV // empty state. const clear = async (viewId: string): Promise<BrowserNavState> => { const entry = ensure(viewId); + entry.view.setVisible?.(false); + entry.view.setBounds(OFFSCREEN_BOUNDS); await entry.view.webContents.loadURL("about:blank"); + entry.view.webContents.clearHistory(); return pushNavState(options, entry); }; diff --git a/frontend/src/renderer/components/SessionView.tsx b/frontend/src/renderer/components/SessionView.tsx index 7dbddfbea..69b320299 100644 --- a/frontend/src/renderer/components/SessionView.tsx +++ b/frontend/src/renderer/components/SessionView.tsx @@ -60,6 +60,7 @@ export function SessionView({ sessionId }: SessionViewProps) { sessionId, active: Boolean(session && hasInspector && (browserPoppedOut || isInspectorOpen)), poppedOut: browserPoppedOut, + terminated: session?.status === "terminated", previewUrl, previewRevision, }); diff --git a/frontend/src/renderer/hooks/useBrowserView.test.tsx b/frontend/src/renderer/hooks/useBrowserView.test.tsx index 206b26f95..b83568eee 100644 --- a/frontend/src/renderer/hooks/useBrowserView.test.tsx +++ b/frontend/src/renderer/hooks/useBrowserView.test.tsx @@ -75,6 +75,18 @@ describe("useBrowserView", () => { const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false })); await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1")); + // Simulate the real IPC flow: after ensure, a navigate call sends a nav + // state with a URL so the positioning effect considers the view visible. + act(() => + bridge.emit({ + viewId: "42:sess-1", + url: "http://localhost:3000/", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + }), + ); act(() => result.current.slotRef(slot)); await waitFor(() => @@ -111,6 +123,16 @@ describe("useBrowserView", () => { const { result } = renderHook(() => useBrowserView({ sessionId: "sess-1", active: true, poppedOut: false })); await waitFor(() => expect(bridge.ensure).toHaveBeenCalledWith("sess-1")); + act(() => + bridge.emit({ + viewId: "42:sess-1", + url: "http://localhost:3000/", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + }), + ); act(() => result.current.slotRef(slot)); await waitFor(() => @@ -140,6 +162,17 @@ describe("useBrowserView", () => { await act(async () => { await Promise.resolve(); }); + // Simulate a real nav state with URL so the positioning effect shows the view. + act(() => + bridge.emit({ + viewId: "42:sess-1", + url: "http://localhost:3000/", + title: "", + canGoBack: false, + canGoForward: false, + isLoading: false, + }), + ); act(() => result.current.slotRef(slot)); // Flush the mount measure (immediate frame + settle timer). await act(async () => { @@ -316,4 +349,27 @@ describe("useBrowserView", () => { expect(bridge.navigate).not.toHaveBeenCalled(); expect(bridge.clear).not.toHaveBeenCalled(); }); + + it("clears the view when the session is terminated, even with an active preview URL", async () => { + const bridge = setupBridge(); + const { rerender } = renderHook( + ({ terminated }) => + useBrowserView({ + sessionId: "sess-1", + active: true, + poppedOut: false, + terminated, + previewUrl: "http://localhost:5173/", + previewRevision: 1, + }), + { initialProps: { terminated: false } }, + ); + // The preview drives a navigate on mount. + await waitFor(() => expect(bridge.navigate).toHaveBeenCalledTimes(1)); + + // Terminate the session – the view must be cleared and no re-navigate. + rerender({ terminated: true }); + await waitFor(() => expect(bridge.clear).toHaveBeenCalledWith("42:sess-1")); + expect(bridge.navigate).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/src/renderer/hooks/useBrowserView.ts b/frontend/src/renderer/hooks/useBrowserView.ts index 066ce7505..e5ba98ef4 100644 --- a/frontend/src/renderer/hooks/useBrowserView.ts +++ b/frontend/src/renderer/hooks/useBrowserView.ts @@ -7,6 +7,12 @@ type UseBrowserViewOptions = { sessionId: string; active: boolean; poppedOut: boolean; + /** + * When true, the view is cleared and the daemon-driven preview is suppressed. + * Use when the session is terminated: the old preview content should not + * remain visible even if the DB still carries a preview_url. + */ + terminated?: boolean; /** * Preview target driven by the daemon (via `ao preview`, streamed over CDC). * When set, the view navigates here automatically; an empty value clears it. @@ -68,6 +74,7 @@ export function useBrowserView({ sessionId, active, poppedOut, + terminated, previewUrl, previewRevision, }: UseBrowserViewOptions): BrowserViewModel { @@ -80,11 +87,16 @@ export function useBrowserView({ const settleTimerRef = useRef<number | null>(null); const observerRef = useRef<ResizeObserver | null>(null); const previewTriggerRef = useRef<{ revision: number | null; target: string } | null>(null); + const hasUrlRef = useRef(false); useEffect(() => { activeRef.current = active; }, [active]); + useEffect(() => { + hasUrlRef.current = Boolean(navState.url); + }, [navState.url]); + const sendHiddenBounds = useCallback((id = viewIdRef.current) => { if (!id) return; window.ao?.browser.setBounds({ viewId: id, rect: HIDDEN_RECT, visible: false }); @@ -95,7 +107,7 @@ export function useBrowserView({ const id = viewIdRef.current; const node = slotNodeRef.current; if (!id) return; - if (!activeRef.current || !node || !node.isConnected) { + if (!activeRef.current || !node || !node.isConnected || !hasUrlRef.current) { sendHiddenBounds(id); return; } @@ -189,12 +201,12 @@ export function useBrowserView({ }, []); useEffect(() => { - if (active) { + if (navState.url && active) { scheduleSettleMeasure(); } else { sendHiddenBounds(); } - }, [active, poppedOut, scheduleSettleMeasure, sendHiddenBounds]); + }, [active, navState.url, poppedOut, scheduleSettleMeasure, sendHiddenBounds]); useEffect(() => { const handle = () => scheduleMeasure(); @@ -223,11 +235,18 @@ export function useBrowserView({ const clear = useCallback(() => withView((id) => window.ao!.browser.clear(id)), [withView]); + // When the session is terminated, clear the view and stop reacting to + // daemon-driven preview changes so stale content does not remain visible. + useEffect(() => { + if (!terminated) return; + void clear(); + }, [clear, terminated]); + // Drive the view from the daemon-set preview target. Current daemons key // this on previewRevision (bumped on every `ao preview` call); older daemons // did not send it, so fall back to URL changes for compatibility. useEffect(() => { - if (!viewId) return; + if (!viewId || terminated) return; const target = previewUrl?.trim() ?? ""; const revision = typeof previewRevision === "number" ? previewRevision : null; const previous = previewTriggerRef.current; From 57ba468fead5ea20e3ee89b9296b19f57b0c5484 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 18:11:15 +0530 Subject: [PATCH 28/43] feat(skills): add using-ao skill, install it under the data dir, and wire it into session prompts (#2365) * feat(prompt): point sessions at using-ao skill for CLI usage Append a short standing-prompt pointer to skills/using-ao/SKILL.md so orchestrator and worker sessions read the skill catalog before using the ao CLI, instead of inlining the whole command surface. * docs(skills): add using-ao skill cataloging the ao CLI Full command catalog sourced verbatim from 'ao --help' recursively: SKILL.md (top-level catalog), commands/*.md (one per command with every subcommand, flag, and examples), and references.md (natural-language to command table). * feat(skills): install using-ao skill under the data dir on daemon boot A repo-relative skills/ path only resolves when a worker's project is the AO repo itself; the ao CLI is used in every session regardless of project. Move the skill into the Go module, embed it, and clobber-install it into <dataDir>/skills/using-ao at daemon boot (before any session spawns, so no locking is needed). The embedded copy is the single source of truth: a new daemon build always refreshes it, so there is no version marker to drift. Flip the session-prompt pointer to the absolute installed path so it resolves from any project's worktree. * fix(skillassets): tighten install perms to satisfy gosec golangci-lint gosec flagged G301 (dir perms) and G306 (file perms). Use 0o750 for dirs and 0o600 for files, matching the repo convention for daemon-owned single-user state. * Update preview.md with dev server instructions Added note about using the correct URL for the dev server. --------- Co-authored-by: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> --- backend/internal/daemon/daemon.go | 8 + backend/internal/session_manager/manager.go | 17 +- .../internal/session_manager/manager_test.go | 3 + backend/internal/skillassets/skillassets.go | 65 ++++++ .../internal/skillassets/skillassets_test.go | 41 ++++ .../internal/skillassets/using-ao/SKILL.md | 36 +++ .../skillassets/using-ao/commands/doctor.md | 27 +++ .../skillassets/using-ao/commands/import.md | 35 +++ .../using-ao/commands/orchestrator.md | 40 ++++ .../skillassets/using-ao/commands/preview.md | 62 ++++++ .../skillassets/using-ao/commands/project.md | 163 ++++++++++++++ .../skillassets/using-ao/commands/review.md | 45 ++++ .../skillassets/using-ao/commands/send.md | 28 +++ .../skillassets/using-ao/commands/session.md | 206 ++++++++++++++++++ .../skillassets/using-ao/commands/spawn.md | 38 ++++ .../skillassets/using-ao/commands/start.md | 27 +++ .../skillassets/using-ao/commands/status.md | 27 +++ .../skillassets/using-ao/commands/stop.md | 28 +++ .../skillassets/using-ao/references.md | 26 +++ 19 files changed, 921 insertions(+), 1 deletion(-) create mode 100644 backend/internal/skillassets/skillassets.go create mode 100644 backend/internal/skillassets/skillassets_test.go create mode 100644 backend/internal/skillassets/using-ao/SKILL.md create mode 100644 backend/internal/skillassets/using-ao/commands/doctor.md create mode 100644 backend/internal/skillassets/using-ao/commands/import.md create mode 100644 backend/internal/skillassets/using-ao/commands/orchestrator.md create mode 100644 backend/internal/skillassets/using-ao/commands/preview.md create mode 100644 backend/internal/skillassets/using-ao/commands/project.md create mode 100644 backend/internal/skillassets/using-ao/commands/review.md create mode 100644 backend/internal/skillassets/using-ao/commands/send.md create mode 100644 backend/internal/skillassets/using-ao/commands/session.md create mode 100644 backend/internal/skillassets/using-ao/commands/spawn.md create mode 100644 backend/internal/skillassets/using-ao/commands/start.md create mode 100644 backend/internal/skillassets/using-ao/commands/status.md create mode 100644 backend/internal/skillassets/using-ao/commands/stop.md create mode 100644 backend/internal/skillassets/using-ao/references.md diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 891838815..4f798cb7c 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -24,6 +24,7 @@ import ( importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" + "github.com/aoagents/agent-orchestrator/backend/internal/skillassets" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" "github.com/aoagents/agent-orchestrator/backend/internal/terminal" ) @@ -61,6 +62,13 @@ func Run() error { } defer func() { _ = store.Close() }() + // Refresh the embedded using-ao skill into the data dir so worker sessions + // in any project can read the ao CLI catalog from a stable absolute path. + // Non-fatal: the skill is an enhancement over `ao --help`, not required. + if err := skillassets.Install(cfg.DataDir); err != nil { + log.Warn("install using-ao skill", "err", err) + } + telemetrySink := newTelemetrySink(cfg, store, log) defer func() { _ = telemetrySink.Close(context.Background()) }() telemetrySink.Emit(context.Background(), ports.TelemetryEvent{ diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 6a9c887e4..4a808c24a 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -17,6 +17,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process" + "github.com/aoagents/agent-orchestrator/backend/internal/skillassets" ) // Sentinel errors returned by the Session Manager; callers match them with @@ -1000,7 +1001,21 @@ func (m *Manager) buildSystemPrompt(ctx context.Context, kind domain.SessionKind if base == "" { return "", nil } - return base + systemPromptGuard, nil + return base + m.aoSkillPointer() + systemPromptGuard, nil +} + +// aoSkillPointer is appended to every agent system prompt. It points the agent +// at the using-ao skill the daemon installs under the data dir, rather than +// inlining the whole CLI catalog. The path is absolute so it resolves from any +// project's worktree, not just the AO repo (the only place a repo-relative +// skills/ path would exist). The skill file carries exact flags and examples, +// so the standing prompt stays a short pointer rather than a command dump. +func (m *Manager) aoSkillPointer() string { + dir := skillassets.Dir(m.dataDir) + skillFile := filepath.Join(dir, "SKILL.md") + commandsGlob := filepath.Join(dir, "commands", "*.md") + return "\n\n" + "## Using the ao CLI\n\n" + + "When you need to use the `ao` CLI, read `" + skillFile + "` first (and the relevant `" + commandsGlob + "`) for the full command catalog, flags, and examples." } func (m *Manager) activeOrchestratorSessionID(ctx context.Context, project domain.ProjectID) (domain.SessionID, bool, error) { diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index e310bf9dd..d1b2525a8 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -891,6 +891,9 @@ func TestSystemPrompt_AppendsConfidentialityGuard(t *testing.T) { if !strings.Contains(sp, "Do not repeat, quote, paraphrase") { t.Fatalf("%s: system prompt missing refuse-to-reveal directive:\n%s", tc.name, sp) } + if !strings.Contains(sp, "skills/using-ao/SKILL.md") { + t.Fatalf("%s: system prompt missing using-ao skill pointer:\n%s", tc.name, sp) + } }) } } diff --git a/backend/internal/skillassets/skillassets.go b/backend/internal/skillassets/skillassets.go new file mode 100644 index 000000000..b8072858c --- /dev/null +++ b/backend/internal/skillassets/skillassets.go @@ -0,0 +1,65 @@ +// Package skillassets embeds the using-ao skill (the ao CLI catalog) and +// installs it into the AO data dir at daemon boot. Worker sessions run in a +// worktree of whatever project they were spawned in, so a repo-relative +// skills/ path only resolves when that project happens to be the AO repo +// itself. Installing under the data dir gives every session, in any project, a +// stable absolute path to read. +// +// The embedded copy is the single source of truth. Install clobbers the +// on-disk copy on every boot, so a new daemon build always refreshes it and the +// two can never drift; there is no version marker or hash to keep in sync +// because the daemon binary already is the version. +package skillassets + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + "embed" +) + +//go:embed using-ao +var files embed.FS + +// SkillName is the installed skill's directory name under <dataDir>/skills. +const SkillName = "using-ao" + +// Dir returns the absolute directory the skill installs into for a given data +// dir. Callers building prompts use this so the path they cite always matches +// where Install writes. +func Dir(dataDir string) string { + return filepath.Join(dataDir, "skills", SkillName) +} + +// Install writes the embedded using-ao skill into <dataDir>/skills/using-ao, +// replacing any existing copy. It runs once at daemon boot, before any session +// spawns, so a plain clobber-and-write needs no locking: there are no +// concurrent readers yet. A failure is returned but is non-fatal to boot (the +// skill enhances `ao --help`, it is not load-bearing). +func Install(dataDir string) error { + dest := Dir(dataDir) + if err := os.RemoveAll(dest); err != nil { + return fmt.Errorf("clear skill dir %q: %w", dest, err) + } + // embed.FS always uses forward-slash paths rooted at "using-ao"; map each + // onto <dataDir>/skills/<same path> with the platform separator. + return fs.WalkDir(files, SkillName, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + target := filepath.Join(dataDir, "skills", filepath.FromSlash(p)) + if d.IsDir() { + return os.MkdirAll(target, 0o750) + } + b, err := files.ReadFile(p) + if err != nil { + return fmt.Errorf("read embedded %q: %w", p, err) + } + if err := os.WriteFile(target, b, 0o600); err != nil { + return fmt.Errorf("write %q: %w", target, err) + } + return nil + }) +} diff --git a/backend/internal/skillassets/skillassets_test.go b/backend/internal/skillassets/skillassets_test.go new file mode 100644 index 000000000..a8d06564b --- /dev/null +++ b/backend/internal/skillassets/skillassets_test.go @@ -0,0 +1,41 @@ +package skillassets + +import ( + "os" + "path/filepath" + "testing" +) + +// TestInstall_WritesSkillAndIsIdempotent: Install must lay down the embedded +// skill (SKILL.md plus a commands file) under <dataDir>/skills/using-ao, and a +// second run must clobber cleanly, leaving no stale files. This is the whole +// contract the daemon boot hook relies on. +func TestInstall_WritesSkillAndIsIdempotent(t *testing.T) { + dataDir := t.TempDir() + + if err := Install(dataDir); err != nil { + t.Fatalf("Install: %v", err) + } + + skillFile := filepath.Join(Dir(dataDir), "SKILL.md") + if b, err := os.ReadFile(skillFile); err != nil { + t.Fatalf("read %s: %v", skillFile, err) + } else if len(b) == 0 { + t.Fatalf("SKILL.md is empty") + } + if _, err := os.Stat(filepath.Join(Dir(dataDir), "commands", "spawn.md")); err != nil { + t.Fatalf("commands/spawn.md missing: %v", err) + } + + // A stale file inside the skill dir must not survive a reinstall (clobber). + stale := filepath.Join(Dir(dataDir), "stale.md") + if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil { + t.Fatalf("seed stale file: %v", err) + } + if err := Install(dataDir); err != nil { + t.Fatalf("reinstall: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("stale file survived reinstall (err=%v)", err) + } +} diff --git a/backend/internal/skillassets/using-ao/SKILL.md b/backend/internal/skillassets/using-ao/SKILL.md new file mode 100644 index 000000000..7be54f5ba --- /dev/null +++ b/backend/internal/skillassets/using-ao/SKILL.md @@ -0,0 +1,36 @@ +--- +name: using-ao +description: Catalog of the AO (Agent Orchestrator) `ao` CLI: spawning workers, managing sessions and projects, sending messages, previewing pages, and daemon control. Use when using the ao CLI, spawning workers, or managing AO sessions in an AO workspace. +trigger: Using the ao CLI in an AO workspace: spawning workers, managing sessions/projects, sending messages, previewing pages. +--- + +# AO CLI Catalog + +`ao` is a thin CLI over the local AO daemon. Every command is `ao <command> --help` for the authoritative flag list. + +| Command | What it does | When to use | Details | +|---|---|---|---| +| `spawn` | Spawn a worker agent in a fresh git worktree | Starting a new task or issue | [commands/spawn.md](commands/spawn.md) | +| `session` | Manage agent sessions (list, kill, rename, restore, etc.) | Inspecting or controlling running/terminated sessions | [commands/session.md](commands/session.md) | +| `project` | Register, inspect, configure, or remove projects | Setting up or managing repos AO knows about | [commands/project.md](commands/project.md) | +| `orchestrator` | List orchestrator sessions | Viewing which sessions are orchestrators | [commands/orchestrator.md](commands/orchestrator.md) | +| `review` | Submit a reviewer result for a worker's PR | Completing a code review loop | [commands/review.md](commands/review.md) | +| `send` | Send a message to a running agent session | Correcting or directing a live agent | [commands/send.md](commands/send.md) | +| `preview` | Open a URL in the desktop browser panel | Demoing a local server or file from inside a session | [commands/preview.md](commands/preview.md) | +| `start` | Fetch (if needed) and open the AO desktop app | Launching the app | [commands/start.md](commands/start.md) | +| `stop` | Stop the AO daemon | Shutting down AO | [commands/stop.md](commands/stop.md) | +| `status` | Show daemon status | Verifying the daemon is up and healthy | [commands/status.md](commands/status.md) | +| `doctor` | Run local health checks | Diagnosing AO setup problems | [commands/doctor.md](commands/doctor.md) | +| `import` | Import projects from a legacy AO install | Migrating from the old flat-file store | [commands/import.md](commands/import.md) | +| `version` | Print version information | Checking installed version | - | +| `completion` | Generate shell completion scripts | Setting up tab completion | - | + +## Conventions + +- Most read commands accept `--json` for machine-readable output. +- `-p / --project` scopes session subcommand lookups to one project. +- Session and project ids are shown by `ao session ls` and `ao project ls`. +- `--agent` is an alias for `--harness` on `ao spawn`. +- Every command accepts `-h / --help` for the full flag list. + +See [references.md](references.md) for natural-language-to-command mappings. diff --git a/backend/internal/skillassets/using-ao/commands/doctor.md b/backend/internal/skillassets/using-ao/commands/doctor.md new file mode 100644 index 000000000..f406cc21b --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/doctor.md @@ -0,0 +1,27 @@ +# ao doctor + +Run local AO health checks. Use this to diagnose setup problems or verify the environment is correctly configured. + +## Syntax + +``` +ao doctor [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output health checks as JSON | - | + +## Examples + +```bash +# Run health checks +ao doctor +``` + +```bash +# Get health check results as JSON +ao doctor --json +``` diff --git a/backend/internal/skillassets/using-ao/commands/import.md b/backend/internal/skillassets/using-ao/commands/import.md new file mode 100644 index 000000000..a2d64721b --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/import.md @@ -0,0 +1,35 @@ +# ao import + +Import reads the legacy Agent Orchestrator flat-file store (`~/.agent-orchestrator`) read-only and ports its projects and per-project settings into the rewrite database. Legacy files are never modified, and a re-run skips rows that already exist, so it is safe to run more than once. The daemon must be stopped before running: it is the sole writer of the database. + +## Syntax + +``` +ao import [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--dry-run` | Parse and report the planned import without writing | - | +| `--from string` | Legacy AO root to read | `~/.agent-orchestrator` | +| `--json` | Output the import report as JSON | - | +| `-y, --yes` | Skip the confirmation prompt (for non-interactive use) | - | + +## Examples + +```bash +# Preview what would be imported without writing anything +ao import --dry-run +``` + +```bash +# Run the import non-interactively +ao import -y +``` + +```bash +# Import from a custom legacy path +ao import --from /tmp/old-agent-orchestrator -y +``` diff --git a/backend/internal/skillassets/using-ao/commands/orchestrator.md b/backend/internal/skillassets/using-ao/commands/orchestrator.md new file mode 100644 index 000000000..9f2f2ceec --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/orchestrator.md @@ -0,0 +1,40 @@ +# ao orchestrator + +Manage orchestrator sessions. + +## Syntax + +``` +ao orchestrator <subcommand> [flags] +``` + +## Subcommands + +--- + +### ao orchestrator ls + +List orchestrator sessions. Aliases: `ls`, `list`. + +**Syntax:** +``` +ao orchestrator ls [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output as JSON | - | + +## Examples + +```bash +# List all orchestrator sessions +ao orchestrator ls +``` + +```bash +# List orchestrator sessions as JSON +ao orchestrator ls --json +``` diff --git a/backend/internal/skillassets/using-ao/commands/preview.md b/backend/internal/skillassets/using-ao/commands/preview.md new file mode 100644 index 000000000..e2de8d586 --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/preview.md @@ -0,0 +1,62 @@ +# ao preview + +Open a URL in the desktop browser panel for the current session. With no argument it opens the workspace's static entry point, falling back to this session's existing preview target when no entry point exists. A local file can be opened by its absolute `file://` URL. Use `ao preview clear` to empty the panel. + +## Syntax + +``` +ao preview [url] [flags] +ao preview [command] +``` + +## Flags + +No flags beyond `-h / --help`. + +## Subcommands + +--- + +### ao preview (bare form) + +Open the workspace's static entry point, or the session's existing preview target. + +**Examples:** + +```bash +# Open the default entry point for this session's workspace +ao preview +``` + +```bash +# Open a local dev server +ao preview http://localhost:5173 +(or wherever the dev server is running) +``` + +```bash +# Open a local HTML file +ao preview file://$(pwd)/index.html +``` + +--- + +### ao preview clear + +Clear the desktop browser panel for the current session. + +**Syntax:** +``` +ao preview clear [flags] +``` + +**Flags:** + +No flags beyond `-h / --help`. + +**Examples:** + +```bash +# Clear the preview panel +ao preview clear +``` diff --git a/backend/internal/skillassets/using-ao/commands/project.md b/backend/internal/skillassets/using-ao/commands/project.md new file mode 100644 index 000000000..a58bac309 --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/project.md @@ -0,0 +1,163 @@ +# ao project + +Manage projects: register repos, inspect, configure per-project settings, and remove. + +## Syntax + +``` +ao project <subcommand> [args] [flags] +``` + +## Subcommands + +--- + +### ao project add + +Register a local git repo as a project so sessions can be spawned in it. The path must be an existing git repository on disk. With `--as-workspace`, the path may be a parent folder containing direct child git repositories; AO initializes/adopts the parent as the root repo and gitignores children. + +**Syntax:** +``` +ao project add [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--as-workspace` | Register a parent folder as a workspace project (root-as-repo plus direct child repos) | - | +| `--id string` | Project id | Derived by the daemon from the path | +| `--name string` | Display name | - | +| `--orchestrator-agent string` | Default orchestrator session agent | - | +| `--path string` | Absolute path to the local git repo | Required | +| `--worker-agent string` | Default worker session agent | - | + +**Examples:** + +```bash +# Register a repo as a project +ao project add --path /Users/harshit/Downloads/side-quests/agent-orchestrator --name "agent-orchestrator" +``` + +```bash +# Register a workspace (parent folder containing multiple repos) +ao project add --path /Users/harshit/Downloads/side-quests --as-workspace --name "side-quests" +``` + +--- + +### ao project ls + +List registered projects. Aliases: `ls`, `list`. + +**Syntax:** +``` +ao project ls [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output projects as JSON | - | + +**Examples:** + +```bash +# List all registered projects +ao project ls +``` + +--- + +### ao project get + +Fetch one registered project. + +**Syntax:** +``` +ao project get <id> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output project as JSON | - | + +**Examples:** + +```bash +# Get details for the agent-orchestrator project +ao project get agent-orchestrator +``` + +--- + +### ao project rm + +Remove a registered project. Aliases: `rm`, `remove`, `delete`. + +**Syntax:** +``` +ao project rm <id> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output removal result as JSON | - | +| `-y, --yes` | Skip confirmation prompt | - | + +**Examples:** + +```bash +# Remove a project (with confirmation) +ao project rm agent-orchestrator +``` + +```bash +# Remove without prompt +ao project rm agent-orchestrator -y +``` + +--- + +### ao project set-config + +Replace a project's per-project config (branch, session prefix, env, symlinks, post-create, agent model/permissions, role overrides). The config is resolved when a session spawns. Set fields via flags, pass the whole object with `--config-json`, or `--clear` to remove all config. + +**Syntax:** +``` +ao project set-config <id> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--clear` | Clear all config | - | +| `--config-json string` | Full config as a JSON object (overrides field flags) | - | +| `--default-branch string` | Base branch new session worktrees are created from | - | +| `--env stringArray` | Env var `KEY=VALUE` forwarded into sessions (repeatable) | - | +| `--json` | Output the updated project as JSON | - | +| `--model string` | Agent model override (e.g. `claude-opus-4-5`) | - | +| `--orchestrator-agent string` | Harness override for orchestrator sessions | - | +| `--permission string` | Permission mode: `default`, `accept-edits`, `auto`, `bypass-permissions` | - | +| `--post-create stringArray` | Command to run after workspace creation (repeatable) | - | +| `--session-prefix string` | Displayed session-id prefix | - | +| `--symlink stringArray` | Repo-relative path to symlink into workspaces (repeatable) | - | +| `--worker-agent string` | Harness override for worker sessions | - | + +**Examples:** + +```bash +# Set default branch and model for a project +ao project set-config agent-orchestrator --default-branch main --model claude-opus-4-5 +``` + +```bash +# Set an env var and a post-create command +ao project set-config agent-orchestrator --env "NODE_ENV=development" --post-create "npm install" +``` diff --git a/backend/internal/skillassets/using-ao/commands/review.md b/backend/internal/skillassets/using-ao/commands/review.md new file mode 100644 index 000000000..9677ad5fc --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/review.md @@ -0,0 +1,45 @@ +# ao review + +Manage AO code reviews of a worker's PR. + +## Syntax + +``` +ao review <subcommand> [args] [flags] +``` + +## Subcommands + +--- + +### ao review submit + +Record a reviewer's result for a worker's PR. + +**Syntax:** +``` +ao review submit [worker-session-id] [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--body string` | Review body: a path to a Markdown file, or `-` to read from stdin | - | +| `--review-id string` | Id of the GitHub PR review just posted (the `.id` from the `gh api` POST that created the review) | - | +| `--reviews string` | JSON review results array or object: a path, or `-` to read from stdin | - | +| `--run string` | Review run id | Required | +| `--session string` | Worker session id (or pass it as the positional argument) | - | +| `--verdict string` | Review verdict: `approved` or `changes_requested` | Required | + +## Examples + +```bash +# Submit an approved review for session mer-3 +ao review submit mer-3 --run review-run-1 --verdict approved +``` + +```bash +# Submit a changes-requested review with a body from stdin +echo "Please fix the null check on line 42." | ao review submit --session mer-3 --run review-run-1 --verdict changes_requested --body - +``` diff --git a/backend/internal/skillassets/using-ao/commands/send.md b/backend/internal/skillassets/using-ao/commands/send.md new file mode 100644 index 000000000..08565d3be --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/send.md @@ -0,0 +1,28 @@ +# ao send + +Send a message to a running agent session. Use this to correct or direct a live agent mid-stream without killing and respawning it. + +## Syntax + +``` +ao send [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--message string` | Message body | Required | +| `--session string` | Session id | Required | + +## Examples + +```bash +# Send a correction to a running session +ao send --session mer-3 --message "Focus only on the backend; ignore frontend files." +``` + +```bash +# Give the agent new instructions mid-task +ao send --session mer-3 --message "The issue is in session_manager.go line 142, not in the CLI. Investigate there." +``` diff --git a/backend/internal/skillassets/using-ao/commands/session.md b/backend/internal/skillassets/using-ao/commands/session.md new file mode 100644 index 000000000..29ba2ae3b --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/session.md @@ -0,0 +1,206 @@ +# ao session + +Manage agent sessions: list, inspect, rename, kill, restore, clean up, and claim PRs. + +## Syntax + +``` +ao session <subcommand> [args] [flags] +``` + +## Subcommands + +--- + +### ao session ls + +List sessions. + +**Syntax:** +``` +ao session ls [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `-a, --all` | Include orchestrator sessions | - | +| `--include-terminated` | Include terminated sessions | - | +| `--json` | Output as JSON | - | +| `-p, --project string` | Filter by project ID | - | + +**Examples:** + +```bash +# List all active worker sessions +ao session ls +``` + +```bash +# List all sessions including terminated, scoped to one project +ao session ls --include-terminated -p agent-orchestrator +``` + +--- + +### ao session get + +Fetch one session. + +**Syntax:** +``` +ao session get <id> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output as JSON | - | +| `-p, --project string` | Project id to scope the lookup | - | + +**Examples:** + +```bash +# Get details for session mer-3 +ao session get mer-3 +``` + +```bash +# Get session details as JSON +ao session get mer-3 --json +``` + +--- + +### ao session kill + +Terminate a session. + +**Syntax:** +``` +ao session kill <id> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `-p, --project string` | Project id to scope the lookup | - | + +**Examples:** + +```bash +# Kill session mer-3 +ao session kill mer-3 +``` + +--- + +### ao session rename + +Rename a session. + +**Syntax:** +``` +ao session rename <id> <name> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `-p, --project string` | Project id to scope the lookup | - | + +**Examples:** + +```bash +# Rename session mer-3 to a new display name +ao session rename mer-3 "fix-auth-bug" +``` + +--- + +### ao session restore + +Relaunch a terminated session. + +**Syntax:** +``` +ao session restore <id> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `-p, --project string` | Project id to scope the lookup | - | + +**Examples:** + +```bash +# Restore a terminated session +ao session restore mer-3 +``` + +--- + +### ao session cleanup + +Clean up terminated sessions by reclaiming eligible workspaces. Dirty worktrees are skipped by the daemon. + +**Syntax:** +``` +ao session cleanup [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `-p, --project string` | Filter by project ID | - | +| `-y, --yes` | Skip confirmation prompt | - | + +**Examples:** + +```bash +# Clean up all terminated sessions (skip prompt) +ao session cleanup -y +``` + +```bash +# Clean up terminated sessions for one project +ao session cleanup -p agent-orchestrator +``` + +--- + +### ao session claim-pr + +Attach an existing PR to a session. + +**Syntax:** +``` +ao session claim-pr <session-id> <pr-ref> [flags] +``` + +**Flags:** + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output as JSON | - | +| `--no-takeover` | Refuse if another active session owns the PR | - | +| `-p, --project string` | Project id to scope the lookup | - | + +**Examples:** + +```bash +# Attach PR 88 to session mer-3 +ao session claim-pr mer-3 88 +``` + +```bash +# Claim PR 88 but refuse if another session already owns it +ao session claim-pr mer-3 88 --no-takeover +``` diff --git a/backend/internal/skillassets/using-ao/commands/spawn.md b/backend/internal/skillassets/using-ao/commands/spawn.md new file mode 100644 index 000000000..8fe31bde4 --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/spawn.md @@ -0,0 +1,38 @@ +# ao spawn + +Spawn a worker agent session in a registered project. The session runs the chosen agent in a fresh git worktree. Register the project first with `ao project add`. + +## Syntax + +``` +ao spawn [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--branch string` | Branch for the session worktree | `ao/<session-id>/root` | +| `--claim-pr string` | Immediately claim an existing PR for the spawned session | - | +| `--harness string` | Agent harness to use (see list below) | Project `worker.agent`; required if the project has none | +| `--issue string` | Issue id to associate with the session | - | +| `--name string` | Display name shown in the sidebar (max 20 characters) | Required | +| `--no-takeover` | Refuse if another active session owns the claimed PR (requires `--claim-pr`) | - | +| `--project string` | Project id to spawn the session in | Required | +| `--prompt string` | Initial prompt for the agent | - | + +`--agent` is an alias for `--harness`. + +Available harnesses: `claude-code`, `codex`, `aider`, `opencode`, `grok`, `droid`, `amp`, `agy`, `crush`, `cursor`, `qwen`, `copilot`, `goose`, `auggie`, `continue`, `devin`, `cline`, `kimi`, `kiro`, `kilocode`, `vibe`, `pi`, `autohand`. + +## Examples + +```bash +# Spawn a worker for issue 142 in the agent-orchestrator project +ao spawn --project agent-orchestrator --issue 142 --name "fix-session-leak" --prompt "Fix the session leak described in issue 142. Branch off upstream/main." +``` + +```bash +# Spawn a worker and immediately claim an open PR +ao spawn --project agent-orchestrator --name "review-pr-88" --claim-pr 88 --harness claude-code +``` diff --git a/backend/internal/skillassets/using-ao/commands/start.md b/backend/internal/skillassets/using-ao/commands/start.md new file mode 100644 index 000000000..33937a8ce --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/start.md @@ -0,0 +1,27 @@ +# ao start + +Fetch (if needed) and open the Agent Orchestrator desktop app. The desktop app owns the daemon, state, and updates. `ao start` no longer runs a daemon: it resolves the installed app (or downloads the latest release), opens it, and exits. + +## Syntax + +``` +ao start [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output start result as JSON | - | + +## Examples + +```bash +# Open the AO desktop app +ao start +``` + +```bash +# Open the app and get the result as JSON +ao start --json +``` diff --git a/backend/internal/skillassets/using-ao/commands/status.md b/backend/internal/skillassets/using-ao/commands/status.md new file mode 100644 index 000000000..65a587717 --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/status.md @@ -0,0 +1,27 @@ +# ao status + +Show AO daemon status. Use this to verify the daemon is up and check which port it is bound to. + +## Syntax + +``` +ao status [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output status as JSON | - | + +## Examples + +```bash +# Check daemon status +ao status +``` + +```bash +# Get status as JSON (e.g. to check port programmatically) +ao status --json +``` diff --git a/backend/internal/skillassets/using-ao/commands/stop.md b/backend/internal/skillassets/using-ao/commands/stop.md new file mode 100644 index 000000000..dd731542f --- /dev/null +++ b/backend/internal/skillassets/using-ao/commands/stop.md @@ -0,0 +1,28 @@ +# ao stop + +Stop the AO daemon. + +## Syntax + +``` +ao stop [flags] +``` + +## Flags + +| Flag | Meaning | Default / Required | +|---|---|---| +| `--json` | Output stop result as JSON | - | +| `--timeout duration` | How long to wait for daemon shutdown | `10s` | + +## Examples + +```bash +# Stop the daemon +ao stop +``` + +```bash +# Stop with a longer timeout +ao stop --timeout 30s +``` diff --git a/backend/internal/skillassets/using-ao/references.md b/backend/internal/skillassets/using-ao/references.md new file mode 100644 index 000000000..ef9fc5507 --- /dev/null +++ b/backend/internal/skillassets/using-ao/references.md @@ -0,0 +1,26 @@ +# Quick Reference + +Natural-language-to-command mappings for common AO tasks. + +| You want to... | Command | +|---|---| +| Show me this webpage / open this page | `ao preview "<url>"` | +| Spawn a worker on issue N | `ao spawn --project <p> --issue N --name "<=20 chars>" --prompt "..."` | +| Message a running agent | `ao send --session <id> --message "..."` | +| Kill a session | `ao session kill <id>` | +| List sessions | `ao session ls` | +| Register a repo as a project | `ao project add --path <abs-path> --name <name>` | +| List projects | `ao project ls` | +| Rename a session | `ao session rename <id> "<name>"` | +| Restore a killed session | `ao session restore <id>` | +| Clean up terminated sessions | `ao session cleanup` | +| See a session's details | `ao session get <id>` | +| Open the desktop app | `ao start` | +| Check the daemon is up | `ao status` | +| Run health checks | `ao doctor` | +| Clear the preview panel | `ao preview clear` | +| List orchestrator sessions | `ao orchestrator ls` | +| Claim an existing PR for a session | `ao session claim-pr <id> <pr-ref>` | +| Submit a code review verdict | `ao review submit <session-id> --run <run-id> --verdict approved` | +| Configure a project's default branch or model | `ao project set-config <id> --default-branch <branch> --model <model>` | +| Import projects from a legacy AO install | `ao import --dry-run` (preview), then `ao import -y` | From 3aa8e044b22aeb6a18d0e17d77ffe2f6249cf243 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 19:59:27 +0530 Subject: [PATCH 29/43] test(session_manager): prove agent sessions survive daemon restart/upgrade (#2335) (#2350) * test(session_manager): prove sessions survive daemon restart and re-adopt (#2335) Add an end-to-end Reconcile() durability test covering the full mix of session states a daemon restart/upgrade leaves behind: an alive orchestrator and worker are adopted in place under their original ids (no id increment, runtime never torn down), a worker whose runtime died with the daemon is captured and relaunched under its original id, and a truly-dead unmarked session is not resurrected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .../internal/session_manager/manager_test.go | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index d1b2525a8..490c84c1f 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -1867,6 +1867,94 @@ func TestReconcileLive_ProbeErrorIsNotDeath(t *testing.T) { } } +// TestReconcile_AdoptAcrossDaemonRestart is the end-to-end durability proof for +// #2335: it drives the full boot-time Reconcile pass over the exact mix of +// session states a daemon restart/upgrade leaves behind and asserts agent +// sessions are decoupled from the daemon's lifetime: +// +// - an alive orchestrator is ADOPTED in place: same id, still live, its runtime +// never torn down, and NO new session minted (the id-increment regression +// guard: adoption failure used to mint a fresh orchestrator id 14->15->16). +// - an alive worker is adopted as a no-op. +// - a worker whose runtime died with the daemon has its work captured (stashed +// into a preserve ref, restore marker written) and is relaunched on this same +// boot under its ORIGINAL id. +// - a truly-dead session with no restore marker is NOT resurrected. +func TestReconcile_AdoptAcrossDaemonRestart(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()} + rt := &fakeRuntime{aliveByHandle: map[string]bool{ + "orch": true, // orchestrator runtime survived the daemon exit + "w-alive": true, // worker runtime survived the daemon exit + // "w-dead" is absent -> that worker's runtime died with the daemon. + }} + ws := &fakeWorkspace{stashRef: "refs/ao/preserved/mer-3"} + lcm := &fakeLCM{store: st} + lookPath := func(string) (string, error) { return "/bin/true", nil } + m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath}) + + // Alive orchestrator: the promptless session whose adoption failure used to + // mint a fresh orchestrator id. It must be adopted in place. + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, Harness: domain.HarnessClaudeCode, + Metadata: domain.SessionMetadata{Branch: "ao/mer-1/root", WorkspacePath: "/ws/mer-1", RuntimeHandleID: "orch"}, + } + // Alive worker: adopted as a no-op. + st.sessions["mer-2"] = domain.SessionRecord{ + ID: "mer-2", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, + Metadata: domain.SessionMetadata{Branch: "ao/mer-2/root", WorkspacePath: "/ws/mer-2", RuntimeHandleID: "w-alive", AgentSessionID: "agent-2"}, + } + // Dead worker: its runtime died with the daemon; capture + relaunch under same id. + st.sessions["mer-3"] = domain.SessionRecord{ + ID: "mer-3", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, + Metadata: domain.SessionMetadata{Branch: "ao/mer-3/root", WorkspacePath: "/ws/mer-3", RuntimeHandleID: "w-dead", AgentSessionID: "agent-3"}, + } + // Truly-dead session the user killed before restart (terminated, no marker). + st.sessions["mer-4"] = domain.SessionRecord{ + ID: "mer-4", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, + IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}, + Metadata: domain.SessionMetadata{Branch: "ao/mer-4/root", WorkspacePath: "/ws/mer-4"}, + } + + if err := m.Reconcile(ctx); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + // Alive orchestrator + worker adopted in place: same id, still live. + if st.sessions["mer-1"].IsTerminated { + t.Fatal("alive orchestrator must be adopted in place, not terminated") + } + if st.sessions["mer-2"].IsTerminated { + t.Fatal("alive worker must be adopted in place, not terminated") + } + // No id increment: Reconcile must never mint a new session row. + if st.num != 0 { + t.Fatalf("Reconcile minted %d new session(s); adoption must reuse existing ids", st.num) + } + // Adopted runtimes were never torn down. + if rt.destroyed != 0 { + t.Fatalf("adopted sessions must not be destroyed; Destroy called %d times", rt.destroyed) + } + // Dead worker captured, then relaunched under its original id on this same boot. + if lcm.terminated["mer-3"] != 1 { + t.Fatalf("dead worker must be marked terminated once before relaunch; got %d", lcm.terminated["mer-3"]) + } + if st.sessions["mer-3"].IsTerminated { + t.Fatal("dead worker must be relaunched (not terminated) after Reconcile") + } + if rt.created != 1 { + t.Fatalf("exactly one runtime relaunch expected (the dead worker); got %d", rt.created) + } + // One-shot restore marker consumed so it never outlives one restart (#2319). + if rows := st.worktrees["mer-3"]; len(rows) != 0 { + t.Fatalf("restore marker for mer-3 must be deleted after relaunch; got %+v", rows) + } + // Truly-dead, unmarked session is NOT resurrected. + if !st.sessions["mer-4"].IsTerminated { + t.Fatal("terminated session with no restore marker must stay terminated") + } +} + func TestReconcileReap_TerminatedButAliveTmuxDestroyed(t *testing.T) { st := newFakeStore() rt := &fakeRuntime{aliveByHandle: map[string]bool{"t1": true}} From a32d507eb8b9f9e99bc9d3e94d2d7c3977d66821 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 20:50:41 +0530 Subject: [PATCH 30/43] feat(scm): detect cross-fork PRs by scanning all project remotes (#2368) The SCM observer only polled a project's push origin for open PRs, so a PR opened from the fork against an upstream base repo (the standard fork -> upstream flow) was never discovered: GitHub lists a PR under its base repo, not its head, so an origin-only scan can't see it. Sessions that opened such PRs showed no PR on the dashboard. Scan every GitHub remote in the project checkout (origin + upstreams / mirrors) for open PRs, and attribute a PR to a session only when its head branch lives in that session's push origin. This surfaces cross-fork PRs while preserving the no-misattribution guarantee: a stranger's fork PR has a head repo no session owns and is dropped. Tracked cross-fork PRs are keyed for refresh by their own recorded repo (the upstream base) so the GraphQL refetch targets the right repo. --- backend/internal/observe/scm/observer.go | 142 +++++++++++++++--- backend/internal/observe/scm/observer_test.go | 118 ++++++++++++++- 2 files changed, 240 insertions(+), 20 deletions(-) diff --git a/backend/internal/observe/scm/observer.go b/backend/internal/observe/scm/observer.go index 1ca0fa10e..780e669fc 100644 --- a/backend/internal/observe/scm/observer.go +++ b/backend/internal/observe/scm/observer.go @@ -191,12 +191,18 @@ type subject struct { hasPR bool } -// sessionRepo pairs a live session with its parsed repo and branch for per-repo -// branch-prefix discovery of new (including stacked) pull requests. +// sessionRepo pairs a live session with a repo to scan and its branch for +// per-repo branch-prefix discovery of new (including stacked) pull requests. +// A session is scanned against its push origin plus every other remote in the +// project checkout, so repo is the repo whose open-PR list is listed while +// headRepo is the repo the session's head branch actually lives in (the push +// origin). For same-repo PRs repo == headRepo; for a cross-fork PR (fork head, +// upstream base) repo is the upstream base and headRepo is the fork origin. type sessionRepo struct { - session domain.SessionRecord - repo ports.SCMRepo - branch string + session domain.SessionRecord + repo ports.SCMRepo + headRepo ports.SCMRepo + branch string } type repoGuardState struct { @@ -424,6 +430,8 @@ func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, [ return nil, nil, err } projects := map[domain.ProjectID]domain.ProjectRecord{} + originRepos := map[domain.ProjectID]ports.SCMRepo{} + scanRepos := map[domain.ProjectID][]ports.SCMRepo{} out := map[string]*subject{} var sessionRepos []sessionRepo for _, sess := range sessions { @@ -453,29 +461,87 @@ func (o *Observer) discoverSubjects(ctx context.Context) (map[string]*subject, [ } projects[sess.ProjectID] = p proj = p + if origin, ok := o.provider.ParseRepository(p.RepoOriginURL); ok { + originRepos[sess.ProjectID] = origin + scanRepos[sess.ProjectID] = o.resolveScanRepos(p, origin) + } } - repo, ok := o.provider.ParseRepository(proj.RepoOriginURL) + origin, ok := originRepos[sess.ProjectID] if !ok { o.logger.Debug("scm observer: project has no supported SCM origin", "project", proj.ID, "origin", proj.RepoOriginURL) continue } - sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, branch: branch}) + for _, repo := range scanRepos[sess.ProjectID] { + sessionRepos = append(sessionRepos, sessionRepo{session: sess, repo: repo, headRepo: origin, branch: branch}) + } prs, err := o.store.ListPRsBySession(ctx, sess.ID) if err != nil { return nil, nil, err } for _, pr := range openTrackedPRs(prs) { - key := prKey(repo, pr.Number) + // A tracked PR may live on an upstream repo (cross-fork), so its + // refresh subject is keyed by the PR's own recorded repo, not the + // push origin, or the GraphQL refetch would target the wrong repo. + prRepo := subjectRepoForPR(pr, origin) + key := prKey(prRepo, pr.Number) if existing, ok := out[key]; ok { o.logger.Warn("scm observer: duplicate tracked PR ownership skipped", "pr", key, "kept_session", existing.session.ID, "skipped_session", sess.ID) continue } - out[key] = &subject{session: sess, repo: repo, branch: branch, known: pr, hasPR: true} + out[key] = &subject{session: sess, repo: prRepo, branch: branch, known: pr, hasPR: true} } } return out, sessionRepos, nil } +// resolveScanRepos returns the deduped set of repos whose open-PR lists should be +// scanned to attribute PRs to this project's sessions: the push origin plus every +// other GitHub remote configured in the project checkout (upstreams, mirrors). +// Attribution still requires a PR's head branch to live in the origin, so scanning +// extra remotes only surfaces cross-fork PRs (fork head, upstream base) and can +// never misattribute a stranger's PR. +// +// ponytail: remotes are read once per project per process (memoized by the +// caller); a remote added after the daemon started is picked up on restart. Move +// to a git-config watch if that latency ever matters. +func (o *Observer) resolveScanRepos(proj domain.ProjectRecord, origin ports.SCMRepo) []ports.SCMRepo { + repos := []ports.SCMRepo{origin} + if strings.TrimSpace(proj.Path) == "" { + return repos + } + seen := map[string]bool{prKey(origin, 0): true} + for _, url := range gitRemoteURLs(proj.Path) { + repo, ok := o.provider.ParseRepository(url) + if !ok { + continue + } + key := prKey(repo, 0) + if seen[key] { + continue + } + seen[key] = true + repos = append(repos, repo) + } + return repos +} + +// subjectRepoForPR resolves the repo that owns a tracked PR's number for refresh. +// A cross-fork PR lives on the base/upstream repo recorded in pr.Repo rather than +// the push origin, so the refresh/GraphQL fetch must target pr.Repo. Legacy rows +// written before pr.Repo was populated fall back to the origin. +func subjectRepoForPR(pr domain.PullRequest, origin ports.SCMRepo) ports.SCMRepo { + if strings.TrimSpace(pr.Repo) == "" { + return origin + } + return ports.SCMRepo{ + Provider: firstNonEmpty(pr.Provider, origin.Provider), + Host: firstNonEmpty(pr.Host, origin.Host), + Repo: pr.Repo, + Owner: ownerOf(pr.Repo), + Name: nameOf(pr.Repo), + } +} + func openTrackedPRs(prs []domain.PullRequest) []domain.PullRequest { out := make([]domain.PullRequest, 0, len(prs)) for _, pr := range prs { @@ -550,20 +616,19 @@ func (o *Observer) discoverNewPRs(ctx context.Context, sessionRepos []sessionRep if pr.Number <= 0 || pr.SourceBranch == "" { continue } - // Branch-prefix attribution must only claim PRs whose head branch - // lives in the project repo. A fork PR can carry a head branch whose - // name matches an AO session branch; its commits live in the fork, so - // auto-claiming it would misattribute work. Same-repo PRs always - // report the base repo's full name as their head repo, so anything - // else (including an empty head repo from a deleted fork) is skipped. - if !strings.EqualFold(pr.HeadRepo, repoFullName(repo)) { - continue - } key := prKey(repo, pr.Number) if _, ok := subjects[key]; ok { continue } - sr, ok := matchSession(byRepo[repoKey], pr.SourceBranch) + // Branch-prefix attribution must only claim PRs whose head branch + // lives in a session's push origin. A same-repo PR has head == origin + // == this scanned repo; a cross-fork PR (fork head, upstream base) has + // head == origin while this scanned repo is the upstream base. A + // stranger's fork PR carries a head repo no session owns and is + // dropped (as is an empty head repo from a deleted fork), preserving + // the no-misattribution guarantee. + eligible := candidatesForHeadRepo(byRepo[repoKey], pr.HeadRepo) + sr, ok := matchSession(eligible, pr.SourceBranch) if !ok { continue } @@ -612,6 +677,24 @@ func (o *Observer) discoverNewPRs(ctx context.Context, sessionRepos []sessionRep // branches are prefixes of the same source branch the longest (most specific) // one wins, so a child session claims its own stacked PRs rather than the // ancestor session. +// candidatesForHeadRepo narrows the scanned repo's session candidates to those +// whose head branch lives in headRepo (the PR's head repository full name). This +// is the fork guard: a PR is only attributable when its head repo equals a +// session's push origin, whether the PR was found on the origin itself or on a +// scanned upstream base repo. +func candidatesForHeadRepo(candidates []sessionRepo, headRepo string) []sessionRepo { + if strings.TrimSpace(headRepo) == "" { + return nil + } + var out []sessionRepo + for _, sr := range candidates { + if strings.EqualFold(repoFullName(sr.headRepo), headRepo) { + out = append(out, sr) + } + } + return out +} + func matchSession(candidates []sessionRepo, sourceBranch string) (sessionRepo, bool) { var best sessionRepo bestLen := -1 @@ -1232,6 +1315,27 @@ func resolveGitOriginURL(path string) string { return strings.TrimSpace(string(out)) } +// gitRemoteURLs lists the fetch URL of every git remote configured at path. It +// returns nil on any error (missing repo, no git, no remotes). The observer uses +// it to scan upstream/mirror remotes for cross-fork PRs in addition to origin. +func gitRemoteURLs(path string) []string { + out, err := aoprocess.Command("git", "-C", path, "remote").Output() + if err != nil { + return nil + } + var urls []string + for _, name := range strings.Fields(string(out)) { + u, err := aoprocess.Command("git", "-C", path, "remote", "get-url", name).Output() + if err != nil { + continue + } + if s := strings.TrimSpace(string(u)); s != "" { + urls = append(urls, s) + } + } + return urls +} + func scrubLine(s string) string { s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") diff --git a/backend/internal/observe/scm/observer_test.go b/backend/internal/observe/scm/observer_test.go index dec26750a..f07b1fef5 100644 --- a/backend/internal/observe/scm/observer_test.go +++ b/backend/internal/observe/scm/observer_test.go @@ -139,7 +139,18 @@ func (p *fakeProvider) SCMCredentialsAvailable(context.Context) (bool, error) { } func (p *fakeProvider) ParseRepository(remote string) (ports.SCMRepo, bool) { - return testRepo, remote != "" + remote = strings.TrimSpace(remote) + if remote == "" { + return ports.SCMRepo{}, false + } + s := strings.TrimSuffix(remote, ".git") + s = strings.TrimPrefix(s, "https://github.com/") + s = strings.TrimPrefix(s, "git@github.com:") + parts := strings.Split(strings.Trim(s, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return ports.SCMRepo{}, false + } + return ports.SCMRepo{Provider: "github", Host: "github.com", Owner: parts[0], Name: parts[1], Repo: parts[0] + "/" + parts[1]}, true } func (p *fakeProvider) RepoPRListGuard(_ context.Context, repo ports.SCMRepo, _ string) (ports.SCMGuardResult, error) { p.mu.Lock() @@ -540,6 +551,111 @@ func TestPoll_IgnoresForkPRWithMatchingBranch(t *testing.T) { } } +func mustGit(t *testing.T, args ...string) { + t.Helper() + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, out) + } +} + +// A PR opened from the fork push origin against an upstream base repo (the +// standard fork -> upstream contribution flow) must be discovered and attributed +// by scanning every remote in the project checkout, not just origin. Its head +// lives in origin (o/r) while its base lives in upstream (up/r); the persisted +// row records the upstream base repo so refresh refetches against it. +func TestPoll_DiscoversCrossForkPRFromUpstreamRemote(t *testing.T) { + dir := t.TempDir() + mustGit(t, "init", dir) + mustGit(t, "-C", dir, "remote", "add", "origin", "https://github.com/o/r.git") + mustGit(t, "-C", dir, "remote", "add", "upstream", "https://github.com/up/r.git") + + upstream := ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "up", Name: "r", Repo: "up/r"} + store := &fakeStore{ + sessions: []domain.SessionRecord{{ID: "p-1", ProjectID: "p", Metadata: domain.SessionMetadata{Branch: "ao/p-1/root"}}}, + projects: map[string]domain.ProjectRecord{"p": {ID: "p", Path: dir, RepoOriginURL: "https://github.com/o/r.git"}}, + prs: map[domain.SessionID][]domain.PullRequest{}, + checks: map[string][]domain.PullRequestCheck{}, + } + crossObs := ports.SCMObservation{ + Fetched: true, Provider: "github", Host: "github.com", Repo: "up/r", + PR: ports.SCMPRObservation{URL: "https://github.com/up/r/pull/1", Number: 1, State: "open", SourceBranch: "ao/p-1/feat", HeadRepo: "o/r", TargetBranch: "main", HeadSHA: "sha1", Title: "PR"}, + CI: ports.SCMCIObservation{Summary: string(domain.CIPassing), HeadSHA: "sha1"}, + Review: ports.SCMReviewObservation{Decision: string(domain.ReviewNone)}, + Mergeability: ports.SCMMergeabilityObservation{State: string(domain.MergeMergeable), Mergeable: true}, + } + provider := &fakeProvider{ + repoGuards: map[string]ports.SCMGuardResult{prKey(testRepo, 0): {ETag: "origin"}, prKey(upstream, 0): {ETag: "up"}}, + openPRs: map[string][]ports.SCMPRObservation{ + prKey(upstream, 0): {{URL: "https://github.com/up/r/pull/1", Number: 1, SourceBranch: "ao/p-1/feat", HeadRepo: "o/r", TargetBranch: "main", HeadSHA: "sha1"}}, + }, + observations: map[string]ports.SCMObservation{prKey(upstream, 1): crossObs}, + } + lc := &fakeLifecycle{} + obs := newTestObserver(store, provider, lc, time.Unix(1, 0).UTC()) + if err := obs.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if len(store.writes) == 0 { + t.Fatal("expected cross-fork PR write") + } + got := store.writes[0].pr + if got.SessionID != "p-1" { + t.Fatalf("session id = %q, want p-1", got.SessionID) + } + if got.Repo != "up/r" { + t.Fatalf("pr repo = %q, want up/r (upstream base)", got.Repo) + } + if got.SourceBranch != "ao/p-1/feat" { + t.Fatalf("source branch = %q, want ao/p-1/feat", got.SourceBranch) + } + fetched := false + for _, batch := range provider.fetchBatches { + for _, ref := range batch { + if ref.Repo.Repo == "up/r" && ref.Number == 1 { + fetched = true + } + } + } + if !fetched { + t.Fatalf("cross-fork PR must be refreshed against upstream, batches=%#v", provider.fetchBatches) + } +} + +// A PR on a scanned upstream remote whose head lives in some third-party fork +// (not this project's origin) must never be attributed, even though its branch +// name matches a session. Scanning extra remotes stays safe. +func TestPoll_IgnoresUpstreamPRFromForeignHead(t *testing.T) { + dir := t.TempDir() + mustGit(t, "init", dir) + mustGit(t, "-C", dir, "remote", "add", "origin", "https://github.com/o/r.git") + mustGit(t, "-C", dir, "remote", "add", "upstream", "https://github.com/up/r.git") + + upstream := ports.SCMRepo{Provider: "github", Host: "github.com", Owner: "up", Name: "r", Repo: "up/r"} + store := &fakeStore{ + sessions: []domain.SessionRecord{{ID: "p-1", ProjectID: "p", Metadata: domain.SessionMetadata{Branch: "ao/p-1/root"}}}, + projects: map[string]domain.ProjectRecord{"p": {ID: "p", Path: dir, RepoOriginURL: "https://github.com/o/r.git"}}, + prs: map[domain.SessionID][]domain.PullRequest{}, + checks: map[string][]domain.PullRequestCheck{}, + } + provider := &fakeProvider{ + repoGuards: map[string]ports.SCMGuardResult{prKey(testRepo, 0): {ETag: "origin"}, prKey(upstream, 0): {ETag: "up"}}, + openPRs: map[string][]ports.SCMPRObservation{ + prKey(upstream, 0): {{URL: "https://github.com/up/r/pull/9", Number: 9, SourceBranch: "ao/p-1/feat", HeadRepo: "stranger/r", TargetBranch: "main", HeadSHA: "sha9"}}, + }, + observations: map[string]ports.SCMObservation{}, + } + obs := newTestObserver(store, provider, &fakeLifecycle{}, time.Unix(1, 0).UTC()) + if err := obs.Poll(context.Background()); err != nil { + t.Fatal(err) + } + if len(provider.fetchBatches) != 0 { + t.Fatalf("foreign-head upstream PR must not be fetched, got %#v", provider.fetchBatches) + } + if len(store.writes) != 0 { + t.Fatalf("foreign-head upstream PR must not be persisted, got %d writes", len(store.writes)) + } +} + // A newly discovered open PR is persisted as a baseline row during discovery, // before the refresh/lifecycle pass. This is what lets a same-poll terminal // observation for a sibling PR see the open PR in the store and avoid completing From 365c99f705f884971da6a18656907007dff9a0be Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:59:51 +0530 Subject: [PATCH 31/43] docs: add first-timer README overview (#2384) Co-authored-by: Vaibhaav <user@example.com> --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0faa318d3..e9adb764c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,49 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, ![Agent Orchestrator Dashboard](ao-dashboard-preview.png) +</div> + +--- + +## What is Agent Orchestrator? + +Agent Orchestrator is a meta-harness agent IDE for running AI coding agents in parallel. It gives terminal-based agents like Claude Code, Codex, Cursor, Aider, Goose, and others a shared workspace where their sessions, terminals, branches, pull requests, and feedback loops can be supervised from one place. + +The agents still do the coding. AO provides the harness around them: isolated workspaces, live terminal access, session state, PR awareness, and automatic loops that send CI failures, review comments, and merge conflicts back to the right agent. Instead of manually coordinating a pile of agent terminals, AO turns parallel agent work into a managed workflow. + +--- + +## Why Agent Orchestrator? + +AI coding agents become much more useful when they can work in parallel, but parallel work gets messy quickly. Branches overlap, terminals get lost, CI failures need follow-up, review comments need replies, and merge conflicts have to reach the right worker. + +Agent Orchestrator is built to keep that loop visible and manageable. It helps you: + +- Start multiple agents from the same project without mixing their work +- Keep every session in a separate git worktree +- See which agents are working, waiting, finished, or blocked +- Route CI failures, review comments, and merge conflicts back to the right session +- Use different agent CLIs through one common supervisor + +--- + +## How it works + +At a high level, Agent Orchestrator follows a simple loop: + +1. Add a project you want agents to work on. +2. Start one or more sessions from the desktop app or CLI. +3. AO creates an isolated git worktree for each session. +4. AO launches the selected coding agent in that session's terminal runtime. +5. The local daemon watches session state, terminal activity, pull requests, CI, and review feedback. +6. The desktop app and CLI show the current state and let you send follow-up instructions to the right session. + +The result is a local control layer for agentic coding: agents still do the coding, while Agent Orchestrator keeps their workspaces, status, terminals, and feedback loops organized. + +--- + +<div align="center"> + ### Witness AO's Journey on X <table border="1" style="border-collapse: collapse; width: 100%;"> @@ -41,7 +84,7 @@ An Agentic IDE that supervises parallel AI coding agents in isolated workspaces, </tr> </table> -[Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing) +[What is Agent Orchestrator?](#what-is-agent-orchestrator) • [Why Agent Orchestrator?](#why-agent-orchestrator) • [How it works](#how-it-works) • [Features](#features) • [Quick Start](#quick-start) • [Architecture](#architecture) • [Documentation](#documentation) • [Contributing](#contributing) </div> From 52d8c6051a010c10a6d1feb73b4c45c16c869984 Mon Sep 17 00:00:00 2001 From: Vaibhaav Tiwari <155460282+Vaibhaav-Tiwari@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:17:02 +0530 Subject: [PATCH 32/43] docs: add contributing guide (#2385) Co-authored-by: Vaibhaav <user@example.com> --- CONTRIBUTING.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7dbc0eca8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +We love contributions! Join our community on Discord to get started. + +## Join us on Discord + +[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white&logoSize=auto)](https://discord.com/invite/UZv7JjxbwG) + +**Daily contributor sync:** Every day at **10:00 PM IST** + +Get your issues verified by core contributors, ask questions, share progress, and learn from the community. New contributors are always welcome! + +**Why join Discord?** + +- Get your issues and PRs verified by core contributors before investing time +- Learn from experienced contributors in daily sync calls +- Share your progress and get feedback +- Get help troubleshooting in real-time +- Stay updated on the latest developments and roadmap + +## Quick Start + +1. **Join the Discord** - Connect with the community and get guidance +2. **Read the contributor contract** - See [AGENTS.md](AGENTS.md) for repo layout, daemon/API boundaries, and coding conventions +3. **Pick a focused problem** - Browse [open issues](https://github.com/AgentWrapper/agent-orchestrator/issues) and choose one small enough for a focused PR +4. **Open a clear PR** - Keep changes narrow, explain user-visible impact, link issues, include tests +5. **Iterate with contributors** - Use review feedback to tighten the PR until verified From f92ff1111af4c23cc95368603cb1171e6401cf16 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 22:23:03 +0530 Subject: [PATCH 33/43] feat(release): add important flag to nightly feed manifests (#2378) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/frontend-nightly.yml | 10 +++++++++- frontend/scripts/feed.mjs | 18 +++++++++++++----- frontend/scripts/feed.test.mjs | 23 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml index c441b0bd7..7ad149c8f 100644 --- a/.github/workflows/frontend-nightly.yml +++ b/.github/workflows/frontend-nightly.yml @@ -9,6 +9,11 @@ on: schedule: - cron: "30 13 * * *" # 13:30 UTC = 19:00 IST daily workflow_dispatch: + inputs: + important: + description: 'Mark this nightly as an important update (escalates the in-app restart prompt). Retro-flag an already-published nightly by re-running only the publish-feed job with this input set.' + type: boolean + default: false jobs: guard: @@ -201,6 +206,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NIGHTLY_VERSION: ${{ needs.guard.outputs.version }} + NIGHTLY_IMPORTANT: ${{ inputs.important || 'false' }} run: | # Forge stamps package.json to VERSION without +build metadata and # publishes to v<that>. Match it. @@ -208,7 +214,9 @@ jobs: tag="v$version" mkdir -p dist gh release download "$tag" --dir dist --clobber - node scripts/feed.mjs dist "$version" nightly + important_flag="" + [ "$NIGHTLY_IMPORTANT" = "true" ] && important_flag="--important" + node scripts/feed.mjs dist "$version" nightly $important_flag shopt -s nullglob assets=(dist/nightly*.yml dist/*.blockmap) if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi diff --git a/frontend/scripts/feed.mjs b/frontend/scripts/feed.mjs index ee77b05f4..f9f5f73ab 100644 --- a/frontend/scripts/feed.mjs +++ b/frontend/scripts/feed.mjs @@ -3,6 +3,10 @@ // ESM (mirrors nightly-version.mjs) so CI runs `node scripts/feed.mjs` directly // and vitest unit-tests the pure functions. The only non-stdlib reach is the // blockmap wrapper (Task 1). +// Pass --important to emit `important: true` in each generated yml. An +// already-published nightly can be retro-flagged by re-running the feed job +// with --important set (or editing the yml and running +// `gh release upload TAG nightly*.yml --clobber`). import { readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { writeBlockmap } from "./blockmap.mjs"; @@ -34,7 +38,9 @@ export function feedFilename(channel, platform) { // buildYml serializes one platform's feed. files is [{ url, sha512, size }]; // for mac the arm64 entry comes first. The deprecated top-level path/sha512 // point at files[0]. blockMapSize is never written (forces sidecar differential). -export function buildYml(version, files, releaseDate) { +// When important is true, emits `important: true` after releaseDate so the +// in-app update prompt is escalated. +export function buildYml(version, files, releaseDate, important = false) { const lines = [`version: ${version}`, "files:"]; for (const f of files) { lines.push(` - url: ${f.url}`); @@ -44,12 +50,13 @@ export function buildYml(version, files, releaseDate) { lines.push(`path: ${files[0].url}`); lines.push(`sha512: ${files[0].sha512}`); lines.push(`releaseDate: '${releaseDate}'`); + if (important) lines.push("important: true"); return lines.join("\n") + "\n"; } // generateFeeds writes the yml + sidecar blockmaps for every platform present in // dir. version may carry +build metadata (nightly); strip it for the yml. -async function generateFeeds(dir, rawVersion, channel, releaseDate) { +async function generateFeeds(dir, rawVersion, channel, releaseDate, important = false) { const version = rawVersion.split("+")[0]; const sel = selectInstallers(readdirSync(dir), version); const groups = [ @@ -64,18 +71,19 @@ async function generateFeeds(dir, rawVersion, channel, releaseDate) { const { sha512, size } = await writeBlockmap(join(dir, name)); files.push({ url: name, sha512, size }); } - writeFileSync(join(dir, feedFilename(channel, platform)), buildYml(version, files, releaseDate)); + writeFileSync(join(dir, feedFilename(channel, platform)), buildYml(version, files, releaseDate, important)); } } -// CLI: node scripts/feed.mjs <dir> <version> <channel> +// CLI: node scripts/feed.mjs <dir> <version> <channel> [--important] if (import.meta.url === `file://${process.argv[1]}`) { const [, , dir, version, channel] = process.argv; if (!dir || !version || !channel) { process.stderr.write("usage: node feed.mjs <dir> <version> <channel>\n"); process.exit(2); } - generateFeeds(dir, version, channel, new Date().toISOString()).catch((err) => { + const important = process.argv.includes("--important"); + generateFeeds(dir, version, channel, new Date().toISOString(), important).catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); }); diff --git a/frontend/scripts/feed.test.mjs b/frontend/scripts/feed.test.mjs index 745d07066..fd5c1bab3 100644 --- a/frontend/scripts/feed.test.mjs +++ b/frontend/scripts/feed.test.mjs @@ -69,4 +69,27 @@ describe("buildYml", () => { expect(lines[5]).toBe(" - url: Agent.Orchestrator-darwin-x64-0.10.4.zip"); expect(yml).toContain("path: Agent.Orchestrator-darwin-arm64-0.10.4.zip"); }); + + it("omits important key when flag is false (byte-identical to old output)", () => { + const yml = buildYml( + "0.10.4", + [{ url: "Agent.Orchestrator.Setup.0.10.4.exe", sha512: "AA/BB+cc==", size: 123 }], + "2026-06-27T12:00:00.000Z", + false, + ); + expect(yml).not.toContain("important"); + }); + + it("emits important: true as top-level key when flag is true", () => { + const yml = buildYml( + "0.10.4", + [{ url: "Agent.Orchestrator.Setup.0.10.4.exe", sha512: "AA/BB+cc==", size: 123 }], + "2026-06-27T12:00:00.000Z", + true, + ); + expect(yml).toContain("important: true\n"); + // must still have all existing fields + expect(yml).toContain("version: 0.10.4"); + expect(yml).toContain("releaseDate:"); + }); }); From 6f09a1fc78ec78ce4c81002363d0156a9500830e Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 22:23:57 +0530 Subject: [PATCH 34/43] fix(frontend): show session display name in terminal toolbar header (#2382) * fix(frontend): show session display name in terminal toolbar header The terminal toolbar showed the raw session id. Show the display name (session.title, same field the sidebar renders) instead, and 'Orchestrator' for orchestrator sessions. Fixes #2380 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(frontend): cover terminal toolbar session label Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- .../renderer/components/CenterPane.test.tsx | 38 +++++++++++++++++++ .../src/renderer/components/CenterPane.tsx | 6 ++- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 frontend/src/renderer/components/CenterPane.test.tsx diff --git a/frontend/src/renderer/components/CenterPane.test.tsx b/frontend/src/renderer/components/CenterPane.test.tsx new file mode 100644 index 000000000..f4ebef8a1 --- /dev/null +++ b/frontend/src/renderer/components/CenterPane.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceSession } from "../types/workspace"; +import { CenterPane } from "./CenterPane"; + +// The terminal body pulls in xterm/SSE machinery irrelevant to the toolbar under test. +vi.mock("./TerminalPane", () => ({ TerminalPane: () => <div>terminal body</div> })); + +const worker = { + id: "sess-1", + workspaceId: "proj-1", + workspaceName: "my-app", + title: "do the thing", + provider: "claude-code", + kind: "worker", + branch: "ao/sess-1", + status: "working", + updatedAt: "2026-06-10T00:00:00Z", + prs: [], +} satisfies WorkspaceSession; + +describe("CenterPane toolbar session label", () => { + it("shows the session display name for a worker", () => { + render(<CenterPane session={worker} theme="dark" daemonReady />); + expect(screen.getByText("do the thing")).toBeInTheDocument(); + expect(screen.queryByText("sess-1")).not.toBeInTheDocument(); + }); + + it("shows 'Orchestrator' for an orchestrator session", () => { + render(<CenterPane session={{ ...worker, id: "sess-orch", kind: "orchestrator" }} theme="dark" daemonReady />); + expect(screen.getByText("Orchestrator")).toBeInTheDocument(); + }); + + it("shows 'No session' when there is no session", () => { + render(<CenterPane theme="dark" daemonReady />); + expect(screen.getByText("No session")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/CenterPane.tsx b/frontend/src/renderer/components/CenterPane.tsx index 09d494ae5..e0a2fca45 100644 --- a/frontend/src/renderer/components/CenterPane.tsx +++ b/frontend/src/renderer/components/CenterPane.tsx @@ -2,7 +2,7 @@ import { ChevronLeft, Maximize2, Minimize2, Shield } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type WheelEvent } from "react"; import type { Theme } from "../stores/ui-store"; import type { TerminalTarget } from "../types/terminal"; -import type { WorkspaceSession } from "../types/workspace"; +import { isOrchestratorSession, type WorkspaceSession } from "../types/workspace"; import { TerminalPane } from "./TerminalPane"; type CenterPaneProps = { @@ -99,7 +99,9 @@ export function CenterPane({ session, theme, daemonReady, terminalTarget, onSele <div className="terminal-toolbar"> <div className="terminal-toolbar__label"> <span className="terminal-toolbar__eyebrow">TERMINAL</span> - <span className="terminal-toolbar__session">{session?.id ?? "No session"}</span> + <span className="terminal-toolbar__session"> + {!session ? "No session" : isOrchestratorSession(session) ? "Orchestrator" : session.title} + </span> </div> <div className="terminal-toolbar__controls"> <button From c9cff5f22e7f5e29449e6fdd2c3947b95c77c939 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 22:29:07 +0530 Subject: [PATCH 35/43] feat(sidebar): nightly channel badge on the wordmark (#2379) * feat(sidebar): purple nightly badge on the wordmark Add a small purple pill labeled "nightly" next to the "Agent Orchestrator" wordmark in the sidebar header. The badge: - is derived from the running app version string (contains "-nightly."), not the update-channel setting, so it reflects the actual binary identity - is hidden in the collapsed icon rail (group-data-[collapsible=icon]:hidden) - uses a new --purple CSS token added to both dark (#a78bfa) and light (#7c3aed) palette blocks in styles.css, styled via color-mix following the existing reviewer-status idiom - is shrink-0 so it does not crowd the flex-1 truncate wordmark span Stable builds show no badge. Dev builds ("0.0.0-preview") show no badge. Part of AgentWrapper/agent-orchestrator#2377. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: format with prettier [skip ci] --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- frontend/src/renderer/components/Sidebar.tsx | 25 ++++++++++++++++++-- frontend/src/renderer/styles.css | 2 ++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index c9567db77..81f8a9bc3 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; import { ChevronRight, @@ -151,7 +151,17 @@ export function Sidebar({ next.has(id) ? next.delete(id) : next.add(id); return next; }); - // agent-orchestrator's sidebar resize: drag the right edge (200–420px, + // Fetch the running app version to derive the build channel. Channel is + // identity: derived from the version string, not the update-channel setting + // (the setting can be changed mid-session; the binary cannot). + const { data: appVersion } = useQuery({ + queryKey: ["app-version"], + queryFn: () => aoBridge.app.getVersion(), + staleTime: Infinity, + }); + const isNightly = typeof appVersion === "string" && appVersion.includes("-nightly."); + + // agent-orchestrator's sidebar resize: drag the right edge (200-420px, // persisted), double-click to reset to 240px. Drives --ao-sidebar-w on :root, // which the provider forwards into shadcn's --sidebar-width. const { onPointerDown: onResizePointerDown, onDoubleClick: onResizeDoubleClick } = useResizable({ @@ -199,6 +209,17 @@ export function Sidebar({ <span className="min-w-0 flex-1 truncate text-[14px] font-bold tracking-[-0.015em] text-foreground group-data-[collapsible=icon]:hidden"> Agent Orchestrator </span> + {isNightly && ( + <span + className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none group-data-[collapsible=icon]:hidden" + style={{ + color: "var(--purple)", + background: "color-mix(in srgb, var(--purple) 12%, transparent)", + }} + > + nightly + </span> + )} {/* On macOS the toggle lives in the titlebar cluster instead. */} {!isMac && ( <Tooltip> diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css index 10c3b4f51..eb419dde5 100644 --- a/frontend/src/renderer/styles.css +++ b/frontend/src/renderer/styles.css @@ -111,6 +111,7 @@ --green-strong: #74b98a; --amber: #e8c14a; --red: #ef6b6b; + --purple: #a78bfa; /* Terminal — dark palette (light override below). */ --term-bg: #15171b; --term-fg: #d7d7d2; @@ -148,6 +149,7 @@ --green-strong: #1a7f37; --amber: #9a6b00; --red: #c0392b; + --purple: #7c3aed; --interactive-hover: rgb(0 0 0 / 0.04); --interactive-active: rgb(0 0 0 / 0.07); --kanban-column-bg: rgb(0 0 0 / 0.02); From 8cadea5b1693d4213ff74967d8125ab9ef41c247 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Fri, 3 Jul 2026 22:42:27 +0530 Subject: [PATCH 36/43] fix(ci): gofmt manager_test.go to unbreak main (#2386) manager_test.go landed from #2350 with a gofmt violation (extra spaces aligning a struct field), turning main red: build-test's `gofmt -l .` and lint's goimports both flag it. #2350's merge push never ran the Go workflow, so it went unnoticed until the next push surfaced it. Pure `gofmt -w` output, one whitespace line, no behavior change. --- backend/internal/session_manager/manager_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 490c84c1f..261881cd0 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -1913,7 +1913,7 @@ func TestReconcile_AdoptAcrossDaemonRestart(t *testing.T) { st.sessions["mer-4"] = domain.SessionRecord{ ID: "mer-4", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}, - Metadata: domain.SessionMetadata{Branch: "ao/mer-4/root", WorkspacePath: "/ws/mer-4"}, + Metadata: domain.SessionMetadata{Branch: "ao/mer-4/root", WorkspacePath: "/ws/mer-4"}, } if err := m.Reconcile(ctx); err != nil { From 45571466812017d986ad81e28e06d8be83898d4e Mon Sep 17 00:00:00 2001 From: Adil Shaikh <106678504+whoisasx@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:29:32 +0530 Subject: [PATCH 37/43] fix: render raw activity state in inspector (#2278) * fix: render raw session activity in inspector * fix: hide unknown activity state in inspector --- .../components/SessionInspector.test.tsx | 183 +++++++++++++++++- .../renderer/components/SessionInspector.tsx | 152 ++++++++++----- .../renderer/hooks/useWorkspaceQuery.test.tsx | 2 + .../src/renderer/hooks/useWorkspaceQuery.ts | 2 + frontend/src/renderer/types/workspace.ts | 24 +++ 5 files changed, 311 insertions(+), 52 deletions(-) diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index bff3ac9d9..4ae34cea9 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionInspector } from "./SessionInspector"; import type { PRState, PullRequestFacts, WorkspaceSession } from "../types/workspace"; @@ -25,7 +25,7 @@ vi.mock("../lib/api-client", () => ({ }, })); -const pr = (n: number, state: PRState): PullRequestFacts => ({ +const pr = (n: number, state: PRState, overrides: Partial<PullRequestFacts> = {}): PullRequestFacts => ({ url: `https://example.com/pr/${n}`, number: n, state, @@ -34,9 +34,10 @@ const pr = (n: number, state: PRState): PullRequestFacts => ({ mergeability: "mergeable", reviewComments: false, updatedAt: "2026-06-15T00:00:00Z", + ...overrides, }); -const session = (prs: PullRequestFacts[]): WorkspaceSession => ({ +const session = (prs: PullRequestFacts[], overrides: Partial<WorkspaceSession> = {}): WorkspaceSession => ({ id: "sess-1", workspaceId: "ws-1", workspaceName: "my-app", @@ -47,6 +48,7 @@ const session = (prs: PullRequestFacts[]): WorkspaceSession => ({ status: "review_pending", updatedAt: "2026-06-15T00:00:00Z", prs, + ...overrides, }); const sessionWithProvider = (prs: PullRequestFacts[], provider: WorkspaceSession["provider"]): WorkspaceSession => ({ @@ -124,9 +126,12 @@ beforeEach(() => { postMock.mockResolvedValue({ data: { ok: true, sessionId: "sess-1" }, error: undefined }); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("SessionInspector PR section", () => { - // Scope assertions to the PR section: the activity timeline also renders - // "Opened PR #n", so an unscoped query matches both the card and the event. + // Scope assertions to the PR section so the card order is explicit. const prSection = (title: string) => within(screen.getByText(title).closest("section.inspector-section") as HTMLElement); @@ -166,6 +171,174 @@ describe("SessionInspector PR section", () => { }); }); +describe("SessionInspector Activity section", () => { + const activitySection = () => + within(screen.getByText("Activity").closest("section.inspector-section") as HTMLElement); + + it.each([ + ["idle", "Idle"], + ["active", "Working"], + ["waiting_input", "Input Needed"], + ["exited", "Exited"], + ] as const)("renders %s from raw session activity", (state, label) => { + renderWithQuery( + <SessionInspector + session={session([pr(7, "open")], { + status: "review_pending", + activity: { state, lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + expect(activitySection().getByText(label)).toBeInTheDocument(); + }); + + it("renders unknown activity as unavailable instead of leaking the internal enum", () => { + renderWithQuery( + <SessionInspector + session={session([], { + status: "working", + activity: { state: "unknown", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + expect(activitySection().getByText("Activity Unavailable")).toBeInTheDocument(); + expect(activitySection().queryByText("Unknown")).not.toBeInTheDocument(); + }); + + it("falls back to unavailable when no activity has been reported", () => { + renderWithQuery(<SessionInspector session={session([], { status: "working" })} />); + + expect(activitySection().getByText("Activity Unavailable")).toBeInTheDocument(); + }); + + it("keeps the last known activity visible when the daemon reports no signal", () => { + renderWithQuery( + <SessionInspector + session={session([], { + status: "no_signal", + activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement; + expect(within(activityRow).getByText("No Signal")).toBeInTheDocument(); + }); + + it("does not derive the Activity label from PR-oriented session status", () => { + renderWithQuery( + <SessionInspector + session={session([], { + status: "review_pending", + activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + expect(activitySection().getByText("Idle")).toBeInTheDocument(); + expect(activitySection().queryByText("Input Needed")).not.toBeInTheDocument(); + }); + + it.each([ + ["ci_failed", "CI Failed"], + ["changes_requested", "Changes Requested"], + ] as const)("renders %s as an SCM state in the current Activity row", (status, label) => { + renderWithQuery( + <SessionInspector + session={session([], { + status, + activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement; + expect(within(activityRow).getByText(label)).toBeInTheDocument(); + }); + + it("renders PR conflicts as an SCM state in the current Activity row", () => { + renderWithQuery( + <SessionInspector + session={session([pr(7, "open", { mergeability: "conflicting" })], { + status: "working", + activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement; + expect(within(activityRow).getByText("Conflict")).toBeInTheDocument(); + }); + + it("uses activity.lastActivityAt for the Activity timestamp", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-15T12:00:00Z")); + + renderWithQuery( + <SessionInspector + session={session([], { + status: "working", + updatedAt: "2026-06-15T11:55:00Z", + activity: { state: "active", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + const activityRow = activitySection().getByText("Working").closest(".inspector-timeline__ev") as HTMLElement; + expect(within(activityRow).getByText("2h ago")).toBeInTheDocument(); + }); + + it("keeps worktree, PR, and SCM context rows in the Activity timeline", () => { + renderWithQuery( + <SessionInspector + session={session([pr(7, "open", { ci: "failing", review: "changes_requested" })], { + status: "ci_failed", + activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + expect(activitySection().getByText(/Created worktree/)).toBeInTheDocument(); + expect(activitySection().getByText("Opened")).toBeInTheDocument(); + expect(activitySection().getByText("PR #7")).toBeInTheDocument(); + const activityRow = activitySection().getByText("Idle").closest(".inspector-timeline__ev") as HTMLElement; + expect(within(activityRow).getByText("CI Failed")).toBeInTheDocument(); + expect(within(activityRow).getByText("Changes Requested")).toBeInTheDocument(); + }); + + it("orders timeline milestones around the combined current state row", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-15T12:00:00Z")); + + renderWithQuery( + <SessionInspector + session={session([pr(42, "draft"), pr(41, "open"), pr(40, "merged")], { + status: "merged", + createdAt: "2026-06-15T09:00:00Z", + updatedAt: "2026-06-15T11:55:00Z", + activity: { state: "idle", lastActivityAt: "2026-06-15T10:00:00Z" }, + })} + />, + ); + + const section = screen.getByText("Activity").closest("section.inspector-section") as HTMLElement; + const rows = Array.from(section.querySelectorAll(".inspector-timeline__ev"), (row) => + row.textContent?.replace(/\s+/g, " ").trim(), + ); + expect(rows).toEqual([ + "Created worktree & branch3h ago", + "Draft PR #42", + "Opened PR #41", + "Opened PR #40", + "Idle2h ago", + "Merged PR #40", + "Done5m ago", + ]); + }); +}); + describe("SessionInspector tabs", () => { it("exposes Summary, Reviews, and Browser as the three inspector tabs", () => { renderWithQuery(<SessionInspector session={session([pr(1, "open")])} />); diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index baad79ff4..80d8c5918 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -7,8 +7,8 @@ import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { formatTimeCompact } from "../lib/format-time"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; -import type { SessionStatus, WorkspaceSession } from "../types/workspace"; -import { sortedPRs, workerDisplayStatus } from "../types/workspace"; +import type { SessionActivityState, WorkspaceSession } from "../types/workspace"; +import { sortedPRs } from "../types/workspace"; import { BrowserPanelView } from "./BrowserPanel"; import type { BrowserViewModel } from "../hooks/useBrowserView"; import { Badge } from "./ui/badge"; @@ -259,24 +259,29 @@ type TimelineTone = "now" | "good" | "warn" | "neutral"; function ActivityTimeline({ session }: { session: WorkspaceSession }) { const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = []; - const detail = activityDetail(session.status); events.push({ - tone: "now", - node: ( - <> - <span className="inspector-timeline__badge"> - <InspectorStatusPill session={session} /> - </span> - {detail ? <span className="inspector-timeline__detail"> — {detail}</span> : null} - </> - ), - ts: formatTimeCompact(session.updatedAt), + tone: "neutral", + node: <>Created worktree & branch</>, + ts: formatTimeCompact(session.createdAt ?? session.updatedAt), }); - for (const pr of sortedPRs(session)) { + const prs = sortedPRs(session); + for (const pr of prs.filter((pr) => pr.state === "draft")) { events.push({ - tone: "good", + tone: "neutral", + node: ( + <> + Draft <b>PR #{pr.number}</b> + </> + ), + ts: null, + }); + } + + for (const pr of prs.filter((pr) => pr.state !== "draft")) { + events.push({ + tone: "neutral", node: ( <> Opened <b>PR #{pr.number}</b> @@ -287,11 +292,47 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) { } events.push({ - tone: "neutral", - node: <>Created worktree & branch</>, - ts: formatTimeCompact(session.createdAt ?? session.updatedAt), + tone: "now", + node: ( + <span className="inline-flex flex-wrap items-center gap-1.5"> + <span className="inspector-timeline__badge"> + <InspectorActivityPill state={session.activity?.state ?? "unknown"} /> + </span> + {session.status === "no_signal" ? ( + <span className="inspector-timeline__badge"> + <TimelinePill {...ACTIVITY_WARNING_PILL.no_signal} /> + </span> + ) : null} + {scmTimelineStates(session).map((state) => ( + <span key={state} className="inspector-timeline__badge"> + <InspectorScmPill state={state} /> + </span> + ))} + </span> + ), + ts: session.activity?.lastActivityAt ? formatTimeCompact(session.activity.lastActivityAt) : null, }); + for (const pr of prs.filter((pr) => pr.state === "merged")) { + events.push({ + tone: "good", + node: ( + <> + Merged <b>PR #{pr.number}</b> + </> + ), + ts: null, + }); + } + + if (session.status === "merged") { + events.push({ + tone: "good", + node: <>Done</>, + ts: formatTimeCompact(session.updatedAt), + }); + } + return ( <div className="inspector-timeline"> {events.map((event, index) => ( @@ -313,38 +354,35 @@ function ActivityTimeline({ session }: { session: WorkspaceSession }) { ); } -function activityDetail(status: SessionStatus): string | null { - switch (status) { - case "idle": - return "Session idle"; - case "needs_input": - return "Waiting for your input"; - case "no_signal": - return "No recent agent signal"; - case "working": - return null; - default: - return null; - } -} - -const STATUS_PILL: Record< - ReturnType<typeof workerDisplayStatus> | "idle", - { label: string; tone: string; breathe: boolean } -> = { - working: { label: "Working", tone: "var(--orange)", breathe: true }, - needs_you: { label: "Input needed", tone: "var(--amber)", breathe: false }, - ci_failed: { label: "CI failed", tone: "var(--red)", breathe: false }, - no_signal: { label: "No signal", tone: "var(--fg-muted)", breathe: false }, - mergeable: { label: "Ready", tone: "var(--green)", breathe: false }, - done: { label: "Done", tone: "var(--fg-muted)", breathe: false }, - unknown: { label: "Unknown", tone: "var(--fg-muted)", breathe: false }, +const ACTIVITY_PILL: Record<SessionActivityState, { label: string; tone: string; breathe: boolean }> = { + active: { label: "Working", tone: "var(--orange)", breathe: true }, idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false }, + waiting_input: { label: "Input Needed", tone: "var(--amber)", breathe: false }, + exited: { label: "Exited", tone: "var(--fg-muted)", breathe: false }, + unknown: { label: "Activity Unavailable", tone: "var(--fg-muted)", breathe: false }, }; -function InspectorStatusPill({ session }: { session: WorkspaceSession }) { - const key = session.status === "idle" ? "idle" : workerDisplayStatus(session); - const { label, tone, breathe } = STATUS_PILL[key]; +const ACTIVITY_WARNING_PILL: Record<"no_signal", { label: string; tone: string; breathe: boolean }> = { + no_signal: { label: "No Signal", tone: "var(--fg-muted)", breathe: false }, +}; + +type ScmTimelineState = "ci_failed" | "changes_requested" | "conflict"; + +const SCM_PILL: Record<ScmTimelineState, { label: string; tone: string; breathe: boolean }> = { + ci_failed: { label: "CI Failed", tone: "var(--red)", breathe: false }, + changes_requested: { label: "Changes Requested", tone: "var(--amber)", breathe: false }, + conflict: { label: "Conflict", tone: "var(--red)", breathe: false }, +}; + +function InspectorActivityPill({ state }: { state: SessionActivityState }) { + return <TimelinePill {...ACTIVITY_PILL[state]} />; +} + +function InspectorScmPill({ state }: { state: ScmTimelineState }) { + return <TimelinePill {...SCM_PILL[state]} />; +} + +function TimelinePill({ label, tone, breathe }: { label: string; tone: string; breathe: boolean }) { return ( <span className="inline-flex shrink-0 items-center gap-[7px] whitespace-nowrap rounded-[7px] px-[11px] py-[5px] text-[11.5px] font-semibold" @@ -363,6 +401,26 @@ function InspectorStatusPill({ session }: { session: WorkspaceSession }) { ); } +function scmTimelineStates(session: WorkspaceSession): ScmTimelineState[] { + const states: ScmTimelineState[] = []; + const seen = new Set<ScmTimelineState>(); + const add = (state: ScmTimelineState) => { + if (seen.has(state)) return; + seen.add(state); + states.push(state); + }; + + if (session.status === "ci_failed") add("ci_failed"); + if (session.status === "changes_requested") add("changes_requested"); + for (const pr of session.prs) { + if (pr.ci === "failing") add("ci_failed"); + if (pr.review === "changes_requested") add("changes_requested"); + if (pr.mergeability === "conflicting") add("conflict"); + } + + return states; +} + function ReviewsView({ session, onOpenReviewerTerminal, diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index a81309c17..69daa089e 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -66,6 +66,7 @@ describe("useWorkspaceQuery", () => { branch: "qa/modal-worker", status: "mergeable", isTerminated: false, + activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" }, updatedAt: "2026-06-10T16:15:04Z", }, { @@ -99,6 +100,7 @@ describe("useWorkspaceQuery", () => { provider: "claude-code", branch: "qa/modal-worker", status: "mergeable", + activity: { state: "idle", lastActivityAt: "2026-06-10T15:30:00Z" }, }); expect(workspace.sessions[1]).toMatchObject({ id: "sess-2", diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 0967d8d31..c07efa195 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -6,6 +6,7 @@ import { type PRState, type PullRequestFacts, toAgentProvider, + toSessionActivity, toSessionStatus, type WorkspaceSummary, } from "../types/workspace"; @@ -57,6 +58,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> { status: toSessionStatus(session.status, session.isTerminated), createdAt: session.createdAt, updatedAt: session.updatedAt, + activity: toSessionActivity(session.activity), previewUrl: session.previewUrl, previewRevision: session.previewRevision, prs: (session.prs ?? []).map(toPullRequestFacts), diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index afb69b780..49a98f427 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -35,6 +35,28 @@ export function toSessionStatus(status?: string, isTerminated = false): SessionS return isTerminated ? "terminated" : "unknown"; } +export type SessionActivityState = "active" | "idle" | "waiting_input" | "exited" | "unknown"; + +const sessionActivityStates = new Set<SessionActivityState>(["active", "idle", "waiting_input", "exited"]); + +export type SessionActivity = { + state: SessionActivityState; + lastActivityAt: string; +}; + +export function toSessionActivity( + activity?: { state?: string; lastActivityAt?: string } | null, +): SessionActivity | undefined { + if (!activity) return undefined; + const state = sessionActivityStates.has(activity.state as SessionActivityState) + ? (activity.state as SessionActivityState) + : "unknown"; + return { + state, + lastActivityAt: activity.lastActivityAt ?? "", + }; +} + export type AgentProvider = | "codex" | "claude-code" @@ -104,6 +126,8 @@ export type WorkspaceSession = { createdAt?: string; /** ISO timestamp from the daemon. */ updatedAt: string; + /** Raw agent lifecycle activity from the daemon. */ + activity?: SessionActivity; /** * Live preview target set by the daemon (via `ao preview`) and streamed over * CDC. When non-empty, the browser panel opens and navigates here. From d18ea87f57418e135a59ad0e7c9138799eafc9f9 Mon Sep 17 00:00:00 2001 From: Anirudh Sharma <78658727+anirudh5harma@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:52:14 +0530 Subject: [PATCH 38/43] feat(tracker): GitHub issue intake (backend + dashboard) (#2325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tracker): GitHub issue intake observer Adds an opt-in daemon poll loop that spawns one worker session per eligible open GitHub issue, keyed by the canonical "github:<native>" id so restarts cannot double-spawn. Eligibility requires an explicit label or assignee rule to avoid draining an entire backlog. Provider dispatch goes through a TrackerResolver interface (SingleTrackerResolver for now) so Linear/Jira can be added later as new adapters plus a resolver-map entry, without touching the poll, eligibility, or backoff logic. Reuses the existing rate-limit-aware GitHub tracker adapter and backs off per-project on any poll failure (including rate limits) instead of retrying in a tight loop. Closes #2324. * feat(frontend): surface GitHub tracker intake in project settings Adds a Tracker Intake card to project settings (enable, repository override, labels, assignee) with inline validation requiring at least one label or assignee before intake can be saved. Session cards and the inspector show the canonical "github:<native>" issue id when a session was spawned by intake. Regenerated frontend/src/api/schema.ts against the backend's TrackerIntakeConfig. The provider is currently fixed to "github"; adding a picker for future providers is additive once the backend enum grows. Closes #2324. * fix(tracker): start the intake observer unconditionally at boot startTrackerIntake scanned projects once at daemon startup and skipped starting the observer loop entirely when none had intake enabled yet. Poll() already re-reads every project's config on each tick and skips disabled projects there, so the boot-time gate only broke the common case: enabling intake on a project after the daemon is already running silently never got picked up until the next restart, with no error or log line to explain why. Always start the loop; the adapter (and its token resolution) stays lazy regardless, so there's no added cost when intake is unused. Found while manually verifying issue intake end-to-end against a live dev daemon: enabled intake via the settings UI, saved successfully, but no session ever spawned for a matching labeled issue. * fix(frontend): show auto-detected repo link, hide labels input for now The Electron app only registers git projects today, so the daemon always has a usable git origin to derive owner/repo from when trackerIntake.repo is unset (trackerRepo() in observer.go, already covered by every Poll-level test in observer_test.go). Replace the manual Repository input with a read-only link derived client-side from the project's own git origin, purely for display — the daemon's own derivation at poll time is unaffected either way. Comment out the Labels input; Assignee is the only intake eligibility rule editable from this form for now. form.intakeLabels/intakeRepo stay wired into buildIntake so a value set via the CLI round-trips on a UI save instead of being silently cleared. * chore: format with prettier [skip ci] * feat(frontend): offer issue intake at project creation Add the GitHub tracker intake controls to the create-project sheet so a project can opt into issue-driven worker spawning at creation time, not only later via Settings. - Extract the shared IntakeFields component (enable toggle, assignee input, validation, credential hint) plus buildIntake/intakeNeedsRule/ deriveGitHubRepo helpers into IntakeFields.tsx, and consume it from ProjectSettingsForm so the two surfaces can't drift. - CreateProjectAgentSheet renders IntakeFields (no repo-preview row, since the git origin isn't known there; the daemon derives the repo). The selection now carries an optional trackerIntake payload, gated by the same "requires a label or assignee" rule the backend enforces. - Thread trackerIntake through Sidebar's onCreateProject into the POST /api/v1/projects config. No backend change: the endpoint already accepts a full ProjectConfig and the intake observer picks up newly enabled projects on its next tick. Verified in the web preview: enabling reveals the assignee field and gates submit until a rule is set; submit builds the intake payload. * chore: format with prettier [skip ci] * feat(frontend): simplify intake controls in the create-project sheet Reduce the create sheet's tracker-intake block to the essentials: the enable toggle plus an info icon whose hover tooltip explains what enabling does, then the assignee input. Drop the descriptive intro paragraph, the inline "requires a label or assignee" guard text, and the credential hint — the sheet stays minimal and submit gating already communicates the missing rule via the disabled button. - IntakeFields gains a `compact` prop: hides the prose, folds the explanation into an Info tooltip, and drops the trailing help text. The tooltip is wrapped in its own TooltipProvider so the component is self-contained regardless of ancestor. The verbose settings card is unchanged (renders without `compact`). - Assignee placeholder reworded to "type username or * for any". Verified in the web preview: the info tooltip surfaces the one-line description on hover, the assignee placeholder is updated, and no other copy remains in the create sheet. * perf(tracker): cache GitHub issue lists with ETag conditional requests The intake observer polls Tracker.List for the same repo+filter every tick, and each poll did an uncached GET + full JSON decode even when nothing changed. Add HTTP conditional requests so unchanged polls are cheap and don't consume the primary rate limit. - Tracker holds a per-request-path cache of {etag, mapped issues}, guarded by a mutex. The key space is bounded by intake-enabled repo/filter pairs, so no eviction is needed. - List sends If-None-Match with the cached ETag (verbatim, preserving weak validators). On 304 it returns the cached issues without re-decoding (GitHub 304s don't count against the primary rate limit); a rotated ETag on the 304 is recorded. On 200 it stores the new {etag, issues}, or drops a stale entry if the response omits an ETag. A 304 without a prior validator falls back to an unconditional refetch. - Extract the HTTP round-trip into roundTrip(); do() delegates to it so Get and Preflight keep byte-for-byte identical behavior. roundTrip owns If-None-Match, ETag capture, and 304 handling before classifyError. Tests cover revalidation returning cached issues on 304, ETag rotation on change, separate cache keys per filter, and no caching when the response carries no ETag. * feat(frontend): trim tracker intake copy in project settings Reduce the settings Tracker Intake card to a one-line description ("Auto-spawn worker sessions from matching tracker issues.") and drop the trailing credential/daemon-restart hint. The compact create sheet is unaffected (it already renders neither). * feat(tracker): scope v1 intake to assignee-only Per PR review, labels were a persisted/API/CLI eligibility rule while the UI only ever exposed assignee — surface beyond the intended v1 scope. Remove labels from intake end-to-end; assignee becomes the required and only eligibility rule. - domain: drop TrackerIntakeConfig.Labels; Validate() now requires a non-empty assignee when enabled (was "labels or assignee"). - observer: pass only State+Assignee into ListFilter; drop the label match loop in issueMatchesConfig. - CLI: remove --tracker-label and its plumbing. - Regenerate openapi.yaml + schema.ts (labels gone from the schema). - frontend: drop labels from IntakeForm/buildIntake/intakeNeedsRule and the settings form state; validation copy now "requires an assignee". Remove the label round-trip test; keep assignee coverage. The generic domain.ListFilter.Labels adapter capability is retained; only intake stops using it. * fix(tracker): paginate GitHub issue listing with page-aware ETag cache Per PR review, single-page listing is a correctness bug for intake, not just a bounded v1 tradeoff: with more than a page of eligible open issues, AO re-sees the same first page every poll, the persisted issue_id dedupe skips the already-spawned first-page issues, and later pages are never fetched or spawned. - List now requests per_page=100 and follows the GitHub Link header rel="next" until no next link remains, accumulating issues across all pages. A maxListPages guard fails loud on a pathological Link cycle. - The ETag cache is page-aware: each entry stores {etag, issues, nextPath} keyed by the per-page request path, so a 304 Not Modified still continues to the cached next page. roundTrip now surfaces the parsed next path alongside the ETag; do() delegates unchanged so Get/Preflight keep identical behavior. - ListFilter.Limit becomes an optional total-result cap (page size is fixed at the provider max). Tests cover multi-page accumulation, all-304 revalidation continuing the cached chain, page-count shrink orphaning a stale entry, and Link header parsing. * style(session_manager): gofmt manager_test.go to unblock CI The session-restart test carried a struct-literal alignment that gofmt (and golangci-lint's goimports) reject, failing the build-test and lint jobs. Whitespace-only reformat; no test logic changes. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: whoisasx <adil.business4064@gmail.com> --- .../adapters/tracker/github/tracker.go | 221 +++++++++-- .../adapters/tracker/github/tracker_test.go | 337 +++++++++++++++- backend/internal/cli/project.go | 46 ++- backend/internal/cli/project_test.go | 59 +++ backend/internal/daemon/daemon.go | 1 + backend/internal/daemon/lifecycle_wiring.go | 10 +- .../internal/daemon/tracker_intake_wiring.go | 132 +++++++ backend/internal/daemon/wiring_test.go | 52 +++ backend/internal/domain/projectconfig.go | 24 +- backend/internal/domain/projectconfig_test.go | 16 + backend/internal/domain/tracker.go | 54 ++- backend/internal/httpd/apispec/openapi.yaml | 15 + .../internal/httpd/apispec/specgen/build.go | 15 +- .../observe/trackerintake/observer.go | 369 ++++++++++++++++++ .../observe/trackerintake/observer_test.go | 345 ++++++++++++++++ frontend/src/api/schema.ts | 8 + .../CreateProjectAgentSheet.test.tsx | 71 ++++ .../components/CreateProjectAgentSheet.tsx | 18 +- .../src/renderer/components/IntakeFields.tsx | 175 +++++++++ .../components/ProjectSettingsForm.test.tsx | 72 ++++ .../components/ProjectSettingsForm.tsx | 47 +++ .../components/SessionInspector.test.tsx | 7 + .../renderer/components/SessionInspector.tsx | 4 +- .../src/renderer/components/SessionsBoard.tsx | 17 +- frontend/src/renderer/components/Sidebar.tsx | 2 +- .../renderer/hooks/useWorkspaceQuery.test.tsx | 2 + .../src/renderer/hooks/useWorkspaceQuery.ts | 1 + frontend/src/renderer/routes/_shell.tsx | 9 +- frontend/src/renderer/types/workspace.test.ts | 9 + frontend/src/renderer/types/workspace.ts | 18 + 30 files changed, 2084 insertions(+), 72 deletions(-) create mode 100644 backend/internal/daemon/tracker_intake_wiring.go create mode 100644 backend/internal/observe/trackerintake/observer.go create mode 100644 backend/internal/observe/trackerintake/observer_test.go create mode 100644 frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx create mode 100644 frontend/src/renderer/components/IntakeFields.tsx diff --git a/backend/internal/adapters/tracker/github/tracker.go b/backend/internal/adapters/tracker/github/tracker.go index 1d5d6c5df..df0524a0f 100644 --- a/backend/internal/adapters/tracker/github/tracker.go +++ b/backend/internal/adapters/tracker/github/tracker.go @@ -33,10 +33,12 @@ const ( stateClosedGH = "closed" reasonNotPlan = "not_planned" - // List pagination — GitHub's per_page maxes at 100. We default to 30 - // (matching the legacy gh CLI default) when the caller passes 0. - defaultListLimit = 30 - maxListLimit = 100 + // List pagination — GitHub's per_page maxes at 100. ListFilter.Limit is + // an optional total-result cap; page size stays at the provider max. + listPageSize = 100 + // Guard against a pathological Link cycle. At GitHub's max page size this + // still permits a 5k-issue intake sweep before failing loud. + maxListPages = 50 ) // Sentinel errors. Adapter-level callers should match on these via @@ -96,6 +98,12 @@ type Tracker struct { baseURL string userAgent string + // listCache stores one entry per distinct List request path. The key + // space is naturally bounded by intake-enabled repo/filter pairs, so no + // eviction is needed here. + listCacheMu sync.Mutex + listCache map[string]listCacheEntry + // preflightOK is the fast-path: once a Preflight succeeds, every // subsequent call short-circuits via atomic.Load without touching the // mutex. preflightMu serializes the one-time network call so concurrent @@ -104,6 +112,12 @@ type Tracker struct { preflightMu sync.Mutex } +type listCacheEntry struct { + etag string + issues []domain.Issue + nextPath string +} + // New returns a Tracker. It fails fast when no token can be obtained so // daemons crash at startup rather than at first issue lookup. func New(opts Options) (*Tracker, error) { @@ -119,6 +133,7 @@ func New(opts Options) (*Tracker, error) { tokens: src, baseURL: opts.BaseURL, userAgent: opts.UserAgent, + listCache: map[string]listCacheEntry{}, } if t.http == nil { t.http = &http.Client{Timeout: 30 * time.Second} @@ -253,8 +268,8 @@ func mapStateFromGitHub(state, reason string, labels []string) domain.Normalized // List returns issues for a repo, filtered by state/labels/assignee. PRs // that GitHub's /issues endpoint conflates into the response are filtered -// out client-side. Pagination is intentionally NOT implemented in v1 — -// callers get one page bounded by ListFilter.Limit (default 30, max 100). +// out client-side. GitHub pagination is followed until no next link remains; +// ListFilter.Limit, when set, caps the total accumulated issue count. func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { if repo.Provider != domain.TrackerProviderGitHub { return nil, fmt.Errorf("%w: provider=%q", ErrWrongProvider, repo.Provider) @@ -279,34 +294,101 @@ func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter doma if filter.Assignee != "" { q.Set("assignee", filter.Assignee) } - limit := filter.Limit - if limit <= 0 { - limit = defaultListLimit - } - if limit > maxListLimit { - limit = maxListLimit - } - q.Set("per_page", strconv.Itoa(limit)) + q.Set("per_page", strconv.Itoa(listPageSize)) path := fmt.Sprintf("/repos/%s/%s/issues?%s", owner, repoName, q.Encode()) - resp, err := t.do(ctx, http.MethodGet, path, nil) - if err != nil { - return nil, err + out := make([]domain.Issue, 0) + if filter.Limit > 0 { + out = make([]domain.Issue, 0, filter.Limit) } - var raw []ghIssue - if err := json.Unmarshal(resp, &raw); err != nil { - return nil, fmt.Errorf("github tracker: decode list: %w", err) - } - out := make([]domain.Issue, 0, len(raw)) - for _, r := range raw { - if r.PullRequest != nil { - continue + for page := 0; path != ""; page++ { + if page >= maxListPages { + return nil, fmt.Errorf("github tracker: list pagination exceeded %d pages", maxListPages) } - out = append(out, issueFromGH(owner, repoName, r)) + t.listCacheMu.Lock() + cached, hasCached := t.listCache[path] + t.listCacheMu.Unlock() + + resp, etag, nextPath, notModified, err := t.roundTrip(ctx, http.MethodGet, path, nil, cached.etag) + if err != nil { + return nil, err + } + if notModified { + if hasCached { + if etag != "" && etag != cached.etag { + t.listCacheMu.Lock() + t.listCache[path] = listCacheEntry{etag: etag, issues: cached.issues, nextPath: cached.nextPath} + t.listCacheMu.Unlock() + } + var done bool + out, done = appendIssuesWithLimit(out, cloneIssues(cached.issues), filter.Limit) + if done { + break + } + path = cached.nextPath + continue + } + // A 304 requires a prior validator, but if a server violates that + // contract, retry unconditionally rather than returning no data. + resp, etag, nextPath, notModified, err = t.roundTrip(ctx, http.MethodGet, path, nil, "") + if err != nil { + return nil, err + } + if notModified { + return nil, fmt.Errorf("github tracker: unexpected 304 for uncached list") + } + } + var raw []ghIssue + if err := json.Unmarshal(resp, &raw); err != nil { + return nil, fmt.Errorf("github tracker: decode list: %w", err) + } + pageIssues := make([]domain.Issue, 0, len(raw)) + for _, r := range raw { + if r.PullRequest != nil { + continue + } + pageIssues = append(pageIssues, issueFromGH(owner, repoName, r)) + } + if etag != "" { + t.listCacheMu.Lock() + t.listCache[path] = listCacheEntry{etag: etag, issues: cloneIssues(pageIssues), nextPath: nextPath} + t.listCacheMu.Unlock() + } else if hasCached { + t.listCacheMu.Lock() + delete(t.listCache, path) + t.listCacheMu.Unlock() + } + var done bool + out, done = appendIssuesWithLimit(out, pageIssues, filter.Limit) + if done { + break + } + path = nextPath } return out, nil } +func appendIssuesWithLimit(dst, src []domain.Issue, limit int) ([]domain.Issue, bool) { + if limit <= 0 { + return append(dst, src...), false + } + remaining := limit - len(dst) + if remaining <= 0 { + return dst, true + } + if len(src) > remaining { + return append(dst, src[:remaining]...), true + } + dst = append(dst, src...) + return dst, len(dst) >= limit +} + +func cloneIssues(in []domain.Issue) []domain.Issue { + out := make([]domain.Issue, len(in)) + copy(out, in) + return out +} + // --------------------------------------------------------------------------- // Preflight // --------------------------------------------------------------------------- @@ -347,43 +429,116 @@ func (t *Tracker) Preflight(ctx context.Context) error { // --------------------------------------------------------------------------- func (t *Tracker) do(ctx context.Context, method, path string, body any) ([]byte, error) { + respBody, _, _, _, err := t.roundTrip(ctx, method, path, body, "") + return respBody, err +} + +func (t *Tracker) roundTrip(ctx context.Context, method, path string, body any, ifNoneMatch string) ([]byte, string, string, bool, error) { var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { - return nil, fmt.Errorf("github tracker: encode body: %w", err) + return nil, "", "", false, fmt.Errorf("github tracker: encode body: %w", err) } rdr = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, rdr) if err != nil { - return nil, fmt.Errorf("github tracker: build request: %w", err) + return nil, "", "", false, fmt.Errorf("github tracker: build request: %w", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } + if ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") req.Header.Set("User-Agent", t.userAgent) tok, err := t.tokens.Token(ctx) if err != nil { - return nil, err + return nil, "", "", false, err } req.Header.Set("Authorization", "Bearer "+tok) resp, err := t.http.Do(req) if err != nil { - return nil, fmt.Errorf("github tracker: %s %s: %w", method, path, err) + return nil, "", "", false, fmt.Errorf("github tracker: %s %s: %w", method, path, err) } defer func() { _ = resp.Body.Close() }() + etag := resp.Header.Get("ETag") + nextPath := parseLinkNext(resp.Header.Get("Link"), t.baseURL) + if resp.StatusCode == http.StatusNotModified { + return nil, etag, nextPath, true, nil + } respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { - return nil, fmt.Errorf("github tracker: read response body: %w", readErr) + return nil, "", "", false, fmt.Errorf("github tracker: read response body: %w", readErr) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return respBody, nil + return respBody, etag, nextPath, false, nil } - return respBody, classifyError(resp, respBody) + return respBody, etag, nextPath, false, classifyError(resp, respBody) +} + +func parseLinkNext(linkHeader, baseURL string) string { + for _, part := range strings.Split(linkHeader, ",") { + part = strings.TrimSpace(part) + if part == "" || !strings.HasPrefix(part, "<") { + continue + } + urlEnd := strings.Index(part, ">") + if urlEnd < 0 { + continue + } + rawURL := part[1:urlEnd] + if !linkHasRelNext(part[urlEnd+1:]) { + continue + } + return relativePathForLink(rawURL, baseURL) + } + return "" +} + +func linkHasRelNext(params string) bool { + for _, param := range strings.Split(params, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(param), "=") + if !ok || !strings.EqualFold(name, "rel") { + continue + } + value = strings.Trim(value, `"`) + for _, rel := range strings.Fields(value) { + if strings.EqualFold(rel, "next") { + return true + } + } + } + return false +} + +func relativePathForLink(rawLink, baseURL string) string { + if strings.HasPrefix(rawLink, "/") { + return rawLink + } + base, err := url.Parse(baseURL) + if err != nil { + return "" + } + u, err := url.Parse(rawLink) + if err != nil { + return "" + } + if !u.IsAbs() { + u = base.ResolveReference(u) + } + if u.Path == "" { + return "" + } + path := u.EscapedPath() + if u.RawQuery != "" { + path += "?" + u.RawQuery + } + return path } func classifyError(resp *http.Response, body []byte) error { diff --git a/backend/internal/adapters/tracker/github/tracker_test.go b/backend/internal/adapters/tracker/github/tracker_test.go index 57585b743..23fae915a 100644 --- a/backend/internal/adapters/tracker/github/tracker_test.go +++ b/backend/internal/adapters/tracker/github/tracker_test.go @@ -20,9 +20,10 @@ import ( // recordedReq captures one inbound HTTP request so tests can assert against // the exact GitHub API surface the adapter touched. type recordedReq struct { - Method string - Path string - Body string + Method string + Path string + Body string + IfNoneMatch string } // fakeGH is a programmable httptest.Server that matches requests by @@ -54,7 +55,7 @@ func (f *fakeGH) serve(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) key := r.Method + " " + r.URL.Path f.mu.Lock() - f.requests = append(f.requests, recordedReq{Method: r.Method, Path: r.URL.Path, Body: string(body)}) + f.requests = append(f.requests, recordedReq{Method: r.Method, Path: r.URL.Path, Body: string(body), IfNoneMatch: r.Header.Get("If-None-Match")}) h, ok := f.handlers[key] f.mu.Unlock() if !ok { @@ -427,8 +428,8 @@ func TestList_HappyPathAndDefaults(t *testing.T) { if got := q.Get("state"); got != "all" { t.Errorf("state = %q, want all (default)", got) } - if got := q.Get("per_page"); got != "30" { - t.Errorf("per_page = %q, want 30 (default)", got) + if got := q.Get("per_page"); got != "100" { + t.Errorf("per_page = %q, want 100 (default)", got) } _, _ = w.Write([]byte(`[ {"number":1,"title":"first","body":"b1","state":"open","html_url":"https://github.com/o/r/issues/1","labels":[{"name":"bug"}],"assignees":[]}, @@ -484,15 +485,15 @@ func TestList_QueryEncoding(t *testing.T) { { name: "open + labels + assignee + limit", filter: domain.ListFilter{State: domain.ListOpen, Labels: []string{"bug", "help wanted"}, Assignee: "alice", Limit: 50}, - wantQ: map[string]string{"state": "open", "labels": "bug,help wanted", "assignee": "alice", "per_page": "50"}, + wantQ: map[string]string{"state": "open", "labels": "bug,help wanted", "assignee": "alice", "per_page": "100"}, }, { name: "closed only", filter: domain.ListFilter{State: domain.ListClosed}, - wantQ: map[string]string{"state": "closed", "per_page": "30"}, + wantQ: map[string]string{"state": "closed", "per_page": "100"}, }, { - name: "limit capped at 100", + name: "large total limit still uses max page size", filter: domain.ListFilter{Limit: 9999}, wantQ: map[string]string{"state": "all", "per_page": "100"}, }, @@ -517,6 +518,324 @@ func TestList_QueryEncoding(t *testing.T) { } } +func TestList_PaginatesAcrossLinkNext(t *testing.T) { + f := newFakeGH(t) + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "": + w.Header().Set("Link", `<`+f.server.URL+`/repos/o/r/issues?state=all&per_page=100&page=2>; rel="next"`) + _, _ = w.Write([]byte(`[{"number":1,"title":"first","state":"open","html_url":"https://github.com/o/r/issues/1"}]`)) + case "2": + _, _ = w.Write([]byte(`[{"number":2,"title":"second","state":"open","html_url":"https://github.com/o/r/issues/2"}]`)) + default: + t.Fatalf("unexpected page %q", r.URL.Query().Get("page")) + } + }) + tr := newTrackerForTest(t, f) + + issues, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"}, domain.ListFilter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(issues) != 2 || issues[0].ID.Native != "o/r#1" || issues[1].ID.Native != "o/r#2" { + t.Fatalf("issues = %#v, want both pages in order", issues) + } +} + +func TestList_ConditionalRevalidationContinuesCachedPageChain(t *testing.T) { + f := newFakeGH(t) + pageCalls := map[string]int{} + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pageCalls[page]++ + switch { + case page == "" && pageCalls[page] == 1: + if got := r.Header.Get("If-None-Match"); got != "" { + t.Errorf("first page1 If-None-Match = %q, want empty", got) + } + w.Header().Set("ETag", `"e1"`) + w.Header().Set("Link", `<`+f.server.URL+`/repos/o/r/issues?state=all&per_page=100&page=2>; rel="next"`) + _, _ = w.Write([]byte(`[{"number":1,"title":"first","state":"open","html_url":"https://github.com/o/r/issues/1"}]`)) + case page == "2" && pageCalls[page] == 1: + if got := r.Header.Get("If-None-Match"); got != "" { + t.Errorf("first page2 If-None-Match = %q, want empty", got) + } + w.Header().Set("ETag", `"e2"`) + _, _ = w.Write([]byte(`[{"number":2,"title":"second","state":"open","html_url":"https://github.com/o/r/issues/2"}]`)) + case page == "" && pageCalls[page] == 2: + if got := r.Header.Get("If-None-Match"); got != `"e1"` { + t.Errorf("second page1 If-None-Match = %q, want \"e1\"", got) + } + w.WriteHeader(http.StatusNotModified) + case page == "2" && pageCalls[page] == 2: + if got := r.Header.Get("If-None-Match"); got != `"e2"` { + t.Errorf("second page2 If-None-Match = %q, want \"e2\"", got) + } + w.WriteHeader(http.StatusNotModified) + default: + t.Fatalf("unexpected page=%q call=%d", page, pageCalls[page]) + } + }) + tr := newTrackerForTest(t, f) + repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"} + + first, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("first List: %v", err) + } + second, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("second List: %v", err) + } + if !reflect.DeepEqual(second, first) { + t.Fatalf("second issues = %#v\nwant %#v", second, first) + } + if len(second) != 2 { + t.Fatalf("second len = %d, want both cached pages", len(second)) + } +} + +func TestList_PageCountShrinkIgnoresOrphanedCachedPage(t *testing.T) { + f := newFakeGH(t) + var page1Calls int + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + if page == "2" { + w.Header().Set("ETag", `"old-page-2"`) + _, _ = w.Write([]byte(`[{"number":2,"title":"old second","state":"open","html_url":"https://github.com/o/r/issues/2"}]`)) + return + } + page1Calls++ + switch page1Calls { + case 1: + w.Header().Set("ETag", `"old-page-1"`) + w.Header().Set("Link", `<`+f.server.URL+`/repos/o/r/issues?state=all&per_page=100&page=2>; rel="next"`) + _, _ = w.Write([]byte(`[{"number":1,"title":"old first","state":"open","html_url":"https://github.com/o/r/issues/1"}]`)) + case 2: + if got := r.Header.Get("If-None-Match"); got != `"old-page-1"` { + t.Errorf("second page1 If-None-Match = %q, want \"old-page-1\"", got) + } + w.Header().Set("ETag", `"new-page-1"`) + _, _ = w.Write([]byte(`[{"number":3,"title":"only remaining","state":"open","html_url":"https://github.com/o/r/issues/3"}]`)) + default: + t.Fatalf("unexpected page1 call #%d", page1Calls) + } + }) + tr := newTrackerForTest(t, f) + repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"} + + first, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("first List: %v", err) + } + if len(first) != 2 { + t.Fatalf("first len = %d, want two cached pages", len(first)) + } + second, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("second List: %v", err) + } + if len(second) != 1 || second[0].ID.Native != "o/r#3" { + t.Fatalf("second issues = %#v, want only refreshed page 1", second) + } +} + +func TestParseLinkNext(t *testing.T) { + baseURL := "https://api.github.com" + cases := []struct { + name string + link string + want string + }{ + { + name: "quoted next strips absolute host", + link: `<https://api.github.com/repos/o/r/issues?state=all&per_page=100&page=2>; rel="next"`, + want: "/repos/o/r/issues?state=all&per_page=100&page=2", + }, + { + name: "unquoted next among multiple links", + link: `<https://api.github.com/repos/o/r/issues?page=1>; rel=prev, <https://api.github.com/repos/o/r/issues?page=3>; rel=next`, + want: "/repos/o/r/issues?page=3", + }, + { + name: "multiple rel values", + link: `<https://example.test/repos/o/r/issues?page=4>; rel="last next"`, + want: "/repos/o/r/issues?page=4", + }, + { + name: "relative path", + link: `</repos/o/r/issues?page=2>; rel="next"`, + want: "/repos/o/r/issues?page=2", + }, + { + name: "no next", + link: `<https://api.github.com/repos/o/r/issues?page=1>; rel="prev"`, + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseLinkNext(tc.link, baseURL); got != tc.want { + t.Fatalf("parseLinkNext() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestList_ConditionalRevalidationReturns304CachedIssues(t *testing.T) { + f := newFakeGH(t) + var calls int + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + calls++ + switch calls { + case 1: + if got := r.Header.Get("If-None-Match"); got != "" { + t.Errorf("first If-None-Match = %q, want empty", got) + } + w.Header().Set("ETag", `"abc"`) + _, _ = w.Write([]byte(`[ + {"number":1,"title":"first","body":"b1","state":"open","html_url":"https://github.com/o/r/issues/1"}, + {"number":2,"title":"second","body":"b2","state":"closed","state_reason":"completed","html_url":"https://github.com/o/r/issues/2"} + ]`)) + case 2: + if got := r.Header.Get("If-None-Match"); got != `"abc"` { + t.Errorf("second If-None-Match = %q, want \"abc\"", got) + } + w.WriteHeader(http.StatusNotModified) + default: + t.Fatalf("unexpected List call #%d", calls) + } + }) + tr := newTrackerForTest(t, f) + repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"} + + first, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("first List: %v", err) + } + second, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("second List: %v", err) + } + if !reflect.DeepEqual(second, first) { + t.Fatalf("second issues = %#v\nwant %#v", second, first) + } + reqs := f.calls() + if len(reqs) != 2 { + t.Fatalf("HTTP calls = %d, want 2", len(reqs)) + } + if got := reqs[1].IfNoneMatch; got != `"abc"` { + t.Fatalf("recorded If-None-Match = %q, want \"abc\"", got) + } +} + +func TestList_ETagUpdatesWhenIssuesChange(t *testing.T) { + f := newFakeGH(t) + var calls int + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + calls++ + switch calls { + case 1: + w.Header().Set("ETag", `"v1"`) + _, _ = w.Write([]byte(`[{"number":1,"title":"old","state":"open","html_url":"https://github.com/o/r/issues/1"}]`)) + case 2: + if got := r.Header.Get("If-None-Match"); got != `"v1"` { + t.Errorf("second If-None-Match = %q, want \"v1\"", got) + } + w.Header().Set("ETag", `"v2"`) + _, _ = w.Write([]byte(`[{"number":2,"title":"new","state":"open","html_url":"https://github.com/o/r/issues/2"}]`)) + case 3: + if got := r.Header.Get("If-None-Match"); got != `"v2"` { + t.Errorf("third If-None-Match = %q, want \"v2\"", got) + } + w.WriteHeader(http.StatusNotModified) + default: + t.Fatalf("unexpected List call #%d", calls) + } + }) + tr := newTrackerForTest(t, f) + repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"} + + if _, err := tr.List(ctx(), repo, domain.ListFilter{}); err != nil { + t.Fatalf("first List: %v", err) + } + second, err := tr.List(ctx(), repo, domain.ListFilter{}) + if err != nil { + t.Fatalf("second List: %v", err) + } + if len(second) != 1 || second[0].ID.Native != "o/r#2" || second[0].Title != "new" { + t.Fatalf("second issues = %#v, want new issue", second) + } + if _, err := tr.List(ctx(), repo, domain.ListFilter{}); err != nil { + t.Fatalf("third List: %v", err) + } +} + +func TestList_SeparateCacheKeyPerFilter(t *testing.T) { + f := newFakeGH(t) + var calls int + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + calls++ + switch calls { + case 1: + if got := r.Header.Get("If-None-Match"); got != "" { + t.Errorf("first If-None-Match = %q, want empty", got) + } + w.Header().Set("ETag", `"bug-etag"`) + _, _ = w.Write([]byte(`[{"number":1,"title":"bug","state":"open","html_url":"https://github.com/o/r/issues/1"}]`)) + case 2: + if got := r.Header.Get("If-None-Match"); got != "" { + t.Errorf("second If-None-Match = %q, want empty for different filter", got) + } + w.Header().Set("ETag", `"docs-etag"`) + _, _ = w.Write([]byte(`[{"number":2,"title":"docs","state":"open","html_url":"https://github.com/o/r/issues/2"}]`)) + default: + t.Fatalf("unexpected List call #%d", calls) + } + }) + tr := newTrackerForTest(t, f) + repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"} + + first, err := tr.List(ctx(), repo, domain.ListFilter{Labels: []string{"bug"}}) + if err != nil { + t.Fatalf("first List: %v", err) + } + second, err := tr.List(ctx(), repo, domain.ListFilter{Labels: []string{"docs"}}) + if err != nil { + t.Fatalf("second List: %v", err) + } + if len(first) != 1 || first[0].Title != "bug" { + t.Fatalf("first issues = %#v, want bug", first) + } + if len(second) != 1 || second[0].Title != "docs" { + t.Fatalf("second issues = %#v, want docs", second) + } +} + +func TestList_NoETagHeaderNotCached(t *testing.T) { + f := newFakeGH(t) + var calls int + f.on("GET", "/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + calls++ + if got := r.Header.Get("If-None-Match"); got != "" { + t.Errorf("call #%d If-None-Match = %q, want empty", calls, got) + } + _, _ = w.Write([]byte(`[{"number":1,"title":"uncached","state":"open","html_url":"https://github.com/o/r/issues/1"}]`)) + }) + tr := newTrackerForTest(t, f) + repo := domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"} + + if _, err := tr.List(ctx(), repo, domain.ListFilter{}); err != nil { + t.Fatalf("first List: %v", err) + } + if _, err := tr.List(ctx(), repo, domain.ListFilter{}); err != nil { + t.Fatalf("second List: %v", err) + } + if got := len(f.calls()); got != 2 { + t.Fatalf("HTTP calls = %d, want 2", got) + } +} + func TestList_RejectsWrongProvider(t *testing.T) { f := newFakeGH(t) tr := newTrackerForTest(t, f) diff --git a/backend/internal/cli/project.go b/backend/internal/cli/project.go index ff74dec8e..08e9a7439 100644 --- a/backend/internal/cli/project.go +++ b/backend/internal/cli/project.go @@ -85,18 +85,27 @@ type roleOverride struct { AgentConfig agentConfig `json:"agentConfig,omitempty"` } +// trackerIntakeConfig mirrors domain.TrackerIntakeConfig. +type trackerIntakeConfig struct { + Enabled bool `json:"enabled,omitempty"` + Provider string `json:"provider,omitempty"` + Repo string `json:"repo,omitempty"` + Assignee string `json:"assignee,omitempty"` +} + // projectConfig mirrors the daemon's typed domain.ProjectConfig for the CLI // client. The CLI sets common fields via flags and the whole object via // --config-json. type projectConfig struct { - DefaultBranch string `json:"defaultBranch,omitempty"` - SessionPrefix string `json:"sessionPrefix,omitempty"` - Env map[string]string `json:"env,omitempty"` - Symlinks []string `json:"symlinks,omitempty"` - PostCreate []string `json:"postCreate,omitempty"` - AgentConfig agentConfig `json:"agentConfig,omitempty"` - Worker roleOverride `json:"worker,omitempty"` - Orchestrator roleOverride `json:"orchestrator,omitempty"` + DefaultBranch string `json:"defaultBranch,omitempty"` + SessionPrefix string `json:"sessionPrefix,omitempty"` + Env map[string]string `json:"env,omitempty"` + Symlinks []string `json:"symlinks,omitempty"` + PostCreate []string `json:"postCreate,omitempty"` + AgentConfig agentConfig `json:"agentConfig,omitempty"` + Worker roleOverride `json:"worker,omitempty"` + Orchestrator roleOverride `json:"orchestrator,omitempty"` + TrackerIntake trackerIntakeConfig `json:"trackerIntake,omitempty"` } // setConfigRequest mirrors the daemon's SetConfigInput body for @@ -115,6 +124,9 @@ type projectSetConfigOptions struct { env []string symlink []string postCreate []string + trackerIntake bool + trackerRepo string + trackerAssignee string configJSON string clear bool json bool @@ -259,7 +271,7 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { Use: "set-config <id>", Short: "Set the per-project config", Long: "Replace a project's per-project config (branch, session prefix, env, " + - "symlinks, post-create, agent model/permissions, role overrides). The config " + + "symlinks, post-create, agent model/permissions, role overrides, tracker intake). The config " + "is resolved when a session spawns.\n\n" + "Set fields via flags, pass the whole object with --config-json, or --clear " + "to remove all config.", @@ -300,6 +312,9 @@ func newProjectSetConfigCommand(ctx *commandContext) *cobra.Command { f.StringArrayVar(&opts.env, "env", nil, "Env var KEY=VALUE forwarded into sessions (repeatable)") f.StringArrayVar(&opts.symlink, "symlink", nil, "Repo-relative path to symlink into workspaces (repeatable)") f.StringArrayVar(&opts.postCreate, "post-create", nil, "Command to run after workspace creation (repeatable)") + f.BoolVar(&opts.trackerIntake, "tracker-intake", false, "Enable GitHub issue intake for matching issues") + f.StringVar(&opts.trackerRepo, "tracker-repo", "", "GitHub repo for issue intake (owner/repo; default: derive from git origin)") + f.StringVar(&opts.trackerAssignee, "tracker-assignee", "", "GitHub issue assignee required for intake eligibility") f.StringVar(&opts.configJSON, "config-json", "", "Full config as a JSON object (overrides field flags)") f.BoolVar(&opts.clear, "clear", false, "Clear all config") f.BoolVar(&opts.json, "json", false, "Output the updated project as JSON") @@ -335,6 +350,12 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { AgentConfig: agentConfig{Model: opts.model, Permissions: opts.permission}, Worker: roleOverride{Agent: opts.workerAgent}, Orchestrator: roleOverride{Agent: opts.orchestratorAgent}, + TrackerIntake: trackerIntakeConfig{ + Enabled: opts.trackerIntake, + Provider: trackerProviderForFlags(opts), + Repo: opts.trackerRepo, + Assignee: opts.trackerAssignee, + }, } if reflect.DeepEqual(cfg, projectConfig{}) { return projectConfig{}, usageError{errors.New("usage: provide at least one config flag, --config-json, or --clear")} @@ -342,6 +363,13 @@ func buildProjectConfig(opts projectSetConfigOptions) (projectConfig, error) { return cfg, nil } +func trackerProviderForFlags(opts projectSetConfigOptions) string { + if opts.trackerIntake || opts.trackerRepo != "" || opts.trackerAssignee != "" { + return "github" + } + return "" +} + // parseEnvPairs turns repeated KEY=VALUE flags into a map. func parseEnvPairs(pairs []string) (map[string]string, error) { if len(pairs) == 0 { diff --git a/backend/internal/cli/project_test.go b/backend/internal/cli/project_test.go index eea7c924a..2099d5ad5 100644 --- a/backend/internal/cli/project_test.go +++ b/backend/internal/cli/project_test.go @@ -12,6 +12,7 @@ import ( type projectCapture struct { method string path string + body []byte } func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, *projectCapture) { @@ -20,6 +21,7 @@ func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capture.method = r.Method capture.path = r.URL.Path + capture.body, _ = io.ReadAll(r.Body) if !strings.HasPrefix(r.URL.Path, "/api/v1/projects") { http.NotFound(w, r) return @@ -32,6 +34,63 @@ func projectServer(t *testing.T, status int, respBody string) (*httptest.Server, return srv, capture } +func TestProjectSetConfig_TrackerIntakeFlags(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"project":{"id":"demo","path":"/repo/demo"}}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "set-config", "demo", "--tracker-intake", "--tracker-repo", "acme/demo", "--tracker-assignee", "alice") + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + if capture.method != http.MethodPut || capture.path != "/api/v1/projects/demo/config" { + t.Fatalf("request = %s %s, want PUT /api/v1/projects/demo/config", capture.method, capture.path) + } + var got setConfigRequest + if err := json.Unmarshal(capture.body, &got); err != nil { + t.Fatalf("decode request: %v\nbody=%s", err, capture.body) + } + if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Repo != "acme/demo" || got.Config.TrackerIntake.Assignee != "alice" { + t.Fatalf("tracker intake request = %#v", got.Config.TrackerIntake) + } +} + +func TestProjectSetConfig_TrackerIntakeJSON(t *testing.T) { + cfg := setConfigEnv(t) + srv, capture := projectServer(t, http.StatusOK, `{"project":{"id":"demo","path":"/repo/demo"}}`) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ + ProcessAlive: func(int) bool { return true }, + }, "project", "set-config", "demo", "--config-json", `{"trackerIntake":{"enabled":true,"provider":"github","assignee":"alice"}}`) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut) + } + var got setConfigRequest + if err := json.Unmarshal(capture.body, &got); err != nil { + t.Fatalf("decode request: %v\nbody=%s", err, capture.body) + } + if !got.Config.TrackerIntake.Enabled || got.Config.TrackerIntake.Provider != "github" || got.Config.TrackerIntake.Assignee != "alice" { + t.Fatalf("tracker intake request = %#v", got.Config.TrackerIntake) + } +} + +func TestBuildProjectConfigTrackerIntakeFlags(t *testing.T) { + got, err := buildProjectConfig(projectSetConfigOptions{ + trackerIntake: true, + trackerRepo: "acme/demo", + trackerAssignee: "alice", + }) + if err != nil { + t.Fatal(err) + } + if !got.TrackerIntake.Enabled || got.TrackerIntake.Provider != "github" || got.TrackerIntake.Repo != "acme/demo" || got.TrackerIntake.Assignee != "alice" { + t.Fatalf("tracker intake config = %#v", got.TrackerIntake) + } +} + func TestProjectList_Success(t *testing.T) { cfg := setConfigEnv(t) srv, capture := projectServer(t, http.StatusOK, `{"projects":[{"id":"zeta","name":"Zeta","sessionPrefix":"zeta"},{"id":"alpha","name":"Alpha","sessionPrefix":"alpha","resolveError":"config missing"}]}`) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 4f798cb7c..ab7c2b2eb 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -128,6 +128,7 @@ func Run() error { } return fmt.Errorf("wire session service: %w", err) } + lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ diff --git a/backend/internal/daemon/lifecycle_wiring.go b/backend/internal/daemon/lifecycle_wiring.go index c5391fe19..5113d9dcb 100644 --- a/backend/internal/daemon/lifecycle_wiring.go +++ b/backend/internal/daemon/lifecycle_wiring.go @@ -34,9 +34,10 @@ type lifecycleStack struct { // LCM is the Lifecycle Manager (the canonical write path). It is exposed so // startSession can share the same reducer the reaper drives, rather than // standing up a second store+LCM pair that would diverge under writes. - LCM *lifecycle.Manager - reaperDone <-chan struct{} - scmDone <-chan struct{} + LCM *lifecycle.Manager + reaperDone <-chan struct{} + scmDone <-chan struct{} + trackerDone <-chan struct{} } // startLifecycle constructs the Lifecycle Manager over the store and starts the @@ -56,6 +57,9 @@ func (l *lifecycleStack) Stop() { if l.scmDone != nil { <-l.scmDone } + if l.trackerDone != nil { + <-l.trackerDone + } } // sessionLifecycle is the narrow surface of sessionmanager.Manager used for diff --git a/backend/internal/daemon/tracker_intake_wiring.go b/backend/internal/daemon/tracker_intake_wiring.go new file mode 100644 index 000000000..cfdd15576 --- /dev/null +++ b/backend/internal/daemon/tracker_intake_wiring.go @@ -0,0 +1,132 @@ +package daemon + +import ( + "context" + "errors" + "log/slog" + "strings" + "sync" + "time" + + trackergithub "github.com/aoagents/agent-orchestrator/backend/internal/adapters/tracker/github" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + trackerintake "github.com/aoagents/agent-orchestrator/backend/internal/observe/trackerintake" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + aoprocess "github.com/aoagents/agent-orchestrator/backend/internal/process" + sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// startTrackerIntake wires the opt-in GitHub issue-intake loop. The observer +// always runs — Poll re-reads each project's config on every tick and skips +// projects with intake disabled, so a project enabling intake after daemon +// boot is picked up on the next tick without a restart. The adapter itself +// stays lazy so daemon readiness is not blocked by credential probing or a gh +// CLI call, and no token is resolved until some enabled project is actually +// polled. +func startTrackerIntake(ctx context.Context, store *sqlite.Store, sessions *sessionsvc.Service, logger *slog.Logger) <-chan struct{} { + resolver := trackerintake.SingleTrackerResolver{ + Provider: domain.TrackerProviderGitHub, + Adapter: newLazyGitHubTracker(logger), + } + observer := trackerintake.New(resolver, store, sessions, trackerintake.Config{Logger: logger}) + return observer.Start(ctx) +} + +// --------------------------------------------------------------------------- +// GitHub lazy adapter (token sourced from env or gh CLI fallback) +// --------------------------------------------------------------------------- + +type lazyGitHubTracker struct { + logger *slog.Logger + tokens *trackerTokenSource + mu sync.Mutex + tracker ports.Tracker +} + +func newLazyGitHubTracker(logger *slog.Logger) *lazyGitHubTracker { + return &lazyGitHubTracker{logger: logger, tokens: &trackerTokenSource{}} +} + +func (t *lazyGitHubTracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return domain.Issue{}, err + } + return tracker.Get(ctx, id) +} + +func (t *lazyGitHubTracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + tracker, err := t.resolve() + if err != nil { + return nil, err + } + return tracker.List(ctx, repo, filter) +} + +func (t *lazyGitHubTracker) Preflight(ctx context.Context) error { + tracker, err := t.resolve() + if err != nil { + return err + } + return tracker.Preflight(ctx) +} + +func (t *lazyGitHubTracker) resolve() (ports.Tracker, error) { + t.mu.Lock() + defer t.mu.Unlock() + if t.tracker != nil { + return t.tracker, nil + } + tracker, err := trackergithub.New(trackergithub.Options{Token: t.tokens}) + if err != nil { + if errors.Is(err, trackergithub.ErrNoToken) && t.logger != nil { + t.logger.Warn("tracker intake disabled: no usable GitHub token", "err", err) + } + return nil, err + } + t.tracker = tracker + return tracker, nil +} + +const ( + trackerTokenCacheTTL = 5 * time.Minute + trackerTokenCommandTimeout = 5 * time.Second +) + +// trackerTokenSource mirrors the SCM credential precedence while returning the +// tracker adapter's own ErrNoToken sentinel. +type trackerTokenSource struct { + mu sync.Mutex + token string + expiresAt time.Time +} + +func (s *trackerTokenSource) Token(ctx context.Context) (string, error) { + env := trackergithub.EnvTokenSource{EnvVars: []string{"AO_GITHUB_TOKEN"}} + if tok, err := env.Token(ctx); err == nil { + return tok, nil + } else if !errors.Is(err, trackergithub.ErrNoToken) { + return "", err + } + + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + if s.token != "" && now.Before(s.expiresAt) { + return s.token, nil + } + cmdCtx, cancel := context.WithTimeout(ctx, trackerTokenCommandTimeout) + defer cancel() + out, err := aoprocess.CommandContext(cmdCtx, "gh", "auth", "token").Output() + if err != nil { + return "", err + } + token := strings.TrimSpace(string(out)) + if token == "" { + return "", trackergithub.ErrNoToken + } + s.token = token + s.expiresAt = now.Add(trackerTokenCacheTTL) + return token, nil +} diff --git a/backend/internal/daemon/wiring_test.go b/backend/internal/daemon/wiring_test.go index c5a24402d..9a3eb2058 100644 --- a/backend/internal/daemon/wiring_test.go +++ b/backend/internal/daemon/wiring_test.go @@ -167,6 +167,58 @@ func TestWiring_StartSessionBuildsSessionService(t *testing.T) { } } +// TestStartTrackerIntake_RunsEvenWithoutEnabledProjects is a regression test: +// startTrackerIntake used to scan projects once at call time and skip starting +// the observer loop entirely when none had intake enabled yet. Poll() itself +// already re-reads project config on every tick, so a project enabling +// intake after daemon boot was silently never picked up until a restart. The +// loop must always start; Poll is what decides whether there's work to do. +func TestStartTrackerIntake_RunsEvenWithoutEnabledProjects(t *testing.T) { + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + lcm := lifecycle.New(store, nil) + cfg := config.Config{DataDir: t.TempDir()} + rt := runtimeselect.New(nil) + messenger := newSessionMessenger(store, rt, log) + svc, _, _, err := startSession(cfg, rt, store, lcm, messenger, telemetryadapter.NoopSink{}, log) + if err != nil { + t.Fatalf("startSession: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := startTrackerIntake(ctx, store, svc, log) + + select { + case <-done: + t.Fatal("startTrackerIntake returned an already-closed channel; observer loop did not start") + case <-time.After(20 * time.Millisecond): + } + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("observer did not stop after context cancellation") + } +} + +func TestTrackerTokenSourcePrefersAOGitHubToken(t *testing.T) { + t.Setenv("AO_GITHUB_TOKEN", "ao-token") + t.Setenv("GITHUB_TOKEN", "github-token") + token, err := (&trackerTokenSource{}).Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "ao-token" { + t.Fatalf("token = %q, want AO_GITHUB_TOKEN", token) + } +} + type captureRuntimeSender struct { handle ports.RuntimeHandle message string diff --git a/backend/internal/domain/projectconfig.go b/backend/internal/domain/projectconfig.go index 828890e39..15e1c918a 100644 --- a/backend/internal/domain/projectconfig.go +++ b/backend/internal/domain/projectconfig.go @@ -14,9 +14,8 @@ import ( // // Only fields with a live consumer are modeled: DefaultBranch, Env, Symlinks, // PostCreate, AgentConfig, and the role overrides are consumed at spawn; -// SessionPrefix feeds the display prefix. Settings whose consumers do not yet -// exist (tracker/SCM per-project config, prompt rules) are intentionally absent -// and land in focused follow-up PRs alongside the code that reads them. +// SessionPrefix feeds the display prefix. TrackerIntake feeds the background +// issue-intake loop. type ProjectConfig struct { // DefaultBranch is the base branch new session worktrees are created from. DefaultBranch string `json:"defaultBranch,omitempty"` @@ -41,6 +40,11 @@ type ProjectConfig struct { // triggered. It is configured independently of the Worker override; an empty // list falls back to claude-code (see ResolveReviewerHarness). Reviewers []ReviewerConfig `json:"reviewers,omitempty"` + + // TrackerIntake controls issue-driven worker spawning. It is opt-in and + // read-only toward the tracker in v1: matching issues spawn sessions, but the + // tracker is not commented on or transitioned. + TrackerIntake TrackerIntakeConfig `json:"trackerIntake,omitempty"` } // ReviewerConfig names one reviewer agent by harness. The harness is drawn from @@ -88,6 +92,7 @@ func (c ProjectConfig) WithDefaults() ProjectConfig { if c.DefaultBranch == "" { c.DefaultBranch = def.DefaultBranch } + c.TrackerIntake = c.TrackerIntake.WithDefaults() return c } @@ -124,6 +129,19 @@ func (c ProjectConfig) Validate() error { return fmt.Errorf("reviewers[%d].harness: unknown harness %q", i, rv.Harness) } } + if err := c.TrackerIntake.Validate(); err != nil { + return err + } + return nil +} + +func validateNoWhitespaceField(name, value string) error { + if value == "" { + return nil + } + if strings.TrimSpace(value) != value { + return fmt.Errorf("%s: must not have leading or trailing whitespace", name) + } return nil } diff --git a/backend/internal/domain/projectconfig_test.go b/backend/internal/domain/projectconfig_test.go index 2542c5653..d100708f6 100644 --- a/backend/internal/domain/projectconfig_test.go +++ b/backend/internal/domain/projectconfig_test.go @@ -29,6 +29,12 @@ func TestProjectConfigValidate(t *testing.T) { {"unknown reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: "nope"}}}, true}, {"worker-only harness is not auto a reviewer", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ReviewerHarness(HarnessAider)}}}, true}, {"empty reviewer harness", ProjectConfig{Reviewers: []ReviewerConfig{{Harness: ""}}}, true}, + {"tracker intake assignee rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, false}, + {"tracker intake explicit github", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: TrackerProviderGitHub, Assignee: "alice"}}, false}, + {"tracker intake no rule", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true}}, true}, + {"tracker intake unknown provider", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Provider: "linear", Assignee: "alice"}}, true}, + {"tracker intake repo with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Repo: " acme/demo", Assignee: "alice"}}, true}, + {"tracker intake assignee with whitespace", ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: " alice"}}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -73,6 +79,16 @@ func TestProjectConfigWithDefaults(t *testing.T) { if got.AgentConfig.Model != "m" { t.Fatalf("WithDefaults dropped a set field: %#v", got.AgentConfig) } + + got = (ProjectConfig{TrackerIntake: TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}).WithDefaults() + if got.TrackerIntake.Provider != TrackerProviderGitHub { + t.Fatalf("TrackerIntake.Provider = %q, want %q", got.TrackerIntake.Provider, TrackerProviderGitHub) + } + + got = (ProjectConfig{}).WithDefaults() + if got.TrackerIntake.Provider != "" { + t.Fatalf("disabled TrackerIntake.Provider = %q, want empty", got.TrackerIntake.Provider) + } } func TestResolveReviewerHarness(t *testing.T) { diff --git a/backend/internal/domain/tracker.go b/backend/internal/domain/tracker.go index fde1631b3..289d4347f 100644 --- a/backend/internal/domain/tracker.go +++ b/backend/internal/domain/tracker.go @@ -1,5 +1,10 @@ package domain +import ( + "fmt" + "strings" +) + // TrackerProvider identifies an issue-tracker provider implementation. type TrackerProvider string @@ -64,11 +69,56 @@ const ( // ListFilter is the query the Session Manager passes to Tracker.List. // Empty / zero values mean "no filter on this dimension". // -// Limit is the requested page size. The adapter applies its own default when -// zero and caps at the provider's per-page maximum. +// Limit is an optional total-result cap. Adapters choose their own provider +// page size. type ListFilter struct { State ListStateFilter `json:"state,omitempty"` Labels []string `json:"labels,omitempty"` Assignee string `json:"assignee,omitempty"` Limit int `json:"limit,omitempty"` } + +// TrackerIntakeConfig controls issue-driven worker spawning for a project. +// Enabled requires an explicit assignee eligibility rule so turning intake on +// cannot accidentally drain an entire issue backlog. +type TrackerIntakeConfig struct { + Enabled bool `json:"enabled,omitempty"` + // Provider defaults to github when Enabled is true. + Provider TrackerProvider `json:"provider,omitempty" enum:"github"` + // Repo is the GitHub-native repository key ("owner/repo"). When empty, the + // intake loop derives it from the project's repo origin URL. GitHub only. + Repo string `json:"repo,omitempty"` + // Assignee narrows eligible issues to one assignee. Provider-specific values + // such as "*" are passed through unchanged. + Assignee string `json:"assignee,omitempty"` +} + +// WithDefaults fills the provider only when intake is enabled. Disabled intake +// leaves the zero value untouched so empty project configs still store as NULL. +func (c TrackerIntakeConfig) WithDefaults() TrackerIntakeConfig { + if c.Enabled && c.Provider == "" { + c.Provider = TrackerProviderGitHub + } + return c +} + +// Validate rejects accidental broad intake and unknown providers. +func (c TrackerIntakeConfig) Validate() error { + if !c.Enabled { + return nil + } + c = c.WithDefaults() + if c.Enabled && c.Provider != TrackerProviderGitHub { + return fmt.Errorf("trackerIntake.provider: unsupported provider %q", c.Provider) + } + if err := validateNoWhitespaceField("trackerIntake.repo", c.Repo); err != nil { + return err + } + if err := validateNoWhitespaceField("trackerIntake.assignee", c.Assignee); err != nil { + return err + } + if strings.TrimSpace(c.Assignee) == "" { + return fmt.Errorf("trackerIntake: assignee is required when enabled") + } + return nil +} diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index c194ffa0b..2d7e7bf46 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1938,6 +1938,8 @@ components: items: type: string type: array + trackerIntake: + $ref: '#/components/schemas/TrackerIntakeConfig' worker: $ref: '#/components/schemas/RoleOverride' type: object @@ -2546,6 +2548,19 @@ components: - runId - verdict type: object + TrackerIntakeConfig: + properties: + assignee: + type: string + enabled: + type: boolean + provider: + enum: + - github + type: string + repo: + type: string + type: object TriggerReviewResponse: properties: reviewerHandleId: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 21570e358..564328684 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -124,13 +124,14 @@ var schemaNames = map[string]string{ // httpd/envelope "EnvelopeAPIError": "APIError", // domain - "DomainProjectID": "ProjectID", - "DomainSessionID": "SessionID", - "DomainIssueID": "IssueID", - "DomainSession": "Session", - "DomainProjectConfig": "ProjectConfig", - "DomainAgentConfig": "AgentConfig", - "DomainRoleOverride": "RoleOverride", + "DomainProjectID": "ProjectID", + "DomainSessionID": "SessionID", + "DomainIssueID": "IssueID", + "DomainSession": "Session", + "DomainProjectConfig": "ProjectConfig", + "DomainTrackerIntakeConfig": "TrackerIntakeConfig", + "DomainAgentConfig": "AgentConfig", + "DomainRoleOverride": "RoleOverride", // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", diff --git a/backend/internal/observe/trackerintake/observer.go b/backend/internal/observe/trackerintake/observer.go new file mode 100644 index 000000000..d3e46964b --- /dev/null +++ b/backend/internal/observe/trackerintake/observer.go @@ -0,0 +1,369 @@ +// Package trackerintake implements the opt-in issue-intake observer. It polls a +// project's configured tracker for eligible issues and starts one worker session +// per issue, leaving PR/lifecycle handling to the existing observers. +package trackerintake + +import ( + "context" + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/observe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // DefaultTickInterval is intentionally slower than runtime liveness checks: + // intake is a backlog sweep, not an interactive status surface. + DefaultTickInterval = time.Minute + // DefaultFailureBackoff suppresses repeated polls for a project after an + // intake failure. The observer retries automatically after this window. + DefaultFailureBackoff = 5 * time.Minute + // maxIntakePromptLen mirrors the session HTTP prompt limit. Intake uses the + // session service directly, so it must enforce the same boundary itself. + maxIntakePromptLen = 4096 + + intakePromptTruncationNotice = "\n\n[Issue content truncated to fit the session prompt limit. Open the linked issue for the full details.]\n" + intakePromptFooter = "\nImplement the requested change in this repository, run the relevant checks, and open or update a pull request when ready." +) + +// Store is the durable read surface the observer needs. +type Store interface { + ListProjects(ctx context.Context) ([]domain.ProjectRecord, error) + ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) +} + +// Spawner is the session creation surface used by intake. +type Spawner interface { + Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) +} + +// TrackerResolver picks the tracker adapter for a project's configured +// provider. +type TrackerResolver interface { + Resolve(provider domain.TrackerProvider) (ports.Tracker, error) +} + +// SingleTrackerResolver returns the same tracker for one specific provider and +// refuses every other provider. It exists so single-provider deployments don't +// need to construct a map. +type SingleTrackerResolver struct { + Provider domain.TrackerProvider + Adapter ports.Tracker +} + +// Resolve returns the wrapped adapter when the requested provider matches, or +// when the resolver was constructed without a provider pin. +func (s SingleTrackerResolver) Resolve(provider domain.TrackerProvider) (ports.Tracker, error) { + if s.Adapter == nil { + return nil, fmt.Errorf("tracker intake: no adapter for provider %q", provider) + } + if s.Provider == "" || provider == "" || provider == s.Provider { + return s.Adapter, nil + } + return nil, fmt.Errorf("tracker intake: no adapter for provider %q", provider) +} + +// Config holds optional observer knobs. Zero values use production defaults. +type Config struct { + Tick time.Duration + FailureBackoff time.Duration + Clock func() time.Time + Logger *slog.Logger +} + +// Observer polls configured projects and starts sessions for eligible issues. +type Observer struct { + resolver TrackerResolver + store Store + spawner Spawner + tick time.Duration + failureBackoff time.Duration + clock func() time.Time + logger *slog.Logger + backoffUntil map[string]time.Time +} + +// New constructs an Observer with safe defaults. +func New(resolver TrackerResolver, store Store, spawner Spawner, cfg Config) *Observer { + o := &Observer{resolver: resolver, store: store, spawner: spawner, tick: cfg.Tick, failureBackoff: cfg.FailureBackoff, clock: cfg.Clock, logger: cfg.Logger, backoffUntil: map[string]time.Time{}} + if o.tick <= 0 { + o.tick = DefaultTickInterval + } + if o.failureBackoff <= 0 { + o.failureBackoff = DefaultFailureBackoff + } + if o.clock == nil { + o.clock = time.Now + } + if o.logger == nil { + o.logger = slog.Default() + } + return o +} + +// Start launches the observer loop. The first poll runs immediately inside the +// goroutine, keeping daemon startup non-blocking. +func (o *Observer) Start(ctx context.Context) <-chan struct{} { + return observe.StartPollLoop(ctx, o.tick, o.Poll, o.logger, "tracker intake") +} + +// Poll runs one synchronous intake pass. Store discovery failures are returned +// because they prevent the pass from knowing the current world; provider and +// spawn failures are logged and skipped so one bad issue/project does not block +// the rest of the daemon. +func (o *Observer) Poll(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if o.resolver == nil || o.store == nil || o.spawner == nil { + return nil + } + now := o.clock().UTC() + projects, err := o.store.ListProjects(ctx) + if err != nil { + return err + } + enabledProjects := make([]domain.ProjectRecord, 0, len(projects)) + for _, project := range projects { + if project.Config.TrackerIntake.Enabled { + enabledProjects = append(enabledProjects, project) + } + } + if len(enabledProjects) == 0 { + return nil + } + sessions, err := o.store.ListAllSessions(ctx) + if err != nil { + return err + } + seen := seenIssueIDs(sessions) + for _, project := range enabledProjects { + if err := ctx.Err(); err != nil { + return err + } + if until, ok := o.backoffUntil[project.ID]; ok && now.Before(until) { + o.logger.Debug("tracker intake: project in failure backoff", "project", project.ID, "until", until) + continue + } + if failed := o.pollProject(ctx, project, seen); failed { + o.backoffUntil[project.ID] = now.Add(o.failureBackoff) + } else { + delete(o.backoffUntil, project.ID) + } + } + return nil +} + +// pollProject returns failed=true for conditions that should be retried after a +// backoff window rather than logged on every poll. +func (o *Observer) pollProject(ctx context.Context, project domain.ProjectRecord, seen map[domain.IssueID]bool) (failed bool) { + cfg := project.Config.TrackerIntake.WithDefaults() + if !cfg.Enabled { + return false + } + if err := cfg.Validate(); err != nil { + o.logger.Warn("tracker intake: skipping project with invalid config", "project", project.ID, "err", err) + return true + } + repo, ok := trackerRepo(project, cfg) + if !ok { + o.logger.Warn("tracker intake: skipping project without tracker scope", "project", project.ID, "provider", cfg.Provider, "origin", project.RepoOriginURL) + return true + } + tracker, err := o.resolver.Resolve(cfg.Provider) + if err != nil { + o.logger.Warn("tracker intake: no adapter for provider", "project", project.ID, "provider", cfg.Provider, "err", err) + return true + } + issues, err := tracker.List(ctx, repo, domain.ListFilter{ + State: domain.ListOpen, + Assignee: cfg.Assignee, + }) + if err != nil { + o.logger.Error("tracker intake: list issues failed", "project", project.ID, "repo", repo.Native, "err", err) + return true + } + var spawnFailed bool + for _, issue := range issues { + if ctx.Err() != nil { + return true + } + if issue.State != domain.IssueOpen { + continue + } + if !issueMatchesConfig(issue, cfg) { + continue + } + issueID := CanonicalIssueID(issue.ID) + if issueID == "" || seen[issueID] { + continue + } + if _, err := o.spawner.Spawn(ctx, ports.SpawnConfig{ + ProjectID: domain.ProjectID(project.ID), + IssueID: issueID, + Kind: domain.KindWorker, + Prompt: BuildIssuePrompt(issue), + }); err != nil { + o.logger.Error("tracker intake: spawn issue session failed", "project", project.ID, "issue", issueID, "err", err) + spawnFailed = true + continue + } + seen[issueID] = true + } + return spawnFailed +} + +func issueMatchesConfig(issue domain.Issue, cfg domain.TrackerIntakeConfig) bool { + assignee := strings.TrimSpace(cfg.Assignee) + switch { + case assignee == "": + return true + case assignee == "*": + return len(issue.Assignees) > 0 + case strings.EqualFold(assignee, "none"): + return len(issue.Assignees) == 0 + default: + return containsFold(issue.Assignees, assignee) + } +} + +func containsFold(values []string, needle string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), needle) { + return true + } + } + return false +} + +func seenIssueIDs(sessions []domain.SessionRecord) map[domain.IssueID]bool { + seen := make(map[domain.IssueID]bool, len(sessions)) + for _, sess := range sessions { + if sess.IssueID != "" { + seen[sess.IssueID] = true + } + } + return seen +} + +// CanonicalIssueID stores tracker issue ids in sessions.issue_id with the +// provider included, so future providers cannot collide on native ids. +func CanonicalIssueID(id domain.TrackerID) domain.IssueID { + provider := id.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + native := strings.TrimSpace(id.Native) + if native == "" { + return "" + } + return domain.IssueID(string(provider) + ":" + native) +} + +// BuildIssuePrompt turns normalized issue facts into the worker's initial task. +func BuildIssuePrompt(issue domain.Issue) string { + var b strings.Builder + fmt.Fprintf(&b, "Work on tracker issue %s.\n\n", CanonicalIssueID(issue.ID)) + if issue.Title != "" { + fmt.Fprintf(&b, "Title: %s\n", issue.Title) + } + if issue.URL != "" { + fmt.Fprintf(&b, "URL: %s\n", issue.URL) + } + if len(issue.Labels) > 0 { + fmt.Fprintf(&b, "Labels: %s\n", strings.Join(issue.Labels, ", ")) + } + if len(issue.Assignees) > 0 { + fmt.Fprintf(&b, "Assignees: %s\n", strings.Join(issue.Assignees, ", ")) + } + body := strings.TrimSpace(issue.Body) + if body != "" { + fmt.Fprintf(&b, "\nBody:\n%s\n", body) + } + b.WriteString(intakePromptFooter) + return capIntakePrompt(b.String()) +} + +func capIntakePrompt(prompt string) string { + if len(prompt) <= maxIntakePromptLen { + return prompt + } + prefix := strings.TrimSuffix(prompt, intakePromptFooter) + prefixBudget := maxIntakePromptLen - len(intakePromptTruncationNotice) - len(intakePromptFooter) + if prefixBudget <= 0 { + return truncateUTF8(prompt, maxIntakePromptLen) + } + return truncateUTF8(prefix, prefixBudget) + intakePromptTruncationNotice + intakePromptFooter +} + +func truncateUTF8(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := 0 + for i := range s { + if i > maxBytes { + break + } + cut = i + } + return s[:cut] +} + +func trackerRepo(project domain.ProjectRecord, cfg domain.TrackerIntakeConfig) (domain.TrackerRepo, bool) { + provider := cfg.Provider + if provider == "" { + provider = domain.TrackerProviderGitHub + } + if provider != domain.TrackerProviderGitHub { + return domain.TrackerRepo{}, false + } + native := strings.TrimSpace(cfg.Repo) + if native == "" { + native = parseGitHubRepoNative(project.RepoOriginURL) + } + if native == "" { + return domain.TrackerRepo{}, false + } + return domain.TrackerRepo{Provider: provider, Native: native}, true +} + +func parseGitHubRepoNative(remote string) string { + remote = strings.TrimSpace(remote) + if remote == "" { + return "" + } + if strings.HasPrefix(remote, "git@") { + if _, rest, ok := strings.Cut(remote, ":"); ok { + return cleanRepoPath(rest) + } + } + if u, err := url.Parse(remote); err == nil && u.Host != "" { + host := strings.TrimPrefix(strings.ToLower(u.Host), "www.") + if host == "github.com" || strings.HasSuffix(host, ".github.com") || strings.HasSuffix(host, ".ghe.io") { + return cleanRepoPath(u.Path) + } + return "" + } + return cleanRepoPath(remote) +} + +func cleanRepoPath(path string) string { + path = strings.Trim(strings.TrimSpace(path), "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "" + } + owner := strings.TrimSpace(parts[len(parts)-2]) + repo := strings.TrimSpace(parts[len(parts)-1]) + if owner == "" || repo == "" { + return "" + } + return owner + "/" + repo +} diff --git a/backend/internal/observe/trackerintake/observer_test.go b/backend/internal/observe/trackerintake/observer_test.go new file mode 100644 index 000000000..08ef3d8c2 --- /dev/null +++ b/backend/internal/observe/trackerintake/observer_test.go @@ -0,0 +1,345 @@ +package trackerintake + +import ( + "context" + "errors" + "io" + "log/slog" + "strings" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestPollSpawnsWorkerForEligibleIssue(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ + Enabled: true, + Assignee: "alice", + }}, + }}, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#12"}, + Title: "Fix login", + Body: "The login form submits twice.", + State: domain.IssueOpen, + URL: "https://github.com/acme/demo/issues/12", + Labels: []string{"agent-ready"}, + Assignees: []string{"alice"}, + }}} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 { + t.Fatalf("spawn calls = %d, want 1", len(spawner.calls)) + } + call := spawner.calls[0] + if call.ProjectID != "demo" || call.Kind != domain.KindWorker { + t.Fatalf("spawn config = %+v", call) + } + if call.IssueID != "github:acme/demo#12" { + t.Fatalf("IssueID = %q, want canonical github id", call.IssueID) + } + if !strings.Contains(call.Prompt, "Fix login") || !strings.Contains(call.Prompt, "The login form submits twice.") { + t.Fatalf("prompt missing issue context:\n%s", call.Prompt) + } + if len(tracker.filters) != 1 { + t.Fatalf("tracker filters = %d, want 1", len(tracker.filters)) + } + if got := tracker.filters[0]; got.State != domain.ListOpen || got.Assignee != "alice" || len(got.Labels) != 0 { + t.Fatalf("tracker filter = %+v", got) + } +} + +func TestPollSkipsExistingIssueSessionsAfterRestart(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + }}, + sessions: []domain.SessionRecord{{ID: "demo-1", ProjectID: "demo", IssueID: "github:acme/demo#12"}}, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#12"}, + Title: "Already running", + State: domain.IssueOpen, + Assignees: []string{"alice"}, + }}} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 0 { + t.Fatalf("spawn calls = %d, want 0", len(spawner.calls)) + } +} + +func TestPollSkipsSessionScanWhenIntakeDisabled(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{{ID: "demo"}}, + sessionsErr: errors.New("session scan should not run"), + } + + if err := New(singleResolver(&fakeTracker{}), store, &fakeSpawner{}, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v, want nil", err) + } +} + +func TestPollSkipsIneligibleAndInvalidProjects(t *testing.T) { + store := &fakeStore{ + projects: []domain.ProjectRecord{ + {ID: "off", RepoOriginURL: "https://github.com/acme/off.git"}, + {ID: "broad", RepoOriginURL: "https://github.com/acme/broad.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true}}}, + {ID: "missing-origin", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}}, + }, + } + tracker := &fakeTracker{issues: []domain.Issue{{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/off#1"}, + Title: "ignored", + State: domain.IssueOpen, + }}} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(tracker.repos) != 0 { + t.Fatalf("tracker was called for invalid/off projects: %+v", tracker.repos) + } + if len(spawner.calls) != 0 { + t.Fatalf("spawn calls = %d, want 0", len(spawner.calls)) + } +} + +func TestPollContinuesAfterTrackerAndSpawnFailures(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{ + {ID: "bad", RepoOriginURL: "https://github.com/acme/bad.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}}, + {ID: "good", RepoOriginURL: "https://github.com/acme/good.git", Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}}, + }} + tracker := &fakeTracker{ + failRepos: map[string]error{"acme/bad": errors.New("rate limited")}, + issuesByRepo: map[string][]domain.Issue{ + "acme/good": { + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#1"}, Title: "first", State: domain.IssueOpen, Assignees: []string{"alice"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/good#2"}, Title: "second", State: domain.IssueOpen, Assignees: []string{"alice"}}, + }, + }, + } + spawner := &fakeSpawner{failIssue: domain.IssueID("github:acme/good#1")} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 2 { + t.Fatalf("spawn attempts = %d, want 2", len(spawner.calls)) + } + if spawner.calls[1].IssueID != "github:acme/good#2" { + t.Fatalf("second spawn issue = %q", spawner.calls[1].IssueID) + } +} + +func TestPollBacksOffProjectAfterFailure(t *testing.T) { + now := time.Date(2026, 6, 27, 10, 0, 0, 0, time.UTC) + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + }}} + tracker := &fakeTracker{failRepos: map[string]error{"acme/demo": errors.New("rate limited")}} + observer := New(singleResolver(tracker), store, &fakeSpawner{}, Config{ + Clock: func() time.Time { return now }, + FailureBackoff: time.Minute, + Logger: discardLogger(), + }) + + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("first Poll() error = %v", err) + } + if len(tracker.repos) != 1 { + t.Fatalf("tracker calls after first poll = %d, want 1", len(tracker.repos)) + } + + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("second Poll() error = %v", err) + } + if len(tracker.repos) != 1 { + t.Fatalf("tracker calls during backoff = %d, want still 1", len(tracker.repos)) + } + + now = now.Add(time.Minute + time.Nanosecond) + if err := observer.Poll(context.Background()); err != nil { + t.Fatalf("third Poll() error = %v", err) + } + if len(tracker.repos) != 2 { + t.Fatalf("tracker calls after backoff = %d, want 2", len(tracker.repos)) + } +} + +func TestPollSkipsNonOpenIssueStates(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + }}} + tracker := &fakeTracker{issues: []domain.Issue{ + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "already active", State: domain.IssueInProgress, Assignees: []string{"alice"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "ready", State: domain.IssueOpen, Assignees: []string{"alice"}}, + }} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 || spawner.calls[0].IssueID != "github:acme/demo#2" { + t.Fatalf("spawn calls = %+v, want only open issue #2", spawner.calls) + } +} + +func TestPollAppliesLocalEligibilityFilter(t *testing.T) { + store := &fakeStore{projects: []domain.ProjectRecord{{ + ID: "demo", + RepoOriginURL: "https://github.com/acme/demo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{Enabled: true, Assignee: "alice"}}, + }}} + tracker := &fakeTracker{issues: []domain.Issue{ + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#1"}, Title: "unassigned", State: domain.IssueOpen}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#2"}, Title: "wrong assignee", State: domain.IssueOpen, Assignees: []string{"bob"}}, + {ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#3"}, Title: "eligible", State: domain.IssueOpen, Labels: []string{"Agent-Ready"}, Assignees: []string{"Alice"}}, + }} + spawner := &fakeSpawner{} + + if err := New(singleResolver(tracker), store, spawner, Config{Logger: discardLogger()}).Poll(context.Background()); err != nil { + t.Fatalf("Poll() error = %v", err) + } + if len(spawner.calls) != 1 || spawner.calls[0].IssueID != "github:acme/demo#3" { + t.Fatalf("spawn calls = %+v, want only eligible issue #3", spawner.calls) + } +} + +func TestIssueMatchesConfigAssigneeSpecialValues(t *testing.T) { + assigned := domain.Issue{Assignees: []string{"alice"}} + unassigned := domain.Issue{} + if !issueMatchesConfig(assigned, domain.TrackerIntakeConfig{Assignee: "*"}) { + t.Fatal("assigned issue should match assignee=*") + } + if issueMatchesConfig(unassigned, domain.TrackerIntakeConfig{Assignee: "*"}) { + t.Fatal("unassigned issue should not match assignee=*") + } + if !issueMatchesConfig(unassigned, domain.TrackerIntakeConfig{Assignee: "none"}) { + t.Fatal("unassigned issue should match assignee=none") + } + if issueMatchesConfig(assigned, domain.TrackerIntakeConfig{Assignee: "none"}) { + t.Fatal("assigned issue should not match assignee=none") + } +} + +func TestBuildIssuePromptCapsLargeIssueBody(t *testing.T) { + prompt := BuildIssuePrompt(domain.Issue{ + ID: domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "acme/demo#99"}, + Title: "Large issue", + URL: "https://github.com/acme/demo/issues/99", + Body: strings.Repeat("body ", 2000), + }) + if len(prompt) > maxIntakePromptLen { + t.Fatalf("prompt length = %d, want <= %d", len(prompt), maxIntakePromptLen) + } + if !strings.Contains(prompt, "Issue content truncated") { + t.Fatalf("prompt missing truncation notice:\n%s", prompt) + } + if !strings.Contains(prompt, "https://github.com/acme/demo/issues/99") { + t.Fatalf("prompt missing issue URL:\n%s", prompt) + } + if !strings.HasSuffix(prompt, intakePromptFooter) { + t.Fatalf("prompt missing footer:\n%s", prompt) + } +} + +func TestTrackerRepoUsesConfiguredRepo(t *testing.T) { + project := domain.ProjectRecord{ + ID: "demo", + RepoOriginURL: "https://github.com/wrong/repo.git", + Config: domain.ProjectConfig{TrackerIntake: domain.TrackerIntakeConfig{ + Enabled: true, + Repo: "acme/demo", + Assignee: "alice", + }}, + } + repo, ok := trackerRepo(project, project.Config.TrackerIntake.WithDefaults()) + if !ok { + t.Fatal("trackerRepo ok = false") + } + if repo.Native != "acme/demo" { + t.Fatalf("repo.Native = %q, want acme/demo", repo.Native) + } +} + +func singleResolver(tracker ports.Tracker) TrackerResolver { + return SingleTrackerResolver{Provider: domain.TrackerProviderGitHub, Adapter: tracker} +} + +type fakeStore struct { + projects []domain.ProjectRecord + sessions []domain.SessionRecord + sessionsErr error +} + +func (f *fakeStore) ListProjects(context.Context) ([]domain.ProjectRecord, error) { + return append([]domain.ProjectRecord(nil), f.projects...), nil +} + +func (f *fakeStore) ListAllSessions(context.Context) ([]domain.SessionRecord, error) { + return append([]domain.SessionRecord(nil), f.sessions...), f.sessionsErr +} + +type fakeTracker struct { + issues []domain.Issue + issuesByRepo map[string][]domain.Issue + failRepos map[string]error + repos []domain.TrackerRepo + filters []domain.ListFilter +} + +func (f *fakeTracker) Get(context.Context, domain.TrackerID) (domain.Issue, error) { + return domain.Issue{}, nil +} + +func (f *fakeTracker) List(_ context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + f.repos = append(f.repos, repo) + f.filters = append(f.filters, filter) + if err := f.failRepos[repo.Native]; err != nil { + return nil, err + } + if f.issuesByRepo != nil { + return append([]domain.Issue(nil), f.issuesByRepo[repo.Native]...), nil + } + return append([]domain.Issue(nil), f.issues...), nil +} + +func (f *fakeTracker) Preflight(context.Context) error { return nil } + +type fakeSpawner struct { + calls []ports.SpawnConfig + failIssue domain.IssueID +} + +func (f *fakeSpawner) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain.Session, error) { + f.calls = append(f.calls, cfg) + if cfg.IssueID == f.failIssue { + return domain.Session{}, errors.New("spawn failed") + } + return domain.Session{SessionRecord: domain.SessionRecord{ID: domain.SessionID(string(cfg.ProjectID) + "-1"), ProjectID: cfg.ProjectID, IssueID: cfg.IssueID, Kind: cfg.Kind}}, nil +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 9ee643dad..8bef18801 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -679,6 +679,7 @@ export interface components { reviewers?: components["schemas"]["DomainReviewerConfig"][]; sessionPrefix?: string; symlinks?: string[]; + trackerIntake?: components["schemas"]["TrackerIntakeConfig"]; worker?: components["schemas"]["RoleOverride"]; }; ProjectGetResponse: { @@ -911,6 +912,13 @@ export interface components { /** @description Review verdict: approved or changes_requested. */ verdict: string; }; + TrackerIntakeConfig: { + assignee?: string; + enabled?: boolean; + /** @enum {string} */ + provider?: "github"; + repo?: string; + }; TriggerReviewResponse: { reviewerHandleId: string; reviews: components["schemas"]["PRReviewState"][]; diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx new file mode 100644 index 000000000..6101d11e0 --- /dev/null +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { CreateProjectAgentSheet } from "./CreateProjectAgentSheet"; + +function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { + render( + <CreateProjectAgentSheet + isCreating={false} + onOpenChange={() => undefined} + onSubmit={onSubmit} + open={true} + path="/repo/new-project" + />, + ); + return onSubmit; +} + +async function chooseOption(trigger: HTMLElement, optionName: string) { + await userEvent.click(trigger); + await userEvent.click(await screen.findByRole("option", { name: optionName })); +} + +describe("CreateProjectAgentSheet", () => { + it("creates without intake when the toggle is left off", async () => { + const onSubmit = renderSheet(); + await chooseOption(screen.getByLabelText("Worker agent"), "claude-code"); + await chooseOption(screen.getByLabelText("Orchestrator agent"), "codex"); + + await userEvent.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ + workerAgent: "claude-code", + orchestratorAgent: "codex", + trackerIntake: undefined, + }); + }); + + it("blocks submit when intake is enabled with no assignee, then passes the intake payload once one is set", async () => { + const onSubmit = renderSheet(); + await chooseOption(screen.getByLabelText("Worker agent"), "claude-code"); + await chooseOption(screen.getByLabelText("Orchestrator agent"), "codex"); + + await userEvent.click(screen.getByLabelText("Enable issue intake")); + // Enabled with no eligibility rule → submit stays disabled (compact sheet + // carries no inline guard prose; gating is the disabled button). + expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled(); + + await userEvent.type(screen.getByLabelText("Assignee"), "octocat"); + await userEvent.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ + workerAgent: "claude-code", + orchestratorAgent: "codex", + trackerIntake: { enabled: true, provider: "github", assignee: "octocat" }, + }); + }); + + it("keeps the create sheet minimal: info tooltip instead of prose, no repo row or credential hint", async () => { + renderSheet(); + // Info affordance is present even before enabling; the descriptive prose is not. + expect(screen.getByLabelText("What does enabling issue intake do?")).toBeInTheDocument(); + expect(screen.queryByText(/Auto-spawn worker sessions from matching tracker issues/)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText("Enable issue intake")); + expect(screen.queryByText("Repository")).not.toBeInTheDocument(); + expect(screen.queryByText(/Reads credentials from/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index a2c52fe73..5f9e1a67a 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -2,15 +2,22 @@ import * as Dialog from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { memo, useEffect, useState } from "react"; import { AGENT_OPTIONS, DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; +import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import type { components } from "../../api/schema"; + +type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; export type CreateProjectAgentSelection = { workerAgent: string; orchestratorAgent: string; + trackerIntake?: TrackerIntakeConfig; }; +const EMPTY_INTAKE: IntakeForm = { enabled: false, repo: "", assignee: "" }; + type CreateProjectAgentSheetProps = { error?: string | null; isCreating: boolean; @@ -30,12 +37,15 @@ export function CreateProjectAgentSheet({ }: CreateProjectAgentSheetProps) { const [workerAgent, setWorkerAgent] = useState<string>(DEFAULT_PROJECT_AGENT); const [orchestratorAgent, setOrchestratorAgent] = useState<string>(DEFAULT_PROJECT_AGENT); - const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !isCreating; + const [intake, setIntake] = useState<IntakeForm>(EMPTY_INTAKE); + const intakeIncomplete = intakeNeedsRule(intake); + const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating; useEffect(() => { if (!open) { setWorkerAgent(DEFAULT_PROJECT_AGENT); setOrchestratorAgent(DEFAULT_PROJECT_AGENT); + setIntake(EMPTY_INTAKE); } }, [open, path]); @@ -67,7 +77,7 @@ export function CreateProjectAgentSheet({ onSubmit={(event) => { event.preventDefault(); if (!canSubmit) return; - void onSubmit({ workerAgent, orchestratorAgent }); + void onSubmit({ workerAgent, orchestratorAgent, trackerIntake: buildIntake(intake) }); }} > <div className="grid gap-3 sm:grid-cols-2"> @@ -87,6 +97,10 @@ export function CreateProjectAgentSheet({ /> </div> + <div className="border-t border-border pt-4"> + <IntakeFields form={intake} onChange={(patch) => setIntake((f) => ({ ...f, ...patch }))} compact /> + </div> + {error && ( <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive"> {error} diff --git a/frontend/src/renderer/components/IntakeFields.tsx b/frontend/src/renderer/components/IntakeFields.tsx new file mode 100644 index 000000000..daf3f9f20 --- /dev/null +++ b/frontend/src/renderer/components/IntakeFields.tsx @@ -0,0 +1,175 @@ +import { Info } from "lucide-react"; +import type { components } from "../../api/schema"; +import { Label } from "./ui/label"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; + +type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; + +// IntakeForm is the flat, string-backed shape both the create sheet and the +// project settings form edit. repo has no input today (it's derived from the +// git origin server-side) but is plumbed so a value set via the CLI +// (--tracker-repo) survives a UI save instead of being wiped. +export type IntakeForm = { + enabled: boolean; + repo: string; + assignee: string; +}; + +// Only "github" is a valid TrackerIntakeConfig["provider"] today (see the +// backend's openapi enum). Adding Linear/Jira later means: the backend enum +// grows, IntakeFields gains a provider <Select> + per-provider scope fields, +// and buildIntake switches the scope field it emits. + +// intakeNeedsRule mirrors the backend guard (TrackerIntakeConfig.Validate): +// enabling intake requires an assignee so it cannot drain an entire issue +// backlog. v1 intake is assignee-only. +export function intakeNeedsRule(form: IntakeForm): boolean { + return form.enabled && form.assignee.trim() === ""; +} + +// buildIntake produces the payload field, scrubbing empties so a disabled or +// blank intake serializes to `undefined` (omit) rather than an empty object the +// daemon would persist. +export function buildIntake(form: IntakeForm): TrackerIntakeConfig | undefined { + const next: TrackerIntakeConfig = { + enabled: form.enabled || undefined, + provider: form.enabled ? "github" : undefined, + repo: form.repo.trim() || undefined, + assignee: form.assignee.trim() || undefined, + }; + return Object.values(next).some((v) => v !== undefined) ? next : undefined; +} + +// deriveGitHubRepo mirrors the daemon's parseGitHubRepoNative (observer.go): +// derive "owner/repo" from a git origin URL for display only. The daemon does +// the authoritative derivation server-side at poll time; this is purely so a +// settings card can show which repo intake will actually poll. +export function deriveGitHubRepo(remote?: string): string | undefined { + const trimmed = remote?.trim(); + if (!trimmed) return undefined; + let path: string | undefined; + if (trimmed.startsWith("git@")) { + path = trimmed.split(":")[1]; + } else { + try { + path = new URL(trimmed).pathname; + } catch { + path = trimmed; + } + } + if (!path) return undefined; + const parts = path + .replace(/\.git$/, "") + .replace(/^\/+|\/+$/g, "") + .split("/"); + if (parts.length < 2) return undefined; + const owner = parts[parts.length - 2].trim(); + const repo = parts[parts.length - 1].trim(); + return owner && repo ? `${owner}/${repo}` : undefined; +} + +// IntakeFields renders the shared "Tracker intake" controls: an enable checkbox +// that reveals the eligibility inputs. It is deliberately card-agnostic (no +// <Card> wrapper) so the create sheet and the settings form can frame it +// however they like. +// +// repoPreview is only meaningful once a project exists and its git origin is +// known: pass `{ show: true, value }` from settings to render the repo link +// row, and omit it from the create sheet (the origin URL isn't available there, +// and the daemon derives the repo regardless). +export function IntakeFields({ + form, + onChange, + repoPreview, + compact = false, +}: { + form: IntakeForm; + onChange: (patch: Partial<IntakeForm>) => void; + repoPreview?: { value?: string }; + // compact drops the descriptive/help prose and folds the explanation into an + // info-icon tooltip — used by the create-project sheet, which stays minimal. + compact?: boolean; +}) { + const needsRule = intakeNeedsRule(form); + return ( + <div className="flex flex-col gap-4"> + {!compact && ( + <p className="text-[12px] leading-5 text-muted-foreground"> + Auto-spawn worker sessions from matching tracker issues. + </p> + )} + <div className="flex items-center gap-2"> + <label className="flex items-center gap-2.5 text-[13px] text-foreground"> + <input + type="checkbox" + className="h-4 w-4 accent-accent" + checked={form.enabled} + onChange={(e) => onChange({ enabled: e.target.checked })} + /> + Enable issue intake + </label> + {compact && ( + <TooltipProvider delayDuration={0}> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + className="grid size-4 place-items-center rounded-full text-muted-foreground hover:text-foreground focus-visible:outline-none" + aria-label="What does enabling issue intake do?" + > + <Info className="size-3.5" aria-hidden="true" /> + </button> + </TooltipTrigger> + <TooltipContent>Auto-spawns a worker session for each matching GitHub issue.</TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + {form.enabled && ( + <> + {repoPreview && ( + <IntakeField label="Repository"> + {repoPreview.value ? ( + <a + href={`https://github.com/${repoPreview.value}`} + target="_blank" + rel="noopener noreferrer" + className="text-[13px] text-accent hover:underline" + > + {repoPreview.value} + </a> + ) : ( + <span className="text-[13px] text-muted-foreground"> + Could not detect a GitHub repo from this project's git origin. + </span> + )} + </IntakeField> + )} + <IntakeField label="Assignee" htmlFor="intakeAssignee"> + <input + id="intakeAssignee" + className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-[13px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak" + value={form.assignee} + onChange={(e) => onChange({ assignee: e.target.value })} + placeholder="type username or * for any" + /> + </IntakeField> + {!compact && needsRule && ( + <p className="text-[12px] leading-5 text-error">Enabling intake requires an assignee.</p> + )} + </> + )} + </div> + ); +} + +function IntakeField({ label, htmlFor, children }: { label: string; htmlFor?: string; children: React.ReactNode }) { + return ( + <div className="flex flex-col gap-1.5"> + <Label htmlFor={htmlFor} className="text-[12px] text-muted-foreground"> + {label} + </Label> + {children} + </div> + ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 41bb524ac..914790320 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -239,4 +239,76 @@ describe("ProjectSettingsForm", () => { }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); }); + + it("saves GitHub tracker intake settings, deriving the repo from the project's git origin", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + await userEvent.click(await screen.findByLabelText("Enable issue intake")); + + // Repository is display-only, derived from the project's own git origin — no input to + // fill. Assignee is the only eligibility rule in v1. + expect(screen.getByRole("link", { name: "acme/project-one" })).toHaveAttribute( + "href", + "https://github.com/acme/project-one", + ); + await userEvent.type(screen.getByLabelText("Assignee"), "octocat"); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + const body = putMock.mock.calls[0]?.[1]?.body; + expect(body.config.trackerIntake).toEqual({ + enabled: true, + provider: "github", + assignee: "octocat", + }); + }); + + it("blocks save when intake is enabled with no assignee", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + + renderSettings(); + + await userEvent.click(await screen.findByLabelText("Enable issue intake")); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2); + expect(putMock).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index c6be21a19..59f30fc63 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -6,6 +6,7 @@ import { DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; +import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Label } from "./ui/label"; @@ -13,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". type Project = components["schemas"]["Project"]; type ProjectConfig = components["schemas"]["ProjectConfig"]; +type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; const PERMISSION_MODE_OPTIONS = [ { value: "default", label: "Default" }, @@ -67,6 +69,7 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { const queryClient = useQueryClient(); const config = project.config ?? {}; + const intake: TrackerIntakeConfig = config.trackerIntake ?? {}; const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", sessionPrefix: config.sessionPrefix ?? "", @@ -75,8 +78,32 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje model: config.agentConfig?.model ?? "", permissions: config.agentConfig?.permissions ?? "", reviewerHarness: config.reviewers?.[0]?.harness ?? "", + intakeEnabled: intake.enabled ?? false, + intakeRepo: intake.repo ?? "", + intakeAssignee: intake.assignee ?? "", }); const [savedAt, setSavedAt] = useState<number | null>(null); + const [validationError, setValidationError] = useState<string | null>(null); + const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; + + // The Electron app only registers git projects today, so the daemon always has a usable + // git origin to derive owner/repo from (trackerRepo() in observer.go) when + // trackerIntake.repo is unset — there's no manual override input here. This mirrors that + // same derivation client-side purely for display (a link to the repo being polled). + const intakeForm: IntakeForm = { + enabled: form.intakeEnabled, + repo: form.intakeRepo, + assignee: form.intakeAssignee, + }; + const patchIntake = (patch: Partial<IntakeForm>) => + setForm((f) => ({ + ...f, + intakeEnabled: patch.enabled ?? f.intakeEnabled, + intakeRepo: patch.repo ?? f.intakeRepo, + intakeAssignee: patch.assignee ?? f.intakeAssignee, + })); + const effectiveIntakeRepo = form.intakeRepo.trim() || deriveGitHubRepo(project.repo); + const intakeIncomplete = intakeNeedsRule(intakeForm); const mutation = useMutation({ mutationFn: async () => { @@ -94,6 +121,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje permissions: form.permissions || undefined, }), reviewers: form.reviewerHarness ? [{ harness: form.reviewerHarness }] : undefined, + trackerIntake: buildIntake(intakeForm), }; const { error } = await apiClient.PUT("/api/v1/projects/{id}/config", { params: { path: { id: projectId } }, @@ -114,6 +142,15 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje onSubmit={(event) => { event.preventDefault(); setSavedAt(null); + if (missingRequiredAgent) { + setValidationError("Worker and orchestrator agents are required."); + return; + } + if (intakeIncomplete) { + setValidationError("Enabling intake requires an assignee."); + return; + } + setValidationError(null); mutation.mutate(); }} > @@ -207,10 +244,20 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje </CardContent> </Card> + <Card> + <CardHeader> + <CardTitle className="text-[13px]">Tracker intake</CardTitle> + </CardHeader> + <CardContent> + <IntakeFields form={intakeForm} onChange={patchIntake} repoPreview={{ value: effectiveIntakeRepo }} /> + </CardContent> + </Card> + <div className="flex items-center gap-3"> <Button type="submit" variant="primary" disabled={mutation.isPending}> {mutation.isPending ? "Saving…" : "Save changes"} </Button> + {validationError && <span className="text-[12px] text-error">{validationError}</span>} {mutation.isError && ( <span className="text-[12px] text-error"> {mutation.error instanceof Error ? mutation.error.message : "Save failed"} diff --git a/frontend/src/renderer/components/SessionInspector.test.tsx b/frontend/src/renderer/components/SessionInspector.test.tsx index 4ae34cea9..78a32c608 100644 --- a/frontend/src/renderer/components/SessionInspector.test.tsx +++ b/frontend/src/renderer/components/SessionInspector.test.tsx @@ -345,6 +345,13 @@ describe("SessionInspector tabs", () => { const tabs = screen.getAllByRole("tab").map((el) => el.textContent?.trim()); expect(tabs).toEqual(["Summary", "Reviews", "Browser"]); }); + + it("shows the intake issue id in the summary overview when present", () => { + renderWithQuery(<SessionInspector session={{ ...session([]), issueId: "github:acme/project-one#42" }} />); + + expect(screen.getByText("Issue")).toBeInTheDocument(); + expect(screen.getByText("github:acme/project-one#42")).toBeInTheDocument(); + }); }); describe("SessionInspector reviews tab", () => { diff --git a/frontend/src/renderer/components/SessionInspector.tsx b/frontend/src/renderer/components/SessionInspector.tsx index 80d8c5918..9be031a8e 100644 --- a/frontend/src/renderer/components/SessionInspector.tsx +++ b/frontend/src/renderer/components/SessionInspector.tsx @@ -8,7 +8,7 @@ import { formatTimeCompact } from "../lib/format-time"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; import type { SessionActivityState, WorkspaceSession } from "../types/workspace"; -import { sortedPRs } from "../types/workspace"; +import { canonicalTrackerIssueId, sortedPRs } from "../types/workspace"; import { BrowserPanelView } from "./BrowserPanel"; import type { BrowserViewModel } from "../hooks/useBrowserView"; import { Badge } from "./ui/badge"; @@ -169,6 +169,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) { const prSummaries = sessionPRDisplaySummaries(session, query.data); const prSectionTitle = prSummaries.length > 1 ? `Pull requests (${prSummaries.length})` : "Pull request"; const branchLabel = session.branch || `session/${session.id}`; + const issueId = canonicalTrackerIssueId(session.issueId); return ( <div role="tabpanel"> @@ -191,6 +192,7 @@ function SummaryView({ session }: { session: WorkspaceSession }) { <Section className="inspector-section--separated" title="Overview"> <dl className="inspector-kv"> <Row k="Agent" v={session.provider} mono /> + {issueId && <Row k="Issue" v={issueId} mono />} <Row k="Branch" v={branchLabel} mono /> <Row k="Started" v={formatTimeCompact(session.createdAt ?? session.updatedAt)} mono /> <Row k="Session" v={session.id} mono /> diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 7da1b8a30..261ca406c 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -2,7 +2,13 @@ import { useState, type KeyboardEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Plus } from "lucide-react"; -import { type AttentionZone, type WorkspaceSession, attentionZone, workerSessions } from "../types/workspace"; +import { + type AttentionZone, + type WorkspaceSession, + attentionZone, + canonicalTrackerIssueId, + workerSessions, +} from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { DashboardSubhead } from "./DashboardSubhead"; @@ -260,6 +266,7 @@ function ZoneColumn({ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: () => void }) { const badge = sessionBadge(session); + const issueId = canonicalTrackerIssueId(session.issueId); const branch = session.branch || ""; const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); const prSummaries = sessionPRDisplaySummaries(session, useSessionScmSummary(session.id).data); @@ -285,6 +292,14 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( <span className={cn("h-[7px] w-[7px] rounded-full bg-current")} /> {badge.label} </span> + {issueId && ( + <span + className="inline-flex max-w-[13rem] items-center truncate rounded-[4px] bg-[color-mix(in_srgb,var(--accent)_12%,transparent)] px-1.5 py-0.5 font-mono text-[10px] text-accent" + title={`Intake issue: ${issueId}`} + > + {issueId} + </span> + )} <span className="ml-auto shrink-0 font-mono text-[10.5px] tracking-[0.04em] text-passive"> {agentLabel(session.provider)} </span> diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 81f8a9bc3..e1200349f 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -81,7 +81,7 @@ type SidebarProps = { underTopbar?: boolean; workspaceError?: string; workspaces: WorkspaceSummary[]; - onCreateProject: (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>; + onCreateProject: (input: { path: string } & CreateProjectAgentSelection) => Promise<void>; onRemoveProject: (projectId: string) => Promise<void>; }; diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 69daa089e..21b402872 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -62,6 +62,7 @@ describe("useWorkspaceQuery", () => { projectId: "proj-1", terminalHandleId: "term-1", displayName: "fix-bug", + issueId: "github:acme/project-one#42", harness: "claude-code", branch: "qa/modal-worker", status: "mergeable", @@ -97,6 +98,7 @@ describe("useWorkspaceQuery", () => { id: "sess-1", terminalHandleId: "term-1", title: "fix-bug", + issueId: "github:acme/project-one#42", provider: "claude-code", branch: "qa/modal-worker", status: "mergeable", diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index c07efa195..b46373aff 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -52,6 +52,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> { workspaceId: project.id, workspaceName: project.name, title: session.displayName ?? session.issueId ?? session.id, + issueId: session.issueId, provider: toAgentProvider(session.harness), kind: session.kind === "orchestrator" ? "orchestrator" : session.kind === "worker" ? "worker" : undefined, branch: session.branch ?? `session/${session.id}`, diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 72c12a11e..6dcbb2c42 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -14,6 +14,7 @@ import { ShellProvider } from "../lib/shell-context"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; import type { WorkspaceSummary } from "../types/workspace"; +import type { components } from "../../api/schema"; export const Route = createFileRoute("/_shell")({ // Prefetch the workspace list for the whole shell (parent loaders run before @@ -54,7 +55,12 @@ function ShellLayout() { ); const createProject = useCallback( - async (input: { path: string; workerAgent: string; orchestratorAgent: string }) => { + async (input: { + path: string; + workerAgent: string; + orchestratorAgent: string; + trackerIntake?: components["schemas"]["TrackerIntakeConfig"]; + }) => { void addRendererExceptionStep("Project add requested", { source: "project-add", operation: "project_add", @@ -67,6 +73,7 @@ function ShellLayout() { config: { worker: { agent: input.workerAgent }, orchestrator: { agent: input.orchestratorAgent }, + trackerIntake: input.trackerIntake, }, }, }); diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index c0ffab127..0125bd75a 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { attentionZone, + canonicalTrackerIssueId, findProjectOrchestrator, sessionIsActive, sessionNeedsAttention, @@ -20,6 +21,14 @@ import { type WorkspaceSummary, } from "./workspace"; +describe("canonicalTrackerIssueId", () => { + it("keeps provider-prefixed intake ids and rejects manual task titles", () => { + expect(canonicalTrackerIssueId("github:acme/project#42")).toBe("github:acme/project#42"); + expect(canonicalTrackerIssueId("Fix fallback renderer")).toBeUndefined(); + expect(canonicalTrackerIssueId(undefined)).toBeUndefined(); + }); +}); + function sessionWith(overrides: Partial<WorkspaceSession>): WorkspaceSession { return { id: "sess-1", diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index 49a98f427..e8bb86f7e 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -118,6 +118,8 @@ export type WorkspaceSession = { workspaceId: string; workspaceName: string; title: string; + /** Raw issue/task identifier from the daemon. Intake ids are provider-prefixed. */ + issueId?: string; provider: AgentProvider; kind?: SessionKind; branch: string; @@ -156,6 +158,22 @@ export type WorkspaceSession = { displayStatus?: WorkerDisplayStatus; }; +// Tracker providers whose ids the intake daemon stamps sessions with, in +// "<provider>:<native>" form. Adding a provider (Linear, Jira, ...) later is +// just another prefix in this list — no caller of canonicalTrackerIssueId +// needs to change. +const TRACKER_PROVIDER_PREFIXES = ["github:"] as const; + +/** + * The provider-prefixed issue id if `issueId` came from tracker intake, or + * undefined for manually created sessions (whose issueId, if any, is a plain + * task title with no provider prefix). + */ +export function canonicalTrackerIssueId(issueId?: string): string | undefined { + if (!issueId) return undefined; + return TRACKER_PROVIDER_PREFIXES.some((prefix) => issueId.startsWith(prefix)) ? issueId : undefined; +} + /** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */ export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done" | "unknown"; From a639e2025cff346a9ea97296c94f121bda34c837 Mon Sep 17 00:00:00 2001 From: NIKHIL ACHALE <96230495+nikhilachale@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:59:34 +0530 Subject: [PATCH 39/43] feat: add agent catalog/auth API and safer orchestrator switching (#2309) * feat: add agent catalog API and integrate with project settings - Implemented AgentsController to handle /agents endpoint, returning a list of supported and installed agents. - Created agent inventory service to manage agent data and detect installed agents. - Updated ProjectSettingsForm to fetch and display agent information, including installed and supported agents. - Enhanced error handling for agent detection and orchestrator restarts. - Added tests for agent catalog and service to ensure correct functionality and error handling. * Implement agent authorization status checks and update frontend to reflect changes - Added `AuthStatus` method to various agent plugins to check authorization status using CLI probes. - Introduced `authprobe` package to handle common CLI command checks for agent authorization. - Updated backend tests to include scenarios for authorized and unauthorized agents. - Modified frontend API schema to include `authorized` counts and `authStatus` for agents. - Enhanced `ProjectSettingsForm` to display authorized agents and their statuses, including prompts for login when necessary. - Adjusted agent selection logic to prioritize authorized agents and provide feedback for unauthorized or uninstalled agents. * fix: simplify orchestrator replacement retry flow * refactor: cache agent catalog ,remove AgentCounts schema and related references from API and frontend * fix: clarify orchestrator replacement recovery state * feat: enhance project settings and agent management - Updated NewTaskDialog tests to increase timeout for async operations. - Modified ProjectSettingsForm tests to improve agent handling and validation messages. - Refactored ProjectSettingsForm component to streamline agent selection and validation logic. - Introduced new agent service to manage agent inventory and authentication status. - Improved Sidebar tests to ensure proper agent options are loaded and handled. - Enhanced SessionsBoard component by removing unused imports and optimizing state management. - Fixed Select component styling for better consistency in UI. - Added error handling for AO daemon readiness in ShellLayout. * chore: format with prettier [skip ci] * feat: add AuthStatus method documentation and improve error handling in session manager * feat: enhance agent authentication and configuration management - Implement local authentication status checks for PI, Qwen, and Vibe agents. - Introduce JSON-based authentication status retrieval for PI agents. - Add environment variable checks for Qwen agents and improve settings file parsing. - Enhance Vibe agent authentication with support for environment variables and session logs. - Update agent service to handle asynchronous probing for installed and authorized agents. - Modify session manager to support prompt delivery strategies based on agent capabilities. - Improve frontend agent selection UI with loading states and error handling. - Add tests for new authentication logic and session management features. * chore: format with prettier [skip ci] * test: fix session manager fake after rebase * feat: enhance agent authentication status checks - Implement local authentication status checks for the Devin, Droid, Kiro, and other agents. - Add support for reading credentials from specific configuration files and environment variables. - Introduce new tests for various agents to ensure proper authentication status reporting. - Refactor existing authentication logic to improve clarity and maintainability. - Remove deprecated agent setup warnings from the SessionsBoard component in the frontend. * feat: clear environment variables in auth status tests for Aider and OpenCode * feat: enhance error handling in authentication status functions and update component props * feat: integrate fallback agents in RequiredAgentField and streamline props handling * fix: refactor Sidebar test imports and parameters for clarity * refactor: remove orchestrator retirement logic and related tests - Deleted the RetireOrchestrator function and its associated error handling. - Removed tests related to orchestrator retirement and state management. - Simplified ProjectSettingsForm by eliminating orchestrator restart logic and related UI elements. - Updated API client mocks to reflect the removal of orchestrator-related functionality. * feat: enhance agent management and error handling - Added agent refresh functionality in ProjectSettingsForm with UI updates for agent availability. - Implemented `refreshAgents` API call to fetch the latest agent catalog. - Updated agent selection logic to disable unavailable agents and show appropriate error messages. - Enhanced error handling in `apiErrorMessage` to include daemon error codes alongside messages. - Created new test cases for agent availability and error handling in Sidebar component. - Introduced `ResolveBinary` method for multiple agent adapters to standardize binary resolution. - Added new agent adapter files for various agents (e.g., Aider, Claude Code, etc.) to support binary resolution. * chore: format with prettier [skip ci] * fix: satisfy backend lint checks * refactor: clean up authentication logic and improve error handling across agents * chore: format with prettier [skip ci] * feat(authprobe): enhance status classification for authentication outputs and add tests for explicit false/true keys chore(docs): update README to remove outdated agent adapter contract references fix(components): improve agent selection logic to handle unknown auth status and update related tests * chore: format with prettier [skip ci] * refactor: remove shell environment authentication logic and update related tests * feat(tests): integrate QueryClient for agent data in CreateProjectAgentSheet tests --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- README.md | 1 - .../internal/adapters/agent/agy/agy_test.go | 12 + backend/internal/adapters/agent/agy/auth.go | 17 + .../internal/adapters/agent/agy/install.go | 8 + backend/internal/adapters/agent/aider/auth.go | 131 +++ .../adapters/agent/aider/auth_test.go | 77 ++ .../internal/adapters/agent/aider/install.go | 8 + backend/internal/adapters/agent/amp/auth.go | 121 +++ .../internal/adapters/agent/amp/auth_test.go | 99 ++ .../internal/adapters/agent/amp/install.go | 8 + .../internal/adapters/agent/auggie/auth.go | 40 + .../adapters/agent/auggie/auth_test.go | 54 + .../internal/adapters/agent/auggie/install.go | 8 + .../adapters/agent/authprobe/authprobe.go | 134 +++ .../agent/authprobe/authprobe_test.go | 96 ++ .../internal/adapters/agent/autohand/auth.go | 75 ++ .../adapters/agent/autohand/auth_test.go | 76 ++ .../adapters/agent/autohand/install.go | 8 + .../adapters/agent/claudecode/claudecode.go | 107 ++ .../agent/claudecode/claudecode_test.go | 91 ++ .../adapters/agent/claudecode/install.go | 8 + backend/internal/adapters/agent/cline/auth.go | 122 +++ .../adapters/agent/cline/auth_test.go | 118 +++ .../internal/adapters/agent/cline/cline.go | 32 +- .../adapters/agent/cline/cline_test.go | 25 +- .../internal/adapters/agent/cline/install.go | 8 + .../internal/adapters/agent/codex/codex.go | 45 +- .../adapters/agent/codex/codex_test.go | 32 + .../internal/adapters/agent/codex/install.go | 8 + .../adapters/agent/continueagent/auth.go | 79 ++ .../adapters/agent/continueagent/auth_test.go | 50 + .../agent/continueagent/continueagent_test.go | 6 + .../adapters/agent/continueagent/install.go | 8 + .../internal/adapters/agent/copilot/auth.go | 160 +++ .../adapters/agent/copilot/copilot.go | 53 +- .../adapters/agent/copilot/copilot_test.go | 114 ++- .../adapters/agent/copilot/install.go | 8 + backend/internal/adapters/agent/crush/auth.go | 84 ++ .../adapters/agent/crush/auth_test.go | 42 + .../internal/adapters/agent/crush/install.go | 8 + .../internal/adapters/agent/cursor/auth.go | 113 +++ .../adapters/agent/cursor/auth_test.go | 96 ++ .../internal/adapters/agent/cursor/install.go | 8 + backend/internal/adapters/agent/devin/auth.go | 62 ++ .../adapters/agent/devin/auth_test.go | 61 ++ .../internal/adapters/agent/devin/install.go | 8 + backend/internal/adapters/agent/droid/auth.go | 89 ++ .../adapters/agent/droid/auth_test.go | 72 ++ .../internal/adapters/agent/droid/install.go | 8 + backend/internal/adapters/agent/goose/auth.go | 167 +++ .../internal/adapters/agent/goose/goose.go | 12 +- .../adapters/agent/goose/goose_test.go | 83 ++ .../internal/adapters/agent/goose/install.go | 8 + backend/internal/adapters/agent/grok/auth.go | 74 ++ .../internal/adapters/agent/grok/auth_test.go | 76 ++ .../internal/adapters/agent/grok/install.go | 8 + .../internal/adapters/agent/kilocode/auth.go | 194 ++++ .../adapters/agent/kilocode/auth_test.go | 165 +++ .../adapters/agent/kilocode/install.go | 8 + backend/internal/adapters/agent/kimi/auth.go | 88 ++ .../internal/adapters/agent/kimi/auth_test.go | 79 ++ .../internal/adapters/agent/kimi/install.go | 8 + backend/internal/adapters/agent/kiro/auth.go | 36 + .../internal/adapters/agent/kiro/install.go | 8 + .../internal/adapters/agent/kiro/kiro_test.go | 46 + .../adapters/agent/opencode/install.go | 8 + .../adapters/agent/opencode/opencode.go | 196 +++- .../adapters/agent/opencode/opencode_test.go | 275 +++++ backend/internal/adapters/agent/pi/auth.go | 85 ++ .../internal/adapters/agent/pi/auth_test.go | 42 + backend/internal/adapters/agent/pi/install.go | 8 + backend/internal/adapters/agent/qwen/auth.go | 116 +++ .../internal/adapters/agent/qwen/auth_test.go | 78 ++ .../internal/adapters/agent/qwen/install.go | 8 + .../adapters/agent/registry/registry.go | 10 +- .../adapters/agent/registry/registry_test.go | 20 + backend/internal/adapters/agent/vibe/auth.go | 181 ++++ .../internal/adapters/agent/vibe/install.go | 8 + backend/internal/adapters/agent/vibe/vibe.go | 16 +- .../internal/adapters/agent/vibe/vibe_test.go | 99 +- backend/internal/cli/stop_test.go | 1 + backend/internal/daemon/daemon.go | 5 +- backend/internal/httpd/api.go | 6 + backend/internal/httpd/apispec/openapi.yaml | 95 ++ .../internal/httpd/apispec/specgen/build.go | 28 + backend/internal/httpd/controllers/agents.go | 55 + .../internal/httpd/controllers/agents_test.go | 94 ++ backend/internal/httpd/controllers/dto.go | 10 + backend/internal/httpd/server_test.go | 2 + backend/internal/observe/scm/observer_test.go | 1 + backend/internal/ports/agent.go | 28 + backend/internal/runfile/runfile_test.go | 4 + .../internal/service/agent/catalog_test.go | 303 ++++++ backend/internal/service/agent/service.go | 214 ++++ backend/internal/service/project/service.go | 50 +- .../internal/service/project/service_test.go | 29 + backend/internal/service/session/claim_pr.go | 6 +- backend/internal/session_manager/manager.go | 15 +- .../internal/session_manager/manager_test.go | 144 +-- docs/README.md | 1 - docs/agent/README.md | 950 ------------------ frontend/src/api/schema.ts | 127 +++ .../CreateProjectAgentSheet.test.tsx | 33 +- .../components/CreateProjectAgentSheet.tsx | 138 ++- .../components/NewTaskDialog.test.tsx | 95 +- .../src/renderer/components/NewTaskDialog.tsx | 56 +- .../components/ProjectSettingsForm.test.tsx | 222 ++-- .../components/ProjectSettingsForm.tsx | 46 +- .../components/SessionsBoard.test.tsx | 40 + .../src/renderer/components/SessionsBoard.tsx | 16 +- .../src/renderer/components/Sidebar.test.tsx | 157 ++- .../src/renderer/components/ui/select.tsx | 6 +- frontend/src/renderer/hooks/useAgentsQuery.ts | 30 + frontend/src/renderer/lib/agent-options.ts | 4 - frontend/src/renderer/lib/api-client.test.ts | 26 +- frontend/src/renderer/lib/api-client.ts | 7 +- .../renderer/lib/spawn-orchestrator.test.ts | 18 + .../src/renderer/lib/spawn-orchestrator.ts | 9 +- frontend/src/renderer/routes/_shell.tsx | 18 +- frontend/src/renderer/types/workspace.test.ts | 6 + frontend/src/renderer/types/workspace.ts | 9 +- 121 files changed, 6685 insertions(+), 1327 deletions(-) create mode 100644 backend/internal/adapters/agent/agy/auth.go create mode 100644 backend/internal/adapters/agent/agy/install.go create mode 100644 backend/internal/adapters/agent/aider/auth.go create mode 100644 backend/internal/adapters/agent/aider/auth_test.go create mode 100644 backend/internal/adapters/agent/aider/install.go create mode 100644 backend/internal/adapters/agent/amp/auth.go create mode 100644 backend/internal/adapters/agent/amp/auth_test.go create mode 100644 backend/internal/adapters/agent/amp/install.go create mode 100644 backend/internal/adapters/agent/auggie/auth.go create mode 100644 backend/internal/adapters/agent/auggie/auth_test.go create mode 100644 backend/internal/adapters/agent/auggie/install.go create mode 100644 backend/internal/adapters/agent/authprobe/authprobe.go create mode 100644 backend/internal/adapters/agent/authprobe/authprobe_test.go create mode 100644 backend/internal/adapters/agent/autohand/auth.go create mode 100644 backend/internal/adapters/agent/autohand/auth_test.go create mode 100644 backend/internal/adapters/agent/autohand/install.go create mode 100644 backend/internal/adapters/agent/claudecode/install.go create mode 100644 backend/internal/adapters/agent/cline/auth.go create mode 100644 backend/internal/adapters/agent/cline/auth_test.go create mode 100644 backend/internal/adapters/agent/cline/install.go create mode 100644 backend/internal/adapters/agent/codex/install.go create mode 100644 backend/internal/adapters/agent/continueagent/auth.go create mode 100644 backend/internal/adapters/agent/continueagent/auth_test.go create mode 100644 backend/internal/adapters/agent/continueagent/install.go create mode 100644 backend/internal/adapters/agent/copilot/auth.go create mode 100644 backend/internal/adapters/agent/copilot/install.go create mode 100644 backend/internal/adapters/agent/crush/auth.go create mode 100644 backend/internal/adapters/agent/crush/auth_test.go create mode 100644 backend/internal/adapters/agent/crush/install.go create mode 100644 backend/internal/adapters/agent/cursor/auth.go create mode 100644 backend/internal/adapters/agent/cursor/auth_test.go create mode 100644 backend/internal/adapters/agent/cursor/install.go create mode 100644 backend/internal/adapters/agent/devin/auth.go create mode 100644 backend/internal/adapters/agent/devin/auth_test.go create mode 100644 backend/internal/adapters/agent/devin/install.go create mode 100644 backend/internal/adapters/agent/droid/auth.go create mode 100644 backend/internal/adapters/agent/droid/auth_test.go create mode 100644 backend/internal/adapters/agent/droid/install.go create mode 100644 backend/internal/adapters/agent/goose/auth.go create mode 100644 backend/internal/adapters/agent/goose/install.go create mode 100644 backend/internal/adapters/agent/grok/auth.go create mode 100644 backend/internal/adapters/agent/grok/auth_test.go create mode 100644 backend/internal/adapters/agent/grok/install.go create mode 100644 backend/internal/adapters/agent/kilocode/auth.go create mode 100644 backend/internal/adapters/agent/kilocode/auth_test.go create mode 100644 backend/internal/adapters/agent/kilocode/install.go create mode 100644 backend/internal/adapters/agent/kimi/auth.go create mode 100644 backend/internal/adapters/agent/kimi/auth_test.go create mode 100644 backend/internal/adapters/agent/kimi/install.go create mode 100644 backend/internal/adapters/agent/kiro/auth.go create mode 100644 backend/internal/adapters/agent/kiro/install.go create mode 100644 backend/internal/adapters/agent/opencode/install.go create mode 100644 backend/internal/adapters/agent/pi/auth.go create mode 100644 backend/internal/adapters/agent/pi/auth_test.go create mode 100644 backend/internal/adapters/agent/pi/install.go create mode 100644 backend/internal/adapters/agent/qwen/auth.go create mode 100644 backend/internal/adapters/agent/qwen/auth_test.go create mode 100644 backend/internal/adapters/agent/qwen/install.go create mode 100644 backend/internal/adapters/agent/vibe/auth.go create mode 100644 backend/internal/adapters/agent/vibe/install.go create mode 100644 backend/internal/httpd/controllers/agents.go create mode 100644 backend/internal/httpd/controllers/agents_test.go create mode 100644 backend/internal/service/agent/catalog_test.go create mode 100644 backend/internal/service/agent/service.go delete mode 100644 docs/agent/README.md create mode 100644 frontend/src/renderer/components/SessionsBoard.test.tsx create mode 100644 frontend/src/renderer/hooks/useAgentsQuery.ts diff --git a/README.md b/README.md index e9adb764c..f51285142 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,6 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | -| [Agent Adapter Contract](docs/agent/README.md) | Agent adapter interface and hook behavior | --- diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go index b51205003..db6202c2e 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -200,3 +200,15 @@ func TestHooksLifecycle(t *testing.T) { t.Fatal("expected hooks to be uninstalled after UninstallHooks") } } + +func TestAuthStatus(t *testing.T) { + plugin := &Plugin{resolvedBinary: "agy"} + + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Errorf("AuthStatus() = %v, want AgentAuthStatusAuthorized", status) + } +} diff --git a/backend/internal/adapters/agent/agy/auth.go b/backend/internal/adapters/agent/agy/auth.go new file mode 100644 index 000000000..658519a7d --- /dev/null +++ b/backend/internal/adapters/agent/agy/auth.go @@ -0,0 +1,17 @@ +package agy + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + return ports.AgentAuthStatusAuthorized, nil +} diff --git a/backend/internal/adapters/agent/agy/install.go b/backend/internal/adapters/agent/agy/install.go new file mode 100644 index 000000000..223e7448d --- /dev/null +++ b/backend/internal/adapters/agent/agy/install.go @@ -0,0 +1,8 @@ +package agy + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.agyBinary(ctx) +} diff --git a/backend/internal/adapters/agent/aider/auth.go b/backend/internal/adapters/agent/aider/auth.go new file mode 100644 index 000000000..e06b090ca --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth.go @@ -0,0 +1,131 @@ +package aider + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := aiderLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var aiderAPIKeyEnvVars = []string{ + "AIDER_API_KEY", + "AIDER_OPENAI_API_KEY", + "AIDER_ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func aiderLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range aiderAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + for _, path := range aiderConfigPaths() { + status, ok, err := aiderAuthStatusFromFile(path) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if ok { + return status, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func aiderConfigPaths() []string { + paths := []string{} + if cwd, err := os.Getwd(); err == nil && cwd != "" { + paths = append(paths, + filepath.Join(cwd, ".env"), + filepath.Join(cwd, ".aider.conf.yml"), + filepath.Join(cwd, ".aider.conf.yaml"), + ) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + paths = append(paths, + filepath.Join(home, ".env"), + filepath.Join(home, ".aider.conf.yml"), + filepath.Join(home, ".aider.conf.yaml"), + ) + } + return paths +} + +func aiderAuthStatusFromFile(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + if fileContainsAPIKey(text) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func fileContainsAPIKey(text string) bool { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "api") || !strings.Contains(lower, "key") { + continue + } + if valueAfterAssignment(line) != "" { + return true + } + } + return false +} + +func valueAfterAssignment(line string) string { + for _, sep := range []string{"=", ":"} { + before, after, ok := strings.Cut(line, sep) + if !ok || !strings.Contains(strings.ToLower(before), "key") { + continue + } + value := strings.Trim(strings.TrimSpace(after), `"'`) + if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") { + return value + } + } + return "" +} diff --git a/backend/internal/adapters/agent/aider/auth_test.go b/backend/internal/adapters/agent/aider/auth_test.go new file mode 100644 index 000000000..6635c7b9f --- /dev/null +++ b/backend/internal/adapters/agent/aider/auth_test.go @@ -0,0 +1,77 @@ +package aider + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAiderLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + clearAiderAuthEnv(t) + t.Setenv("OPENAI_API_KEY", "sk-test") + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusAuthorizedWithConfigFile(t *testing.T) { + clearAiderAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".aider.conf.yml"), []byte("openai-api-key: sk-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusAuthorizedWithDotEnv(t *testing.T) { + clearAiderAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.WriteFile(filepath.Join(home, ".env"), []byte("ANTHROPIC_API_KEY=sk-ant-test\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAiderLocalAuthStatusUnknownWhenMissing(t *testing.T) { + clearAiderAuthEnv(t) + t.Setenv("HOME", t.TempDir()) + + status, ok, err := aiderLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func clearAiderAuthEnv(t *testing.T) { + t.Helper() + for _, name := range aiderAPIKeyEnvVars { + t.Setenv(name, "") + } +} diff --git a/backend/internal/adapters/agent/aider/install.go b/backend/internal/adapters/agent/aider/install.go new file mode 100644 index 000000000..6b86b07b1 --- /dev/null +++ b/backend/internal/adapters/agent/aider/install.go @@ -0,0 +1,8 @@ +package aider + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.aiderBinary(ctx) +} diff --git a/backend/internal/adapters/agent/amp/auth.go b/backend/internal/adapters/agent/amp/auth.go new file mode 100644 index 000000000..57e7c18a8 --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth.go @@ -0,0 +1,121 @@ +package amp + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := ampLocalAuthStatus(ctx); err != nil || ok { + return status, err + } + return ampUsageAuthStatus(ctx, binary) +} + +func ampLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("AMP_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + status, ok, err := ampSettingsAuthStatus(ampSettingsPath()) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + return status, ok, nil +} + +func ampSettingsPath() string { + if path := strings.TrimSpace(os.Getenv("AMP_SETTINGS_FILE")); path != "" { + return expandHome(path) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "amp", "settings.json") + } + return "" +} + +func ampSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + if path == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, false, nil + } + return ports.AgentAuthStatusUnknown, false, err + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, key := range []string{"amp.apiKey", "amp.api_key", "apiKey", "api_key"} { + if value, ok := settings[key]; ok { + if strings.TrimSpace(stringValue(value)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func ampUsageAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out, err := authprobe.CmdRunner(probeCtx, binary, "usage", "--no-color") + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func stringValue(value any) string { + switch v := value.(type) { + case string: + return v + default: + return "" + } +} + +func expandHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + } + } + return path +} diff --git a/backend/internal/adapters/agent/amp/auth_test.go b/backend/internal/adapters/agent/amp/auth_test.go new file mode 100644 index 000000000..662e9ed7c --- /dev/null +++ b/backend/internal/adapters/agent/amp/auth_test.go @@ -0,0 +1,99 @@ +package amp + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + t.Setenv("AMP_API_KEY", "amp-key") + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpSettingsAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"amp.apiKey":"amp-key"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := ampSettingsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpSettingsAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"amp.apiKey":""}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := ampSettingsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestAmpUsageAuthStatusAuthorizedOnSuccessfulUsage(t *testing.T) { + t.Setenv("AMP_API_KEY", "") + t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json")) + restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "amp" || !reflect.DeepEqual(arg, []string{"usage", "--no-color"}) { + t.Fatalf("command = %s %#v, want amp usage --no-color", name, arg) + } + return []byte("Credits remaining: 100"), nil + }) + defer restore() + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAmpUsageAuthStatusUnauthorizedFromUsageOutput(t *testing.T) { + t.Setenv("AMP_API_KEY", "") + t.Setenv("AMP_SETTINGS_FILE", filepath.Join(t.TempDir(), "missing-settings.json")) + restore := mockAuthProbeRunner(t, func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return []byte("login required"), errors.New("exit status 1") + }) + defer restore() + + got, err := (&Plugin{resolvedBinary: "amp"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func mockAuthProbeRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} diff --git a/backend/internal/adapters/agent/amp/install.go b/backend/internal/adapters/agent/amp/install.go new file mode 100644 index 000000000..20a45a9f5 --- /dev/null +++ b/backend/internal/adapters/agent/amp/install.go @@ -0,0 +1,8 @@ +package amp + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.ampBinary(ctx) +} diff --git a/backend/internal/adapters/agent/auggie/auth.go b/backend/internal/adapters/agent/auggie/auth.go new file mode 100644 index 000000000..29ae23e1f --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth.go @@ -0,0 +1,40 @@ +package auggie + +import ( + "context" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + return auggieAccountAuthStatus(ctx, binary) +} + +func auggieAccountAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + + out, err := authprobe.CmdRunner(probeCtx, binary, "account", "status") + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} diff --git a/backend/internal/adapters/agent/auggie/auth_test.go b/backend/internal/adapters/agent/auggie/auth_test.go new file mode 100644 index 000000000..2b204c5d7 --- /dev/null +++ b/backend/internal/adapters/agent/auggie/auth_test.go @@ -0,0 +1,54 @@ +package auggie + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusUsesAuggieAccountStatus(t *testing.T) { + restore := stubAuggieAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) { + if name != "auggie" { + t.Fatalf("binary = %q, want auggie", name) + } + if !reflect.DeepEqual(arg, []string{"account", "status"}) { + t.Fatalf("args = %#v, want [account status]", arg) + } + return []byte("Credits remaining: 42\n"), nil + }) + defer restore() + + status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromAuggieAccountStatus(t *testing.T) { + restore := stubAuggieAuthRunner(t, func(context.Context, string, ...string) ([]byte, error) { + return []byte("You are not currently logged in to Augment.\nRun 'auggie login' to authenticate first.\n"), errors.New("exit status 1") + }) + defer restore() + + status, err := (&Plugin{resolvedBinary: "auggie"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized) + } +} + +func stubAuggieAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} diff --git a/backend/internal/adapters/agent/auggie/install.go b/backend/internal/adapters/agent/auggie/install.go new file mode 100644 index 000000000..2a605fdfe --- /dev/null +++ b/backend/internal/adapters/agent/auggie/install.go @@ -0,0 +1,8 @@ +package auggie + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.auggieBinary(ctx) +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe.go b/backend/internal/adapters/agent/authprobe/authprobe.go new file mode 100644 index 000000000..ea15f8201 --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe.go @@ -0,0 +1,134 @@ +package authprobe + +import ( + "context" + "os/exec" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// DefaultCommands are cheap local auth/status probes common across agent CLIs. +// Unsupported commands usually exit quickly with help text and are treated as +// unknown rather than unauthorized. +var DefaultCommands = [][]string{ + {"auth", "status"}, + {"login", "status"}, + {"providers", "list"}, +} + +// CmdRunner runs the command and returns the combined stdout/stderr. +// It is exposed as a package variable to allow mocking in tests. +var CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, arg...).CombinedOutput() +} + +// CLIStatus runs bounded local CLI probes and classifies their output. +func CLIStatus(ctx context.Context, binary string, commands [][]string) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + if len(commands) == 0 { + commands = DefaultCommands + } + for _, args := range commands { + status, err := commandStatus(ctx, binary, args) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + } + return ports.AgentAuthStatusUnknown, nil +} + +func commandStatus(ctx context.Context, binary string, args []string) (ports.AgentAuthStatus, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := CmdRunner(probeCtx, binary, args...) + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + status := StatusFromText(string(out)) + if status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +// StatusFromText classifies common CLI auth/status output. +func StatusFromText(out string) ports.AgentAuthStatus { + text := strings.ToLower(out) + compactText := compact(text) + if hasAny(text, + "not logged in", + "not currently logged in", + "logged out", + "not authenticated", + "unauthenticated", + "authentication required", + "not authorized", + "unauthorized", + "login required", + "no credentials", + "0 credentials", + "no api key", + "no token", + `"loggedin": false`, + `"loggedin":false`, + ) || hasAny(compactText, + `"authenticated":false`, + `'authenticated':false`, + "authenticated:false", + "authenticated=false", + `"authorized":false`, + `'authorized':false`, + "authorized:false", + "authorized=false", + `"logged_in":false`, + `'logged_in':false`, + "logged_in:false", + "logged_in=false", + `"loggedin":false`, + `'loggedin':false`, + "loggedin:false", + "loggedin=false", + ) { + return ports.AgentAuthStatusUnauthorized + } + if hasAny(text, + "logged in", + "authenticated", + "authorized", + "token valid", + "api key found", + "credentials found", + `"loggedin": true`, + `"loggedin":true`, + ) { + return ports.AgentAuthStatusAuthorized + } + return ports.AgentAuthStatusUnknown +} + +func compact(text string) string { + return strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "").Replace(text) +} + +func hasAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/authprobe/authprobe_test.go b/backend/internal/adapters/agent/authprobe/authprobe_test.go new file mode 100644 index 000000000..0c8aa661c --- /dev/null +++ b/backend/internal/adapters/agent/authprobe/authprobe_test.go @@ -0,0 +1,96 @@ +package authprobe + +import ( + "context" + "errors" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCLIStatus_Mocked(t *testing.T) { + tests := []struct { + name string + mockOutput string + mockError error + wantStatus ports.AgentAuthStatus + wantError bool + }{ + { + name: "authorized status check", + mockOutput: "User is logged in and authenticated", + wantStatus: ports.AgentAuthStatusAuthorized, + }, + { + name: "unauthorized status check", + mockOutput: "You are not logged in", + wantStatus: ports.AgentAuthStatusUnauthorized, + }, + { + name: "unknown status check with exit error", + mockOutput: "command not found or invalid syntax", + mockError: errors.New("exit status 1"), + wantStatus: ports.AgentAuthStatusUnknown, + }, + { + name: "unknown status check with success but unrecognized output", + mockOutput: "some random output here", + wantStatus: ports.AgentAuthStatusUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore CmdRunner + oldCmdRunner := CmdRunner + defer func() { CmdRunner = oldCmdRunner }() + + CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + return []byte(tt.mockOutput), tt.mockError + } + + status, err := CLIStatus(context.Background(), "mockbinary", nil) + if (err != nil) != tt.wantError { + t.Fatalf("unexpected error: %v", err) + } + if status != tt.wantStatus { + t.Errorf("CLIStatus() = %v, want %v", status, tt.wantStatus) + } + }) + } +} + +func TestStatusFromTextExplicitFalseKeys(t *testing.T) { + tests := []string{ + `{ "authenticated": false }`, + `{ "authorized": false }`, + `authenticated=false`, + `authorized: false`, + `{ "logged_in": false }`, + `{ "loggedIn": false }`, + } + + for _, out := range tests { + t.Run(out, func(t *testing.T) { + if got := StatusFromText(out); got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusUnauthorized) + } + }) + } +} + +func TestStatusFromTextExplicitTrueKeys(t *testing.T) { + tests := []string{ + `{ "authenticated": true }`, + `{ "authorized": true }`, + `{ "loggedIn": true }`, + } + + for _, out := range tests { + t.Run(out, func(t *testing.T) { + if got := StatusFromText(out); got != ports.AgentAuthStatusAuthorized { + t.Fatalf("StatusFromText(%q) = %q, want %q", out, got, ports.AgentAuthStatusAuthorized) + } + }) + } +} diff --git a/backend/internal/adapters/agent/autohand/auth.go b/backend/internal/adapters/agent/autohand/auth.go new file mode 100644 index 000000000..0471e8d31 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth.go @@ -0,0 +1,75 @@ +package autohand + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, err + } + return autohandConfigAuthStatus(autohandConfigPath()) +} + +func autohandConfigAuthStatus(configPath string) (ports.AgentAuthStatus, error) { + data, err := os.ReadFile(configPath) //nolint:gosec // path is the user's own Autohand config + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, nil + } + + var config map[string]json.RawMessage + if err := json.Unmarshal(data, &config); err != nil { + return ports.AgentAuthStatusUnknown, err + } + + authReady, authKnown := autohandCloudAuthReady(config) + if authReady { + return ports.AgentAuthStatusAuthorized, nil + } + if authKnown { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func autohandCloudAuthReady(config map[string]json.RawMessage) (ready, known bool) { + authRaw, ok := config["auth"] + if !ok { + return false, false + } + var auth struct { + Token string `json:"token"` + } + if err := json.Unmarshal(authRaw, &auth); err != nil { + return false, false + } + return usableSecret(auth.Token), true +} + +func usableSecret(value string) bool { + normalized := strings.TrimSpace(value) + if normalized == "" { + return false + } + switch strings.ToLower(normalized) { + case "api key", "apikey", "your api key", "your-api-key", "your_api_key", "token", "your token", "your-token", "your_token", "changeme", "change-me", "replace-me", "replace_me": + return false + default: + return true + } +} diff --git a/backend/internal/adapters/agent/autohand/auth_test.go b/backend/internal/adapters/agent/autohand/auth_test.go new file mode 100644 index 000000000..6b0214911 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/auth_test.go @@ -0,0 +1,76 @@ +package autohand + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAutohandConfigAuthStatusAuthorized(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": "session-token", "user": {"email": "agent@example.com"}}, + "provider": "zai", + "zai": {"apiKey": "real-provider-key", "model": "glm-5.1"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAutohandConfigAuthStatusUnauthorizedWithMissingCloudToken(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": ""}, + "provider": "zai", + "zai": {"apiKey": "real-provider-key"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func TestAutohandConfigAuthStatusAuthorizedWithPlaceholderProviderKey(t *testing.T) { + path := writeAutohandAuthConfig(t, `{ + "auth": {"token": "session-token"}, + "provider": "zai", + "zai": {"apiKey": "api key ", "model": "glm-5.1"} +}`) + + got, err := autohandConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAutohandConfigAuthStatusUnknownWhenMissing(t *testing.T) { + got, err := autohandConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", got, ports.AgentAuthStatusUnknown) + } +} + +func writeAutohandAuthConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/autohand/install.go b/backend/internal/adapters/agent/autohand/install.go new file mode 100644 index 000000000..62f06f0e3 --- /dev/null +++ b/backend/internal/adapters/agent/autohand/install.go @@ -0,0 +1,8 @@ +package autohand + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.autohandBinary(ctx) +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 6e2b63362..147fec382 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -17,6 +17,7 @@ package claudecode import ( + "bytes" "context" "encoding/json" "fmt" @@ -26,6 +27,7 @@ import ( "runtime" "strings" "sync" + "time" "github.com/google/uuid" @@ -60,6 +62,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -269,6 +272,110 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Claude Code's local authentication state without starting a +// session. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.claudeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := claudeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + if status, ok := claudeAuthStatusFromOutput(out); ok { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +func claudeAuthStatusFromOutput(out []byte) (ports.AgentAuthStatus, bool) { + start := bytes.IndexByte(out, '{') + end := bytes.LastIndexByte(out, '}') + if start < 0 || end < start { + return ports.AgentAuthStatusUnknown, false + } + var status struct { + LoggedIn bool `json:"loggedIn"` + } + if json.Unmarshal(out[start:end+1], &status) != nil { + return ports.AgentAuthStatusUnknown, false + } + if status.LoggedIn { + return ports.AgentAuthStatusAuthorized, true + } + return ports.AgentAuthStatusUnauthorized, true +} + +func claudeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + cfgPath, err := claudeConfigPath() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + return claudeConfigAuthStatus(cfgPath) +} + +func claudeConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + var hasSubscription bool + if raw := root["hasAvailableSubscription"]; len(raw) > 0 { + _ = json.Unmarshal(raw, &hasSubscription) + } + var userID string + if raw := root["userID"]; len(raw) > 0 { + _ = json.Unmarshal(raw, &userID) + } + if strings.TrimSpace(userID) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + var oauthAccount map[string]any + if raw := root["oauthAccount"]; len(raw) > 0 { + if err := json.Unmarshal(raw, &oauthAccount); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + } + if len(oauthAccount) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + if hasSubscription { + return ports.AgentAuthStatusAuthorized, true, nil + } + if accountUUID, ok := oauthAccount["accountUuid"].(string); ok && strings.TrimSpace(accountUUID) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + // claudeSessionUUID maps an AO session id onto a stable Claude Code // session UUID via UUIDv5 over a fixed namespace, so the same AO session // always resolves to the same Claude session. diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index 97a1d175d..eb2fc98ba 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -490,6 +490,97 @@ func TestManifestID(t *testing.T) { } } +func TestClaudeConfigAuthStatusAuthorizedWithOAuthSubscription(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{ + "hasAvailableSubscription": true, + "oauthAccount": { + "accountUuid": "account-1", + "subscriptionCreatedAt": "2026-01-01T00:00:00Z" + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithOAuthAccount(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{"accountUuid":"account-1"}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusAuthorizedWithUserID(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + if err := os.WriteFile(path, []byte(`{"userID":"user-1"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeConfigAuthStatusUnknownWithoutOAuthIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), ".claude.json") + content := `{"oauthAccount":{}}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := claudeConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithCleanJSON(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":true,"authMethod":"oauth_token"}`)) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputAuthorizedWithPrefixedWarning(t *testing.T) { + output := []byte("warning: ignored config line\n{\"loggedIn\":true,\"authMethod\":\"oauth_token\"}\n") + status, ok := claudeAuthStatusFromOutput(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClaudeAuthStatusFromOutputUnauthorized(t *testing.T) { + status, ok := claudeAuthStatusFromOutput([]byte(`{"loggedIn":false}`)) + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + func TestEnsureWorkspaceTrustedCreatesEntry(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, ".claude.json") diff --git a/backend/internal/adapters/agent/claudecode/install.go b/backend/internal/adapters/agent/claudecode/install.go new file mode 100644 index 000000000..a809f0c02 --- /dev/null +++ b/backend/internal/adapters/agent/claudecode/install.go @@ -0,0 +1,8 @@ +package claudecode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.claudeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/cline/auth.go b/backend/internal/adapters/agent/cline/auth.go new file mode 100644 index 000000000..850d86e65 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth.go @@ -0,0 +1,122 @@ +package cline + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := clineProviderAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +type clineProvidersFile struct { + LastUsedProvider string `json:"lastUsedProvider"` + Providers map[string]clineProvider `json:"providers"` +} + +type clineProvider struct { + Settings clineProviderSettings `json:"settings"` +} + +type clineProviderSettings struct { + APIKey string `json:"apiKey"` + APIKeyAlt string `json:"apikey"` + Auth *clineProviderAuth `json:"auth"` + Provider string `json:"provider"` +} + +type clineProviderAuth struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` +} + +func clineProviderAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + path := filepath.Join(home, ".cline", "data", "settings", "providers.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var file clineProvidersFile + if err := json.Unmarshal(data, &file); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(file.Providers) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + if provider, ok := configuredClineProvider(file); ok { + if providerAuthorized(provider.Settings) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func configuredClineProvider(file clineProvidersFile) (clineProvider, bool) { + if file.LastUsedProvider != "" { + if provider, ok := file.Providers[file.LastUsedProvider]; ok { + return provider, true + } + } + for _, provider := range file.Providers { + return provider, true + } + return clineProvider{}, false +} + +func providerAuthorized(settings clineProviderSettings) bool { + if strings.TrimSpace(settings.APIKey) != "" || strings.TrimSpace(settings.APIKeyAlt) != "" { + return true + } + if settings.Auth == nil { + return false + } + if strings.TrimSpace(settings.Auth.RefreshToken) != "" { + return true + } + if strings.TrimSpace(settings.Auth.AccessToken) == "" { + return false + } + if settings.Auth.ExpiresAt > 0 && settings.Auth.ExpiresAt <= time.Now().UnixMilli() { + return false + } + return true +} diff --git a/backend/internal/adapters/agent/cline/auth_test.go b/backend/internal/adapters/agent/cline/auth_test.go new file mode 100644 index 000000000..4bfbed8f9 --- /dev/null +++ b/backend/internal/adapters/agent/cline/auth_test.go @@ -0,0 +1,118 @@ +package cline + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestClineProviderAuthStatusAuthorizedWithOAuth(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "cline", + "providers": { + "cline": { + "settings": { + "provider": "cline", + "auth": { + "accessToken": "token", + "refreshToken": "refresh", + "expiresAt": `+futureMillis(t)+` + } + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClineProviderAuthStatusAuthorizedWithAPIKey(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "openai", + "providers": { + "openai": { + "settings": { + "provider": "openai", + "apiKey": "sk-test" + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestClineProviderAuthStatusUnauthorizedWithExpiredOAuth(t *testing.T) { + writeClineProvidersFile(t, `{ + "version": 1, + "lastUsedProvider": "cline", + "providers": { + "cline": { + "settings": { + "provider": "cline", + "auth": { + "accessToken": "token", + "expiresAt": 1 + } + } + } + } + }`) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestClineProviderAuthStatusUnknownWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + status, ok, err := clineProviderAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeClineProvidersFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + settingsDir := filepath.Join(home, ".cline", "data", "settings") + if err := os.MkdirAll(settingsDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(settingsDir, "providers.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func futureMillis(t *testing.T) string { + t.Helper() + return strconv.FormatInt(time.Now().Add(time.Hour).UnixMilli(), 10) +} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index 512beeb65..9c032f3c0 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -3,9 +3,11 @@ // workspace-local Cline hooks, and reading hook-derived session info. // // Cline is an autonomous coding agent that runs in the terminal (binary -// "cline", installed via `npm i -g cline`). AO drives it headlessly by passing -// the prompt as a positional argument and requesting NDJSON output with -// `--json`, which Cline emits one event per line for machine parsing. +// "cline", installed via `npm i -g cline`). AO drives task launches headlessly +// by passing the prompt as a positional argument and requesting NDJSON output +// with `--json`, which Cline emits one event per line for machine parsing. +// Promptless launches (notably orchestrators) must stay in Cline's interactive +// mode because Cline rejects `--json` without a prompt or piped stdin. // // AO-managed sessions derive native session identity from Cline hooks // (the workspace-local `.clinerules/hooks/` executable scripts AO installs) @@ -67,18 +69,21 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { return ports.ConfigSpec{}, nil } -// GetLaunchCommand builds the argv to start a new headless Cline session, -// requesting machine-readable NDJSON output (`--json`), applying the approval -// flags, an optional system-prompt override (`-s`), and the initial prompt as -// the trailing positional argument. The prompt is placed after `--` so a -// leading "-" is not read as a flag. +// GetLaunchCommand builds the argv to start a new Cline session. Prompted +// launches request machine-readable NDJSON output (`--json`). Promptless +// launches stay interactive because Cline's JSON output mode requires a prompt +// argument or piped stdin. The prompt is placed after `--` so a leading "-" is +// not read as a flag. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.clineBinary(ctx) if err != nil { return nil, err } - cmd = []string{binary, "--json"} + cmd = []string{binary} + if cfg.Prompt != "" { + cmd = append(cmd, "--json") + } appendApprovalFlags(&cmd, cfg.Permissions) if cfg.SystemPrompt != "" { @@ -102,9 +107,10 @@ func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.Launch } // GetRestoreCommand rebuilds the argv that continues an existing Cline session: -// `cline --json [approval flags] --id <agentSessionId>`. ok is false when the -// hook-derived native session id has not landed yet, so callers can fall back -// to fresh launch behavior. +// `cline [approval flags] --id <agentSessionId>`. Resumes are interactive +// because no prompt is supplied here. ok is false when the hook-derived native +// session id has not landed yet, so callers can fall back to fresh launch +// behavior. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { if err := ctx.Err(); err != nil { return nil, false, err @@ -120,7 +126,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) } cmd = make([]string, 0, 8) - cmd = append(cmd, binary, "--json") + cmd = append(cmd, binary) appendApprovalFlags(&cmd, cfg.Permissions) cmd = append(cmd, "--id", agentSessionID) return cmd, true, nil diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 7b33121f4..2ef2c6b7c 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -36,6 +36,30 @@ func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) { } } +func TestGetLaunchCommandOmitsJSONForPromptlessInteractiveLaunch(t *testing.T) { + plugin := &Plugin{resolvedBinary: "cline"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + Permissions: ports.PermissionModeBypassPermissions, + SystemPrompt: "coordinate the project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{ + "cline", + "--yolo", + "-s", "coordinate the project", + } + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } + if contains(cmd, "--json") { + t.Fatalf("promptless Cline launch must not use --json: %#v", cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -254,7 +278,6 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { } want := []string{ "cline", - "--json", "--auto-approve", "true", "--id", "session-123", } diff --git a/backend/internal/adapters/agent/cline/install.go b/backend/internal/adapters/agent/cline/install.go new file mode 100644 index 000000000..d3dd2267a --- /dev/null +++ b/backend/internal/adapters/agent/cline/install.go @@ -0,0 +1,8 @@ +package cline + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.clineBinary(ctx) +} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 43d1f6b04..8ce77fbd3 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -13,8 +13,10 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -34,6 +36,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -146,10 +149,37 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks Codex's local login state without making a model call. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.codexBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "login", "status").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "not logged in") || strings.Contains(text, "logged out") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "logged in") { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnauthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} + // ResolveCodexBinary returns the path to the codex binary on this machine, // searching PATH then a handful of well-known install locations -// (Homebrew, Cargo, npm global). Returns "codex" as a last-ditch fallback -// so callers see a clear "command not found" rather than an empty argv. +// (Homebrew, Cargo, npm global, NVM). Returns "codex" as a last-ditch +// fallback so callers see a clear "command not found" rather than an empty +// argv. func ResolveCodexBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -203,6 +233,7 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { filepath.Join(home, ".cargo", "bin", "codex"), filepath.Join(home, ".npm", "bin", "codex"), ) + candidates = append(candidates, nvmNodeBinCandidates(home, "codex")...) } for _, candidate := range candidates { @@ -217,6 +248,14 @@ func ResolveCodexBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("codex: %w", ports.ErrAgentBinaryNotFound) } +func nvmNodeBinCandidates(home, binary string) []string { + matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", binary)) + if err != nil || len(matches) == 0 { + return nil + } + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + return matches +} func resolveNativeWindowsCodex(path string) string { if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(path), ".cmd") { return path @@ -328,7 +367,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { } } -func fileExists(path string) bool { +var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index 0981b2512..e4348f62f 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -90,6 +90,38 @@ func TestGetLaunchCommandWithoutWorkspaceOmitsTrustFlag(t *testing.T) { } } +func TestResolveCodexBinaryFindsNVMInstallWhenPathIsSparse(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("NVM install discovery is Unix-specific") + } + home := t.TempDir() + binDir := filepath.Join(home, ".nvm", "versions", "node", "v20.19.4", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + want := filepath.Join(binDir, "codex") + if err := os.WriteFile(want, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", "") + origFileExists := fileExists + fileExists = func(path string) bool { + return strings.HasPrefix(path, home+string(os.PathSeparator)) && origFileExists(path) + } + t.Cleanup(func() { + fileExists = origFileExists + }) + + got, err := ResolveCodexBinary(context.Background()) + if err != nil { + t.Fatalf("ResolveCodexBinary: %v", err) + } + if got != want { + t.Fatalf("ResolveCodexBinary = %q, want %q", got, want) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/adapters/agent/codex/install.go b/backend/internal/adapters/agent/codex/install.go new file mode 100644 index 000000000..94465ab41 --- /dev/null +++ b/backend/internal/adapters/agent/codex/install.go @@ -0,0 +1,8 @@ +package codex + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.codexBinary(ctx) +} diff --git a/backend/internal/adapters/agent/continueagent/auth.go b/backend/internal/adapters/agent/continueagent/auth.go new file mode 100644 index 000000000..e80dc538d --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth.go @@ -0,0 +1,79 @@ +package continueagent + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + yaml "gopkg.in/yaml.v3" +) + +func continueLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("CONTINUE_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + status, ok, err := continueConfigAuthStatus(filepath.Join(home, ".continue", "config.yaml")) + if err != nil || ok { + return status, ok, err + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func continueConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if continueConfigHasCredential(&root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func continueConfigHasCredential(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if continueConfigHasCredential(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`) + if (strings.Contains(key, "apikey") || strings.Contains(key, "api_key") || strings.Contains(key, "token")) && + value != "" && + !strings.EqualFold(value, "null") && + !strings.EqualFold(value, "none") { + return true + } + if continueConfigHasCredential(node.Content[i+1]) { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/continueagent/auth_test.go b/backend/internal/adapters/agent/continueagent/auth_test.go new file mode 100644 index 000000000..6dfe5597e --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/auth_test.go @@ -0,0 +1,50 @@ +package continueagent + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestContinueLocalAuthStatusAuthorizedFromEnv(t *testing.T) { + t.Setenv("CONTINUE_API_KEY", "continue-key") + + status, ok, err := continueLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestContinueLocalAuthStatusUnknownWithoutEnv(t *testing.T) { + t.Setenv("CONTINUE_API_KEY", "") + t.Setenv("HOME", t.TempDir()) + + status, ok, err := continueLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func TestContinueConfigAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("models:\n - provider: anthropic\n apiKey: continue-key\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := continueConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} diff --git a/backend/internal/adapters/agent/continueagent/continueagent_test.go b/backend/internal/adapters/agent/continueagent/continueagent_test.go index 1cb146578..b3a23e097 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent_test.go +++ b/backend/internal/adapters/agent/continueagent/continueagent_test.go @@ -29,6 +29,12 @@ func TestManifest(t *testing.T) { } } +func TestDoesNotImplementAuthChecker(t *testing.T) { + if _, ok := any(&Plugin{}).(ports.AgentAuthChecker); ok { + t.Fatal("Continue must not implement AgentAuthChecker; catalog refresh must not run model-call auth probes") + } +} + func TestGetConfigSpecEmpty(t *testing.T) { spec, err := (&Plugin{}).GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/install.go b/backend/internal/adapters/agent/continueagent/install.go new file mode 100644 index 000000000..d9fe23a22 --- /dev/null +++ b/backend/internal/adapters/agent/continueagent/install.go @@ -0,0 +1,8 @@ +package continueagent + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.continueBinary(ctx) +} diff --git a/backend/internal/adapters/agent/copilot/auth.go b/backend/internal/adapters/agent/copilot/auth.go new file mode 100644 index 000000000..6444bbaad --- /dev/null +++ b/backend/internal/adapters/agent/copilot/auth.go @@ -0,0 +1,160 @@ +package copilot + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := copilotLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var copilotTokenEnvVars = []string{ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", +} + +func copilotLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range copilotTokenEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + configStatus, configOK, err := copilotConfigAuthStatus(filepath.Join(home, ".copilot", "config.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if configOK { + return configStatus, true, nil + } + if status, ok, err := copilotSessionStateAuthStatus(ctx, filepath.Join(home, ".copilot", "session-state")); err != nil || ok { + return status, ok, err + } + if status, ok, err := copilotGHAuthStatus(ctx); err != nil || ok { + return status, ok, err + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + if textContainsTokenAssignment(text) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotSessionStateAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + paths, err := filepath.Glob(filepath.Join(dir, "*", "events.jsonl")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, path := range paths { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if copilotEventsShowModelUse(string(data)) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func copilotEventsShowModelUse(text string) bool { + return strings.Contains(text, `"model":`) || + strings.Contains(text, `"type":"tool.execution_complete"`) || + strings.Contains(text, `"type":"message"`) +} + +func copilotGHAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, "gh", "auth", "token").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, false, probeCtx.Err() + } + text := strings.TrimSpace(string(out)) + if err == nil && text != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if strings.Contains(strings.ToLower(text), "no oauth token") || strings.Contains(strings.ToLower(text), "not logged") { + return ports.AgentAuthStatusUnknown, false, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func textContainsTokenAssignment(text string) bool { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "token") && !strings.Contains(lower, "auth") { + continue + } + for _, sep := range []string{":", "="} { + _, after, ok := strings.Cut(line, sep) + if !ok { + continue + } + value := strings.Trim(strings.TrimSpace(strings.TrimRight(after, ",")), `"'`) + if value != "" && !strings.EqualFold(value, "null") && !strings.EqualFold(value, "none") { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 5a5d9ee45..3a38de564 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -6,9 +6,9 @@ // "copilot", installed via npm "@github/copilot"), NOT the older `gh copilot` // suggest/explain extension. // -// Launch runs the CLI in non-interactive ("programmatic") mode with `-p -// <prompt>` so it executes the task and exits. Permission modes map onto the -// CLI's allow flags (`--allow-tool`, `--allow-all-tools`, `--allow-all`). +// Launch runs the CLI in interactive mode so AO can keep a durable terminal +// pane attached to the session. Permission modes map onto the CLI's allow flags +// (`--allow-tool`, `--allow-all-tools`, `--allow-all`). // Restore continues an existing session via `--resume <agentSessionId>`; the // native session id (a UUID under ~/.copilot/session-state/) is captured by the // SessionStart hook AO installs (see hooks.go). @@ -74,13 +74,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { return ports.ConfigSpec{}, nil } -// GetLaunchCommand builds the argv to start a new headless Copilot session: +// GetLaunchCommand builds the argv to start a new interactive Copilot session: // -// copilot [permission flags] [-p <prompt>] +// copilot [permission flags] // -// The prompt is delivered with `-p`, which runs the prompt in non-interactive -// mode and exits when done. Copilot CLI does not have a documented -// system-prompt-injection flag, so SystemPrompt/SystemPromptFile are ignored. +// The prompt is delivered after the process starts; using `-p` runs Copilot in +// programmatic mode and exits when done, which leaves AO's terminal pane blank +// or dead. Copilot CLI does not have a documented system-prompt-injection flag, +// so SystemPrompt/SystemPromptFile are ignored. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.copilotBinary(ctx) if err != nil { @@ -90,20 +91,16 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = []string{binary} appendApprovalFlags(&cmd, cfg.Permissions) - if cfg.Prompt != "" { - cmd = append(cmd, "-p", cfg.Prompt) - } - return cmd, nil } -// GetPromptDeliveryStrategy reports that Copilot receives its prompt in the -// launch command itself (via `-p`). +// GetPromptDeliveryStrategy reports that Copilot receives its prompt after the +// interactive process starts. func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { if err := ctx.Err(); err != nil { return "", err } - return ports.PromptDeliveryInCommand, nil + return ports.PromptDeliveryAfterStart, nil } // GetRestoreCommand rebuilds the argv that continues an existing Copilot @@ -194,6 +191,9 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { } if path, err := exec.LookPath("copilot"); err == nil && path != "" { + if native := copilotNativeBinaryForLoader(path); native != "" { + return native, nil + } return path, nil } @@ -206,11 +206,15 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { filepath.Join(home, ".copilot", "bin", "copilot"), filepath.Join(home, ".npm", "bin", "copilot"), filepath.Join(home, ".local", "bin", "copilot"), + filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "github.copilot-chat", "copilotCli", "copilot"), ) } for _, candidate := range candidates { if fileExists(candidate) { + if native := copilotNativeBinaryForLoader(candidate); native != "" { + return native, nil + } return candidate, nil } if err := ctx.Err(); err != nil { @@ -221,6 +225,25 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { return "", fmt.Errorf("copilot: %w", ports.ErrAgentBinaryNotFound) } +func copilotNativeBinaryForLoader(path string) string { + if path == "" || runtime.GOOS == "windows" { + return "" + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil || filepath.Base(resolved) != "npm-loader.js" { + return "" + } + platform := runtime.GOOS + if platform == "darwin" { + platform = "darwin" + } + native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) + if fileExists(native) { + return native + } + return "" +} + func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { p.binaryMu.Lock() defer p.binaryMu.Unlock() diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index d4c4c0c1c..a81b8b520 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "testing" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -33,7 +34,7 @@ func TestGetLaunchCommandBuildsArgv(t *testing.T) { t.Fatal(err) } - want := []string{"copilot", "--allow-all", "-p", "-fix this"} + want := []string{"copilot", "--allow-all"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -123,7 +124,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { if err != nil { t.Fatal(err) } - if got != ports.PromptDeliveryInCommand { + if got != ports.PromptDeliveryAfterStart { t.Fatalf("unexpected strategy: %q", got) } } @@ -140,6 +141,115 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestCopilotNativeBinaryForNpmLoader(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("npm loader native binary naming is covered on Unix-like platforms") + } + dir := t.TempDir() + packageDir := filepath.Join(dir, "lib", "node_modules", "@github", "copilot") + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(filepath.Join(packageDir, "node_modules", ".bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + loader := filepath.Join(packageDir, "npm-loader.js") + if err := os.WriteFile(loader, []byte("#!/usr/bin/env node\n"), 0o755); err != nil { + t.Fatal(err) + } + native := filepath.Join(packageDir, "node_modules", ".bin", "copilot-"+runtime.GOOS+"-"+runtime.GOARCH) + if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(binDir, "copilot") + if err := os.Symlink(loader, link); err != nil { + t.Fatal(err) + } + + want, err := filepath.EvalSymlinks(native) + if err != nil { + t.Fatal(err) + } + got, err := filepath.EvalSymlinks(copilotNativeBinaryForLoader(link)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("native binary = %q, want %q", got, want) + } +} + +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearCopilotAuthEnv(t) + t.Setenv("GH_TOKEN", "github_pat_test") + plugin := &Plugin{resolvedBinary: "copilot"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusAuthorizedWithPlainTextToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{"authToken":"token"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCopilotConfigAuthStatusUnauthorizedWithEmptyConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotConfigAuthStatus(configPath) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestCopilotSessionStateAuthStatusAuthorizedWithModelEvent(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session-1") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "events.jsonl"), []byte(`{"type":"tool.execution_complete","data":{"model":"claude-sonnet-4.5"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := copilotSessionStateAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearCopilotAuthEnv(t *testing.T) { + t.Helper() + for _, name := range copilotTokenEnvVars { + t.Setenv(name, "") + } +} + func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) { plugin := &Plugin{resolvedBinary: "copilot"} diff --git a/backend/internal/adapters/agent/copilot/install.go b/backend/internal/adapters/agent/copilot/install.go new file mode 100644 index 000000000..67b5e10e4 --- /dev/null +++ b/backend/internal/adapters/agent/copilot/install.go @@ -0,0 +1,8 @@ +package copilot + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.copilotBinary(ctx) +} diff --git a/backend/internal/adapters/agent/crush/auth.go b/backend/internal/adapters/agent/crush/auth.go new file mode 100644 index 000000000..15e58ab6e --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth.go @@ -0,0 +1,84 @@ +package crush + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := crushLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func crushLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + dataDir, ok := crushDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return crushProvidersAuthStatus(filepath.Join(dataDir, "providers.json")) +} + +func crushDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("CRUSH_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "crush"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "crush"), true +} + +type crushProviderAuth struct { + APIKey string `json:"api_key"` +} + +func crushProvidersAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var providers []crushProviderAuth + if err := json.Unmarshal(data, &providers); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(providers) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + for _, provider := range providers { + if strings.TrimSpace(provider.APIKey) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/crush/auth_test.go b/backend/internal/adapters/agent/crush/auth_test.go new file mode 100644 index 000000000..f47f5230b --- /dev/null +++ b/backend/internal/adapters/agent/crush/auth_test.go @@ -0,0 +1,42 @@ +package crush + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCrushProvidersAuthStatusAuthorizedWithAPIKey(t *testing.T) { + path := writeCrushProviders(t, `[{"id":"anthropic","api_key":"sk-test"}]`) + + status, ok, err := crushProvidersAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCrushProvidersAuthStatusUnauthorizedWithEmptyAPIKeys(t *testing.T) { + path := writeCrushProviders(t, `[{"id":"anthropic","api_key":""}]`) + + status, ok, err := crushProvidersAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func writeCrushProviders(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "providers.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/crush/install.go b/backend/internal/adapters/agent/crush/install.go new file mode 100644 index 000000000..e6d4f041b --- /dev/null +++ b/backend/internal/adapters/agent/crush/install.go @@ -0,0 +1,8 @@ +package crush + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.crushBinary(ctx) +} diff --git a/backend/internal/adapters/agent/cursor/auth.go b/backend/internal/adapters/agent/cursor/auth.go new file mode 100644 index 000000000..487d9ad0a --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth.go @@ -0,0 +1,113 @@ +package cursor + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, err := cursorCLIAuthStatus(ctx, binary); err == nil && status != ports.AgentAuthStatusUnknown { + return status, nil + } else if err != nil && ctx.Err() != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := cursorLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func cursorCLIAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + return authprobe.CLIStatus(ctx, binary, [][]string{{"status"}}) +} + +func cursorLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return cursorConfigAuthStatus(filepath.Join(home, ".cursor", "cli-config.json")) +} + +func cursorConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + type cursorConfig struct { + AuthInfo struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + UserID any `json:"userId"` + AuthID string `json:"authId"` + } `json:"authInfo"` + } + + var cfgs []cursorConfig + if err := json.Unmarshal(data, &cfgs); err != nil { + var cfg cursorConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + cfgs = []cursorConfig{cfg} + } + for _, cfg := range cfgs { + if cursorConfigHasIdentity(cfg) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func cursorConfigHasIdentity(cfg struct { + AuthInfo struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + UserID any `json:"userId"` + AuthID string `json:"authId"` + } `json:"authInfo"` +}) bool { + if cfg.AuthInfo.UserID != nil { + switch v := cfg.AuthInfo.UserID.(type) { + case string: + if strings.TrimSpace(v) != "" { + return true + } + default: + return true + } + } + if strings.TrimSpace(cfg.AuthInfo.AuthID) != "" || + strings.TrimSpace(cfg.AuthInfo.Email) != "" || + strings.TrimSpace(cfg.AuthInfo.DisplayName) != "" { + return true + } + return false +} diff --git a/backend/internal/adapters/agent/cursor/auth_test.go b/backend/internal/adapters/agent/cursor/auth_test.go new file mode 100644 index 000000000..5e1539e76 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/auth_test.go @@ -0,0 +1,96 @@ +package cursor + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestCursorCLIAuthStatusAuthorizedFromStatus(t *testing.T) { + restore := stubCursorAuthCommand(t, []string{"status"}, []byte("✓ Logged in as user@example.com\n"), nil) + defer restore() + + status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent") + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestCursorCLIAuthStatusUnknownFromKeychainError(t *testing.T) { + restore := stubCursorAuthCommand(t, []string{"status"}, []byte("ERROR: SecItemCopyMatching failed -50\n"), assertErr("exit status 139")) + defer restore() + + status, err := cursorCLIAuthStatus(context.Background(), "cursor-agent") + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown) + } +} + +func TestCursorConfigAuthStatusAuthorizedWithAuthInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli-config.json") + if err := os.WriteFile(path, []byte(`{"authInfo":{"email":"user@example.com","userId":"user-1","authId":"auth-1"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := cursorConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestCursorConfigAuthStatusUnknownWithoutAuthInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "cli-config.json") + if err := os.WriteFile(path, []byte(`{"model":{"modelId":"cursor-default"}}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := cursorConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +type assertErr string + +func (e assertErr) Error() string { + return string(e) +} + +func stubCursorAuthCommand(t *testing.T, wantArgs []string, out []byte, err error) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "cursor-agent" || !reflect.DeepEqual(arg, wantArgs) { + t.Fatalf("command = %s %#v, want cursor-agent %#v", name, arg, wantArgs) + } + return out, err + } + return func() { authprobe.CmdRunner = previous } +} + +func TestCursorConfigAuthStatusUnknownWhenMissing(t *testing.T) { + status, ok, err := cursorConfigAuthStatus(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} diff --git a/backend/internal/adapters/agent/cursor/install.go b/backend/internal/adapters/agent/cursor/install.go new file mode 100644 index 000000000..eaf9f3ce0 --- /dev/null +++ b/backend/internal/adapters/agent/cursor/install.go @@ -0,0 +1,8 @@ +package cursor + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.cursorBinary(ctx) +} diff --git a/backend/internal/adapters/agent/devin/auth.go b/backend/internal/adapters/agent/devin/auth.go new file mode 100644 index 000000000..0a3940b85 --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth.go @@ -0,0 +1,62 @@ +package devin + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := devinLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, [][]string{{"auth", "status"}}) +} + +func devinLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return devinCredentialsAuthStatus(filepath.Join(home, ".local", "share", "devin", "credentials.toml")) +} + +func devinCredentialsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + text := strings.TrimSpace(string(data)) + if text == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + lower := strings.ToLower(text) + if strings.Contains(lower, "windsurf_api_key") || + strings.Contains(lower, "devin_api_url") || + strings.Contains(lower, "devin_webapp_host") { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} diff --git a/backend/internal/adapters/agent/devin/auth_test.go b/backend/internal/adapters/agent/devin/auth_test.go new file mode 100644 index 000000000..d33b3abee --- /dev/null +++ b/backend/internal/adapters/agent/devin/auth_test.go @@ -0,0 +1,61 @@ +package devin + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromAuthStatusOutput(t *testing.T) { + previous := authprobe.CmdRunner + authprobe.CmdRunner = func(ctx context.Context, name string, arg ...string) ([]byte, error) { + if name != "devin" || !reflect.DeepEqual(arg, []string{"auth", "status"}) { + t.Fatalf("command = %s %#v, want devin auth status", name, arg) + } + return []byte("Logged in (via Devin).\n\nUser:\n Email: agentsubs@example.com\n"), nil + } + defer func() { authprobe.CmdRunner = previous }() + + got, err := (&Plugin{resolvedBinary: "devin"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestDevinCredentialsAuthStatusAuthorized(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.toml") + if err := os.WriteFile(path, []byte("windsurf_api_key = \"token\"\ndevin_api_url = \"https://api.devin.ai\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := devinCredentialsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDevinCredentialsAuthStatusUnauthorizedWithEmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.toml") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := devinCredentialsAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/devin/install.go b/backend/internal/adapters/agent/devin/install.go new file mode 100644 index 000000000..327c6154a --- /dev/null +++ b/backend/internal/adapters/agent/devin/install.go @@ -0,0 +1,8 @@ +package devin + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.devinBinary(ctx) +} diff --git a/backend/internal/adapters/agent/droid/auth.go b/backend/internal/adapters/agent/droid/auth.go new file mode 100644 index 000000000..b31caa15a --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth.go @@ -0,0 +1,89 @@ +package droid + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if _, err := p.ResolveBinary(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } + status, ok, err := droidLocalAuthStatus(ctx) + if err != nil || ok { + return status, err + } + return ports.AgentAuthStatusUnauthorized, nil +} + +func droidLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("FACTORY_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return droidFactoryAuthStatus(filepath.Join(home, ".factory")) +} + +func droidFactoryAuthStatus(factoryDir string) (ports.AgentAuthStatus, bool, error) { + if factoryDir == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + if fileHasContent(filepath.Join(factoryDir, "auth.v2.file")) && fileHasContent(filepath.Join(factoryDir, "auth.v2.key")) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return droidSettingsAuthStatus(filepath.Join(factoryDir, "settings.json")) +} + +func droidSettingsAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + var settings struct { + CustomModels []struct { + Model string `json:"model"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + } `json:"customModels"` + } + if err := json.Unmarshal(data, &settings); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, model := range settings.CustomModels { + if strings.TrimSpace(model.Model) != "" && + strings.TrimSpace(model.BaseURL) != "" && + strings.TrimSpace(model.APIKey) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + if len(settings.CustomModels) > 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func fileHasContent(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Size() > 0 +} diff --git a/backend/internal/adapters/agent/droid/auth_test.go b/backend/internal/adapters/agent/droid/auth_test.go new file mode 100644 index 000000000..56e777393 --- /dev/null +++ b/backend/internal/adapters/agent/droid/auth_test.go @@ -0,0 +1,72 @@ +package droid + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestAuthStatusAuthorizedFromFactoryAPIKey(t *testing.T) { + t.Setenv("FACTORY_API_KEY", "fk-test") + + got, err := (&Plugin{resolvedBinary: "droid"}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusAuthorizedFromAuthFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "auth.v2.file"), []byte("encrypted auth"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "auth.v2.key"), []byte("key"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusAuthorizedFromCustomModelAPIKey(t *testing.T) { + dir := t.TempDir() + settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":"sk-test"}],"model":"custom:Sonnet-0"}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestDroidFactoryAuthStatusUnauthorizedFromCustomModelWithoutAPIKey(t *testing.T) { + dir := t.TempDir() + settings := `{"customModels":[{"model":"claude-sonnet-4-5-20250929","baseUrl":"https://api.anthropic.com","apiKey":""}]}` + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := droidFactoryAuthStatus(dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/droid/install.go b/backend/internal/adapters/agent/droid/install.go new file mode 100644 index 000000000..b67c2e856 --- /dev/null +++ b/backend/internal/adapters/agent/droid/install.go @@ -0,0 +1,8 @@ +package droid + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.droidBinary(ctx) +} diff --git a/backend/internal/adapters/agent/goose/auth.go b/backend/internal/adapters/agent/goose/auth.go new file mode 100644 index 000000000..76afd2a9f --- /dev/null +++ b/backend/internal/adapters/agent/goose/auth.go @@ -0,0 +1,167 @@ +package goose + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + yaml "gopkg.in/yaml.v3" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := gooseLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var gooseAPIKeyEnvVars = []string{ + "GOOSE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func gooseLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range gooseAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + for _, path := range gooseConfigPaths() { + status, ok, err := gooseAuthStatusFromConfig(path) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if ok { + return status, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func gooseConfigPaths() []string { + seen := map[string]struct{}{} + paths := []string{} + add := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + paths = append(paths, path) + } + + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + add(filepath.Join(xdg, "goose", "config.yaml")) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + // Goose stores config here on macOS as well, rather than under + // os.UserConfigDir's "Application Support" path. + add(filepath.Join(home, ".config", "goose", "config.yaml")) + } + return paths +} + +func gooseAuthStatusFromConfig(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if gooseConfigHasCredential(&root) || gooseConfigHasConfiguredProvider(&root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func gooseConfigHasCredential(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if gooseConfigHasCredential(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`) + if (strings.Contains(key, "api_key") || strings.Contains(key, "apikey") || strings.Contains(key, "token")) && + value != "" && + !strings.EqualFold(value, "null") && + !strings.EqualFold(value, "none") { + return true + } + if gooseConfigHasCredential(node.Content[i+1]) { + return true + } + } + } + return false +} + +func gooseConfigHasConfiguredProvider(node *yaml.Node) bool { + if node == nil { + return false + } + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + if gooseConfigHasConfiguredProvider(child) { + return true + } + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(node.Content[i].Value)) + value := strings.ToLower(strings.Trim(strings.TrimSpace(node.Content[i+1].Value), `"'`)) + if key == "configured" && (value == "true" || value == "yes" || value == "1") { + return true + } + if gooseConfigHasConfiguredProvider(node.Content[i+1]) { + return true + } + } + } + return false +} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go index c0f9c5ab6..faa39963a 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -84,12 +84,15 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // GetLaunchCommand builds the argv to start a new headless Goose session: // -// [env GOOSE_MODE=<mode>] goose run [--system <text>] [-t <prompt>] +// [env GOOSE_MODE=<mode>] goose run [--system <text>] -t <prompt> // // The prompt is delivered in-command via `-t`. A non-default permission mode is // rendered as an `env GOOSE_MODE=<mode>` prefix because Goose reads its approval // mode from the environment, not from a flag. System instructions, when present, -// are passed via `--system`. +// are passed via `--system`. Goose requires one of --instructions, --text, or +// --recipe even when AO intentionally starts a promptless orchestrator, so empty +// prompts are delivered as `-t "" --interactive` to land in an input-ready +// terminal without inventing an initial task. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { binary, err := p.gooseBinary(ctx) if err != nil { @@ -106,8 +109,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( cmd = append(cmd, "--system", systemPrompt) } - if cfg.Prompt != "" { - cmd = append(cmd, "-t", cfg.Prompt) + cmd = append(cmd, "-t", cfg.Prompt) + if cfg.Prompt == "" { + cmd = append(cmd, "--interactive") } return cmd, nil diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go index c5adc2016..8c2da8af1 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -71,6 +71,22 @@ func TestGetLaunchCommandSystemPromptFileInlined(t *testing.T) { } } +func TestGetLaunchCommandPromptlessLaunchStaysInteractive(t *testing.T) { + plugin := &Plugin{resolvedBinary: "goose"} + + cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ + SystemPrompt: "coordinate this project", + }) + if err != nil { + t.Fatal(err) + } + + want := []string{"goose", "run", "--system", "coordinate this project", "-t", "", "--interactive"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) + } +} + func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { tests := []struct { name string @@ -148,6 +164,73 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearGooseAuthEnv(t) + t.Setenv("OPENROUTER_API_KEY", "test-key") + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusAuthorizedFromGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("providers:\n openrouter:\n configured: true\n model: anthropic/claude-sonnet-4\n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromEmptyGooseConfig(t *testing.T) { + clearGooseAuthEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + configPath := filepath.Join(home, ".config", "goose", "config.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + plugin := &Plugin{resolvedBinary: "goose"} + + got, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusUnauthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusUnauthorized) + } +} + +func clearGooseAuthEnv(t *testing.T) { + t.Helper() + for _, name := range gooseAPIKeyEnvVars { + t.Setenv(name, "") + } +} + func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "goose"} ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/adapters/agent/goose/install.go b/backend/internal/adapters/agent/goose/install.go new file mode 100644 index 000000000..3b3cb45ca --- /dev/null +++ b/backend/internal/adapters/agent/goose/install.go @@ -0,0 +1,8 @@ +package goose + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.gooseBinary(ctx) +} diff --git a/backend/internal/adapters/agent/grok/auth.go b/backend/internal/adapters/agent/grok/auth.go new file mode 100644 index 000000000..3f1fd402a --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth.go @@ -0,0 +1,74 @@ +package grok + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := grokLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func grokLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(os.Getenv("GROK_API_KEY")) != "" || strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + path := filepath.Join(home, ".grok", "auth.json") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var entries map[string]json.RawMessage + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for key, value := range entries { + if strings.TrimSpace(key) == "" { + continue + } + trimmed := strings.TrimSpace(string(value)) + if trimmed != "" && trimmed != "null" && trimmed != "{}" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/grok/auth_test.go b/backend/internal/adapters/agent/grok/auth_test.go new file mode 100644 index 000000000..28ec92005 --- /dev/null +++ b/backend/internal/adapters/agent/grok/auth_test.go @@ -0,0 +1,76 @@ +package grok + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestGrokLocalAuthStatusAuthorizedWithAPIKeyEnv(t *testing.T) { + t.Setenv("XAI_API_KEY", "xai-test") + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestGrokLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) { + writeGrokAuthFile(t, `{ + "https://auth.x.ai::account": { + "access_token": "token", + "refresh_token": "refresh" + } + }`) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestGrokLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) { + writeGrokAuthFile(t, `{}`) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestGrokLocalAuthStatusUnknownWhenMissing(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + status, ok, err := grokLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeGrokAuthFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + grokDir := filepath.Join(home, ".grok") + if err := os.MkdirAll(grokDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(grokDir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/adapters/agent/grok/install.go b/backend/internal/adapters/agent/grok/install.go new file mode 100644 index 000000000..2af44fde4 --- /dev/null +++ b/backend/internal/adapters/agent/grok/install.go @@ -0,0 +1,8 @@ +package grok + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.grokBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kilocode/auth.go b/backend/internal/adapters/agent/kilocode/auth.go new file mode 100644 index 000000000..6e42c94c6 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth.go @@ -0,0 +1,194 @@ +package kilocode + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + _ "modernc.org/sqlite" // register sqlite driver for KiloCode auth database probes +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := kilocodeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + status, ok := kilocodeAuthListStatus(string(out)) + if ok { + return status, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var kilocodeAPIKeyEnvVars = []string{ + "KILO_API_KEY", + "KILOCODE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func kilocodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range kilocodeAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + dataDir, ok := kilocodeDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + authorized, known, err := kilocodeAuthJSONStatus(filepath.Join(dataDir, "auth.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if known && authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + authorized, known, err = kilocodeDBAuthStatus(ctx, filepath.Join(dataDir, "kilo.db")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if known && authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func kilocodeDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("KILO_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "kilo"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "kilo"), true +} + +func kilocodeAuthJSONStatus(path string) (authorized, known bool, err error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return false, false, nil + } + if err != nil { + return false, false, err + } + if strings.TrimSpace(string(data)) == "" { + return false, false, nil + } + var providers map[string]map[string]any + if err := json.Unmarshal(data, &providers); err != nil { + return false, false, err + } + for _, provider := range providers { + if len(provider) == 0 { + continue + } + known = true + for _, key := range []string{"key", "apiKey", "api_key", "access_token", "token"} { + if strings.TrimSpace(asString(provider[key])) != "" { + return true, true, nil + } + } + } + return false, known, nil +} + +func asString(value any) string { + s, _ := value.(string) + return s +} + +func kilocodeDBAuthStatus(ctx context.Context, path string) (authorized, known bool, err error) { + if err := ctx.Err(); err != nil { + return false, false, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return false, false, nil + } else if err != nil { + return false, false, err + } + + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)") + if err != nil { + return false, false, err + } + defer func() { + _ = db.Close() + }() + return kilocodeDBHasAuthorizedAccount(ctx, db) +} + +func kilocodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) { + for _, query := range []string{ + `SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`, + `SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`, + `SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`, + } { + var count int + if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + continue + } + return false, false, err + } + known = true + if count > 0 { + return true, true, nil + } + } + return false, known, nil +} + +var kilocodeAuthListCountRE = regexp.MustCompile(`(?m)\b([1-9][0-9]*)\s+(credentials?|environment variables?)\b`) + +func kilocodeAuthListStatus(output string) (ports.AgentAuthStatus, bool) { + text := strings.ToLower(output) + if kilocodeAuthListCountRE.MatchString(text) { + return ports.AgentAuthStatusAuthorized, true + } + if strings.Contains(text, "0 credentials") && strings.Contains(text, "0 environment variable") { + return ports.AgentAuthStatusUnauthorized, true + } + return ports.AgentAuthStatusUnknown, false +} diff --git a/backend/internal/adapters/agent/kilocode/auth_test.go b/backend/internal/adapters/agent/kilocode/auth_test.go new file mode 100644 index 000000000..90947b345 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/auth_test.go @@ -0,0 +1,165 @@ +package kilocode + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestKilocodeLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "openai-key") + + status, ok, err := kilocodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthListStatusAuthorizedWithEnvironmentVariable(t *testing.T) { + output := ` +log stream error: EPERM: operation not permitted +Credentials ~/.local/share/kilo/auth.json +0 credentials + +Environment +OpenAI OPENAI_API_KEY +1 environment variable +` + status, ok := kilocodeAuthListStatus(output) + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthListStatusAuthorizedWithCredentials(t *testing.T) { + status, ok := kilocodeAuthListStatus("2 credentials\n0 environment variables") + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKilocodeAuthJSONStatusAuthorizedWithProviderKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(`{"zai":{"type":"api","key":"secret"}}`), 0o600); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !known || !authorized { + t.Fatalf("authorized, known = %v, %v; want true, true", authorized, known) + } +} + +func TestKilocodeAuthJSONStatusUnknownWhenEmpty(t *testing.T) { + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(`{"zai":{"type":"api","key":""}}`), 0o600); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !known || authorized { + t.Fatalf("authorized, known = %v, %v; want false, true", authorized, known) + } +} + +func TestKilocodeDBHasAuthorizedAccount(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://kilo.ai', 'token', 'refresh', 1, 1); + INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1'); + `); err != nil { + t.Fatal(err) + } + + authorized, known, err := kilocodeDBHasAuthorizedAccount(context.Background(), db) + if err != nil { + t.Fatal(err) + } + if !known || !authorized { + t.Fatalf("authorized, known = %v, %v; want true, true", authorized, known) + } +} + +func TestAuthStatusUnknownWhenKeyOnlyComesFromInteractiveShell(t *testing.T) { + dir := t.TempDir() + shellPath := filepath.Join(dir, "fake-shell") + if err := os.WriteFile(shellPath, []byte(`#!/bin/sh +/usr/bin/touch "$AO_SHELL_PROBE_MARKER" +if [ "$1" = "-ic" ]; then + OPENAI_API_KEY=from-shell /bin/sh -c "$2" +fi +`), 0o755); err != nil { + t.Fatal(err) + } + kilocodePath := filepath.Join(dir, "kilocode") + if err := os.WriteFile(kilocodePath, []byte(`#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "list" ]; then + printf 'auth status unavailable\n' + exit 1 +fi +exit 1 +`), 0o755); err != nil { + t.Fatal(err) + } + markerPath := filepath.Join(dir, "shell-probe-marker") + + t.Setenv("SHELL", shellPath) + t.Setenv("PATH", dir) + t.Setenv("KILO_DATA_DIR", filepath.Join(dir, "missing-kilo-data")) + t.Setenv("AO_SHELL_PROBE_MARKER", markerPath) + for _, name := range kilocodeAPIKeyEnvVars { + t.Setenv(name, "") + } + + status, err := (&Plugin{resolvedBinary: kilocodePath}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnknown) + } + if _, err := os.Stat(markerPath); !os.IsNotExist(err) { + t.Fatalf("interactive shell probe ran; marker stat error = %v", err) + } +} + +func TestKilocodeAuthListStatusUnauthorizedWhenEmpty(t *testing.T) { + status, ok := kilocodeAuthListStatus("0 credentials\n0 environment variables") + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/kilocode/install.go b/backend/internal/adapters/agent/kilocode/install.go new file mode 100644 index 000000000..c2c651602 --- /dev/null +++ b/backend/internal/adapters/agent/kilocode/install.go @@ -0,0 +1,8 @@ +package kilocode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kilocodeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kimi/auth.go b/backend/internal/adapters/agent/kimi/auth.go new file mode 100644 index 000000000..2ec795eb7 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth.go @@ -0,0 +1,88 @@ +package kimi + +import ( + "context" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := kimiLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var kimiAPIKeyEnvVars = []string{ + "KIMI_API_KEY", + "KIMI_CODE_API_KEY", + "MOONSHOT_API_KEY", +} + +func kimiLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range kimiAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + home, ok := kimiCodeHome() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return kimiConfigAuthStatus(filepath.Join(home, "config.toml")) +} + +func kimiCodeHome() (string, bool) { + if home := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")); home != "" { + return home, true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".kimi-code"), true +} + +var kimiAPIKeyLineRE = regexp.MustCompile(`(?m)^\s*api_key\s*=\s*("([^"]*)"|'([^']*)'|([^\s#]+))`) + +func kimiConfigAuthStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + matches := kimiAPIKeyLineRE.FindAllStringSubmatch(string(data), -1) + if len(matches) == 0 { + return ports.AgentAuthStatusUnknown, false, nil + } + for _, match := range matches { + for _, group := range match[2:] { + if strings.TrimSpace(group) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/kimi/auth_test.go b/backend/internal/adapters/agent/kimi/auth_test.go new file mode 100644 index 000000000..f060ebeb3 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/auth_test.go @@ -0,0 +1,79 @@ +package kimi + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestKimiLocalAuthStatusAuthorizedWithEnvKey(t *testing.T) { + t.Setenv("KIMI_API_KEY", "kimi-key") + + status, ok, err := kimiLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiLocalAuthStatusUsesKimiCodeHome(t *testing.T) { + home := t.TempDir() + t.Setenv("KIMI_CODE_HOME", home) + if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(` +[providers.zai-coding-plan] +type = "openai-compatible" +api_key = "secret" +base_url = "https://api.z.ai/api/coding/paas/v4" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiConfigAuthStatusAuthorizedWithProviderAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[providers.zai-coding-plan] +api_key = "secret" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestKimiConfigAuthStatusUnauthorizedWithEmptyAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(` +[providers.zai-coding-plan] +api_key = "" +`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := kimiConfigAuthStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} diff --git a/backend/internal/adapters/agent/kimi/install.go b/backend/internal/adapters/agent/kimi/install.go new file mode 100644 index 000000000..a36b10d66 --- /dev/null +++ b/backend/internal/adapters/agent/kimi/install.go @@ -0,0 +1,8 @@ +package kimi + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kimiBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kiro/auth.go b/backend/internal/adapters/agent/kiro/auth.go new file mode 100644 index 000000000..f68b22b98 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/auth.go @@ -0,0 +1,36 @@ +package kiro + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.kiroBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + return kiroWhoamiAuthStatus(ctx, binary) +} + +func kiroWhoamiAuthStatus(ctx context.Context, binary string) (ports.AgentAuthStatus, error) { + if binary == "" { + return ports.AgentAuthStatusUnknown, nil + } + out, err := authprobe.CmdRunner(ctx, binary, "whoami") + if ctx.Err() != nil { + return ports.AgentAuthStatusUnknown, ctx.Err() + } + if status := authprobe.StatusFromText(string(out)); status != ports.AgentAuthStatusUnknown { + return status, nil + } + if err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + return ports.AgentAuthStatusUnknown, nil +} diff --git a/backend/internal/adapters/agent/kiro/install.go b/backend/internal/adapters/agent/kiro/install.go new file mode 100644 index 000000000..4010ea295 --- /dev/null +++ b/backend/internal/adapters/agent/kiro/install.go @@ -0,0 +1,8 @@ +package kiro + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.kiroBinary(ctx) +} diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go index 6cd25c9ac..61dfab11a 100644 --- a/backend/internal/adapters/agent/kiro/kiro_test.go +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -136,6 +137,44 @@ func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { } } +func TestAuthStatusUsesKiroWhoami(t *testing.T) { + restore := stubKiroAuthRunner(t, func(_ context.Context, name string, arg ...string) ([]byte, error) { + if name != "kiro-cli" { + t.Fatalf("binary = %q, want kiro-cli", name) + } + if !reflect.DeepEqual(arg, []string{"whoami"}) { + t.Fatalf("args = %#v, want [whoami]", arg) + } + return []byte("Logged in with Google\nEmail: nicachale456@gmail.com\n"), nil + }) + defer restore() + + plugin := &Plugin{resolvedBinary: "kiro-cli"} + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusAuthorized) + } +} + +func TestAuthStatusUnauthorizedFromKiroWhoami(t *testing.T) { + restore := stubKiroAuthRunner(t, func(_ context.Context, _ string, _ ...string) ([]byte, error) { + return []byte("Not logged in\n"), nil + }) + defer restore() + + plugin := &Plugin{resolvedBinary: "kiro-cli"} + status, err := plugin.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = %q, want %q", status, ports.AgentAuthStatusUnauthorized) + } +} + func TestGetAgentHooksInstallsKiroHooks(t *testing.T) { plugin := &Plugin{resolvedBinary: "kiro-cli"} workspace := t.TempDir() @@ -441,6 +480,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } +func stubKiroAuthRunner(t *testing.T, runner func(context.Context, string, ...string) ([]byte, error)) func() { + t.Helper() + previous := authprobe.CmdRunner + authprobe.CmdRunner = runner + return func() { authprobe.CmdRunner = previous } +} + func countKiroHookCommand(entries []kiroHookEntry, command string) int { count := 0 for _, entry := range entries { diff --git a/backend/internal/adapters/agent/opencode/install.go b/backend/internal/adapters/agent/opencode/install.go new file mode 100644 index 000000000..b49f37c3f --- /dev/null +++ b/backend/internal/adapters/agent/opencode/install.go @@ -0,0 +1,8 @@ +package opencode + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.opencodeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go index 377f1bde3..415125998 100644 --- a/backend/internal/adapters/agent/opencode/opencode.go +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -17,15 +17,21 @@ package opencode import ( "context" + "database/sql" + "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" + "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/ports" + + _ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes ) const ( @@ -55,6 +61,7 @@ func New() *Plugin { var _ adapters.Adapter = (*Plugin)(nil) var _ ports.Agent = (*Plugin)(nil) +var _ ports.AgentAuthChecker = (*Plugin)(nil) // Manifest returns the adapter's static self-description. func (p *Plugin) Manifest() adapters.Manifest { @@ -158,6 +165,187 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return info, true, nil } +// AuthStatus checks whether opencode has at least one configured provider +// credential. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.opencodeBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := opencodeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + out, err := exec.CommandContext(probeCtx, binary, "auth", "list").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, probeCtx.Err() + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "0 credentials") { + return ports.AgentAuthStatusUnauthorized, nil + } + if strings.Contains(text, "credential") && err == nil { + return ports.AgentAuthStatusAuthorized, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, nil + } + return ports.AgentAuthStatusUnknown, nil +} + +var opencodeAPIKeyEnvVars = []string{ + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", +} + +func opencodeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range opencodeAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + dataDir, ok := opencodeDataDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + jsonStatus, jsonOK, err := opencodeAuthJSONStatus(filepath.Join(dataDir, "auth.json")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if jsonOK && jsonStatus == ports.AgentAuthStatusAuthorized { + return jsonStatus, true, nil + } + if status, ok, err := opencodeDBAuthStatus(ctx, filepath.Join(dataDir, "opencode.db")); err != nil || ok { + return status, ok, err + } + if jsonOK { + return jsonStatus, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func opencodeDataDir() (string, bool) { + if dataDir := strings.TrimSpace(os.Getenv("OPENCODE_DATA_DIR")); dataDir != "" { + return dataDir, true + } + if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" { + return filepath.Join(dataHome, "opencode"), true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".local", "share", "opencode"), true +} + +func opencodeAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnauthorized, true, nil + } + + var entries map[string]json.RawMessage + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for key, value := range entries { + if strings.TrimSpace(key) == "" { + continue + } + trimmed := strings.TrimSpace(string(value)) + if trimmed != "" && trimmed != "null" && trimmed != "{}" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} + +func opencodeDBAuthStatus(ctx context.Context, path string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } else if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro&_pragma=busy_timeout(1000)") + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + defer func() { + _ = db.Close() + }() + + authorized, known, err := opencodeDBHasAuthorizedAccount(ctx, db) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if !known { + return ports.AgentAuthStatusUnknown, false, nil + } + if authorized { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil +} + +func opencodeDBHasAuthorizedAccount(ctx context.Context, db *sql.DB) (authorized, known bool, err error) { + for _, query := range []string{ + `SELECT COUNT(*) FROM account_state WHERE active_account_id IS NOT NULL AND trim(active_account_id) != ''`, + `SELECT COUNT(*) FROM account WHERE trim(access_token) != ''`, + `SELECT COUNT(*) FROM control_account WHERE active = 1 AND trim(access_token) != ''`, + } { + count, err := opencodeDBCount(ctx, db, query) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "no such table") { + continue + } + return false, false, err + } + known = true + if count > 0 { + return true, true, nil + } + } + return false, known, nil +} + +func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error) { + var count int + if err := db.QueryRowContext(ctx, query).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + // appendPermissionFlags maps AO's permission modes onto opencode's single // approval flag. opencode exposes only --dangerously-skip-permissions (no // graduated accept-edits/auto modes), so: @@ -185,9 +373,7 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { // ResolveOpenCodeBinary returns the path to the opencode binary on this machine, // searching PATH then a handful of well-known install locations (the install -// script's ~/.opencode/bin, Homebrew, npm global). Returns "opencode" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// script's ~/.opencode/bin, Homebrew, npm global). func ResolveOpenCodeBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -211,7 +397,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { return candidate, nil } } - return "opencode", nil + return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound) } if path, err := exec.LookPath("opencode"); err == nil && path != "" { @@ -238,7 +424,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { } } - return "opencode", nil + return "", fmt.Errorf("opencode: %w", ports.ErrAgentBinaryNotFound) } func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) { diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go index ba73297c1..20a19326d 100644 --- a/backend/internal/adapters/agent/opencode/opencode_test.go +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -2,6 +2,8 @@ package opencode import ( "context" + "database/sql" + "errors" "os" "path/filepath" "reflect" @@ -11,6 +13,279 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +func TestOpenCodeLocalAuthStatusAuthorizedWithEnv(t *testing.T) { + clearOpenCodeAuthEnv(t) + t.Setenv("ANTHROPIC_API_KEY", "sk-ant-test") + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + writeOpenCodeAuthFile(t, `{ + "anthropic": { + "type": "api", + "key": "sk-ant-test" + } + }`) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + writeOpenCodeAuthFile(t, `{}`) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithActiveDBAccount(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1); + INSERT INTO account_state (id, active_account_id) VALUES (1, 'acct_1'); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusDBAccountOverridesEmptyAuthFile(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) + VALUES ('acct_1', 'user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + if err := os.WriteFile(filepath.Join(dataDir, "auth.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusAuthorizedWithControlDBAccount(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataHome := t.TempDir() + dataDir := filepath.Join(dataHome, "opencode") + writeOpenCodeDBAt(t, dataDir, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE control_account ( + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + active integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + PRIMARY KEY(email, url) + ); + INSERT INTO control_account (email, url, access_token, refresh_token, active, time_created, time_updated) + VALUES ('user@example.com', 'https://opencode.ai', 'token', 'refresh', 1, 1, 1); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("XDG_DATA_HOME", dataHome) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnauthorizedWithEmptyDBAccounts(t *testing.T) { + clearOpenCodeAuthEnv(t) + dataDir := writeOpenCodeDB(t, func(db *sql.DB) { + if _, err := db.Exec(` + CREATE TABLE account ( + id text PRIMARY KEY, + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + time_created integer NOT NULL, + time_updated integer NOT NULL + ); + CREATE TABLE account_state ( + id integer PRIMARY KEY NOT NULL, + active_account_id text, + active_org_id text + ); + CREATE TABLE control_account ( + email text NOT NULL, + url text NOT NULL, + access_token text NOT NULL, + refresh_token text NOT NULL, + token_expiry integer, + active integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + PRIMARY KEY(email, url) + ); + `); err != nil { + t.Fatal(err) + } + }) + t.Setenv("OPENCODE_DATA_DIR", dataDir) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestOpenCodeLocalAuthStatusUnknownWhenMissing(t *testing.T) { + clearOpenCodeAuthEnv(t) + t.Setenv("HOME", t.TempDir()) + + status, ok, err := opencodeLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} + +func writeOpenCodeDB(t *testing.T, setup func(*sql.DB)) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dataDir := filepath.Join(home, ".local", "share", "opencode") + writeOpenCodeDBAt(t, dataDir, setup) + return dataDir +} + +func writeOpenCodeDBAt(t *testing.T, dataDir string, setup func(*sql.DB)) { + t.Helper() + if err := os.MkdirAll(dataDir, 0o700); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(dataDir, "opencode.db"))+"?mode=rwc") + if err != nil { + t.Fatal(err) + } + defer db.Close() + setup(db) +} + +func writeOpenCodeAuthFile(t *testing.T, content string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + authDir := filepath.Join(home, ".local", "share", "opencode") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(authDir, "auth.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func clearOpenCodeAuthEnv(t *testing.T) { + t.Helper() + for _, name := range opencodeAPIKeyEnvVars { + t.Setenv(name, "") + } + t.Setenv("OPENCODE_DATA_DIR", "") + t.Setenv("XDG_DATA_HOME", "") +} + +func TestResolveOpenCodeBinaryFallback(t *testing.T) { + bin, err := ResolveOpenCodeBinary(context.Background()) + if err != nil { + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err) + } + return + } + if bin == "" { + t.Fatal("ResolveOpenCodeBinary returned empty path with no error") + } +} + +func TestResolveOpenCodeBinaryContextCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := ResolveOpenCodeBinary(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("ResolveOpenCodeBinary err = %v, want context.Canceled", err) + } +} + func TestGetLaunchCommandBuildsArgv(t *testing.T) { plugin := &Plugin{resolvedBinary: "opencode"} diff --git a/backend/internal/adapters/agent/pi/auth.go b/backend/internal/adapters/agent/pi/auth.go new file mode 100644 index 000000000..035bf3a50 --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth.go @@ -0,0 +1,85 @@ +package pi + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := piLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +func piLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + configDir, ok := piConfigDir() + if !ok { + return ports.AgentAuthStatusUnknown, false, nil + } + return piAuthJSONStatus(filepath.Join(configDir, "auth.json")) +} + +func piConfigDir() (string, bool) { + if configDir := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); configDir != "" { + return configDir, true + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", false + } + return filepath.Join(home, ".pi", "agent"), true +} + +type piAuthEntry struct { + Type string `json:"type"` + Key string `json:"key"` +} + +func piAuthJSONStatus(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var entries map[string]piAuthEntry + if err := json.Unmarshal(data, &entries); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if len(entries) == 0 { + return ports.AgentAuthStatusUnauthorized, true, nil + } + for provider, entry := range entries { + if strings.TrimSpace(provider) == "" { + continue + } + if strings.TrimSpace(entry.Key) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnauthorized, true, nil +} diff --git a/backend/internal/adapters/agent/pi/auth_test.go b/backend/internal/adapters/agent/pi/auth_test.go new file mode 100644 index 000000000..3a04737e4 --- /dev/null +++ b/backend/internal/adapters/agent/pi/auth_test.go @@ -0,0 +1,42 @@ +package pi + +import ( + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestPiAuthJSONStatusAuthorizedWithProviderKey(t *testing.T) { + path := writePiAuthJSON(t, `{"zai":{"type":"api_key","key":"test-key"}}`) + + status, ok, err := piAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestPiAuthJSONStatusUnauthorizedWhenEmpty(t *testing.T) { + path := writePiAuthJSON(t, `{}`) + + status, ok, err := piAuthJSONStatus(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func writePiAuthJSON(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/backend/internal/adapters/agent/pi/install.go b/backend/internal/adapters/agent/pi/install.go new file mode 100644 index 000000000..54130bbef --- /dev/null +++ b/backend/internal/adapters/agent/pi/install.go @@ -0,0 +1,8 @@ +package pi + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.piBinary(ctx) +} diff --git a/backend/internal/adapters/agent/qwen/auth.go b/backend/internal/adapters/agent/qwen/auth.go new file mode 100644 index 000000000..7f69a5a9e --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth.go @@ -0,0 +1,116 @@ +package qwen + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := qwenLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +var qwenAPIKeyEnvVars = []string{ + "QWEN_API_KEY", + "BAILIAN_CODING_PLAN_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "REQUESTY_API_KEY", + "DASHSCOPE_API_KEY", + "ZAI_API_KEY", +} + +func qwenLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, name := range qwenAPIKeyEnvVars { + if strings.TrimSpace(os.Getenv(name)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + return qwenAuthStatusFromSettings(filepath.Join(home, ".qwen", "settings.json")) +} + +func qwenAuthStatusFromSettings(path string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if strings.TrimSpace(string(data)) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + + var root any + if err := json.Unmarshal(data, &root); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if containsQwenAPIKey(root) { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func containsQwenAPIKey(value any) bool { + switch v := value.(type) { + case map[string]any: + for key, child := range v { + if strings.EqualFold(key, "apiKey") || strings.EqualFold(key, "apikey") { + if stringSetting(child) != "" { + return true + } + continue + } + if containsQwenAPIKey(child) { + return true + } + } + case []any: + for _, child := range v { + if containsQwenAPIKey(child) { + return true + } + } + } + return false +} + +func stringSetting(value any) string { + text, ok := value.(string) + if !ok { + return "" + } + text = strings.TrimSpace(text) + if text == "" || strings.EqualFold(text, "null") || strings.EqualFold(text, "none") { + return "" + } + return text +} diff --git a/backend/internal/adapters/agent/qwen/auth_test.go b/backend/internal/adapters/agent/qwen/auth_test.go new file mode 100644 index 000000000..1fe791325 --- /dev/null +++ b/backend/internal/adapters/agent/qwen/auth_test.go @@ -0,0 +1,78 @@ +package qwen + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestQwenLocalAuthStatusAuthorizedWithProviderEnv(t *testing.T) { + t.Setenv("ZAI_API_KEY", "zai-key") + + status, ok, err := qwenLocalAuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsAuthorizedWithModelProviderAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + content := `{ + "modelProviders": { + "zai": { + "baseUrl": "https://api.z.ai/api/coding/paas/v4", + "apiKey": "zai-key" + } + }, + "defaultModel": "glm-4.5" + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := qwenAuthStatusFromSettings(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsAuthorizedWithSecurityAuthAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + content := `{ + "security": { + "auth": { + "apiKey": "openai-compatible-key" + } + } + }` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := qwenAuthStatusFromSettings(path) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestQwenAuthStatusFromSettingsUnknownWhenMissing(t *testing.T) { + status, ok, err := qwenAuthStatusFromSettings(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if ok || status != ports.AgentAuthStatusUnknown { + t.Fatalf("status = (%q, %v), want (%q, false)", status, ok, ports.AgentAuthStatusUnknown) + } +} diff --git a/backend/internal/adapters/agent/qwen/install.go b/backend/internal/adapters/agent/qwen/install.go new file mode 100644 index 000000000..e54ef99dc --- /dev/null +++ b/backend/internal/adapters/agent/qwen/install.go @@ -0,0 +1,8 @@ +package qwen + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.qwenBinary(ctx) +} diff --git a/backend/internal/adapters/agent/registry/registry.go b/backend/internal/adapters/agent/registry/registry.go index 77f9b5264..91141e9d9 100644 --- a/backend/internal/adapters/agent/registry/registry.go +++ b/backend/internal/adapters/agent/registry/registry.go @@ -83,8 +83,9 @@ func Build() (*adapters.Registry, error) { // harness is the adapter's manifest id, which is also the domain.AgentHarness // value a session carries and the `--harness` flag users pass. type HarnessAgent struct { - Harness domain.AgentHarness - Agent ports.Agent + Harness domain.AgentHarness + Manifest adapters.Manifest + Agent ports.Agent } // Harnessed returns every shipped adapter that drives an agent, paired with its @@ -99,8 +100,9 @@ func Harnessed() []HarnessAgent { continue } out = append(out, HarnessAgent{ - Harness: domain.AgentHarness(a.Manifest().ID), - Agent: agent, + Harness: domain.AgentHarness(a.Manifest().ID), + Manifest: a.Manifest(), + Agent: agent, }) } return out diff --git a/backend/internal/adapters/agent/registry/registry_test.go b/backend/internal/adapters/agent/registry/registry_test.go index 269abced9..25cef3eed 100644 --- a/backend/internal/adapters/agent/registry/registry_test.go +++ b/backend/internal/adapters/agent/registry/registry_test.go @@ -23,6 +23,9 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { for _, ha := range Harnessed() { t.Run(string(ha.Harness), func(t *testing.T) { ws := t.TempDir() + if ha.Harness == "autohand" { + t.Setenv("AUTOHAND_CONFIG", filepath.Join(t.TempDir(), "config.json")) + } cfg := ports.WorkspaceHookConfig{ SessionID: "proj-1", WorkspacePath: ws, @@ -52,6 +55,23 @@ func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { } } +func TestEveryHarnessReportsAuthStatus(t *testing.T) { + authCheckerExempt := map[string]string{ + "continue": "Continue auth probes require sending a model prompt, so catalog refresh must not run them", + } + for _, ha := range Harnessed() { + if reason, exempt := authCheckerExempt[string(ha.Harness)]; exempt { + if _, ok := ha.Agent.(ports.AgentAuthChecker); ok { + t.Errorf("%s implements ports.AgentAuthChecker but is exempt: %s", ha.Harness, reason) + } + continue + } + if _, ok := ha.Agent.(ports.AgentAuthChecker); !ok { + t.Errorf("%s does not implement ports.AgentAuthChecker", ha.Harness) + } + } +} + // workspaceFiles returns every regular file under root, relative to root. func workspaceFiles(t *testing.T, root string) []string { t.Helper() diff --git a/backend/internal/adapters/agent/vibe/auth.go b/backend/internal/adapters/agent/vibe/auth.go new file mode 100644 index 000000000..b05a6d186 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/auth.go @@ -0,0 +1,181 @@ +package vibe + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var _ ports.AgentAuthChecker = (*Plugin)(nil) + +// AuthStatus returns the plugin's local authentication status. +func (p *Plugin) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + binary, err := p.ResolveBinary(ctx) + if err != nil { + return ports.AgentAuthStatusUnknown, err + } + if status, ok, err := vibeLocalAuthStatus(ctx); err != nil { + return ports.AgentAuthStatusUnknown, err + } else if ok { + return status, nil + } + return authprobe.CLIStatus(ctx, binary, nil) +} + +const ( + // This names the default env var Vibe reads; it is not a credential value. + vibeDefaultAPIKeyEnvVar = "MISTRAL_API_KEY" //nolint:gosec // env var name, not a credential value + vibeKeychainService = "vibe" +) + +func vibeLocalAuthStatus(ctx context.Context) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + home, err := os.UserHomeDir() + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if home == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + vibeHome := os.Getenv("VIBE_HOME") + if strings.TrimSpace(vibeHome) == "" { + vibeHome = filepath.Join(home, ".vibe") + } + + envVars, err := vibeAPIKeyEnvVars(filepath.Join(vibeHome, "config.toml")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, envVar := range envVars { + if strings.TrimSpace(os.Getenv(envVar)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + if status, ok, err := vibeEnvFileAuthStatus(filepath.Join(vibeHome, ".env"), envVar); err != nil || ok { + return status, ok, err + } + } + if status, ok, err := vibeSessionLogAuthStatus(ctx, filepath.Join(vibeHome, "logs", "session")); err != nil || ok { + return status, ok, err + } + for _, envVar := range envVars { + if status, ok, err := vibeKeychainAuthStatus(ctx, envVar); err != nil || ok { + return status, ok, err + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeAPIKeyEnvVars(configPath string) ([]string, error) { + vars := []string{vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY"} + data, err := os.ReadFile(configPath) + if os.IsNotExist(err) { + return vars, nil + } + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || strings.TrimSpace(key) != "api_key_env_var" { + continue + } + envVar := strings.Trim(strings.TrimSpace(value), `"',`) + if envVar != "" && !strings.EqualFold(envVar, "null") && !containsString(vars, envVar) { + vars = append(vars, envVar) + } + } + return vars, nil +} + +func vibeEnvFileAuthStatus(path, envVar string) (ports.AgentAuthStatus, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return ports.AgentAuthStatusUnknown, false, nil + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) != envVar { + continue + } + if strings.Trim(strings.TrimSpace(value), `"'`) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnauthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeKeychainAuthStatus(ctx context.Context, envVar string) (ports.AgentAuthStatus, bool, error) { + if strings.TrimSpace(envVar) == "" { + return ports.AgentAuthStatusUnknown, false, nil + } + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + //nolint:gosec // invokes macOS security with fixed command and validated account/service arguments + out, err := exec.CommandContext(probeCtx, "security", "find-generic-password", "-s", vibeKeychainService, "-a", envVar, "-w").CombinedOutput() + if probeCtx.Err() != nil { + return ports.AgentAuthStatusUnknown, false, nil + } + if err == nil && strings.TrimSpace(string(out)) != "" { + return ports.AgentAuthStatusAuthorized, true, nil + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeSessionLogAuthStatus(ctx context.Context, dir string) (ports.AgentAuthStatus, bool, error) { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + paths, err := filepath.Glob(filepath.Join(dir, "session_*", "messages.jsonl")) + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + for _, path := range paths { + if err := ctx.Err(); err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return ports.AgentAuthStatusUnknown, false, err + } + if vibeMessagesShowModelUse(string(data)) { + return ports.AgentAuthStatusAuthorized, true, nil + } + } + return ports.AgentAuthStatusUnknown, false, nil +} + +func vibeMessagesShowModelUse(text string) bool { + return strings.Contains(text, `"role": "assistant"`) || + strings.Contains(text, `"role":"assistant"`) || + strings.Contains(text, `"reasoning_content"`) || + strings.Contains(text, `"session_completion_tokens"`) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/adapters/agent/vibe/install.go b/backend/internal/adapters/agent/vibe/install.go new file mode 100644 index 000000000..3ed8bb0c9 --- /dev/null +++ b/backend/internal/adapters/agent/vibe/install.go @@ -0,0 +1,8 @@ +package vibe + +import "context" + +// ResolveBinary resolves the executable path for the plugin. +func (p *Plugin) ResolveBinary(ctx context.Context) (string, error) { + return p.vibeBinary(ctx) +} diff --git a/backend/internal/adapters/agent/vibe/vibe.go b/backend/internal/adapters/agent/vibe/vibe.go index a838349ac..d002a5e57 100644 --- a/backend/internal/adapters/agent/vibe/vibe.go +++ b/backend/internal/adapters/agent/vibe/vibe.go @@ -78,12 +78,14 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // GetLaunchCommand builds the argv to start a new non-interactive Vibe session: // -// vibe --trust --output text [--agent <profile>] -p <prompt> +// vibe --trust --output text [--workdir <path>] [--agent <profile>] -p <prompt> // // The prompt is delivered through `-p` (programmatic mode), so AO uses // in-command delivery. `--trust` skips the trust prompt for automation and -// `--output text` pins the output format. Vibe exposes no CLI system-prompt -// flag (system prompts are config-driven), so SystemPrompt is not forwarded. +// `--output text` pins the output format. `--workdir` is passed explicitly +// because Vibe validates its own working directory in addition to the process +// cwd AO sets through the runtime. Vibe exposes no CLI system-prompt flag +// (system prompts are config-driven), so SystemPrompt is not forwarded. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { if err := ctx.Err(); err != nil { return nil, err @@ -94,6 +96,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( } cmd = []string{binary, "--trust", "--output", "text"} + appendWorkdirFlag(&cmd, cfg.WorkspacePath) appendAgentFlags(&cmd, cfg.Permissions) if cfg.Prompt != "" { cmd = append(cmd, "-p", cfg.Prompt) @@ -134,6 +137,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) } cmd = make([]string, 0, 8) cmd = append(cmd, binary, "--trust", "--output", "text") + appendWorkdirFlag(&cmd, cfg.Session.WorkspacePath) appendAgentFlags(&cmd, cfg.Permissions) cmd = append(cmd, "--resume", agentSessionID) return cmd, true, nil @@ -148,6 +152,12 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por return ports.SessionInfo{}, false, nil } +func appendWorkdirFlag(cmd *[]string, workspacePath string) { + if workspacePath != "" { + *cmd = append(*cmd, "--workdir", workspacePath) + } +} + // appendAgentFlags maps AO permission modes onto Vibe's builtin `--agent` // profiles. PermissionModeDefault (and the empty mode) emit no flag so Vibe // resolves its starting agent from the user's `default_agent` config. diff --git a/backend/internal/adapters/agent/vibe/vibe_test.go b/backend/internal/adapters/agent/vibe/vibe_test.go index 06d8deef0..baae1024d 100644 --- a/backend/internal/adapters/agent/vibe/vibe_test.go +++ b/backend/internal/adapters/agent/vibe/vibe_test.go @@ -3,6 +3,8 @@ package vibe import ( "context" "errors" + "os" + "path/filepath" "reflect" "testing" @@ -39,6 +41,91 @@ func TestGetConfigSpecEmpty(t *testing.T) { } } +func TestAuthStatusAuthorizedFromEnv(t *testing.T) { + clearVibeAuthEnv(t, vibeDefaultAPIKeyEnvVar, "VIBE_CODE_API_KEY") + t.Setenv(vibeDefaultAPIKeyEnvVar, "test-key") + p := &Plugin{resolvedBinary: "vibe"} + + got, err := p.AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got != ports.AgentAuthStatusAuthorized { + t.Fatalf("AuthStatus = %q, want %q", got, ports.AgentAuthStatusAuthorized) + } +} + +func TestVibeAPIKeyEnvVarsReadsConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(configPath, []byte("[[providers]]\napi_key_env_var = \"CUSTOM_VIBE_KEY\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := vibeAPIKeyEnvVars(configPath) + if err != nil { + t.Fatal(err) + } + if !containsString(got, vibeDefaultAPIKeyEnvVar) || !containsString(got, "CUSTOM_VIBE_KEY") { + t.Fatalf("vibeAPIKeyEnvVars = %#v, want default and custom key", got) + } +} + +func TestVibeEnvFileAuthStatusAuthorized(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=test-key\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func TestVibeEnvFileAuthStatusUnauthorizedForEmptyValue(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("MISTRAL_API_KEY=\n"), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeEnvFileAuthStatus(envPath, vibeDefaultAPIKeyEnvVar) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusUnauthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusUnauthorized) + } +} + +func TestVibeSessionLogAuthStatusAuthorizedWithAssistantMessage(t *testing.T) { + dir := t.TempDir() + sessionDir := filepath.Join(dir, "session_20260625_071829_d5e8a6eb") + if err := os.MkdirAll(sessionDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sessionDir, "messages.jsonl"), []byte(`{"role":"assistant","content":"Hello"}`), 0o600); err != nil { + t.Fatal(err) + } + + status, ok, err := vibeSessionLogAuthStatus(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if !ok || status != ports.AgentAuthStatusAuthorized { + t.Fatalf("status = (%q, %v), want (%q, true)", status, ok, ports.AgentAuthStatusAuthorized) + } +} + +func clearVibeAuthEnv(t *testing.T, names ...string) { + t.Helper() + for _, name := range names { + t.Setenv(name, "") + } +} + func TestGetPromptDeliveryStrategy(t *testing.T) { s, err := (&Plugin{}).GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -52,14 +139,15 @@ func TestGetPromptDeliveryStrategy(t *testing.T) { func TestGetLaunchCommandWithPrompt(t *testing.T) { p := &Plugin{resolvedBinary: "vibe"} cmd, err := p.GetLaunchCommand(context.Background(), ports.LaunchConfig{ - Permissions: ports.PermissionModeBypassPermissions, - Prompt: "add a health check", + Permissions: ports.PermissionModeBypassPermissions, + Prompt: "add a health check", + WorkspacePath: "/work/repo", }) if err != nil { t.Fatal(err) } - want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "-p", "add a health check"} + want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "-p", "add a health check"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd) } @@ -124,7 +212,8 @@ func TestGetRestoreCommand(t *testing.T) { p := &Plugin{resolvedBinary: "vibe"} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ - Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abcd1234-5678-90ab-cdef-1234567890ab"}, + WorkspacePath: "/work/repo", }, Permissions: ports.PermissionModeBypassPermissions, }) @@ -135,7 +224,7 @@ func TestGetRestoreCommand(t *testing.T) { t.Fatal("ok=false, want true") } - want := []string{"vibe", "--trust", "--output", "text", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} + want := []string{"vibe", "--trust", "--output", "text", "--workdir", "/work/repo", "--agent", "auto-approve", "--resume", "abcd1234-5678-90ab-cdef-1234567890ab"} if !reflect.DeepEqual(cmd, want) { t.Fatalf("cmd = %#v, want %#v", cmd, want) } diff --git a/backend/internal/cli/stop_test.go b/backend/internal/cli/stop_test.go index 0a150d633..9a298ecae 100644 --- a/backend/internal/cli/stop_test.go +++ b/backend/internal/cli/stop_test.go @@ -43,6 +43,7 @@ func TestWaitForStoppedKeepsRunFileFromConcurrentStart(t *testing.T) { } if info == nil { t.Fatal("new daemon's run-file was deleted by stop of a different PID") + return } if info.PID != newPID { t.Fatalf("run-file PID = %d, want %d (new daemon)", info.PID, newPID) diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index ab7c2b2eb..727e0a86f 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -16,11 +16,13 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/runtimeselect" "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/daemon/supervisor" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd" "github.com/aoagents/agent-orchestrator/backend/internal/notify" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/preview" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer" notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" @@ -132,7 +134,8 @@ func Run() error { previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ - Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}), + Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}), + Agents: agentsvc.New(), Sessions: sessionSvc, Reviews: reviewSvc, Notifications: notifier, diff --git a/backend/internal/httpd/api.go b/backend/internal/httpd/api.go index 3ec736911..218e6815b 100644 --- a/backend/internal/httpd/api.go +++ b/backend/internal/httpd/api.go @@ -19,6 +19,7 @@ import ( // APIDeps bundles every service the API layer's controllers depend on. type APIDeps struct { + Agents controllers.AgentCatalog Projects projectsvc.Manager Sessions controllers.SessionService Activity controllers.ActivityRecorder @@ -36,6 +37,7 @@ type APIDeps struct { // router invokes to mount the /api/v1 surface. type API struct { cfg config.Config + agents *controllers.AgentsController projects *controllers.ProjectsController sessions *controllers.SessionsController prs *controllers.PRsController @@ -51,6 +53,9 @@ type API struct { func NewAPI(cfg config.Config, deps APIDeps) *API { return &API{ cfg: cfg, + agents: &controllers.AgentsController{ + Catalog: deps.Agents, + }, projects: &controllers.ProjectsController{ Mgr: deps.Projects, }, @@ -80,6 +85,7 @@ func (a *API) Register(root chi.Router) { r.Group(func(r chi.Router) { r.Use(middleware.Timeout(timeout)) + a.agents.Register(r) a.projects.Register(r) a.sessions.Register(r) a.prs.Register(r) diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2d7e7bf46..d3c628a46 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -8,6 +8,56 @@ servers: - description: Local daemon (loopback only) url: http://127.0.0.1:3001 paths: + /api/v1/agents: + get: + operationId: listAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Return cached supported and locally installed agent adapters + tags: + - agents + /api/v1/agents/refresh: + post: + operationId: refreshAgents + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + description: OK + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Refresh the cached local agent adapter catalog + tags: + - agents /api/v1/events: get: operationId: streamEvents @@ -1486,6 +1536,24 @@ components: permissions: type: string type: object + AgentInfo: + properties: + authStatus: + description: Advisory local auth probe result. authorized means a recent + local probe passed; spawn remains the authoritative validation point. + enum: + - authorized + - unauthorized + - unknown + type: string + id: + type: string + label: + type: string + required: + - id + - label + type: object ClaimPRRequest: properties: allowTakeover: @@ -1695,6 +1763,31 @@ components: - ok - sessionId type: object + ListAgentsResponse: + properties: + authorized: + description: Compatibility list of installed agents whose local auth probe + recently returned authorized. Advisory and stale-prone; spawn may still + fail. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + installed: + description: Agents whose binary resolved during the latest best-effort + local catalog probe. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + supported: + description: Agents supported by this daemon build. + items: + $ref: '#/components/schemas/AgentInfo' + type: array + required: + - supported + - installed + - authorized + type: object ListNotificationsResponse: properties: notifications: @@ -2587,6 +2680,8 @@ components: - repo type: object tags: +- description: Supported and locally runnable agent adapters + name: agents - description: Project registry, configuration, and lifecycle administration name: projects - description: Agent session lifecycle and messaging diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 564328684..9e3d0634a 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -55,6 +55,8 @@ func Build() ([]byte, error) { *(&openapi31.Server{URL: "http://127.0.0.1:3001"}).WithDescription("Local daemon (loopback only)"), } r.Spec.Tags = []openapi31.Tag{ + *(&openapi31.Tag{Name: "agents"}).WithDescription( + "Supported and locally runnable agent adapters"), *(&openapi31.Tag{Name: "projects"}).WithDescription( "Project registry, configuration, and lifecycle administration"), *(&openapi31.Tag{Name: "sessions"}).WithDescription( @@ -170,6 +172,8 @@ var schemaNames = map[string]string{ "ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest", "ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse", "ControllersOrchestratorResponse": "OrchestratorResponse", + "AgentInventory": "ListAgentsResponse", + "AgentInfo": "AgentInfo", "ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationIDParam": "NotificationIDParam", @@ -279,6 +283,7 @@ type operation struct { func operations() []operation { ops := append([]operation{}, eventOperations()...) + ops = append(ops, agentOperations()...) ops = append(ops, projectOperations()...) ops = append(ops, sessionOperations()...) ops = append(ops, prOperations()...) @@ -288,6 +293,29 @@ func operations() []operation { return ops } +func agentOperations() []operation { + return []operation{ + { + method: http.MethodGet, path: "/api/v1/agents", id: "listAgents", tag: "agents", + summary: "Return cached supported and locally installed agent adapters", + resps: []respUnit{ + {http.StatusOK, controllers.ListAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/agents/refresh", id: "refreshAgents", tag: "agents", + summary: "Refresh the cached local agent adapter catalog", + resps: []respUnit{ + {http.StatusOK, controllers.RefreshAgentsResponse{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, + } +} + // importOperations declares the 2 /import operations. Must stay 1:1 with // the routes ImportController.Register mounts (enforced by the parity test). func importOperations() []operation { diff --git a/backend/internal/httpd/controllers/agents.go b/backend/internal/httpd/controllers/agents.go new file mode 100644 index 000000000..d8c3624e3 --- /dev/null +++ b/backend/internal/httpd/controllers/agents.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +// AgentCatalog is the controller-facing contract for local agent inventory. +type AgentCatalog interface { + List(ctx context.Context) (agentsvc.Inventory, error) + Refresh(ctx context.Context) (agentsvc.Inventory, error) +} + +// AgentsController owns the /agents routes. +type AgentsController struct { + Catalog AgentCatalog +} + +// Register mounts the agent inventory routes on the supplied router. +func (c *AgentsController) Register(r chi.Router) { + r.Get("/agents", c.list) + r.Post("/agents/refresh", c.refresh) +} + +func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "GET", "/api/v1/agents") + return + } + inventory, err := c.Catalog.List(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, inventory) +} + +func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/agents/refresh") + return + } + inventory, err := c.Catalog.Refresh(r.Context()) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, inventory) +} diff --git a/backend/internal/httpd/controllers/agents_test.go b/backend/internal/httpd/controllers/agents_test.go new file mode 100644 index 000000000..76e0d0545 --- /dev/null +++ b/backend/internal/httpd/controllers/agents_test.go @@ -0,0 +1,94 @@ +package controllers_test + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/config" + "github.com/aoagents/agent-orchestrator/backend/internal/httpd" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" +) + +type fakeAgentCatalog struct { + inventory agentsvc.Inventory + refreshed agentsvc.Inventory + err error + listCalls int + refreshCalls int +} + +func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { + f.listCalls++ + return f.inventory, f.err +} + +func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) { + f.refreshCalls++ + if f.refreshed.Supported != nil { + return f.refreshed, f.err + } + return f.inventory, f.err +} + +func TestListAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "claude-code", Label: "Claude Code"}, {ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }} + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/agents", "") + if status != http.StatusOK { + t.Fatalf("GET /agents = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if strings.Contains(string(body), `"counts"`) { + t.Fatalf("body includes removed counts field: %s", body) + } + if catalog.listCalls != 1 || catalog.refreshCalls != 0 { + t.Fatalf("calls: list=%d refresh=%d, want list=1 refresh=0", catalog.listCalls, catalog.refreshCalls) + } +} + +func TestRefreshAgents(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{ + inventory: agentsvc.Inventory{Supported: []agentsvc.Info{{ID: "codex", Label: "Codex"}}}, + refreshed: agentsvc.Inventory{ + Supported: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Installed: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + Authorized: []agentsvc.Info{{ID: "codex", Label: "Codex"}}, + }, + } + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/refresh", "") + if status != http.StatusOK { + t.Fatalf("POST /agents/refresh = %d, body=%s", status, body) + } + for _, want := range []string{`"supported"`, `"installed"`, `"authorized"`, `"id":"codex"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if catalog.listCalls != 0 || catalog.refreshCalls != 1 { + t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls) + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index ab859ef7a..0f830b244 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -7,6 +7,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -435,6 +436,15 @@ type OrchestratorResponse struct { ProjectName string `json:"projectName,omitempty"` } +// ListAgentsResponse is the body of GET /api/v1/agents. +type ListAgentsResponse = agentsvc.Inventory + +// RefreshAgentsResponse is the body of POST /api/v1/agents/refresh. +type RefreshAgentsResponse = agentsvc.Inventory + +// AgentInfo is one supported or installed agent entry. +type AgentInfo = agentsvc.Info + // ListNotificationsQuery is the query string accepted by GET /api/v1/notifications. type ListNotificationsQuery struct { Status string `query:"status,omitempty" enum:"unread" description:"Notification status filter. V1 supports only unread."` diff --git a/backend/internal/httpd/server_test.go b/backend/internal/httpd/server_test.go index 299d218f6..a315c78a0 100644 --- a/backend/internal/httpd/server_test.go +++ b/backend/internal/httpd/server_test.go @@ -96,6 +96,7 @@ func TestServerLifecycle(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) + defer cancel() runErr := make(chan error, 1) go func() { runErr <- srv.Run(ctx) }() @@ -109,6 +110,7 @@ func TestServerLifecycle(t *testing.T) { } if info == nil { t.Fatal("run-file not written while server running") + return } if info.Port == 0 { t.Error("run-file recorded port 0; want the actual bound port") diff --git a/backend/internal/observe/scm/observer_test.go b/backend/internal/observe/scm/observer_test.go index f07b1fef5..d75d217a7 100644 --- a/backend/internal/observe/scm/observer_test.go +++ b/backend/internal/observe/scm/observer_test.go @@ -681,6 +681,7 @@ func TestPoll_DiscoveredPRPersistedAsBaselineBeforeRefresh(t *testing.T) { } if baseline == nil { t.Fatalf("discovered PR #1 not persisted as a baseline row; writes=%#v", store.writes) + return } if baseline.Merged || baseline.Closed { t.Fatalf("baseline row must be open, got merged=%v closed=%v", baseline.Merged, baseline.Closed) diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 9b5d6a9e2..0b8c39dfb 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -14,6 +14,22 @@ import ( // for a live session. var ErrAgentBinaryNotFound = errors.New("agent: binary not found on PATH") +// AgentAuthStatus describes the result of a short local auth probe for an +// installed agent. It is advisory only: credentials, quota, selected model +// availability, or CLI state can still fail at session spawn/model-call time. +type AgentAuthStatus string + +const ( + // AgentAuthStatusAuthorized means the local auth probe recently passed. + // It does not guarantee that a later spawn or model call will succeed. + AgentAuthStatusAuthorized AgentAuthStatus = "authorized" + // AgentAuthStatusUnauthorized means the agent is installed but its local + // auth probe reported missing or invalid authentication. + AgentAuthStatusUnauthorized AgentAuthStatus = "unauthorized" + // AgentAuthStatusUnknown means the daemon could not determine auth status. + AgentAuthStatusUnknown AgentAuthStatus = "unknown" +) + // Agent is the contract every CLI coding agent adapter (claude-code, codex, …) // must satisfy. It supplies the argv and process configuration the Session // Manager needs to launch, restore, and read back a native agent session. @@ -42,6 +58,18 @@ type Agent interface { SessionInfo(ctx context.Context, session SessionRef) (info SessionInfo, ok bool, err error) } +// AgentAuthChecker is the optional capability for adapters whose native CLI has +// a cheap local authentication status probe. +type AgentAuthChecker interface { + AuthStatus(ctx context.Context) (AgentAuthStatus, error) +} + +// AgentBinaryResolver is the optional capability adapters expose when their +// binary can be checked without constructing a real session launch command. +type AgentBinaryResolver interface { + ResolveBinary(ctx context.Context) (path string, err error) +} + // AgentResolver maps a session's harness onto the Agent adapter that drives it, // so the Session Manager can spawn (and restore) a different agent per session // without depending on the concrete adapter registry. ok=false means no adapter diff --git a/backend/internal/runfile/runfile_test.go b/backend/internal/runfile/runfile_test.go index 87b62b320..162421e40 100644 --- a/backend/internal/runfile/runfile_test.go +++ b/backend/internal/runfile/runfile_test.go @@ -20,6 +20,7 @@ func TestWriteReadRoundTrip(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for an existing file") + return } if got.PID != want.PID || got.Port != want.Port || !got.StartedAt.Equal(want.StartedAt) { t.Errorf("round trip mismatch: got %+v, want %+v", *got, want) @@ -44,6 +45,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for an existing file") + return } if got.Owner != "app" { t.Errorf("Owner round trip: got %q, want %q", got.Owner, "app") @@ -60,6 +62,7 @@ func TestWriteReadRoundTripOwner(t *testing.T) { } if got == nil { t.Fatal("Read returned nil for headless file") + return } if got.Owner != "" { t.Errorf("headless Owner round trip: got %q, want %q", got.Owner, "") @@ -164,6 +167,7 @@ func TestCheckStaleLivePID(t *testing.T) { } if live == nil { t.Fatal("CheckStale on live PID = nil, want the live Info") + return } if live.PID != os.Getpid() { t.Errorf("live.PID = %d, want %d", live.PID, os.Getpid()) diff --git a/backend/internal/service/agent/catalog_test.go b/backend/internal/service/agent/catalog_test.go new file mode 100644 index 000000000..2df062e3c --- /dev/null +++ b/backend/internal/service/agent/catalog_test.go @@ -0,0 +1,303 @@ +package agent + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +type fakeAgent struct { + err error + delay time.Duration +} + +type fakeAuthAgent struct { + fakeAgent + status ports.AgentAuthStatus + authErr error + authDelay time.Duration +} + +type probeTrackingAgent struct { + fakeAgent + onProbe func() +} + +func (f fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, nil +} + +func (f fakeAgent) GetLaunchCommand(ctx context.Context, _ ports.LaunchConfig) ([]string, error) { + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if f.err != nil { + return nil, f.err + } + return []string{"agent"}, nil +} + +func (f fakeAgent) ResolveBinary(ctx context.Context) (string, error) { + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return "", ctx.Err() + } + } + if f.err != nil { + return "", f.err + } + return "agent", nil +} + +func (f probeTrackingAgent) ResolveBinary(ctx context.Context) (string, error) { + if f.onProbe != nil { + f.onProbe() + } + return f.fakeAgent.ResolveBinary(ctx) +} + +func (f fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + return ports.PromptDeliveryInCommand, nil +} + +func (f fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { + return nil +} + +func (f fakeAgent) GetRestoreCommand(context.Context, ports.RestoreConfig) ([]string, bool, error) { + return nil, false, nil +} + +func (f fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) { + return ports.SessionInfo{}, false, nil +} + +func (f fakeAuthAgent) AuthStatus(ctx context.Context) (ports.AgentAuthStatus, error) { + if f.authDelay > 0 { + select { + case <-time.After(f.authDelay): + case <-ctx.Done(): + return ports.AgentAuthStatusUnknown, ctx.Err() + } + } + return f.status, f.authErr +} + +func TestListReturnsInitialSupportedInventoryWithoutProbing(t *testing.T) { + probed := false + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{onProbe: func() { probed = true }}, + }, + }) + + got, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if probed { + t.Fatal("List ran a live probe") + } + if len(got.Supported) != 1 || got.Supported[0].ID != "codex" { + t.Fatalf("supported = %#v, want codex", got.Supported) + } + if len(got.Installed) != 0 || len(got.Authorized) != 0 { + t.Fatalf("inventory = %#v, want only supported entries before refresh", got) + } + if got.Installed == nil { + t.Fatal("Installed = nil, want empty slice") + } + if got.Authorized == nil { + t.Fatal("Authorized = nil, want empty slice") + } +} + +func TestRefreshReportsInstalledAgentsAndIgnoresDetectorErrors(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + harnessAgent("broken", "Broken", errors.New("unexpected detector failure")), + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Supported) != 3 { + t.Fatalf("supported = %#v, want 3 agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestRefreshReportsAuthorizedInstalledAgents(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAuthAgent("codex", "Codex", ports.AgentAuthStatusAuthorized, nil), + harnessAuthAgent("claude-code", "Claude Code", ports.AgentAuthStatusUnauthorized, nil), + harnessAgent("opencode", "OpenCode", nil), + harnessAuthAgent("broken-auth", "Broken Auth", ports.AgentAuthStatusAuthorized, errors.New("probe failed")), + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Supported) != 4 || len(got.Installed) != 4 { + t.Fatalf("inventory = %#v, want supported=4 installed=4", got) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "codex" { + t.Fatalf("authorized = %#v, want only codex", got.Authorized) + } + + byID := map[string]Info{} + for _, info := range got.Installed { + byID[info.ID] = info + } + if byID["codex"].AuthStatus != ports.AgentAuthStatusAuthorized { + t.Fatalf("codex authStatus = %q", byID["codex"].AuthStatus) + } + if byID["claude-code"].AuthStatus != ports.AgentAuthStatusUnauthorized { + t.Fatalf("claude-code authStatus = %q", byID["claude-code"].AuthStatus) + } + if byID["opencode"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("opencode authStatus = %q", byID["opencode"].AuthStatus) + } + if byID["broken-auth"].AuthStatus != ports.AgentAuthStatusUnknown { + t.Fatalf("broken-auth authStatus = %q", byID["broken-auth"].AuthStatus) + } +} + +func TestRefreshDoesNotWaitForSlowAgentProbe(t *testing.T) { + previous := agentInstallProbeTimeout + agentInstallProbeTimeout = 20 * time.Millisecond + t.Cleanup(func() { agentInstallProbeTimeout = previous }) + + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("codex", "Codex", nil), + { + Harness: domain.AgentHarness("slow"), + Manifest: adapters.Manifest{ + ID: "slow", + Name: "Slow", + }, + Agent: fakeAgent{delay: time.Minute}, + }, + }) + + start := time.Now() + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("List took %s, want bounded by slow probe timeout", elapsed) + } + if len(got.Supported) != 2 { + t.Fatalf("supported = %#v, want both agents", got.Supported) + } + if len(got.Installed) != 1 || got.Installed[0].ID != "codex" { + t.Fatalf("installed = %#v, want only codex", got.Installed) + } +} + +func TestRefreshUsesSeparateTimeoutForAuthProbe(t *testing.T) { + previousInstall := agentInstallProbeTimeout + previousAuth := agentAuthProbeTimeout + agentInstallProbeTimeout = 20 * time.Millisecond + agentAuthProbeTimeout = 200 * time.Millisecond + t.Cleanup(func() { + agentInstallProbeTimeout = previousInstall + agentAuthProbeTimeout = previousAuth + }) + + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("claude-code"), + Manifest: adapters.Manifest{ + ID: "claude-code", + Name: "Claude Code", + }, + Agent: fakeAuthAgent{ + fakeAgent: fakeAgent{}, + status: ports.AgentAuthStatusAuthorized, + authDelay: 75 * time.Millisecond, + }, + }, + }) + + got, err := svc.Refresh(context.Background()) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if len(got.Authorized) != 1 || got.Authorized[0].ID != "claude-code" { + t.Fatalf("authorized = %#v, want claude-code", got.Authorized) + } +} + +func TestRefreshIsRateLimited(t *testing.T) { + previous := agentRefreshMinInterval + agentRefreshMinInterval = time.Hour + t.Cleanup(func() { agentRefreshMinInterval = previous }) + + probes := 0 + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{onProbe: func() { probes++ }}, + }, + }) + + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("first Refresh: %v", err) + } + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("second Refresh: %v", err) + } + if probes != 1 { + t.Fatalf("probes = %d, want 1", probes) + } +} + +func harnessAgent(id, label string, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAgent{err: err}, + } +} + +func harnessAuthAgent(id, label string, status ports.AgentAuthStatus, err error) agentregistry.HarnessAgent { + return agentregistry.HarnessAgent{ + Harness: domain.AgentHarness(id), + Manifest: adapters.Manifest{ + ID: id, + Name: label, + }, + Agent: fakeAuthAgent{fakeAgent: fakeAgent{}, status: status, authErr: err}, + } +} diff --git a/backend/internal/service/agent/service.go b/backend/internal/service/agent/service.go new file mode 100644 index 000000000..2a2391294 --- /dev/null +++ b/backend/internal/service/agent/service.go @@ -0,0 +1,214 @@ +package agent + +import ( + "context" + "errors" + "sort" + "sync" + "time" + + agentregistry "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/registry" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +var ( + agentInstallProbeTimeout = 2 * time.Second + agentAuthProbeTimeout = 10 * time.Second + agentRefreshMinInterval = 10 * time.Second +) + +type probeResult struct { + info Info + installed bool + authorized bool +} + +// Info is the user-facing identity for an agent adapter. +type Info struct { + ID string `json:"id"` + Label string `json:"label"` + AuthStatus ports.AgentAuthStatus `json:"authStatus,omitempty" enum:"authorized,unauthorized,unknown" description:"Advisory local auth probe result. authorized means a recent local probe passed; spawn remains the authoritative validation point."` +} + +// Inventory describes all daemon-supported agents and best-effort local probe +// results. Installed/authorized entries are advisory snapshots and can be stale; +// session spawn is the authoritative validation point for binary availability, +// runtime prerequisites, and model-call readiness. +type Inventory struct { + Supported []Info `json:"supported" description:"Agents supported by this daemon build."` + Installed []Info `json:"installed" description:"Agents whose binary resolved during the latest best-effort local catalog probe."` + Authorized []Info `json:"authorized" description:"Compatibility list of installed agents whose local auth probe recently returned authorized. Advisory and stale-prone; spawn may still fail."` +} + +// Service reports supported agent adapters and best-effort local readiness +// probes. Catalog readiness is advisory UI metadata, not a spawn precheck. +type Service struct { + agents []agentregistry.HarnessAgent + + mu sync.RWMutex + inventory Inventory + lastRefresh time.Time + refreshMu sync.Mutex +} + +// New returns an agent inventory service backed by the daemon's shipped +// adapter registry. +func New() *Service { + return NewWithAgents(agentregistry.Harnessed()) +} + +// NewWithAgents returns an inventory service over a caller-provided adapter +// slice. It is used by focused tests. +func NewWithAgents(agents []agentregistry.HarnessAgent) *Service { + return &Service{agents: agents, inventory: Inventory{ + Supported: supportedInfos(agents), + Installed: []Info{}, + Authorized: []Info{}, + }} +} + +// List returns the cached agent inventory without running probes. Installed and +// authorized entries come from the last explicit Refresh call and are advisory: +// they can be stale by the time a user starts a session, and session spawn +// performs the authoritative binary/runtime validation. +func (s *Service) List(ctx context.Context) (Inventory, error) { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + return cloneInventory(s.inventory), nil +} + +// Refresh runs the bounded local binary/auth probes, updates the cached +// inventory, and returns the new snapshot. Refreshes are serialized and +// rate-limited so repeated frontend reloads cannot stampede agent CLIs. +func (s *Service) Refresh(ctx context.Context) (Inventory, error) { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + + s.mu.RLock() + if !s.lastRefresh.IsZero() && time.Since(s.lastRefresh) < agentRefreshMinInterval { + cached := cloneInventory(s.inventory) + s.mu.RUnlock() + return cached, nil + } + s.mu.RUnlock() + + results := make(chan probeResult, len(s.agents)) + var wg sync.WaitGroup + for _, item := range s.agents { + if err := ctx.Err(); err != nil { + return Inventory{}, err + } + wg.Add(1) + go func(item agentregistry.HarnessAgent) { + defer wg.Done() + results <- probeAgent(ctx, item) + }(item) + } + wg.Wait() + close(results) + + supported := make([]Info, 0, len(s.agents)) + installed := make([]Info, 0, len(s.agents)) + authorized := make([]Info, 0, len(s.agents)) + for res := range results { + supported = append(supported, res.info) + if res.installed { + installed = append(installed, res.info) + } + if res.authorized { + authorized = append(authorized, res.info) + } + } + sortInfos(supported) + sortInfos(installed) + sortInfos(authorized) + next := Inventory{ + Supported: supported, + Installed: installed, + Authorized: authorized, + } + s.mu.Lock() + s.inventory = cloneInventory(next) + s.lastRefresh = time.Now() + s.mu.Unlock() + return next, nil +} + +func supportedInfos(agents []agentregistry.HarnessAgent) []Info { + supported := make([]Info, 0, len(agents)) + for _, item := range agents { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + supported = append(supported, info) + } + sortInfos(supported) + return supported +} + +func cloneInventory(in Inventory) Inventory { + return Inventory{ + Supported: cloneInfos(in.Supported), + Installed: cloneInfos(in.Installed), + Authorized: cloneInfos(in.Authorized), + } +} + +func cloneInfos(in []Info) []Info { + out := make([]Info, len(in)) + copy(out, in) + return out +} + +func probeAgent(ctx context.Context, item agentregistry.HarnessAgent) probeResult { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + probeCtx, cancel := context.WithTimeout(ctx, agentInstallProbeTimeout) + defer cancel() + resolver, ok := item.Agent.(ports.AgentBinaryResolver) + if !ok { + return probeResult{info: info} + } + if _, err := resolver.ResolveBinary(probeCtx); err != nil { + return probeResult{info: info} + } + authCtx, authCancel := context.WithTimeout(ctx, agentAuthProbeTimeout) + defer authCancel() + info.AuthStatus = authStatus(authCtx, item.Agent) + return probeResult{info: info, installed: true, authorized: info.AuthStatus == ports.AgentAuthStatusAuthorized} +} + +func authStatus(ctx context.Context, a ports.Agent) ports.AgentAuthStatus { + checker, ok := a.(ports.AgentAuthChecker) + if !ok { + return ports.AgentAuthStatusUnknown + } + status, err := checker.AuthStatus(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return ports.AgentAuthStatusUnknown + } + return ports.AgentAuthStatusUnknown + } + switch status { + case ports.AgentAuthStatusAuthorized, ports.AgentAuthStatusUnauthorized: + return status + default: + return ports.AgentAuthStatusUnknown + } +} + +func sortInfos(infos []Info) { + sort.Slice(infos, func(i, j int) bool { + return infos[i].ID < infos[j].ID + }) +} diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index af8593737..c8d2e3d65 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/aoagents/agent-orchestrator/backend/internal/config" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr" "github.com/aoagents/agent-orchestrator/backend/internal/ports" @@ -45,10 +46,11 @@ type SessionTeardowner interface { // Service implements project registration and lookup use-cases for controllers. type Service struct { - store Store - sessions SessionTeardowner - clock func() time.Time - telemetry ports.EventSink + store Store + sessions SessionTeardowner + clock func() time.Time + telemetry ports.EventSink + defaultHarness domain.AgentHarness // addMu serialises the whole body of Add. Workspace registration performs // filesystem mutations (git init, .gitignore writes, commits) that are not // covered by the store's own writeMu, so path/id conflict checks plus the @@ -60,10 +62,13 @@ var _ Manager = (*Service)(nil) // Deps captures optional collaborators for project use-cases. type Deps struct { - Store Store - Sessions SessionTeardowner - Clock func() time.Time - Telemetry ports.EventSink + // DefaultHarness is the daemon's configured default agent (AO_AGENT). + // When empty, the service falls back to config.DefaultAgent. + DefaultHarness domain.AgentHarness + Store Store + Sessions SessionTeardowner + Clock func() time.Time + Telemetry ports.EventSink } // New returns a project service backed by the given durable store. @@ -73,7 +78,17 @@ func New(store Store) *Service { // NewWithDeps returns a project service with optional teardown dependencies. func NewWithDeps(d Deps) *Service { - s := &Service{store: d.Store, sessions: d.Sessions, clock: d.Clock, telemetry: d.Telemetry} + defaultHarness := d.DefaultHarness + if defaultHarness == "" { + defaultHarness = domain.AgentHarness(config.DefaultAgent) + } + s := &Service{ + store: d.Store, + sessions: d.Sessions, + clock: d.Clock, + telemetry: d.Telemetry, + defaultHarness: defaultHarness, + } if s.clock == nil { s.clock = time.Now } @@ -111,7 +126,7 @@ func (m *Service) Get(ctx context.Context, id domain.ProjectID) (GetResult, erro if !ok || !row.ArchivedAt.IsZero() { return GetResult{}, apierr.NotFound("PROJECT_NOT_FOUND", "Unknown project") } - p := projectFromRow(row) + p := m.projectFromRow(row) if row.Kind.WithDefault() == domain.ProjectKindWorkspace { repos, err := m.store.ListWorkspaceRepos(ctx, row.ID) if err != nil { @@ -174,12 +189,12 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { }) } - var config domain.ProjectConfig + var projectConfig domain.ProjectConfig if in.Config != nil { if err := in.Config.Validate(); err != nil { return Project{}, apierr.Invalid("INVALID_PROJECT_CONFIG", err.Error(), nil) } - config = *in.Config + projectConfig = *in.Config } registeredAt := time.Now() @@ -189,7 +204,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { DisplayName: name, RegisteredAt: registeredAt, Kind: domain.ProjectKindSingleRepo, - Config: config, + Config: projectConfig, } if in.AsWorkspace { repos, err := prepareWorkspaceProject(ctx, path, domain.ProjectID(row.ID), registeredAt) @@ -202,7 +217,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register workspace project") } m.emitProjectAdded(row, projectCountBefore == 0) - p := projectFromRow(row) + p := m.projectFromRow(row) p.WorkspaceRepos = workspaceReposFromRecords(repos) return p, nil } @@ -224,7 +239,7 @@ func (m *Service) Add(ctx context.Context, in AddInput) (Project, error) { return Project{}, apierr.Internal("PROJECT_ADD_FAILED", "Failed to register project") } m.emitProjectAdded(row, projectCountBefore == 0) - return projectFromRow(row), nil + return m.projectFromRow(row), nil } func (m *Service) activeProjectCount(ctx context.Context) (int, error) { @@ -286,7 +301,7 @@ func (m *Service) SetConfig(ctx context.Context, id domain.ProjectID, in SetConf if err := m.store.UpsertProject(ctx, row); err != nil { return Project{}, apierr.Internal("PROJECT_CONFIG_UPDATE_FAILED", "Failed to update project config") } - return projectFromRow(row), nil + return m.projectFromRow(row), nil } // resolveGitOriginURL returns the project's `origin` remote URL via @@ -365,7 +380,7 @@ func (m *Service) suggestID(ctx context.Context, base domain.ProjectID) domain.P } } -func projectFromRow(row domain.ProjectRecord) Project { +func (m *Service) projectFromRow(row domain.ProjectRecord) Project { p := Project{ ID: domain.ProjectID(row.ID), Name: displayName(row), @@ -373,6 +388,7 @@ func projectFromRow(row domain.ProjectRecord) Project { Path: row.Path, Repo: row.RepoOriginURL, DefaultBranch: row.Config.WithDefaults().DefaultBranch, + Agent: string(m.defaultHarness), } if !row.Config.IsZero() { cfg := row.Config diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index 4901b83bd..f10dba67e 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -272,6 +272,9 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { if got.Project.DefaultBranch != domain.DefaultBranchName { t.Fatalf("default branch = %q, want %q", got.Project.DefaultBranch, domain.DefaultBranchName) } + if got.Project.Agent != "claude-code" { + t.Fatalf("default agent = %q, want claude-code", got.Project.Agent) + } if got.Project.Config != nil { t.Fatalf("unconfigured project should omit config, got %#v", got.Project.Config) } @@ -285,6 +288,32 @@ func TestManager_DefaultsWhenUnconfigured(t *testing.T) { } } +func TestManager_GetUsesConfiguredDefaultHarness(t *testing.T) { + ctx := context.Background() + store, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + m := project.NewWithDeps(project.Deps{Store: store, DefaultHarness: domain.HarnessCodex}) + repo := gitRepo(t) + + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao")}); err != nil { + t.Fatalf("Add: %v", err) + } + + got, err := m.Get(ctx, "ao") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Project == nil { + t.Fatalf("Get returned no project: %#v", got) + } + if got.Project.Agent != "codex" { + t.Fatalf("default agent = %q, want codex", got.Project.Agent) + } +} + func TestManager_AddDetectsNonMainDefaultBranch(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/session/claim_pr.go b/backend/internal/service/session/claim_pr.go index 54070e1dc..a0818102f 100644 --- a/backend/internal/service/session/claim_pr.go +++ b/backend/internal/service/session/claim_pr.go @@ -47,9 +47,11 @@ type ClaimPRResult struct { // ListPRs returns all PRs currently owned by a session, ordered for display. func (s *Service) ListPRs(ctx context.Context, id domain.SessionID) ([]domain.PRFacts, error) { - if _, ok, err := s.store.GetSession(ctx, id); err != nil { + _, ok, err := s.store.GetSession(ctx, id) + if err != nil { return nil, fmt.Errorf("get %s: %w", id, err) - } else if !ok { + } + if !ok { return nil, apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") } return s.listPRFacts(ctx, id) diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 4a808c24a..9fc3af565 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -97,7 +97,9 @@ type Store interface { // presence of any row is the marker; preserved_ref may be empty for clean // worktrees. ListSessionWorktrees(ctx context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) - // DeleteSessionWorktrees clears the "shutdown-saved" restore marker. + // DeleteSessionWorktrees consumes stale shutdown-restore markers. Explicit + // Kill and successful RestoreAll must remove these rows to prevent + // resurrecting sessions the user intentionally terminated. DeleteSessionWorktrees(ctx context.Context, id domain.SessionID) error } @@ -450,11 +452,8 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { if err := m.lcm.MarkTerminated(ctx, id); err != nil { return false, fmt.Errorf("kill %s: %w", id, err) } - - // Clear the restore marker so the next boot's RestoreAll cannot resurrect a - // killed session (#2319). Best-effort: teardown below still matters. if err := m.store.DeleteSessionWorktrees(ctx, id); err != nil { - m.logger.Warn("kill: delete restore marker failed", "sessionID", id, "error", err) + return false, fmt.Errorf("kill %s: delete restore marker: %w", id, err) } // Only tear down what exists. A session may have lost its handle after a @@ -849,12 +848,10 @@ func (m *Manager) RestoreAll(ctx context.Context) error { } else { m.logger.Error("restore-all: relaunch failed", "sessionID", rec.ID, "error", err) } + continue } - - // One-shot: drop the consumed marker so it never outlives one restart - // (#2319). A still-live session re-acquires it at the next quit. if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { - m.logger.Warn("restore-all: delete restore marker failed", "sessionID", rec.ID, "error", err) + m.logger.Error("restore-all: delete consumed worktree marker failed", "sessionID", rec.ID, "error", err) } } return nil diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 261881cd0..679f7a6e5 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -564,6 +564,25 @@ func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) { } } +func TestKill_DeletesStaleRestoreMarker(t *testing.T) { + m, st, _, _ := newManager() + st.sessions["mer-1"] = mkLive("mer-1") + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/tmp/wt"}, + } + + freed, err := m.Kill(ctx, "mer-1") + if err != nil { + t.Fatalf("Kill: %v", err) + } + if !freed { + t.Fatal("Kill freed = false, want true") + } + if rows := st.worktrees["mer-1"]; len(rows) != 0 { + t.Fatalf("stale restore marker = %+v, want deleted", rows) + } +} + // TestKill_OtherWorkspaceErrorStillFails: only the typed dirty refusal is a // success-with-preserved-workspace; any other teardown failure keeps erroring. func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { @@ -574,29 +593,6 @@ func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) { t.Fatalf("kill err = %v, want workspace error surfaced", err) } } - -// TestKill_DeletesRestoreMarker covers issue #2319 (a): a user kill is explicit -// terminal intent and must delete the session_worktrees "shutdown-saved" marker. -// A session that carried a marker (e.g. it survived a prior reopen cycle) and is -// then killed must not keep that marker, or the next boot's RestoreAll would -// resurrect it. -func TestKill_DeletesRestoreMarker(t *testing.T) { - m, st, _, _ := newManager() - st.sessions["mer-1"] = mkLive("mer-1") - // The session carries a leftover shutdown-saved marker from a prior cycle. - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - if _, err := m.Kill(ctx, "mer-1"); err != nil { - t.Fatalf("kill err = %v", err) - } - rows, err := st.ListSessionWorktrees(ctx, "mer-1") - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("kill must delete the restore marker, got %d rows", len(rows)) - } -} func TestRestore_ReopensTerminal(t *testing.T) { m, st, rt, _ := newManager() seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"}) @@ -1580,6 +1576,33 @@ func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) { } } +func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) { + m, st, rt, _ := newLifecycleManager() + + st.sessions["mer-1"] = domain.SessionRecord{ + ID: "mer-1", + ProjectID: "mer", + Kind: domain.KindWorker, + Harness: domain.HarnessClaudeCode, + IsTerminated: true, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, + Activity: domain.Activity{State: domain.ActivityExited}, + } + st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{ + {SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/ws/mer-1"}, + } + + if err := m.RestoreAll(ctx); err != nil { + t.Fatalf("RestoreAll err = %v", err) + } + if rt.created != 1 { + t.Fatalf("RestoreAll must relaunch session, runtime.Create called %d times", rt.created) + } + if rows := st.worktrees["mer-1"]; len(rows) != 0 { + t.Fatalf("consumed restore marker = %+v, want deleted", rows) + } +} + // TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session // the user killed BEFORE shutdown has no session_worktrees row and must NOT // be resurrected. @@ -1611,81 +1634,6 @@ func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) { } } -// TestRestoreAll_DeletesMarkerAfterRelaunch covers issue #2319 (b): the -// shutdown-saved marker is one-shot. After RestoreAll relaunches a session, its -// session_worktrees marker is deleted, so a second RestoreAll (with no fresh -// marker) does NOT relaunch it again. -func TestRestoreAll_DeletesMarkerAfterRelaunch(t *testing.T) { - m, st, rt, _ := newLifecycleManager() - - st.sessions["mer-1"] = domain.SessionRecord{ - ID: "mer-1", - ProjectID: "mer", - Kind: domain.KindWorker, - Harness: domain.HarnessClaudeCode, - IsTerminated: true, - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, - Activity: domain.Activity{State: domain.ActivityExited}, - } - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) - } - rows, err := st.ListSessionWorktrees(ctx, "mer-1") - if err != nil { - t.Fatal(err) - } - if len(rows) != 0 { - t.Fatalf("RestoreAll must delete the one-shot marker, got %d rows", len(rows)) - } -} - -// TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot covers issue #2319 (c), -// the killed-session-resurrection scenario. A terminated session WITH a marker -// is relaunched exactly once; on a second RestoreAll (no new marker) it stays -// terminated and is not relaunched again. -func TestRestoreAll_KilledSessionNotResurrectedOnSecondBoot(t *testing.T) { - m, st, rt, _ := newLifecycleManager() - - st.sessions["mer-1"] = domain.SessionRecord{ - ID: "mer-1", - ProjectID: "mer", - Kind: domain.KindWorker, - Harness: domain.HarnessClaudeCode, - IsTerminated: true, - Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"}, - Activity: domain.Activity{State: domain.ActivityExited}, - } - st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__"}} - - // First boot: marker present, session relaunches once. - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("first RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("first RestoreAll must relaunch once, runtime.Create called %d times", rt.created) - } - - // Simulate the user killing the relaunched session before the next quit, so - // it has no fresh marker, then a second boot. - if _, err := m.Kill(ctx, "mer-1"); err != nil { - t.Fatalf("kill err = %v", err) - } - if err := m.RestoreAll(ctx); err != nil { - t.Fatalf("second RestoreAll err = %v", err) - } - if rt.created != 1 { - t.Fatalf("killed session must NOT be resurrected on second boot, runtime.Create total = %d, want 1", rt.created) - } - if !st.sessions["mer-1"].IsTerminated { - t.Error("killed session must remain terminated after second RestoreAll") - } -} - // TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a // non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace // restore but before relaunching. diff --git a/docs/README.md b/docs/README.md index 073951ec3..1ecef2359 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,6 @@ Start with [architecture.md](architecture.md) for the current backend model and | [architecture.md](architecture.md) | Current backend model, package layout, status derivation, persistence/CDC, and load-bearing rules. | | [backend-code-structure.md](backend-code-structure.md) | Package ownership rules for the Go backend: domain, services, ports, adapters, storage, HTTP, CLI, and daemon wiring. | | [cli/README.md](cli/README.md) | CLI commands and daemon control surface. | -| [agent/README.md](agent/README.md) | Agent adapter contract, hook methodology, and session-info derivation. | | [STATUS.md](STATUS.md) | What is shipped on `main` today and what is still in flight. | | [stack.md](stack.md) | Accepted library/runtime choices, pending stack decisions, and dependencies explicitly avoided for V1. | diff --git a/docs/agent/README.md b/docs/agent/README.md deleted file mode 100644 index eca64de39..000000000 --- a/docs/agent/README.md +++ /dev/null @@ -1,950 +0,0 @@ -# Agent Adapter Contract - -This document defines the contract between Agent Orchestrator and CLI-based coding agent adapters. Every agent must implement the contract defined in `backend/internal/ports/agent.go` to be usable by AO. - -## Table of Contents - -- [Overview](#overview) -- [Agent Contract](#agent-contract) -- [Hook System](#hook-system) -- [Session Metadata](#session-metadata) -- [Agent Lifecycle](#agent-lifecycle) -- [Implementation Guide](#implementation-guide) -- [Supported Agents](#supported-agents) -- [Testing](#testing) - ---- - -## Overview - -Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. The adapter contract provides: - -- **Agent discovery** — Find and validate the agent binary -- **Launch configuration** — Build the native agent command -- **Session restoration** — Resume existing sessions -- **Hook integration** — Install and manage agent hooks -- **Metadata extraction** — Surface session title, summary, and native session ID - -```mermaid -graph TB - AO[Agent Orchestrator] -->|uses| Adapter[Agent Adapter] - Adapter -->|implements| Contract[ports.Agent Contract] - - Contract --> Methods[Required Methods] - Methods --> GetConfig[GetConfigSpec] - Methods --> GetLaunch[GetLaunchCommand] - Methods --> GetStrategy[GetPromptDeliveryStrategy] - Methods --> GetHooks[GetAgentHooks] - Methods --> GetRestore[GetRestoreCommand] - Methods --> SessionInfo[SessionInfo] - Methods --> Uninstall[UninstallHooks] - - Adapter --> Agent[Native CLI Agent] - Adapter --> Hooks[Agent Hook Config] - -``` - ---- - -## Agent Contract - -### Port Interface - -The `ports.Agent` interface (defined in `backend/internal/ports/agent.go`) specifies the required adapter methods: - -```go -// Agent is the interface all coding agent adapters must implement. -type Agent interface { - // GetConfigSpec describes user-facing agent configuration. - GetConfigSpec() ConfigSpec - - // GetLaunchCommand builds the native agent command for spawning a new session. - GetLaunchCommand(ctx context.Context, cfg LaunchConfig) ([]string, error) - - // GetPromptDeliveryStrategy reports how the prompt is delivered. - GetPromptDeliveryStrategy() PromptDeliveryStrategy - - // GetAgentHooks installs or merges AO hooks into the agent's workspace-local config. - GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error - - // GetRestoreCommand builds a command to resume an existing session. - GetRestoreCommand(ctx context.Context, cfg RestoreConfig) ([]string, bool, error) - - // SessionInfo returns normalized session metadata from the session record. - SessionInfo(ctx context.Context, session SessionRef) (SessionInfo, bool, error) - - // UninstallHooks removes AO hooks from the workspace. - UninstallHooks(ctx context.Context, workspacePath string) error -} -``` - -### Method Details - -#### GetConfigSpec() - -Returns a specification of user-facing configuration options: - -```go -type ConfigSpec struct { - // Permissions is the permission mode the agent supports. - Permissions PermissionMode - - // Model is the optional model identifier (e.g., "claude-3-5-sonnet-20241022"). - Model string - - // SupportsSystemPrompt indicates the agent accepts a system prompt. - SupportsSystemPrompt bool - - // SupportsRestore indicates the agent supports session restoration. - SupportsRestore bool -} -``` - -#### GetLaunchCommand() - -Builds the native agent launch command: - -```mermaid -flowchart LR - Input[LaunchConfig] --> Validate[Validate Config] - Validate --> Resolve[Resolve Binary] - Resolve --> Build[Build Command] - Build --> Apply[Apply Permissions] - Apply --> Model[Add Model Flag] - Model --> System[Add System Prompt] - System --> Prompt[Add Prompt] - Prompt --> Output[Launch Command] - -``` - -**Input (`LaunchConfig`):** - -- `SessionID` — AO session identifier -- `ProjectID` — AO project identifier -- `Prompt` — User prompt to send -- `Config` — Agent-specific configuration -- `Permissions` — Permission mode override -- `WorkspacePath` — Path to session worktree - -**Output:** Executable command line as argv array - -#### GetPromptDeliveryStrategy() - -Reports how the prompt is delivered to the agent: - -```go -type PromptDeliveryStrategy string - -const ( - // StrategyArgv means the prompt is passed as a command-line argument. - StrategyArgv PromptDeliveryStrategy = "argv" - - // StrategyStdin means the prompt is written to stdin after launch. - StrategyStdin PromptDeliveryStrategy = "stdin" -) -``` - -#### GetAgentHooks() - -Installs AO hooks into the agent's workspace-local configuration: - -```mermaid -flowchart TD - Input[WorkspaceHookConfig] --> Read[Read Existing Config] - Read --> Merge[Merge AO Hooks] - Merge --> Dedup[Deduplicate Entries] - Dedupe --> Preserve[Preserve User Hooks] - Preserve --> Write[Write Updated Config] - Write --> Gitignore[Update .gitignore] - -``` - -**Requirements:** - -- Preserve all user hooks -- Deduplicate AO hook entries -- Make AO hooks machine-portable (use `ao hooks ...` command) -- Ensure all written files are covered by `.gitignore` - -#### GetRestoreCommand() - -Builds a command to resume an existing session: - -**Input (`RestoreConfig`):** - -- `Session` — Complete session record with metadata -- `Permissions` — Permission mode to apply -- `SystemPrompt` — System prompt to re-apply - -**Output:** - -- `cmd` — Restore command argv -- `ok` — True if restore is supported -- `err` — Error if restore fails - -#### SessionInfo() - -Returns normalized session metadata from the stored session record: - -```go -type SessionInfo struct { - // AgentSessionID is the native agent's session identifier. - AgentSessionID string - - // Title is the display title derived from the first user prompt. - Title string - - // Summary is the display summary derived from the final assistant message. - Summary string - - // Metadata contains adapter-specific extra fields. - Metadata map[string]any -} -``` - -**Important:** `SessionInfo` must read from the `session.Metadata` map (populated by hooks), not from scanning agent transcript/cache files. - -#### UninstallHooks() - -Removes AO hooks from the workspace while preserving user hooks. - ---- - -## Hook System - -### Hook Overview - -AO uses agent hooks to receive activity signals and session metadata from running agents: - -```mermaid -sequenceDiagram - participant AO as AO Daemon - participant Adapter as Agent Adapter - participant Agent as Native Agent - participant Hook as Hook Command - - Note over AO: Spawning session - AO->>Adapter: GetAgentHooks(config) - Adapter->>Hook: Write hook config - Hook-->>Adapter: Config written - - AO->>Agent: Launch agent - Agent->>Agent: Running in worktree - - Note over Agent: Agent triggers event - Agent->>Hook: Execute hook callback - Hook->>Hook: Read stdin payload - Hook->>AO: POST /api/v1/sessions/{id}/activity - AO->>AO: Update activity_state - AO->>Hook: 200 OK - Hook-->>Agent: Exit 0 - - Agent->>Agent: Continue work -``` - -### Hook Events - -AO supports hooks for these agent events: - -| Event | Purpose | Activity State | -| ------------------ | --------------------- | ------------------ | -| `SessionStart` | Session initialized | - | -| `UserPromptSubmit` | User submitted prompt | `active` | -| `AssistantMessage` | Assistant response | - | -| `ToolUse` | Agent used a tool | `active` | -| `Stop` | Session stopped | `idle` or `exited` | - -### Hook Contract - -All hook callbacks follow this contract: - -```bash -# Hook command format -ao hooks <agent-adapter> <event> <session-id> - -# Input (stdin) -# JSON payload from agent (agent-specific format) - -# Output (stdout) -# None (discarded) - -# Exit code -# 0 = success (always, even on delivery failure) -``` - -### Hook Behavior - -```mermaid -flowchart TD - Trigger[Agent Event Triggered] --> Execute[Execute Hook Command] - Execute --> ReadEnv[Read AO_SESSION_ID<br/>from environment] - ReadEnv --> CheckSession{Is AO Session?} - CheckSession -->|No| Exit[Exit 0 - ignore] - CheckSession -->|Yes| ReadStdin[Read JSON Payload<br/>from stdin] - ReadStdin --> Derive[Derive Activity State<br/>from event] - Derive --> Post[POST to Daemon<br/>/api/v1/sessions/{id}/activity] - Post --> Success{Success?} - Success -->|Yes| Update[Daemon updates<br/>activity_state] - Success -->|No| Log[Log to hooks.log<br/>under AO_DATA_DIR] - Update --> Exit - Log --> Exit - -``` - -**Critical rules:** - -1. **Always exit 0** — Hook failure must never break the user's agent -2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR` -3. **Best-effort delivery** — The daemon may be temporarily unavailable -4. **Idempotent** — Duplicate hook deliveries are safe - -### Hook Installation Patterns - -Different agents use different hook mechanisms: - -#### Claude Code / Compatible Agents - -```json -// .claude/hooks.json -{ - "SessionStart": ["ao hooks claude-code SessionStart"], - "UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"], - "Stop": ["ao hooks claude-code Stop"] -} -``` - -#### Factory Droid - -```json -// .factory/hooks.json -{ - "hooks": [ - { - "event": "agent:beforeThinking", - "command": "ao hooks droid beforeThinking" - }, - { - "event": "agent:afterThinking", - "command": "ao hooks droid afterThinking" - } - ] -} -``` - -#### Codex (Session Flags) - -```bash -# Codex passes hooks as session flags -codex -c 'hooks.SessionStart=["ao hooks codex SessionStart"]' \ - -c 'hooks.Stop=["ao hooks codex Stop"]' \ - --dangerously-bypass-hook-trust -``` - ---- - -## Session Metadata - -### Metadata Keys - -Hook callbacks persist normalized keys in the session metadata JSON blob: - -```go -const ( - // MetadataKeyAgentSessionID is the native agent's session identifier. - MetadataKeyAgentSessionID = "agentSessionId" - - // MetadataKeyTitle is the display title from the first user prompt. - MetadataKeyTitle = "title" - - // MetadataKeySummary is the display summary from the final assistant message. - MetadataKeySummary = "summary" -) -``` - -### Metadata Flow - -```mermaid -sequenceDiagram - participant Hook as Hook Callback - participant Daemon as AO Daemon - participant Store as SQLite Store - participant UI as Dashboard - - Note over Hook: User submits first prompt - Hook->>Daemon: POST /activity<br/>{state: "active", title: "..."} - Daemon->>Store: Update session metadata<br/>metadata.title = "..." - Store->>Store: CDC: session.updated - Store->>UI: SSE: session.updated - UI->>UI: Update session title - - Note over Hook: Agent finishes work - Hook->>Daemon: POST /activity<br/>{state: "idle", summary: "..."} - Daemon->>Store: Update session metadata<br/>metadata.summary = "..." - Store->>Store: CDC: session.updated - Store->>UI: SSE: session.updated - UI->>UI: Update session summary -``` - -### Metadata Persistence - -```mermaid -flowchart LR - Hook[Hook Callback] --> POST[POST Activity] - POST --> LCM[Lifecycle Manager] - LCM --> Update[Update Session Record] - Update --> Metadata[Set Metadata Fields] - Metadata --> CDC[CDC Broadcast] - CDC --> UI[Dashboard Updates] - -``` - ---- - -## Agent Lifecycle - -### Spawn Flow - -```mermaid -sequenceDiagram - participant User as User - participant UI as Dashboard - participant API as HTTP API - participant Mgr as Session Manager - participant Adapter as Agent Adapter - participant Runtime as Runtime - participant Agent as Agent Process - - User->>UI: Click "Spawn Session" - UI->>API: POST /sessions - API->>Mgr: Spawn(config) - - Note over Mgr: 1. Create session row - Mgr->>Mgr: Insert session with empty metadata - - Note over Mgr: 2. Prepare workspace - Mgr->>Adapter: GetAgentHooks(config) - Adapter->>Adapter: Write hook config - Adapter-->>Mgr: Hooks installed - - Note over Mgr: 3. Build launch command - Mgr->>Adapter: GetLaunchCommand(config) - Adapter-->>Mgr: Launch command argv - - Note over Mgr: 4. Execute in runtime - Mgr->>Runtime: Execute(agent command) - Runtime->>Agent: Spawn process - - Note over Agent: Agent starts - Agent->>Agent: Execute SessionStart hook - Agent->>API: POST /activity (agentSessionId) - API->>API: Update metadata - - Agent-->>Runtime: Running - Runtime-->>Mgr: Session active - Mgr-->>API: Session response - API-->>UI: Session created -``` - -### Restore Flow - -```mermaid -sequenceDiagram - participant User as User - participant UI as Dashboard - participant API as HTTP API - participant Mgr as Session Manager - participant Adapter as Agent Adapter - participant Runtime as Runtime - participant Agent as Agent Process - - User->>UI: Click "Restore Session" - UI->>API: POST /sessions/{id}/restore - API->>Mgr: Restore(session) - - Note over Mgr: Check adapter supports restore - Mgr->>Adapter: GetRestoreCommand(config) - - alt Restore supported - Adapter-->>Mgr: Restore command - Mgr->>Runtime: Execute(restore command) - Runtime->>Agent: Spawn process - Agent-->>Mgr: Running - Mgr-->>API: Session restored - else Restore not supported - Adapter-->>Mgr: (cmd, false, nil) - Mgr->>Mgr: Fresh spawn instead - Mgr-->>API: Session created (new) - end - - API-->>UI: Session response -``` - -### Activity Flow - -```mermaid -stateDiagram-v2 - [*] --> Spawning: Spawn() - Spawning --> Idle: Agent starts - Idle --> Active: User prompt - Active --> Active: Agent working - Active --> Waiting: Needs input - Waiting --> Active: User responds - Active --> Idle: Agent completes - Idle --> Exited: Agent exits - Active --> Exited: Agent crashes - Exited --> [*] - - note right of Active - Hook: UserPromptSubmit - Hook: ToolUse - activity_state = "active" - end note - - note right of Waiting - Hook: WaitingForInput - activity_state = "waiting_input" - end note - - note right of Idle - Hook: Stop - activity_state = "idle" - end note - - note right of Exited - Hook: Stop - activity_state = "exited" - is_terminated = true - end note -``` - ---- - -## Implementation Guide - -### Adapter Template - -```go -package myagent - -import ( - "context" - "fmt" -) - -// Plugin is the MyAgent adapter. -type Plugin struct { - // Adapter state (e.g., resolved binary path) -} - -// New returns a ready-to-register MyAgent adapter. -func New() *Plugin { - return &Plugin{} -} - -// GetConfigSpec describes the agent configuration. -func (p *Plugin) GetConfigSpec() ports.ConfigSpec { - return ports.ConfigSpec{ - Permissions: ports.PermissionReadWrite, // or ReadOnly, WriteOnly - SupportsSystemPrompt: true, - SupportsRestore: true, - } -} - -// GetLaunchCommand builds the native agent command. -func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ([]string, error) { - // 1. Validate config - if err := cfg.Config.Validate(); err != nil { - return nil, fmt.Errorf("myagent: %w", err) - } - - // 2. Resolve binary - binary, err := p.resolveBinary(ctx) - if err != nil { - return nil, err - } - - // 3. Build command - cmd := []string{binary} - - // Add flags (model, permissions, etc.) - if cfg.Config.Model != "" { - cmd = append(cmd, "--model", cfg.Config.Model) - } - - // Add system prompt if supported - if cfg.SystemPrompt != "" { - cmd = append(cmd, "--system-prompt", cfg.SystemPrompt) - } - - // Add prompt - if cfg.Prompt != "" { - cmd = append(cmd, "--prompt", cfg.Prompt) - } - - return cmd, nil -} - -// GetPromptDeliveryStrategy reports how the prompt is delivered. -func (p *Plugin) GetPromptDeliveryStrategy() ports.PromptDeliveryStrategy { - return ports.StrategyArgv // or StrategyStdin -} - -// GetAgentHooks installs AO hooks. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - // 1. Read existing config - config, err := p.readConfig(cfg.WorkspacePath) - if err != nil { - return err - } - - // 2. Add AO hooks (preserving user hooks) - config = p.addHooks(config, cfg.SessionID) - - // 3. Write updated config - if err := p.writeConfig(cfg.WorkspacePath, config); err != nil { - return err - } - - // 4. Ensure .gitignore coverage - if err := p.ensureGitignore(cfg.WorkspacePath); err != nil { - return err - } - - return nil -} - -// GetRestoreCommand builds a restore command. -func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { - // 1. Extract native session ID from metadata - sessionID := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID] - if sessionID == "" { - return nil, false, nil // Restore not supported - } - - // 2. Build restore command - binary, err := p.resolveBinary(ctx) - if err != nil { - return nil, false, err - } - - cmd := []string{ - binary, - "--resume", sessionID, - // Re-apply permissions and system prompt - } - - return cmd, true, nil -} - -// SessionInfo returns normalized metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - - // Return hasData=true if any field is populated - hasData := info.AgentSessionID != "" || info.Title != "" || info.Summary != "" - - return info, hasData, nil -} - -// UninstallHooks removes AO hooks. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - // Remove AO hook entries while preserving user hooks - config, err := p.readConfig(workspacePath) - if err != nil { - return err - } - - config = p.removeHooks(config) - - return p.writeConfig(workspacePath, config) -} -``` - -### Hook Implementation - -```go -// Add hooks to the agent's config -func (p *Plugin) addHooks(config map[string]any, sessionID string) map[string]any { - // Define AO hook commands - aoHooks := map[string]string{ - "SessionStart": "ao hooks myagent SessionStart", - "UserPromptSubmit": "ao hooks myagent UserPromptSubmit", - "Stop": "ao hooks myagent Stop", - } - - // Merge with existing config, preserving user hooks - for event, command := range aoHooks { - if config[event] == nil { - config[event] = []string{command} - } else { - // Deduplicate: check if AO hook already exists - hooks := config[event].([]string) - found := false - for _, h := range hooks { - if strings.HasPrefix(h, "ao hooks myagent") { - found = true - break - } - } - if !found { - config[event] = append(hooks, command) - } - } - } - - return config -} -``` - -### Gitignore Enforcement - -```go -import "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" - -func (p *Plugin) ensureGitignore(workspacePath string) error { - // Every file the adapter writes must be gitignored - filesToIgnore := []string{ - ".myagent/config.json", - ".myagent/hooks.json", - } - - return hookutil.EnsureWorkspaceGitignore(workspacePath, ".myagent/") -} -``` - ---- - -## Supported Agents - -### Current Adapters - -AO currently supports 23+ agent adapters: - -```mermaid -mindmap - root((Agent Adapters)) - Anthropic - Claude Code - OpenAI - Codex - Cursor - Cursor - OpenCode - OpenCode - Aider - Aider - Amp - Amp Code - Goose - Goose - GitHub - Copilot - xAI - Grok - Alibaba - Qwen Code - Moonshot - Kimi Code - Reverb - Crush - Cline - Cline - Factory - Droid - Devin - Augment - Auggie - Continue - Continue - Kiro - Kiro - Kilo - Kilo Code - Agy - Agy - Roo - Roo Code - Windsurf - Windsurf -``` - -### Adapter Locations - -``` -backend/internal/adapters/agent/ -├── agy/ -├── aider/ -├── amp/ -├── augie/ -├── claudecode/ -├── clone/ -├── codex/ -├── continue/ -├── cursor/ -├── devin/ -├── droid/ -├── grok/ -├── goose/ -├── kilo/ -├── kimi/ -├── kiro/ -├── opencode/ -├── qwen/ -├── roo/ -├── crush/ -├── windsurf/ -└── ... -``` - -### Adapter Capabilities - -| Agent | Permissions | System Prompt | Restore | Hooks | -| ----------- | ----------- | ------------- | ------- | ----------------- | -| Claude Code | ✓ | ✓ | ✓ | ✓ | -| Codex | ✓ | ✓ | ✓ | ✓ (session flags) | -| Cursor | ✓ | ✓ | ✗ | ✗ | -| OpenCode | ✓ | ✓ | ✗ | ✗ | -| Aider | ✓ | ✓ | ✓ | ✓ | -| Amp | ✓ | ✓ | ✓ | ✓ | -| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) | -| ... | ... | ... | ... | ... | - ---- - -## Testing - -### Unit Tests - -```go -func TestGetLaunchCommand(t *testing.T) { - p := New() - cfg := ports.LaunchConfig{ - SessionID: "test-session", - Prompt: "Write a function", - Config: domain.AgentConfig{ - Model: "gpt-4", - }, - } - - cmd, err := p.GetLaunchCommand(context.Background(), cfg) - assert.NoError(t, err) - assert.Contains(t, cmd, "myagent") - assert.Contains(t, cmd, "--model", "gpt-4") - assert.Contains(t, cmd, "Write a function") -} -``` - -### Integration Tests - -```go -func TestAgentE2E(t *testing.T) { - // Skip if agent not installed - if !agentInstalled(t) { - t.Skip("myagent not installed") - } - - // Create test project - ctx := context.Background() - project := createTestProject(t) - - // Spawn session - session := spawnSession(t, ctx, project.ID, "myagent", "Hello") - - // Wait for activity - waitForActivity(t, ctx, session.ID, "active") - - // Verify metadata - info := getSessionInfo(t, ctx, session.ID) - assert.NotEmpty(t, info.AgentSessionID) - assert.NotEmpty(t, info.Title) - - // Kill session - killSession(t, ctx, session.ID) -} -``` - -### Conformance Tests - -```go -// TestGetAgentHooksFootprintIsGitignored verifies all adapter-written -// files are covered by .gitignore. -func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { - registry := agentRegistry.New() - for _, adapter := range registry.List() { - t.Run(adapter, func(t *testing.T) { - tmpDir := t.TempDir() - cfg := ports.WorkspaceHookConfig{ - WorkspacePath: tmpDir, - SessionID: "test-session", - } - - agent := registry.Get(adapter) - err := agent.GetAgentHooks(context.Background(), cfg) - assert.NoError(t, err) - - // Verify all written files are gitignored - verifyGitignore(t, tmpDir) - }) - } -} -``` - -### Hook Tests - -```go -func TestHookDelivery(t *testing.T) { - // Mock daemon endpoint - server := mockActivityServer(t) - defer server.Close() - - // Simulate hook callback - payload := map[string]any{ - "state": "active", - "title": "Test session", - } - - cmd := exec.Command("ao", "hooks", "myagent", "UserPromptSubmit", "test-session") - cmd.Env = append(cmd.Env, - fmt.Sprintf("AO_SESSION_ID=%s", "test-session"), - fmt.Sprintf("AO_DAEMON_URL=%s", server.URL), - ) - - stdin, _ := cmd.StdinPipe() - json.NewEncoder(stdin).Encode(payload) - stdin.Close() - - err := cmd.Run() - assert.NoError(t, err) - - // Verify daemon received the activity - assert.ActivityReceived(t, server, "active") -} -``` - ---- - -## Summary - -**Key points:** - -1. **Port contract** — All agents implement `ports.Agent` -2. **Hooks are critical** — Activity signals and metadata flow through hooks -3. **Always gitignore** — Every adapter-written file must be covered -4. **Metadata from hooks** — `SessionInfo` reads from metadata, not files -5. **Preserve user hooks** — Never delete user configuration -6. **Exit 0 always** — Hook failure must never break the agent - -**Implementation checklist:** - -- [ ] Implement `ports.Agent` interface -- [ ] Write hook installation/removal -- [ ] Ensure all files are gitignored -- [ ] Implement `SessionInfo` from metadata -- [ ] Add unit tests -- [ ] Add integration tests -- [ ] Add conformance tests -- [ ] Register in daemon wiring diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8bef18801..fe960f8e0 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -4,6 +4,40 @@ */ export interface paths { + "/api/v1/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return cached supported and locally installed agent adapters */ + get: operations["listAgents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/agents/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh the cached local agent adapter catalog */ + post: operations["refreshAgents"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/events": { parameters: { query?: never; @@ -512,6 +546,15 @@ export interface components { model?: string; permissions?: string; }; + AgentInfo: { + /** + * @description Advisory local auth probe result. authorized means a recent local probe passed; spawn remains the authoritative validation point. + * @enum {string} + */ + authStatus?: "authorized" | "unauthorized" | "unknown"; + id: string; + label: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -587,6 +630,14 @@ export interface components { ok: boolean; sessionId: string; }; + ListAgentsResponse: { + /** @description Compatibility list of installed agents whose local auth probe recently returned authorized. Advisory and stale-prone; spawn may still fail. */ + authorized: components["schemas"]["AgentInfo"][]; + /** @description Agents whose binary resolved during the latest best-effort local catalog probe. */ + installed: components["schemas"]["AgentInfo"][]; + /** @description Agents supported by this daemon build. */ + supported: components["schemas"]["AgentInfo"][]; + }; ListNotificationsResponse: { notifications: components["schemas"]["NotificationResponse"][]; }; @@ -937,6 +988,82 @@ export interface components { } export type $defs = Record<string, never>; export interface operations { + listAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; + refreshAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAgentsResponse"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; streamEvents: { parameters: { query?: { diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx index 6101d11e0..4c0f08697 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.test.tsx @@ -1,17 +1,36 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; +import { agentsQueryKey } from "../hooks/useAgentsQuery"; import { CreateProjectAgentSheet } from "./CreateProjectAgentSheet"; function renderSheet(onSubmit = vi.fn().mockResolvedValue(undefined)) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(agentsQueryKey, { + supported: [ + { id: "claude-code", label: "claude-code" }, + { id: "codex", label: "codex" }, + ], + installed: [ + { id: "claude-code", label: "claude-code", authStatus: "authorized" }, + { id: "codex", label: "codex", authStatus: "authorized" }, + ], + authorized: [ + { id: "claude-code", label: "claude-code", authStatus: "authorized" }, + { id: "codex", label: "codex", authStatus: "authorized" }, + ], + }); render( - <CreateProjectAgentSheet - isCreating={false} - onOpenChange={() => undefined} - onSubmit={onSubmit} - open={true} - path="/repo/new-project" - />, + <QueryClientProvider client={queryClient}> + <CreateProjectAgentSheet + isCreating={false} + onOpenChange={() => undefined} + onSubmit={onSubmit} + open={true} + path="/repo/new-project" + /> + </QueryClientProvider>, ); return onSubmit; } diff --git a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx index 5f9e1a67a..745ac9a72 100644 --- a/frontend/src/renderer/components/CreateProjectAgentSheet.tsx +++ b/frontend/src/renderer/components/CreateProjectAgentSheet.tsx @@ -1,15 +1,19 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as Dialog from "@radix-ui/react-dialog"; -import { X } from "lucide-react"; +import { TriangleAlert, X } from "lucide-react"; import { memo, useEffect, useState } from "react"; -import { AGENT_OPTIONS, DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; +import type { components } from "../../api/schema"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; +import { AGENT_OPTIONS } from "../lib/agent-options"; import { buildIntake, type IntakeForm, IntakeFields, intakeNeedsRule } from "./IntakeFields"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; -import type { components } from "../../api/schema"; type TrackerIntakeConfig = components["schemas"]["TrackerIntakeConfig"]; +type AgentInfo = components["schemas"]["AgentInfo"]; + export type CreateProjectAgentSelection = { workerAgent: string; orchestratorAgent: string; @@ -35,16 +39,36 @@ export function CreateProjectAgentSheet({ open, path, }: CreateProjectAgentSheetProps) { - const [workerAgent, setWorkerAgent] = useState<string>(DEFAULT_PROJECT_AGENT); - const [orchestratorAgent, setOrchestratorAgent] = useState<string>(DEFAULT_PROJECT_AGENT); + const queryClient = useQueryClient(); + const agentsQuery = useQuery({ + ...agentsQueryOptions, + enabled: open, + }); + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); + const agents = agentsQuery.data; + const installedAgents = agents?.installed ?? []; + const agentOptions = agents?.authorized ?? []; + const supportedAgents = agents?.supported ?? []; + const isLoadingAgents = agents === undefined && agentsQuery.isFetching; + const agentsError = agentsQuery.isError + ? agentsQuery.error instanceof Error + ? agentsQuery.error.message + : "Could not load agent catalog." + : null; + const [workerAgent, setWorkerAgent] = useState(""); + const [orchestratorAgent, setOrchestratorAgent] = useState(""); const [intake, setIntake] = useState<IntakeForm>(EMPTY_INTAKE); const intakeIncomplete = intakeNeedsRule(intake); - const canSubmit = workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating; + const canSubmit = + workerAgent !== "" && orchestratorAgent !== "" && !intakeIncomplete && !isCreating && !isLoadingAgents; useEffect(() => { if (!open) { - setWorkerAgent(DEFAULT_PROJECT_AGENT); - setOrchestratorAgent(DEFAULT_PROJECT_AGENT); + setWorkerAgent(""); + setOrchestratorAgent(""); setIntake(EMPTY_INTAKE); } }, [open, path]); @@ -86,6 +110,10 @@ export function CreateProjectAgentSheet({ label="Worker agent" placeholder="Select worker agent" value={workerAgent} + authorized={agentOptions} + installed={installedAgents} + supported={supportedAgents} + disabled={isLoadingAgents} onChange={setWorkerAgent} /> <RequiredAgentField @@ -93,10 +121,49 @@ export function CreateProjectAgentSheet({ label="Orchestrator agent" placeholder="Select orchestrator agent" value={orchestratorAgent} + authorized={agentOptions} + installed={installedAgents} + supported={supportedAgents} + disabled={isLoadingAgents} onChange={setOrchestratorAgent} /> </div> + {isLoadingAgents && <p className="text-[12px] leading-5 text-muted-foreground">Loading agents...</p>} + + <div className="flex items-center justify-between gap-3 text-[12px] leading-5 text-muted-foreground"> + <span>Agent availability is cached.</span> + <button + type="button" + className="shrink-0 rounded text-foreground underline-offset-2 hover:underline disabled:pointer-events-none disabled:opacity-50" + disabled={refreshAgentsMutation.isPending} + onClick={() => refreshAgentsMutation.mutate()} + > + {refreshAgentsMutation.isPending ? "Refreshing..." : "Refresh agents"} + </button> + </div> + + {agentsError && ( + <div className="flex items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive"> + <span>{agentsError}</span> + <button + type="button" + className="shrink-0 rounded text-foreground underline-offset-2 hover:underline" + onClick={() => refreshAgentsMutation.mutate()} + > + Retry + </button> + </div> + )} + + {refreshAgentsMutation.isError && ( + <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive"> + {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} + </div> + )} + <div className="border-t border-border pt-4"> <IntakeFields form={intake} onChange={(patch) => setIntake((f) => ({ ...f, ...patch }))} compact /> </div> @@ -123,33 +190,78 @@ export function CreateProjectAgentSheet({ } export const RequiredAgentField = memo(function RequiredAgentField({ + authorized, + disabled = false, id, invalid = false, + installed, label, onChange, placeholder, + supported, value, }: { + authorized?: AgentInfo[]; + disabled?: boolean; id: string; invalid?: boolean; + installed?: AgentInfo[]; label: string; onChange: (value: string) => void; placeholder: string; + supported?: AgentInfo[]; value: string; }) { + const fallbackAgents: AgentInfo[] = AGENT_OPTIONS.map((agent) => ({ id: agent, label: agent })); + const supportedAgents = supported ?? fallbackAgents; + const installedAgents = installed ?? supportedAgents; + const authorizedAgents = authorized ?? supportedAgents; + const authorizedIds = new Set(authorizedAgents.map((agent) => agent.id)); + const installedById = new Map(installedAgents.map((agent) => [agent.id, agent])); + const options = supportedAgents + .map((agent) => { + const installedAgent = installedById.get(agent.id); + const authStatus = installedAgent?.authStatus; + const isAuthorized = authorizedIds.has(agent.id) || authStatus === "authorized"; + const isAuthUnknown = Boolean(installedAgent) && !isAuthorized && authStatus !== "unauthorized"; + const isSelectable = isAuthorized || isAuthUnknown; + const rank = isAuthorized ? 0 : isAuthUnknown ? 1 : installedAgent ? 2 : 3; + return { + ...agent, + disabled: !isSelectable, + rank, + reason: !installedAgent ? "Needs install" : isAuthUnknown ? "Auth unknown" : !isAuthorized ? "Needs auth" : "", + warning: isAuthUnknown, + }; + }) + .sort((a, b) => a.rank - b.rank || a.label.localeCompare(b.label) || a.id.localeCompare(b.id)); + return ( <div className="flex flex-col gap-1.5"> <Label htmlFor={id} className="text-[12px] font-medium text-muted-foreground"> {label} </Label> - <Select value={value} onValueChange={onChange}> + <Select value={value} onValueChange={onChange} disabled={disabled}> <SelectTrigger id={id} className="h-8 w-full text-[13px]" aria-invalid={invalid || undefined}> <SelectValue placeholder={placeholder} /> </SelectTrigger> - <SelectContent> - {AGENT_OPTIONS.map((agent) => ( - <SelectItem key={agent} value={agent}> - {agent} + <SelectContent position="popper" align="start" sideOffset={4} className="!max-h-80"> + {options.map((agent) => ( + <SelectItem + key={agent.id} + value={agent.id} + disabled={agent.disabled} + className="[&>span:last-child]:w-full" + > + <span className="flex min-w-0 w-full items-center justify-between gap-4"> + <span className="truncate">{agent.label}</span> + {agent.reason && ( + <span className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground"> + {agent.warning && <TriangleAlert className="size-3 text-warning" aria-hidden="true" />} + {agent.reason} + </span> + )} + </span> </SelectItem> ))} </SelectContent> diff --git a/frontend/src/renderer/components/NewTaskDialog.test.tsx b/frontend/src/renderer/components/NewTaskDialog.test.tsx index d7e480f5a..9ec8474bc 100644 --- a/frontend/src/renderer/components/NewTaskDialog.test.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.test.tsx @@ -16,7 +16,9 @@ vi.mock("../lib/api-client", () => ({ }, apiErrorMessage: (error: unknown, fallback = "Request failed") => { if (typeof error === "object" && error !== null && "message" in error) { - return String((error as { message: unknown }).message); + const body = error as { code?: unknown; message: unknown }; + const message = String(body.message); + return typeof body.code === "string" && body.code !== "" ? `${message} (${body.code})` : message; } return fallback; }, @@ -37,10 +39,37 @@ function spawnBody() { return (postMock.mock.calls[0][1] as { body: Record<string, unknown> }).body; } +async function waitForAgentCatalog() { + await waitFor(() => expect(screen.getAllByText("Claude Code").length).toBeGreaterThan(0)); +} + beforeEach(() => { - getMock.mockReset().mockResolvedValue({ - data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, - error: undefined, + getMock.mockReset().mockImplementation(async (path: string) => { + if (path === "/api/v1/agents") { + return { + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "kiro", label: "Kiro" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "authorized" }, + { id: "kiro", label: "Kiro", authStatus: "unknown" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "authorized" }, + ], + }, + error: undefined, + }; + } + return { + data: { status: "ok", project: { id: "proj-1", config: { worker: { agent: "claude-code" } } } }, + error: undefined, + }; }); postMock.mockReset().mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined }); }); @@ -52,7 +81,7 @@ describe("NewTaskDialog", () => { const { onCreated, onOpenChange } = renderDialog(); const user = userEvent.setup(); - await screen.findByText("claude-code"); + await waitForAgentCatalog(); await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); await user.type(screen.getByLabelText("Brief"), "Restore the fallback renderer after WebGL init fails."); @@ -71,18 +100,18 @@ describe("NewTaskDialog", () => { }); expect(onCreated).toHaveBeenCalledWith("task-1"); expect(onOpenChange).toHaveBeenCalledWith(false); - }); + }, 10_000); - it("sends the chosen harness when the user overrides the default, including agents beyond the legacy four", async () => { + it("sends the chosen harness when the user overrides the default", async () => { renderDialog(); const user = userEvent.setup(); - await screen.findByText("claude-code"); + await waitForAgentCatalog(); await user.type(screen.getByLabelText("Title"), "T"); await user.type(screen.getByLabelText("Brief"), "B"); await user.click(screen.getByRole("combobox", { name: "Agent" })); - await user.click(await screen.findByRole("option", { name: "cursor" })); + await user.click(await screen.findByRole("option", { name: "Cursor" })); await user.click(screen.getByRole("button", { name: "Start task" })); @@ -90,6 +119,25 @@ describe("NewTaskDialog", () => { expect(spawnBody().harness).toBe("cursor"); }); + it("allows selecting an installed agent with unknown auth", async () => { + renderDialog(); + const user = userEvent.setup(); + await waitForAgentCatalog(); + + await user.click(screen.getByRole("combobox", { name: "Agent" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["Claude Code", "Cursor", "KiroAuth unknown"]); + expect(options[2]).not.toHaveAttribute("aria-disabled", "true"); + await user.click(options[2]); + + await user.type(screen.getByLabelText("Title"), "T"); + await user.type(screen.getByLabelText("Brief"), "B"); + await user.click(screen.getByRole("button", { name: "Start task" })); + + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(spawnBody().harness).toBe("kiro"); + }); + it("requires both title and brief", async () => { renderDialog(); const user = userEvent.setup(); @@ -99,4 +147,33 @@ describe("NewTaskDialog", () => { expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument(); expect(postMock).not.toHaveBeenCalled(); }); + + it.each([ + { + code: "AGENT_BINARY_NOT_FOUND", + message: "agent binary not found on PATH", + }, + { + code: "RUNTIME_PREREQUISITE_MISSING", + message: "tmux required on macOS/Linux but not in PATH", + }, + { + code: "INTERNAL", + message: "runtime launch failed", + }, + ])("displays daemon spawn errors for $code", async ({ code, message }) => { + postMock.mockResolvedValueOnce({ + data: undefined, + error: { code, message }, + }); + renderDialog(); + const user = userEvent.setup(); + await waitForAgentCatalog(); + + await user.type(screen.getByLabelText("Title"), "Fix fallback renderer"); + await user.type(screen.getByLabelText("Brief"), "Restore fallback renderer."); + await user.click(screen.getByRole("button", { name: "Start task" })); + + expect(await screen.findByText(`${message} (${code})`)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index cef37d5b8..80a9bcddc 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -1,5 +1,5 @@ import * as Dialog from "@radix-ui/react-dialog"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, X } from "lucide-react"; import { type FormEvent, useEffect, useId, useState } from "react"; import { Button } from "./ui/button"; @@ -8,6 +8,7 @@ import { RequiredAgentField } from "./CreateProjectAgentSheet"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import type { AgentProvider } from "../types/workspace"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; type Project = components["schemas"]["Project"]; @@ -19,6 +20,7 @@ type NewTaskDialogProps = { }; export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewTaskDialogProps) { + const queryClient = useQueryClient(); const titleId = useId(); const promptId = useId(); const branchId = useId(); @@ -43,7 +45,16 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT return data.project as Project; }, }); + const agentsQuery = useQuery({ + ...agentsQueryOptions, + enabled: open, + }); + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); const defaultWorkerAgent = projectQuery.data?.config?.worker?.agent ?? ""; + const agentCatalog = agentsQuery.data; useEffect(() => { if (!open) { @@ -93,6 +104,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT onCreated(data.session.id); onOpenChange(false); } catch (err) { + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); setError(err instanceof Error ? err.message : "Unable to start task"); } finally { setIsSubmitting(false); @@ -150,16 +162,30 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT </div> <div className="grid gap-3 sm:grid-cols-[1fr_1fr]"> - <RequiredAgentField - id={agentId} - label="Agent" - placeholder="Project default" - value={agent} - onChange={(value) => { - setAgent(value); - setAgentTouched(true); - }} - /> + <div className="space-y-1.5"> + <RequiredAgentField + id={agentId} + label="Agent" + placeholder="Project default" + value={agent} + authorized={agentCatalog?.authorized} + installed={agentCatalog?.installed} + supported={agentCatalog?.supported} + disabled={agentsQuery.isFetching && agentCatalog === undefined} + onChange={(value) => { + setAgent(value); + setAgentTouched(true); + }} + /> + <button + type="button" + className="text-[12px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50" + disabled={refreshAgentsMutation.isPending} + onClick={() => refreshAgentsMutation.mutate()} + > + {refreshAgentsMutation.isPending ? "Refreshing agents..." : "Refresh agents"} + </button> + </div> <div className="space-y-1.5"> <label className="text-[12px] font-medium text-muted-foreground" htmlFor={branchId}> Branch @@ -179,6 +205,14 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT </div> )} + {refreshAgentsMutation.isError && ( + <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] text-destructive"> + {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} + </div> + )} + <div className="flex items-center justify-end gap-2 pt-1"> <Dialog.Close asChild> <Button type="button" variant="ghost" disabled={isSubmitting}> diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index 914790320..e1a58e340 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -44,6 +44,45 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { await userEvent.click(await screen.findByRole("option", { name: optionName })); } +const agentCatalogResponse = { + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + { id: "goose", label: "Goose" }, + { id: "kiro", label: "Kiro" }, + { id: "opencode", label: "OpenCode" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + { id: "goose", label: "Goose", authStatus: "authorized" }, + { id: "kiro", label: "Kiro", authStatus: "unknown" }, + { id: "opencode", label: "OpenCode", authStatus: "authorized" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + { id: "goose", label: "Goose", authStatus: "authorized" }, + { id: "opencode", label: "OpenCode", authStatus: "authorized" }, + ], + }, + error: undefined, +}; + +function mockProject(project: Record<string, unknown>) { + getMock.mockImplementation(async (path: string) => { + if (path === "/api/v1/agents") return agentCatalogResponse; + return { + data: { + status: "ok", + project, + }, + error: undefined, + }; + }); +} + beforeEach(() => { getMock.mockReset(); putMock.mockReset(); @@ -52,36 +91,30 @@ beforeEach(() => { describe("ProjectSettingsForm", () => { it("loads the current project settings and saves the exposed fields without dropping hidden config", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "git@github.com:acme/project-one.git", - defaultBranch: "main", - config: { - defaultBranch: "develop", - sessionPrefix: "po", - env: { FOO: "bar" }, - symlinks: [".env"], - postCreate: ["npm install"], - worker: { - agent: "codex", - agentConfig: { model: "worker-model" }, - }, - orchestrator: { agent: "claude-code" }, - agentConfig: { - model: "claude-opus-4-5", - permissions: "auto", - }, - reviewers: [{ harness: "claude-code" }], - }, + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "git@github.com:acme/project-one.git", + defaultBranch: "main", + config: { + defaultBranch: "develop", + sessionPrefix: "po", + env: { FOO: "bar" }, + symlinks: [".env"], + postCreate: ["npm install"], + worker: { + agent: "codex", + agentConfig: { model: "worker-model" }, }, + orchestrator: { agent: "claude-code" }, + agentConfig: { + model: "claude-opus-4-5", + permissions: "auto", + }, + reviewers: [{ harness: "claude-code" }], }, - error: undefined, }); renderSettings(); @@ -106,8 +139,8 @@ describe("ProjectSettingsForm", () => { await userEvent.type(screen.getByLabelText("Session prefix"), "rel"); await userEvent.clear(screen.getByLabelText("Model override")); await userEvent.type(screen.getByLabelText("Model override"), "gpt-5-codex"); - await chooseOption(workerAgent, "opencode"); - await chooseOption(orchestratorAgent, "goose"); + await chooseOption(workerAgent, "OpenCode"); + await chooseOption(orchestratorAgent, "Goose"); await chooseOption(permissionMode, "Bypass permissions"); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); @@ -139,23 +172,17 @@ describe("ProjectSettingsForm", () => { }, 20_000); it("shows the daemon validation message when save fails", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "", - defaultBranch: "main", - config: { - worker: { agent: "codex" }, - orchestrator: { agent: "claude-code" }, - }, - }, + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, }, - error: undefined, }); putMock.mockResolvedValue({ data: undefined, @@ -170,74 +197,59 @@ describe("ProjectSettingsForm", () => { expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); }); - it("offers every supported reviewer harness", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "", - defaultBranch: "main", - config: { - worker: { agent: "codex" }, - orchestrator: { agent: "claude-code" }, - }, - }, - }, - error: undefined, + it("requires worker and orchestrator agents for existing projects missing role config", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: {}, }); renderSettings(); - const reviewerAgent = await screen.findByRole("combobox", { name: "Default reviewer agent" }); - await userEvent.click(reviewerAgent); - for (const option of ["claude-code (default)", "claude-code", "codex", "opencode"]) { - expect(await screen.findByRole("option", { name: option })).toBeInTheDocument(); - } - }); - - it("defaults worker and orchestrator to claude-code for projects missing role config", async () => { - getMock.mockResolvedValue({ - data: { - status: "ok", - project: { - id: "proj-1", - name: "Project One", - kind: "single_repo", - path: "/repo/project-one", - repo: "", - defaultBranch: "main", - config: {}, - }, - }, - error: undefined, - }); - - renderSettings(); - - const workerAgent = await screen.findByRole("combobox", { name: "Default worker agent" }); - const orchestratorAgent = screen.getByRole("combobox", { name: "Default orchestrator agent" }); - expect(workerAgent).toHaveTextContent("claude-code"); - expect(orchestratorAgent).toHaveTextContent("claude-code"); - expect(screen.queryByText("Worker and orchestrator agents are required.")).not.toBeInTheDocument(); + expect(await screen.findByText("Worker and orchestrator agents are required.")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Default worker agent" })).toHaveTextContent("Select worker agent"); + expect(screen.getByRole("combobox", { name: "Default orchestrator agent" })).toHaveTextContent( + "Select orchestrator agent", + ); await userEvent.click(screen.getByRole("button", { name: "Save changes" })); - await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); - expect(putMock).toHaveBeenCalledWith("/api/v1/projects/{id}/config", { - params: { path: { id: "proj-1" } }, - body: { - config: { - defaultBranch: "main", - worker: { agent: "claude-code" }, - orchestrator: { agent: "claude-code" }, - }, + expect(await screen.findAllByText("Worker and orchestrator agents are required.")).toHaveLength(2); + expect(putMock).not.toHaveBeenCalled(); + }); + + it("shows unknown-auth agents as selectable with a warning in project settings", async () => { + mockProject({ + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, }, }); - expect(await screen.findByText("Saved.")).toBeInTheDocument(); + + renderSettings(); + + await waitFor(() => expect(screen.getAllByText("/repo/project-one").length).toBeGreaterThan(0)); + const workerAgent = screen.getByRole("combobox", { name: "Default worker agent" }); + await userEvent.click(workerAgent); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Claude Code", + "Codex", + "Goose", + "OpenCode", + "KiroAuth unknown", + ]); + expect(options[4]).not.toHaveAttribute("aria-disabled", "true"); }); it("saves GitHub tracker intake settings, deriving the repo from the project's git origin", async () => { diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index 59f30fc63..d50216dca 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; -import { DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; @@ -73,8 +73,8 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", sessionPrefix: config.sessionPrefix ?? "", - workerAgent: config.worker?.agent || DEFAULT_PROJECT_AGENT, - orchestratorAgent: config.orchestrator?.agent || DEFAULT_PROJECT_AGENT, + workerAgent: config.worker?.agent ?? "", + orchestratorAgent: config.orchestrator?.agent ?? "", model: config.agentConfig?.model ?? "", permissions: config.agentConfig?.permissions ?? "", reviewerHarness: config.reviewers?.[0]?.harness ?? "", @@ -85,6 +85,12 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje const [savedAt, setSavedAt] = useState<number | null>(null); const [validationError, setValidationError] = useState<string | null>(null); const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; + const agentsQuery = useQuery(agentsQueryOptions); + const agentCatalog = agentsQuery.data; + const refreshAgentsMutation = useMutation({ + mutationFn: refreshAgents, + onSuccess: (next) => queryClient.setQueryData(agentsQueryKey, next), + }); // The Electron app only registers git projects today, so the daemon always has a usable // git origin to derive owner/repo from (trackerRepo() in observer.go) when @@ -131,6 +137,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje }, onSuccess: () => { setSavedAt(Date.now()); + setValidationError(null); void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); onSaved(); }, @@ -201,6 +208,11 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje value={form.workerAgent} placeholder="Select worker agent" label="Default worker agent" + authorized={agentCatalog?.authorized} + installed={agentCatalog?.installed} + supported={agentCatalog?.supported} + disabled={agentsQuery.isFetching && agentCatalog === undefined} + invalid={validationError !== null && form.workerAgent === ""} onChange={(v) => setForm((f) => ({ ...f, workerAgent: v }))} /> <RequiredAgentField @@ -208,8 +220,34 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje value={form.orchestratorAgent} placeholder="Select orchestrator agent" label="Default orchestrator agent" + authorized={agentCatalog?.authorized} + installed={agentCatalog?.installed} + supported={agentCatalog?.supported} + disabled={agentsQuery.isFetching && agentCatalog === undefined} + invalid={validationError !== null && form.orchestratorAgent === ""} onChange={(v) => setForm((f) => ({ ...f, orchestratorAgent: v }))} /> + <div className="flex items-center justify-between gap-3 text-[12px] leading-5 text-muted-foreground"> + <span>Agent availability is cached.</span> + <button + type="button" + className="shrink-0 rounded text-foreground underline-offset-2 hover:underline disabled:pointer-events-none disabled:opacity-50" + disabled={refreshAgentsMutation.isPending} + onClick={() => refreshAgentsMutation.mutate()} + > + {refreshAgentsMutation.isPending ? "Refreshing..." : "Refresh agents"} + </button> + </div> + {refreshAgentsMutation.isError && ( + <p className="text-[12px] leading-5 text-error"> + {refreshAgentsMutation.error instanceof Error + ? refreshAgentsMutation.error.message + : "Could not refresh agent catalog."} + </p> + )} + {missingRequiredAgent && ( + <p className="text-[12px] leading-5 text-error">Worker and orchestrator agents are required.</p> + )} <Field label="Model override" htmlFor="model"> <input id="model" @@ -304,7 +342,7 @@ function ReviewerSelect({ id, value, onChange }: { id: string; value: string; on <SelectValue /> </SelectTrigger> <SelectContent> - <SelectItem value="__default__">claude-code (default)</SelectItem> + <SelectItem value="__default__">Project default</SelectItem> {REVIEWER_OPTIONS.map((reviewer) => ( <SelectItem key={reviewer} value={reviewer}> {reviewer} diff --git a/frontend/src/renderer/components/SessionsBoard.test.tsx b/frontend/src/renderer/components/SessionsBoard.test.tsx new file mode 100644 index 000000000..2b55fcb47 --- /dev/null +++ b/frontend/src/renderer/components/SessionsBoard.test.tsx @@ -0,0 +1,40 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { navigateMock, workspaceQueryMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + workspaceQueryMock: vi.fn(), +})); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../hooks/useWorkspaceQuery", () => ({ + useWorkspaceQuery: workspaceQueryMock, +})); + +import { SessionsBoard } from "./SessionsBoard"; + +function renderBoard() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + <QueryClientProvider client={queryClient}> + <SessionsBoard /> + </QueryClientProvider>, + ); +} + +beforeEach(() => { + navigateMock.mockReset(); + workspaceQueryMock.mockReset().mockReturnValue({ data: [], isError: false }); +}); + +describe("SessionsBoard", () => { + it("does not show an agent setup warning on the board", () => { + renderBoard(); + + expect(screen.queryByText(/reload agents/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 261ca406c..6e85b89fa 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,6 +1,8 @@ -import { useState, type KeyboardEvent } from "react"; +import { type KeyboardEvent, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; + +import { DashboardSubhead } from "./DashboardSubhead"; import { Plus } from "lucide-react"; import { type AttentionZone, @@ -11,7 +13,6 @@ import { } from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; -import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; @@ -271,13 +272,10 @@ function SessionCard({ session, onOpen }: { session: WorkspaceSession; onOpen: ( const showBranch = branch !== "" && !sameLabel(branch, session.title) && !sameLabel(branch, session.id); const prSummaries = sessionPRDisplaySummaries(session, useSessionScmSummary(session.id).data); const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { - if (event.target !== event.currentTarget) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onOpen(); - } + if (event.currentTarget !== event.target) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onOpen(); }; return ( <div diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index 9e9215230..e55e93ccd 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -5,8 +5,10 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sidebar } from "./Sidebar"; import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace"; +import { agentsQueryKey } from "../hooks/useAgentsQuery"; -const { navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({ +const { getMock, navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({ + getMock: vi.fn(), navigateMock: vi.fn(), mockParams: { projectId: undefined as string | undefined }, renameSessionMock: vi.fn().mockResolvedValue(undefined), @@ -19,12 +21,23 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { return { ...actual, useNavigate: () => navigateMock, - useParams: () => mockParams, + useParams: () => ({}), useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => unknown }) => select({ location: { pathname: "/" } }), }; }); +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock }, + apiErrorMessage: (error: unknown) => { + if (error instanceof Error) return error.message; + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message; + } + return "Request failed"; + }, +})); + const workspace: WorkspaceSummary = { id: "proj-1", name: "Project One", @@ -51,15 +64,33 @@ type RemoveProjectHandler = (projectId: string) => Promise<void>; function renderSidebar({ onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler, onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler, + seedAgents = true, workspaces = [workspace], }: { onCreateProject?: CreateProjectHandler; onRemoveProject?: RemoveProjectHandler; + seedAgents?: boolean; workspaces?: WorkspaceSummary[]; } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); + if (seedAgents) { + queryClient.setQueryData(agentsQueryKey, { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }); + } render( <QueryClientProvider client={queryClient}> <SidebarProvider> @@ -81,6 +112,24 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { } beforeEach(() => { + getMock.mockReset(); + getMock.mockResolvedValue({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }, + error: undefined, + }); navigateMock.mockReset(); renameSessionMock.mockReset().mockResolvedValue(undefined); mockParams.projectId = undefined; @@ -145,8 +194,8 @@ describe("Sidebar", () => { expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); const dialog = screen.getByRole("dialog", { name: "Project agents" }); expect(dialog).toHaveClass("left-1/2", "top-1/2", "-translate-x-1/2", "-translate-y-1/2"); - await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "codex"); - await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "claude-code"); + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); await user.click(screen.getByRole("button", { name: "Create and start" })); await waitFor(() => @@ -158,26 +207,102 @@ describe("Sidebar", () => { ); }); - it("opens global settings from the footer menu when no project is selected", async () => { + it("shows needs-auth agents as unavailable while keeping authorized agents selectable", async () => { const user = userEvent.setup(); - renderSidebar(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); + getMock.mockResolvedValueOnce({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "cursor", label: "Cursor" }, + { id: "aider", label: "Aider" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "cursor", label: "Cursor", authStatus: "unauthorized" }, + ], + authorized: [{ id: "claude-code", label: "Claude Code", authStatus: "authorized" }], + }, + error: undefined, + }); + renderSidebar({ onCreateProject, seedAgents: false }); - await user.click(screen.getAllByLabelText("Settings")[0]); - await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + await user.click(screen.getByLabelText("New project")); + expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); - expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + await user.click(screen.getByRole("combobox", { name: "Worker agent" })); + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Claude Code", + "CursorNeeds auth", + "AiderNeeds install", + ]); + expect(options[1]).toHaveAttribute("aria-disabled", "true"); + expect(options[2]).toHaveAttribute("aria-disabled", "true"); + await user.keyboard("{Escape}"); + + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Claude Code"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith(expect.objectContaining({ workerAgent: "claude-code" })), + ); }); - it("shows both project and global settings in the footer menu when a project is selected", async () => { - mockParams.projectId = "proj-1"; + it("updates project agent options when the catalog loads after the dialog opens", async () => { const user = userEvent.setup(); - renderSidebar(); + const onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler; + window.ao!.app.chooseDirectory = vi.fn().mockResolvedValue("/repo/new-project"); + let resolveAgents!: (value: { + data: { + supported: { id: string; label: string }[]; + installed: { id: string; label: string }[]; + authorized: { id: string; label: string; authStatus: "authorized" }[]; + }; + error: undefined; + }) => void; + getMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveAgents = resolve; + }), + ); + renderSidebar({ onCreateProject, seedAgents: false }); - await user.click(screen.getAllByLabelText("Settings")[0]); - expect(await screen.findByRole("menuitem", { name: "Project settings" })).toBeInTheDocument(); - await user.click(await screen.findByRole("menuitem", { name: "Global settings" })); + await user.click(screen.getByLabelText("New project")); + expect(await screen.findByText("/repo/new-project")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create and start" })).toBeDisabled(); - expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); + resolveAgents({ + data: { + supported: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + installed: [ + { id: "claude-code", label: "Claude Code" }, + { id: "codex", label: "Codex" }, + ], + authorized: [ + { id: "claude-code", label: "Claude Code", authStatus: "authorized" }, + { id: "codex", label: "Codex", authStatus: "authorized" }, + ], + }, + error: undefined, + }); + + await chooseOption(screen.getByRole("combobox", { name: "Worker agent" }), "Codex"); + await chooseOption(screen.getByRole("combobox", { name: "Orchestrator agent" }), "Claude Code"); + await user.click(screen.getByRole("button", { name: "Create and start" })); + + await waitFor(() => + expect(onCreateProject).toHaveBeenCalledWith({ + path: "/repo/new-project", + workerAgent: "codex", + orchestratorAgent: "claude-code", + }), + ); }); it("renames a session inline and persists via the daemon", async () => { diff --git a/frontend/src/renderer/components/ui/select.tsx b/frontend/src/renderer/components/ui/select.tsx index e8fd1f0be..c274cc642 100644 --- a/frontend/src/renderer/components/ui/select.tsx +++ b/frontend/src/renderer/components/ui/select.tsx @@ -65,11 +65,7 @@ function SelectContent({ > <SelectScrollUpButton /> <SelectPrimitive.Viewport - className={cn( - "p-1", - position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1", - )} + className={cn("p-1", position === "popper" && "w-full min-w-(--radix-select-trigger-width) scroll-my-1")} > {children} </SelectPrimitive.Viewport> diff --git a/frontend/src/renderer/hooks/useAgentsQuery.ts b/frontend/src/renderer/hooks/useAgentsQuery.ts new file mode 100644 index 000000000..6654e16b6 --- /dev/null +++ b/frontend/src/renderer/hooks/useAgentsQuery.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import type { components } from "../../api/schema"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; + +export type AgentCatalog = components["schemas"]["ListAgentsResponse"]; + +export const agentsQueryKey = ["agents"] as const; + +async function fetchAgents(): Promise<AgentCatalog> { + const { data, error } = await apiClient.GET("/api/v1/agents"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export async function refreshAgents(): Promise<AgentCatalog> { + const { data, error } = await apiClient.POST("/api/v1/agents/refresh"); + if (error) throw new Error(apiErrorMessage(error)); + return data as AgentCatalog; +} + +export const agentsQueryOptions = { + queryKey: agentsQueryKey, + queryFn: fetchAgents, + retry: 1, + staleTime: 5 * 60 * 1000, +}; + +export function useAgentsQuery() { + return useQuery(agentsQueryOptions); +} diff --git a/frontend/src/renderer/lib/agent-options.ts b/frontend/src/renderer/lib/agent-options.ts index 39ade7eae..d26c9a4c0 100644 --- a/frontend/src/renderer/lib/agent-options.ts +++ b/frontend/src/renderer/lib/agent-options.ts @@ -23,7 +23,3 @@ export const AGENT_OPTIONS = [ "pi", "autohand", ] as const; - -// The agent new projects use by default, and the fallback for worker/orchestrator -// role fields that have no explicit configuration. Users can change it per project. -export const DEFAULT_PROJECT_AGENT: (typeof AGENT_OPTIONS)[number] = "claude-code"; diff --git a/frontend/src/renderer/lib/api-client.test.ts b/frontend/src/renderer/lib/api-client.test.ts index f72096dd8..df7898253 100644 --- a/frontend/src/renderer/lib/api-client.test.ts +++ b/frontend/src/renderer/lib/api-client.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiClient, getApiBaseUrl, hasTrustedApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; +import { + apiClient, + apiErrorMessage, + getApiBaseUrl, + hasTrustedApiBaseUrl, + setApiBaseUrl, + subscribeApiBaseUrl, +} from "./api-client"; describe("apiClient runtime base URL", () => { afterEach(() => { @@ -154,3 +161,20 @@ describe("subscribeApiBaseUrl", () => { expect(listener).not.toHaveBeenCalled(); }); }); + +describe("apiErrorMessage", () => { + it("preserves daemon error codes next to human messages", () => { + expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe( + "agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)", + ); + }); + + it("does not duplicate a code that is already present in the message", () => { + expect( + apiErrorMessage({ + code: "RUNTIME_PREREQUISITE_MISSING", + message: "tmux required (RUNTIME_PREREQUISITE_MISSING)", + }), + ).toBe("tmux required (RUNTIME_PREREQUISITE_MISSING)"); + }); +}); diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 9afdf193d..8bc2551aa 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -93,8 +93,11 @@ export function apiErrorMessage(error: unknown, fallback = "Request failed"): st if (error instanceof Error) return error.message; if (typeof error === "string" && error !== "") return error; if (typeof error === "object" && error !== null) { - const body = error as { message?: unknown; error?: unknown }; - if (typeof body.message === "string" && body.message !== "") return body.message; + const body = error as { code?: unknown; message?: unknown; error?: unknown }; + const code = typeof body.code === "string" && body.code !== "" ? body.code : ""; + if (typeof body.message === "string" && body.message !== "") { + return code && !body.message.includes(code) ? `${body.message} (${code})` : body.message; + } if (typeof body.error === "string" && body.error !== "") return body.error; } return fallback; diff --git a/frontend/src/renderer/lib/spawn-orchestrator.test.ts b/frontend/src/renderer/lib/spawn-orchestrator.test.ts index 34475155d..a8748fd20 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.test.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.test.ts @@ -4,6 +4,14 @@ import { apiClient } from "./api-client"; vi.mock("./api-client", () => ({ apiClient: { POST: vi.fn() }, + apiErrorMessage: (error: unknown, fallback = "Request failed") => { + if (typeof error === "object" && error !== null && "message" in error) { + const body = error as { code?: unknown; message: unknown }; + const message = String(body.message); + return typeof body.code === "string" && body.code !== "" ? `${message} (${body.code})` : message; + } + return fallback; + }, })); describe("spawnOrchestrator", () => { @@ -33,4 +41,14 @@ describe("spawnOrchestrator", () => { body: { projectId: "proj", clean: false }, }); }); + + it("surfaces daemon spawn error messages and codes", async () => { + (apiClient.POST as ReturnType<typeof vi.fn>).mockResolvedValue({ + data: undefined, + error: { code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" }, + response: { status: 400 }, + }); + + await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)"); + }); }); diff --git a/frontend/src/renderer/lib/spawn-orchestrator.ts b/frontend/src/renderer/lib/spawn-orchestrator.ts index 1a0c2c81c..13d2f5533 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.ts @@ -1,4 +1,4 @@ -import { apiClient } from "./api-client"; +import { apiClient, apiErrorMessage } from "./api-client"; /** Spawn the project's orchestrator session via the daemon API. When clean is * true the daemon first tears down any active orchestrator for the project, then @@ -9,10 +9,9 @@ export async function spawnOrchestrator(projectId: string, clean = false): Promi }); if (error || !data?.orchestrator?.id) { - const message = - error && typeof error === "object" && "message" in error && typeof error.message === "string" - ? error.message - : `Failed to spawn orchestrator (${response.status})`; + const message = error + ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`) + : `Failed to spawn orchestrator (${response.status})`; throw new Error(message); } diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 6dcbb2c42..a6f300d71 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -1,10 +1,11 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; -import { type CSSProperties, useCallback, useEffect } from "react"; +import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; import { TitlebarNav } from "../components/TitlebarNav"; +import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { useDaemonStatus } from "../hooks/useDaemonStatus"; import { useWorkspaceQuery, workspaceQueryKey, workspaceQueryOptions } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; @@ -45,6 +46,7 @@ function ShellLayout() { const workspaceQuery = useWorkspaceQuery(); const workspaces = workspaceQuery.data ?? []; const daemonStatus = useDaemonStatus(queryClient); + const agentCatalogPortRef = useRef<number | undefined>(undefined); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); const updateWorkspaces = useCallback( @@ -67,6 +69,10 @@ function ShellLayout() { surface: "project_board", }); void captureRendererEvent("ao.renderer.project_add_requested"); + const status = await refreshDaemonStatus(); + if (status.state !== "ready" || !status.port) { + throw new Error(status.message || "AO daemon is not ready."); + } const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path, @@ -145,6 +151,16 @@ function ShellLayout() { document.documentElement.style.colorScheme = theme; }, [theme]); + useEffect(() => { + if (daemonStatus.state !== "ready" || !daemonStatus.port) return; + if (agentCatalogPortRef.current === daemonStatus.port) return; + + agentCatalogPortRef.current = daemonStatus.port; + void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); + void queryClient.fetchQuery({ ...agentsQueryOptions, queryFn: refreshAgents }); + void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + }, [daemonStatus.port, daemonStatus.state, queryClient]); + // Follow OS appearance only until the user picks a theme explicitly. useEffect(() => { if (readStoredTheme()) return; diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 0125bd75a..992db2685 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -124,6 +124,12 @@ describe("findProjectOrchestrator", () => { expect(findProjectOrchestrator([workspaceWith([dead, live, worker])], "skills")).toBe(live); }); + it("prefers the newest live orchestrator when multiple replacements overlap", () => { + const older = sessionWith({ id: "skills-4", kind: "orchestrator", status: "idle", provider: "claude-code" }); + const newer = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working", provider: "codex" }); + expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer); + }); + it("returns undefined when every orchestrator is terminated", () => { const dead = sessionWith({ id: "skills-4", kind: "orchestrator", status: "terminated" }); expect(findProjectOrchestrator([workspaceWith([dead])], "skills")).toBeUndefined(); diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index e8bb86f7e..d3e597fa4 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -241,7 +241,14 @@ export function findProjectOrchestrator( projectId: string, ): WorkspaceSession | undefined { const workspace = workspaces.find((w) => w.id === projectId); - return workspace?.sessions.find((session) => isOrchestratorSession(session) && sessionIsActive(session)); + if (!workspace) return undefined; + for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { + const session = workspace.sessions[i]; + if (isOrchestratorSession(session) && sessionIsActive(session)) { + return session; + } + } + return undefined; } export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] { From cc75a519ab2244534e47d8f766cf5778486976c9 Mon Sep 17 00:00:00 2001 From: NIKHIL ACHALE <96230495+nikhilachale@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:35:03 +0530 Subject: [PATCH 40/43] fix: replace orchestrators through safe canonical branch handoff (#2338) * fix: replace orchestrators through canonical handoff * fix(tests): correct logic in TestRetireForReplacementCapturesAndReleasesWorkspace * fix: enhance tests and add project config handling in service and workspace components * fix: update project summary to include orchestrator agent and adjust related tests * fix: block orchestrator replacement on runtime teardown failure * fix: enhance orchestrator handling in ProjectSettingsForm and SessionsBoard components * fix: rename parameter for clarity in projectConfigPtr function --- backend/internal/httpd/apispec/openapi.yaml | 2 + backend/internal/service/project/service.go | 24 ++-- .../internal/service/project/service_test.go | 26 ++++ backend/internal/service/project/types.go | 13 +- backend/internal/service/session/service.go | 112 +++++++++++++-- .../internal/service/session/service_test.go | 81 ++++++++++- backend/internal/session_manager/manager.go | 53 +++++++ .../internal/session_manager/manager_test.go | 130 +++++++++++++++++- frontend/src/api/schema.ts | 1 + .../OrchestratorReplacementDialog.tsx | 75 ++++++++++ .../components/ProjectSettingsForm.test.tsx | 115 +++++++++++++++- .../components/ProjectSettingsForm.tsx | 31 ++++- .../src/renderer/components/SessionsBoard.tsx | 52 +++++-- .../src/renderer/components/ShellTopbar.tsx | 11 +- frontend/src/renderer/components/Sidebar.tsx | 11 +- .../renderer/hooks/useWorkspaceQuery.test.tsx | 13 +- .../src/renderer/hooks/useWorkspaceQuery.ts | 1 + ...orchestrator-replacement-telemetry.test.ts | 26 ++++ .../lib/orchestrator-replacement-telemetry.ts | 10 ++ .../renderer/lib/restart-orchestrator.test.ts | 73 ++++++++++ .../src/renderer/lib/restart-orchestrator.ts | 52 +++++++ frontend/src/renderer/routes/_shell.tsx | 33 +++++ frontend/src/renderer/stores/ui-store.ts | 26 ++++ frontend/src/renderer/types/workspace.test.ts | 90 ++++++++++++ frontend/src/renderer/types/workspace.ts | 70 ++++++++-- 25 files changed, 1068 insertions(+), 63 deletions(-) create mode 100644 frontend/src/renderer/components/OrchestratorReplacementDialog.tsx create mode 100644 frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts create mode 100644 frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts create mode 100644 frontend/src/renderer/lib/restart-orchestrator.test.ts create mode 100644 frontend/src/renderer/lib/restart-orchestrator.ts diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index d3c628a46..2551c32ee 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -2069,6 +2069,8 @@ components: type: string name: type: string + orchestratorAgent: + type: string path: type: string resolveError: diff --git a/backend/internal/service/project/service.go b/backend/internal/service/project/service.go index c8d2e3d65..3f8da5cee 100644 --- a/backend/internal/service/project/service.go +++ b/backend/internal/service/project/service.go @@ -104,11 +104,12 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) { out := make([]Summary, 0, len(projects)) for _, row := range projects { out = append(out, Summary{ - ID: domain.ProjectID(row.ID), - Name: displayName(row), - Path: row.Path, - Kind: row.Kind.WithDefault(), - SessionPrefix: resolveSessionPrefix(row), + ID: domain.ProjectID(row.ID), + Name: displayName(row), + Path: row.Path, + Kind: row.Kind.WithDefault(), + SessionPrefix: resolveSessionPrefix(row), + OrchestratorAgent: row.Config.Orchestrator.Harness, }) } return out, nil @@ -390,13 +391,18 @@ func (m *Service) projectFromRow(row domain.ProjectRecord) Project { DefaultBranch: row.Config.WithDefaults().DefaultBranch, Agent: string(m.defaultHarness), } - if !row.Config.IsZero() { - cfg := row.Config - p.Config = &cfg - } + p.Config = projectConfigPtr(row.Config) return p } +func projectConfigPtr(projectConfig domain.ProjectConfig) *domain.ProjectConfig { + if projectConfig.IsZero() { + return nil + } + cfg := projectConfig + return &cfg +} + func displayName(row domain.ProjectRecord) string { if strings.TrimSpace(row.DisplayName) != "" { return row.DisplayName diff --git a/backend/internal/service/project/service_test.go b/backend/internal/service/project/service_test.go index f10dba67e..48e66a653 100644 --- a/backend/internal/service/project/service_test.go +++ b/backend/internal/service/project/service_test.go @@ -436,6 +436,32 @@ func TestManager_SetConfig(t *testing.T) { wantCode(t, err, "PROJECT_NOT_FOUND") } +func TestManager_ListIncludesOnlySummarySafeProjectConfig(t *testing.T) { + ctx := context.Background() + m := newManager(t) + repo := gitRepo(t) + + cfg := domain.ProjectConfig{ + DefaultBranch: "develop", + Env: map[string]string{"GITHUB_TOKEN": "secret"}, + Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex}, + } + if _, err := m.Add(ctx, project.AddInput{Path: repo, ProjectID: ptr("ao"), Config: &cfg}); err != nil { + t.Fatalf("Add: %v", err) + } + + list, err := m.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 { + t.Fatalf("List len = %d, want 1", len(list)) + } + if list[0].OrchestratorAgent != domain.HarnessCodex { + t.Fatalf("summary orchestrator agent = %q, want codex", list[0].OrchestratorAgent) + } +} + func TestManager_ReaddAfterRemove(t *testing.T) { ctx := context.Background() m := newManager(t) diff --git a/backend/internal/service/project/types.go b/backend/internal/service/project/types.go index f33b338d5..81e5da2be 100644 --- a/backend/internal/service/project/types.go +++ b/backend/internal/service/project/types.go @@ -4,12 +4,13 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain" // Summary is the row shape returned by GET /api/v1/projects. type Summary struct { - ID domain.ProjectID `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - Kind domain.ProjectKind `json:"kind"` - SessionPrefix string `json:"sessionPrefix"` - ResolveError string `json:"resolveError,omitempty"` + ID domain.ProjectID `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Kind domain.ProjectKind `json:"kind"` + SessionPrefix string `json:"sessionPrefix"` + OrchestratorAgent domain.AgentHarness `json:"orchestratorAgent,omitempty"` + ResolveError string `json:"resolveError,omitempty"` } // Project is the full read-model returned by GET /api/v1/projects/{id}. diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 32d338897..0851f3e37 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" @@ -45,6 +46,7 @@ type commander interface { Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.SessionRecord, error) Restore(ctx context.Context, id domain.SessionID) (domain.SessionRecord, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) + RetireForReplacement(ctx context.Context, id domain.SessionID) error Send(ctx context.Context, id domain.SessionID, message string) error Cleanup(ctx context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) RollbackSpawn(ctx context.Context, id domain.SessionID) (deleted, killed bool, err error) @@ -81,12 +83,14 @@ type scmProvider interface { // session operations to the internal sessionmanager.Manager and owns read-model // assembly, including user-facing display status derivation. type Service struct { - manager commander - store Store - prClaimer ports.PRClaimer - scm scmProvider - clock func() time.Time - telemetry ports.EventSink + manager commander + store Store + prClaimer ports.PRClaimer + scm scmProvider + clock func() time.Time + telemetry ports.EventSink + orchestratorLocksMu sync.Mutex + orchestratorLocks map[domain.ProjectID]*sync.Mutex // signalCapable reports whether a harness has a hook pipeline that can // deliver activity signals at all. Only capable harnesses are eligible for // the no_signal downgrade: a hook-less harness staying silent forever is @@ -263,6 +267,13 @@ func (s *Service) emitSpawnFailed(cfg ports.SpawnConfig, err error, durationMs i // active orchestrator already exists it is returned as-is. A business rule that // belongs here, not in the HTTP controller. func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.ProjectID, clean bool) (domain.Session, error) { + unlock := s.lockOrchestratorProject(projectID) + defer unlock() + + project, err := s.requireProject(ctx, projectID) + if err != nil { + return domain.Session{}, err + } active := true if clean { existing, err := s.List(ctx, ListFilter{ProjectID: projectID, Active: &active, OrchestratorOnly: true}) @@ -270,8 +281,9 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec return domain.Session{}, err } for _, orch := range existing { - if _, err := s.Kill(ctx, orch.ID); err != nil { - return domain.Session{}, err + _ = s.sendRetireNotice(ctx, orch.ID) + if err := s.manager.RetireForReplacement(ctx, orch.ID); err != nil { + return domain.Session{}, toAPIError(err) } } } else { @@ -281,10 +293,90 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec return domain.Session{}, err } if len(existing) > 0 { - return existing[0], nil + return newestSession(existing), nil } } - return s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + sess, err := s.Spawn(ctx, ports.SpawnConfig{ProjectID: projectID, Kind: domain.KindOrchestrator}) + if err != nil { + return domain.Session{}, err + } + if err := verifyOrchestratorReplacement(project, sess); err != nil { + return domain.Session{}, err + } + return sess, nil +} + +const orchestratorRetireNotice = "AO is replacing this project orchestrator. Stop coordinating new work now; a fresh orchestrator will take over on the canonical branch." + +func (s *Service) sendRetireNotice(ctx context.Context, id domain.SessionID) error { + if err := s.manager.Send(ctx, id, orchestratorRetireNotice); err != nil { + return fmt.Errorf("send retire notice to %s: %w", id, err) + } + return nil +} + +func verifyOrchestratorReplacement(project domain.ProjectRecord, sess domain.Session) error { + if sess.IsTerminated { + return fmt.Errorf("orchestrator replacement verification failed: new session %s is terminated", sess.ID) + } + if sess.Kind != domain.KindOrchestrator { + return fmt.Errorf("orchestrator replacement verification failed: new session %s has kind %q", sess.ID, sess.Kind) + } + if expected := project.Config.Orchestrator.Harness; expected != "" && sess.Harness != expected { + return fmt.Errorf("orchestrator replacement verification failed: new session %s uses harness %q, want %q", sess.ID, sess.Harness, expected) + } + expectedBranch := "ao/" + serviceSessionPrefix(project) + "-orchestrator" + if sess.Metadata.Branch != "" && sess.Metadata.Branch != expectedBranch { + return fmt.Errorf("orchestrator replacement verification failed: new session %s uses branch %q, want %q", sess.ID, sess.Metadata.Branch, expectedBranch) + } + return nil +} + +func serviceSessionPrefix(project domain.ProjectRecord) string { + if p := strings.TrimSpace(project.Config.SessionPrefix); p != "" { + return p + } + id := project.ID + if len(id) <= 12 { + return id + } + return id[:12] +} + +func newestSession(sessions []domain.Session) domain.Session { + newest := sessions[0] + for _, sess := range sessions[1:] { + if sessionNewer(sess.SessionRecord, newest.SessionRecord) { + newest = sess + } + } + return newest +} + +func sessionNewer(a, b domain.SessionRecord) bool { + if !a.CreatedAt.Equal(b.CreatedAt) { + return a.CreatedAt.After(b.CreatedAt) + } + if !a.UpdatedAt.Equal(b.UpdatedAt) { + return a.UpdatedAt.After(b.UpdatedAt) + } + return string(a.ID) > string(b.ID) +} + +func (s *Service) lockOrchestratorProject(projectID domain.ProjectID) func() { + s.orchestratorLocksMu.Lock() + if s.orchestratorLocks == nil { + s.orchestratorLocks = make(map[domain.ProjectID]*sync.Mutex) + } + mu := s.orchestratorLocks[projectID] + if mu == nil { + mu = &sync.Mutex{} + s.orchestratorLocks[projectID] = mu + } + s.orchestratorLocksMu.Unlock() + + mu.Lock() + return mu.Unlock } // Restore relaunches a terminated session and returns the API-facing read model. diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index c5b1b1ca6..9dea20257 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" @@ -201,10 +202,15 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { // clean-orchestrator ordering without wiring a real session engine. type fakeCommander struct { killed []domain.SessionID + retired []domain.SessionID + sent []domain.SessionID cleanupProjects []domain.ProjectID killErr error + retireErr error + sendErr error cleanupErr error spawnErr error + spawnRecord domain.SessionRecord spawned bool killsAtSpawn int } @@ -214,7 +220,10 @@ func (f *fakeCommander) Spawn(_ context.Context, cfg ports.SpawnConfig) (domain. return domain.SessionRecord{}, f.spawnErr } f.spawned = true - f.killsAtSpawn = len(f.killed) + f.killsAtSpawn = len(f.retired) + if f.spawnRecord.ID != "" { + return f.spawnRecord, nil + } return domain.SessionRecord{ID: "mer-9", ProjectID: cfg.ProjectID, Kind: cfg.Kind, Harness: cfg.Harness}, nil } func (f *fakeCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) { @@ -227,7 +236,20 @@ func (f *fakeCommander) Kill(_ context.Context, id domain.SessionID) (bool, erro f.killed = append(f.killed, id) return true, nil } -func (f *fakeCommander) Send(context.Context, domain.SessionID, string) error { return nil } +func (f *fakeCommander) RetireForReplacement(_ context.Context, id domain.SessionID) error { + if f.retireErr != nil { + return f.retireErr + } + f.retired = append(f.retired, id) + return nil +} +func (f *fakeCommander) Send(_ context.Context, id domain.SessionID, _ string) error { + if f.sendErr != nil { + return f.sendErr + } + f.sent = append(f.sent, id) + return nil +} func (f *fakeCommander) Cleanup(_ context.Context, project domain.ProjectID) (sessionmanager.CleanupResult, error) { f.cleanupProjects = append(f.cleanupProjects, project) if f.cleanupErr != nil { @@ -293,7 +315,7 @@ func TestTeardownProjectStopsOnKillError(t *testing.T) { } } -func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) { +func TestSpawnOrchestratorCleanRetiresActiveOrchestratorsBeforeSpawn(t *testing.T) { st := newFakeStore() st.projects["mer"] = domain.ProjectRecord{ID: "mer"} // Two active orchestrators plus an unrelated worker and a terminated @@ -310,11 +332,35 @@ func TestSpawnOrchestratorCleanKillsActiveOrchestratorsBeforeSpawn(t *testing.T) t.Fatalf("SpawnOrchestrator: %v", err) } - if len(fc.killed) != 2 { - t.Fatalf("killed = %v, want the two active orchestrators", fc.killed) + if len(fc.retired) != 2 { + t.Fatalf("retired = %v, want the two active orchestrators", fc.retired) + } + if len(fc.sent) != 2 { + t.Fatalf("retire notices = %v, want the two active orchestrators", fc.sent) } if !fc.spawned || fc.killsAtSpawn != 2 { - t.Fatalf("spawn must run after both kills: spawned=%v killsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) + t.Fatalf("spawn must run after both retirements: spawned=%v retirementsAtSpawn=%d", fc.spawned, fc.killsAtSpawn) + } + if len(fc.killed) != 0 { + t.Fatalf("interactive Kill must not be used for replacement: killed=%v", fc.killed) + } +} + +func TestSpawnOrchestratorCleanContinuesWhenRetireNoticeFails(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ID: "mer"} + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator} + fc := &fakeCommander{sendErr: errors.New("pane closed")} + svc := &Service{manager: fc, store: st} + + if _, err := svc.SpawnOrchestrator(context.Background(), "mer", true); err != nil { + t.Fatalf("SpawnOrchestrator: %v", err) + } + if len(fc.retired) != 1 || fc.retired[0] != "mer-1" { + t.Fatalf("retired = %v, want mer-1 despite retire notice failure", fc.retired) + } + if !fc.spawned { + t.Fatal("replacement should still spawn when retire notice delivery fails") } } @@ -620,6 +666,29 @@ func TestSpawnOrchestratorNoCleanSpawnsWhenNoneExists(t *testing.T) { } } +func TestSpawnOrchestratorVerifiesReplacementHarness(t *testing.T) { + st := newFakeStore() + st.projects["mer"] = domain.ProjectRecord{ + ID: "mer", + Config: domain.ProjectConfig{Orchestrator: domain.RoleOverride{Harness: domain.HarnessCodex}}, + } + fc := &fakeCommander{ + spawnRecord: domain.SessionRecord{ + ID: "mer-9", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Harness: domain.HarnessClaudeCode, + Metadata: domain.SessionMetadata{Branch: "ao/mer-orchestrator"}, + }, + } + svc := &Service{manager: fc, store: st} + + _, err := svc.SpawnOrchestrator(context.Background(), "mer", false) + if err == nil || !strings.Contains(err.Error(), `uses harness "claude-code", want "codex"`) { + t.Fatalf("SpawnOrchestrator err = %v, want harness verification failure", err) + } +} + type fakePRClaimer struct { out errorFreeClaimOutcome err error diff --git a/backend/internal/session_manager/manager.go b/backend/internal/session_manager/manager.go index 9fc3af565..11da9edd8 100644 --- a/backend/internal/session_manager/manager.go +++ b/backend/internal/session_manager/manager.go @@ -476,6 +476,59 @@ func (m *Manager) Kill(ctx context.Context, id domain.SessionID) (bool, error) { return freed, nil } +// RetireForReplacement terminates a live orchestrator and releases its branch +// for a replacement session. Unlike Kill, this captures uncommitted work before +// force-removing the worktree, so a dirty canonical orchestrator worktree does +// not block the replacement from claiming the canonical branch. +// +// This deliberately does not write a session_worktrees row: those rows are +// boot-restore markers, and a replaced orchestrator must stay terminated. +func (m *Manager) RetireForReplacement(ctx context.Context, id domain.SessionID) error { + rec, ok, err := m.store.GetSession(ctx, id) + if err != nil { + return fmt.Errorf("retire replacement %s: %w", id, err) + } + if !ok || rec.IsTerminated { + return nil + } + if rec.Metadata.WorkspacePath == "" || rec.Metadata.Branch == "" { + if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: clear restore markers: %w", id, err) + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("retire replacement %s: runtime: %w", id, err) + } + } + if err := m.lcm.MarkTerminated(ctx, id); err != nil { + return fmt.Errorf("retire replacement %s: mark terminated: %w", id, err) + } + return nil + } + + ws := workspaceInfo(rec) + if _, err := m.workspace.StashUncommitted(ctx, ws); err != nil { + return fmt.Errorf("retire replacement %s: stash: %w", id, err) + } + if err := m.store.DeleteSessionWorktrees(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: clear restore markers: %w", id, err) + } + handle := runtimeHandle(rec.Metadata) + if handle.ID != "" { + if err := m.runtime.Destroy(ctx, handle); err != nil { + return fmt.Errorf("retire replacement %s: runtime: %w", id, err) + } + } + if err := m.workspace.ForceDestroy(ctx, ws); err != nil { + return fmt.Errorf("retire replacement %s: force destroy: %w", id, err) + } + if err := m.lcm.MarkTerminated(ctx, rec.ID); err != nil { + return fmt.Errorf("retire replacement %s: mark terminated: %w", id, err) + } + return nil +} + // Restore relaunches a torn-down session in its workspace. The fallible I/O runs // before any durable session write, so a failure never resurrects the row or destroys // the worktree (it may hold the agent's prior work). diff --git a/backend/internal/session_manager/manager_test.go b/backend/internal/session_manager/manager_test.go index 679f7a6e5..4b290885d 100644 --- a/backend/internal/session_manager/manager_test.go +++ b/backend/internal/session_manager/manager_test.go @@ -114,6 +114,9 @@ func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) return f.worktrees[id], nil } func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error { + if f.sharedLog != nil { + *f.sharedLog = append(*f.sharedLog, "DeleteSessionWorktrees:"+string(id)) + } delete(f.worktrees, id) return nil } @@ -148,6 +151,7 @@ func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error { type fakeRuntime struct { createErr error + destroyErr error created, destroyed int lastCfg ports.RuntimeConfig // aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false. @@ -167,7 +171,7 @@ func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports. func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error { r.destroyed++ r.destroyedIDs = append(r.destroyedIDs, handle.ID) - return nil + return r.destroyErr } func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) { if r.aliveErr != nil { @@ -1427,6 +1431,130 @@ func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) { } } +func TestRetireForReplacementCapturesAndReleasesWorkspace(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + var sharedLog []string + st.sharedLog = &sharedLog + ws.sharedLog = &sharedLog + ws.stashRef = "refs/ao/preserved/mer-orch" + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{ + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old", + }} + + if err := m.RetireForReplacement(ctx, "mer-orch"); err != nil { + t.Fatalf("RetireForReplacement err = %v", err) + } + + if rows := st.worktrees["mer-orch"]; len(rows) != 0 { + t.Fatalf("replacement retirement must not write restore markers, got %#v", rows) + } + if !st.sessions["mer-orch"].IsTerminated { + t.Fatal("retired orchestrator must be marked terminated") + } + if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" { + t.Fatalf("runtime destroyed = %d ids=%v, want orch-handle", rt.destroyed, rt.destroyedIDs) + } + + stashIdx, deleteIdx, forceIdx := -1, -1, -1 + for i, c := range sharedLog { + switch c { + case "StashUncommitted:mer-orch": + stashIdx = i + case "DeleteSessionWorktrees:mer-orch": + deleteIdx = i + case "ForceDestroy:mer-orch": + forceIdx = i + } + } + if stashIdx == -1 || deleteIdx == -1 || forceIdx == -1 { + t.Fatalf("missing expected calls in shared log: %v", sharedLog) + } + if stashIdx >= deleteIdx || deleteIdx >= forceIdx { + t.Fatalf("replacement retire must capture, clear restore marker, then force release; log=%v", sharedLog) + } +} + +func TestRetireForReplacementForceDestroyFailureLeavesSessionActive(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + ws.forceDestroyErr = errors.New("worktree still registered") + ws.stashRef = "refs/ao/preserved/mer-orch" + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{ + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old", + }} + + err := m.RetireForReplacement(ctx, "mer-orch") + if err == nil || !strings.Contains(err.Error(), "force destroy") { + t.Fatalf("RetireForReplacement err = %v, want force destroy failure", err) + } + if st.sessions["mer-orch"].IsTerminated { + t.Fatal("session must remain active so retry can retire it again") + } + if rt.destroyed != 1 { + t.Fatalf("runtime destroyed = %d, want 1 before workspace release", rt.destroyed) + } + if ws.stashCalls != 1 { + t.Fatalf("stash calls = %d, want 1", ws.stashCalls) + } +} + +func TestRetireForReplacementRuntimeDestroyFailureBlocksWorkspaceRelease(t *testing.T) { + m, st, rt, ws := newLifecycleManager() + rt.destroyErr = errors.New("tmux transient") + ws.stashRef = "refs/ao/preserved/mer-orch" + st.sessions["mer-orch"] = domain.SessionRecord{ + ID: "mer-orch", + ProjectID: "mer", + Kind: domain.KindOrchestrator, + Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"}, + Activity: domain.Activity{State: domain.ActivityActive}, + } + st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{ + SessionID: "mer-orch", + RepoName: domain.RootWorkspaceRepoName, + Branch: "ao/mer-orchestrator", + WorktreePath: "/ws/mer-orch", + PreservedRef: "refs/ao/preserved/old", + }} + + err := m.RetireForReplacement(ctx, "mer-orch") + if err == nil || !strings.Contains(err.Error(), "runtime") { + t.Fatalf("RetireForReplacement err = %v, want runtime failure", err) + } + if st.sessions["mer-orch"].IsTerminated { + t.Fatal("session must remain active when runtime destroy fails") + } + if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" { + t.Fatalf("runtime destroyed = %d ids=%v, want one attempt for orch-handle", rt.destroyed, rt.destroyedIDs) + } + for _, call := range ws.calls { + if call == "ForceDestroy:mer-orch" { + t.Fatalf("ForceDestroy must not run after runtime destroy failure; calls=%v", ws.calls) + } + } +} + // TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef verifies that a clean // worktree (StashUncommitted returns "") still writes a worktree row (with // empty preserved_ref). The row's presence is the shutdown-saved marker. diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index fe960f8e0..294de0c00 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -746,6 +746,7 @@ export interface components { id: string; kind: string; name: string; + orchestratorAgent?: string; path: string; resolveError?: string; sessionPrefix: string; diff --git a/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx new file mode 100644 index 000000000..c9f3817e6 --- /dev/null +++ b/frontend/src/renderer/components/OrchestratorReplacementDialog.tsx @@ -0,0 +1,75 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { useNavigate } from "@tanstack/react-router"; +import { AlertTriangle, RotateCw, X } from "lucide-react"; +import { findProjectOrchestrator, type WorkspaceSummary } from "../types/workspace"; + +type OrchestratorReplacementDialogProps = { + projectId: string | null; + error?: string; + workspaces: WorkspaceSummary[]; + onOpenChange: (open: boolean) => void; + onRetry: (projectId: string) => void; +}; + +export function OrchestratorReplacementDialog({ + projectId, + error, + workspaces, + onOpenChange, + onRetry, +}: OrchestratorReplacementDialogProps) { + const navigate = useNavigate(); + const open = Boolean(projectId && error); + const orchestrator = projectId ? findProjectOrchestrator(workspaces, projectId) : undefined; + + const openCurrent = () => { + if (!projectId || !orchestrator) return; + onOpenChange(false); + void navigate({ + to: "/projects/$projectId/sessions/$sessionId", + params: { projectId, sessionId: orchestrator.id }, + }); + }; + + return ( + <Dialog.Root open={open} onOpenChange={onOpenChange}> + <Dialog.Portal> + <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" /> + <Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(460px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-5 shadow-lg"> + <div className="flex items-start gap-3"> + <div className="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-surface-subtle text-warning"> + <AlertTriangle className="size-4" aria-hidden="true" /> + </div> + <div className="min-w-0 flex-1"> + <Dialog.Title className="text-sm font-medium text-foreground">Orchestrator replacement failed</Dialog.Title> + <Dialog.Description className="mt-2 text-[13px] leading-5 text-muted-foreground"> + {error ?? "The project orchestrator could not be replaced."} + </Dialog.Description> + </div> + <Dialog.Close asChild> + <button className="rounded-md p-1 text-passive hover:bg-interactive-hover hover:text-foreground" type="button"> + <X className="size-4" aria-hidden="true" /> + <span className="sr-only">Close</span> + </button> + </Dialog.Close> + </div> + <div className="mt-5 flex justify-end gap-2"> + {orchestrator ? ( + <button className="dashboard-app-header__primary-btn" onClick={openCurrent} type="button"> + Open current orchestrator + </button> + ) : null} + <button + className="dashboard-app-header__accent-btn" + onClick={() => projectId && onRetry(projectId)} + type="button" + > + <RotateCw className="size-3.5" aria-hidden="true" /> + Retry + </button> + </div> + </Dialog.Content> + </Dialog.Portal> + </Dialog.Root> + ); +} diff --git a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx index e1a58e340..8811eefb9 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.test.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.test.tsx @@ -3,15 +3,17 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getMock, putMock } = vi.hoisted(() => ({ +const { getMock, putMock, postMock } = vi.hoisted(() => ({ getMock: vi.fn(), putMock: vi.fn(), + postMock: vi.fn(), })); vi.mock("../lib/api-client", () => ({ apiClient: { GET: getMock, PUT: putMock, + POST: postMock, }, apiErrorMessage: (error: unknown) => { if (error instanceof Error) return error.message; @@ -23,14 +25,19 @@ vi.mock("../lib/api-client", () => ({ })); import { ProjectSettingsForm } from "./ProjectSettingsForm"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import type { WorkspaceSummary } from "../types/workspace"; -function renderSettings(projectId = "proj-1") { +function renderSettings(projectId = "proj-1", workspaces?: WorkspaceSummary[]) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); + if (workspaces) { + queryClient.setQueryData(workspaceQueryKey, workspaces); + } render( <QueryClientProvider client={queryClient}> <ProjectSettingsForm projectId={projectId} /> @@ -86,7 +93,9 @@ function mockProject(project: Record<string, unknown>) { beforeEach(() => { getMock.mockReset(); putMock.mockReset(); + postMock.mockReset(); putMock.mockResolvedValue({ data: { project: {} }, error: undefined }); + postMock.mockResolvedValue({ data: { orchestrator: { id: "proj-1-orch-2" } }, error: undefined, response: { status: 200 } }); }); describe("ProjectSettingsForm", () => { @@ -168,6 +177,10 @@ describe("ProjectSettingsForm", () => { }, }, }); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); expect(await screen.findByText("Saved.")).toBeInTheDocument(); }, 20_000); @@ -195,6 +208,7 @@ describe("ProjectSettingsForm", () => { expect(await screen.findByText("invalid permissions")).toBeInTheDocument(); expect(screen.queryByText("Saved.")).not.toBeInTheDocument(); + expect(postMock).not.toHaveBeenCalled(); }); it("requires worker and orchestrator agents for existing projects missing role config", async () => { @@ -323,4 +337,101 @@ describe("ProjectSettingsForm", () => { expect(await screen.findAllByText("Enabling intake requires an assignee.")).toHaveLength(2); expect(putMock).not.toHaveBeenCalled(); }); + + it("restarts when the saved orchestrator agent already differs from the running orchestrator", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "goose" }, + }, + }, + }, + error: undefined, + }); + + renderSettings("proj-1", [ + { + id: "proj-1", + name: "Project One", + path: "/repo/project-one", + orchestratorAgent: "goose", + sessions: [ + { + id: "proj-1-orchestrator", + workspaceId: "proj-1", + workspaceName: "Project One", + title: "Orchestrator", + provider: "claude-code", + kind: "orchestrator", + branch: "ao/proj-1-orchestrator", + status: "working", + createdAt: "2026-07-03T00:00:00Z", + updatedAt: "2026-07-03T00:00:00Z", + prs: [], + }, + ], + }, + ]); + + const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" }); + expect(orchestratorAgent).toHaveTextContent("goose"); + + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(postMock).toHaveBeenCalledWith("/api/v1/orchestrators", { + body: { projectId: "proj-1", clean: true }, + }); + }); + + it("keeps the config save successful when orchestrator replacement fails", async () => { + getMock.mockResolvedValue({ + data: { + status: "ok", + project: { + id: "proj-1", + name: "Project One", + kind: "single_repo", + path: "/repo/project-one", + repo: "", + defaultBranch: "main", + config: { + worker: { agent: "codex" }, + orchestrator: { agent: "claude-code" }, + }, + }, + }, + error: undefined, + }); + postMock.mockResolvedValue({ + data: undefined, + error: { message: "missing goose binary" }, + response: { status: 500 }, + }); + + const queryClient = renderSettings(); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const orchestratorAgent = await screen.findByRole("combobox", { name: "Default orchestrator agent" }); + await chooseOption(orchestratorAgent, "goose"); + await userEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(putMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("Saved.")).toBeInTheDocument(); + expect(await screen.findByText("Orchestrator restart failed: missing goose binary")).toBeInTheDocument(); + expect(screen.queryByText("Save failed")).not.toBeInTheDocument(); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["project", "proj-1"] }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); + }); }); diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index d50216dca..513567027 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -1,9 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { components } from "../../api/schema"; -import { apiClient, apiErrorMessage } from "../lib/api-client"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; -import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { newestActiveOrchestrator } from "../types/workspace"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import { DashboardSubhead } from "./DashboardSubhead"; import { buildIntake, deriveGitHubRepo, IntakeFields, type IntakeForm, intakeNeedsRule } from "./IntakeFields"; @@ -68,7 +70,10 @@ export function ProjectSettingsForm({ projectId }: { projectId: string }) { function SettingsBody({ project, projectId, onSaved }: { project: Project; projectId: string; onSaved: () => void }) { const queryClient = useQueryClient(); + const workspaceQuery = useWorkspaceQuery(); const config = project.config ?? {}; + const workspace = workspaceQuery.data?.find((item) => item.id === projectId); + const activeOrchestrator = newestActiveOrchestrator(workspace?.sessions ?? []); const intake: TrackerIntakeConfig = config.trackerIntake ?? {}; const [form, setForm] = useState({ defaultBranch: config.defaultBranch ?? project.defaultBranch ?? "", @@ -83,7 +88,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje intakeAssignee: intake.assignee ?? "", }); const [savedAt, setSavedAt] = useState<number | null>(null); + const [replacementError, setReplacementError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null); + const initialOrchestratorAgent = config.orchestrator?.agent ?? ""; const missingRequiredAgent = form.workerAgent === "" || form.orchestratorAgent === ""; const agentsQuery = useQuery(agentsQueryOptions); const agentCatalog = agentsQuery.data; @@ -134,9 +141,23 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje body: { config: next }, }); if (error) throw new Error(apiErrorMessage(error)); + if ( + form.orchestratorAgent !== initialOrchestratorAgent || + (activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent) + ) { + try { + await spawnOrchestrator(projectId, true); + } catch (error) { + return { + replacementError: error instanceof Error ? error.message : "Could not replace orchestrator", + }; + } + } + return { replacementError: null }; }, - onSuccess: () => { + onSuccess: (result) => { setSavedAt(Date.now()); + setReplacementError(result.replacementError); setValidationError(null); void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); onSaved(); @@ -149,6 +170,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje onSubmit={(event) => { event.preventDefault(); setSavedAt(null); + setReplacementError(null); if (missingRequiredAgent) { setValidationError("Worker and orchestrator agents are required."); return; @@ -304,6 +326,9 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje {savedAt && !mutation.isPending && !mutation.isError && ( <span className="text-[12px] text-success">Saved.</span> )} + {replacementError && !mutation.isPending && !mutation.isError && ( + <span className="text-[12px] text-warning">Orchestrator restart failed: {replacementError}</span> + )} </div> </form> ); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 6e85b89fa..30f4fde97 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -1,14 +1,15 @@ import { type KeyboardEvent, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; - +import { AlertTriangle, Plus, RotateCw } from "lucide-react"; import { DashboardSubhead } from "./DashboardSubhead"; -import { Plus } from "lucide-react"; import { type AttentionZone, type WorkspaceSession, attentionZone, canonicalTrackerIssueId, + newestActiveOrchestrator, + orchestratorHealth, workerSessions, } from "../types/workspace"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; @@ -16,9 +17,11 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { restartProjectOrchestrator } from "../lib/restart-orchestrator"; import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; +import { useUiStore } from "../stores/ui-store"; type SessionsBoardProps = { /** When set, the board shows only this project's sessions. */ @@ -77,12 +80,16 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const workspaceQuery = useWorkspaceQuery(); const all = workspaceQuery.data ?? []; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; + const workspace = projectId ? workspaces[0] : undefined; const sessions = workspaces.flatMap((w) => workerSessions(w.sessions)); - const orchestrator = projectId - ? workspaces[0]?.sessions.find((session) => session.kind === "orchestrator" && session.status !== "terminated") - : undefined; + const orchestrator = projectId ? newestActiveOrchestrator(workspaces[0]?.sessions ?? []) : undefined; const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const [isSpawning, setIsSpawning] = useState(false); + const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); + const setProjectRestarting = useUiStore((state) => state.setProjectRestarting); + const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError); + const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false; + const health = workspace ? orchestratorHealth(workspace, isProjectRestarting) : { state: "ok" as const }; const byZone = new Map<AttentionZone, WorkspaceSession[]>(); for (const session of sessions) { @@ -101,7 +108,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { }); const openOrchestrator = async () => { - if (!projectId) return; + if (!projectId || isProjectRestarting) return; if (orchestrator) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -122,6 +129,17 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { } }; + const restartOrchestrator = async () => { + if (!projectId) return; + await restartProjectOrchestrator({ + projectId, + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + }); + }; + const handleTaskCreated = async (sessionId: string) => { if (!projectId) return; await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); @@ -136,6 +154,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { <button aria-label="New task" className="dashboard-app-header__accent-btn" + disabled={isProjectRestarting} onClick={() => setIsNewTaskOpen(true)} type="button" > @@ -145,12 +164,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { <button aria-label={orchestrator ? "Orchestrator" : "Spawn Orchestrator"} className="dashboard-app-header__primary-btn" - disabled={isSpawning} + disabled={isSpawning || isProjectRestarting} onClick={() => void openOrchestrator()} type="button" > <OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> - {isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"} + {isProjectRestarting ? "Restarting..." : isSpawning ? "Spawning..." : orchestrator ? "Orchestrator" : "Spawn Orchestrator"} </button> </> ) : undefined; @@ -164,6 +183,23 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { /> <div className="min-h-0 flex-1 overflow-hidden p-[18px]"> + {projectId && health.state !== "ok" ? ( + <div className="mb-3 flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-[12px] text-muted-foreground"> + <AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden="true" /> + <span className="min-w-0 flex-1">{health.message}</span> + {health.state === "restart_needed" || health.state === "duplicates" ? ( + <button + className="dashboard-app-header__primary-btn" + disabled={isProjectRestarting} + onClick={() => void restartOrchestrator()} + type="button" + > + <RotateCw className="size-3.5" aria-hidden="true" /> + Restart + </button> + ) : null} + </div> + ) : null} {workspaceQuery.isError ? ( <p className="py-10 text-center text-[12px] text-passive">Could not load sessions.</p> ) : ( diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index c336b19cb..2929475c7 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -55,6 +55,7 @@ export function ShellTopbar() { const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string }; const isInspectorOpen = useUiStore((state) => state.isInspectorOpen); const toggleInspector = useUiStore((state) => state.toggleInspector); + const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); const [isSpawning, setIsSpawning] = useState(false); const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const all = useWorkspaceQuery().data ?? []; @@ -74,17 +75,18 @@ export function ShellTopbar() { const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined; const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator"); const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined; + const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false; const openBoard = () => projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" }); const openNewTask = () => { - if (!projectId) return; + if (!projectId || isProjectRestarting) return; setIsNewTaskOpen(true); }; const handleTaskCreated = async (sessionId: string) => { - if (!projectId) return; + if (!projectId || isProjectRestarting) return; await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -171,6 +173,7 @@ export function ShellTopbar() { <button aria-label="New task" className="dashboard-app-header__primary-btn" + disabled={isProjectRestarting} onClick={openNewTask} style={noDragStyle} type="button" @@ -197,13 +200,13 @@ export function ShellTopbar() { <button aria-label="Open orchestrator" className="dashboard-app-header__primary-btn dashboard-app-header__primary-btn--compact" - disabled={isSpawning} + disabled={isSpawning || isProjectRestarting} onClick={() => void openOrchestrator()} style={noDragStyle} type="button" > <OrchestratorIcon className="h-3.5 w-3.5" aria-hidden="true" /> - {isSpawning ? "Spawning…" : "Orchestrator"} + {isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : "Orchestrator"} </button> )} {/* Inspector collapse (worker sessions only — orchestrators have no rail). */} diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index e1200349f..09923768c 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -16,7 +16,7 @@ import { import { useRef, useState, type ReactNode } from "react"; import { attentionZone, - isOrchestratorSession, + newestActiveOrchestrator, sessionIsActive, type WorkspaceSession, type WorkspaceSummary, @@ -426,16 +426,19 @@ function ProjectItem({ const [removeError, setRemoveError] = useState<string | null>(null); const [isRemoving, setIsRemoving] = useState(false); const [isSpawning, setIsSpawning] = useState(false); + const restartingProjectIds = useUiStore((state) => state.restartingProjectIds); + const isProjectRestarting = restartingProjectIds.has(workspace.id); // Live workers only: merged/terminated sessions leave the sidebar and stay // reachable through the board's Done / Terminated bar (SessionsBoard). const sessions = workerSessions(workspace.sessions).filter(sessionIsActive); // The project's live orchestrator (if any) backs the hover Orchestrator // button: navigate to it when present, otherwise spawn one first. - const orchestrator = workspace.sessions.find((s) => isOrchestratorSession(s) && sessionIsActive(s)); + const orchestrator = newestActiveOrchestrator(workspace.sessions); // Mirrors ShellTopbar's launcher: attach to the running orchestrator, or // spawn one via the daemon and follow it once the workspace refetches. const openOrchestrator = async () => { + if (isProjectRestarting) return; if (orchestrator) { selection.goSession(workspace.id, orchestrator.id); return; @@ -546,7 +549,7 @@ function ProjectItem({ <button aria-label={orchestrator ? `Open ${workspace.name} orchestrator` : `Spawn ${workspace.name} orchestrator`} className={HOVER_ACTION_CLASS} - disabled={isSpawning} + disabled={isSpawning || isProjectRestarting} onClick={() => void openOrchestrator()} type="button" > @@ -554,7 +557,7 @@ function ProjectItem({ </button> </TooltipTrigger> <TooltipContent> - {isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"} + {isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"} </TooltipContent> </Tooltip> <DropdownMenu> diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 21b402872..60fc3c4e8 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -51,7 +51,16 @@ describe("useWorkspaceQuery", () => { it("maps projects and their sessions, applying provider/status/title fallbacks", async () => { respondWith({ projects: { - data: { projects: [{ id: "proj-1", name: "my-app", path: "/home/me/my-app" }] }, + data: { + projects: [ + { + id: "proj-1", + name: "my-app", + path: "/home/me/my-app", + orchestratorAgent: "codex", + }, + ], + }, error: undefined, }, sessions: { @@ -92,7 +101,7 @@ describe("useWorkspaceQuery", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); const [workspace] = result.current.data ?? []; - expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app" }); + expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app", orchestratorAgent: "codex" }); expect(workspace.sessions).toHaveLength(2); expect(workspace.sessions[0]).toMatchObject({ id: "sess-1", diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index b46373aff..479323c78 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -44,6 +44,7 @@ async function fetchWorkspaces(): Promise<WorkspaceSummary[]> { id: project.id, name: project.name, path: project.path, + orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined, sessions: (sessionsData?.sessions ?? []) .filter((session) => session.projectId === project.id) .map((session) => ({ diff --git a/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts new file mode 100644 index 000000000..e56be81e1 --- /dev/null +++ b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; + +const { captureRendererExceptionMock } = vi.hoisted(() => ({ + captureRendererExceptionMock: vi.fn(), +})); + +vi.mock("./telemetry", () => ({ + captureRendererException: captureRendererExceptionMock, +})); + +import { captureOrchestratorReplacementFailure } from "./orchestrator-replacement-telemetry"; + +describe("captureOrchestratorReplacementFailure", () => { + it("records the shell restart-failure telemetry payload", () => { + const error = new Error("missing goose binary"); + + captureOrchestratorReplacementFailure(error, "proj-1"); + + expect(captureRendererExceptionMock).toHaveBeenCalledWith(error, { + source: "orchestrator-replace", + operation: "replace_orchestrator", + surface: "shell", + project_id: "proj-1", + }); + }); +}); diff --git a/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts new file mode 100644 index 000000000..64e28a826 --- /dev/null +++ b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts @@ -0,0 +1,10 @@ +import { captureRendererException } from "./telemetry"; + +export function captureOrchestratorReplacementFailure(error: unknown, projectId: string) { + void captureRendererException(error, { + source: "orchestrator-replace", + operation: "replace_orchestrator", + surface: "shell", + project_id: projectId, + }); +} diff --git a/frontend/src/renderer/lib/restart-orchestrator.test.ts b/frontend/src/renderer/lib/restart-orchestrator.test.ts new file mode 100644 index 000000000..8b85b9bf8 --- /dev/null +++ b/frontend/src/renderer/lib/restart-orchestrator.test.ts @@ -0,0 +1,73 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), +})); + +vi.mock("./spawn-orchestrator", () => ({ + spawnOrchestrator: spawnMock, +})); + +import { restartProjectOrchestrator } from "./restart-orchestrator"; + +describe("restartProjectOrchestrator", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + it("invalidates workspace state and records an error when clean restart fails", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue(); + const navigate = vi.fn(); + const setProjectRestarting = vi.fn(); + const setOrchestratorReplacementError = vi.fn(); + const onError = vi.fn(); + const failure = new Error("missing goose binary"); + spawnMock.mockRejectedValue(failure); + + await restartProjectOrchestrator({ + projectId: "proj-1", + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError, + }); + + expect(spawnMock).toHaveBeenCalledWith("proj-1", true); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); + expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null); + expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary"); + expect(setProjectRestarting).toHaveBeenNthCalledWith(1, "proj-1", true); + expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false); + expect(onError).toHaveBeenCalledWith(failure); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("still records the replacement error when workspace invalidation fails", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.spyOn(queryClient, "invalidateQueries").mockRejectedValue(new Error("refetch failed")); + const navigate = vi.fn(); + const setProjectRestarting = vi.fn(); + const setOrchestratorReplacementError = vi.fn(); + const onError = vi.fn(); + const failure = new Error("missing goose binary"); + spawnMock.mockRejectedValue(failure); + + await restartProjectOrchestrator({ + projectId: "proj-1", + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError, + }); + + expect(setOrchestratorReplacementError).toHaveBeenLastCalledWith("proj-1", "missing goose binary"); + expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false); + expect(onError).toHaveBeenCalledWith(failure); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/renderer/lib/restart-orchestrator.ts b/frontend/src/renderer/lib/restart-orchestrator.ts new file mode 100644 index 000000000..0d41f7981 --- /dev/null +++ b/frontend/src/renderer/lib/restart-orchestrator.ts @@ -0,0 +1,52 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; +import { spawnOrchestrator } from "./spawn-orchestrator"; + +type NavigateToSession = (options: { + to: "/projects/$projectId/sessions/$sessionId"; + params: { projectId: string; sessionId: string }; +}) => unknown; + +type RestartProjectOrchestratorOptions = { + projectId: string; + queryClient: QueryClient; + navigate: NavigateToSession; + setProjectRestarting: (projectId: string, restarting: boolean) => void; + setOrchestratorReplacementError: (projectId: string, message: string | null) => void; + onError?: (error: unknown) => void; +}; + +async function refreshWorkspaceState(queryClient: QueryClient) { + try { + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch { + // The restart outcome is more important than cache refresh bookkeeping: + // callers still need navigation/error state even if refetching fails. + } +} + +export async function restartProjectOrchestrator({ + projectId, + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError, +}: RestartProjectOrchestratorOptions) { + setProjectRestarting(projectId, true); + setOrchestratorReplacementError(projectId, null); + try { + const sessionId = await spawnOrchestrator(projectId, true); + await refreshWorkspaceState(queryClient); + void navigate({ + to: "/projects/$projectId/sessions/$sessionId", + params: { projectId, sessionId }, + }); + } catch (error) { + await refreshWorkspaceState(queryClient); + setOrchestratorReplacementError(projectId, error instanceof Error ? error.message : "Could not replace orchestrator"); + onError?.(error); + } finally { + setProjectRestarting(projectId, false); + } +} diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index a6f300d71..8891662b2 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { type CSSProperties, useCallback, useEffect, useRef } from "react"; import { ShellTopbar } from "../components/ShellTopbar"; +import { OrchestratorReplacementDialog } from "../components/OrchestratorReplacementDialog"; import { Sidebar } from "../components/Sidebar"; import { SidebarProvider } from "../components/ui/sidebar"; import { TitlebarNav } from "../components/TitlebarNav"; @@ -13,6 +14,8 @@ import { refreshDaemonStatus } from "../lib/daemon-status"; import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { ShellProvider } from "../lib/shell-context"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { restartProjectOrchestrator } from "../lib/restart-orchestrator"; +import { captureOrchestratorReplacementFailure } from "../lib/orchestrator-replacement-telemetry"; import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store"; import type { WorkspaceSummary } from "../types/workspace"; import type { components } from "../../api/schema"; @@ -48,6 +51,10 @@ function ShellLayout() { const daemonStatus = useDaemonStatus(queryClient); const agentCatalogPortRef = useRef<number | undefined>(undefined); const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore(); + const setProjectRestarting = useUiStore((state) => state.setProjectRestarting); + const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors); + const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError); + const replacementErrorProjectId = Object.keys(orchestratorReplacementErrors)[0] ?? null; const updateWorkspaces = useCallback( (updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => { @@ -99,6 +106,7 @@ function ShellLayout() { name: data.project.name, path: data.project.path, type: "main", + orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"], sessions: [], }; void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id }); @@ -146,6 +154,22 @@ function ShellLayout() { [updateWorkspaces], ); + const restartOrchestrator = useCallback( + async (projectId: string) => { + await restartProjectOrchestrator({ + projectId, + queryClient, + navigate, + setProjectRestarting, + setOrchestratorReplacementError, + onError: (error) => { + captureOrchestratorReplacementFailure(error, projectId); + }, + }); + }, + [navigate, queryClient, setOrchestratorReplacementError, setProjectRestarting], + ); + useEffect(() => { document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; @@ -229,6 +253,15 @@ function ShellLayout() { by window-drag even though DOM hit-testing looks correct. */} <TitlebarNav /> </SidebarProvider> + <OrchestratorReplacementDialog + error={replacementErrorProjectId ? orchestratorReplacementErrors[replacementErrorProjectId] : undefined} + onOpenChange={(open) => { + if (!open && replacementErrorProjectId) setOrchestratorReplacementError(replacementErrorProjectId, null); + }} + onRetry={(projectId) => void restartOrchestrator(projectId)} + projectId={replacementErrorProjectId} + workspaces={workspaces} + /> </div> </ShellProvider> ); diff --git a/frontend/src/renderer/stores/ui-store.ts b/frontend/src/renderer/stores/ui-store.ts index 237405512..7d50c8179 100644 --- a/frontend/src/renderer/stores/ui-store.ts +++ b/frontend/src/renderer/stores/ui-store.ts @@ -13,11 +13,15 @@ type UiState = { isSidebarOpen: boolean; isInspectorOpen: boolean; theme: Theme; + restartingProjectIds: ReadonlySet<string>; + orchestratorReplacementErrors: Record<string, string>; setWorkbenchTab: (tab: WorkbenchTab) => void; setTheme: (theme: Theme) => void; toggleTheme: () => void; toggleSidebar: () => void; toggleInspector: () => void; + setProjectRestarting: (projectId: string, restarting: boolean) => void; + setOrchestratorReplacementError: (projectId: string, message: string | null) => void; }; const sidebarStorageKey = "ao.sidebar.open"; @@ -58,6 +62,8 @@ export const useUiStore = create<UiState>((set) => ({ isSidebarOpen: initialSidebarOpen(), isInspectorOpen: initialInspectorOpen(), theme: initialTheme(), + restartingProjectIds: new Set<string>(), + orchestratorReplacementErrors: {}, setWorkbenchTab: (workbenchTab) => set({ workbenchTab }), setTheme: (theme) => { getLocalStorage()?.setItem(themeStorageKey, theme); @@ -81,4 +87,24 @@ export const useUiStore = create<UiState>((set) => ({ getLocalStorage()?.setItem(inspectorStorageKey, String(isInspectorOpen)); return { isInspectorOpen }; }), + setProjectRestarting: (projectId, restarting) => + set((state) => { + const restartingProjectIds = new Set(state.restartingProjectIds); + if (restarting) { + restartingProjectIds.add(projectId); + } else { + restartingProjectIds.delete(projectId); + } + return { restartingProjectIds }; + }), + setOrchestratorReplacementError: (projectId, message) => + set((state) => { + const orchestratorReplacementErrors = { ...state.orchestratorReplacementErrors }; + if (message) { + orchestratorReplacementErrors[projectId] = message; + } else { + delete orchestratorReplacementErrors[projectId]; + } + return { orchestratorReplacementErrors }; + }), })); diff --git a/frontend/src/renderer/types/workspace.test.ts b/frontend/src/renderer/types/workspace.test.ts index 992db2685..1f9021da6 100644 --- a/frontend/src/renderer/types/workspace.test.ts +++ b/frontend/src/renderer/types/workspace.test.ts @@ -3,6 +3,8 @@ import { attentionZone, canonicalTrackerIssueId, findProjectOrchestrator, + newestActiveOrchestrator, + orchestratorHealth, sessionIsActive, sessionNeedsAttention, toAgentProvider, @@ -144,6 +146,50 @@ describe("findProjectOrchestrator", () => { const live = sessionWith({ id: "skills-5", kind: "orchestrator", status: "working" }); expect(findProjectOrchestrator([workspaceWith([live])], "other")).toBeUndefined(); }); + + it("selects the newest active orchestrator, not the first active one", () => { + const older = sessionWith({ + id: "skills-1", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + const newer = sessionWith({ + id: "skills-2", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-02T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + expect(findProjectOrchestrator([workspaceWith([older, newer])], "skills")).toBe(newer); + }); + + it("uses updatedAt and id as newest orchestrator tie breakers", () => { + const oldUpdate = sessionWith({ + id: "skills-2", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + const newUpdate = sessionWith({ + id: "skills-1", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + const sameTimesHigherID = sessionWith({ + id: "skills-3", + kind: "orchestrator", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + expect(newestActiveOrchestrator([oldUpdate, newUpdate])).toBe(newUpdate); + expect(newestActiveOrchestrator([newUpdate, sameTimesHigherID])).toBe(sameTimesHigherID); + }); }); describe("sessionNeedsAttention", () => { @@ -164,6 +210,50 @@ describe("sessionNeedsAttention", () => { }); }); +describe("orchestratorHealth", () => { + it("reports restart_needed when the configured orchestrator agent differs from the newest active orchestrator", () => { + const older = sessionWith({ + id: "skills-1", + kind: "orchestrator", + provider: "codex", + status: "working", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + const newest = sessionWith({ + id: "skills-2", + kind: "orchestrator", + provider: "claude-code", + status: "working", + createdAt: "2026-01-02T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + }); + + expect( + orchestratorHealth({ + id: "skills", + name: "skills", + path: "/tmp/skills", + orchestratorAgent: "codex", + sessions: [older, newest], + }), + ).toEqual({ + state: "duplicates", + message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.", + }); + + expect( + orchestratorHealth({ + id: "skills", + name: "skills", + path: "/tmp/skills", + orchestratorAgent: "codex", + sessions: [newest], + }).state, + ).toBe("restart_needed"); + }); +}); + describe("workerStatusPulses", () => { it("pulses only for working and needs_you", () => { expect(workerStatusPulses("working")).toBe(true); diff --git a/frontend/src/renderer/types/workspace.ts b/frontend/src/renderer/types/workspace.ts index d3e597fa4..9d0ad4587 100644 --- a/frontend/src/renderer/types/workspace.ts +++ b/frontend/src/renderer/types/workspace.ts @@ -241,14 +241,31 @@ export function findProjectOrchestrator( projectId: string, ): WorkspaceSession | undefined { const workspace = workspaces.find((w) => w.id === projectId); - if (!workspace) return undefined; - for (let i = workspace.sessions.length - 1; i >= 0; i -= 1) { - const session = workspace.sessions[i]; - if (isOrchestratorSession(session) && sessionIsActive(session)) { - return session; - } - } - return undefined; + return newestActiveOrchestrator(workspace?.sessions ?? []); +} + +export function newestActiveOrchestrator(sessions: WorkspaceSession[]): WorkspaceSession | undefined { + const active = sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session)); + return active.reduce<WorkspaceSession | undefined>( + (newest, session) => (!newest || sessionNewer(session, newest) ? session : newest), + undefined, + ); +} + +function sessionNewer(a: WorkspaceSession, b: WorkspaceSession): boolean { + const aCreated = timestamp(a.createdAt); + const bCreated = timestamp(b.createdAt); + if (aCreated !== bCreated) return aCreated > bCreated; + const aUpdated = timestamp(a.updatedAt); + const bUpdated = timestamp(b.updatedAt); + if (aUpdated !== bUpdated) return aUpdated > bUpdated; + return a.id > b.id; +} + +function timestamp(value?: string): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; } export function workerSessions(sessions: WorkspaceSession[]): WorkspaceSession[] { @@ -340,6 +357,7 @@ export type WorkspaceSummary = { name: string; path: string; type?: "main" | "worktree"; + orchestratorAgent?: AgentProvider; accentColor?: string; diff?: { additions: number; @@ -348,6 +366,42 @@ export type WorkspaceSummary = { sessions: WorkspaceSession[]; }; +export function orchestratorNeedsRestart(workspace: WorkspaceSummary, orchestrator?: WorkspaceSession): boolean { + if (!orchestrator || !workspace.orchestratorAgent) return false; + return orchestrator.provider !== workspace.orchestratorAgent; +} + +export type OrchestratorHealth = + | { state: "ok" } + | { state: "restarting"; message: string } + | { state: "restart_needed"; message: string } + | { state: "missing"; message: string } + | { state: "duplicates"; message: string }; + +export function orchestratorHealth(workspace: WorkspaceSummary, restarting = false): OrchestratorHealth { + if (restarting) { + return { state: "restarting", message: "Restarting orchestrator. New tasks wait until the replacement is ready." }; + } + const active = workspace.sessions.filter((session) => isOrchestratorSession(session) && sessionIsActive(session)); + if (active.length > 1) { + return { + state: "duplicates", + message: "Multiple orchestrators are active. The newest one is used; stale ones will be cleaned up on daemon reconcile.", + }; + } + const orchestrator = newestActiveOrchestrator(workspace.sessions); + if (!orchestrator) { + return { state: "missing", message: "No orchestrator is running for this project." }; + } + if (orchestratorNeedsRestart(workspace, orchestrator)) { + return { + state: "restart_needed", + message: `Configured orchestrator agent is ${workspace.orchestratorAgent}; running agent is ${orchestrator.provider}.`, + }; + } + return { state: "ok" }; +} + export function toAgentProvider(provider?: string): AgentProvider { switch (provider) { case "claude-code": From 5d7ad6137d04e1e7ef89d88a69425612b1cc85e8 Mon Sep 17 00:00:00 2001 From: Khushi Diwan <diwankhushi428@gmail.com> Date: Sat, 4 Jul 2026 18:29:04 +0530 Subject: [PATCH 41/43] Fix restored terminal reconnect (#2313) * fix: reconnect restored terminals - Reopen terminal mux after restored sessions become live - Cover restore with unchanged terminal handles * fix: limit restored terminal reconnects * chore: format with prettier [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .../hooks/useTerminalSession.test.tsx | 66 +++++++++++++++++++ .../src/renderer/hooks/useTerminalSession.ts | 19 ++++++ 2 files changed, 85 insertions(+) diff --git a/frontend/src/renderer/hooks/useTerminalSession.test.tsx b/frontend/src/renderer/hooks/useTerminalSession.test.tsx index dae10728d..aa4238762 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.test.tsx +++ b/frontend/src/renderer/hooks/useTerminalSession.test.tsx @@ -263,6 +263,72 @@ describe("useTerminalSession", () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); }); + it("reconnects when a restored session becomes live with the same terminal handle", () => { + const muxes: FakeMux[] = []; + const createMux = () => { + const fake = createFakeMux(); + muxes.push(fake); + return fake.mux; + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); + const view = renderHook( + ({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }), + { initialProps: { attachedSession: session }, wrapper }, + ); + const terminal = createFakeTerminal(); + act(() => { + view.result.current.attach(terminal); + }); + act(() => muxes[0].emitOpened("handle-1")); + act(() => muxes[0].emitExit("handle-1")); + expect(view.result.current.state).toBe("exited"); + + view.rerender({ attachedSession: { ...session, status: "terminated", updatedAt: "terminated" } }); + expect(muxes).toHaveLength(1); + + view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "restored" } }); + expect(view.result.current.state).toBe("connecting"); + expect(muxes).toHaveLength(2); + expect(muxes[0].disposed).toBe(true); + expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]); + act(() => muxes[1].emitOpened("handle-1")); + expect(view.result.current.state).toBe("attached"); + terminal.typeKeys("echo ok\r"); + expect(muxes[1].inputs).toEqual([["handle-1", "echo ok\r"]]); + }); + + it("does not reconnect a broken live pane on ordinary session updates", () => { + const muxes: FakeMux[] = []; + const createMux = () => { + const fake = createFakeMux(); + muxes.push(fake); + return fake.mux; + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); + const view = renderHook( + ({ attachedSession }) => useTerminalSession(attachedSession, { daemonReady: true, createMux }), + { initialProps: { attachedSession: session }, wrapper }, + ); + const terminal = createFakeTerminal(); + act(() => { + view.result.current.attach(terminal); + }); + act(() => muxes[0].emitError("handle-1", "no such pane")); + expect(view.result.current.state).toBe("error"); + + view.rerender({ attachedSession: { ...session, status: "idle", updatedAt: "tick-1" } }); + view.rerender({ attachedSession: { ...session, status: "working", updatedAt: "tick-2" } }); + + expect(view.result.current.state).toBe("error"); + expect(muxes).toHaveLength(1); + }); + it("surfaces pane errors and refetches, with no automatic retry", () => { const { view, muxes, invalidateSpy } = setup(); act(() => muxes[0].emitError("handle-1", "no such pane")); diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index ae36c03d3..70365e350 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -80,6 +80,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option const sessionRef = useRef(session); sessionRef.current = session; + const previousSessionStatusRef = useRef(session?.status); const optionsRef = useRef(options); optionsRef.current = options; const stateRef = useRef<TerminalSessionState>(state); @@ -323,6 +324,24 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option connect(); }, [daemonReady, connect]); + useEffect(() => { + const r = runtime.current; + const handle = session?.terminalHandleId ?? null; + const previousStatus = previousSessionStatusRef.current; + previousSessionStatusRef.current = session?.status; + if (!handle || previousStatus !== "terminated" || session?.status === "terminated" || r.detached || !r.terminal) { + return; + } + if (r.handle !== handle) return; + if (stateRef.current !== "exited" && stateRef.current !== "error") return; + if (optionsRef.current.daemonReady) { + transition("connecting"); + connect(); + } else { + transition("reattaching"); + } + }, [connect, session?.status, session?.terminalHandleId, transition]); + // Belt-and-braces: never leak a socket past unmount, even if the owner // forgot to call detach. useEffect( From 2c08597ee6047e3b8045f096fd70a8af24f8728f Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari <dev@theharshitsingh.com> Date: Sat, 4 Jul 2026 20:49:15 +0530 Subject: [PATCH 42/43] refactor(adapters): cut ~3,100 LOC of redundancy in the agent adapter layer (#2349) (#2355) * refactor(adapters): add shared helpers for adapter dedup (#2349) Introduce the shared building blocks the per-adapter cleanup will use: - ports.NormalizePermissionMode (finding 3) - hookutil.FileExists (finding 10) - binaryutil.ResolveBinary + BinarySpec (finding 2) - activitystate.StandardDeriveActivityState (finding 4) - agentbase.Base embed + StandardSessionInfo (findings 6-9) No adapters wired up yet; behavior unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(goose): convert to shared adapter helpers (reference) (#2349) Reference conversion proving the shared packages against real tests: - hooks.go collapses onto hooksjson.Manager (finding 1) - ResolveGooseBinary via binaryutil.BinarySpec (finding 2) - gooseMode uses ports.NormalizePermissionMode (finding 3) - activity.go deleted; dispatch points at activitystate (findings 4, 5) - SessionInfo via agentbase.StandardSessionInfo; Base embed drops the GetConfigSpec/GetPromptDeliveryStrategy no-ops (findings 6-8) - fileExists/atomicWriteFile copies removed (findings 5, 10) Also adds hooksjson package + activitystate test. goose: 327->~90 LOC. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(adapters): dedup remaining 22 agent adapters onto shared helpers (#2349) Applies the shared helpers across every remaining adapter: - hooksjson.Manager for the matcher-group cohort (claudecode, qwen, droid) - binaryutil.BinarySpec for 19 binary resolvers (aider/cursor/opencode/codex keep their special resolvers; all now use hookutil.FileExists) - ports.NormalizePermissionMode replaces 15 private copies - agentbase.Base embed drops the GetConfigSpec/GetPromptDeliveryStrategy no-ops (and GetAgentHooks/GetRestoreCommand/SessionInfo no-ops on hookless adapters) - agentbase.StandardSessionInfo replaces the per-adapter metadata readers - activitystate.StandardDeriveActivityState via dispatch for the 8 name-only derivers; their activity.go files removed (claudecode/codex/droid/agy/opencode keep payload-parsing derivers) - 4 private atomicWriteFile copies missing fsync now use hookutil.AtomicWriteFile - 23 private fileExists copies removed Full backend build + vet + test suite green (1647 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] * fix(binaryutil): preserve per-adapter Windows candidate order (#2349) Review feedback: the shared resolver hardcoded Windows candidate order as APPDATA then LOCALAPPDATA, which flipped Kiro's lookup so the npm shim (%APPDATA%\npm\kiro-cli.*) was probed before the native install (%LOCALAPPDATA%\Programs\kiro\kiro-cli.exe). A fixed order can't preserve every adapter's original order (goose/vibe want APPDATA first, kiro wants LOCALAPPDATA first), so BinarySpec now takes an ordered WinPaths []WinPath list where each entry names its base (WinAppData/WinLocalAppData/WinHome). Every adapter's list reproduces its pre-refactor order exactly; kiro's native path is restored to first. go build / vet / test all green (1647 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(adapters): drop unused resolvedBinary writes flagged by govet (#2349) Embedding agentbase.Base (value receiver) lets govet's unusedwrite analyzer prove that setting resolvedBinary in tests that only call Base-promoted methods (GetConfigSpec/GetPromptDeliveryStrategy/SessionInfo/cancellation) is a dead write. Drop the field from those 23 constructions; GetLaunchCommand/GetRestore tests that actually read it keep it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: gofmt manager_test.go struct alignment (#2349) Inherited via the merge of main: a SessionRecord literal aligned its Metadata field across a multi-key line, which gofmt/goimports rejects. One-line reformat to unblock CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(adapters): fix stale resolver fallback comments (#2349) Review nit: 6 Resolve*Binary functions now delegate to binaryutil.ResolveBinary, which returns a wrapped ports.ErrAgentBinaryNotFound rather than the bare binary name. Update their doc comments (kiro, vibe, amp, agy, crush, cline) to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- .../agent/activitydispatch/dispatch.go | 29 +- .../agent/activitystate/activitystate.go | 32 ++ .../agent/activitystate/activitystate_test.go | 28 ++ .../adapters/agent/agentbase/agentbase.go | 69 ++++ backend/internal/adapters/agent/agy/agy.go | 129 +------ .../internal/adapters/agent/agy/agy_test.go | 2 +- .../internal/adapters/agent/aider/aider.go | 52 +-- .../adapters/agent/aider/aider_test.go | 2 +- backend/internal/adapters/agent/amp/amp.go | 116 +----- .../internal/adapters/agent/auggie/auggie.go | 118 +----- .../adapters/agent/autohand/activity.go | 26 -- .../adapters/agent/autohand/autohand.go | 148 ++----- .../adapters/agent/autohand/autohand_test.go | 13 +- .../internal/adapters/agent/autohand/hooks.go | 26 +- .../adapters/agent/binaryutil/binaryutil.go | 134 +++++++ .../agent/binaryutil/binaryutil_test.go | 81 ++++ .../adapters/agent/claudecode/claudecode.go | 111 +----- .../agent/claudecode/claudecode_test.go | 13 +- .../adapters/agent/claudecode/hooks.go | 334 ++-------------- .../internal/adapters/agent/cline/activity.go | 32 -- .../internal/adapters/agent/cline/cline.go | 138 +------ .../adapters/agent/cline/cline_test.go | 37 +- .../internal/adapters/agent/cline/hooks.go | 31 +- .../internal/adapters/agent/codex/codex.go | 45 +-- .../adapters/agent/codex/codex_test.go | 4 +- .../agent/continueagent/continueagent.go | 124 +----- .../adapters/agent/copilot/activity.go | 38 -- .../adapters/agent/copilot/copilot.go | 72 +--- .../adapters/agent/copilot/copilot_test.go | 32 +- .../internal/adapters/agent/copilot/hooks.go | 30 +- .../internal/adapters/agent/crush/crush.go | 117 +----- .../adapters/agent/crush/crush_test.go | 2 +- .../adapters/agent/cursor/activity.go | 30 -- .../adapters/agent/cursor/activity_test.go | 32 -- .../internal/adapters/agent/cursor/cursor.go | 57 +-- .../adapters/agent/cursor/cursor_test.go | 8 +- .../internal/adapters/agent/devin/devin.go | 124 +----- .../adapters/agent/devin/devin_test.go | 4 +- .../internal/adapters/agent/droid/droid.go | 144 ++----- .../adapters/agent/droid/droid_test.go | 4 +- .../internal/adapters/agent/droid/hooks.go | 334 +--------------- .../internal/adapters/agent/goose/activity.go | 35 -- .../adapters/agent/goose/activity_test.go | 32 -- .../internal/adapters/agent/goose/goose.go | 142 ++----- .../adapters/agent/goose/goose_test.go | 17 +- .../internal/adapters/agent/goose/hooks.go | 339 ++--------------- backend/internal/adapters/agent/grok/grok.go | 130 +------ .../adapters/agent/hooksjson/hooksjson.go | 332 ++++++++++++++++ .../adapters/agent/hookutil/hookutil.go | 8 + .../adapters/agent/kilocode/activity.go | 31 -- .../internal/adapters/agent/kilocode/hooks.go | 30 +- .../adapters/agent/kilocode/kilocode.go | 145 ++----- .../adapters/agent/kilocode/kilocode_test.go | 13 +- backend/internal/adapters/agent/kimi/kimi.go | 126 +----- .../internal/adapters/agent/kiro/activity.go | 31 -- backend/internal/adapters/agent/kiro/hooks.go | 25 +- backend/internal/adapters/agent/kiro/kiro.go | 153 ++------ .../internal/adapters/agent/kiro/kiro_test.go | 32 +- .../adapters/agent/opencode/opencode.go | 67 +--- .../adapters/agent/opencode/opencode_test.go | 8 +- backend/internal/adapters/agent/pi/pi.go | 114 +----- .../internal/adapters/agent/qwen/activity.go | 31 -- backend/internal/adapters/agent/qwen/hooks.go | 360 +----------------- backend/internal/adapters/agent/qwen/qwen.go | 140 +------ .../internal/adapters/agent/qwen/qwen_test.go | 41 +- backend/internal/adapters/agent/vibe/vibe.go | 123 +----- backend/internal/ports/agent.go | 16 + 67 files changed, 1300 insertions(+), 4123 deletions(-) create mode 100644 backend/internal/adapters/agent/activitystate/activitystate.go create mode 100644 backend/internal/adapters/agent/activitystate/activitystate_test.go create mode 100644 backend/internal/adapters/agent/agentbase/agentbase.go delete mode 100644 backend/internal/adapters/agent/autohand/activity.go create mode 100644 backend/internal/adapters/agent/binaryutil/binaryutil.go create mode 100644 backend/internal/adapters/agent/binaryutil/binaryutil_test.go delete mode 100644 backend/internal/adapters/agent/cline/activity.go delete mode 100644 backend/internal/adapters/agent/copilot/activity.go delete mode 100644 backend/internal/adapters/agent/cursor/activity.go delete mode 100644 backend/internal/adapters/agent/cursor/activity_test.go delete mode 100644 backend/internal/adapters/agent/goose/activity.go delete mode 100644 backend/internal/adapters/agent/goose/activity_test.go create mode 100644 backend/internal/adapters/agent/hooksjson/hooksjson.go delete mode 100644 backend/internal/adapters/agent/kilocode/activity.go delete mode 100644 backend/internal/adapters/agent/kiro/activity.go delete mode 100644 backend/internal/adapters/agent/qwen/activity.go diff --git a/backend/internal/adapters/agent/activitydispatch/dispatch.go b/backend/internal/adapters/agent/activitydispatch/dispatch.go index 812e15e29..9e849e0bd 100644 --- a/backend/internal/adapters/agent/activitydispatch/dispatch.go +++ b/backend/internal/adapters/agent/activitydispatch/dispatch.go @@ -9,19 +9,12 @@ package activitydispatch import ( + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agy" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/autohand" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cline" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/copilot" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/cursor" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/goose" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kilocode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/kiro" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/qwen" "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) @@ -32,19 +25,21 @@ type DeriveFunc func(event string, payload []byte) (domain.ActivityState, bool) // Derivers maps the agent token in `ao hooks <agent> <event>` to its deriver. // Per-adapter PRs add their tokens here as they land. var Derivers = map[string]DeriveFunc{ + // Adapters that parse hook payloads for finer-grained state keep their own + // deriver; the rest share the name-only StandardDeriveActivityState. "claude-code": claudecode.DeriveActivityState, "codex": codex.DeriveActivityState, - "cursor": cursor.DeriveActivityState, - "opencode": opencode.DeriveActivityState, - "qwen": qwen.DeriveActivityState, - "copilot": copilot.DeriveActivityState, "droid": droid.DeriveActivityState, "agy": agy.DeriveActivityState, - "goose": goose.DeriveActivityState, - "cline": cline.DeriveActivityState, - "kiro": kiro.DeriveActivityState, - "kilocode": kilocode.DeriveActivityState, - "autohand": autohand.DeriveActivityState, + "opencode": opencode.DeriveActivityState, + "goose": activitystate.StandardDeriveActivityState, + "cursor": activitystate.StandardDeriveActivityState, + "qwen": activitystate.StandardDeriveActivityState, + "copilot": activitystate.StandardDeriveActivityState, + "cline": activitystate.StandardDeriveActivityState, + "kiro": activitystate.StandardDeriveActivityState, + "kilocode": activitystate.StandardDeriveActivityState, + "autohand": activitystate.StandardDeriveActivityState, } // Derive looks up the deriver for an agent token and applies it. ok=false when diff --git a/backend/internal/adapters/agent/activitystate/activitystate.go b/backend/internal/adapters/agent/activitystate/activitystate.go new file mode 100644 index 000000000..3c5375d9a --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate.go @@ -0,0 +1,32 @@ +// Package activitystate holds the standard mapping from an AO hook sub-command +// name onto an activity state. Most adapters install the same +// session-start/user-prompt-submit/stop/permission-request callbacks and derive +// activity identically from the event name alone; they share this deriver rather +// than each carrying a copy. Adapters that inspect the hook payload for finer +// grained state (claude-code, codex, droid) keep their own deriver. +package activitystate + +import "github.com/aoagents/agent-orchestrator/backend/internal/domain" + +// StandardDeriveActivityState maps a hook sub-command name onto an AO activity +// state. The bool is false when the event carries no activity signal. The +// payload is ignored: this is the name-only mapping shared by adapters whose +// hooks report activity purely through which callback fired. +// +// - session-start / user-prompt-submit → active +// - stop → idle +// - permission-request → waiting_input +func StandardDeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { + switch event { + case "session-start": + return domain.ActivityActive, true + case "user-prompt-submit": + return domain.ActivityActive, true + case "stop": + return domain.ActivityIdle, true + case "permission-request": + return domain.ActivityWaitingInput, true + default: + return "", false + } +} diff --git a/backend/internal/adapters/agent/activitystate/activitystate_test.go b/backend/internal/adapters/agent/activitystate/activitystate_test.go new file mode 100644 index 000000000..3fe8f91b9 --- /dev/null +++ b/backend/internal/adapters/agent/activitystate/activitystate_test.go @@ -0,0 +1,28 @@ +package activitystate + +import ( + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +func TestStandardDeriveActivityState(t *testing.T) { + cases := []struct { + event string + want domain.ActivityState + wantOK bool + }{ + {"session-start", domain.ActivityActive, true}, + {"user-prompt-submit", domain.ActivityActive, true}, + {"stop", domain.ActivityIdle, true}, + {"permission-request", domain.ActivityWaitingInput, true}, + {"unknown", "", false}, + {"", "", false}, + } + for _, tc := range cases { + got, ok := StandardDeriveActivityState(tc.event, []byte("ignored")) + if got != tc.want || ok != tc.wantOK { + t.Errorf("event %q: got (%q, %v), want (%q, %v)", tc.event, got, ok, tc.want, tc.wantOK) + } + } +} diff --git a/backend/internal/adapters/agent/agentbase/agentbase.go b/backend/internal/adapters/agent/agentbase/agentbase.go new file mode 100644 index 000000000..7c1a302a7 --- /dev/null +++ b/backend/internal/adapters/agent/agentbase/agentbase.go @@ -0,0 +1,69 @@ +// Package agentbase supplies the defaults an agent adapter would otherwise +// hand-copy. Most adapters implement several ports.Agent methods identically: +// no config keys, prompt delivered in the launch command, and (for the simpler +// harnesses) no hooks, no resume, no session metadata. Embedding Base gives an +// adapter those defaults so it only writes the methods it actually customizes. +package agentbase + +import ( + "context" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// Base provides no-op defaults for the optional ports.Agent methods. Embed it in +// a Plugin struct (`agentbase.Base`) and override only what the harness needs. +// Every method honors ctx cancellation and otherwise does nothing, matching what +// the adapters previously wrote by hand. +type Base struct{} + +// GetConfigSpec reports no agent-specific config keys. +func (Base) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { + return ports.ConfigSpec{}, ctx.Err() +} + +// GetPromptDeliveryStrategy reports that the agent receives its prompt in the +// launch command itself, which is true for every shipped adapter. +func (Base) GetPromptDeliveryStrategy(ctx context.Context, _ ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { + if err := ctx.Err(); err != nil { + return "", err + } + return ports.PromptDeliveryInCommand, nil +} + +// GetAgentHooks is a no-op for harnesses without a native hook surface. +func (Base) GetAgentHooks(ctx context.Context, _ ports.WorkspaceHookConfig) error { + return ctx.Err() +} + +// GetRestoreCommand reports that no existing native session can be continued. +func (Base) GetRestoreCommand(ctx context.Context, _ ports.RestoreConfig) (cmd []string, ok bool, err error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + return nil, false, nil +} + +// SessionInfo reports no agent-owned session metadata. +func (Base) SessionInfo(ctx context.Context, _ ports.SessionRef) (ports.SessionInfo, bool, error) { + if err := ctx.Err(); err != nil { + return ports.SessionInfo{}, false, err + } + return ports.SessionInfo{}, false, nil +} + +// StandardSessionInfo returns the normalized session metadata (native session +// id, title, summary) an adapter's hooks persisted under the shared +// ports.MetadataKey* keys. ok is false when none of the three is present. An +// adapter whose SessionInfo just reads those keys delegates here. +func StandardSessionInfo(session ports.SessionRef) (ports.SessionInfo, bool) { + info := ports.SessionInfo{ + AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], + Title: session.Metadata[ports.MetadataKeyTitle], + Summary: session.Metadata[ports.MetadataKeySummary], + } + if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { + return ports.SessionInfo{}, false + } + return info, true +} diff --git a/backend/internal/adapters/agent/agy/agy.go b/backend/internal/adapters/agent/agy/agy.go index e29563bad..c527cc125 100644 --- a/backend/internal/adapters/agent/agy/agy.go +++ b/backend/internal/adapters/agent/agy/agy.go @@ -5,30 +5,34 @@ package agy import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "agy" +const adapterID = "agy" - // Normalized session-metadata keys. Shared vocabulary with the Codex and Claude Code - // adapters so the dashboard treats every agent uniformly. - agyTitleMetadataKey = "title" - agySummaryMetadataKey = "summary" -) +var agyBinarySpec = binaryutil.BinarySpec{ + Label: "agy", + Names: []string{"agy"}, + WinNames: []string{"agy.cmd", "agy.exe", "agy"}, + UnixPaths: []string{"/usr/local/bin/agy", "/opt/homebrew/bin/agy"}, + UnixHomePaths: [][]string{{".local", "bin", "agy"}, {".cargo", "bin", "agy"}, {".npm", "bin", "agy"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "agy.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "agy.exe"}}, + }, +} // Plugin is the Agy agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.RWMutex resolvedBinary string } @@ -54,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Agy exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start an interactive Agy session. // Shape: // @@ -89,15 +85,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Agy receives its prompt in the -// launch command itself via --prompt-interactive. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Agy session: // `agy --add-dir <WorkspacePath> [--dangerously-skip-permissions] --conversation <agentSessionId>`. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { @@ -134,84 +121,15 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[agyTitleMetadataKey], - Summary: session.Metadata[agySummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveAgyBinary returns the path to the agy binary on this machine, -// searching PATH then a handful of well-known install locations. -// Returns "agy" as a last-ditch fallback. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when agy is absent. func ResolveAgyBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"agy.cmd", "agy.exe", "agy"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "agy.cmd"), - filepath.Join(appData, "npm", "agy.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "agy.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("agy"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/agy", - "/opt/homebrew/bin/agy", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "agy"), - filepath.Join(home, ".cargo", "bin", "agy"), - filepath.Join(home, ".npm", "bin", "agy"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("agy: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, agyBinarySpec) } func (p *Plugin) agyBinary(ctx context.Context) (string, error) { @@ -238,8 +156,3 @@ func (p *Plugin) agyBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/agy/agy_test.go b/backend/internal/adapters/agent/agy/agy_test.go index db6202c2e..eba2c0d29 100644 --- a/backend/internal/adapters/agent/agy/agy_test.go +++ b/backend/internal/adapters/agent/agy/agy_test.go @@ -43,7 +43,7 @@ func TestGetLaunchCommand(t *testing.T) { } func TestGetPromptDeliveryStrategy(t *testing.T) { - plugin := &Plugin{resolvedBinary: "agy"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { t.Fatal(err) diff --git a/backend/internal/adapters/agent/aider/aider.go b/backend/internal/adapters/agent/aider/aider.go index e813b88a2..148885d44 100644 --- a/backend/internal/adapters/agent/aider/aider.go +++ b/backend/internal/adapters/agent/aider/aider.go @@ -18,6 +18,8 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -26,6 +28,7 @@ const adapterID = "aider" // Plugin is the Aider agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -51,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a headless Aider session: // // aider -m <prompt> [permission flags] --no-check-update --no-stream --no-pretty [--read <system prompt file>] @@ -91,40 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Aider receives its prompt in the launch -// command itself (via -m). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is a no-op: Aider emits no lifecycle hooks (Tier C), so there -// is no native hook config to install AO hooks into. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - -// GetRestoreCommand always reports that no native session can be continued. -// Aider has no native session id or resume-by-id mechanism -// (see github.com/Aider-AI/aider issues/166), so the manager always falls back -// to a fresh launch. -func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { - if err := ctx.Err(); err != nil { - return nil, false, err - } - return nil, false, nil -} - -// SessionInfo is a no-op: Aider exposes no captureable session metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // normalizePermissionMode collapses an empty mode onto PermissionModeDefault so // callers can switch over a stable set of values. func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { @@ -190,7 +151,7 @@ func ResolveAiderBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -216,8 +177,3 @@ func (p *Plugin) aiderBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/aider/aider_test.go b/backend/internal/adapters/agent/aider/aider_test.go index 72bcaffdf..9a32707e2 100644 --- a/backend/internal/adapters/agent/aider/aider_test.go +++ b/backend/internal/adapters/agent/aider/aider_test.go @@ -217,7 +217,7 @@ func TestGetLaunchCommandInlineSystemPromptIsDropped(t *testing.T) { } func TestGetRestoreCommandAlwaysFalse(t *testing.T) { - p := &Plugin{resolvedBinary: "aider"} + p := &Plugin{} cmd, ok, err := p.GetRestoreCommand(context.Background(), ports.RestoreConfig{ Session: ports.SessionRef{ Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "abc123"}, diff --git a/backend/internal/adapters/agent/amp/amp.go b/backend/internal/adapters/agent/amp/amp.go index 65c3512a8..4f0580744 100644 --- a/backend/internal/adapters/agent/amp/amp.go +++ b/backend/internal/adapters/agent/amp/amp.go @@ -8,15 +8,12 @@ package amp import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -25,6 +22,7 @@ const adapterID = "amp" // Plugin is the Amp agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -50,14 +48,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Amp session: // // amp [--permission-mode <mode>] [--append-system-prompt <system prompt>] [-- <prompt>] @@ -87,21 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Amp receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op until Amp activity can be reported via -// an Amp-specific plugin. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Amp session // when plugin-derived native session metadata is available. Until that metadata // exists, ok is false and callers fall back to fresh launch behavior. @@ -126,14 +101,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Amp plugin metadata exists. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { switch mode { case ports.PermissionModeAcceptEdits: @@ -145,66 +112,22 @@ func appendPermissionFlags(cmd *[]string, mode ports.PermissionMode) { } } +var ampBinarySpec = binaryutil.BinarySpec{ + Label: "amp", + Names: []string{"amp"}, + WinNames: []string{"amp.cmd", "amp.exe", "amp"}, + UnixPaths: []string{"/usr/local/bin/amp", "/opt/homebrew/bin/amp"}, + UnixHomePaths: [][]string{{".local", "bin", "amp"}, {".npm", "bin", "amp"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "amp.exe"}}, + }, +} + // ResolveAmpBinary finds the `amp` binary, searching PATH then common install -// locations. It returns "amp" as a last resort so callers get the shell's normal -// command-not-found behavior if Amp is absent. +// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Amp is absent. func ResolveAmpBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"amp.cmd", "amp.exe", "amp"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "amp.cmd"), - filepath.Join(appData, "npm", "amp.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("amp"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/amp", - "/opt/homebrew/bin/amp", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "amp"), - filepath.Join(home, ".npm", "bin", "amp"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("amp: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, ampBinarySpec) } func (p *Plugin) ampBinary(ctx context.Context) (string, error) { @@ -222,8 +145,3 @@ func (p *Plugin) ampBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/auggie/auggie.go b/backend/internal/adapters/agent/auggie/auggie.go index e74dc78d5..71ddf5a17 100644 --- a/backend/internal/adapters/agent/auggie/auggie.go +++ b/backend/internal/adapters/agent/auggie/auggie.go @@ -4,7 +4,7 @@ // // Auggie is Augment Code's terminal coding agent (binary "auggie", installed via // `npm install -g @augmentcode/auggie`). It exposes a headless one-shot mode via -// `--print` (alias `-p`) which runs a single instruction and exits — the mode AO +// `--print` (alias `-p`) which runs a single instruction and exits -- the mode AO // uses to drive it unattended. // // Launch shape: @@ -38,15 +38,12 @@ package auggie import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -55,6 +52,7 @@ const adapterID = "auggie" // Plugin is the Auggie agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -80,14 +78,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Auggie session: // // auggie --print [--instruction-file <f> | --instruction <s>] [-- <prompt>] @@ -116,22 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Auggie receives its prompt in the launch -// command itself (the print-mode positional). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op: Auggie has no hook or lifecycle event -// system, so there is nothing to install. Activity reporting will require an -// Auggie-specific integration once one exists. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Auggie session // when a native session id is available in metadata: // @@ -156,81 +130,28 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Auggie session metadata can be -// captured (Auggie exposes no hook surface to derive it from). -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // Auggie has no single blanket auto-approve/bypass flag; unattended tool/file // approval is governed by granular `--permission <tool>:<allow|deny>` rules, so // AO emits no approval flag and defers every mode to the user's Auggie config. // There is therefore no appendApprovalFlags helper for this adapter. +var auggieBinarySpec = binaryutil.BinarySpec{ + Label: "auggie", + Names: []string{"auggie"}, + WinNames: []string{"auggie.cmd", "auggie.exe", "auggie"}, + UnixPaths: []string{"/usr/local/bin/auggie", "/opt/homebrew/bin/auggie"}, + UnixHomePaths: [][]string{{".local", "bin", "auggie"}, {".npm", "bin", "auggie"}, {".npm-global", "bin", "auggie"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "auggie.exe"}}, + }, +} + // ResolveAuggieBinary finds the `auggie` binary, searching PATH then common // install locations. It returns "auggie" as a last resort so callers get the // shell's normal command-not-found behavior if Auggie is absent. func ResolveAuggieBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"auggie.cmd", "auggie.exe", "auggie"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "auggie.cmd"), - filepath.Join(appData, "npm", "auggie.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("auggie"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/auggie", - "/opt/homebrew/bin/auggie", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "auggie"), - filepath.Join(home, ".npm", "bin", "auggie"), - filepath.Join(home, ".npm-global", "bin", "auggie"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("auggie: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, auggieBinarySpec) } func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { @@ -248,8 +169,3 @@ func (p *Plugin) auggieBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/autohand/activity.go b/backend/internal/adapters/agent/autohand/activity.go deleted file mode 100644 index e1280f371..000000000 --- a/backend/internal/adapters/agent/autohand/activity.go +++ /dev/null @@ -1,26 +0,0 @@ -package autohand - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps an Autohand hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in autohandManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), routed -// from Autohand's native lifecycle events. Autohand has no SessionEnd/process- -// exit hook wired into the adapter, so runtime exit still falls back to the -// lifecycle reaper. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/autohand/autohand.go b/backend/internal/adapters/agent/autohand/autohand.go index 988bc9ac2..90daf1f22 100644 --- a/backend/internal/adapters/agent/autohand/autohand.go +++ b/backend/internal/adapters/agent/autohand/autohand.go @@ -6,34 +6,27 @@ // command mode (`autohand -p <prompt>` / positional prompt), native session // resume (`autohand resume <sessionId>`), and a native hook/lifecycle system // whose events (session-start, stop, permission-request, ...) AO maps onto -// activity states. See hooks.go for hook installation and activity.go for the -// event→state mapping. +// activity states. See hooks.go for hook installation. package autohand import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "autohand" - - autohandTitleMetadataKey = "title" - autohandSummaryMetadataKey = "summary" -) +const adapterID = "autohand" // Plugin is the Autohand agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base + binaryMu sync.Mutex resolvedBinary string } @@ -59,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Autohand exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Autohand command-mode session, // scoping the run to the workspace, applying the approval-mode flags and optional // system-prompt override, and passing the initial prompt as a positional argument @@ -98,15 +83,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Autohand receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Autohand // session: `autohand resume [--path <workspace>] <sessionId>`. ok is false when // the hook-derived native session id has not landed yet, so callers can fall @@ -139,15 +115,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[autohandTitleMetadataKey], - Summary: session.Metadata[autohandSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // appendWorkspaceFlag scopes the run to the given workspace path via --path. @@ -160,10 +129,10 @@ func appendWorkspaceFlag(cmd *[]string, workspacePath string) { // appendApprovalFlags maps AO's four permission modes onto Autohand's approval // flags. Default emits no flag so Autohand resolves its starting mode from the // user's own config (permissions.mode). Autohand has no distinct "accept-edits" -// mode, so it maps to --yes (auto-confirm risky actions) — the least-privileged -// non-interactive option — while auto/bypass map to --unrestricted. +// mode, so it maps to --yes (auto-confirm risky actions) -- the least-privileged +// non-interactive option -- while auto/bypass map to --unrestricted. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Autohand config/default behavior. case ports.PermissionModeAcceptEdits: @@ -175,85 +144,23 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } +var autohandBinarySpec = binaryutil.BinarySpec{ + Label: "autohand", + Names: []string{"autohand"}, + WinNames: []string{"autohand.cmd", "autohand.exe", "autohand"}, + UnixPaths: []string{"/usr/local/bin/autohand", "/opt/homebrew/bin/autohand"}, + UnixHomePaths: [][]string{{".local", "bin", "autohand"}, {".npm", "bin", "autohand"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "autohand.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "autohand.exe"}}, + }, } -// ResolveAutohandBinary returns the path to the autohand binary on this machine, -// searching PATH then a handful of well-known install locations (Homebrew, the -// official ~/.local/bin installer, npm global). Returns "autohand" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// ResolveAutohandBinary returns the path to the autohand binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveAutohandBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"autohand.cmd", "autohand.exe", "autohand"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "autohand.cmd"), - filepath.Join(appData, "npm", "autohand.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".local", "bin", "autohand.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("autohand"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/autohand", - "/opt/homebrew/bin/autohand", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "autohand"), - filepath.Join(home, ".npm", "bin", "autohand"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("autohand: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, autohandBinarySpec) } func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { @@ -277,8 +184,3 @@ func (p *Plugin) autohandBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/autohand/autohand_test.go b/backend/internal/adapters/agent/autohand/autohand_test.go index 9e96bbfff..9387bd3e8 100644 --- a/backend/internal/adapters/agent/autohand/autohand_test.go +++ b/backend/internal/adapters/agent/autohand/autohand_test.go @@ -8,6 +8,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -125,7 +126,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -137,7 +138,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "autohand"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -208,8 +209,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "sess-123", - autohandTitleMetadataKey: "Fix login redirect", - autohandSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -356,9 +357,9 @@ func TestDeriveActivityState(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) + got, ok := activitystate.StandardDeriveActivityState(tt.event, []byte(`{}`)) if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", + t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) } }) diff --git a/backend/internal/adapters/agent/autohand/hooks.go b/backend/internal/adapters/agent/autohand/hooks.go index 084515bba..1e579d423 100644 --- a/backend/internal/adapters/agent/autohand/hooks.go +++ b/backend/internal/adapters/agent/autohand/hooks.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -294,35 +295,12 @@ func writeAutohandHooks(configPath string, topLevel, hooksSection map[string]jso return fmt.Errorf("encode %s: %w", configPath, err) } data = append(data, '\n') - if err := atomicWriteFile(configPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(configPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", configPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty config that Autohand then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - func isAutohandManagedHook(command string) bool { return strings.HasPrefix(command, autohandHookCommandPrefix) } diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil.go b/backend/internal/adapters/agent/binaryutil/binaryutil.go new file mode 100644 index 000000000..8016056e5 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil.go @@ -0,0 +1,134 @@ +// Package binaryutil centralizes the "find an agent's CLI binary" search that +// every adapter otherwise reimplements. Adapters differ only in the binary +// name(s) and the well-known install locations to probe, so they describe those +// with a BinarySpec and share the identical PATH-then-candidates iteration. +package binaryutil + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +// BinarySpec describes where one agent's CLI binary can live. ResolveBinary +// searches PATH (via the platform's name list) first, then the platform's +// candidate install paths in order, returning the first hit. +// +// Path components are given as string slices joined onto their base directory, +// so a spec stays OS-agnostic and never hard-codes a separator. env-derived +// bases (APPDATA, LOCALAPPDATA, home) that are unset simply skip their +// candidates. +type BinarySpec struct { + // Label prefixes the ErrAgentBinaryNotFound error, e.g. "claude". + Label string + + // Names are the binary names looked up on PATH on non-Windows, in order. + Names []string + // WinNames are the binary names looked up on PATH on Windows, in order. + // Empty means the Windows branch does no PATH lookup. + WinNames []string + + // UnixPaths are absolute candidate paths probed on non-Windows, in order. + UnixPaths []string + // UnixHomePaths are candidate paths under the user's home dir on + // non-Windows; each entry is the components to join onto $HOME. + UnixHomePaths [][]string + + // WinPaths are candidate paths probed on Windows, in the exact order given. + // Each entry names the base directory (%APPDATA%, %LOCALAPPDATA%, or home) it + // is joined onto. Order is significant: a native installer location listed + // before an npm shim wins when both are present, so it is spelled out here + // rather than assumed. Entries whose base env is unset are skipped. + WinPaths []WinPath +} + +// WinBase names the base directory a Windows candidate path is joined onto. +type WinBase int + +// The base directories a Windows candidate path can resolve against. +const ( + WinAppData WinBase = iota // %APPDATA% + WinLocalAppData // %LOCALAPPDATA% + WinHome // the user's home directory +) + +// WinPath is one Windows candidate: Parts joined onto Base's directory. +type WinPath struct { + Base WinBase + Parts []string +} + +// ResolveBinary returns the path to spec's binary, searching PATH then the +// platform's candidate install locations. It returns a wrapped +// ports.ErrAgentBinaryNotFound when nothing matches, so callers surface a clear +// "command not found" rather than launching an empty argv. ctx cancellation is +// honored between probes. +func ResolveBinary(ctx context.Context, spec BinarySpec) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + names := spec.Names + var candidates []string + if runtime.GOOS == "windows" { + names = spec.WinNames + home, _ := os.UserHomeDir() + appData := os.Getenv("APPDATA") + localAppData := os.Getenv("LOCALAPPDATA") + for _, wp := range spec.WinPaths { + var base string + switch wp.Base { + case WinAppData: + base = appData + case WinLocalAppData: + base = localAppData + case WinHome: + base = home + } + if base == "" { + continue + } + candidates = append(candidates, filepath.Join(append([]string{base}, wp.Parts...)...)) + } + } else { + candidates = append(candidates, spec.UnixPaths...) + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, joinAll(home, spec.UnixHomePaths)...) + } + } + + for _, name := range names { + if err := ctx.Err(); err != nil { + return "", err + } + if path, err := exec.LookPath(name); err == nil && path != "" { + return path, nil + } + } + + for _, candidate := range candidates { + if err := ctx.Err(); err != nil { + return "", err + } + if hookutil.FileExists(candidate) { + return candidate, nil + } + } + + return "", fmt.Errorf("%s: %w", spec.Label, ports.ErrAgentBinaryNotFound) +} + +// joinAll joins each component slice onto base into an absolute candidate path. +func joinAll(base string, entries [][]string) []string { + out := make([]string, 0, len(entries)) + for _, parts := range entries { + out = append(out, filepath.Join(append([]string{base}, parts...)...)) + } + return out +} diff --git a/backend/internal/adapters/agent/binaryutil/binaryutil_test.go b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go new file mode 100644 index 000000000..7cc3b24c7 --- /dev/null +++ b/backend/internal/adapters/agent/binaryutil/binaryutil_test.go @@ -0,0 +1,81 @@ +package binaryutil + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestResolveBinaryPrefersPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("PATH lookup shape differs on windows") + } + dir := t.TempDir() + bin := filepath.Join(dir, "widget") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + got, err := ResolveBinary(context.Background(), BinarySpec{Label: "widget", Names: []string{"widget"}}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryFallsBackToHomeCandidate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix home candidate shape") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", t.TempDir()) // empty of the binary + bin := filepath.Join(home, ".local", "bin", "widget") + if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget"}, + UnixHomePaths: [][]string{{".local", "bin", "widget"}}, + }) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if got != bin { + t.Fatalf("got %q, want %q", got, bin) + } +} + +func TestResolveBinaryNotFound(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + _, err := ResolveBinary(context.Background(), BinarySpec{ + Label: "widget", + Names: []string{"widget-does-not-exist"}, + WinNames: []string{"widget-does-not-exist.exe"}, + }) + if !errors.Is(err, ports.ErrAgentBinaryNotFound) { + t.Fatalf("want ErrAgentBinaryNotFound, got %v", err) + } +} + +func TestResolveBinaryHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := ResolveBinary(ctx, BinarySpec{Label: "widget", Names: []string{"widget"}}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } +} diff --git a/backend/internal/adapters/agent/claudecode/claudecode.go b/backend/internal/adapters/agent/claudecode/claudecode.go index 147fec382..a803f86aa 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode.go +++ b/backend/internal/adapters/agent/claudecode/claudecode.go @@ -24,7 +24,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "sync" "time" @@ -32,6 +31,8 @@ import ( "github.com/google/uuid" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -51,6 +52,7 @@ var claudeSessionNamespace = uuid.MustParse("a1f0c3d2-7b54-4e96-8a2b-0d9e1f2a3b4 // Plugin is the Claude Code agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -177,15 +179,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Claude Code receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // PreLaunch is an optional capability the spawn engine invokes (via type // assertion) immediately before creating the session. Claude Code shows a // blocking "do you trust this folder?" dialog the first time it runs in any @@ -261,15 +254,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // AuthStatus checks Claude Code's local authentication state without starting a @@ -409,7 +395,7 @@ func resolveSystemPrompt(cfg ports.LaunchConfig) (string, error) { // // Empty/unrecognized normalizes to default, so no flag is emitted. func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's settings.json defaultMode. case ports.PermissionModeAcceptEdits: @@ -435,74 +421,24 @@ func appendToolFlags(cmd *[]string, allowed, disallowed []string) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to settings.json (no flag). - return ports.PermissionModeDefault - } +// claudeBinarySpec locates the claude binary: PATH first, then the native +// installer's locations, npm global, Homebrew, and the claude-managed dir. +var claudeBinarySpec = binaryutil.BinarySpec{ + Label: "claude", + Names: []string{"claude"}, + WinNames: []string{"claude.cmd", "claude.exe", "claude"}, + UnixPaths: []string{"/usr/local/bin/claude", "/opt/homebrew/bin/claude"}, + UnixHomePaths: [][]string{{".local", "bin", "claude"}, {".npm", "bin", "claude"}, {".claude", "local", "claude"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "claude.exe"}}, + }, } -// ResolveClaudeBinary finds the `claude` binary, searching PATH then a few -// well-known install locations (the native installer's ~/.local/bin, npm -// global, Homebrew). Returns "claude" as a last resort so callers get a -// clear "command not found" rather than an empty argv. +// ResolveClaudeBinary returns the path to the claude binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveClaudeBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"claude.cmd", "claude.exe", "claude"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "claude.cmd"), - filepath.Join(appData, "npm", "claude.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - } - return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("claude"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/claude", - "/opt/homebrew/bin/claude", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "claude"), - filepath.Join(home, ".npm", "bin", "claude"), - filepath.Join(home, ".claude", "local", "claude"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("claude: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, claudeBinarySpec) } func (p *Plugin) claudeBinary(ctx context.Context) (string, error) { @@ -609,8 +545,3 @@ func ensureWorkspaceTrusted(configPath, workspacePath string) error { } return nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/claudecode/claudecode_test.go b/backend/internal/adapters/agent/claudecode/claudecode_test.go index eb2fc98ba..e0f21d1eb 100644 --- a/backend/internal/adapters/agent/claudecode/claudecode_test.go +++ b/backend/internal/adapters/agent/claudecode/claudecode_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -183,8 +184,8 @@ func TestGetAgentHooksInstallsClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -260,8 +261,8 @@ func TestUninstallHooksRemovesClaudeHooks(t *testing.T) { t.Fatal(err) } var config struct { - Hooks map[string][]claudeMatcherGroup `json:"hooks"` - Permissions json.RawMessage `json:"permissions"` + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` + Permissions json.RawMessage `json:"permissions"` } if err := json.Unmarshal(data, &config); err != nil { t.Fatal(err) @@ -343,7 +344,7 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { // countClaudeHookCommand counts how many hook entries under one event register // the given command — used to prove no duplicate AO hooks. -func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { +func countClaudeHookCommand(groups []hooksjson.MatcherGroup, command string) int { count := 0 for _, group := range groups { for _, hook := range group.Hooks { @@ -357,7 +358,7 @@ func countClaudeHookCommand(groups []claudeMatcherGroup, command string) int { // matcherForCommand returns the matcher on the group that registers the given // command (nil if the group has no matcher). -func matcherForCommand(groups []claudeMatcherGroup, command string) *string { +func matcherForCommand(groups []hooksjson.MatcherGroup, command string) *string { for _, group := range groups { for _, hook := range group.Hooks { if hook.Command == command { diff --git a/backend/internal/adapters/agent/claudecode/hooks.go b/backend/internal/adapters/agent/claudecode/hooks.go index da8fbb6a6..88019cdb3 100644 --- a/backend/internal/adapters/agent/claudecode/hooks.go +++ b/backend/internal/adapters/agent/claudecode/hooks.go @@ -2,64 +2,25 @@ package claudecode import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - claudeSettingsDirName = ".claude" - claudeSettingsFileName = "settings.local.json" - - // claudeHookCommandPrefix identifies the hook commands AO owns. Every - // managed command starts with it, so install can skip duplicates and - // uninstall can recognize AO entries by prefix without an embedded - // template to diff against. + claudeSettingsDirName = ".claude" + claudeSettingsFileName = "settings.local.json" claudeHookCommandPrefix = "ao hooks claude-code " claudeHookTimeout = 30 ) -type claudeMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart requires a - // real matcher ("startup"); UserPromptSubmit/Stop omit it (Claude ignores - // matcher for those events). omitempty drops a nil matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []claudeHookEntry `json:"hooks"` -} - -type claudeHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// claudeHookSpec describes one hook AO installs, defined in code rather -// than read from an embedded settings file. -type claudeHookSpec struct { - Event string - Matcher *string - Command string -} - // claudeStartupMatcher is referenced by pointer so SessionStart serializes with // its required "startup" matcher. var claudeStartupMatcher = "startup" -// claudeManagedHooks is the source of truth for the hooks AO installs: -// SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized session metadata and -// activity-state signals back into AO's store (see DeriveActivityState). -// Notification and SessionEnd carry no matcher: each installs once and fires -// for every sub-type, and the handler filters on the payload's -// notification_type / reason field — installing one command under multiple -// matchers would trip the per-command dedup in claudeHookCommandExists. -var claudeManagedHooks = []claudeHookSpec{ +// claudeManagedHooks is the source of truth for the hooks AO installs. +var claudeManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &claudeStartupMatcher, Command: claudeHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: claudeHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: claudeHookCommandPrefix + "stop"}, @@ -67,282 +28,31 @@ var claudeManagedHooks = []claudeHookSpec{ {Event: "SessionEnd", Command: claudeHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Claude Code hooks into the worktree-local -// .claude/settings.local.json file (the per-session local settings, not the -// shared .claude/settings.json). The hooks (SessionStart, UserPromptSubmit, -// Stop, Notification, SessionEnd) report normalized session metadata and -// activity-state signals back into AO's store. Existing hooks and unrelated -// settings are preserved, and duplicate AO commands are not appended, so -// the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("claude-code.GetAgentHooks: WorkspacePath is required") - } - - settingsPath := claudeSettingsPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - - for event, specs := range groupClaudeHooksByEvent() { - var existingGroups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !claudeHookCommandExists(existingGroups, spec.Command) { - entry := claudeHookEntry{Type: "command", Command: spec.Command, Timeout: claudeHookTimeout} - existingGroups = addClaudeHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalClaudeHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), claudeSettingsFileName); err != nil { - return fmt.Errorf("claude-code.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Claude Code hooks from the workspace-local -// .claude/settings.local.json file, leaving user-defined hooks and unrelated -// settings untouched. A missing settings file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("claude-code.UninstallHooks: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - groups = removeClaudeManagedHooks(groups) - if err := marshalClaudeHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - } - - if err := writeClaudeSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("claude-code.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Claude Code hook is present in -// the workspace-local settings file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("claude-code.AreHooksInstalled: workspacePath is required") - } - - settingsPath := claudeSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readClaudeSettings(settingsPath) - if err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - - for _, event := range claudeManagedEvents() { - var groups []claudeMatcherGroup - if err := parseClaudeHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("claude-code.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isClaudeManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// claudeHooks manages AO's hooks in the workspace-local +// .claude/settings.local.json file. +var claudeHooks = hooksjson.Manager{ + Label: "claude-code", + CommandPrefix: claudeHookCommandPrefix, + Timeout: claudeHookTimeout, + Path: claudeSettingsPath, + Managed: claudeManagedHooks, } func claudeSettingsPath(workspacePath string) string { return filepath.Join(workspacePath, claudeSettingsDirName, claudeSettingsFileName) } -// readClaudeSettings loads the settings file into a top-level raw map plus the -// decoded "hooks" sub-map, preserving every key AO doesn't manage. A -// missing or empty file yields empty maps. -func readClaudeSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Claude Code hooks, preserving user-defined hooks and unrelated settings. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return claudeHooks.Install(ctx, cfg.WorkspacePath) } -// writeClaudeSettings folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeClaudeSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { - return fmt.Errorf("create settings dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", settingsPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(settingsPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", settingsPath, err) - } - return nil +// UninstallHooks removes AO's Claude Code hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return claudeHooks.Uninstall(ctx, workspacePath) } -// groupClaudeHooksByEvent groups the managed hook specs by their Claude event so -// each event's settings array is rewritten once. -func groupClaudeHooksByEvent() map[string][]claudeHookSpec { - byEvent := map[string][]claudeHookSpec{} - for _, spec := range claudeManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// claudeManagedEvents returns the distinct Claude events AO manages, in -// the order they first appear in claudeManagedHooks. -func claudeManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(claudeManagedHooks)) - for _, spec := range claudeManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isClaudeManagedHook(command string) bool { - return strings.HasPrefix(command, claudeHookCommandPrefix) -} - -// removeClaudeManagedHooks strips AO hook entries from every group, -// dropping any group left without hooks so the event array doesn't accumulate -// empty matcher objects. -func removeClaudeManagedHooks(groups []claudeMatcherGroup) []claudeMatcherGroup { - result := make([]claudeMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]claudeHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isClaudeManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseClaudeHookType(rawHooks map[string]json.RawMessage, event string, target *[]claudeMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalClaudeHookType(rawHooks map[string]json.RawMessage, event string, groups []claudeMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func claudeHookCommandExists(groups []claudeMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addClaudeHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addClaudeHook(groups []claudeMatcherGroup, hook claudeHookEntry, matcher *string) []claudeMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, claudeMatcherGroup{Matcher: matcher, Hooks: []claudeHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Claude Code hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return claudeHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/cline/activity.go b/backend/internal/adapters/agent/cline/activity.go deleted file mode 100644 index 5d51238f0..000000000 --- a/backend/internal/adapters/agent/cline/activity.go +++ /dev/null @@ -1,32 +0,0 @@ -package cline - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cline hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed by clineManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), not -// the native Cline event name. Cline currently exposes no stable -// session/process-end hook the adapter installs, so runtime exit still falls -// back to the lifecycle reaper. -// -// TODO(cline): ActivityExited is still runtime-observation-owned. If Cline adds -// a stable native session/process-end hook (e.g. session_shutdown via the CLI -// `cline hook` path), map it to ActivityExited here. Until then, ensure the -// reaper can still mark a dead Cline runtime as exited even when the last hook -// signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/cline/cline.go b/backend/internal/adapters/agent/cline/cline.go index 9c032f3c0..f2fbde636 100644 --- a/backend/internal/adapters/agent/cline/cline.go +++ b/backend/internal/adapters/agent/cline/cline.go @@ -16,26 +16,19 @@ package cline import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - clineTitleMetadataKey = "title" - clineSummaryMetadataKey = "summary" -) - // Plugin is the Cline agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -61,14 +54,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Cline exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Cline session. Prompted // launches request machine-readable NDJSON output (`--json`). Promptless // launches stay interactive because Cline's JSON output mode requires a prompt @@ -97,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Cline receives its prompt in the -// launch command itself (as a positional argument). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Cline session: // `cline [approval flags] --id <agentSessionId>`. Resumes are interactive // because no prompt is supplied here. ok is false when the hook-derived native @@ -138,82 +114,27 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[clineTitleMetadataKey], - Summary: session.Metadata[clineSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil +} + +var clineBinarySpec = binaryutil.BinarySpec{ + Label: "cline", + Names: []string{"cline"}, + WinNames: []string{"cline.cmd", "cline.exe", "cline"}, + UnixPaths: []string{"/usr/local/bin/cline", "/opt/homebrew/bin/cline"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cline"}, {".npm", "bin", "cline"}, {".local", "bin", "cline"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cline.exe"}}, + }, } // ResolveClineBinary returns the path to the cline binary on this machine, -// searching PATH then a handful of well-known install locations -// (Homebrew, npm global). Returns "cline" as a last-ditch fallback so callers -// see a clear "command not found" rather than an empty argv. +// searching PATH then a handful of well-known install locations (Homebrew, npm +// global). It returns a wrapped ports.ErrAgentBinaryNotFound when cline is absent. func ResolveClineBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"cline.cmd", "cline.exe", "cline"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "cline.cmd"), - filepath.Join(appData, "npm", "cline.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("cline"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/cline", - "/opt/homebrew/bin/cline", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "cline"), - filepath.Join(home, ".npm", "bin", "cline"), - filepath.Join(home, ".local", "bin", "cline"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cline: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, clineBinarySpec) } func (p *Plugin) clineBinary(ctx context.Context) (string, error) { @@ -233,7 +154,7 @@ func (p *Plugin) clineBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cline config/default behavior. case ports.PermissionModeAcceptEdits: @@ -249,20 +170,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/cline/cline_test.go b/backend/internal/adapters/agent/cline/cline_test.go index 2ef2c6b7c..ec1e359e9 100644 --- a/backend/internal/adapters/agent/cline/cline_test.go +++ b/backend/internal/adapters/agent/cline/cline_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -114,7 +114,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cline"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -126,7 +126,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cline"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -245,11 +245,11 @@ func TestUninstallHooksRemovesClineHooks(t *testing.T) { } for _, spec := range clineManagedHooks { - if fileExists(filepath.Join(hooksDir, spec.Event)) { + if hookutil.FileExists(filepath.Join(hooksDir, spec.Event)) { t.Fatalf("%s still present after uninstall", spec.Event) } } - if !fileExists(userHook) { + if !hookutil.FileExists(userHook) { t.Fatal("user PostToolUse hook was removed by uninstall") } } @@ -324,8 +324,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "session-123", - clineTitleMetadataKey: "Fix login redirect", - clineSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -367,29 +367,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - want domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, nil) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} - func TestContextCancellationIsHonored(t *testing.T) { plugin := &Plugin{resolvedBinary: "cline"} ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/adapters/agent/cline/hooks.go b/backend/internal/adapters/agent/cline/hooks.go index 53df0d0b7..184d4fe8c 100644 --- a/backend/internal/adapters/agent/cline/hooks.go +++ b/backend/internal/adapters/agent/cline/hooks.go @@ -83,11 +83,11 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) // Never clobber a user-authored hook with the same event name. - if fileExists(scriptPath) && !isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && !isManagedClineHook(scriptPath) { continue } script := renderClineHookScript(spec.Subcommand) - if err := atomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { + if err := hookutil.AtomicWriteFile(scriptPath, []byte(script), 0o700); err != nil { return fmt.Errorf("cline.GetAgentHooks: write %s: %w", spec.Event, err) } written = append(written, spec.Event) @@ -116,7 +116,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if !fileExists(scriptPath) || !isManagedClineHook(scriptPath) { + if !hookutil.FileExists(scriptPath) || !isManagedClineHook(scriptPath) { continue } if err := os.Remove(scriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { @@ -143,7 +143,7 @@ func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (b for _, spec := range clineManagedHooks { scriptPath := filepath.Join(hooksDir, spec.Event) - if fileExists(scriptPath) && isManagedClineHook(scriptPath) { + if hookutil.FileExists(scriptPath) && isManagedClineHook(scriptPath) { return true, nil } } @@ -177,26 +177,3 @@ func isManagedClineHook(scriptPath string) bool { } return strings.Contains(string(data), clineHookMarker) } - -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated script that Cline then fails to execute. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} diff --git a/backend/internal/adapters/agent/codex/codex.go b/backend/internal/adapters/agent/codex/codex.go index 8ce77fbd3..8985c0ec6 100644 --- a/backend/internal/adapters/agent/codex/codex.go +++ b/backend/internal/adapters/agent/codex/codex.go @@ -19,12 +19,14 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) // Plugin is the Codex agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -51,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Codex exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Codex session, applying the // no-update-check, hook-trust bypass, and approval flags, AO's session-flag // activity hooks, the workspace trust override, optional system-prompt @@ -92,15 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Codex receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Codex // session: `codex resume <agentSessionId>`. ok is false when the hook-derived // native session id has not landed yet, so callers can fall back to fresh @@ -138,15 +123,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // AuthStatus checks Codex's local login state without making a model call. @@ -340,7 +318,7 @@ func appendTerminalCompatibilityFlags(cmd *[]string) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // Codex sessions are AO-managed and run headlessly inside a terminal // mux pane; default to no approval prompts unless project settings @@ -355,18 +333,7 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - +// fileExists is a package var so tests can stub it to scope candidate probing. var fileExists = func(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/backend/internal/adapters/agent/codex/codex_test.go b/backend/internal/adapters/agent/codex/codex_test.go index e4348f62f..49b60a538 100644 --- a/backend/internal/adapters/agent/codex/codex_test.go +++ b/backend/internal/adapters/agent/codex/codex_test.go @@ -233,7 +233,7 @@ func TestCodexTOMLBasicStringEscapes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -245,7 +245,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "codex"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { diff --git a/backend/internal/adapters/agent/continueagent/continueagent.go b/backend/internal/adapters/agent/continueagent/continueagent.go index 65f4122ea..0d82c3c4e 100644 --- a/backend/internal/adapters/agent/continueagent/continueagent.go +++ b/backend/internal/adapters/agent/continueagent/continueagent.go @@ -22,15 +22,12 @@ package continueagent import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -39,9 +36,22 @@ import ( // (NOT the Go package name "continueagent"). const adapterID = "continue" +var continueBinarySpec = binaryutil.BinarySpec{ + Label: "cn", + Names: []string{"cn"}, + WinNames: []string{"cn.cmd", "cn.exe", "cn"}, + UnixPaths: []string{"/usr/local/bin/cn", "/opt/homebrew/bin/cn"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "cn"}, {".local", "bin", "cn"}, {".npm", "bin", "cn"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "cn.exe"}}, + }, +} + // Plugin is the Continue CLI agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -67,14 +77,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `cn --print [--auto|--readonly] <prompt>`. // // `--print` runs Continue in non-interactive (headless) mode. The prompt is the @@ -97,14 +99,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because the Continue CLI // natively reads Claude Code hook settings. // @@ -154,15 +148,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveContinueBinary finds the `cn` binary (Continue CLI), searching PATH then @@ -170,63 +157,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // callers get the shell's normal command-not-found behavior if Continue is // absent. func ResolveContinueBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"cn.cmd", "cn.exe", "cn"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "cn.cmd"), - filepath.Join(appData, "npm", "cn.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("cn"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/cn", - "/opt/homebrew/bin/cn", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "cn"), - filepath.Join(home, ".local", "bin", "cn"), - filepath.Join(home, ".npm", "bin", "cn"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("cn: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, continueBinarySpec) } func (p *Plugin) continueBinary(ctx context.Context) (string, error) { @@ -251,7 +182,7 @@ func (p *Plugin) continueBinary(ctx context.Context) (string, error) { // and the two flags are mutually exclusive. Default and AcceptEdits emit no flag // so Continue defers to the user's own config / default behavior. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Continue config / default behavior. case ports.PermissionModeAcceptEdits: @@ -262,20 +193,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--auto") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/copilot/activity.go b/backend/internal/adapters/agent/copilot/activity.go deleted file mode 100644 index a32110547..000000000 --- a/backend/internal/adapters/agent/copilot/activity.go +++ /dev/null @@ -1,38 +0,0 @@ -package copilot - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Copilot CLI hook event onto an AO activity state. -// The bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in copilotManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), NOT the -// native Copilot event name. Keeping this beside hooks.go means the events AO -// installs and what they mean live in one place. -// -// Copilot CLI documents that prompt-style hooks (userPromptSubmitted) do NOT -// fire in non-interactive `-p` mode, while preToolUse fires before every tool -// invocation (including ones that would prompt the user for approval) and is -// the most reliable signal in CLI pipe mode (-p). AO still installs every event -// so interactive resume and future modes report activity; the -// permission-request → waiting_input mapping (driven by preToolUse) is the one -// that always fires under AO's headless launch. -// -// TODO(copilot): ActivityExited is still runtime-observation-owned. If Copilot's -// sessionEnd/agentStop hook proves reliable in `-p` mode, map a real -// session-end here. Until then, the lifecycle reaper marks a dead Copilot -// runtime exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/copilot/copilot.go b/backend/internal/adapters/agent/copilot/copilot.go index 3a38de564..043023e1b 100644 --- a/backend/internal/adapters/agent/copilot/copilot.go +++ b/backend/internal/adapters/agent/copilot/copilot.go @@ -28,19 +28,17 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - adapterID = "copilot" - - copilotTitleMetadataKey = "title" - copilotSummaryMetadataKey = "summary" -) +const adapterID = "copilot" // Plugin is the GitHub Copilot CLI agent adapter. It is safe for concurrent use; // the binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -66,14 +64,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Copilot exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Copilot session: // // copilot [permission flags] @@ -95,7 +85,9 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( } // GetPromptDeliveryStrategy reports that Copilot receives its prompt after the -// interactive process starts. +// interactive process starts. This overrides the agentbase.Base default +// (in-command) because Copilot's `-p` programmatic mode exits when done, which +// would leave AO's terminal pane dead. func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { if err := ctx.Err(); err != nil { return "", err @@ -138,21 +130,16 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[copilotTitleMetadataKey], - Summary: session.Metadata[copilotSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCopilotBinary returns the path to the copilot binary on this machine, // searching PATH then a handful of well-known install locations (npm global, -// Homebrew). Returns "copilot" as a last-ditch fallback so callers see a clear -// "command not found" rather than an empty argv. +// Homebrew, the VS Code extension's bundled CLI). When the resolved path is the +// npm-loader shim, the platform-native binary is returned instead. This resolver +// stays hand-rolled (rather than binaryutil.ResolveBinary) because of that +// native-loader indirection. func ResolveCopilotBinary(ctx context.Context) (string, error) { if err := ctx.Err(); err != nil { return "", err @@ -179,7 +166,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { candidates = append(candidates, filepath.Join(home, ".copilot", "bin", "copilot.exe")) } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -211,7 +198,7 @@ func ResolveCopilotBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { if native := copilotNativeBinaryForLoader(candidate); native != "" { return native, nil } @@ -238,7 +225,7 @@ func copilotNativeBinaryForLoader(path string) string { platform = "darwin" } native := filepath.Join(filepath.Dir(resolved), "node_modules", ".bin", "copilot-"+platform+"-"+runtime.GOARCH) - if fileExists(native) { + if hookutil.FileExists(native) { return native } return "" @@ -263,12 +250,12 @@ func (p *Plugin) copilotBinary(ctx context.Context) (string, error) { // appendApprovalFlags maps AO's 4 permission modes onto Copilot CLI approval // flags (https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-programmatic-reference): // -// default → no flag (defer to ~/.copilot config / per-tool prompts) -// accept-edits → --allow-tool 'write' (auto-approve file edits only) -// auto → --allow-all-tools (auto-approve every tool, still scoped paths/urls) -// bypass-permissions → --allow-all (full bypass: tools, paths, urls) +// default -> no flag (defer to ~/.copilot config / per-tool prompts) +// accept-edits -> --allow-tool 'write' (auto-approve file edits only) +// auto -> --allow-all-tools (auto-approve every tool, still scoped paths/urls) +// bypass-permissions -> --allow-all (full bypass: tools, paths, urls) func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.copilot config / interactive prompts. case ports.PermissionModeAcceptEdits: @@ -279,20 +266,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--allow-all") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/copilot/copilot_test.go b/backend/internal/adapters/agent/copilot/copilot_test.go index a81b8b520..711e37209 100644 --- a/backend/internal/adapters/agent/copilot/copilot_test.go +++ b/backend/internal/adapters/agent/copilot/copilot_test.go @@ -9,7 +9,6 @@ import ( "runtime" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -118,7 +117,7 @@ func TestGetLaunchCommandRespectsCanceledContext(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -130,7 +129,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "copilot"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -309,8 +308,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "uuid-123", - copilotTitleMetadataKey: "Fix login redirect", - copilotSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -507,29 +506,6 @@ func TestCopilotManagedHooksUseDocumentedEventNames(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - wantState domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - state, ok := DeriveActivityState(tt.event, nil) - if state != tt.wantState || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) - } - }) - } -} - func contains(values []string, needle string) bool { for _, value := range values { if value == needle { diff --git a/backend/internal/adapters/agent/copilot/hooks.go b/backend/internal/adapters/agent/copilot/hooks.go index 65e2e280d..39f02ae61 100644 --- a/backend/internal/adapters/agent/copilot/hooks.go +++ b/backend/internal/adapters/agent/copilot/hooks.go @@ -238,40 +238,12 @@ func writeCopilotHooks(hooksPath string, file copilotHookFile) error { return fmt.Errorf("encode %s: %w", hooksPath, err) } data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", hooksPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file in the same directory -// followed by a rename, so a crash or signal mid-write can't leave a truncated -// or empty file that Copilot then fails to parse (silently disabling hooks). -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() // no-op once renamed - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - // isCopilotManagedHook reports whether an entry is one AO owns, recognized by the // command prefix on either the bash or powershell command. func isCopilotManagedHook(entry copilotHookEntry) bool { diff --git a/backend/internal/adapters/agent/crush/crush.go b/backend/internal/adapters/agent/crush/crush.go index 6f192c8a3..3971e573c 100644 --- a/backend/internal/adapters/agent/crush/crush.go +++ b/backend/internal/adapters/agent/crush/crush.go @@ -8,15 +8,12 @@ package crush import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -29,6 +26,7 @@ const ( // Plugin is the Crush agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Crush exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start an interactive Crush session. // Shape: // @@ -102,15 +92,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Crush receives its prompt in the -// launch command itself as a positional argument. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Crush session: // `crush [--cwd <WorkspacePath>] [--yolo] --session <agentSessionId>`. // It re-applies the permission flag but not the prompt, which the session @@ -143,83 +124,24 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo surfaces Crush session metadata. Currently a no-op since Crush -// doesn't have full hooks support like Claude Code and Codex. Returns false -// to indicate no metadata is available. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - // No-op for now since Crush doesn't have full hooks support - return ports.SessionInfo{}, false, nil +var crushBinarySpec = binaryutil.BinarySpec{ + Label: "crush", + Names: []string{"crush"}, + WinNames: []string{"crush.cmd", "crush.exe", "crush"}, + UnixPaths: []string{"/usr/local/bin/crush", "/opt/homebrew/bin/crush"}, + UnixHomePaths: [][]string{{".local", "bin", "crush"}, {".cargo", "bin", "crush"}, {".npm", "bin", "crush"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "crush.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "crush.exe"}}, + }, } // ResolveCrushBinary returns the path to the crush binary on this machine, -// searching PATH then a handful of well-known install locations. -// Returns "crush" as a last-ditch fallback. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when crush is absent. func ResolveCrushBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"crush.cmd", "crush.exe", "crush"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "crush.cmd"), - filepath.Join(appData, "npm", "crush.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "crush.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("crush"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/crush", - "/opt/homebrew/bin/crush", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "crush"), - filepath.Join(home, ".cargo", "bin", "crush"), - filepath.Join(home, ".npm", "bin", "crush"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("crush: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, crushBinarySpec) } func (p *Plugin) crushBinary(ctx context.Context) (string, error) { @@ -237,8 +159,3 @@ func (p *Plugin) crushBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/crush/crush_test.go b/backend/internal/adapters/agent/crush/crush_test.go index 45756dc14..b7cb1b26e 100644 --- a/backend/internal/adapters/agent/crush/crush_test.go +++ b/backend/internal/adapters/agent/crush/crush_test.go @@ -90,7 +90,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "crush"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { diff --git a/backend/internal/adapters/agent/cursor/activity.go b/backend/internal/adapters/agent/cursor/activity.go deleted file mode 100644 index 068ce985a..000000000 --- a/backend/internal/adapters/agent/cursor/activity.go +++ /dev/null @@ -1,30 +0,0 @@ -package cursor - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Cursor hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in cursorManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Cursor event name. Cursor currently has no SessionEnd/Notification -// equivalent in the adapter, so runtime exit still falls back to the reaper. -// -// TODO(cursor): ActivityExited is still runtime-observation-owned. If Cursor -// adds a native session/process-end hook, map that hook to ActivityExited here. -// Until then, make sure the lifecycle reaper can still mark a dead Cursor -// runtime as exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/cursor/activity_test.go b/backend/internal/adapters/agent/cursor/activity_test.go deleted file mode 100644 index 9b9e13227..000000000 --- a/backend/internal/adapters/agent/cursor/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package cursor - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - name string - event string - want domain.ActivityState - wantOK bool - }{ - {"session start -> active", "session-start", domain.ActivityActive, true}, - {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, - {"stop -> idle", "stop", domain.ActivityIdle, true}, - {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, - {"unknown event -> no signal", "frobnicate", "", false}, - {"native event name -> no signal", "beforeShellExecution", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", - tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} diff --git a/backend/internal/adapters/agent/cursor/cursor.go b/backend/internal/adapters/agent/cursor/cursor.go index ecc883556..9b9dc9dfd 100644 --- a/backend/internal/adapters/agent/cursor/cursor.go +++ b/backend/internal/adapters/agent/cursor/cursor.go @@ -18,17 +18,15 @@ import ( "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - cursorTitleMetadataKey = "title" - cursorSummaryMetadataKey = "summary" -) - // Plugin is the Cursor agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -54,14 +52,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Cursor exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Cursor CLI session: // // cursor-agent -p --output-format stream-json --trust [permission flags] <prompt> @@ -92,15 +82,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Cursor receives its prompt in the -// launch command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Cursor CLI // session: // @@ -136,15 +117,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[cursorTitleMetadataKey], - Summary: session.Metadata[cursorSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveCursorBinary returns the path to the cursor-agent binary on this @@ -183,7 +157,7 @@ func ResolveCursorBinary(ctx context.Context) (string, error) { ) for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -211,7 +185,7 @@ func (p *Plugin) cursorBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Cursor config approvalMode. case ports.PermissionModeAcceptEdits: @@ -223,20 +197,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/cursor/cursor_test.go b/backend/internal/adapters/agent/cursor/cursor_test.go index 6600a9cd4..3e20d24a9 100644 --- a/backend/internal/adapters/agent/cursor/cursor_test.go +++ b/backend/internal/adapters/agent/cursor/cursor_test.go @@ -113,7 +113,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -125,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "cursor-agent"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -200,8 +200,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "chat-123", - cursorTitleMetadataKey: "Fix login redirect", - cursorSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) diff --git a/backend/internal/adapters/agent/devin/devin.go b/backend/internal/adapters/agent/devin/devin.go index dade0428f..55097c34a 100644 --- a/backend/internal/adapters/agent/devin/devin.go +++ b/backend/internal/adapters/agent/devin/devin.go @@ -24,26 +24,30 @@ package devin import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - devinTitleMetadataKey = "title" - devinSummaryMetadataKey = "summary" -) +var devinBinarySpec = binaryutil.BinarySpec{ + Label: "devin", + Names: []string{"devin"}, + WinNames: []string{"devin.cmd", "devin.exe", "devin"}, + UnixPaths: []string{"/usr/local/bin/devin", "/opt/homebrew/bin/devin"}, + UnixHomePaths: [][]string{{".devin", "bin", "devin"}, {".local", "bin", "devin"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinHome, Parts: []string{".devin", "bin", "devin.exe"}}, + }, +} // Plugin is the Devin for Terminal agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -69,14 +73,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `devin [--permission-mode <mode>] -p <prompt>`. // Prompt is delivered via -p (in command, non-interactive print mode). // @@ -99,14 +95,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because Devin for Terminal // has a documented Claude Code compatibility layer. // @@ -161,74 +149,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[devinTitleMetadataKey], - Summary: session.Metadata[devinSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveDevinBinary finds the `devin` binary (Cognition Devin for Terminal CLI). func ResolveDevinBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"devin.cmd", "devin.exe", "devin"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".devin", "bin", "devin.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("devin"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/devin", - "/opt/homebrew/bin/devin", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".devin", "bin", "devin"), - filepath.Join(home, ".local", "bin", "devin"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("devin: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, devinBinarySpec) } func (p *Plugin) devinBinary(ctx context.Context) (string, error) { @@ -251,7 +178,7 @@ func (p *Plugin) devinBinary(ctx context.Context) (string, error) { // permission values (`auto`/normal and `dangerous`/bypass), per // `devin --permission-mode -h`. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to ~/.config/devin/config.json (default mode is auto). case ports.PermissionModeAcceptEdits: @@ -264,20 +191,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "dangerous") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/devin/devin_test.go b/backend/internal/adapters/agent/devin/devin_test.go index 26159b811..9fe36cf07 100644 --- a/backend/internal/adapters/agent/devin/devin_test.go +++ b/backend/internal/adapters/agent/devin/devin_test.go @@ -195,8 +195,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "devin-ses-1", - devinTitleMetadataKey: "Fix login redirect", - devinSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/droid.go b/backend/internal/adapters/agent/droid/droid.go index 7643ab141..a06f0e02f 100644 --- a/backend/internal/adapters/agent/droid/droid.go +++ b/backend/internal/adapters/agent/droid/droid.go @@ -23,28 +23,21 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - // Normalized session-metadata keys the hooks persist into the AO session - // store and SessionInfo reads back. Shared vocabulary with the Codex, Grok, - // and opencode adapters so the dashboard treats every agent uniformly. - droidTitleMetadataKey = "title" - droidSummaryMetadataKey = "summary" -) - // Plugin is the Droid agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -70,14 +63,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Droid session: // // droid [--settings <path>] [--append-system-prompt[-file] <x>] [prompt] @@ -115,15 +100,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Droid receives its prompt in the launch -// command itself (the positional prompt argument). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Droid session: // `droid [--settings <path>] -r <agentSessionId>`. It re-applies the permission // autonomy (resume otherwise reverts to the configured default) but not the @@ -161,15 +137,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[droidTitleMetadataKey], - Summary: session.Metadata[droidSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // droidAutonomyLevel maps an AO permission mode onto Droid's @@ -182,7 +151,7 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por // bypass-permissions → high (max interactive autonomy; Droid's interactive // TUI has no exec-style --skip-permissions-unsafe) func droidAutonomyLevel(mode ports.PermissionMode) string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "low" case ports.PermissionModeAuto: @@ -249,73 +218,24 @@ func sanitizeSessionID(id string) string { return b.String() } -// ResolveDroidBinary finds the `droid` binary (Factory Droid CLI), searching -// PATH then a handful of well-known install locations. Returns "droid" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +var droidBinarySpec = binaryutil.BinarySpec{ + Label: "droid", + Names: []string{"droid"}, + WinNames: []string{"droid.cmd", "droid.exe", "droid"}, + UnixPaths: []string{"/usr/local/bin/droid", "/opt/homebrew/bin/droid"}, + UnixHomePaths: [][]string{{".local", "bin", "droid"}, {".factory", "bin", "droid"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "droid.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".factory", "bin", "droid.exe"}}, + }, +} + +// ResolveDroidBinary returns the path to the droid binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveDroidBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"droid.cmd", "droid.exe", "droid"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "droid.cmd"), - filepath.Join(appData, "npm", "droid.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "droid.exe"), - filepath.Join(home, ".factory", "bin", "droid.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("droid"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/droid", - "/opt/homebrew/bin/droid", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "droid"), - filepath.Join(home, ".factory", "bin", "droid"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("droid: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, droidBinarySpec) } func (p *Plugin) droidBinary(ctx context.Context) (string, error) { @@ -333,21 +253,3 @@ func (p *Plugin) droidBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to Droid's own settings (no flag). - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/droid/droid_test.go b/backend/internal/adapters/agent/droid/droid_test.go index 2607372c3..2c47c3bae 100644 --- a/backend/internal/adapters/agent/droid/droid_test.go +++ b/backend/internal/adapters/agent/droid/droid_test.go @@ -178,8 +178,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { info, ok, err := plugin.SessionInfo(context.Background(), ports.SessionRef{ Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "droid-ses-1", - droidTitleMetadataKey: "Fix login redirect", - droidSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", }, }) if err != nil { diff --git a/backend/internal/adapters/agent/droid/hooks.go b/backend/internal/adapters/agent/droid/hooks.go index 55c4f561c..4b55ebf4a 100644 --- a/backend/internal/adapters/agent/droid/hooks.go +++ b/backend/internal/adapters/agent/droid/hooks.go @@ -2,15 +2,9 @@ package droid import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "sort" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -18,49 +12,20 @@ const ( droidSettingsDirName = ".factory" droidHooksFileName = "hooks.json" - // droidHookCommandPrefix identifies the hook commands AO owns. Every managed - // command starts with it, so install can skip duplicates and uninstall can - // recognize AO entries by prefix without an embedded template to diff - // against. The CLI dispatcher routes `ao hooks droid <event>` to the Droid - // activity deriver. + // droidHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix. droidHookCommandPrefix = "ao hooks droid " droidHookTimeout = 30 ) -type droidMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart serializes - // with its "startup" matcher; UserPromptSubmit/Stop/Notification/SessionEnd - // omit it (Droid ignores matcher for those events). omitempty drops a nil - // matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []droidHookEntry `json:"hooks"` -} - -type droidHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// droidHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded settings file. -type droidHookSpec struct { - Event string - Matcher *string - Command string -} - // droidStartupMatcher is referenced by pointer so SessionStart serializes with // its "startup" source matcher. var droidStartupMatcher = "startup" // droidManagedHooks is the source of truth for the hooks AO installs: // SessionStart (under the "startup" matcher), UserPromptSubmit, Stop, -// Notification, and SessionEnd. They report normalized activity-state signals -// back into AO's store (see DeriveActivityState). The non-SessionStart events -// carry no matcher: each installs once and fires for every sub-type, and the -// handler filters on the payload where it must. -var droidManagedHooks = []droidHookSpec{ +// Notification, and SessionEnd. +var droidManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &droidStartupMatcher, Command: droidHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: droidHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: droidHookCommandPrefix + "stop"}, @@ -68,287 +33,30 @@ var droidManagedHooks = []droidHookSpec{ {Event: "SessionEnd", Command: droidHookCommandPrefix + "session-end"}, } -// GetAgentHooks installs AO's Droid hooks into the worktree-local -// .factory/hooks.json file (the project-scope hooks config Droid reads). The -// hooks report normalized activity-state signals back into AO's store. Existing -// hooks and unrelated keys are preserved, and duplicate AO commands are not -// appended, so the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("droid.GetAgentHooks: WorkspacePath is required") - } - - hooksPath := droidHooksPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - - byEvent := groupDroidHooksByEvent() - events := make([]string, 0, len(byEvent)) - for event := range byEvent { - events = append(events, event) - } - sort.Strings(events) - for _, event := range events { - specs := byEvent[event] - var existingGroups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !droidHookCommandExists(existingGroups, spec.Command) { - entry := droidHookEntry{Type: "command", Command: spec.Command, Timeout: droidHookTimeout} - existingGroups = addDroidHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalDroidHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - } - - if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("droid.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), droidHooksFileName); err != nil { - return fmt.Errorf("droid.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Droid hooks from the workspace-local -// .factory/hooks.json file, leaving user-defined hooks and unrelated keys -// untouched. A missing file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("droid.UninstallHooks: workspacePath is required") - } - - hooksPath := droidHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - - for _, event := range droidManagedEvents() { - var groups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - groups = removeDroidManagedHooks(groups) - if err := marshalDroidHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - } - - if err := writeDroidHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("droid.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Droid hook is present in the -// workspace-local hooks file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("droid.AreHooksInstalled: workspacePath is required") - } - - hooksPath := droidHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readDroidHooks(hooksPath) - if err != nil { - return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) - } - - for _, event := range droidManagedEvents() { - var groups []droidMatcherGroup - if err := parseDroidHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("droid.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isDroidManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// droidHooks manages AO's hooks in the workspace-local .factory/hooks.json file. +var droidHooks = hooksjson.Manager{ + Label: "droid", + CommandPrefix: droidHookCommandPrefix, + Timeout: droidHookTimeout, + Path: droidHooksPath, + Managed: droidManagedHooks, } func droidHooksPath(workspacePath string) string { return filepath.Join(workspacePath, droidSettingsDirName, droidHooksFileName) } -// readDroidHooks loads the hooks file into a top-level raw map plus the decoded -// "hooks" sub-map, preserving every key AO doesn't manage. A missing or empty -// file yields empty maps. -func readDroidHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Droid hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return droidHooks.Install(ctx, cfg.WorkspacePath) } -// writeDroidHooks folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeDroidHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { - return fmt.Errorf("create hooks dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", hooksPath, err) - } - data = append(data, '\n') - if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", hooksPath, err) - } - return nil +// UninstallHooks removes AO's Droid hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return droidHooks.Uninstall(ctx, workspacePath) } -// groupDroidHooksByEvent groups the managed hook specs by their Droid event so -// each event's array is rewritten once. -func groupDroidHooksByEvent() map[string][]droidHookSpec { - byEvent := map[string][]droidHookSpec{} - for _, spec := range droidManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// droidManagedEvents returns the distinct Droid events AO manages, in the order -// they first appear in droidManagedHooks. -func droidManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(droidManagedHooks)) - for _, spec := range droidManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isDroidManagedHook(command string) bool { - return strings.HasPrefix(command, droidHookCommandPrefix) -} - -// removeDroidManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks so the event array doesn't accumulate empty matcher -// objects. -func removeDroidManagedHooks(groups []droidMatcherGroup) []droidMatcherGroup { - result := make([]droidMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]droidHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isDroidManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseDroidHookType(rawHooks map[string]json.RawMessage, event string, target *[]droidMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalDroidHookType(rawHooks map[string]json.RawMessage, event string, groups []droidMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func droidHookCommandExists(groups []droidMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addDroidHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addDroidHook(groups []droidMatcherGroup, hook droidHookEntry, matcher *string) []droidMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, droidMatcherGroup{Matcher: matcher, Hooks: []droidHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Droid hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return droidHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/goose/activity.go b/backend/internal/adapters/agent/goose/activity.go deleted file mode 100644 index 183568925..000000000 --- a/backend/internal/adapters/agent/goose/activity.go +++ /dev/null @@ -1,35 +0,0 @@ -package goose - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Goose hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in gooseManagedHooks -// ("session-start", "user-prompt-submit", "stop", "permission-request"), not -// the native Goose event name. -// -// Goose's native hook surface (as of 2026-05) emits SessionStart / -// UserPromptSubmit / Stop / SessionEnd plus the tool-use events, but has no -// dedicated permission/approval event yet, so AO does not install a -// "permission-request" hook today. The case is kept here so that, if a future -// Goose release adds an approval lifecycle event, mapping it to waiting_input is -// a one-line hooks.go change with no deriver edit needed. -// -// TODO(goose): ActivityExited is still runtime-observation-owned. Goose has a -// native SessionEnd hook; if AO starts installing it, map it to ActivityExited -// here. Until then, the lifecycle reaper marks a dead Goose runtime as exited. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/goose/activity_test.go b/backend/internal/adapters/agent/goose/activity_test.go deleted file mode 100644 index 224ac8a4e..000000000 --- a/backend/internal/adapters/agent/goose/activity_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package goose - -import ( - "testing" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" -) - -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - name string - event string - want domain.ActivityState - wantOK bool - }{ - {"session start -> active", "session-start", domain.ActivityActive, true}, - {"user prompt -> active", "user-prompt-submit", domain.ActivityActive, true}, - {"stop -> idle", "stop", domain.ActivityIdle, true}, - {"permission request -> waiting input", "permission-request", domain.ActivityWaitingInput, true}, - {"unknown event -> no signal", "frobnicate", "", false}, - {"empty event -> no signal", "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, ok := DeriveActivityState(tt.event, []byte(`{}`)) - if got != tt.want || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", - tt.event, got, ok, tt.want, tt.wantOK) - } - }) - } -} diff --git a/backend/internal/adapters/agent/goose/goose.go b/backend/internal/adapters/agent/goose/goose.go index faa39963a..f2f81768c 100644 --- a/backend/internal/adapters/agent/goose/goose.go +++ b/backend/internal/adapters/agent/goose/goose.go @@ -25,22 +25,18 @@ import ( "context" "fmt" "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( adapterID = "goose" - gooseTitleMetadataKey = "title" - gooseSummaryMetadataKey = "summary" - // gooseModeEnvVar is the only permission-control surface Goose honors: the // approval mode is read from this process env var, not from any CLI flag. gooseModeEnvVar = "GOOSE_MODE" @@ -49,6 +45,7 @@ const ( // Plugin is the Goose agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -74,14 +71,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Goose exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Goose session: // // [env GOOSE_MODE=<mode>] goose run [--system <text>] -t <prompt> @@ -117,15 +106,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Goose receives its prompt in the launch -// command itself (via `-t`). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Goose session: // // [env GOOSE_MODE=<mode>] goose run --resume --session-id <agentSessionId> @@ -156,15 +136,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[gooseTitleMetadataKey], - Summary: session.Metadata[gooseSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // systemPromptText returns the system instructions to inject. Goose's `--system` @@ -210,7 +183,7 @@ func gooseModeEnvPrefix(mode ports.PermissionMode) []string { // - bypass-permissions → auto: Goose's fully-autonomous mode is the nearest // equivalent to bypass. func gooseMode(mode ports.PermissionMode) string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return "smart_approve" case ports.PermissionModeAuto: @@ -222,90 +195,26 @@ func gooseMode(mode ports.PermissionMode) string { } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to Goose's own config (no env). - return ports.PermissionModeDefault - } +// gooseBinarySpec locates the goose binary: PATH first, then the install +// script's ~/.local/bin, Homebrew, Cargo, and npm global locations. +var gooseBinarySpec = binaryutil.BinarySpec{ + Label: "goose", + Names: []string{"goose"}, + WinNames: []string{"goose.cmd", "goose.exe", "goose"}, + UnixPaths: []string{"/usr/local/bin/goose", "/opt/homebrew/bin/goose"}, + UnixHomePaths: [][]string{{".local", "bin", "goose"}, {".cargo", "bin", "goose"}, {".npm", "bin", "goose"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "goose.exe"}}, + {Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "goose", "goose.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".cargo", "bin", "goose.exe"}}, + }, } -// ResolveGooseBinary returns the path to the goose binary on this machine, -// searching PATH then a handful of well-known install locations (the install -// script's ~/.local/bin, Homebrew, Cargo, npm global). Returns "goose" as a -// last-ditch fallback so callers see a clear "command not found" rather than an -// empty argv. +// ResolveGooseBinary returns the path to the goose binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveGooseBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"goose.cmd", "goose.exe", "goose"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "goose.cmd"), - filepath.Join(appData, "npm", "goose.exe"), - ) - } - if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - candidates = append(candidates, filepath.Join(localAppData, "Programs", "goose", "goose.exe")) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, filepath.Join(home, ".cargo", "bin", "goose.exe")) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("goose"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/goose", - "/opt/homebrew/bin/goose", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "goose"), - filepath.Join(home, ".cargo", "bin", "goose"), - filepath.Join(home, ".npm", "bin", "goose"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("goose: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, gooseBinarySpec) } func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { @@ -323,8 +232,3 @@ func (p *Plugin) gooseBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/goose/goose_test.go b/backend/internal/adapters/agent/goose/goose_test.go index 8c2da8af1..c9f8ac562 100644 --- a/backend/internal/adapters/agent/goose/goose_test.go +++ b/backend/internal/adapters/agent/goose/goose_test.go @@ -9,6 +9,7 @@ import ( "reflect" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -141,7 +142,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "goose"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -153,7 +154,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "goose"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -419,8 +420,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "thread-123", - gooseTitleMetadataKey: "Fix login redirect", - gooseSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -511,7 +512,13 @@ func containsSubsequence(values []string, needle []string) bool { return false } -func countGooseHookCommand(entries []gooseMatcherGroup, command string) int { +// gooseHookFile is the on-disk shape of the hooks file, used to decode and +// assert on what GetAgentHooks wrote. +type gooseHookFile struct { + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` +} + +func countGooseHookCommand(entries []hooksjson.MatcherGroup, command string) int { count := 0 for _, entry := range entries { for _, hook := range entry.Hooks { diff --git a/backend/internal/adapters/agent/goose/hooks.go b/backend/internal/adapters/agent/goose/hooks.go index f631659e5..da935f14b 100644 --- a/backend/internal/adapters/agent/goose/hooks.go +++ b/backend/internal/adapters/agent/goose/hooks.go @@ -2,22 +2,16 @@ package goose import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) const ( - // goosePluginDirName is the AO plugin directory under a workspace's - // .agents/plugins/. Goose auto-discovers any plugin dir containing a - // hooks/hooks.json at startup; unlike Codex there is no separate feature - // flag to toggle, so installing the file is sufficient. + // Goose auto-discovers any plugin dir containing a hooks/hooks.json at + // startup; unlike Codex there is no separate feature flag to toggle, so + // installing the file is sufficient. gooseHooksRootDirName = ".agents" goosePluginsDirName = "plugins" goosePluginName = "ao" @@ -25,332 +19,45 @@ const ( gooseHooksFileName = "hooks.json" // gooseHookCommandPrefix identifies the hook commands AO owns, so install - // skips duplicates and uninstall recognizes AO entries by prefix without an - // embedded template to diff against. + // skips duplicates and uninstall recognizes AO entries by prefix. gooseHookCommandPrefix = "ao hooks goose " gooseHookTimeout = 30 ) -// gooseHookFile is the on-disk shape of .agents/plugins/ao/hooks/hooks.json. It -// is used by tests to decode the written file. -type gooseHookFile struct { - Hooks map[string][]gooseMatcherGroup `json:"hooks"` -} - -type gooseMatcherGroup struct { - Matcher *string `json:"matcher,omitempty"` - Hooks []gooseHookEntry `json:"hooks"` -} - -type gooseHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// gooseHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded hooks file. -type gooseHookSpec struct { - Event string - Command string -} - // gooseManagedHooks is the source of truth for the hooks AO installs. Goose // groups every hook under the nil matcher. Goose has no permission/approval // lifecycle event yet, so AO installs only the session/prompt/stop signals. -var gooseManagedHooks = []gooseHookSpec{ +var gooseManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Command: gooseHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: gooseHookCommandPrefix + "user-prompt-submit"}, {Event: "Stop", Command: gooseHookCommandPrefix + "stop"}, } -// GetAgentHooks installs AO's Goose hooks into the worktree-local -// .agents/plugins/ao/hooks/hooks.json file. Existing hook entries are preserved -// and duplicate AO commands are not appended. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("goose.GetAgentHooks: WorkspacePath is required") - } - - hooksPath := gooseHooksPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - - for event, specs := range groupGooseHooksByEvent() { - var existingGroups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !gooseHookCommandExists(existingGroups, spec.Command) { - entry := gooseHookEntry{Type: "command", Command: spec.Command, Timeout: gooseHookTimeout} - existingGroups = addGooseHook(existingGroups, entry) - } - } - if err := marshalGooseHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - } - - if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("goose.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), gooseHooksFileName); err != nil { - return fmt.Errorf("goose.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Goose hooks from the workspace-local -// .agents/plugins/ao/hooks/hooks.json file, leaving user-defined hooks -// untouched. A missing file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("goose.UninstallHooks: workspacePath is required") - } - - hooksPath := gooseHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - - for _, event := range gooseManagedEvents() { - var groups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - groups = removeGooseManagedHooks(groups) - if err := marshalGooseHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - } - - if err := writeGooseHooks(hooksPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("goose.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Goose hook is present in the -// workspace-local hooks file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("goose.AreHooksInstalled: workspacePath is required") - } - - hooksPath := gooseHooksPath(workspacePath) - if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readGooseHooks(hooksPath) - if err != nil { - return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) - } - - for _, event := range gooseManagedEvents() { - var groups []gooseMatcherGroup - if err := parseGooseHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("goose.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isGooseManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// gooseHooks manages AO's hooks in the workspace-local +// .agents/plugins/ao/hooks/hooks.json file. +var gooseHooks = hooksjson.Manager{ + Label: "goose", + CommandPrefix: gooseHookCommandPrefix, + Timeout: gooseHookTimeout, + Path: gooseHooksPath, + Managed: gooseManagedHooks, } func gooseHooksPath(workspacePath string) string { return filepath.Join(workspacePath, gooseHooksRootDirName, goosePluginsDirName, goosePluginName, gooseHooksSubDirName, gooseHooksFileName) } -// readGooseHooks loads the hooks file into a top-level raw map plus the decoded -// "hooks" sub-map, preserving keys AO doesn't manage. A missing or empty file -// yields empty maps. -func readGooseHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Goose hooks, preserving user-defined hooks. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return gooseHooks.Install(ctx, cfg.WorkspacePath) } -// writeGooseHooks folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeGooseHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { - return fmt.Errorf("create hook dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", hooksPath, err) - } - data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", hooksPath, err) - } - return nil +// UninstallHooks removes AO's Goose hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return gooseHooks.Uninstall(ctx, workspacePath) } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty file that Goose then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - -// groupGooseHooksByEvent groups the managed hook specs by their Goose event so -// each event's array is rewritten once. -func groupGooseHooksByEvent() map[string][]gooseHookSpec { - byEvent := map[string][]gooseHookSpec{} - for _, spec := range gooseManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// gooseManagedEvents returns the distinct Goose events AO manages, in the order -// they first appear in gooseManagedHooks. -func gooseManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(gooseManagedHooks)) - for _, spec := range gooseManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isGooseManagedHook(command string) bool { - return strings.HasPrefix(command, gooseHookCommandPrefix) -} - -// removeGooseManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks. -func removeGooseManagedHooks(groups []gooseMatcherGroup) []gooseMatcherGroup { - result := make([]gooseMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]gooseHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isGooseManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseGooseHookType(rawHooks map[string]json.RawMessage, event string, target *[]gooseMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalGooseHookType(rawHooks map[string]json.RawMessage, event string, groups []gooseMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func gooseHookCommandExists(groups []gooseMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -func addGooseHook(groups []gooseMatcherGroup, hook gooseHookEntry) []gooseMatcherGroup { - for i, group := range groups { - if group.Matcher == nil { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, gooseMatcherGroup{ - Matcher: nil, - Hooks: []gooseHookEntry{hook}, - }) +// AreHooksInstalled reports whether any AO Goose hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return gooseHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/grok/grok.go b/backend/internal/adapters/agent/grok/grok.go index ddebb78a0..0f5ca4cfb 100644 --- a/backend/internal/adapters/agent/grok/grok.go +++ b/backend/internal/adapters/agent/grok/grok.go @@ -16,21 +16,32 @@ package grok import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) +var grokBinarySpec = binaryutil.BinarySpec{ + Label: "grok", + Names: []string{"grok"}, + WinNames: []string{"grok.cmd", "grok.exe", "grok"}, + UnixPaths: []string{"/usr/local/bin/grok", "/opt/homebrew/bin/grok"}, + UnixHomePaths: [][]string{{".grok", "bin", "grok"}, {".local", "bin", "grok"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "grok.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".grok", "bin", "grok.exe"}}, + }, +} + // Plugin is the Grok Build agent adapter. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -56,14 +67,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds `grok --no-auto-update [--permission-mode <mode>] -p <prompt>`. // Prompt is delivered via -p (in command). // @@ -85,14 +88,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that the prompt is delivered in the launch command. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetAgentHooks reuses the Claude Code hook installer because Grok Build // has a full Claude Code compatibility layer. // @@ -166,81 +161,13 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - // The keys written by claude hooks (which we install for grok too). - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[ports.MetadataKeyTitle], - Summary: session.Metadata[ports.MetadataKeySummary], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // ResolveGrokBinary finds the `grok` binary (xAI Grok Build CLI). func ResolveGrokBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"grok.cmd", "grok.exe", "grok"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "grok.cmd"), - filepath.Join(appData, "npm", "grok.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("grok"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/grok", - "/opt/homebrew/bin/grok", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".grok", "bin", "grok"), - filepath.Join(home, ".local", "bin", "grok"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("grok: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, grokBinarySpec) } func (p *Plugin) grokBinary(ctx context.Context) (string, error) { @@ -260,7 +187,7 @@ func (p *Plugin) grokBinary(ctx context.Context) (string, error) { } func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's ~/.grok/config.toml (or default behavior). case ports.PermissionModeAcceptEdits: @@ -271,20 +198,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--permission-mode", "bypassPermissions") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/hooksjson/hooksjson.go b/backend/internal/adapters/agent/hooksjson/hooksjson.go new file mode 100644 index 000000000..21d497b54 --- /dev/null +++ b/backend/internal/adapters/agent/hooksjson/hooksjson.go @@ -0,0 +1,332 @@ +// Package hooksjson implements the matcher-group hooks file that several agents +// (claude-code, goose, qwen, agy, droid) share byte-for-byte in shape. Each such +// file is a JSON object with a "hooks" sub-map keyed by native event name, whose +// values are matcher groups ({matcher?, hooks:[{type,command,timeout}]}). The +// adapters differed only in the file path, the AO command prefix, the per-hook +// timeout, and which events they install, so they describe those with a Manager +// and share the install/uninstall/detect logic here. +// +// The read/write path preserves every top-level key and every user-defined hook +// AO does not own, and writes atomically, so installing AO's hooks never clobbers +// unrelated settings. +package hooksjson + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" +) + +// HookEntry is one command hook inside a matcher group. +type HookEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// MatcherGroup is a set of hooks sharing one matcher. Matcher is a pointer so it +// round-trips exactly: events that require a matcher (e.g. claude SessionStart's +// "startup") carry one; events that omit it serialize without the key. +type MatcherGroup struct { + Matcher *string `json:"matcher,omitempty"` + Hooks []HookEntry `json:"hooks"` +} + +// HookSpec describes one hook AO installs: the native event it attaches to, its +// optional matcher, and the command to run. Adapters define these in code rather +// than reading an embedded template. +type HookSpec struct { + Event string + Matcher *string + Command string +} + +// Manager installs, removes, and detects AO's hooks in one agent's matcher-group +// hooks file. Construct one per adapter with its file path, command prefix, +// per-hook timeout, and managed hook set. +type Manager struct { + // Label prefixes error messages, e.g. "claude-code" or "goose", so the + // wrapped error reads "<label>.GetAgentHooks: ...". + Label string + // CommandPrefix identifies AO-owned hook commands, e.g. "ao hooks goose ". + // Install skips commands already present and uninstall/detect match on it. + CommandPrefix string + // Timeout is written into each installed hook entry. + Timeout int + // Path returns the hooks file path for a workspace. + Path func(workspacePath string) string + // Managed is the set of hooks AO installs. + Managed []HookSpec +} + +// Install merges AO's managed hooks into the workspace's hooks file, preserving +// user-defined hooks and unrelated settings, and is idempotent (a command +// already present is not appended). It also writes a self-ignoring .gitignore +// covering the hooks file so it does not block worktree teardown. +func (m Manager) Install(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return fmt.Errorf("%s.GetAgentHooks: WorkspacePath is required", m.Label) + } + + hooksPath := m.Path(workspacePath) + topLevel, rawHooks, err := readHooksFile(hooksPath) + if err != nil { + return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err) + } + + for event, specs := range m.groupByEvent() { + var groups []MatcherGroup + if err := parseEvent(rawHooks, event, &groups); err != nil { + return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err) + } + for _, spec := range specs { + if !commandExists(groups, spec.Command) { + entry := HookEntry{Type: "command", Command: spec.Command, Timeout: m.Timeout} + groups = addHook(groups, entry, spec.Matcher) + } + } + if err := marshalEvent(rawHooks, event, groups); err != nil { + return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err) + } + } + + if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("%s.GetAgentHooks: %w", m.Label, err) + } + if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), filepath.Base(hooksPath)); err != nil { + return fmt.Errorf("%s.GetAgentHooks: gitignore: %w", m.Label, err) + } + return nil +} + +// Uninstall removes AO's hooks from the workspace's hooks file, leaving +// user-defined hooks and unrelated settings untouched. A missing file is a no-op. +func (m Manager) Uninstall(ctx context.Context, workspacePath string) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(workspacePath) == "" { + return fmt.Errorf("%s.UninstallHooks: workspacePath is required", m.Label) + } + + hooksPath := m.Path(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return nil + } + topLevel, rawHooks, err := readHooksFile(hooksPath) + if err != nil { + return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err) + } + + for _, event := range m.managedEvents() { + var groups []MatcherGroup + if err := parseEvent(rawHooks, event, &groups); err != nil { + return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err) + } + groups = removeManaged(groups, m.CommandPrefix) + if err := marshalEvent(rawHooks, event, groups); err != nil { + return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err) + } + } + + if err := writeHooksFile(hooksPath, topLevel, rawHooks); err != nil { + return fmt.Errorf("%s.UninstallHooks: %w", m.Label, err) + } + return nil +} + +// AreInstalled reports whether any AO hook is present in the workspace's hooks +// file. A missing file means none are installed. +func (m Manager) AreInstalled(ctx context.Context, workspacePath string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if strings.TrimSpace(workspacePath) == "" { + return false, fmt.Errorf("%s.AreHooksInstalled: workspacePath is required", m.Label) + } + + hooksPath := m.Path(workspacePath) + if _, err := os.Stat(hooksPath); errors.Is(err, os.ErrNotExist) { + return false, nil + } + _, rawHooks, err := readHooksFile(hooksPath) + if err != nil { + return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err) + } + + for _, event := range m.managedEvents() { + var groups []MatcherGroup + if err := parseEvent(rawHooks, event, &groups); err != nil { + return false, fmt.Errorf("%s.AreHooksInstalled: %w", m.Label, err) + } + for _, group := range groups { + for _, hook := range group.Hooks { + if strings.HasPrefix(hook.Command, m.CommandPrefix) { + return true, nil + } + } + } + } + return false, nil +} + +// groupByEvent groups the managed specs by event so each event array is +// rewritten once. +func (m Manager) groupByEvent() map[string][]HookSpec { + byEvent := map[string][]HookSpec{} + for _, spec := range m.Managed { + byEvent[spec.Event] = append(byEvent[spec.Event], spec) + } + return byEvent +} + +// managedEvents returns the distinct managed events, in first-seen order. +func (m Manager) managedEvents() []string { + seen := map[string]bool{} + events := make([]string, 0, len(m.Managed)) + for _, spec := range m.Managed { + if !seen[spec.Event] { + seen[spec.Event] = true + events = append(events, spec.Event) + } + } + return events +} + +// readHooksFile loads the file into a top-level raw map plus the decoded "hooks" +// sub-map, preserving every key AO doesn't manage. A missing or empty file +// yields empty maps. +func readHooksFile(hooksPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { + topLevel = map[string]json.RawMessage{} + rawHooks = map[string]json.RawMessage{} + + data, err := os.ReadFile(hooksPath) //nolint:gosec // path built from caller-owned workspace dir + if errors.Is(err, os.ErrNotExist) { + return topLevel, rawHooks, nil + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", hooksPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return topLevel, rawHooks, nil + } + if err := json.Unmarshal(data, &topLevel); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", hooksPath, err) + } + if hooksRaw, ok := topLevel["hooks"]; ok { + if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { + return nil, nil, fmt.Errorf("parse hooks in %s: %w", hooksPath, err) + } + } + return topLevel, rawHooks, nil +} + +// writeHooksFile folds rawHooks back into topLevel and writes the file +// atomically. An empty hooks map drops the "hooks" key entirely. +func writeHooksFile(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { + if len(rawHooks) == 0 { + delete(topLevel, "hooks") + } else { + hooksJSON, err := json.Marshal(rawHooks) + if err != nil { + return fmt.Errorf("encode hooks: %w", err) + } + topLevel["hooks"] = hooksJSON + } + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("create hook dir: %w", err) + } + data, err := json.MarshalIndent(topLevel, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", hooksPath, err) + } + data = append(data, '\n') + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { + return fmt.Errorf("write %s: %w", hooksPath, err) + } + return nil +} + +func parseEvent(rawHooks map[string]json.RawMessage, event string, target *[]MatcherGroup) error { + data, ok := rawHooks[event] + if !ok { + return nil + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("parse %s hooks: %w", event, err) + } + return nil +} + +func marshalEvent(rawHooks map[string]json.RawMessage, event string, groups []MatcherGroup) error { + if len(groups) == 0 { + delete(rawHooks, event) + return nil + } + data, err := json.Marshal(groups) + if err != nil { + return fmt.Errorf("encode %s hooks: %w", event, err) + } + rawHooks[event] = data + return nil +} + +func commandExists(groups []MatcherGroup, command string) bool { + for _, group := range groups { + for _, hook := range group.Hooks { + if hook.Command == command { + return true + } + } + } + return false +} + +// addHook appends hook to the group with a matching matcher, creating that group +// if none matches. +func addHook(groups []MatcherGroup, hook HookEntry, matcher *string) []MatcherGroup { + for i, group := range groups { + if matchersEqual(group.Matcher, matcher) { + groups[i].Hooks = append(groups[i].Hooks, hook) + return groups + } + } + return append(groups, MatcherGroup{Matcher: matcher, Hooks: []HookEntry{hook}}) +} + +// removeManaged strips AO hook entries (matched by command prefix) from every +// group, dropping any group left without hooks so the event array doesn't +// accumulate empty matcher objects. +func removeManaged(groups []MatcherGroup, prefix string) []MatcherGroup { + result := make([]MatcherGroup, 0, len(groups)) + for _, group := range groups { + kept := make([]HookEntry, 0, len(group.Hooks)) + for _, hook := range group.Hooks { + if !strings.HasPrefix(hook.Command, prefix) { + kept = append(kept, hook) + } + } + if len(kept) > 0 { + group.Hooks = kept + result = append(result, group) + } + } + return result +} + +func matchersEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/backend/internal/adapters/agent/hookutil/hookutil.go b/backend/internal/adapters/agent/hookutil/hookutil.go index cf1f6fe6d..dfb2b71d7 100644 --- a/backend/internal/adapters/agent/hookutil/hookutil.go +++ b/backend/internal/adapters/agent/hookutil/hookutil.go @@ -53,6 +53,14 @@ func EnsureWorkspaceGitignore(dir string, names ...string) error { return nil } +// FileExists reports whether path names an existing regular file (not a +// directory). Adapters use it when probing well-known install locations for an +// agent binary. +func FileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + // AtomicWriteFile writes data to path via a temp file in the same directory // followed by a rename, so a crash or signal mid-write can't leave a truncated // or empty file that the agent then fails to parse (silently disabling hooks). diff --git a/backend/internal/adapters/agent/kilocode/activity.go b/backend/internal/adapters/agent/kilocode/activity.go deleted file mode 100644 index 4f0dccaee..000000000 --- a/backend/internal/adapters/agent/kilocode/activity.go +++ /dev/null @@ -1,31 +0,0 @@ -package kilocode - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Kilo Code plugin hook event onto an AO activity -// state. The bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name the installed plugin shells via -// `ao hooks kilocode <event>` (see kilocodeManagedEvents in hooks.go), not a -// native Kilo event name. The plugin reports: -// - "session-start" → a Kilo session was created (turn begins). -// - "user-prompt-submit" → the user submitted a prompt (turn begins). -// - "permission-request" → Kilo is asking the user to approve a tool call. -// - "stop" → the current turn went idle/finished. -// -// Kilo has no native session/process-end plugin event the adapter maps to -// ActivityExited, so runtime exit still falls back to the lifecycle reaper. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/kilocode/hooks.go b/backend/internal/adapters/agent/kilocode/hooks.go index e0e2056ed..4f2c54e86 100644 --- a/backend/internal/adapters/agent/kilocode/hooks.go +++ b/backend/internal/adapters/agent/kilocode/hooks.go @@ -92,7 +92,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi if err := os.MkdirAll(filepath.Dir(pluginPath), 0o750); err != nil { return fmt.Errorf("kilocode.GetAgentHooks: create plugin dir: %w", err) } - if err := atomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil { + if err := hookutil.AtomicWriteFile(pluginPath, []byte(kilocodePluginSource), 0o600); err != nil { return fmt.Errorf("kilocode.GetAgentHooks: write plugin: %w", err) } if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(pluginPath), kilocodePluginFileName); err != nil { @@ -160,31 +160,3 @@ func isAOManagedPlugin(path string) (bool, error) { } return strings.Contains(string(data), kilocodePluginSentinel), nil } - -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated plugin file that Kilo then fails to import -// (silently disabling activity reporting). -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() // no-op once renamed - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} diff --git a/backend/internal/adapters/agent/kilocode/kilocode.go b/backend/internal/adapters/agent/kilocode/kilocode.go index e6db8c24c..3480eb1aa 100644 --- a/backend/internal/adapters/agent/kilocode/kilocode.go +++ b/backend/internal/adapters/agent/kilocode/kilocode.go @@ -24,15 +24,12 @@ package kilocode import ( "context" "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -40,18 +37,12 @@ const ( // adapterID is the registry id and the value users pass to // `ao spawn --agent`. It matches domain.HarnessKilocode. adapterID = "kilocode" - - // Normalized session-metadata keys the Kilo plugin persists into the AO - // session store and SessionInfo reads back. Shared vocabulary with the Codex - // and opencode adapters so the dashboard treats every agent uniformly. The - // agent-session-id key is the shared ports.MetadataKeyAgentSessionID. - kilocodeTitleMetadataKey = "title" - kilocodeSummaryMetadataKey = "summary" ) // Plugin is the Kilo Code agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -77,16 +68,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Kilo Code exposes none -// yet: model and agent selection are read from Kilo's own config -// (kilo.json / ~/.config/kilo), exactly as a normal launch. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive Kilo Code session. // Shape: // @@ -112,15 +93,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Kilo Code receives its prompt in the -// launch command itself (via --prompt). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Kilo Code // session: `[env KILO_CONFIG_CONTENT=<json>] kilocode --session <agentSessionId>`. // It re-applies the permission env (resume otherwise reverts to the configured @@ -152,15 +124,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[kilocodeTitleMetadataKey], - Summary: session.Metadata[kilocodeSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // kilocodePermissionEnvVar is the env var Kilo deep-merges as the @@ -176,15 +141,15 @@ const kilocodePermissionEnvVar = "KILO_CONFIG_CONTENT" // config (tool -> action, values "ask"/"allow"/"deny", verified via // `kilocode config check`). Tools left unset fall back to Kilo's own default // action ("ask"), so each mode only names the tools it relaxes: -// - default → nil: no env; Kilo's config decides every prompt. -// - accept-edits → edits ("write"/"edit"/"patch" gate on the "edit" +// - default -> nil: no env; Kilo's config decides every prompt. +// - accept-edits -> edits ("write"/"edit"/"patch" gate on the "edit" // key) auto-approved; bash and everything else still prompt. -// - auto → edits + bash auto-approved; network/other still prompt. +// - auto -> edits + bash auto-approved; network/other still prompt. // Kilo has no classifier/reviewer gate (unlike Claude Code's "auto"), so // this is the closest analog its flat allow/ask/deny config can express. -// - bypass-permissions → "*" wildcard-allows every tool: nothing prompts. +// - bypass-permissions -> "*" wildcard-allows every tool: nothing prompts. func kilocodePermissionConfig(mode ports.PermissionMode) map[string]string { - switch normalizePermissionMode(mode) { + switch ports.NormalizePermissionMode(mode) { case ports.PermissionModeAcceptEdits: return map[string]string{"edit": "allow"} case ports.PermissionModeAuto: @@ -224,81 +189,22 @@ func kilocodePermissionEnvPrefix(mode ports.PermissionMode) []string { return []string{"env", kilocodePermissionEnvVar + "=" + string(blob)} } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to Kilo's own config (no flag). - return ports.PermissionModeDefault - } +var kilocodeBinarySpec = binaryutil.BinarySpec{ + Label: "kilocode", + Names: []string{"kilocode"}, + WinNames: []string{"kilocode.cmd", "kilocode.exe", "kilocode"}, + UnixPaths: []string{"/usr/local/bin/kilocode", "/opt/homebrew/bin/kilocode"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "kilocode"}, {".npm", "bin", "kilocode"}, {".local", "bin", "kilocode"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "kilocode.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "kilocode.exe"}}, + }, } -// ResolveKilocodeBinary returns the path to the kilocode binary on this machine, -// searching PATH then a handful of well-known install locations (npm global -// bin, Homebrew). Returns "kilocode" as a last-ditch fallback so callers see a -// clear "command not found" rather than an empty argv. +// ResolveKilocodeBinary returns the path to the kilocode binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveKilocodeBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"kilocode.cmd", "kilocode.exe", "kilocode"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "kilocode.cmd"), - filepath.Join(appData, "npm", "kilocode.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("kilocode: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("kilocode"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/kilocode", - "/opt/homebrew/bin/kilocode", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "kilocode"), - filepath.Join(home, ".npm", "bin", "kilocode"), - filepath.Join(home, ".local", "bin", "kilocode"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("kilocode: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, kilocodeBinarySpec) } func (p *Plugin) kilocodeBinary(ctx context.Context) (string, error) { @@ -316,8 +222,3 @@ func (p *Plugin) kilocodeBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/kilocode/kilocode_test.go b/backend/internal/adapters/agent/kilocode/kilocode_test.go index c9335d816..ca351d8a3 100644 --- a/backend/internal/adapters/agent/kilocode/kilocode_test.go +++ b/backend/internal/adapters/agent/kilocode/kilocode_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/activitystate" "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -91,7 +92,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "kilocode"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -103,7 +104,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "kilocode"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -347,8 +348,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "ses_abc123", - kilocodeTitleMetadataKey: "Fix login redirect", - kilocodeSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -405,9 +406,9 @@ func TestDeriveActivityState(t *testing.T) { } for _, tc := range cases { t.Run(tc.event, func(t *testing.T) { - state, ok := DeriveActivityState(tc.event, nil) + state, ok := activitystate.StandardDeriveActivityState(tc.event, nil) if state != tc.wantState || ok != tc.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK) + t.Fatalf("StandardDeriveActivityState(%q) = (%q, %v), want (%q, %v)", tc.event, state, ok, tc.wantState, tc.wantOK) } }) } diff --git a/backend/internal/adapters/agent/kimi/kimi.go b/backend/internal/adapters/agent/kimi/kimi.go index 5086a70fc..12e8f56f2 100644 --- a/backend/internal/adapters/agent/kimi/kimi.go +++ b/backend/internal/adapters/agent/kimi/kimi.go @@ -17,15 +17,12 @@ package kimi import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -34,6 +31,7 @@ const adapterID = "kimi" // Plugin is the Kimi CLI agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -59,14 +57,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Kimi session: // // kimi -p <prompt> (non-interactive, default) @@ -74,7 +64,7 @@ func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { // // When a prompt is supplied, it is delivered via `-p` (in command), which runs // a single prompt without opening the TUI. Per Kimi docs, `--prompt` cannot be -// combined with `--yolo`, `--auto`, or `--plan` — non-interactive mode already +// combined with `--yolo`, `--auto`, or `--plan` -- non-interactive mode already // uses the `auto` permission policy by default, so approval flags would be // rejected at startup. They are only emitted on the (interactive) path with no // prompt. Kimi has no documented system-prompt flag, so cfg.SystemPrompt / @@ -96,21 +86,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Kimi receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op: Kimi CLI exposes no native hook system -// and is not documented as Claude Code hook-compatible. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Kimi session // when a native Kimi session id is known: // @@ -118,8 +93,8 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi // // ok is false when no native session id has been captured, so callers fall back // to fresh launch behavior. Per Kimi docs, `--yolo` and `--auto` cannot be -// combined with `--session` (or `--continue`) — resumed sessions inherit the -// approval settings of the original session — so cfg.Permissions is +// combined with `--session` (or `--continue`) -- resumed sessions inherit the +// approval settings of the original session -- so cfg.Permissions is // intentionally ignored here. Kimi has no lifecycle hook for AO to capture the // native session id from yet, so in practice this returns ok=false today. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { @@ -139,15 +114,6 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Kimi exposes a way to capture its -// native session id and display metadata. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - // appendApprovalFlags maps AO's permission modes onto Kimi's approval flags // for interactive launches. Per Kimi docs these flags cannot be combined with // `--prompt`, `--session`, or `--continue`, so callers on those paths must @@ -181,72 +147,25 @@ func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { } } +var kimiBinarySpec = binaryutil.BinarySpec{ + Label: "kimi", + Names: []string{"kimi"}, + WinNames: []string{"kimi.cmd", "kimi.exe", "kimi"}, + UnixPaths: []string{"/usr/local/bin/kimi", "/opt/homebrew/bin/kimi"}, + UnixHomePaths: [][]string{{".local", "bin", "kimi"}, {".cargo", "bin", "kimi"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "kimi.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".local", "bin", "kimi.exe"}}, + }, +} + // ResolveKimiBinary finds the `kimi` binary, searching PATH then common install // locations (the uv tool/curl installer drops it in ~/.local/bin, plus Homebrew // and ~/.cargo/bin). It returns "kimi" as a last resort so callers get the // shell's normal command-not-found behavior if Kimi is absent. func ResolveKimiBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"kimi.cmd", "kimi.exe", "kimi"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "kimi.cmd"), - filepath.Join(appData, "npm", "kimi.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "kimi.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("kimi: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("kimi"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/kimi", - "/opt/homebrew/bin/kimi", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "kimi"), - filepath.Join(home, ".cargo", "bin", "kimi"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("kimi: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, kimiBinarySpec) } func (p *Plugin) kimiBinary(ctx context.Context) (string, error) { @@ -264,8 +183,3 @@ func (p *Plugin) kimiBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/kiro/activity.go b/backend/internal/adapters/agent/kiro/activity.go deleted file mode 100644 index 619bb22ff..000000000 --- a/backend/internal/adapters/agent/kiro/activity.go +++ /dev/null @@ -1,31 +0,0 @@ -package kiro - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Kiro hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in kiroManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), not -// the native Kiro event name (agentSpawn/userPromptSubmit/preToolUse/stop). -// Kiro currently has no session/process-end hook in the adapter, so runtime -// exit still falls back to the lifecycle reaper. -// -// TODO(kiro): ActivityExited is still runtime-observation-owned. If Kiro adds a -// native session/process-end hook, map that hook to ActivityExited here. Until -// then, make sure the lifecycle reaper can still mark a dead Kiro runtime as -// exited even when the last hook signal was sticky waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/kiro/hooks.go b/backend/internal/adapters/agent/kiro/hooks.go index 9ad42106f..04dc96a01 100644 --- a/backend/internal/adapters/agent/kiro/hooks.go +++ b/backend/internal/adapters/agent/kiro/hooks.go @@ -229,35 +229,12 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess return fmt.Errorf("encode %s: %w", hooksPath, err) } data = append(data, '\n') - if err := atomicWriteFile(hooksPath, data, 0o600); err != nil { + if err := hookutil.AtomicWriteFile(hooksPath, data, 0o600); err != nil { return fmt.Errorf("write %s: %w", hooksPath, err) } return nil } -// atomicWriteFile writes data to path via a temp file + rename, so a crash mid- -// write can't leave a truncated/empty file that Kiro then fails to parse. -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - // groupKiroHooksByEvent groups the managed hook specs by their Kiro event so // each event's array is rewritten once. func groupKiroHooksByEvent() map[string][]kiroHookSpec { diff --git a/backend/internal/adapters/agent/kiro/kiro.go b/backend/internal/adapters/agent/kiro/kiro.go index 457ed2a3b..5f54d73f0 100644 --- a/backend/internal/adapters/agent/kiro/kiro.go +++ b/backend/internal/adapters/agent/kiro/kiro.go @@ -15,31 +15,24 @@ // captured from a Kiro hook payload. // // AO-managed sessions derive native session identity and display metadata from -// Kiro's native hooks (see hooks.go / activity.go) rather than transcript scans. +// Kiro's native hooks (see hooks.go) rather than transcript scans. package kiro import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - kiroTitleMetadataKey = "title" - kiroSummaryMetadataKey = "summary" -) - // Plugin is the Kiro agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -65,14 +58,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Kiro exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Kiro session: // `kiro-cli chat --no-interactive [trust flags] -- <prompt>`. // @@ -94,15 +79,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Kiro receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Kiro session: // `kiro-cli chat --no-interactive --resume-id <agentSessionId> [trust flags]`. // ok is false when the hook-derived native session id has not landed yet, so @@ -133,91 +109,31 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[kiroTitleMetadataKey], - Summary: session.Metadata[kiroSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil +} + +var kiroBinarySpec = binaryutil.BinarySpec{ + Label: "kiro", + Names: []string{"kiro-cli"}, + WinNames: []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"}, + UnixPaths: []string{"/usr/local/bin/kiro-cli", "/opt/homebrew/bin/kiro-cli"}, + UnixHomePaths: [][]string{{".kiro", "bin", "kiro-cli"}, {".local", "bin", "kiro-cli"}}, + // The native Kiro installer location is probed before the npm shim, matching + // the pre-refactor order so a native install still wins when both are present. + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinLocalAppData, Parts: []string{"Programs", "kiro", "kiro-cli.exe"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "kiro-cli.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "kiro-cli.exe"}}, + {Base: binaryutil.WinHome, Parts: []string{".kiro", "bin", "kiro-cli.exe"}}, + }, } // ResolveKiroBinary returns the path to the kiro-cli binary on this machine, -// searching PATH then a handful of well-known install locations. Returns -// "kiro-cli" as a last-ditch fallback so callers see a clear "command not -// found" rather than an empty argv. +// searching PATH then a handful of well-known install locations. It returns a +// wrapped ports.ErrAgentBinaryNotFound when kiro-cli is absent. func ResolveKiroBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"kiro-cli.cmd", "kiro-cli.exe", "kiro-cli"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - candidates = append(candidates, - filepath.Join(localAppData, "Programs", "kiro", "kiro-cli.exe"), - ) - } - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "kiro-cli.cmd"), - filepath.Join(appData, "npm", "kiro-cli.exe"), - ) - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".kiro", "bin", "kiro-cli.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("kiro: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("kiro-cli"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/kiro-cli", - "/opt/homebrew/bin/kiro-cli", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".kiro", "bin", "kiro-cli"), - filepath.Join(home, ".local", "bin", "kiro-cli"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("kiro: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, kiroBinarySpec) } func (p *Plugin) kiroBinary(ctx context.Context) (string, error) { @@ -241,7 +157,7 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) { // (the interactive per-tool prompt). accept-edits grants the write-capable // built-in tools; auto/bypass grant all tools. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Kiro config / per-tool prompting. case ports.PermissionModeAcceptEdits: @@ -252,20 +168,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--trust-all-tools") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/kiro/kiro_test.go b/backend/internal/adapters/agent/kiro/kiro_test.go index 61dfab11a..6e4f437fd 100644 --- a/backend/internal/adapters/agent/kiro/kiro_test.go +++ b/backend/internal/adapters/agent/kiro/kiro_test.go @@ -11,7 +11,6 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -114,7 +113,7 @@ func TestGetLaunchCommandCtxCancelled(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "kiro-cli"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -126,7 +125,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "kiro-cli"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -357,8 +356,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "uuid-123", - kiroTitleMetadataKey: "Fix login redirect", - kiroSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -400,29 +399,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - wantState domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - state, ok := DeriveActivityState(tt.event, nil) - if state != tt.wantState || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) - } - }) - } -} - func TestResolveKiroBinaryFallback(t *testing.T) { // When the binary is not on PATH or any well-known location, the resolver // MUST surface ports.ErrAgentBinaryNotFound rather than a silent string diff --git a/backend/internal/adapters/agent/opencode/opencode.go b/backend/internal/adapters/agent/opencode/opencode.go index 415125998..25afe87f5 100644 --- a/backend/internal/adapters/agent/opencode/opencode.go +++ b/backend/internal/adapters/agent/opencode/opencode.go @@ -29,6 +29,8 @@ import ( "time" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" _ "modernc.org/sqlite" // register sqlite driver for opencode session metadata probes @@ -39,17 +41,18 @@ const ( // `ao spawn --agent`. It matches domain.HarnessOpenCode. adapterID = "opencode" - // Normalized session-metadata keys the opencode plugin persists into the AO - // session store and SessionInfo reads back. Shared vocabulary with the Codex - // and Claude Code adapters so the dashboard treats every agent uniformly. + // opencodeAgentSessionIDMetadataKey is the session-metadata key the opencode + // plugin persists the native session id under. GetRestoreCommand reads it back + // to resume an existing session. SessionInfo delegates to + // agentbase.StandardSessionInfo which reads ports.MetadataKeyAgentSessionID + // (same value), but GetRestoreCommand reads it directly, so the const stays. opencodeAgentSessionIDMetadataKey = "agentSessionId" - opencodeTitleMetadataKey = "title" - opencodeSummaryMetadataKey = "summary" ) // Plugin is the opencode agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -76,16 +79,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. opencode exposes none -// yet: model and agent selection are read from opencode's own config -// (opencode.json / ~/.config/opencode), exactly as a normal launch. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new interactive opencode session. // Shape: // @@ -111,15 +104,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that opencode receives its prompt in the -// launch command itself (via --prompt). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing opencode // session: `opencode [--dangerously-skip-permissions] --session <agentSessionId>`. // It re-applies the permission flag (resume otherwise reverts to the configured @@ -154,15 +138,8 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[opencodeAgentSessionIDMetadataKey], - Title: session.Metadata[opencodeTitleMetadataKey], - Summary: session.Metadata[opencodeSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } // AuthStatus checks whether opencode has at least one configured provider @@ -353,24 +330,11 @@ func opencodeDBCount(ctx context.Context, db *sql.DB, query string) (int, error) // - default / accept-edits / auto → no flag. opencode resolves approvals from // its own `permission` config exactly as a normal launch. func appendPermissionFlags(cmd *[]string, permissions ports.PermissionMode) { - if normalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions { + if ports.NormalizePermissionMode(permissions) == ports.PermissionModeBypassPermissions { *cmd = append(*cmd, "--dangerously-skip-permissions") } } -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - // Empty or unrecognized: defer to opencode's own config (no flag). - return ports.PermissionModeDefault - } -} - // ResolveOpenCodeBinary returns the path to the opencode binary on this machine, // searching PATH then a handful of well-known install locations (the install // script's ~/.opencode/bin, Homebrew, npm global). @@ -393,7 +357,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { ) } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } } @@ -416,7 +380,7 @@ func ResolveOpenCodeBinary(ctx context.Context) (string, error) { } for _, candidate := range candidates { - if fileExists(candidate) { + if hookutil.FileExists(candidate) { return candidate, nil } if err := ctx.Err(); err != nil { @@ -442,8 +406,3 @@ func (p *Plugin) opencodeBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/opencode/opencode_test.go b/backend/internal/adapters/agent/opencode/opencode_test.go index 20a19326d..2a0454fd0 100644 --- a/backend/internal/adapters/agent/opencode/opencode_test.go +++ b/backend/internal/adapters/agent/opencode/opencode_test.go @@ -344,7 +344,7 @@ func TestGetLaunchCommandMapsPermissionModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "opencode"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -356,7 +356,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "opencode"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -599,8 +599,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ opencodeAgentSessionIDMetadataKey: "ses_abc123", - opencodeTitleMetadataKey: "Fix login redirect", - opencodeSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) diff --git a/backend/internal/adapters/agent/pi/pi.go b/backend/internal/adapters/agent/pi/pi.go index f4c5f2823..0f467f5ab 100644 --- a/backend/internal/adapters/agent/pi/pi.go +++ b/backend/internal/adapters/agent/pi/pi.go @@ -12,7 +12,7 @@ // variant), so a system-prompt file is read from disk and its contents are // inlined into the flag; a read failure aborts the launch. // -// Permissions: Pi has no permission/approval CLI flags ("No permission popups" — +// Permissions: Pi has no permission/approval CLI flags ("No permission popups" -- // confirmation flows are built via TypeScript extensions), so AO emits no // permission flag and defers to Pi's own behavior. // @@ -33,15 +33,13 @@ package pi import ( "context" - "fmt" "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -50,6 +48,7 @@ const adapterID = "pi" // Plugin is the Pi agent adapter. It is safe for concurrent use; the binary // path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -75,14 +74,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new headless Pi session: // // pi --print [--append-system-prompt <system prompt>] [<prompt>] @@ -112,22 +103,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Pi receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op: Pi's lifecycle hooks are only -// reachable through in-process TypeScript extensions, not a config file AO can -// install, and Pi has no Claude Code hook compatibility. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Pi session when // a native session id is available in metadata. Pi resumes by id with // `--session <id>` (partial UUIDs accepted). Until that id exists, ok is false @@ -149,77 +124,23 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until a Pi-specific extension persists -// session metadata (title/summary). The native session id, when known, is read -// directly from metadata by GetRestoreCommand. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil +var piBinarySpec = binaryutil.BinarySpec{ + Label: "pi", + Names: []string{"pi"}, + WinNames: []string{"pi.cmd", "pi.exe", "pi"}, + UnixPaths: []string{"/usr/local/bin/pi", "/opt/homebrew/bin/pi"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "pi"}, {".local", "bin", "pi"}, {".pi", "bin", "pi"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "pi.exe"}}, + }, } // ResolvePiBinary finds the `pi` binary, searching PATH then common install // locations. It returns "pi" as a last resort so callers get the shell's normal // command-not-found behavior if Pi is absent. func ResolvePiBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"pi.cmd", "pi.exe", "pi"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "pi.cmd"), - filepath.Join(appData, "npm", "pi.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("pi: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("pi"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/pi", - "/opt/homebrew/bin/pi", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "pi"), - filepath.Join(home, ".local", "bin", "pi"), - filepath.Join(home, ".pi", "bin", "pi"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("pi: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, piBinarySpec) } func (p *Plugin) piBinary(ctx context.Context) (string, error) { @@ -237,8 +158,3 @@ func (p *Plugin) piBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/qwen/activity.go b/backend/internal/adapters/agent/qwen/activity.go deleted file mode 100644 index c2c0f5365..000000000 --- a/backend/internal/adapters/agent/qwen/activity.go +++ /dev/null @@ -1,31 +0,0 @@ -package qwen - -import "github.com/aoagents/agent-orchestrator/backend/internal/domain" - -// DeriveActivityState maps a Qwen Code hook event onto an AO activity state. The -// bool is false when the event carries no activity signal. -// -// event is the AO hook sub-command name installed in qwenManagedHooks -// ("session-start", "user-prompt-submit", "permission-request", "stop"), not -// the native Qwen event name. Qwen Code has no SessionEnd equivalent in the -// adapter yet, so runtime exit still falls back to the reaper. -// -// TODO(qwen): ActivityExited is still runtime-observation-owned. Qwen Code has a -// native SessionEnd hook; if AO installs it, map "session-end" to -// ActivityExited here. Until then, make sure the lifecycle reaper can still mark -// a dead Qwen runtime as exited even when the last hook signal was sticky -// waiting_input. -func DeriveActivityState(event string, _ []byte) (domain.ActivityState, bool) { - switch event { - case "session-start": - return domain.ActivityActive, true - case "user-prompt-submit": - return domain.ActivityActive, true - case "stop": - return domain.ActivityIdle, true - case "permission-request": - return domain.ActivityWaitingInput, true - default: - return "", false - } -} diff --git a/backend/internal/adapters/agent/qwen/hooks.go b/backend/internal/adapters/agent/qwen/hooks.go index eb6e91ee6..147167076 100644 --- a/backend/internal/adapters/agent/qwen/hooks.go +++ b/backend/internal/adapters/agent/qwen/hooks.go @@ -2,14 +2,9 @@ package qwen import ( "context" - "encoding/json" - "errors" - "fmt" - "os" "path/filepath" - "strings" - "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -17,10 +12,8 @@ const ( qwenSettingsDirName = ".qwen" qwenSettingsFileName = "settings.json" - // qwenHookCommandPrefix identifies the hook commands AO owns. Every managed - // command starts with it, so install can skip duplicates and uninstall can - // recognize AO entries by prefix without an embedded template to diff - // against. + // qwenHookCommandPrefix identifies the hook commands AO owns, so install + // skips duplicates and uninstall recognizes AO entries by prefix. qwenHookCommandPrefix = "ao hooks qwen " // qwenHookTimeout is in milliseconds: Qwen Code (a gemini-cli fork) measures @@ -28,355 +21,44 @@ const ( qwenHookTimeout = 30000 ) -// qwenHookFile is the on-disk shape of the "hooks" sub-object of -// .qwen/settings.json. It is used by tests to decode the written file. -type qwenHookFile struct { - Hooks map[string][]qwenMatcherGroup `json:"hooks"` -} - -type qwenMatcherGroup struct { - // Matcher is a pointer so it round-trips exactly: SessionStart targets the - // payload "source" field with a "startup" matcher; UserPromptSubmit/Stop/ - // PermissionRequest omit it (Qwen ignores the matcher for those events). - // omitempty drops a nil matcher on write. - Matcher *string `json:"matcher,omitempty"` - Hooks []qwenHookEntry `json:"hooks"` -} - -type qwenHookEntry struct { - Type string `json:"type"` - Command string `json:"command"` - Timeout int `json:"timeout,omitempty"` -} - -// qwenHookSpec describes one hook AO installs, defined in code rather than read -// from an embedded settings file. -type qwenHookSpec struct { - Event string - Matcher *string - Command string -} - // qwenStartupMatcher is referenced by pointer so SessionStart serializes with // its "startup" source matcher. var qwenStartupMatcher = "startup" // qwenManagedHooks is the source of truth for the hooks AO installs: // SessionStart (under the "startup" source matcher), UserPromptSubmit, -// PermissionRequest, and Stop. They report normalized session metadata and -// activity-state signals back into AO's store (see DeriveActivityState). The -// AO sub-command names are FIXED and must match the cases in -// DeriveActivityState. -var qwenManagedHooks = []qwenHookSpec{ +// PermissionRequest, and Stop. +var qwenManagedHooks = []hooksjson.HookSpec{ {Event: "SessionStart", Matcher: &qwenStartupMatcher, Command: qwenHookCommandPrefix + "session-start"}, {Event: "UserPromptSubmit", Command: qwenHookCommandPrefix + "user-prompt-submit"}, {Event: "PermissionRequest", Command: qwenHookCommandPrefix + "permission-request"}, {Event: "Stop", Command: qwenHookCommandPrefix + "stop"}, } -// GetAgentHooks installs AO's Qwen Code hooks into the worktree-local -// .qwen/settings.json file (the project-level settings). The hooks -// (SessionStart, UserPromptSubmit, PermissionRequest, Stop) report normalized -// session metadata and activity signals back into AO's store. Existing hooks -// and unrelated settings are preserved, and duplicate AO commands are not -// appended, so the install is idempotent. -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(cfg.WorkspacePath) == "" { - return errors.New("qwen.GetAgentHooks: WorkspacePath is required") - } - - settingsPath := qwenSettingsPath(cfg.WorkspacePath) - topLevel, rawHooks, err := readQwenSettings(settingsPath) - if err != nil { - return fmt.Errorf("qwen.GetAgentHooks: %w", err) - } - - for event, specs := range groupQwenHooksByEvent() { - var existingGroups []qwenMatcherGroup - if err := parseQwenHookType(rawHooks, event, &existingGroups); err != nil { - return fmt.Errorf("qwen.GetAgentHooks: %w", err) - } - for _, spec := range specs { - if !qwenHookCommandExists(existingGroups, spec.Command) { - entry := qwenHookEntry{Type: "command", Command: spec.Command, Timeout: qwenHookTimeout} - existingGroups = addQwenHook(existingGroups, entry, spec.Matcher) - } - } - if err := marshalQwenHookType(rawHooks, event, existingGroups); err != nil { - return fmt.Errorf("qwen.GetAgentHooks: %w", err) - } - } - - if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("qwen.GetAgentHooks: %w", err) - } - if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(settingsPath), qwenSettingsFileName); err != nil { - return fmt.Errorf("qwen.GetAgentHooks: gitignore: %w", err) - } - return nil -} - -// UninstallHooks removes AO's Qwen Code hooks from the workspace-local -// .qwen/settings.json file, leaving user-defined hooks and unrelated settings -// untouched. A missing settings file is a no-op. -func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { - if err := ctx.Err(); err != nil { - return err - } - if strings.TrimSpace(workspacePath) == "" { - return errors.New("qwen.UninstallHooks: workspacePath is required") - } - - settingsPath := qwenSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return nil - } - topLevel, rawHooks, err := readQwenSettings(settingsPath) - if err != nil { - return fmt.Errorf("qwen.UninstallHooks: %w", err) - } - - for _, event := range qwenManagedEvents() { - var groups []qwenMatcherGroup - if err := parseQwenHookType(rawHooks, event, &groups); err != nil { - return fmt.Errorf("qwen.UninstallHooks: %w", err) - } - groups = removeQwenManagedHooks(groups) - if err := marshalQwenHookType(rawHooks, event, groups); err != nil { - return fmt.Errorf("qwen.UninstallHooks: %w", err) - } - } - - if err := writeQwenSettings(settingsPath, topLevel, rawHooks); err != nil { - return fmt.Errorf("qwen.UninstallHooks: %w", err) - } - return nil -} - -// AreHooksInstalled reports whether any AO Qwen Code hook is present in the -// workspace-local settings file. A missing file means none are installed. -func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { - if err := ctx.Err(); err != nil { - return false, err - } - if strings.TrimSpace(workspacePath) == "" { - return false, errors.New("qwen.AreHooksInstalled: workspacePath is required") - } - - settingsPath := qwenSettingsPath(workspacePath) - if _, err := os.Stat(settingsPath); errors.Is(err, os.ErrNotExist) { - return false, nil - } - _, rawHooks, err := readQwenSettings(settingsPath) - if err != nil { - return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err) - } - - for _, event := range qwenManagedEvents() { - var groups []qwenMatcherGroup - if err := parseQwenHookType(rawHooks, event, &groups); err != nil { - return false, fmt.Errorf("qwen.AreHooksInstalled: %w", err) - } - for _, group := range groups { - for _, hook := range group.Hooks { - if isQwenManagedHook(hook.Command) { - return true, nil - } - } - } - } - return false, nil +// qwenHooks manages AO's hooks in the workspace-local .qwen/settings.json file. +var qwenHooks = hooksjson.Manager{ + Label: "qwen", + CommandPrefix: qwenHookCommandPrefix, + Timeout: qwenHookTimeout, + Path: qwenSettingsPath, + Managed: qwenManagedHooks, } func qwenSettingsPath(workspacePath string) string { return filepath.Join(workspacePath, qwenSettingsDirName, qwenSettingsFileName) } -// readQwenSettings loads the settings file into a top-level raw map plus the -// decoded "hooks" sub-map, preserving every key AO doesn't manage. A missing or -// empty file yields empty maps. -func readQwenSettings(settingsPath string) (topLevel, rawHooks map[string]json.RawMessage, err error) { - topLevel = map[string]json.RawMessage{} - rawHooks = map[string]json.RawMessage{} - - data, err := os.ReadFile(settingsPath) //nolint:gosec // path built from caller-owned workspace dir - if errors.Is(err, os.ErrNotExist) { - return topLevel, rawHooks, nil - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", settingsPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return topLevel, rawHooks, nil - } - if err := json.Unmarshal(data, &topLevel); err != nil { - return nil, nil, fmt.Errorf("parse %s: %w", settingsPath, err) - } - if hooksRaw, ok := topLevel["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return nil, nil, fmt.Errorf("parse hooks in %s: %w", settingsPath, err) - } - } - return topLevel, rawHooks, nil +// GetAgentHooks installs AO's Qwen Code hooks, preserving user-defined hooks and unrelated settings. +func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { + return qwenHooks.Install(ctx, cfg.WorkspacePath) } -// writeQwenSettings folds rawHooks back into topLevel and writes the file. An -// empty hooks map drops the "hooks" key entirely. -func writeQwenSettings(settingsPath string, topLevel, rawHooks map[string]json.RawMessage) error { - if len(rawHooks) == 0 { - delete(topLevel, "hooks") - } else { - hooksJSON, err := json.Marshal(rawHooks) - if err != nil { - return fmt.Errorf("encode hooks: %w", err) - } - topLevel["hooks"] = hooksJSON - } - - if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { - return fmt.Errorf("create settings dir: %w", err) - } - data, err := json.MarshalIndent(topLevel, "", " ") - if err != nil { - return fmt.Errorf("encode %s: %w", settingsPath, err) - } - data = append(data, '\n') - if err := atomicWriteFile(settingsPath, data, 0o600); err != nil { - return fmt.Errorf("write %s: %w", settingsPath, err) - } - return nil +// UninstallHooks removes AO's Qwen Code hooks, leaving user-defined hooks untouched. +func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { + return qwenHooks.Uninstall(ctx, workspacePath) } -// atomicWriteFile writes data to path via a temp file in the same directory -// followed by a rename, so a crash or signal mid-write can't leave a truncated -// or empty file that Qwen Code then fails to parse (silently disabling hooks). -func atomicWriteFile(path string, data []byte, perm os.FileMode) error { - tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*") - if err != nil { - return err - } - tmpName := tmp.Name() - defer func() { _ = os.Remove(tmpName) }() // no-op once renamed - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Chmod(perm); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmpName, path) -} - -// groupQwenHooksByEvent groups the managed hook specs by their Qwen event so -// each event's settings array is rewritten once. -func groupQwenHooksByEvent() map[string][]qwenHookSpec { - byEvent := map[string][]qwenHookSpec{} - for _, spec := range qwenManagedHooks { - byEvent[spec.Event] = append(byEvent[spec.Event], spec) - } - return byEvent -} - -// qwenManagedEvents returns the distinct Qwen events AO manages, in the order -// they first appear in qwenManagedHooks. -func qwenManagedEvents() []string { - seen := map[string]bool{} - events := make([]string, 0, len(qwenManagedHooks)) - for _, spec := range qwenManagedHooks { - if !seen[spec.Event] { - seen[spec.Event] = true - events = append(events, spec.Event) - } - } - return events -} - -func isQwenManagedHook(command string) bool { - return strings.HasPrefix(command, qwenHookCommandPrefix) -} - -// removeQwenManagedHooks strips AO hook entries from every group, dropping any -// group left without hooks so the event array doesn't accumulate empty matcher -// objects. -func removeQwenManagedHooks(groups []qwenMatcherGroup) []qwenMatcherGroup { - result := make([]qwenMatcherGroup, 0, len(groups)) - for _, group := range groups { - kept := make([]qwenHookEntry, 0, len(group.Hooks)) - for _, hook := range group.Hooks { - if !isQwenManagedHook(hook.Command) { - kept = append(kept, hook) - } - } - if len(kept) > 0 { - group.Hooks = kept - result = append(result, group) - } - } - return result -} - -func parseQwenHookType(rawHooks map[string]json.RawMessage, event string, target *[]qwenMatcherGroup) error { - data, ok := rawHooks[event] - if !ok { - return nil - } - if err := json.Unmarshal(data, target); err != nil { - return fmt.Errorf("parse %s hooks: %w", event, err) - } - return nil -} - -func marshalQwenHookType(rawHooks map[string]json.RawMessage, event string, groups []qwenMatcherGroup) error { - if len(groups) == 0 { - delete(rawHooks, event) - return nil - } - data, err := json.Marshal(groups) - if err != nil { - return fmt.Errorf("encode %s hooks: %w", event, err) - } - rawHooks[event] = data - return nil -} - -func qwenHookCommandExists(groups []qwenMatcherGroup, command string) bool { - for _, group := range groups { - for _, hook := range group.Hooks { - if hook.Command == command { - return true - } - } - } - return false -} - -// addQwenHook appends hook to an existing group with the same matcher (so a -// SessionStart hook lands under its "startup" matcher), creating that group if -// none matches. -func addQwenHook(groups []qwenMatcherGroup, hook qwenHookEntry, matcher *string) []qwenMatcherGroup { - for i, group := range groups { - if matchersEqual(group.Matcher, matcher) { - groups[i].Hooks = append(groups[i].Hooks, hook) - return groups - } - } - return append(groups, qwenMatcherGroup{Matcher: matcher, Hooks: []qwenHookEntry{hook}}) -} - -func matchersEqual(a, b *string) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return *a == *b +// AreHooksInstalled reports whether any AO Qwen Code hook is present. +func (p *Plugin) AreHooksInstalled(ctx context.Context, workspacePath string) (bool, error) { + return qwenHooks.AreInstalled(ctx, workspacePath) } diff --git a/backend/internal/adapters/agent/qwen/qwen.go b/backend/internal/adapters/agent/qwen/qwen.go index 0ceb90681..306070305 100644 --- a/backend/internal/adapters/agent/qwen/qwen.go +++ b/backend/internal/adapters/agent/qwen/qwen.go @@ -15,26 +15,19 @@ package qwen import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) -const ( - qwenTitleMetadataKey = "title" - qwenSummaryMetadataKey = "summary" -) - // Plugin is the Qwen Code agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -60,14 +53,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports the agent-specific config keys. Qwen Code exposes none yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new Qwen Code session: the // approval-mode flag, optional system-prompt instructions, and the initial // prompt (passed via `-p` so a leading "-" is not read as a flag). Prompt is @@ -92,15 +77,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Qwen Code receives its prompt in the -// launch command itself (via -p). -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - // GetRestoreCommand rebuilds the argv that continues an existing Qwen Code // session: `qwen [--approval-mode <mode>] -r <agentSessionId>`. ok is false when // the hook-derived native session id has not landed yet, so callers can fall @@ -132,83 +108,26 @@ func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (por if err := ctx.Err(); err != nil { return ports.SessionInfo{}, false, err } - info := ports.SessionInfo{ - AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], - Title: session.Metadata[qwenTitleMetadataKey], - Summary: session.Metadata[qwenSummaryMetadataKey], - } - if info.AgentSessionID == "" && info.Title == "" && info.Summary == "" { - return ports.SessionInfo{}, false, nil - } - return info, true, nil + info, ok := agentbase.StandardSessionInfo(session) + return info, ok, nil } -// ResolveQwenBinary returns the path to the qwen binary on this machine, -// searching PATH then a handful of well-known install locations (Homebrew, npm -// global). Returns ports.ErrAgentBinaryNotFound when none of those find the -// binary — better than the previous silent `"qwen"` fallback, which let an -// empty tmux pane masquerade as a live session. +var qwenBinarySpec = binaryutil.BinarySpec{ + Label: "qwen", + Names: []string{"qwen"}, + WinNames: []string{"qwen.cmd", "qwen.exe", "qwen"}, + UnixPaths: []string{"/usr/local/bin/qwen", "/opt/homebrew/bin/qwen"}, + UnixHomePaths: [][]string{{".npm-global", "bin", "qwen"}, {".npm", "bin", "qwen"}, {".local", "bin", "qwen"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"npm", "qwen.cmd"}}, + {Base: binaryutil.WinAppData, Parts: []string{"npm", "qwen.exe"}}, + }, +} + +// ResolveQwenBinary returns the path to the qwen binary, or a wrapped +// ports.ErrAgentBinaryNotFound when it is absent. func ResolveQwenBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"qwen.cmd", "qwen.exe", "qwen"} { - path, err := exec.LookPath(name) - if err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "npm", "qwen.cmd"), - filepath.Join(appData, "npm", "qwen.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("qwen: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("qwen"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/qwen", - "/opt/homebrew/bin/qwen", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".npm-global", "bin", "qwen"), - filepath.Join(home, ".npm", "bin", "qwen"), - filepath.Join(home, ".local", "bin", "qwen"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("qwen: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, qwenBinarySpec) } func (p *Plugin) qwenBinary(ctx context.Context) (string, error) { @@ -231,7 +150,7 @@ func (p *Plugin) qwenBinary(ctx context.Context) (string, error) { // `--approval-mode` choices (plan|default|auto-edit|auto|yolo). Default emits no // flag so Qwen resolves its starting mode from the user's own config. func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { - switch normalizePermissionMode(permissions) { + switch ports.NormalizePermissionMode(permissions) { case ports.PermissionModeDefault: // No flag: defer to the user's Qwen Code config/default behavior. case ports.PermissionModeAcceptEdits: @@ -242,20 +161,3 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { *cmd = append(*cmd, "--approval-mode", "yolo") } } - -func normalizePermissionMode(mode ports.PermissionMode) ports.PermissionMode { - switch mode { - case ports.PermissionModeDefault, - ports.PermissionModeAcceptEdits, - ports.PermissionModeAuto, - ports.PermissionModeBypassPermissions: - return mode - default: - return ports.PermissionModeDefault - } -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/adapters/agent/qwen/qwen_test.go b/backend/internal/adapters/agent/qwen/qwen_test.go index 034cfc054..62138c761 100644 --- a/backend/internal/adapters/agent/qwen/qwen_test.go +++ b/backend/internal/adapters/agent/qwen/qwen_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hooksjson" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -89,7 +89,7 @@ func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { } func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { - plugin := &Plugin{resolvedBinary: "qwen"} + plugin := &Plugin{} got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{}) if err != nil { @@ -101,7 +101,7 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) { } func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { - plugin := &Plugin{resolvedBinary: "qwen"} + plugin := &Plugin{} spec, err := plugin.GetConfigSpec(context.Background()) if err != nil { @@ -140,6 +140,10 @@ func TestContextCancellationIsHonored(t *testing.T) { } } +type qwenHookFile struct { + Hooks map[string][]hooksjson.MatcherGroup `json:"hooks"` +} + func TestGetAgentHooksInstallsQwenHooks(t *testing.T) { plugin := &Plugin{resolvedBinary: "qwen"} workspace := t.TempDir() @@ -203,7 +207,7 @@ func TestGetAgentHooksInstallsQwenHooks(t *testing.T) { assertStartupMatcher(t, config.Hooks["SessionStart"]) } -func assertStartupMatcher(t *testing.T, groups []qwenMatcherGroup) { +func assertStartupMatcher(t *testing.T, groups []hooksjson.MatcherGroup) { t.Helper() for _, group := range groups { for _, hook := range group.Hooks { @@ -330,8 +334,8 @@ func TestSessionInfoReadsHookMetadata(t *testing.T) { WorkspacePath: "/some/path", Metadata: map[string]string{ ports.MetadataKeyAgentSessionID: "sess-123", - qwenTitleMetadataKey: "Fix login redirect", - qwenSummaryMetadataKey: "Updated the auth callback and tests.", + ports.MetadataKeyTitle: "Fix login redirect", + ports.MetadataKeySummary: "Updated the auth callback and tests.", "ignored": "not returned", }, }) @@ -373,29 +377,6 @@ func TestSessionInfoFalseWhenNoHookMetadata(t *testing.T) { } } -func TestDeriveActivityState(t *testing.T) { - tests := []struct { - event string - wantState domain.ActivityState - wantOK bool - }{ - {"session-start", domain.ActivityActive, true}, - {"user-prompt-submit", domain.ActivityActive, true}, - {"stop", domain.ActivityIdle, true}, - {"permission-request", domain.ActivityWaitingInput, true}, - {"unknown", "", false}, - {"", "", false}, - } - for _, tt := range tests { - t.Run(tt.event, func(t *testing.T) { - state, ok := DeriveActivityState(tt.event, nil) - if state != tt.wantState || ok != tt.wantOK { - t.Fatalf("DeriveActivityState(%q) = (%q, %v), want (%q, %v)", tt.event, state, ok, tt.wantState, tt.wantOK) - } - }) - } -} - func contains(values []string, needle string) bool { for _, value := range values { if value == needle { @@ -429,7 +410,7 @@ func containsSubsequence(values []string, needle []string) bool { return false } -func countQwenHookCommand(entries []qwenMatcherGroup, command string) int { +func countQwenHookCommand(entries []hooksjson.MatcherGroup, command string) int { count := 0 for _, entry := range entries { for _, hook := range entry.Hooks { diff --git a/backend/internal/adapters/agent/vibe/vibe.go b/backend/internal/adapters/agent/vibe/vibe.go index d002a5e57..a0b71943a 100644 --- a/backend/internal/adapters/agent/vibe/vibe.go +++ b/backend/internal/adapters/agent/vibe/vibe.go @@ -26,15 +26,12 @@ package vibe import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" "strings" "sync" "github.com/aoagents/agent-orchestrator/backend/internal/adapters" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" + "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) @@ -43,6 +40,7 @@ const adapterID = "vibe" // Plugin is the Mistral Vibe agent adapter. It is safe for concurrent use; the // binary path is resolved once and cached under binaryMu. type Plugin struct { + agentbase.Base binaryMu sync.Mutex resolvedBinary string } @@ -68,14 +66,6 @@ func (p *Plugin) Manifest() adapters.Manifest { } } -// GetConfigSpec reports no agent-specific config keys yet. -func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) { - if err := ctx.Err(); err != nil { - return ports.ConfigSpec{}, err - } - return ports.ConfigSpec{}, nil -} - // GetLaunchCommand builds the argv to start a new non-interactive Vibe session: // // vibe --trust --output text [--workdir <path>] [--agent <profile>] -p <prompt> @@ -104,21 +94,6 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ( return cmd, nil } -// GetPromptDeliveryStrategy reports that Vibe receives its prompt in the launch -// command itself. -func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) { - if err := ctx.Err(); err != nil { - return "", err - } - return ports.PromptDeliveryInCommand, nil -} - -// GetAgentHooks is intentionally a no-op: Vibe has no usable lifecycle-hook -// surface for AO activity reporting (Tier C). -func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { - return ctx.Err() -} - // GetRestoreCommand rebuilds the argv that continues an existing Vibe session // when a native session id is available in metadata. Without it, ok is false // and callers fall back to fresh launch behavior. @@ -143,15 +118,8 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) return cmd, true, nil } -// SessionInfo is intentionally a no-op until Vibe can surface native session -// metadata to AO. -func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { - if err := ctx.Err(); err != nil { - return ports.SessionInfo{}, false, err - } - return ports.SessionInfo{}, false, nil -} - +// appendWorkdirFlag adds Vibe's explicit `--workdir` flag. Vibe validates its +// own working directory in addition to the process cwd AO sets. func appendWorkdirFlag(cmd *[]string, workspacePath string) { if workspacePath != "" { *cmd = append(*cmd, "--workdir", workspacePath) @@ -172,70 +140,22 @@ func appendAgentFlags(cmd *[]string, mode ports.PermissionMode) { } } +var vibeBinarySpec = binaryutil.BinarySpec{ + Label: "vibe", + Names: []string{"vibe"}, + WinNames: []string{"vibe.exe", "vibe.cmd", "vibe"}, + UnixPaths: []string{"/usr/local/bin/vibe", "/opt/homebrew/bin/vibe"}, + UnixHomePaths: [][]string{{".local", "bin", "vibe"}, {".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"}}, + WinPaths: []binaryutil.WinPath{ + {Base: binaryutil.WinAppData, Parts: []string{"Python", "Scripts", "vibe.exe"}}, + {Base: binaryutil.WinLocalAppData, Parts: []string{"uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"}}, + }, +} + // ResolveVibeBinary finds the `vibe` binary, searching PATH then common install -// locations. It returns "vibe" as a last resort so callers get the shell's -// normal command-not-found behavior if Vibe is absent. +// locations. It returns a wrapped ports.ErrAgentBinaryNotFound when Vibe is absent. func ResolveVibeBinary(ctx context.Context) (string, error) { - if err := ctx.Err(); err != nil { - return "", err - } - - if runtime.GOOS == "windows" { - for _, name := range []string{"vibe.exe", "vibe.cmd", "vibe"} { - if path, err := exec.LookPath(name); err == nil && path != "" { - return path, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - candidates := []string{} - if appData := os.Getenv("APPDATA"); appData != "" { - candidates = append(candidates, - filepath.Join(appData, "Python", "Scripts", "vibe.exe"), - ) - } - if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - candidates = append(candidates, - filepath.Join(localAppData, "uv", "tools", "mistral-vibe", "Scripts", "vibe.exe"), - ) - } - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - return "", fmt.Errorf("vibe: %w", ports.ErrAgentBinaryNotFound) - } - - if path, err := exec.LookPath("vibe"); err == nil && path != "" { - return path, nil - } - - candidates := []string{ - "/usr/local/bin/vibe", - "/opt/homebrew/bin/vibe", - } - if home, err := os.UserHomeDir(); err == nil { - candidates = append(candidates, - filepath.Join(home, ".local", "bin", "vibe"), - filepath.Join(home, ".local", "share", "uv", "tools", "mistral-vibe", "bin", "vibe"), - ) - } - - for _, candidate := range candidates { - if fileExists(candidate) { - return candidate, nil - } - if err := ctx.Err(); err != nil { - return "", err - } - } - - return "", fmt.Errorf("vibe: %w", ports.ErrAgentBinaryNotFound) + return binaryutil.ResolveBinary(ctx, vibeBinarySpec) } func (p *Plugin) vibeBinary(ctx context.Context) (string, error) { @@ -253,8 +173,3 @@ func (p *Plugin) vibeBinary(ctx context.Context) (string, error) { p.resolvedBinary = binary return binary, nil } - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && !info.IsDir() -} diff --git a/backend/internal/ports/agent.go b/backend/internal/ports/agent.go index 0b8c39dfb..00859b7eb 100644 --- a/backend/internal/ports/agent.go +++ b/backend/internal/ports/agent.go @@ -196,6 +196,22 @@ const ( PermissionModeBypassPermissions = domain.PermissionModeBypassPermissions ) +// NormalizePermissionMode collapses an empty or unrecognized mode to +// PermissionModeDefault, leaving the four known modes unchanged. Adapters call +// it so a stored value they don't recognize defers to the agent's own config +// (usually by emitting no flag) rather than mapping onto a bogus one. +func NormalizePermissionMode(mode PermissionMode) PermissionMode { + switch mode { + case PermissionModeDefault, + PermissionModeAcceptEdits, + PermissionModeAuto, + PermissionModeBypassPermissions: + return mode + default: + return PermissionModeDefault + } +} + // PromptDeliveryStrategy describes how AO should deliver the initial prompt. type PromptDeliveryStrategy string From 821cda64a8a7d4556b4304dbb92a63aa1fbf2031 Mon Sep 17 00:00:00 2001 From: NIKHIL ACHALE <96230495+nikhilachale@users.noreply.github.com> Date: Sat, 4 Jul 2026 21:23:03 +0530 Subject: [PATCH 43/43] feat(cli): context-aware agent spawn with agent catalog and auth preflight (#2411) * feat(cli): add agent command for listing and refreshing agent catalog - Implemented `ao agent ls` command to list supported agents and their installation/auth readiness. - Added `--refresh` flag to refresh local install/auth probes before listing agents. - Introduced JSON output option with `--json` for raw agent catalog response. - Updated tests to cover new agent command functionality, including cached catalog usage and refresh behavior. - Enhanced error handling and output formatting for better user experience. * docs: add CLI Guide link to README for direct usage instructions * docs: clarify direct CLI usage * fix: tighten spawn agent preflight * feat(cli): implement agent readiness probe and update API specifications --- README.md | 4 + backend/internal/cli/agent.go | 110 ++++ backend/internal/cli/agent_test.go | 95 +++ backend/internal/cli/client.go | 17 +- backend/internal/cli/dto_drift_e2e_test.go | 33 +- backend/internal/cli/root.go | 1 + backend/internal/cli/spawn.go | 329 +++++++++- backend/internal/cli/spawn_test.go | 588 +++++++++++++++++- backend/internal/daemon/daemon.go | 8 +- backend/internal/httpd/apispec/openapi.yaml | 52 ++ .../internal/httpd/apispec/specgen/build.go | 13 + backend/internal/httpd/controllers/agents.go | 21 + .../internal/httpd/controllers/agents_test.go | 37 ++ backend/internal/httpd/controllers/dto.go | 8 + .../internal/service/agent/catalog_test.go | 55 ++ backend/internal/service/agent/service.go | 32 + docs/cli/README.md | 31 + frontend/src/api/schema.ts | 72 +++ 18 files changed, 1450 insertions(+), 56 deletions(-) create mode 100644 backend/internal/cli/agent.go create mode 100644 backend/internal/cli/agent_test.go diff --git a/README.md b/README.md index f51285142..08b3f42bd 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ The result is a local control layer for agentic coding: agents still do the codi Works with 23+ CLI-based coding agents including Claude Code, OpenAI Codex, Cursor, OpenCode, Aider, Amp, Goose, GitHub Copilot, Grok, Qwen Code, Kimi Code, Crush, Cline, Droid, Devin, Auggie, Continue, Kiro, and Kilo Code. +For direct CLI usage, including agent readiness checks and context-aware session +spawns, see the [CLI Guide](docs/cli/README.md). + **If it runs in a terminal, it runs on Agent Orchestrator.** --- @@ -172,6 +175,7 @@ For detailed architecture diagrams, data flows, and load-bearing rules, see [arc | -------------------------------------------------------- | ------------------------------------------------------- | | [Architecture](docs/architecture.md) | System architecture, data flows, and load-bearing rules | | [Backend Code Structure](docs/backend-code-structure.md) | Package-by-package ownership and dependency rules | +| [CLI Guide](docs/cli/README.md) | Direct `ao` CLI usage, command routes, and smoke tests | | [AGENTS.md](AGENTS.md) | Contributor and worker-agent contract | --- diff --git a/backend/internal/cli/agent.go b/backend/internal/cli/agent.go new file mode 100644 index 000000000..77c3f7fa1 --- /dev/null +++ b/backend/internal/cli/agent.go @@ -0,0 +1,110 @@ +package cli + +import ( + "fmt" + "sort" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +type agentListOptions struct { + refresh bool + json bool +} + +// agentInfo mirrors the daemon's agent Info body for the CLI client. +type agentInfo struct { + ID string `json:"id"` + Label string `json:"label"` + AuthStatus string `json:"authStatus,omitempty"` +} + +// agentInventory mirrors GET /api/v1/agents and POST /api/v1/agents/refresh. +type agentInventory struct { + Supported []agentInfo `json:"supported"` + Installed []agentInfo `json:"installed"` + Authorized []agentInfo `json:"authorized"` +} + +func newAgentCommand(ctx *commandContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Inspect agent catalog readiness", + } + cmd.AddCommand(newAgentListCommand(ctx)) + return cmd +} + +func newAgentListCommand(ctx *commandContext) *cobra.Command { + var opts agentListOptions + cmd := &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "List supported agents and local auth readiness", + Args: noArgs, + RunE: func(cmd *cobra.Command, args []string) error { + inv, err := ctx.fetchAgentInventory(cmd.Context(), opts.refresh) + if err != nil { + return err + } + if opts.json { + return writeJSON(cmd.OutOrStdout(), inv) + } + return writeAgentList(cmd, inv) + }, + } + cmd.Flags().BoolVar(&opts.refresh, "refresh", false, "Refresh local install/auth probes before listing") + cmd.Flags().BoolVar(&opts.json, "json", false, "Output raw agent catalog JSON") + return cmd +} + +func writeAgentList(cmd *cobra.Command, inv agentInventory) error { + out := cmd.OutOrStdout() + if len(inv.Supported) == 0 { + _, err := fmt.Fprintln(out, "No agents supported by this daemon.") + return err + } + + sort.Slice(inv.Supported, func(i, j int) bool { + return inv.Supported[i].ID < inv.Supported[j].ID + }) + installed := agentInfoByID(inv.Installed) + authorized := agentInfoByID(inv.Authorized) + + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "ID\tLABEL\tINSTALL\tAUTH"); err != nil { + return err + } + for _, info := range inv.Supported { + installLabel := "needs install" + authLabel := "auth unknown" + if installedInfo, ok := installed[info.ID]; ok { + installLabel = "installed" + switch installedInfo.AuthStatus { + case "authorized": + authLabel = "authorized" + case "unauthorized": + authLabel = "needs auth" + default: + authLabel = "auth unknown" + } + } + if _, ok := authorized[info.ID]; ok { + installLabel = "installed" + authLabel = "authorized" + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, info.Label, installLabel, authLabel); err != nil { + return err + } + } + return tw.Flush() +} + +func agentInfoByID(infos []agentInfo) map[string]agentInfo { + out := make(map[string]agentInfo, len(infos)) + for _, info := range infos { + out[info.ID] = info + } + return out +} diff --git a/backend/internal/cli/agent_test.go b/backend/internal/cli/agent_test.go new file mode 100644 index 000000000..eb2426ad5 --- /dev/null +++ b/backend/internal/cli/agent_test.go @@ -0,0 +1,95 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestAgentListUsesCachedCatalogByDefault(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" { + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls") + if err != nil { + t.Fatalf("agent ls failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(out, "codex") || !strings.Contains(out, "needs install") { + t.Fatalf("output missing table labels:\n%s", out) + } + want := []string{"GET /api/v1/agents"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestAgentListRefreshAndStatuses(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh" { + _, _ = io.WriteString(w, `{"supported":[{"id":"aider","label":"Aider"},{"id":"codex","label":"Codex"},{"id":"goose","label":"Goose"},{"id":"opencode","label":"OpenCode"}],"installed":[{"id":"aider","label":"Aider","authStatus":"unauthorized"},{"id":"codex","label":"Codex","authStatus":"authorized"},{"id":"goose","label":"Goose","authStatus":"unknown"}],"authorized":[{"id":"codex","label":"Codex","authStatus":"authorized"}]}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--refresh") + if err != nil { + t.Fatalf("agent ls --refresh failed: %v stderr=%s", err, errOut) + } + for _, want := range []string{"codex", "authorized", "aider", "needs auth", "goose", "auth unknown", "opencode", "needs install"} { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q:\n%s", want, out) + } + } + want := []string{"POST /api/v1/agents/refresh"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestAgentListJSONEmitsRawCatalog(t *testing.T) { + cfg := setConfigEnv(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/agents" { + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "agent", "ls", "--json") + if err != nil { + t.Fatalf("agent ls --json failed: %v stderr=%s", err, errOut) + } + var inv agentInventory + if err := json.Unmarshal([]byte(out), &inv); err != nil { + t.Fatalf("json output did not decode: %v\n%s", err, out) + } + if len(inv.Supported) != 1 || len(inv.Installed) != 1 || len(inv.Authorized) != 1 { + t.Fatalf("inventory = %#v", inv) + } +} diff --git a/backend/internal/cli/client.go b/backend/internal/cli/client.go index 8e8698ae6..2c96cb8f9 100644 --- a/backend/internal/cli/client.go +++ b/backend/internal/cli/client.go @@ -27,6 +27,18 @@ type apiError struct { RequestID string `json:"requestId"` } +type apiResponseError struct { + StatusCode int + ErrorBody apiError +} + +func (e apiResponseError) Error() string { + if e.ErrorBody.Message == "" { + return fmt.Sprintf("daemon returned HTTP %d", e.StatusCode) + } + return e.ErrorBody.String() +} + // String renders the envelope for the user: "<message> (<code>) [request <id>]", // omitting whichever parts the daemon left empty. func (e apiError) String() string { @@ -128,10 +140,7 @@ func (c *commandContext) doJSONPath(ctx context.Context, method, path string, bo if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e apiError _ = json.NewDecoder(resp.Body).Decode(&e) - if e.Message == "" { - return fmt.Errorf("daemon returned HTTP %d", resp.StatusCode) - } - return fmt.Errorf("%s", e.String()) + return apiResponseError{StatusCode: resp.StatusCode, ErrorBody: e} } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index 743a398d7..68c37b5f2 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -37,6 +37,7 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/httpd/controllers" "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/runfile" + agentsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/agent" projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project" sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session" ) @@ -106,6 +107,32 @@ func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string, return sessionsvc.ClaimPRResult{}, nil } +type fakeAgentCatalog struct{} + +var _ controllers.AgentCatalog = (*fakeAgentCatalog)(nil) + +func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { + return authorizedCodexInventory(), nil +} + +func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) { + return authorizedCodexInventory(), nil +} + +func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) { + info := agentsvc.Info{ID: agentID, Label: agentID, AuthStatus: "authorized"} + return agentsvc.ProbeResult{Agent: info, Supported: true, Installed: true}, nil +} + +func authorizedCodexInventory() agentsvc.Inventory { + info := agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"} + return agentsvc.Inventory{ + Supported: []agentsvc.Info{info}, + Installed: []agentsvc.Info{info}, + Authorized: []agentsvc.Info{info}, + } +} + // fakeProjectManager captures the project.AddInput the controller decodes from // the CLI's request body. Every other method is a no-op so it satisfies the // projectsvc.Manager interface. @@ -119,8 +146,9 @@ func (f *fakeProjectManager) List(context.Context) ([]projectsvc.Summary, error) return nil, nil } -func (f *fakeProjectManager) Get(context.Context, domain.ProjectID) (projectsvc.GetResult, error) { - return projectsvc.GetResult{}, nil +func (f *fakeProjectManager) Get(_ context.Context, id domain.ProjectID) (projectsvc.GetResult, error) { + project := projectsvc.Project{ID: id, Path: "/repo/" + string(id)} + return projectsvc.GetResult{Status: "ok", Project: &project}, nil } func (f *fakeProjectManager) Add(_ context.Context, in projectsvc.AddInput) (projectsvc.Project, error) { @@ -150,6 +178,7 @@ func startDriftTestDaemon(t *testing.T, sessions controllers.SessionService, pro log := slog.New(slog.NewTextHandler(io.Discard, nil)) router := httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: &fakeAgentCatalog{}, Sessions: sessions, Projects: projects, }, httpd.ControlDeps{}) diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 0984d4b33..7173d9251 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -184,6 +184,7 @@ func NewRootCommand(deps Deps) *cobra.Command { root.AddCommand(newStopCommand(ctx)) root.AddCommand(newStatusCommand(ctx)) root.AddCommand(newDoctorCommand(ctx)) + root.AddCommand(newAgentCommand(ctx)) root.AddCommand(newSpawnCommand(ctx)) root.AddCommand(newSendCommand(ctx)) root.AddCommand(newPreviewCommand(ctx)) diff --git a/backend/internal/cli/spawn.go b/backend/internal/cli/spawn.go index 920b2b363..4445dde59 100644 --- a/backend/internal/cli/spawn.go +++ b/backend/internal/cli/spawn.go @@ -2,9 +2,14 @@ package cli import ( "context" + "errors" "fmt" + "net/http" "net/url" + "os" + "path/filepath" "runtime" + "sort" "strings" "unicode/utf8" @@ -19,14 +24,15 @@ import ( const maxDisplayNameLen = 20 type spawnOptions struct { - project string - harness string - branch string - prompt string - issue string - name string - claimPR string - noTakeover bool + project string + harness string + branch string + prompt string + issue string + name string + claimPR string + noTakeover bool + skipAgentCheck bool } // spawnRequest mirrors the daemon's SpawnSessionRequest body for @@ -37,7 +43,7 @@ type spawnRequest struct { Harness string `json:"harness,omitempty"` Branch string `json:"branch,omitempty"` Prompt string `json:"prompt,omitempty"` - DisplayName string `json:"displayName"` + DisplayName string `json:"displayName,omitempty"` } type spawnResult struct { @@ -47,6 +53,12 @@ type spawnResult struct { } `json:"session"` } +type agentProbeResult struct { + Agent agentInfo `json:"agent"` + Supported bool `json:"supported"` + Installed bool `json:"installed"` +} + func newSpawnCommand(ctx *commandContext) *cobra.Command { var opts spawnOptions cmd := &cobra.Command{ @@ -57,25 +69,33 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { "fresh git worktree. Register the project first with `ao project add`.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { - if opts.project == "" { - return usageError{fmt.Errorf("--project is required")} - } - name := strings.TrimSpace(opts.name) - if name == "" { - return usageError{fmt.Errorf("--name is required")} - } - if utf8.RuneCountInString(name) > maxDisplayNameLen { - return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)} - } if opts.noTakeover && opts.claimPR == "" { return usageError{fmt.Errorf("--no-takeover requires --claim-pr")} } - claimRef := "" - if opts.claimPR != "" { - project, err := ctx.fetchProjectDetails(cmd.Context(), opts.project) - if err != nil { + if explicitName := strings.TrimSpace(opts.name); utf8.RuneCountInString(explicitName) > maxDisplayNameLen { + return usageError{fmt.Errorf("--name must be %d characters or fewer", maxDisplayNameLen)} + } + + project, err := ctx.resolveSpawnProject(cmd.Context(), opts.project) + if err != nil { + return err + } + opts.project = project.ID + + harness, err := resolveSpawnHarness(opts.harness, project) + if err != nil { + return err + } + opts.harness = harness + + name := resolveSpawnDisplayName(opts.name, opts.prompt) + if !opts.skipAgentCheck { + if err := ctx.preflightSpawnAgentAuth(cmd.Context(), cmd, opts.harness); err != nil { return err } + } + claimRef := "" + if opts.claimPR != "" { claimRef, err = ctx.resolvePRRef(cmd.Context(), opts.claimPR, project) if err != nil { return err @@ -123,7 +143,7 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } else { attach = "Attach from the AO dashboard (ConPTY sessions have no CLI attach command)" } - _, err := fmt.Fprintf(out, "attach with: %s\n", attach) + _, err = fmt.Fprintf(out, "attach with: %s\n", attach) return err }, } @@ -136,17 +156,274 @@ func newSpawnCommand(ctx *commandContext) *cobra.Command { } return pflag.NormalizedName(name) }) - f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (required)") + f.StringVar(&opts.project, "project", "", "Project id to spawn the session in (default: AO_PROJECT_ID or current registered repo)") f.StringVar(&opts.harness, "harness", "", "Agent harness / --agent: claude-code, codex, aider, opencode, grok, droid, amp, agy, crush, cursor, qwen, copilot, goose, auggie, continue, devin, cline, kimi, kiro, kilocode, vibe, pi, autohand (default: project worker.agent; required if the project has none)") f.StringVar(&opts.branch, "branch", "", "Branch for the session worktree (default: ao/<session-id>/root)") f.StringVar(&opts.prompt, "prompt", "", "Initial prompt for the agent") f.StringVar(&opts.issue, "issue", "", "Issue id to associate with the session") - f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (required, max 20 characters)") + f.StringVar(&opts.name, "name", "", "Display name shown in the sidebar (default: derived from --prompt, max 20 characters)") f.StringVar(&opts.claimPR, "claim-pr", "", "Immediately claim an existing PR for the spawned session") f.BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the claimed PR (requires --claim-pr)") + f.BoolVar(&opts.skipAgentCheck, "skip-agent-check", false, "Skip advisory agent catalog install/auth preflight before spawning") return cmd } +func (c *commandContext) fetchAgentInventory(ctx context.Context, refresh bool) (agentInventory, error) { + var inv agentInventory + if refresh { + if err := c.postJSON(ctx, "agents/refresh", struct{}{}, &inv); err != nil { + return agentInventory{}, err + } + return inv, nil + } + if err := c.getJSON(ctx, "agents", &inv); err != nil { + return agentInventory{}, err + } + return inv, nil +} + +func (c *commandContext) resolveSpawnProject(ctx context.Context, explicit string) (projectDetails, error) { + if id := strings.TrimSpace(explicit); id != "" { + return c.fetchProjectDetails(ctx, id) + } + if id := strings.TrimSpace(os.Getenv("AO_PROJECT_ID")); id != "" { + return c.fetchProjectDetails(ctx, id) + } + if sessionID := strings.TrimSpace(os.Getenv("AO_SESSION_ID")); sessionID != "" { + project, err := c.resolveProjectFromSession(ctx, sessionID) + if err != nil { + return projectDetails{}, err + } + return project, nil + } + project, ok, err := c.resolveProjectFromCWD(ctx) + if err != nil { + return projectDetails{}, err + } + if ok { + return project, nil + } + return projectDetails{}, usageError{fmt.Errorf("project could not be resolved; pass --project or run `ao project add --path <repo-path> --worker-agent <agent>`")} +} + +func (c *commandContext) resolveProjectFromSession(ctx context.Context, sessionID string) (projectDetails, error) { + sess, err := c.fetchScopedSession(ctx, sessionID, "") + if err != nil { + return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)} + } + if strings.TrimSpace(sess.ProjectID) == "" { + return projectDetails{}, usageError{fmt.Errorf("project could not be resolved from AO_SESSION_ID %q; pass --project", sessionID)} + } + return c.fetchProjectDetails(ctx, sess.ProjectID) +} + +func (c *commandContext) resolveProjectFromCWD(ctx context.Context) (projectDetails, bool, error) { + cwd, err := os.Getwd() + if err != nil { + return projectDetails{}, false, err + } + cwd, err = normalizeProjectMatchPath(cwd) + if err != nil { + return projectDetails{}, false, err + } + + var list projectListResult + if err := c.getJSON(ctx, "projects", &list); err != nil { + return projectDetails{}, false, err + } + sort.Slice(list.Projects, func(i, j int) bool { + return list.Projects[i].ID < list.Projects[j].ID + }) + + var best projectDetails + bestLen := -1 + ambiguous := false + for _, summary := range list.Projects { + project, err := c.fetchProjectDetails(ctx, summary.ID) + if err != nil { + return projectDetails{}, false, err + } + if project.Path == "" { + continue + } + projectPath, err := normalizeProjectMatchPath(project.Path) + if err != nil { + continue + } + if !pathContains(projectPath, cwd) { + continue + } + pathLen := len(projectPath) + switch { + case pathLen > bestLen: + best = project + bestLen = pathLen + ambiguous = false + case pathLen == bestLen: + ambiguous = true + } + } + if bestLen == -1 { + return projectDetails{}, false, nil + } + if ambiguous { + return projectDetails{}, false, usageError{fmt.Errorf("current directory matches multiple registered projects; pass --project")} + } + return best, true, nil +} + +func normalizeProjectMatchPath(path string) (string, error) { + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + if realPath, err := filepath.EvalSymlinks(abs); err == nil { + abs = realPath + } + return filepath.Clean(abs), nil +} + +func pathContains(root, child string) bool { + if root == child { + return true + } + rel, err := filepath.Rel(root, child) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func resolveSpawnHarness(explicit string, project projectDetails) (string, error) { + if harness := strings.TrimSpace(explicit); harness != "" { + return harness, nil + } + if project.Config != nil { + if harness := strings.TrimSpace(project.Config.Worker.Agent); harness != "" { + return harness, nil + } + } + return "", usageError{fmt.Errorf("agent could not be resolved; pass --agent or configure `ao project set-config %s --worker-agent <agent>`", project.ID)} +} + +func resolveSpawnDisplayName(explicit, prompt string) string { + if name := strings.TrimSpace(explicit); name != "" { + return name + } + return deriveDisplayNameFromPrompt(prompt) +} + +func deriveDisplayNameFromPrompt(prompt string) string { + fields := strings.Fields(strings.TrimSpace(prompt)) + if len(fields) == 0 { + return "" + } + var b strings.Builder + for _, field := range fields { + next := strings.Trim(field, " \t\r\n.,;:!?()[]{}\"'") + if next == "" { + continue + } + if b.Len() > 0 { + next = " " + next + } + if utf8.RuneCountInString(b.String()+next) > maxDisplayNameLen { + break + } + b.WriteString(next) + } + return b.String() +} + +func (c *commandContext) preflightSpawnAgentAuth(ctx context.Context, cmd *cobra.Command, agentID string) error { + inv, err := c.fetchAgentInventory(ctx, true) + if err != nil { + return err + } + state := agentCatalogStateFor(inv, agentID) + if !state.supported { + return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID) + } + if !state.installed || state.authStatus == "unauthorized" { + fresh, err := c.probeSpawnAgent(ctx, agentID) + if err != nil { + if agentProbeUnavailable(err) { + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q fresh readiness probe is unavailable; continuing and letting spawn validate runtime readiness\n", agentID) + return err + } + return err + } + if !fresh.Supported { + return fmt.Errorf("agent %q is not supported by this daemon; pass a supported --agent or run `ao agent ls`", agentID) + } + if !fresh.Installed { + return fmt.Errorf("agent %q needs install; install the agent CLI or pass --skip-agent-check to let spawn validate it", agentID) + } + state.installed = true + state.authorized = fresh.Agent.AuthStatus == "authorized" + state.authStatus = fresh.Agent.AuthStatus + } + if state.authorized { + return nil + } + if state.authStatus == "unauthorized" { + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q may need auth according to a fresh local probe; continuing and letting spawn validate runtime readiness\n", agentID) + return err + } + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "warning: agent %q auth status is unknown; continuing and letting spawn validate runtime readiness\n", agentID) + return err +} + +func (c *commandContext) probeSpawnAgent(ctx context.Context, agentID string) (agentProbeResult, error) { + var result agentProbeResult + if err := c.postJSON(ctx, "agents/"+url.PathEscape(agentID)+"/probe", struct{}{}, &result); err != nil { + return agentProbeResult{}, err + } + return result, nil +} + +func agentProbeUnavailable(err error) bool { + var apiErr apiResponseError + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusNotImplemented +} + +type agentCatalogState struct { + supported bool + installed bool + authorized bool + authStatus string +} + +func agentCatalogStateFor(inv agentInventory, agentID string) agentCatalogState { + state := agentCatalogState{} + for _, info := range inv.Supported { + if info.ID == agentID { + state.supported = true + break + } + } + for _, info := range inv.Authorized { + if info.ID == agentID { + state.installed = true + state.authorized = true + state.authStatus = "authorized" + return state + } + } + for _, info := range inv.Installed { + if info.ID == agentID { + state.installed = true + state.authorized = info.AuthStatus == "authorized" + state.authStatus = info.AuthStatus + return state + } + } + return state +} + // rollbackSpawnedSession reverses a partial `spawn` whose out-of-band follow-up // (PR claim) failed. It calls the daemon's `/rollback` endpoint, which deletes // the seed-state row outright instead of marking it terminated — so the user diff --git a/backend/internal/cli/spawn_test.go b/backend/internal/cli/spawn_test.go index 68bf78b44..486077f86 100644 --- a/backend/internal/cli/spawn_test.go +++ b/backend/internal/cli/spawn_test.go @@ -6,23 +6,44 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "strings" "testing" ) -// TestSpawnCommand_RequiresProject asserts `ao spawn` rejects a missing -// --project before touching the network, so it fails fast without a daemon. -func TestSpawnCommand_RequiresProject(t *testing.T) { - var out, errb bytes.Buffer - root := NewRootCommand(Deps{Out: &out, Err: &errb}) - root.SetArgs([]string{"spawn"}) - err := root.Execute() +func authorizedAgentsJSON(agent string) string { + info := `{"id":` + jsonQuote(agent) + `,"label":` + jsonQuote(agent) + `,"authStatus":"authorized"}` + return `{"supported":[` + info + `],"installed":[` + info + `],"authorized":[` + info + `]}` +} + +// TestSpawnCommand_MissingProjectContext asserts `ao spawn` gives a project +// setup hint when neither --project, AO_PROJECT_ID, nor cwd can resolve one. +func TestSpawnCommand_MissingProjectContext(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects" { + _, _ = io.WriteString(w, `{"projects":[]}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex") if err == nil { - t.Fatal("expected an error when --project is missing") + t.Fatal("expected an error when project context is missing") } - if !strings.Contains(err.Error(), "--project is required") { - t.Fatalf("error = %v, want it to mention --project is required", err) + if !strings.Contains(err.Error(), "ao project add --path <repo-path> --worker-agent <agent>") { + t.Fatalf("error = %v, want project add hint", err) + } + if want := []string{"GET /api/v1/projects"}; !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) } } @@ -50,6 +71,8 @@ func TestSpawnClaimPRWiring(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": _, _ = io.WriteString(w, `{"session":{"id":"demo-9","status":"idle"}}`) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions/demo-9/pr/claim": @@ -66,14 +89,14 @@ func TestSpawnClaimPRWiring(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142", "--no-takeover") + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142", "--no-takeover") if err != nil { t.Fatalf("spawn claim-pr failed: %v stderr=%s", err, errOut) } if !strings.Contains(out, "claimed https://github.com/aoagents/agent-orchestrator/pull/142") { t.Fatalf("output missing claimed label: %s", out) } - want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"} + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-9/pr/claim"} if !reflect.DeepEqual(requests, want) { t.Fatalf("requests=%#v want %#v", requests, want) } @@ -89,6 +112,8 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","repo":"https://github.com/aoagents/agent-orchestrator","defaultBranch":"main"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": sessions["demo-10"] = true _, _ = io.WriteString(w, `{"session":{"id":"demo-10","status":"idle"}}`) @@ -108,7 +133,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { t.Cleanup(srv.Close) writeRunFileFor(t, cfg, srv) - _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--name", "worker", "--claim-pr", "142") + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex", "--name", "worker", "--claim-pr", "142") if err == nil { t.Fatal("expected spawn claim failure") } @@ -119,7 +144,7 @@ func TestSpawnClaimPRFailureRollsBackSession(t *testing.T) { if sessions["demo-10"] { t.Fatalf("spawned session still present after claim rollback: %#v", sessions) } - want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"} + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions", "POST /api/v1/sessions/demo-10/pr/claim", "POST /api/v1/sessions/demo-10/rollback"} if !reflect.DeepEqual(requests, want) { t.Fatalf("requests=%#v want %#v", requests, want) } @@ -132,15 +157,6 @@ func TestSpawnNoTakeoverRequiresClaimPR(t *testing.T) { } } -// TestSpawnCommand_RequiresName asserts `ao spawn` rejects a missing --name -// before touching the network, mirroring the --project guard. -func TestSpawnCommand_RequiresName(t *testing.T) { - _, _, err := executeCLI(t, Deps{}, "spawn", "--project", "demo") - if err == nil || ExitCode(err) != 2 || !strings.Contains(err.Error(), "--name is required") { - t.Fatalf("err=%v exit=%d, want --name is required", err, ExitCode(err)) - } -} - // TestSpawnCommand_RejectsOverlongName asserts `ao spawn` rejects a --name // longer than 20 characters without contacting the daemon. func TestSpawnCommand_RejectsOverlongName(t *testing.T) { @@ -149,3 +165,529 @@ func TestSpawnCommand_RejectsOverlongName(t *testing.T) { t.Fatalf("err=%v exit=%d, want 20 characters or fewer", err, ExitCode(err)) } } + +func TestSpawnResolvesProjectFromEnvAndDefaultAgent(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-11","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + t.Setenv("AO_PROJECT_ID", "demo") + + out, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix failing tests in auth") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(out, "spawned session demo-11") { + t.Fatalf("output missing spawn: %s", out) + } + if req.ProjectID != "demo" || req.Harness != "codex" || req.DisplayName != "Fix failing tests in" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnResolvesProjectFromAOSessionID(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/demo-1": + _, _ = io.WriteString(w, `{"session":`+sessionJSON("demo-1", "demo", "worker", "idle", false)+`}`) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo","config":{"worker":{"agent":"codex"}}}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-15","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + t.Setenv("AO_SESSION_ID", "demo-1") + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/sessions/demo-1", "GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnAOSessionIDFailureRequiresProject(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/sessions/missing": + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"not_found","code":"SESSION_NOT_FOUND","message":"Session not found"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + t.Setenv("AO_SESSION_ID", "missing") + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--agent", "codex") + if err == nil || !strings.Contains(err.Error(), `project could not be resolved from AO_SESSION_ID "missing"; pass --project`) { + t.Fatalf("err=%v, want AO_SESSION_ID project error", err) + } + want := []string{"GET /api/v1/sessions/missing"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnResolvesProjectFromCWD(t *testing.T) { + cfg := setConfigEnv(t) + repo := filepath.Join(t.TempDir(), "repo") + subdir := filepath.Join(repo, "pkg") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + oldwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(subdir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects": + _, _ = io.WriteString(w, `{"projects":[{"id":"demo","name":"Demo"}]}`) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":`+jsonQuote(repo)+`,"config":{"worker":{"agent":"codex"}}}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, authorizedAgentsJSON("codex")) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--prompt", "Fix tests") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } +} + +func TestSpawnStaleUnauthorizedAgentRefreshesProbesThenAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"authorized"},"supported":true,"installed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if errOut != "" { + t.Fatalf("stderr = %q, want no warning after fresh authorized probe", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnFreshUnauthorizedWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unauthorized"},"supported":true,"installed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "may need auth according to a fresh local probe") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnavailableFreshProbeWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unauthorized"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + w.WriteHeader(http.StatusNotImplemented) + _, _ = io.WriteString(w, `{"message":"not implemented","code":"NOT_IMPLEMENTED"}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "fresh readiness probe is unavailable") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnsupportedAgentRefreshesThenBlocks(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unknown") + if err == nil || !strings.Contains(err.Error(), "agent \"unknown\" is not supported") { + t.Fatalf("err=%v, want unsupported", err) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnNotInstalledAgentRefreshesThenBlocks(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex"},"supported":true,"installed":false}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err == nil || !strings.Contains(err.Error(), "agent \"codex\" needs install") { + t.Fatalf("err=%v, want needs install", err) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnStaleNotInstalledFreshInstalledWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + _, _ = io.WriteString(w, `{"agent":{"id":"codex","label":"Codex","authStatus":"unknown"},"supported":true,"installed":true}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "auth status is unknown") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnavailableFreshProbeForNotInstalledWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"message":"not found","code":"NOT_FOUND"}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-12","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "fresh readiness probe is unavailable") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnFreshProbeServerErrorBlocks(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/codex/probe": + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"message":"probe failed","code":"PROBE_FAILED","requestId":"req-1"}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, _, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err == nil || !strings.Contains(err.Error(), "probe failed (PROBE_FAILED) [request req-1]") { + t.Fatalf("err=%v, want probe server error", err) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/agents/refresh", "POST /api/v1/agents/codex/probe"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnSkipAgentCheckBypassesOnlyPreflight(t *testing.T) { + cfg := setConfigEnv(t) + var requests []string + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + appendPrimaryRequest(&requests, r) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-14","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "unsupported", "--skip-agent-check") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if req.ProjectID != "demo" || req.Harness != "unsupported" { + t.Fatalf("spawn request = %#v", req) + } + want := []string{"GET /api/v1/projects/demo", "POST /api/v1/sessions"} + if !reflect.DeepEqual(requests, want) { + t.Fatalf("requests=%#v want %#v", requests, want) + } +} + +func TestSpawnUnknownAuthRefreshesWarnsAndAllows(t *testing.T) { + cfg := setConfigEnv(t) + var req spawnRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/projects/demo": + _, _ = io.WriteString(w, `{"status":"ok","project":{"id":"demo","name":"Demo","path":"/repo/demo"}}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/agents/refresh": + _, _ = io.WriteString(w, `{"supported":[{"id":"codex","label":"Codex"}],"installed":[{"id":"codex","label":"Codex","authStatus":"unknown"}],"authorized":[]}`) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/sessions": + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"session":{"id":"demo-13","status":"idle"}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + writeRunFileFor(t, cfg, srv) + + _, errOut, err := executeCLI(t, Deps{ProcessAlive: func(int) bool { return true }}, "spawn", "--project", "demo", "--agent", "codex") + if err != nil { + t.Fatalf("spawn failed: %v stderr=%s", err, errOut) + } + if !strings.Contains(errOut, "auth status is unknown") { + t.Fatalf("stderr missing warning: %s", errOut) + } + if req.ProjectID != "demo" || req.Harness != "codex" { + t.Fatalf("spawn request = %#v", req) + } +} diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go index 727e0a86f..0e31f0639 100644 --- a/backend/internal/daemon/daemon.go +++ b/backend/internal/daemon/daemon.go @@ -132,10 +132,16 @@ func Run() error { } lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log) previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx) + agentSvc := agentsvc.New() + go func() { + if _, err := agentSvc.Refresh(ctx); err != nil { + log.Warn("initial agent catalog refresh failed", "err", err) + } + }() srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{ Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, DefaultHarness: domain.AgentHarness(cfg.Agent), Telemetry: telemetrySink}), - Agents: agentsvc.New(), + Agents: agentSvc, Sessions: sessionSvc, Reviews: reviewSvc, Notifications: notifier, diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 2551c32ee..e4554bde8 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -33,6 +33,45 @@ paths: summary: Return cached supported and locally installed agent adapters tags: - agents + /api/v1/agents/{agent}/probe: + post: + operationId: probeAgent + parameters: + - description: Agent adapter identifier. + in: path + name: agent + required: true + schema: + description: Agent adapter identifier. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ProbeAgentResponse' + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Implemented + summary: Run a fresh local readiness probe for one agent adapter + tags: + - agents /api/v1/agents/refresh: post: operationId: refreshAgents @@ -1975,6 +2014,19 @@ components: - targetSha - status type: object + ProbeAgentResponse: + properties: + agent: + $ref: '#/components/schemas/AgentInfo' + installed: + type: boolean + supported: + type: boolean + required: + - agent + - supported + - installed + type: object Project: properties: agent: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 9e3d0634a..3ce0d5936 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -137,6 +137,7 @@ var schemaNames = map[string]string{ // httpd/controllers (wire envelopes) "ControllersListProjectsResponse": "ListProjectsResponse", "ControllersProjectResponse": "ProjectResponse", + "ControllersAgentIDParam": "AgentIDParam", "ControllersGetProjectResponse": "ProjectGetResponse", "ControllersProjectOrDegraded": "ProjectOrDegraded", "ControllersListSessionsQuery": "ListSessionsQuery", @@ -174,6 +175,7 @@ var schemaNames = map[string]string{ "ControllersOrchestratorResponse": "OrchestratorResponse", "AgentInventory": "ListAgentsResponse", "AgentInfo": "AgentInfo", + "AgentProbeResult": "ProbeAgentResponse", "ControllersListNotificationsQuery": "ListNotificationsQuery", "ControllersNotificationStreamQuery": "NotificationStreamQuery", "ControllersNotificationIDParam": "NotificationIDParam", @@ -313,6 +315,17 @@ func agentOperations() []operation { {http.StatusNotImplemented, envelope.APIError{}}, }, }, + { + method: http.MethodPost, path: "/api/v1/agents/{agent}/probe", id: "probeAgent", tag: "agents", + summary: "Run a fresh local readiness probe for one agent adapter", + pathParams: []any{controllers.AgentIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.ProbeAgentResponse{}}, + {http.StatusBadRequest, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + {http.StatusNotImplemented, envelope.APIError{}}, + }, + }, } } diff --git a/backend/internal/httpd/controllers/agents.go b/backend/internal/httpd/controllers/agents.go index d8c3624e3..30602a5b4 100644 --- a/backend/internal/httpd/controllers/agents.go +++ b/backend/internal/httpd/controllers/agents.go @@ -3,6 +3,7 @@ package controllers import ( "context" "net/http" + "strings" "github.com/go-chi/chi/v5" @@ -15,6 +16,7 @@ import ( type AgentCatalog interface { List(ctx context.Context) (agentsvc.Inventory, error) Refresh(ctx context.Context) (agentsvc.Inventory, error) + Probe(ctx context.Context, agentID string) (agentsvc.ProbeResult, error) } // AgentsController owns the /agents routes. @@ -26,6 +28,7 @@ type AgentsController struct { func (c *AgentsController) Register(r chi.Router) { r.Get("/agents", c.list) r.Post("/agents/refresh", c.refresh) + r.Post("/agents/{agent}/probe", c.probe) } func (c *AgentsController) list(w http.ResponseWriter, r *http.Request) { @@ -53,3 +56,21 @@ func (c *AgentsController) refresh(w http.ResponseWriter, r *http.Request) { } envelope.WriteJSON(w, http.StatusOK, inventory) } + +func (c *AgentsController) probe(w http.ResponseWriter, r *http.Request) { + if c.Catalog == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/agents/{agent}/probe") + return + } + agentID := strings.TrimSpace(chi.URLParam(r, "agent")) + if agentID == "" { + envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "AGENT_REQUIRED", "agent is required", nil) + return + } + result, err := c.Catalog.Probe(r.Context(), agentID) + if err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, result) +} diff --git a/backend/internal/httpd/controllers/agents_test.go b/backend/internal/httpd/controllers/agents_test.go index 76e0d0545..1304f21ca 100644 --- a/backend/internal/httpd/controllers/agents_test.go +++ b/backend/internal/httpd/controllers/agents_test.go @@ -17,9 +17,12 @@ import ( type fakeAgentCatalog struct { inventory agentsvc.Inventory refreshed agentsvc.Inventory + probed agentsvc.ProbeResult err error listCalls int refreshCalls int + probeCalls int + probeAgent string } func (f *fakeAgentCatalog) List(context.Context) (agentsvc.Inventory, error) { @@ -35,6 +38,12 @@ func (f *fakeAgentCatalog) Refresh(context.Context) (agentsvc.Inventory, error) return f.inventory, f.err } +func (f *fakeAgentCatalog) Probe(_ context.Context, agentID string) (agentsvc.ProbeResult, error) { + f.probeCalls++ + f.probeAgent = agentID + return f.probed, f.err +} + func TestListAgents(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) catalog := &fakeAgentCatalog{inventory: agentsvc.Inventory{ @@ -92,3 +101,31 @@ func TestRefreshAgents(t *testing.T) { t.Fatalf("calls: list=%d refresh=%d, want list=0 refresh=1", catalog.listCalls, catalog.refreshCalls) } } + +func TestProbeAgent(t *testing.T) { + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + catalog := &fakeAgentCatalog{ + probed: agentsvc.ProbeResult{ + Agent: agentsvc.Info{ID: "codex", Label: "Codex", AuthStatus: "authorized"}, + Supported: true, + Installed: true, + }, + } + srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{ + Agents: catalog, + }, httpd.ControlDeps{})) + defer srv.Close() + + body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/agents/codex/probe", "") + if status != http.StatusOK { + t.Fatalf("POST /agents/codex/probe = %d, body=%s", status, body) + } + for _, want := range []string{`"supported":true`, `"installed":true`, `"id":"codex"`, `"authStatus":"authorized"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("body missing %s: %s", want, body) + } + } + if catalog.probeCalls != 1 || catalog.probeAgent != "codex" { + t.Fatalf("probe calls=%d agent=%q, want one codex probe", catalog.probeCalls, catalog.probeAgent) + } +} diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 0f830b244..89cad9192 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -27,6 +27,11 @@ type ProjectIDParam struct { ID string `path:"id" description:"Project identifier (registry key)."` } +// AgentIDParam is the {agent} path parameter for one-agent catalog probes. +type AgentIDParam struct { + Agent string `path:"agent" description:"Agent adapter identifier."` +} + // ListProjectsResponse is the body of GET /api/v1/projects. type ListProjectsResponse struct { Projects []projectsvc.Summary `json:"projects"` @@ -442,6 +447,9 @@ type ListAgentsResponse = agentsvc.Inventory // RefreshAgentsResponse is the body of POST /api/v1/agents/refresh. type RefreshAgentsResponse = agentsvc.Inventory +// ProbeAgentResponse is the body of POST /api/v1/agents/{agent}/probe. +type ProbeAgentResponse = agentsvc.ProbeResult + // AgentInfo is one supported or installed agent entry. type AgentInfo = agentsvc.Info diff --git a/backend/internal/service/agent/catalog_test.go b/backend/internal/service/agent/catalog_test.go index 2df062e3c..9b238a2ce 100644 --- a/backend/internal/service/agent/catalog_test.go +++ b/backend/internal/service/agent/catalog_test.go @@ -280,6 +280,61 @@ func TestRefreshIsRateLimited(t *testing.T) { } } +func TestProbeBypassesRefreshRateLimitForOneAgent(t *testing.T) { + previous := agentRefreshMinInterval + agentRefreshMinInterval = time.Hour + t.Cleanup(func() { agentRefreshMinInterval = previous }) + + probes := 0 + svc := NewWithAgents([]agentregistry.HarnessAgent{ + { + Harness: domain.AgentHarness("codex"), + Manifest: adapters.Manifest{ + ID: "codex", + Name: "Codex", + }, + Agent: probeTrackingAgent{fakeAgent: fakeAgent{}, onProbe: func() { probes++ }}, + }, + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + }) + + if _, err := svc.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh: %v", err) + } + got, err := svc.Probe(context.Background(), "codex") + if err != nil { + t.Fatalf("Probe: %v", err) + } + if !got.Supported || !got.Installed || got.Agent.ID != "codex" { + t.Fatalf("Probe = %#v, want supported installed codex", got) + } + if probes != 2 { + t.Fatalf("probes = %d, want refresh plus fresh probe", probes) + } +} + +func TestProbeReportsUnsupportedAndMissingAgent(t *testing.T) { + svc := NewWithAgents([]agentregistry.HarnessAgent{ + harnessAgent("missing", "Missing", ports.ErrAgentBinaryNotFound), + }) + + missing, err := svc.Probe(context.Background(), "missing") + if err != nil { + t.Fatalf("Probe missing: %v", err) + } + if !missing.Supported || missing.Installed { + t.Fatalf("Probe missing = %#v, want supported but not installed", missing) + } + + unsupported, err := svc.Probe(context.Background(), "unknown") + if err != nil { + t.Fatalf("Probe unknown: %v", err) + } + if unsupported.Supported || unsupported.Installed || unsupported.Agent.ID != "unknown" { + t.Fatalf("Probe unknown = %#v, want unsupported unknown", unsupported) + } +} + func harnessAgent(id, label string, err error) agentregistry.HarnessAgent { return agentregistry.HarnessAgent{ Harness: domain.AgentHarness(id), diff --git a/backend/internal/service/agent/service.go b/backend/internal/service/agent/service.go index 2a2391294..837ddee17 100644 --- a/backend/internal/service/agent/service.go +++ b/backend/internal/service/agent/service.go @@ -23,6 +23,13 @@ type probeResult struct { authorized bool } +// ProbeResult describes a fresh readiness probe for one supported agent. +type ProbeResult struct { + Agent Info `json:"agent"` + Supported bool `json:"supported"` + Installed bool `json:"installed"` +} + // Info is the user-facing identity for an agent adapter. type Info struct { ID string `json:"id"` @@ -140,6 +147,31 @@ func (s *Service) Refresh(ctx context.Context) (Inventory, error) { return next, nil } +// Probe runs a fresh bounded binary/auth probe for one agent, bypassing the +// catalog refresh rate limit. It is intended for user-initiated preflight paths +// where a cached negative catalog result may be stale. +func (s *Service) Probe(ctx context.Context, agentID string) (ProbeResult, error) { + if err := ctx.Err(); err != nil { + return ProbeResult{}, err + } + for _, item := range s.agents { + info := Info{ID: string(item.Harness), Label: item.Manifest.Name} + if info.Label == "" { + info.Label = info.ID + } + if info.ID != agentID { + continue + } + res := probeAgent(ctx, item) + return ProbeResult{ + Agent: res.info, + Supported: true, + Installed: res.installed, + }, nil + } + return ProbeResult{Agent: Info{ID: agentID}, Supported: false, Installed: false}, nil +} + func supportedInfos(agents []agentregistry.HarnessAgent) []Info { supported := make([]Info, 0, len(agents)) for _, item := range agents { diff --git a/docs/cli/README.md b/docs/cli/README.md index b26f542b6..a94952ff6 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -5,6 +5,18 @@ It starts, discovers, inspects, and stops the daemon through the loopback HTTP surface and the `running.json` handshake. It must not open SQLite directly or call runtime, workspace, tracker, or agent adapters in-process. +When using the CLI directly from a shell, make sure the daemon is running first +with `ao start` or by opening the desktop app. Product commands such as +`ao agent ls` and `ao spawn` call the loopback daemon and will fail with a +"daemon is not running" error if no `running.json` points at a live process. From +a source checkout, build and run the local binary explicitly, for example: + +```bash +cd backend +go build -o ./bin/ao ./cmd/ao +./bin/ao agent ls +``` + ## Current commands Every product command resolves to a daemon HTTP route. Run `ao <command> @@ -31,6 +43,8 @@ Every product command resolves to a daemon HTTP route. Run `ao <command> | `ao project get <id>` | `GET /api/v1/projects/{id}` | | `ao project set-config <id>` | `PUT /api/v1/projects/{id}/config` | | `ao project rm <id>` | `DELETE /api/v1/projects/{id}` | +| `ao agent ls` | `GET /api/v1/agents` | +| `ao agent ls --refresh` | `POST /api/v1/agents/refresh` | | `ao spawn` | `POST /api/v1/sessions` | | `ao session ls` | `GET /api/v1/sessions` | | `ao session get <id>` | `GET /api/v1/sessions/{id}` | @@ -44,6 +58,23 @@ Every product command resolves to a daemon HTTP route. Run `ao <command> | `ao preview [url]` | `POST /api/v1/sessions/{id}/preview` | | `ao hooks <agent> <event>` | `POST /api/v1/sessions/{id}/activity` (hidden) | +`ao agent ls` prints the daemon-supported agent catalog with local install/auth +readiness. Use `--refresh` to rerun the bounded local probes and `--json` to +print the raw inventory response. + +`ao spawn` resolves project context in this order: explicit `--project`, +`AO_PROJECT_ID`, `AO_SESSION_ID` (by fetching the current session from the +daemon), then the current working directory matched against registered project +paths. If `AO_SESSION_ID` is set but the session cannot be fetched, pass +`--project` explicitly. + +If `--agent` / `--harness` is omitted, `ao spawn` uses the resolved project's +`worker.agent` config. Before spawning, the CLI refreshes the advisory agent +catalog and fails early when the selected agent is unsupported, not installed, +or unauthorized. It warns-but-continues when auth remains unknown because daemon +spawn remains the authoritative runtime validation point. Use +`--skip-agent-check` to bypass only this CLI-side preflight. + `ao preview` resolves its session from the `AO_SESSION_ID` environment variable (it is meant to run inside a session), not a flag. With no argument it autodetects an `index.html` in the session workspace; with a URL argument it diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 294de0c00..3bd45ec04 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -21,6 +21,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/agents/{agent}/probe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Run a fresh local readiness probe for one agent adapter */ + post: operations["probeAgent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/agents/refresh": { parameters: { query?: never; @@ -708,6 +725,11 @@ export interface components { targetSha: string; title: string; }; + ProbeAgentResponse: { + agent: components["schemas"]["AgentInfo"]; + installed: boolean; + supported: boolean; + }; Project: { agent?: string; config?: components["schemas"]["ProjectConfig"]; @@ -1027,6 +1049,56 @@ export interface operations { }; }; }; + probeAgent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent adapter identifier. */ + agent: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProbeAgentResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; refreshAgents: { parameters: { query?: never;