diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 83fd55b0..663a1029 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -110,6 +110,29 @@ jobs: - uses: ./.github/actions/setup-android + # FCM client config, when the run can see secrets. Writing it here is what + # puts the google-services plugin — resource generation and its manifest + # merge, which ONLY a Firebase-enabled build exercises — under PR-time + # verification instead of discovering it in the nightly deploy. + # + # Tolerant, unlike deploy-android's copy: a pull_request from a fork gets + # no secrets, and build.gradle.kts skips the plugin when the file is + # absent, so those builds still pass — just without the Firebase half + # compiled in. Announce which of the two builds this was; a silent + # difference between CI and the release build is the whole reason push + # shipped broken. + - name: Set up FCM client config + env: + GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + run: | + set -euo pipefail + if [ -z "${GOOGLE_SERVICES_JSON_BASE64:-}" ]; then + echo "No GOOGLE_SERVICES_JSON_BASE64 (expected on a fork PR) — building WITHOUT the FCM plugin path." + exit 0 + fi + printf '%s' "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/android/app/google-services.json + echo "Wrote app/android/app/google-services.json — building WITH the FCM plugin path." + - name: Flutter analyze working-directory: app run: flutter analyze diff --git a/.github/workflows/deploy-android.yml b/.github/workflows/deploy-android.yml index 1c4fab3e..4abb776c 100644 --- a/.github/workflows/deploy-android.yml +++ b/.github/workflows/deploy-android.yml @@ -110,6 +110,31 @@ jobs: storeFile=../upload-keystore.jks EOF + # FCM client config for the release AAB. Without this file build.gradle.kts + # skips the google-services plugin, the app ships with no google_app_id + # resource, FirebaseMessaging.getToken() throws on every launch, and the + # phone never sends push:register — push is dead in the field and NOTHING + # in the build log says so. That is how it shipped dark; hard-fail instead + # of inheriting the silence. Only this repo releases (RELEASE_REPO gates + # the job), so an absent secret here is a misconfiguration, never a fork. + # + # No content check: the google-services plugin already refuses a malformed + # file or one with no client for the applicationId, and it reads that id + # from Gradle, so a check here would be a second copy that can drift. + # What nothing can check from this repo is that the file's project_id + # matches the relay's FCM_PROJECT_ID — that env lives in antgrid-ops. A + # mismatch mints tokens fine and fails every send as UNREGISTERED. + - name: Set up FCM client config + env: + GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} + run: | + set -euo pipefail + if [ -z "${GOOGLE_SERVICES_JSON_BASE64:-}" ]; then + echo "::error::GOOGLE_SERVICES_JSON_BASE64 is not set — this AAB would ship to the Play Store with push permanently disabled." + exit 1 + fi + printf '%s' "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/android/app/google-services.json + # versionName is stamped, not read from pubspec — see the `version` job in # build-desktop.yml for the format. VERSION_MAJOR must match the env of the # same name there; nothing enforces it across workflow files, so bump both diff --git a/CLAUDE.md b/CLAUDE.md index 72398cba..5944cbae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Shared packages in `packages/`: **`antgrid_relay_client`** (pure Dart relay/cryp - **Windows build needs VS 2022+/18 with the C++ workload AND a JDK.** VS 2019's CMake 3.20 fails; VS 18 2026 with its bundled CMake 4.3.1 is measured green. The tree asks only 3.14 (`app/windows/CMakeLists.txt`), so the real floor is whatever the Windows plugins demand. The JDK is not optional: `sentry_flutter` pulls `jni`, which declares Windows support, so every configure runs `find_package(JNI)` and the link dies `LNK1104 ... jvm.lib` without one. Any full JDK works (`winget install Microsoft.OpenJDK.21` sets `JAVA_HOME` itself). - **A failed Windows CMake configure poisons `app/build/` permanently.** The install-prefix block in `app/windows/CMakeLists.txt` sits *after* `include(flutter/generated_plugins.cmake)`, and `CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT` is true only on the FIRST configure — so one plugin error (a missing JDK will do it) leaves the prefix at `C:/Program Files/antgrid` and no later successful configure ever corrects it. Symptom is a clean compile that dies in INSTALL with "cannot create directory … Maybe need administrative privileges", which reads like a permissions problem and is not. Fix the plugin error, then delete `app/build/windows` — re-running the build alone cannot recover. - **Windows ships only as a Store MSIX, and a packaged binary is launchable from OUTSIDE the package only if `AppxManifest.xml` declares it as an ``.** An undeclared one fails `CreateProcess` with `ERROR_ACCESS_DENIED` — surfaced by libuv as `EPERM: uv_spawn` — even though its DACL grants execute; only the app itself, which holds package identity, can spawn a sibling. Every agent's hook config names `antgrid-bridge.exe` by absolute path (`resolveHookCommand` bakes `process.execPath`), so losing that declaration kills every hook for every agent, silently and in the field. `scripts/patch-msix-manifest.ps1` adds it between `msix:build` and `msix:pack` — the `msix` package emits exactly one `` and its `execution_alias` only ever aliases the main exe — and `scripts/verify-msix-executables.ps1` gates the packed artifact. Two non-obvious parts of that declaration: an `` is **single-instance by default**, so the bridge needs `desktop4:SupportsMultipleInstances="true"` or concurrent hooks collapse into one process; and `Subsystem="console"` exists only on `uap5:AppExecutionAlias`, so the older `uap3` + `desktop:ExecutionAlias` spelling cannot carry it. `bridge/scripts/smoke-hook-binary.ts` cannot cover any of this: it runs the loose binary, which is the case that always works. **The helper must stay visible in the app list** — one `AppListEntry="none"` anywhere makes the whole package a headless app, which Store ingestion refuses without Microsoft's per-product `HeadlessAppBypass` waiver (request: storeops@microsoft.com). It fails at submission commit, minutes after a full upload, so pack and `verify-msix-executables.ps1` both pass first. -- **A Store update while the bridge host is alive permanently bricks the package.** Every child of the app inherits its Desktop AppX **silo** (measured on 26200 — children do NOT break away), so `antgrid-bridge.exe` and its whole PTY tree are silo members. The update force-kills the app, which fires neither `didRequestAppExit` nor `HostTeardownObserver`; `owner-watchdog.ts` notices ~2s later and then drains *gracefully* for up to 5s more, so members are still live while the Store destages. A silo destroyed with members leaks the package's Helium hives (`%LOCALAPPDATA%\Packages\\SystemAppData\Helium\{User,UserClasses}.dat`) mounted with no owner; the next launch cannot convert a fresh job into that family's silo, fails `ERROR_SHARING_VIOLATION` (0x80070020 → AppXDeploymentServer events 215 + 208), and the shell reports "Another program is currently using this file". **Only sign-out or reboot clears it** — `Reset-AppxPackage`, `Add-AppxPackage -Register`, and service restarts all fail, and Windows' own `RepairAppRegistrationOption` retry is what fails on every launch. The hard backstop is `app/lib/launcher/windows_job_object.dart`: the app assigns each spawned host to a job with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` and never closes the handle, so the kernel sweeps the tree as it reaps the app — on force-kill too, which is the only path that matters. A nested job inside a silo IS permitted and DOES sweep grandchildren (measured). Keep the assignment BEFORE the stdin bootstrap write in `spawnHostProcess`: the host blocks on `readBootstrapPayload()` as its first act, which is the only thing making the window race-free — and it only holds while the spawned pid IS the bridge, not the `cmd.exe` of a `.cmd` `ANTGRID_AGENT_BIN`. POSIX has no equivalent that survives a SIGKILLed parent — the watchdog stays the sole backstop there. +- **A Store update while the bridge host is alive permanently bricks the package.** Every child of the app inherits its Desktop AppX **silo** (measured on 26200 — children do NOT break away), so `antgrid-bridge.exe` and its whole PTY tree are silo members. The update force-kills the app, which fires neither `didRequestAppExit` nor `HostTeardownObserver`; `owner-watchdog.ts` notices ~2s later and then drains *gracefully* for up to 5s more, so members are still live while the Store destages. A silo destroyed with members leaks the package's Helium hives (`%LOCALAPPDATA%\Packages\\SystemAppData\Helium\{User,UserClasses}.dat`) mounted with no owner; the next launch cannot convert a fresh job into that family's silo, fails `ERROR_SHARING_VIOLATION` (0x80070020 → AppXDeploymentServer events 215 + 208), and the shell reports "Another program is currently using this file". **Only sign-out or reboot clears it** — `Reset-AppxPackage`, `Add-AppxPackage -Register`, and service restarts all fail, and Windows' own `RepairAppRegistrationOption` retry is what fails on every launch. The hard backstop is `app/lib/launcher/windows_job_object.dart`: the app assigns each spawned host to a job with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` and never closes the handle, so the kernel sweeps the tree as it reaps the app — on force-kill too, which is the only path that matters. A nested job inside a silo IS permitted and DOES sweep grandchildren (measured). Keep the assignment BEFORE the stdin bootstrap write in `spawnHostProcess`: the host blocks on `readBootstrapPayload()` as its first act, which is the only thing making the window race-free — and it only holds while the spawned pid IS the bridge, not the `cmd.exe` of a `.cmd` `ANTGRID_AGENT_BIN`. POSIX has no equivalent that survives a SIGKILLed parent — the watchdog stays the sole backstop there — and no tree kill either: the host is spawned `ProcessStartMode.normal`, so it leads no process group and `kill(-pid)` is a guaranteed ESRCH. `terminateTree` (`app/lib/launcher/discovery.dart`) gets its reach on POSIX by SIGTERMing first, which the bridge traps and answers by sweeping its own PTYs with `killProcessTree`, escalating to SIGKILL only after a grace; going straight to SIGKILL there orphans every child that ignores the pty hangup. `UpdateInstallController` (`app/lib/update/update_install_controller.dart`) drains the host gracefully before handing an update to the Store, but that is an improvement on the sweep, never a replacement — the job object stays the only thing standing between a mistake here and a bricked package. The app remains fully interactive for the Store's whole window (two consent dialogs, download, deploy), so the drain alone would be undone by the first `ensureHost()`; `HostController.sealSpawns()` is what makes it hold, and every path that does NOT hand the process over owes an `unsealSpawns()` — a seal nothing lifts leaves the machine unable to start any agent. - **Relay Bun floor**: the relay requires Bun ≥ 1.3.14 for iOS direct-APNs push — Bun 1.3.10 fails the APNs sandbox TLS/ALPN handshake. Pin CI/runtime to 1.3.14+, not floating `1.3`. - **Encryption & command-exec boundaries are non-negotiable** — see Conventions. diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index cc2887e9..e33be392 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -114,7 +114,7 @@ ghostty_vte: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/vte/ghostty_vte - ref: 6cd393196ed301afa1d8ada7a996cc345b899b4a + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 ``` `ghostty_vte_flutter` and `portable_pty` are pinned to the same repository and diff --git a/app/lib/demo/demo_transport.dart b/app/lib/demo/demo_transport.dart index d224cb3a..05cbd309 100644 --- a/app/lib/demo/demo_transport.dart +++ b/app/lib/demo/demo_transport.dart @@ -258,6 +258,7 @@ class DemoTransport extends BufferedAgentTransport { // dead end this switch exists to avoid. case 'session:stop': case 'session:create': + case 'session:fork': case 'session:delete': case 'session:rename': case 'session:archive': diff --git a/app/lib/design/ab_icons.dart b/app/lib/design/ab_icons.dart index b272f43c..712d82cf 100644 --- a/app/lib/design/ab_icons.dart +++ b/app/lib/design/ab_icons.dart @@ -100,6 +100,10 @@ abstract final class AbIcons { // off from the main line" without naming a backend — the marker stands for // every non-`main` checkout kind, not for worktrees specifically. static const isolated = Codicon.repo_forked; + // More than one session working in one directory. The collaborative-session + // glyph, because what the marker reports is company rather than a place: + // paired against [isolated], which says a session has a workspace to itself. + static const sharedWorkspace = Codicon.live_share; // Sign-in method glyphs. `key` rather than `lock`: the cell offers a // credential the user supplies, not a state of being secured. static const password = Codicon.key; diff --git a/app/lib/design/widgets/ab_list_row.dart b/app/lib/design/widgets/ab_list_row.dart index de6cf3b3..1559a75a 100644 --- a/app/lib/design/widgets/ab_list_row.dart +++ b/app/lib/design/widgets/ab_list_row.dart @@ -335,9 +335,15 @@ class _AbListRowState extends State { onShowFocusHighlight: (v) { if (_focused != v) setState(() => _focused = v); }, - onShowHoverHighlight: (v) { - if (_hovered != v) setState(() => _hovered = v); - }, + // Only tracked when it can be seen: `_hovered` feeds nothing but the + // `showHover` fill, so on a flat row — which every drawer row is — the + // setState would rebuild the whole row to identical pixels on each + // pointer crossing. + onShowHoverHighlight: widget.hoverable + ? (v) { + if (_hovered != v) setState(() => _hovered = v); + } + : null, actions: { ActivateIntent: CallbackAction( onInvoke: (_) { diff --git a/app/lib/launcher/discovery.dart b/app/lib/launcher/discovery.dart index cc418925..a6b5fc1c 100644 --- a/app/lib/launcher/discovery.dart +++ b/app/lib/launcher/discovery.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show visibleForTesting; + /// Probe whether [pid] is alive without delivering a signal. /// /// Async to avoid blocking the UI isolate during project switches — @@ -43,25 +45,82 @@ Future terminatePid(int pid) async { } } +/// How long the POSIX path waits for a SIGTERMed host to sweep its own trees +/// before escalating. Long enough for the bridge's `shutdown()` to walk its +/// terminals (measured in the low hundreds of ms), short enough that the app +/// close this backstops doesn't visibly stall on a host that is past helping. +const _posixTreeGrace = Duration(milliseconds: 1500); + /// Force-kill [pid] AND its child process tree. Unlike [terminatePid], this /// reaps grandchildren — the host's PTYs (claude/codex/dev servers) — which a /// plain process kill would orphan. Used as the backstop when a graceful -/// `host:shutdown` doesn't exit in time on app close. +/// `host:shutdown` doesn't exit in time on app close, and when a host from +/// another build has to be replaced. Future terminateTree(int pid) async { try { if (Platform.isWindows) { await Process.run('taskkill', ['/F', '/T', '/PID', '$pid']); - } else { - // Negative pid targets the whole process group (reaps PTY grandchildren - // when the host leads its own group). killPid returns false — it does NOT - // throw — when the group doesn't exist (e.g. the host shares the app's - // group, so `pid` is not a PGID). Fall back to the bare pid on a false - // return, not just on an exception, or the host would survive entirely. - if (!Process.killPid(-pid, ProcessSignal.sigkill)) { - Process.killPid(pid, ProcessSignal.sigkill); - } + return; } + await terminateTreePosix(pid); } catch (_) { // best-effort } } + +/// The POSIX half, with the process seams injected so the escalation is +/// testable — the real one signals live pids and cannot be exercised in a unit +/// test. +/// +/// **Signalling the group is not enough here, and on this spawn it reaches +/// nothing at all.** POSIX names a process GROUP, which exists only if the +/// target leads one, and nothing makes the host lead one: `HostController` +/// starts it with `ProcessStartMode.normal`, which leaves it in the app's +/// group (the bridge states the same precondition for its own children in +/// bridge/src/terminal-session.ts, and satisfies it there with `detached`). So +/// `-pid` is a reliable ESRCH and every call lands on the bare-pid fallback, +/// which reaches the host and nothing under it. +/// +/// SIGTERM first is what closes that gap without changing how the host is +/// spawned — the bridge traps it (bridge/src/index.ts) and its `shutdown()` +/// runs `killProcessTree` over every terminal, which DOES name each PTY's own +/// group, because a PTY child is a session leader by construction. Escalating +/// to SIGKILL only after [grace] keeps an unconditional kill as the floor. +/// +/// Changing the spawn to a detached mode would fix the group directly, but it +/// is entangled with the Windows job-object assignment and the bootstrap-write +/// ordering in `spawnHostProcess` — the two things standing between a mistake +/// there and a bricked MSIX package. This stays on the signalling side. +@visibleForTesting +Future terminateTreePosix( + int pid, { + bool Function(int pid, ProcessSignal signal) send = Process.killPid, + Future Function(int pid) alive = isPidAlive, + Future Function(Duration d) delay = _delayFor, + Duration grace = _posixTreeGrace, +}) async { + // killPid returns false — it does NOT throw — when no group carries this id. + // Fall back on a false return, not just on an exception, or a host that + // leads no group is never signalled at all. + if (!send(-pid, ProcessSignal.sigterm)) { + send(pid, ProcessSignal.sigterm); + } + var waited = Duration.zero; + const step = Duration(milliseconds: 100); + while (waited < grace) { + if (!await alive(pid)) return; + await delay(step); + waited += step; + } + // The loop grants the final step but never checks it, and SIGKILL against a + // pid the OS may already have reused reaches something else entirely. + if (!await alive(pid)) return; + // Past helping: take what we can reach. A host wedged badly enough to ignore + // SIGTERM was never going to sweep its own children, so orphans here are the + // floor this escalation accepts rather than something it introduces. + if (!send(-pid, ProcessSignal.sigkill)) { + send(pid, ProcessSignal.sigkill); + } +} + +Future _delayFor(Duration d) => Future.delayed(d); diff --git a/app/lib/launcher/host_controller.dart b/app/lib/launcher/host_controller.dart index fe5af99e..504333cc 100644 --- a/app/lib/launcher/host_controller.dart +++ b/app/lib/launcher/host_controller.dart @@ -18,6 +18,18 @@ import '../util/log_rotation.dart'; void _log(String msg) => AbLog.info('HostController', msg); +/// Thrown by [HostController.ensureHost] while spawning is sealed for an +/// in-flight update. Distinct from a spawn FAILURE: nothing is wrong with the +/// machine and retrying later succeeds. +class HostSpawnSealed implements Exception { + const HostSpawnSealed(); + + @override + String toString() => + 'HostSpawnSealed: an update is installing; the bridge stays down until ' + 'it finishes'; +} + /// Lifecycle of the machine's bridge host as the app understands it. enum HostPhase { /// Nothing has needed the host yet this launch. @@ -225,6 +237,9 @@ class HostController { /// later ordinary spawn inside the 60s window would be mislabelled.) bool _supervisedSpawn = false; + /// While true, nothing may bring a host up — see [sealSpawns]. + bool _spawnSealed = false; + HostStatus _status = const HostStatus(HostPhase.idle); final _statusCtl = StreamController.broadcast(); @@ -288,9 +303,34 @@ class HostController { } } + /// Refuse to bring a host up until [unsealSpawns]. + /// + /// A Windows Store install drains the host and then leaves the app fully + /// interactive for the Store's whole window — two consent dialogs, the + /// download, the deploy. Any [ensureHost] in that window spawns a fresh + /// bridge and PTY tree that the update then force-kills, undoing the drain + /// it was just given. Sealing is what makes the drain hold. + /// + /// The job object still sweeps such a tree ([encloseInAppLifetimeJob]), so + /// this buys a graceful shutdown rather than package integrity — do not + /// treat it as the backstop. + void sealSpawns() => _spawnSealed = true; + + /// Lifts [sealSpawns]. Owed on every path where the install did NOT take the + /// process with it, or the app is left unable to start an agent at all. + void unsealSpawns() => _spawnSealed = false; + + @visibleForTesting + bool get spawnSealed => _spawnSealed; + /// Resolve a live host, respawning if stale. Single-flighted: concurrent /// cold-start callers share one spawn. + /// + /// Throws [HostSpawnSealed] while [sealSpawns] holds. Every caller already + /// handles a throwing spawn (there is no host to fall back to), so the seal + /// needs no per-caller degradation of its own. Future ensureHost() { + if (_spawnSealed) return Future.error(const HostSpawnSealed()); final existing = _inFlight; if (existing != null) return existing; final fut = _ensureHostInner(); @@ -368,7 +408,14 @@ class HostController { /// Clear the crash-loop budget and try again now. Backs the UI's retry /// affordance on [HostPhase.failed]; rethrows so the caller can show the /// spawn error. + /// + /// Refused BEFORE the budget is touched while [sealSpawns] holds: the reset + /// below abandons any restart still sleeping out its backoff, and the seal + /// stops [_scheduleRestart] queueing another — so retrying into a seal would + /// trade a refusal the caller can show for a supervision chain nothing + /// re-arms. Future retryNow() async { + if (_spawnSealed) throw const HostSpawnSealed(); _restartsInWindow = 0; _restartWindowStart = null; _restartEpoch++; @@ -398,7 +445,10 @@ class HostController { } void _scheduleRestart(int? exitCode) { - if (_disposed || _restartPending) return; + // Belt and braces beside `_expectExit`: that only covers the generation we + // drained, while the seal also covers a host someone raced up and lost + // before the update landed. + if (_disposed || _restartPending || _spawnSealed) return; final now = _now(); final windowStart = _restartWindowStart; diff --git a/app/lib/launcher/host_teardown.dart b/app/lib/launcher/host_teardown.dart index 705f7e54..d08ff8ab 100644 --- a/app/lib/launcher/host_teardown.dart +++ b/app/lib/launcher/host_teardown.dart @@ -2,6 +2,7 @@ import 'dart:ui' show AppExitResponse; import 'package:flutter/widgets.dart'; +import 'host_controller.dart'; import 'local_agent_launcher.dart'; /// Tears down the app-spawned bridge host when the app exits, so the @@ -18,11 +19,35 @@ import 'local_agent_launcher.dart'; /// bridge/src/owner-watchdog.ts) is the backstop, plus the host's /// prune-on-load self-heal on the next launch. class HostTeardownObserver with WidgetsBindingObserver { + /// [host] and [budget] are test seams; production uses the shared host and + /// the real ceiling. + HostTeardownObserver({HostController? host, Duration? budget}) + : _host = host ?? LocalAgentLauncher.sharedHost, + _budget = budget ?? _defaultBudget; + + final HostController _host; + final Duration _budget; + + /// Backstops `shutdownOwnedHost`'s own internal budget (a 2s control-plane + /// call, a 3s wait for a graceful exit, then a bounded force-kill), leaving + /// headroom without doubling it. + /// + /// The bound is not decoration. On macOS this observer is the WHOLE update + /// story: Sparkle installs by quitting the app — `[NSApp terminate:]`, which + /// `FlutterAppDelegate.applicationShouldTerminate` turns into this callback — + /// and then waits on the process before swapping the bundle. Without a + /// ceiling a wedged host doesn't just delay the quit, it stalls the update + /// standing behind it. Nothing else on that platform gets a look in: the one + /// pre-quit hook the Sparkle plugin exposes (`onUpdaterBeforeQuitForUpdate`) + /// fires only for silent install-on-quit after an automatic background + /// download, which `SUEnableAutomaticChecks` turns off. + static const _defaultBudget = Duration(seconds: 10); + @override Future didRequestAppExit() async { // Best-effort and owned-only; never block exit on a teardown failure. try { - await LocalAgentLauncher.sharedHost.shutdownOwnedHost(); + await _host.shutdownOwnedHost().timeout(_budget); } catch (_) { // ignore — exiting regardless } diff --git a/app/lib/main.dart b/app/lib/main.dart index 652b75f2..00dc5ccb 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -53,6 +53,7 @@ import 'screens/sign_in_screen.dart'; import 'services/devices_api.dart' show DeviceCapInfo; import 'services/app_settings_service.dart'; import 'services/push_background_handler.dart'; +import 'providers/update_available.dart'; import 'storage/cached_sessions_store.dart'; import 'storage/drawer_collapsed_store.dart'; import 'storage/drawer_order_store.dart'; @@ -60,6 +61,7 @@ import 'storage/first_run_store.dart'; import 'storage/project_store.dart'; import 'storage/recent_agents_store.dart'; import 'storage/recent_ports_store.dart'; +import 'storage/update_handoff_store.dart'; import 'update/update_gate.dart'; import 'util/ab_log.dart'; import 'widgets/auth_splash.dart'; @@ -144,6 +146,7 @@ Future main() async { cachedSessionsStore, firstRunStore, prefs, + updateHandoffStore, ) = await ( ProjectStore.open(), RecentAgentsStore.open(), @@ -153,9 +156,21 @@ Future main() async { CachedSessionsStore.open(), FirstRunStore.open(), openAppSettingsPrefs(), + UpdateHandoffStore.open(), ).wait; final initialAppSettings = AppSettings.fromPrefs(prefs); + // Consumed unconditionally, never behind Windows' `--after-update` argument: + // that comes back after a crash, a hang and a reboot-to-patch as well as + // after an update, so it is not evidence; and Sparkle's relaunch on macOS + // passes no argument at all, so requiring it would mean no macOS or Linux + // announcement ever. A version recorded at hand-off that no longer matches + // the running build is the evidence, on every platform. Once per launch, so + // it can never announce twice. + final updatedFromVersion = await updateHandoffStore.consume( + BuildInfo.version, + ); + final installId = await SecureInstallIdStore().ensure(); final platform = analyticsPlatformTag(defaultTargetPlatform); late final ProviderContainer container; @@ -198,6 +213,8 @@ Future main() async { ), ), analyticsServiceProvider.overrideWithValue(analytics), + updateHandoffStoreProvider.overrideWithValue(updateHandoffStore), + afterUpdateLaunchProvider.overrideWithValue(updatedFromVersion), ], ); diff --git a/app/lib/models/handler_state.dart b/app/lib/models/handler_state.dart index fc3e2997..c5508bff 100644 --- a/app/lib/models/handler_state.dart +++ b/app/lib/models/handler_state.dart @@ -369,6 +369,10 @@ class HandlerEscalationChoice { /// text that answers nothing while the bridge clears the row anyway. The /// bridge refuses to mint these — this is the app's own floor, because a /// dead button is invisible to whoever taps it. + /// - [kind] is `guard_blocked`. That row exists BECAUSE a guard refused this + /// exact text, so a one-tap would re-send it with the thinnest possible + /// human in the loop. The bridge refuses to mint these — this is the app's + /// own floor, for the same reason the `resolve_in_session` one is. /// - the count is outside the wire's 2..3, or any entry is malformed. A card /// is never one chip, so a partial list is dropped whole rather than /// rendered short — and dropping only the choices keeps the escalation @@ -382,6 +386,7 @@ class HandlerEscalationChoice { String? kind, }) { if (kind == 'resolve_in_session') return null; + if (kind == 'guard_blocked') return null; if (json is! List || json.length < 2 || json.length > 3) return null; final choices = []; for (final e in json) { @@ -405,8 +410,15 @@ class HandlerEscalation { final String urgency; // 'normal' | 'high' final String? floorRule; final int at; + // Hand-mirror of OpenEscalationWire.kind (bridge/src/protocol.ts) and + // OpenEscalationSchema (bridge/src/handler/session-store.ts); a bare String on + // both sides, so a missed mirror is silent. + // // null/'reply' → free-text reply sheet; 'resolve_in_session' → option-based - // prompt (permission/question) answered in the chat transcript UI. + // prompt (permission/question) answered in the chat transcript UI; + // 'guard_blocked' → a report that a harness guard refused an action Handler + // wanted to take. A reply to one is optional, a dismiss is what retires it + // (`handler:dismiss`), and the bridge never sends choices for one. final String? kind; /// The quick choices to render as a decision card, or null for a plain @@ -582,8 +594,8 @@ class HandlerActivityRecord { // unrenderable feed row, never at compile time. // 'continue' | 'handle' | 'escalate' | 'armed' | 'goal_edited' | // 'item_done' | 'item_blocked' | 'item_skipped' | 'item_failed' | - // 'instruction_dropped' | 'floor_warning' | 'wrapped_up' | 'parked' | - // 'resumed' + // 'instruction_dropped' | 'floor_warning' | 'evidence_rejected' | + // 'wrapped_up' | 'parked' | 'resumed' final String decision; final String reason; final String? detail; diff --git a/app/lib/models/session_entry.dart b/app/lib/models/session_entry.dart index 5be4de4a..1dd24c2f 100644 --- a/app/lib/models/session_entry.dart +++ b/app/lib/models/session_entry.dart @@ -157,6 +157,12 @@ class SessionEntry { final bool deleting; final String? tool; final String? command; + final bool forkSupported; + + /// The session this one was forked from, or null for every session that was + /// not. Provenance, not a link: the source may since have been renamed, + /// archived or deleted, so nothing may resolve it and render its name. + final String? forkedFromSessionId; final String? args; final String mode; @@ -182,6 +188,8 @@ class SessionEntry { final String checkoutKind; final String? checkoutBranch; final String checkoutState; + final bool sharedWorkspace; + final int workspaceMemberCount; /// Null for every shared session, for a bridge predating the feature, and for /// an isolated session whose project declares no `worktree.setup` — all three @@ -198,6 +206,8 @@ class SessionEntry { this.deleting = false, this.tool, this.command, + this.forkSupported = false, + this.forkedFromSessionId, this.args, this.mode = 'terminal', this.agentSessionResumable = true, @@ -207,6 +217,8 @@ class SessionEntry { this.checkoutKind = 'main', this.checkoutBranch, this.checkoutState = 'ready', + this.sharedWorkspace = false, + this.workspaceMemberCount = 1, this.setup, }); @@ -222,6 +234,8 @@ class SessionEntry { if (deleting) 'deleting': true, if (tool != null) 'tool': tool, if (command != null) 'command': command, + if (forkSupported) 'forkSupported': true, + if (forkedFromSessionId != null) 'forkedFromSessionId': forkedFromSessionId, if (args != null) 'args': args, 'mode': mode, 'agentSessionResumable': agentSessionResumable, @@ -231,6 +245,8 @@ class SessionEntry { 'checkoutKind': checkoutKind, if (checkoutBranch != null) 'checkoutBranch': checkoutBranch, 'checkoutState': checkoutState, + if (sharedWorkspace) 'sharedWorkspace': true, + if (workspaceMemberCount > 1) 'workspaceMemberCount': workspaceMemberCount, if (setup != null) 'setup': setup!.toJson(), }; @@ -250,6 +266,8 @@ class SessionEntry { deleting: j['deleting'] as bool? ?? false, tool: j['tool'] as String?, command: j['command'] as String?, + forkSupported: j['forkSupported'] as bool? ?? false, + forkedFromSessionId: j['forkedFromSessionId'] as String?, args: j['args'] as String?, mode: j['mode'] as String? ?? 'terminal', // Optimistic on absence, matching the bridge schema's default: a stale @@ -264,6 +282,8 @@ class SessionEntry { checkoutKind: j['checkoutKind'] as String? ?? 'main', checkoutBranch: j['checkoutBranch'] as String?, checkoutState: j['checkoutState'] as String? ?? 'ready', + sharedWorkspace: j['sharedWorkspace'] as bool? ?? false, + workspaceMemberCount: (j['workspaceMemberCount'] as num?)?.toInt() ?? 1, setup: switch (j['setup']) { final Map m => SessionSetup.fromJson(m), _ => null, @@ -299,6 +319,8 @@ class SessionEntry { deleting: deleting ?? this.deleting, tool: tool, command: command, + forkSupported: forkSupported, + forkedFromSessionId: forkedFromSessionId, args: args, mode: mode, agentSessionResumable: agentSessionResumable, @@ -308,6 +330,8 @@ class SessionEntry { checkoutKind: checkoutKind, checkoutBranch: checkoutBranch, checkoutState: checkoutState, + sharedWorkspace: sharedWorkspace, + workspaceMemberCount: workspaceMemberCount, setup: clearSetup ? null : (setup ?? this.setup), ); @@ -324,6 +348,8 @@ class SessionEntry { other.deleting == deleting && other.tool == tool && other.command == command && + other.forkSupported == forkSupported && + other.forkedFromSessionId == forkedFromSessionId && other.args == args && other.mode == mode && other.agentSessionResumable == agentSessionResumable && @@ -333,10 +359,13 @@ class SessionEntry { other.checkoutKind == checkoutKind && other.checkoutBranch == checkoutBranch && other.checkoutState == checkoutState && + other.sharedWorkspace == sharedWorkspace && + other.workspaceMemberCount == workspaceMemberCount && other.setup == setup; @override - int get hashCode => Object.hash( + // hashAll, not hash: the field list is past Object.hash's 20-argument ceiling. + int get hashCode => Object.hashAll([ id, name, createdAt, @@ -346,6 +375,8 @@ class SessionEntry { deleting, tool, command, + forkSupported, + forkedFromSessionId, args, mode, agentSessionResumable, @@ -355,6 +386,8 @@ class SessionEntry { checkoutKind, checkoutBranch, checkoutState, + sharedWorkspace, + workspaceMemberCount, setup, - ); + ]); } diff --git a/app/lib/providers/update_available.dart b/app/lib/providers/update_available.dart index 657b24c2..fd345fcf 100644 --- a/app/lib/providers/update_available.dart +++ b/app/lib/providers/update_available.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../storage/update_handoff_store.dart'; import 'value_controller.dart'; /// Whether a newer app version is waiting to be installed — a pending @@ -8,11 +9,23 @@ import 'value_controller.dart'; /// Play flexible update on Android. Lit by `UpdateGate` routing the platform /// `UpdateStrategy`'s check outcome, rendered by `UpdateRow` in the drawer. /// -/// Latches true only — no source un-pends within this process's lifetime -/// (an MSIX update can't apply while running; `releases/latest` and a store -/// listing only move forward; a downloaded flexible update stays installable -/// until the restart applies it), and a later failed/transient re-check must -/// not hide the affordance. +/// A CHECK only ever latches it true: no source un-pends on its own within +/// this process's lifetime (an MSIX update can't apply while running; +/// `releases/latest` and a store listing only move forward; a downloaded +/// flexible update stays installable until the restart applies it), and a +/// later failed or transient re-check must not hide the affordance. +/// +/// The one thing that clears it is an attempted INSTALL whose platform +/// answered "nothing pending" — the only evidence that can outrank a check, +/// and the only way the row stops offering an install that can no longer do +/// anything. Transient failures answer `unavailable` instead and leave it lit. final updateAvailableProvider = NotifierProvider, bool>( () => ValueController(false), ); + +/// Records the build being replaced so the next launch can prove an update +/// actually happened — the `--after-update` argument alone cannot, because +/// Windows relaunches with it after a crash too. Overridden in `main()`. +final updateHandoffStoreProvider = Provider((_) { + throw StateError('updateHandoffStoreProvider must be overridden in main()'); +}); diff --git a/app/lib/services/handler_service.dart b/app/lib/services/handler_service.dart index 221924ff..f605d834 100644 --- a/app/lib/services/handler_service.dart +++ b/app/lib/services/handler_service.dart @@ -375,6 +375,33 @@ class HandlerService { ); } + /// Acknowledge a `guard_blocked` report — the only thing that retires one, on + /// either side of the wire. Nothing the agent or the user does next answers a + /// report about an action Handler never took. + /// + /// Refuses every other kind: the app-side mirror of the bridge's own refusal, + /// because a Dismiss on a live question would drop it more silently than any + /// path that exists today. + void dismiss(HandlerEscalation escalation) { + if (_disposed) return; + if (escalation.kind != 'guard_blocked') return; + _sendDismiss(escalation); + _dropRows( + escalation.terminalId, + (e) => e.escalationId != escalation.escalationId, + ); + } + + void _sendDismiss(HandlerEscalation escalation) { + session.send( + createAbMessage('handler:dismiss', { + 'projectId': session.projectId, + 'terminalId': escalation.terminalId, + 'escalationId': escalation.escalationId, + }), + ); + } + /// The judge pick a picker would seed from (status snapshots and optimistic /// [arm] writes feed the cache). Null = never picked. /// @@ -433,10 +460,12 @@ class HandlerService { /// reuse `terminal:input` (trailing `\r` submits the line, matching the /// bridge act path); chat slots send `agent:prompt` — the same inbound verb /// an app-composed message uses, which also resets the bridge's runaway - /// guard. Optimistically drops the terminal's free-text escalations AND - /// recomputes the answering session's pending count locally so the header pill - /// and tab badge don't show a stale "needs you" over an empty list for the - /// round-trip; the next handler:status snapshot reconciles authoritatively. + /// guard. Optimistically drops exactly the rows the bridge's own rule retires + /// (see [_survivesReply]) through [_dropRows]. + /// + /// A `guard_blocked` row IS replyable — the sheet opens prefilled with the text + /// the guard refused — and sending is itself the explicit act on it, so its + /// dismiss goes out with the answer. /// /// Returns whether the answer reached the wire. Every refusal below leaves an /// unanswered escalation behind, so a surface that showed the send as @@ -479,30 +508,60 @@ class HandlerService { // line clears the terminal's whole free-text set (below, and in the bridge), so a // sibling left out of this set comes back off the next status snapshot with its // one-tap chip live — and that tap puts a second line into a session the first - // one already unblocked. + // one already unblocked. A `guard_blocked` sibling is NOT one of those: the + // bridge does not retire it on a submitted line, so suppressing it here would + // hide a row the next snapshot still legitimately carries. for (final e in _state.escalations) { if (e.terminalId == escalation.terminalId && - e.kind != 'resolve_in_session') { + e.kind != 'resolve_in_session' && + e.kind != 'guard_blocked') { _answeredEscalations.add(e.escalationId); } } _answeredEscalations.add(escalation.escalationId); + // Sending your own words IS the explicit act on a report — the bridge cannot + // tell that line apart from an unrelated one, which is the whole reason the + // kind exists — so the dismiss rides along with it. + if (escalation.kind == 'guard_blocked') _sendDismiss(escalation); + _dropRows( + escalation.terminalId, + (e) => _survivesReply(e, escalation), + ); + return true; + } + + /// Whether [e] outlives the submitted line that answered [answered]. Exactly + /// the bridge's rule: an option-based prompt is unanswerable by a typed line, + /// and a report is not answered by one either — but the report the user + /// replied FROM is dismissed alongside the send, so it goes. + bool _survivesReply(HandlerEscalation e, HandlerEscalation answered) => + e.kind == 'resolve_in_session' || + (e.kind == 'guard_blocked' && + e.escalationId != answered.escalationId); + + /// Optimistically drop every row on [terminalId] that [survives] rejects, and + /// recompute the owning session's pending count so the header pill and tab + /// badge don't show a stale "needs you" over an empty list for the round trip. + /// The next `handler:status` snapshot reconciles authoritatively. + /// + /// Clearing wholesale instead would blank the pill over a session the bridge + /// still reports as needs_you and flip it back a round trip later — the + /// blank-over-a-blocked-agent flash this optimism exists to spare the user. + void _dropRows( + String terminalId, + bool Function(HandlerEscalation) survives, + ) { final sessions = Map.from(_state.sessions); - final answered = sessions[escalation.terminalId]; - if (answered != null) { - // Exactly the bridge's rule: a submitted line retires the terminal's - // free-text rows and leaves every option-based prompt standing. Clearing - // wholesale would blank the pill over a session the bridge still reports - // as needs_you, then flip it back a round-trip later — the blank-over-a- - // blocked-agent flash this optimism is meant to spare the user. - final surviving = _survivingPrompts(answered.escalations); - sessions[escalation.terminalId] = answered.copyWith( + final owner = sessions[terminalId]; + if (owner != null) { + final surviving = owner.escalations.where(survives).toList(); + sessions[terminalId] = owner.copyWith( pendingEscalations: surviving.length, escalations: surviving, runState: - surviving.isEmpty && answered.runState == HandlerRunState.needsYou + surviving.isEmpty && owner.runState == HandlerRunState.needsYou ? HandlerRunState.watching - : answered.runState, + : owner.runState, ); } _emit( @@ -510,20 +569,12 @@ class HandlerService { sessions: sessions, escalations: [ for (final e in _state.escalations) - if (e.terminalId != escalation.terminalId || - e.kind == 'resolve_in_session') - e, + if (e.terminalId != terminalId || survives(e)) e, ], ), ); - return true; } - List _survivingPrompts(List rows) => [ - for (final e in rows) - if (e.kind == 'resolve_in_session') e, - ]; - /// Answer [escalation] by tapping one of its own quick choices (spec §4.6). /// [choiceId] is resolved against the offered set and the choice's `text` is /// what goes on the wire, so a caller holding only an id — an OS notification diff --git a/app/lib/services/sessions_service.dart b/app/lib/services/sessions_service.dart index 298c682b..ca70ced5 100644 --- a/app/lib/services/sessions_service.dart +++ b/app/lib/services/sessions_service.dart @@ -341,6 +341,22 @@ class SessionsService { return pending.future; } + /// Forks from bridge-owned transcript and workspace state. The app supplies + /// no command, native conversation id, transcript, or filesystem path. + Future fork(String sourceSessionId, {required String workspace}) { + final requestId = _newRequestId(); + final pending = _newPending( + () => _pendingCreates.remove(requestId), + ); + _pendingCreates[requestId] = pending; + unawaited(_send(createAbMessage('session:fork', { + 'requestId': requestId, + 'sourceSessionId': sourceSessionId, + 'workspace': workspace, + }))); + return pending.future; + } + /// Starts [id]. With [raiseRefusal], a typed bridge refusal — an isolated /// session whose checkout is gone, an antgrid.yaml that moved /// `agent.workingDir` out of it — arrives as a [SessionOperationException] diff --git a/app/lib/storage/update_handoff_store.dart b/app/lib/storage/update_handoff_store.dart new file mode 100644 index 00000000..cec0cdb4 --- /dev/null +++ b/app/lib/storage/update_handoff_store.dart @@ -0,0 +1,68 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../config/storage_scope.dart'; +import '../update/github_release_update_service.dart'; +import 'scoped_prefs.dart'; + +/// The write half of [UpdateHandoffStore], which is all the install sequence is +/// given: consuming the mark belongs to the launch path alone, and a sequence +/// able to consume it could swallow the announcement it exists to arm. +abstract interface class UpdateHandoffSink { + /// Records [version] as the build about to be replaced. Written BEFORE the + /// platform is handed the update: on Windows the call may not return. + Future markHandoff(String version); + + /// Drops the mark for an install that did not happen, so a later crash + /// relaunch cannot find it and read it as an update. + Future clear(); +} + +/// The app version that was running when an update was handed to the platform. +/// +/// `RegisterApplicationRestart` is registered with flags `0`, deliberately: +/// every `RESTART_NO_*` bit subtracts a case Windows would otherwise relaunch +/// us for, and which bit governs an MSIX servicing restart is not something we +/// can establish short of shipping one. Keeping all of them means a crash, a +/// hang and a reboot-to-patch each relaunch with the same `--after-update` +/// argument, so that argument alone is not evidence an update happened — +/// announcing one on it would tell a user who had just crashed that they were +/// updated. +/// +/// A version written at hand-off and compared against the running build on the +/// next launch IS evidence: only a package replacement can change it. That +/// holds whichever restart reason fired, which is what makes it preferable to +/// narrowing the flags and hoping the update case survives. +class UpdateHandoffStore implements UpdateHandoffSink { + static final _key = scopedStorageKey('antgrid.update_handoff_version.v1'); + final SharedPreferencesWithCache _prefs; + + UpdateHandoffStore._(this._prefs); + + static Future open() async => + UpdateHandoffStore._(await openScopedPrefs({_key})); + + @override + Future markHandoff(String version) => _prefs.setString(_key, version); + + @override + Future clear() => _prefs.remove(_key); + + /// Returns the replaced version when [current] differs from the mark, else + /// null. Always clears, so an announcement can fire at most once. + /// + /// A mark NEWER than the running build is a rollback, not an update — easy + /// to reach on Linux, where installing an older AppImage is a file copy — + /// and announcing an update to the older build would be plainly wrong. + /// CalVer (`..`) is ordered by the same + /// precedence rules the release check uses, so the comparison lives in one + /// place; anything unparseable (a local `dev` build) answers false there and + /// so announces, which is the better of the two mistakes. + Future consume(String current) async { + final previous = _prefs.getString(_key); + if (previous == null) return null; + await clear(); + if (previous == current) return null; + if (isNewerVersion(current: current, latestTag: previous)) return null; + return previous; + } +} diff --git a/app/lib/update/macos_sparkle_update_service.dart b/app/lib/update/macos_sparkle_update_service.dart index d2a17d93..cbc6d3e2 100644 --- a/app/lib/update/macos_sparkle_update_service.dart +++ b/app/lib/update/macos_sparkle_update_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_updater/auto_updater.dart'; import 'package:flutter/foundation.dart'; @@ -18,8 +20,8 @@ import 'github_release_update_service.dart'; /// Every method is a safe no-op off macOS and swallows plugin errors — an /// unpackaged dev build has no Sparkle to talk to and the feature must /// degrade silently rather than block startup. -class MacosSparkleUpdateService { - const MacosSparkleUpdateService(); +class MacosSparkleUpdateService implements UpdaterListener { + MacosSparkleUpdateService(); /// Published as a release asset by build-desktop.yml; `releases/latest` /// makes this URL stable across versions (prereleases never move it). @@ -27,12 +29,44 @@ class MacosSparkleUpdateService { static const String appcastUrl = '${GithubReleaseUpdateService.latestDownloadPageUrl}/download/appcast.xml'; + final _noUpdateFound = StreamController.broadcast(); + bool _listening = false; + bool get _supported => defaultTargetPlatform == TargetPlatform.macOS; + /// Fires when Sparkle's own check disagrees with the appcast read that lit + /// the row: it looked at the same feed and found nothing it will install. + /// + /// The two can differ honestly. `MacosAppcastUpdateService` takes the newest + /// ``'s `sparkle:version`; Sparkle additionally honours + /// `minimumSystemVersion` and the item's channel, so a release built for a + /// newer macOS than the user runs is advertised to us and refused by + /// Sparkle. Without this the row stays lit for the rest of the process — and + /// re-lights on every launch — over an Update button that can never do + /// anything. + Stream get noUpdateFound => _noUpdateFound.stream; + /// Points Sparkle at the appcast. Must have run once before [startUpdate]; /// `UpdateGate` calls it at startup on macOS. Never throws. Future configureFeed() async { if (!_supported) return; + // Registered here rather than in the constructor: the strategy is built on + // every platform (the provider table is one list) and only macOS has a + // plugin to talk to. Latched because configureFeed is deliberately + // idempotent and runs again on every install. + if (!_listening) { + _listening = true; + try { + autoUpdater.addListener(this); + } catch (e) { + _listening = false; + AbLog.warn( + 'Update', + 'MacosSparkleUpdateService.addListener failed (ignored)', + fields: {'error': '$e'}, + ); + } + } try { await autoUpdater.setFeedURL(appcastUrl); } catch (e) { @@ -58,4 +92,64 @@ class MacosSparkleUpdateService { ); } } + + /// Detaches from Sparkle and closes [noUpdateFound]. + /// + /// `autoUpdater` is a process-global singleton that holds listeners by strong + /// reference and never drops them on its own, so an instance that skips this + /// stays registered — and keeps answering delegate callbacks on a closed + /// controller — for the rest of the process. + void dispose() { + if (_listening) { + _listening = false; + try { + autoUpdater.removeListener(this); + } catch (e) { + AbLog.warn( + 'Update', + 'MacosSparkleUpdateService.removeListener failed (ignored)', + fields: {'error': '$e'}, + ); + } + } + unawaited(_noUpdateFound.close()); + } + + // --- UpdaterListener ----------------------------------------------------- + // + // Sparkle drives its own UI through SPUStandardUserDriver: it shows the + // release notes, the progress, the errors and its own "You're up to date". + // So these callbacks are for what the UI cannot do — retracting a row we lit + // ourselves, and leaving a trace for a field report. Deliberately no toast: + // a second opinion beside Sparkle's open dialog can only contradict it. + + @override + void onUpdaterUpdateNotAvailable(UpdaterError? error) { + AbLog.info('Update', 'Sparkle found nothing to install; clearing the row'); + if (!_noUpdateFound.isClosed) _noUpdateFound.add(null); + } + + @override + void onUpdaterError(UpdaterError? error) { + // The plugin forwards only `localizedDescription` — no code, no domain — + // and Sparkle routes a user cancellation through the same delegate as a + // real failure. So this can be logged and never classified. + AbLog.warn( + 'Update', + 'Sparkle aborted (cancelled or failed — indistinguishable here)', + fields: {'error': '${error?.message}'}, + ); + } + + @override + void onUpdaterCheckingForUpdate(Appcast? appcast) {} + + @override + void onUpdaterUpdateAvailable(AppcastItem? appcastItem) {} + + @override + void onUpdaterUpdateDownloaded(AppcastItem? appcastItem) {} + + @override + void onUpdaterBeforeQuitForUpdate(AppcastItem? appcastItem) {} } diff --git a/app/lib/update/update_gate.dart b/app/lib/update/update_gate.dart index bd48534c..c4abad32 100644 --- a/app/lib/update/update_gate.dart +++ b/app/lib/update/update_gate.dart @@ -3,11 +3,24 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../config/build_info.dart'; import '../design/ab_icons.dart'; import '../design/widgets/ab_toast.dart'; import '../providers/update_available.dart'; +import '../util/detached.dart'; +import 'update_install_controller.dart'; import 'update_strategy.dart'; +/// The version this launch replaced, or null when nothing was replaced. +/// +/// Overridden from `main()` out of [UpdateHandoffStore], so an ordinary launch +/// — and every test — sees null and the app says nothing. Deliberately NOT +/// derived from the `--after-update` command line `RegisterApplicationRestart` +/// registers in app/windows/runner/main.cpp: Windows hands that argument back +/// after a crash and a hang too, and macOS's Sparkle relaunch passes no +/// argument at all. +final afterUpdateLaunchProvider = Provider((ref) => null); + /// Root wrapper that drives in-app updates via the running platform's /// [UpdateStrategy] — the single per-platform table in update_strategy.dart. /// @@ -45,20 +58,48 @@ class _UpdateGateState extends ConsumerState UpdateStrategy? _strategy; DateTime? _lastCheck; + StreamSubscription? _retraction; + + /// Set once the platform's own install flow has refused what a check + /// advertised. Terminal for the process on purpose: the feed does not change + /// under us, so the next check reads the same refused item, re-lights the row + /// the retraction just put out and fires the announcement toast again — on + /// every throttled resume, for an Update button that still cannot do + /// anything. + bool _retracted = false; @override void initState() { super.initState(); + // Ahead of the strategy gate below: Windows relaunches a build whose + // update checks are inactive just the same. + final replaced = ref.read(afterUpdateLaunchProvider); + if (replaced != null) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _announceUpdated(replaced), + ); + } final strategy = ref.read(updateStrategyProvider); if (strategy == null || !strategy.active) return; _strategy = strategy; unawaited(strategy.prepare()); + // Subscribed before the first check, so a retraction arriving from the + // platform's own flow is never missed. + final retracted = strategy.updateRetracted; + if (retracted != null) { + _retraction = retracted.listen((_) { + if (!mounted) return; + _retracted = true; + ref.read(updateAvailableProvider.notifier).set(false); + }); + } WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) => _maybeCheck()); } @override void dispose() { + unawaited(_retraction?.cancel()); if (_strategy != null) WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -73,7 +114,7 @@ class _UpdateGateState extends ConsumerState // dispose; `ref` throws on a disposed ConsumerState. if (!mounted) return; final strategy = _strategy; - if (strategy == null) return; + if (strategy == null || _retracted) return; final now = DateTime.now(); final last = _lastCheck; if (last != null && now.difference(last) < _throttle) return; @@ -93,6 +134,11 @@ class _UpdateGateState extends ConsumerState if (!rowAlreadyLit) _showUpdateAvailablePrompt(); case UpdateCheckOutcome.updateAvailableQuiet: ref.read(updateAvailableProvider.notifier).set(true); + // Quiet means "don't ask", not "don't drain": the strategy already + // decided this one installs itself, so run it through the same + // sequence as a tap, minus the dialog. Anything else would hand a + // live bridge to an MSIX replacement. + _startInstall(confirm: false); case UpdateCheckOutcome.restartReady: ref.read(updateAvailableProvider.notifier).set(true); // Same false→true rule as above: Play keeps reporting a downloaded @@ -112,7 +158,7 @@ class _UpdateGateState extends ConsumerState title: 'Update available', description: 'A new version is available to install.', actionLabel: 'Update', - onAction: () => _strategy?.install(context), + onAction: () => _startInstall(), ), ); } @@ -126,9 +172,51 @@ class _UpdateGateState extends ConsumerState title: 'Update ready', description: 'A new version has been downloaded.', actionLabel: 'Restart', - // install == "user accepted the update" on every platform; for the - // Play strategy that is completeFlexibleUpdate. - onAction: () => _strategy?.install(context), + onAction: () => _startInstall(), + ), + ); + } + + /// Both toast actions and the drawer row run the one install sequence, so a + /// toast tapped while the row's attempt is still on screen is refused rather + /// than starting a second one. + /// + /// This state's context outlives both toasts (and the drawer), which matters + /// on Windows: the sequence drains the bridge host before handing over, and + /// a context that dies in that window abandons an install the user already + /// confirmed. + void _startInstall({bool confirm = true}) { + detached('UpdateGate', 'install sequence', () async { + if (!mounted) return; + await ref + .read(updateInstallControllerProvider.notifier) + .start(context, confirm: confirm); + }); + } + + /// One-shot: the mark behind [replaced] is consumed in `main()`, so it + /// cannot survive into the next launch. + void _announceUpdated(String replaced) { + if (!mounted) return; + final version = BuildInfo.version; + // The note is the strategy's because the cost is per-platform: Windows and + // macOS quit to install, Linux only opened a download page. Read off the + // provider rather than `_strategy`, which is null in a build whose checks + // are inactive — the announcement still fires there. + final note = ref.read(updateStrategyProvider)?.updatedNote; + showAbToastOverlay( + context, + duration: const Duration(seconds: 8), + toast: AbToast( + icon: AbIcons.check, + // Not "and reopened your sessions": nothing restores what the bridge + // host was running. + title: version == 'dev' + ? 'Update installed' + : 'Updated to $version', + description: note == null + ? 'Replaced $replaced.' + : 'Replaced $replaced. $note', ), ); } diff --git a/app/lib/update/update_install_controller.dart b/app/lib/update/update_install_controller.dart new file mode 100644 index 00000000..7fac8436 --- /dev/null +++ b/app/lib/update/update_install_controller.dart @@ -0,0 +1,511 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../config/build_info.dart'; +import '../design/ab_colors.dart'; +import '../design/ab_icons.dart'; +import '../design/widgets/ab_confirm_dialog.dart'; +import '../design/widgets/ab_toast.dart'; +import '../project/project_session_registry.dart'; +import '../providers/control_plane.dart' show hostControllerProvider; +import '../providers/providers.dart' show preferencesServiceProvider; +import '../providers/update_available.dart'; +import '../util/ab_log.dart'; +import 'update_strategy.dart'; + +/// Where the install sequence has got to. Carries value equality so a +/// re-derived state doesn't rebuild the affordance that shows it. +sealed class UpdateInstallState { + const UpdateInstallState(); + + /// Whether a fresh attempt may begin from here. + /// + /// The single answer for every entry point — the drawer row's tap, both + /// update toasts, and [UpdateInstallController.start]'s own guard — so a + /// state one of them treats as dead can never be a state another treats as + /// go. + bool get canStart => switch (this) { + UpdateInstallIdle() || UpdateInstallFailed() => true, + UpdateInstallConfirming() || + UpdateInstallWorking() || + UpdateInstallDone() => false, + }; +} + +final class UpdateInstallIdle extends UpdateInstallState { + const UpdateInstallIdle(); + + @override + bool operator ==(Object other) => other is UpdateInstallIdle; + + @override + int get hashCode => (UpdateInstallIdle).hashCode; + + @override + String toString() => 'UpdateInstallIdle()'; +} + +/// The confirm dialog is on screen. +final class UpdateInstallConfirming extends UpdateInstallState { + const UpdateInstallConfirming(); + + @override + bool operator ==(Object other) => other is UpdateInstallConfirming; + + @override + int get hashCode => (UpdateInstallConfirming).hashCode; + + @override + String toString() => 'UpdateInstallConfirming()'; +} + +/// Confirmed and under way: draining, then whatever the platform does. +/// +/// [percent] is whole-percent download progress where the platform reports it. +/// It sits at 0 for as long as nothing has ticked — the Store's pre-install +/// re-scan and both of its consent dialogs come before the first byte — and +/// the platform emits no terminal 100, so it is a hint, not a completion +/// signal. +final class UpdateInstallWorking extends UpdateInstallState { + const UpdateInstallWorking(this.percent); + + final int percent; + + @override + bool operator ==(Object other) => + other is UpdateInstallWorking && other.percent == percent; + + @override + int get hashCode => Object.hash(UpdateInstallWorking, percent); + + @override + String toString() => 'UpdateInstallWorking($percent)'; +} + +/// The platform took the update. On Windows the process is already going away. +final class UpdateInstallDone extends UpdateInstallState { + const UpdateInstallDone(); + + @override + bool operator ==(Object other) => other is UpdateInstallDone; + + @override + int get hashCode => (UpdateInstallDone).hashCode; + + @override + String toString() => 'UpdateInstallDone()'; +} + +/// Nothing was installed. The update is still pending and the affordance is +/// live again — [reason] is for logs and diagnostics, not for the user, who +/// was told in a toast. +final class UpdateInstallFailed extends UpdateInstallState { + const UpdateInstallFailed(this.reason); + + final UpdateInstallResult reason; + + @override + bool operator ==(Object other) => + other is UpdateInstallFailed && other.reason == reason; + + @override + int get hashCode => Object.hash(UpdateInstallFailed, reason); + + @override + String toString() => 'UpdateInstallFailed(${reason.name})'; +} + +/// Drives the whole "install this update" sequence for every entry point that +/// offers one — the drawer row and both update toasts — so the confirmation, +/// the drain and the outcome cannot drift apart between them. +/// +/// The sequence exists because of what a Windows Store install costs: the +/// Store replaces an MSIX only over a dead process, so accepting ends the app +/// and takes every running agent with it. Two things follow. The confirm +/// dialog states that before the Store's own consent dialogs appear, when it +/// is still the user's decision. And the drain is ours to do: the engine's +/// `didRequestAppExit` never fires on Windows (the process carries a second +/// top-level window, so WM_CLOSE is never the last one), leaving +/// `HostTeardownObserver` unreachable and the bridge to be killed by the job +/// object rather than shut down. +/// +/// Platform-agnostic: [UpdateStrategy.installEndsSession] is what asks for the +/// confirm and the drain, so a platform whose install merely opens a page or a +/// dialog still goes straight through, exactly as before. +class UpdateInstallController extends Notifier { + StreamSubscription? _progress; + + @override + UpdateInstallState build() { + // Captured, not re-read on dispose: `ref.read` is unavailable by then, and + // a sequence torn down mid-flight must still lift the spawn seal — a seal + // nothing lifts leaves the machine unable to start any agent at all. + final host = ref.read(hostControllerProvider); + ref.onDispose(() { + _cancelProgress(); + host.unsealSpawns(); + }); + return const UpdateInstallIdle(); + } + + /// Runs confirm → drain → install. Never throws, and surfaces its own UI. + /// + /// A second call while a sequence is on screen or under way is dropped + /// ([UpdateInstallState.canStart] is the one arbiter): the Store's + /// pre-install re-scan alone is several seconds during which the only honest + /// thing to do is nothing, and that is long enough for an impatient second + /// tap to start a second install. + /// + /// [confirm] is false only for a flow the platform itself initiated — the + /// Windows mandatory tier, which the user is not being asked about. The + /// drain still runs: it is owed to the bridge, not to the dialog. + Future start(BuildContext context, {bool confirm = true}) async { + if (!state.canStart) return; + final strategy = ref.read(updateStrategyProvider); + if (strategy == null) return; + + final endsSession = strategy.installEndsSession; + if (endsSession && confirm) { + _set(const UpdateInstallConfirming()); + // Anything that throws between here and the next `_set` would strand the + // machine in Confirming, which `canStart` refuses forever — the row + // would render a dead affordance for the rest of the process. + bool confirmed; + try { + confirmed = await AbConfirmDialog.show( + context: context, + title: 'Install update and restart?', + body: _confirmBody(strategy.pendingVersion), + confirmLabel: 'Install & restart', + ); + } catch (e) { + AbLog.error( + 'UpdateInstall', + 'confirm dialog threw', + fields: {'error': '$e'}, + ); + confirmed = false; + } + if (!confirmed) { + _set(const UpdateInstallIdle()); + return; + } + } + + // Flip to working BEFORE anything slow. Everything below is silent — the + // Store re-scans its pending set for seconds with no UI of its own — and + // that gap is what made the tap look like it had been ignored. + _set(const UpdateInstallWorking(0)); + _listenProgress(strategy); + + // Checked BEFORE the drain, never after: the drain stops the user's agent + // sessions, so bailing out once it has run would cost them everything and + // install nothing. Past this line the sequence always reaches `install`, + // whose Windows implementation ignores the context anyway. + if (!context.mounted) { + _cancelProgress(); + _set(const UpdateInstallIdle()); + return; + } + if (endsSession) { + await _flushPreferences(); + await _drainOwnedHost(); + await _markHandoff(); + } + + UpdateInstallResult result; + try { + // The drain above is the only async gap this context crosses, and it runs + // solely for `installEndsSession` strategies — whose install ignores the + // context entirely (it just calls the Store). Re-checking here and + // bailing is the one outcome worse than proceeding: the sessions are + // already gone. Every context use AFTER this point is guarded. + // ignore: use_build_context_synchronously + result = await strategy.install(context); + } catch (e) { + // Strategies document that they never throw; a future one that does must + // not leave the affordance stuck reporting an install forever. + AbLog.error( + 'UpdateInstall', + 'strategy install threw', + fields: {'error': '$e'}, + ); + result = UpdateInstallResult.unavailable; + } + _cancelProgress(); + // Marked AFTER the hand-off here, and BEFORE it above — the asymmetry is + // the point. A session-ending install may never return, so its mark has to + // exist before the call; every other platform's install returns promptly + // (Sparkle opens a dialog, Linux opens a tab), so marking on the way out + // records only hand-offs that actually happened and leaves nothing to + // clear when one doesn't. + if (!endsSession && result == UpdateInstallResult.handedOff) { + await _markHandoff(); + } + if (result != UpdateInstallResult.handedOff) { + AbLog.warn( + 'UpdateInstall', + 'update did not install', + fields: {'outcome': result.name, 'endsSession': '$endsSession'}, + ); + // Nothing was replaced, so the mark must not survive to be found by a + // later crash relaunch and read as an update. + if (endsSession) await _clearHandoff(); + } + if (ref.mounted && result == UpdateInstallResult.nothingPending) { + // The Store saying "nothing pending" is the one source that can prove an + // update un-pended. Leaving the row lit would offer an install that can + // now only ever answer "already up to date". + ref.read(updateAvailableProvider.notifier).set(false); + } + _set(switch (result) { + // Done is a terminal state the row refuses to leave, which is only + // truthful where the process is dying around it. Everywhere else the + // hand-off was to a browser, Sparkle or the App Store — the user can + // back out of all three, so the affordance has to stay clickable. + UpdateInstallResult.handedOff => + endsSession ? const UpdateInstallDone() : const UpdateInstallIdle(), + UpdateInstallResult.nothingPending => const UpdateInstallIdle(), + UpdateInstallResult.notInstalled || + UpdateInstallResult.unavailable => UpdateInstallFailed(result), + }); + if (context.mounted) _toastFor(context, result, endsSession: endsSession); + // Last, so the answer is on screen before a respawn that can take seconds. + if (endsSession && result != UpdateInstallResult.handedOff) { + await _rearmOwnedHost(); + } + } + + void _toastFor( + BuildContext context, + UpdateInstallResult result, { + required bool endsSession, + }) { + // Only a session-ending attempt drained the host, so only it owes the user + // an account of what the attempt cost them. + final drained = endsSession + ? ' Project sessions were stopped; the bridge is restarting.' + : ''; + switch (result) { + case UpdateInstallResult.handedOff: + break; + case UpdateInstallResult.nothingPending: + _toast( + context, + icon: AbIcons.check, + title: 'Already up to date', + description: 'There was nothing left to install.$drained', + ); + case UpdateInstallResult.notInstalled: + _toast( + context, + icon: AbIcons.warning, + title: 'Update not installed', + description: 'It is still pending — you can start it again.$drained', + ); + case UpdateInstallResult.unavailable: + _toast( + context, + icon: AbIcons.error, + title: "Couldn't start the update", + description: 'Try again later.$drained', + ); + } + } + + /// Writes pending preferences out before an install that ends the process. + /// + /// The same bounded flush `exitApp` does, for the same reason: on this path + /// neither the back gate nor `didRequestAppExit` ever runs, so nothing else + /// gets the chance. + Future _flushPreferences() async { + try { + await ref + .read(preferencesServiceProvider) + .flush() + .timeout(const Duration(seconds: 2)); + } catch (e) { + AbLog.warn( + 'UpdateInstall', + 'preference flush before update failed (ignored)', + fields: {'error': '$e'}, + ); + } + } + + /// Shuts the app-spawned bridge host down while we still can. + /// + /// Best-effort on purpose: the user has already decided, so a wedged host + /// must not be able to veto the update. Failing here costs what happens + /// today — the job object sweeps the tree as the process dies. The timeouts + /// only backstop `shutdownOwnedHost`'s own ceiling (~6.5s: a 2s control-plane + /// call, a 3s graceful wait, then the force-kill's own POSIX grace); the pair + /// still fits well inside Windows' 30s shutdown budget. + Future _drainOwnedHost() async { + // Sealed BEFORE the drain, not after: the app stays interactive for the + // Store's whole window, and anything that spawns a host in it hands the + // update a live PTY tree to kill. Sealing after would leave the gap the + // drain is racing. + final host = ref.read(hostControllerProvider); + host.sealSpawns(); + // The seal only closes `ensureHost`'s door. A spawn already through it has + // yet to publish `ownedHostPid`, so a teardown running ahead of it finds + // nothing to kill and the bridge surfaces behind the update — the exact + // live tree the seal exists to prevent. + try { + await host.drainInFlight().timeout(const Duration(seconds: 4)); + } catch (e) { + AbLog.warn( + 'UpdateInstall', + 'in-flight host spawn did not settle (tearing down anyway)', + fields: {'error': '$e'}, + ); + } + try { + await host.shutdownOwnedHost().timeout(const Duration(seconds: 8)); + } catch (e) { + AbLog.warn( + 'UpdateInstall', + 'host drain before update failed (ignored)', + fields: {'error': '$e'}, + ); + } + } + + /// Records the build the platform is about to replace. Best-effort: a write + /// that fails costs the post-update announcement, never the update. + /// + /// Bounded like every other step in the sequence: by the time this runs the + /// host is drained and spawns are sealed, so a platform channel that never + /// answers would strand the machine there with no bridge and no way back. + Future _markHandoff() async { + try { + await ref + .read(updateHandoffStoreProvider) + .markHandoff(BuildInfo.version) + .timeout(const Duration(seconds: 2)); + } catch (e) { + AbLog.warn( + 'UpdateInstall', + 'recording the handoff version failed (announcement will be skipped)', + fields: {'error': '$e'}, + ); + } + } + + Future _clearHandoff() async { + if (!ref.mounted) return; + try { + await ref + .read(updateHandoffStoreProvider) + .clear() + .timeout(const Duration(seconds: 2)); + } catch (e) { + AbLog.warn( + 'UpdateInstall', + 'clearing the handoff version failed', + fields: {'error': '$e'}, + ); + } + } + + /// Brings the bridge back after a drain that bought nothing. + /// + /// [HostController.shutdownOwnedHost] deliberately cancels supervised + /// respawn — the app was about to die — so on every outcome but a real + /// hand-off there is nothing left to restart the host, and the user is + /// sitting on a dead bridge with no banner to say so. + Future _rearmOwnedHost() async { + if (!ref.mounted) return; + // Unsealed unconditionally and first: an install that did not take the + // process with it must not leave the machine unable to start an agent, + // and this is the only path that lifts the seal. + ref.read(hostControllerProvider).unsealSpawns(); + try { + await ref.read(hostControllerProvider).ensureHost(); + } catch (e) { + AbLog.warn( + 'UpdateInstall', + 'host re-arm after an update that did not install failed', + fields: {'error': '$e'}, + ); + } + } + + String _confirmBody(String? version) { + final lead = version == null + ? 'This update installs over a closed app' + : 'Version $version installs over a closed app'; + // Open PROJECTS, not running agents — a true agent count needs a bridge + // round-trip this dialog does not justify, so the copy says what the + // number actually is. Suppressed at zero rather than printed as "0". + // LOCAL projects only: the drain stops the host this app owns, so a + // relay-attached project on another machine keeps running and must not be + // counted among the casualties. + final open = ref + .read(projectSessionRegistryProvider.notifier) + .localOpenProjects() + .length; + final sessions = switch (open) { + 0 => '', + 1 => ' 1 open project session will stop.', + _ => ' $open open project sessions will stop.', + }; + return '$lead: Antgrid quits and the local agent bridge shuts down with ' + 'it.$sessions Antgrid should reopen itself once the install finishes; ' + 'if it does not, start it again yourself.'; + } + + void _listenProgress(UpdateStrategy strategy) { + _cancelProgress(); + final progress = strategy.installProgress; + if (progress == null) return; + _progress = progress.listen((percent) { + // A tick racing the install's own answer (or the notifier's disposal) + // must not drag a settled state back into working. + if (!ref.mounted || state is! UpdateInstallWorking) return; + _set(UpdateInstallWorking(percent)); + }); + } + + void _cancelProgress() { + final sub = _progress; + _progress = null; + if (sub != null) unawaited(sub.cancel()); + } + + void _set(UpdateInstallState next) { + if (!ref.mounted) return; + state = next; + } + + void _toast( + BuildContext context, { + required String icon, + required String title, + required String description, + }) { + if (!context.mounted) return; + showAbToastOverlay( + context, + duration: const Duration(seconds: 8), + toast: AbToast( + icon: icon, + iconColor: context.antgrid.textMuted, + title: title, + description: description, + ), + ); + } +} + +/// The one install sequence, shared by the drawer row and the update toasts. +/// Never autoDispose: the sequence outlives the widget that started it (the +/// drawer can close, a toast times out) and a second tap must still find the +/// same in-flight state to refuse. +final updateInstallControllerProvider = + NotifierProvider( + UpdateInstallController.new, + ); diff --git a/app/lib/update/update_strategy.dart b/app/lib/update/update_strategy.dart index 488b84a4..af390512 100644 --- a/app/lib/update/update_strategy.dart +++ b/app/lib/update/update_strategy.dart @@ -34,6 +34,30 @@ enum UpdateCheckOutcome { restartReady, } +/// What one accepted install attempt did. +/// +/// Only Windows can report anything but [handedOff]: every other platform +/// passes the update to a browser, to Sparkle, or to Play and learns nothing +/// more about it. +enum UpdateInstallResult { + /// The platform owns the update now — the Store is installing (and is about + /// to end this process), Sparkle's dialog is up, the releases page is open. + handedOff, + + /// Nothing was installed and the update is still pending. On Windows this is + /// the Store's entire "not completed" bucket — a declined consent dialog, a + /// low-battery or Wi-Fi refusal, a download still in flight — so it means + /// "offer it again", never "the user said no". + notInstalled, + + /// The pending set had already cleared when the install started. + nothingPending, + + /// The install route itself could not be reached — a Windows build with no + /// MSIX package identity, or a detected update with no link to open. + unavailable, +} + /// One platform's complete update wiring: whether checks run in this build, /// how a check detects, and what accepting the update does. /// @@ -58,15 +82,64 @@ abstract class UpdateStrategy { /// The user accepted the affordance — the drawer row's tap, or Android's /// restart-toast action. Never throws, surfaces its own UI, and tolerates /// a repeat invocation by re-opening the flow. - Future install(BuildContext context); + Future install(BuildContext context); + + /// Whether [install] ends this process: the app quits, everything it was + /// running quits with it, and coming back is the platform's business. + /// + /// Only the Windows Store hand-off does — an MSIX is replaced over a dead + /// app. Everywhere else [install] opens something (Sparkle's dialog, a + /// releases page, a store listing) or restarts in place with nothing of ours + /// to unwind first, so nothing there is worth a confirmation click. + bool get installEndsSession => false; + + /// Install progress in whole percent (0-100) while [install] runs, or null + /// where the platform reports none. + /// + /// Where it exists it is broadcast and unbuffered, and carries no terminal + /// emission — completion is [install]'s answer alone. + Stream? get installProgress => null; + + /// The version [install] would move to, as last seen by [check], or null + /// when the source didn't name one. Null is common and means only + /// "unknown", so copy that names it needs a nameless fallback. + String? get pendingVersion => null; + + /// Emits when the platform's own flow proves nothing is installable after + /// all, so a row this strategy latched can go dark again. Null where the + /// platform never tells us. + /// + /// Detection and installation can be two different opinions: macOS reads the + /// appcast itself but Sparkle applies filters that reading doesn't (a + /// `minimumSystemVersion` above the running OS, a channel), and a row lit by + /// the first and refused by the second is an Update button that can never do + /// anything. Broadcast and unbuffered, like [installProgress]. + Stream? get updateRetracted => null; + + /// What an install on this platform cost the user, appended to the + /// post-update announcement. Null where it cost nothing worth reporting. + /// + /// Per-platform because the answer is: Windows and macOS both quit the app + /// to install, taking the local bridge and every agent with it. Linux only + /// opens a download page — whatever stopped the user's sessions there was + /// their own quit, possibly days earlier, and a launch that opens by + /// announcing it would be describing something that never happened. + String? get updatedNote => null; /// Copy for the drawer row this strategy's outcomes light. The default /// promises a download/store hand-off; a strategy whose [install] does /// something stronger must say so (Play's restarts the app in place). String get rowTitle => 'Update available'; String get rowActionLabel => 'Update'; + + /// Releases anything [prepare] attached to a process-global. Default: + /// nothing. Called when the container holding [updateStrategyProvider] goes. + void dispose() {} } +/// The cost line shared by every platform whose install quits the app. +const kUpdateStoppedSessionsNote = 'Open project sessions were stopped.'; + /// Android: Google Play owns download and install; the app's only UI duty is /// the restart prompt for a flexible update that finished downloading. /// Active in every build mode — the Play path already degrades silently when @@ -89,8 +162,10 @@ class PlayUpdateStrategy extends UpdateStrategy { } @override - Future install(BuildContext context) => - _service.completeFlexibleUpdate(); + Future install(BuildContext context) async { + await _service.completeFlexibleUpdate(); + return UpdateInstallResult.handedOff; + } // The only outcome that lights the row here is a DOWNLOADED update, and // completeFlexibleUpdate restarts the app immediately — 'Update' would @@ -117,9 +192,20 @@ class WindowsStoreStrategy extends UpdateStrategy { /// provider-held, outliving any widget — so a gate remount can't reset it. bool _mandatoryAutoLaunched = false; + String? _pendingVersion; + @override bool get active => kReleaseMode; + @override + bool get installEndsSession => true; + + @override + Stream get installProgress => _service.downloadProgress; + + @override + String? get pendingVersion => _pendingVersion; + @override Future check({required bool rowAlreadyLit}) async { // Once the mandatory flow has auto-launched, the row is lit and the @@ -128,37 +214,60 @@ class WindowsStoreStrategy extends UpdateStrategy { // While only an OPTIONAL update is pending we keep checking: a later // check may see it escalate to mandatory and auto-launch. if (_mandatoryAutoLaunched) return UpdateCheckOutcome.none; - final check = await _service.checkForUpdates(); - if (check == StoreUpdateCheck.none) return UpdateCheckOutcome.none; + final status = await _service.checkForUpdates(); + final check = status.check; + if (check == StoreUpdateCheck.none) { + // Nothing pending any more, so the name of what WAS pending is a lie the + // confirm dialog would otherwise still render. + _pendingVersion = null; + return UpdateCheckOutcome.none; + } + _pendingVersion = status.version; if (check == StoreUpdateCheck.mandatory) { - // Partner Center marked the release mandatory — hand straight off to - // the Store's install flow (which owns the UI and may restart the app) - // instead of waiting for a click. Service never throws. Quiet: the - // system dialog is already the announcement; the lit drawer row - // remains the re-launch affordance after a cancel. + // Partner Center marked the release mandatory — hand straight off + // instead of waiting for a click. The hand-off is the GATE's to make, + // not ours: an install started from here would skip the drain, and + // killing the bridge by MSIX replacement rather than shutting it down + // is the exact failure this whole sequence exists to prevent. Quiet + // means "install without asking", not "install behind the controller". _mandatoryAutoLaunched = true; - unawaited(_service.requestDownloadAndInstall()); return UpdateCheckOutcome.updateAvailableQuiet; } return UpdateCheckOutcome.updateAvailable; } @override - Future install(BuildContext context) => - _service.requestDownloadAndInstall(); + Future install(BuildContext context) async => + switch (await _service.requestDownloadAndInstall()) { + StoreInstallOutcome.completed => UpdateInstallResult.handedOff, + StoreInstallOutcome.cancelled => UpdateInstallResult.notInstalled, + StoreInstallOutcome.none => UpdateInstallResult.nothingPending, + StoreInstallOutcome.unavailable => UpdateInstallResult.unavailable, + }; + + // The tap here CLOSES the app — an MSIX is replaced over a dead process, and + // we drain the bridge before handing off. 'Update' hides the one part of it + // the user cannot take back. + @override + String get rowActionLabel => 'Install & restart'; + + @override + String? get updatedNote => kUpdateStoppedSessionsNote; } -/// macOS: detection and install read the SAME appcast, so a lit row implies -/// Sparkle has something installable. Install is Sparkle's own dialog -/// (download, verify, install, relaunch). +/// macOS: detection and install read the SAME appcast, but not with the same +/// rules — Sparkle applies filters our own read does not, so a lit row is a +/// claim Sparkle can still refuse, and [updateRetracted] is how it takes it +/// back. Install is Sparkle's own dialog (download, verify, install, +/// relaunch). /// /// Release-only: neither Sparkle nor a GitHub release can update an /// unpackaged `flutter run` bundle. class MacosSparkleStrategy extends UpdateStrategy { MacosSparkleStrategy({ - MacosSparkleUpdateService sparkle = const MacosSparkleUpdateService(), + MacosSparkleUpdateService? sparkle, MacosAppcastUpdateService? appcast, - }) : _sparkle = sparkle, + }) : _sparkle = sparkle ?? MacosSparkleUpdateService(), _appcast = appcast ?? MacosAppcastUpdateService(); final MacosSparkleUpdateService _sparkle; @@ -173,6 +282,20 @@ class MacosSparkleStrategy extends UpdateStrategy { @override Future prepare() => _sparkle.configureFeed(); + /// Sparkle quits the app to install and relaunches it — the same cost as the + /// Windows Store, reached without a confirm dialog because Sparkle runs its + /// own and `didRequestAppExit` drains the host on the way out. + @override + String? get updatedNote => kUpdateStoppedSessionsNote; + + @override + Stream get updateRetracted => _sparkle.noUpdateFound; + + /// `prepare()` registers this strategy's service with the process-global + /// `autoUpdater`, which never releases a listener on its own. + @override + void dispose() => _sparkle.dispose(); + @override Future check({required bool rowAlreadyLit}) async { // The row latches for the process lifetime — once lit a further check @@ -184,12 +307,13 @@ class MacosSparkleStrategy extends UpdateStrategy { } @override - Future install(BuildContext context) async { + Future install(BuildContext context) async { // Re-assert the feed first: prepare() is fire-and-forget at startup and // swallows failures, and a feed-less Sparkle errors (silently) on every // startUpdate — this idempotent local call un-deadens the row's tap. await _sparkle.configureFeed(); await _sparkle.startUpdate(); + return UpdateInstallResult.handedOff; } } @@ -220,10 +344,13 @@ class LinuxBrowserStrategy extends UpdateStrategy { } @override - Future install(BuildContext context) => openExternalUrl( - context, - GithubReleaseUpdateService.latestDownloadPageUrl, - ); + Future install(BuildContext context) async { + await openExternalUrl( + context, + GithubReleaseUpdateService.latestDownloadPageUrl, + ); + return UpdateInstallResult.handedOff; + } } /// iOS: iTunes-lookup detection; install opens the App Store listing (iOS @@ -250,13 +377,14 @@ class IosAppStoreStrategy extends UpdateStrategy { } @override - Future install(BuildContext context) { + Future install(BuildContext context) async { // Cached by this same instance's check that lit the row, so it's // non-null on every reachable path here. final url = _service.listingUrl; assert(url != null, 'iOS update row lit without a store listing URL'); - if (url == null) return Future.value(); - return openExternalUrl(context, url); + if (url == null) return UpdateInstallResult.unavailable; + await openExternalUrl(context, url); + return UpdateInstallResult.handedOff; } } @@ -267,13 +395,15 @@ class IosAppStoreStrategy extends UpdateStrategy { /// /// The switch is exhaustive over [TargetPlatform] on purpose — a new /// platform is a compile error here, not a silently dead update path. -final updateStrategyProvider = Provider( - (_) => switch (defaultTargetPlatform) { +final updateStrategyProvider = Provider((ref) { + final strategy = switch (defaultTargetPlatform) { TargetPlatform.android => PlayUpdateStrategy(), TargetPlatform.windows => WindowsStoreStrategy(), TargetPlatform.macOS => MacosSparkleStrategy(), TargetPlatform.linux => LinuxBrowserStrategy(), TargetPlatform.iOS => IosAppStoreStrategy(), TargetPlatform.fuchsia => null, - }, -); + }; + if (strategy != null) ref.onDispose(strategy.dispose); + return strategy; +}); diff --git a/app/lib/update/windows_store_update_service.dart b/app/lib/update/windows_store_update_service.dart index e39d9f07..5c5001b7 100644 --- a/app/lib/update/windows_store_update_service.dart +++ b/app/lib/update/windows_store_update_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -10,6 +12,50 @@ import '../util/ab_log.dart'; /// instead of offering an optional prompt. enum StoreUpdateCheck { none, optional, mandatory } +/// What came of handing off to the Store's download-and-install flow. +/// +/// [cancelled] is the Store's entire "pending, not installed" bucket: a user +/// declining the system consent dialog is indistinguishable here from the +/// Store refusing on its own terms (low battery, Wi-Fi required) or from a +/// download still in flight — the native side collapses every non-completed +/// package state into it. Treat it as "offer it again", never as a decision +/// the user made. +/// +/// [none] means the pending set cleared before the install started (the Store +/// already applied it); [unavailable] means the channel itself failed — an +/// unpackaged build with no MSIX identity, or a non-Windows host. +enum StoreInstallOutcome { completed, cancelled, none, unavailable } + +/// The result of one Store update check: what was found, and which version it +/// would install. +@immutable +class StoreUpdateStatus { + const StoreUpdateStatus({required this.check, this.version}); + + static const StoreUpdateStatus none = StoreUpdateStatus( + check: StoreUpdateCheck.none, + ); + + final StoreUpdateCheck check; + + /// The pending package's `Major.Minor.Build.Revision`, or null when the + /// Store did not report one. Null is common and carries no meaning beyond + /// "unknown" — a caller that names the version needs a nameless fallback. + final String? version; + + @override + bool operator ==(Object other) => + other is StoreUpdateStatus && + other.check == check && + other.version == version; + + @override + int get hashCode => Object.hash(check, version); + + @override + String toString() => 'StoreUpdateStatus($check, version: $version)'; +} + /// Pure mapping from the native channel's reply to a decision, isolated so it /// can be unit-tested without the platform channel (inert under /// `flutter test`). Tolerates a malformed/missing reply by resolving to @@ -23,6 +69,32 @@ StoreUpdateCheck decideStoreUpdate(Map? reply) { : StoreUpdateCheck.optional; } +/// Pure extraction of the pending package version from the native reply. +/// +/// The native side sends dotted decimals, and the empty string when it has +/// nothing to report; anything that is not a dotted-decimal version resolves +/// to null, so a caller can never render a placeholder as a version. +String? storeUpdateVersion(Map? reply) { + final raw = reply?['version']; + if (raw is! String) return null; + final version = raw.trim(); + if (!_dottedDecimal.hasMatch(version)) return null; + return version; +} + +final RegExp _dottedDecimal = RegExp(r'^\d+(\.\d+)*$'); + +/// Pure mapping from the native install reply to an outcome. Null means the +/// reply was not one of the contract's strings — the caller decides what an +/// unrecognised answer costs. +StoreInstallOutcome? decodeStoreInstallOutcome(Object? reply) => + switch (reply) { + 'completed' => StoreInstallOutcome.completed, + 'cancelled' => StoreInstallOutcome.cancelled, + 'none' => StoreInstallOutcome.none, + _ => null, + }; + /// Thin, Windows-only wrapper over the runner's `antgrid/store_update` /// method channel (WinRT `StoreContext`, see /// `windows/runner/store_update_channel.cpp`). @@ -31,47 +103,115 @@ StoreUpdateCheck decideStoreUpdate(Map? reply) { /// `StoreContext` requires MSIX package identity, so builds not installed /// from the Microsoft Store (`flutter run`, sideloaded exe) fail the native /// call and the whole feature must degrade silently rather than surface an -/// error or block startup. +/// error or block startup. Failure arrives as a value, never a throw: callers +/// run from tap handlers and startup paths that discard the future. class WindowsStoreUpdateService { const WindowsStoreUpdateService(); static const MethodChannel _channel = MethodChannel('antgrid/store_update'); + /// Static because the channel's handler table is process-global and this + /// class is const-constructible: callers make throwaway instances, so + /// per-instance state would register a handler per construction. + static final StreamController _progress = + StreamController.broadcast(); + static bool _progressHandlerInstalled = false; + bool get _supported => defaultTargetPlatform == TargetPlatform.windows; + /// Download progress in whole percent, 0-100, pushed by the Store while + /// [requestDownloadAndInstall] runs. + /// + /// Broadcast and unbuffered: ticks emitted with no listener are dropped, a + /// late listener sees only what follows it, and the stream never closes or + /// errors. The Store emits no terminal tick — 100 may never arrive, and the + /// deploy happens after the last one — so completion is + /// [requestDownloadAndInstall]'s answer alone. + Stream get downloadProgress { + _ensureProgressHandler(); + return _progress.stream; + } + /// Asks the Store for pending package updates. /// - /// Never throws — any failure resolves to [StoreUpdateCheck.none]. - Future checkForUpdates() async { - if (!_supported) return StoreUpdateCheck.none; + /// Never throws — any failure resolves to [StoreUpdateStatus.none]. + Future checkForUpdates() async { + if (!_supported) return StoreUpdateStatus.none; try { final reply = await _channel.invokeMapMethod( 'checkForUpdates', ); - return decideStoreUpdate(reply); + final check = decideStoreUpdate(reply); + if (check == StoreUpdateCheck.none) return StoreUpdateStatus.none; + return StoreUpdateStatus( + check: check, + version: storeUpdateVersion(reply), + ); } catch (e) { AbLog.warn( 'StoreUpdate', 'WindowsStoreUpdateService.checkForUpdates failed (ignored)', fields: {'error': '$e'}, ); - return StoreUpdateCheck.none; + return StoreUpdateStatus.none; } } /// Hands off to the Store's own download-and-install flow (system UI, /// parented to the app window). A mandatory update may restart the app on /// completion. Never throws. - Future requestDownloadAndInstall() async { - if (!_supported) return; + /// + /// Resolves only once the Store reaches a terminal state, which spans the + /// two system consent dialogs, the download and the deploy — expect minutes, + /// and read [downloadProgress] to say something in between. + Future requestDownloadAndInstall() async { + if (!_supported) return StoreInstallOutcome.unavailable; + _ensureProgressHandler(); try { - await _channel.invokeMethod('requestDownloadAndInstall'); + final reply = await _channel.invokeMethod( + 'requestDownloadAndInstall', + ); + final outcome = decodeStoreInstallOutcome(reply); + if (outcome != null) return outcome; + AbLog.warn( + 'StoreUpdate', + 'WindowsStoreUpdateService.requestDownloadAndInstall returned an ' + 'unrecognised outcome (treated as unavailable)', + fields: {'reply': '$reply'}, + ); + return StoreInstallOutcome.unavailable; } catch (e) { AbLog.warn( 'StoreUpdate', 'WindowsStoreUpdateService.requestDownloadAndInstall failed (ignored)', fields: {'error': '$e'}, ); + return StoreInstallOutcome.unavailable; } } + + void _ensureProgressHandler() { + if (_progressHandlerInstalled) return; + _progressHandlerInstalled = true; + try { + _channel.setMethodCallHandler(_handleNativeCall); + } catch (e) { + // Reached when there is no ServicesBinding yet (a plain `flutter test`). + // Leave the latch down so a binding arriving later still gets a handler. + _progressHandlerInstalled = false; + AbLog.warn( + 'StoreUpdate', + 'WindowsStoreUpdateService progress handler not installed (ignored)', + fields: {'error': '$e'}, + ); + } + } + + static Future _handleNativeCall(MethodCall call) async { + if (call.method != 'downloadProgress') return null; + final raw = call.arguments; + if (raw is! num || !raw.isFinite) return null; + _progress.add(raw.round().clamp(0, 100).toInt()); + return null; + } } diff --git a/app/lib/widgets/drawer_entry_row.dart b/app/lib/widgets/drawer_entry_row.dart index 8044ba3f..5074a57c 100644 --- a/app/lib/widgets/drawer_entry_row.dart +++ b/app/lib/widgets/drawer_entry_row.dart @@ -101,16 +101,23 @@ class MachineDrawerHeaderRow extends ConsumerWidget { // Kept on the band, unlike the local one: expanding a machine is what // opens its control-plane socket, so there is something to disclose. expanded: expanded, + // Status dots after the hover actions, and the LIVENESS dot last of + // all: it is [LocalMachineBand]'s host dot under another name, and only + // the final slot sits a fixed distance from the row's edge on both + // bands, so only there can the two share a column. Every dot reserves + // its slot whether or not it renders — one resolving must not slide the + // trash that shares this row out from under the pointer. trailing: Row( mainAxisSize: MainAxisSize.min, + spacing: AbTokens.space4, children: [ - _MachineOnlineDot(machineUuid: machineUuid), - _MachineAggregateDot(machineUuid: machineUuid), _DrawerEntryTrailing( entry: entry, hovered: hovered, showRemoteChip: false, ), + _MachineAggregateDot(machineUuid: machineUuid), + _MachineOnlineDot(machineUuid: machineUuid), ], ), onTap: () => @@ -170,8 +177,9 @@ class LocalMachineBand extends ConsumerWidget { /// /// A SIBLING above the band rather than a wrapper around it: the ~13px this and /// its clearance occupy would otherwise sit inside the band's [MouseRegion], so -/// the empty strip above a band highlighted under the pointer while accepting no -/// click. Being const also keeps it out of the hover rebuild. +/// the empty strip above a band would take the click cursor while accepting no +/// click, and would pop the band's hover-revealed trash out of dead space. +/// Being const also keeps it out of the hover rebuild. class DrawerBandRule extends StatelessWidget { const DrawerBandRule({super.key}); @@ -201,14 +209,40 @@ class _LocalHostDot extends ConsumerWidget { HostPhase.failed => (AbStatusTone.danger, AbDotStyle.filled, false), _ => (null, AbDotStyle.filled, false), }; - if (tone == null) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.only(right: AbTokens.space6), + if (tone == null) return const _BandDotSlot(); + return _BandDotSlot( child: AbStatusDot(tone: tone, style: style, pulse: pulse), ); } } +/// One status-dot cell in a band's trailing kit. +/// +/// The width is reserved whether or not a dot renders. Two things depend on +/// that. A band's trailing is right-anchored, so an empty cell that collapsed +/// would drag everything to its left — including the hover-revealed trash, +/// which would then slide out from under the pointer whenever a socket resolved +/// or an agent asked a question. And because the cell is a constant width, the +/// LAST one is a constant distance from the row's edge on every band, which is +/// what lets [LocalMachineBand]'s host dot and a machine band's liveness dot +/// share a column. Gaps belong to the composing [Row]'s `spacing`, so the +/// alignment is not three widgets independently agreeing on an inset. +/// +/// The reserved width is a floor, not a cap: every dot here is [AbDotSize.sm] +/// today, and a tight box would silently paint a larger one as a squashed +/// circle in an off-centre cell rather than overflow where it can be seen. +class _BandDotSlot extends StatelessWidget { + const _BandDotSlot({this.child}); + + final Widget? child; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(minWidth: AbTokens.dotSizeSm), + child: child, + ); +} + /// One band in the drawer: a small muted section label with an optional /// disclosure chevron and trailing status kit. /// @@ -267,7 +301,12 @@ class DrawerBand extends StatelessWidget { // Bands sit in a run of rows that all clear each other by this much; a // band with no rule above it has nothing else keeping it off them. margin: const EdgeInsets.symmetric(vertical: AbTokens.space2), - hoverable: onTap != null, + // No `hoverable`: a band is a section HEADING, and the project rows it + // contains take no fill — a heading that lit under the pointer would be + // the loudest surface in its own section. The chevron is the affordance + // instead. Rows that DO fill (a session row, a file-tree row) sit in a + // flat run where the fill just tracks the pointer, so it ranks nothing + // above its neighbours. onTap: onTap, ); } @@ -296,7 +335,8 @@ class HoverableDrawerRow extends StatefulWidget { /// Chrome that belongs to this row's block but must not be hover-reactive: /// it sits inside the gutter, above the [MouseRegion]. Kept out of [builder] - /// so it is neither highlighted under the pointer nor rebuilt by it. + /// so the pointer neither reveals the row's hover affordances from over it + /// nor rebuilds it. final Widget? above; final Widget Function(BuildContext context, bool hovered, bool pointerOver) @@ -1092,16 +1132,26 @@ class _MachineOnlineDot extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final status = ref.watch(supervisorStatusProvider(machineUuid)).value; - if (status == null) return const SizedBox.shrink(); - final (tone, _) = connectionDisplayInfo(status); + if (status == null) return const _BandDotSlot(); + final (tone, label) = connectionDisplayInfo(status); final online = status is Connected; - return Padding( - padding: const EdgeInsets.only(right: AbTokens.space6), - child: AbStatusDot( - tone: tone, - style: online ? AbDotStyle.filled : AbDotStyle.hollow, - // Pulse while mid-handshake; a settled offline (released) dot holds. - pulse: !online && status is! Released, + return _BandDotSlot( + // Colour is this dot's only channel, and it is the drawer's sole report + // that a machine is unreachable — so the ladder's own label carries it to + // anyone who cannot use hue. + child: Semantics( + label: label, + child: AbStatusDot( + tone: tone, + style: online ? AbDotStyle.filled : AbDotStyle.hollow, + // Pulse only while the ladder is still climbing. Stated as a + // whitelist over the sealed type, so a fifth [SupervisorStatus] has + // to opt in here rather than inherit an animation nothing stops: both + // settled states must hold still, `Released` being a deliberate + // teardown and `Blocked` staying sticky until a typed unblock input + // clears it. + pulse: status is Climbing, + ), ), ); } @@ -1122,15 +1172,12 @@ class _MachineAggregateDot extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final expanded = ref.watch(expandedDrawerIdsProvider).contains(machineUuid); - if (expanded) return const SizedBox.shrink(); + if (expanded) return const _BandDotSlot(); final status = ref.watch(machineWorkStatusProvider(machineUuid)); if (status == null || !agentWorkStatusNeedsUser(status)) { - return const SizedBox.shrink(); + return const _BandDotSlot(); } - return Padding( - padding: const EdgeInsets.only(right: AbTokens.space6), - child: AgentWorkStatusDot(status: status), - ); + return _BandDotSlot(child: AgentWorkStatusDot(status: status)); } } diff --git a/app/lib/widgets/handler/handler_blocked_action_card.dart b/app/lib/widgets/handler/handler_blocked_action_card.dart new file mode 100644 index 00000000..51010971 --- /dev/null +++ b/app/lib/widgets/handler/handler_blocked_action_card.dart @@ -0,0 +1,157 @@ +import 'package:flutter/widgets.dart'; + +import '../../design/ab_colors.dart'; +import '../../design/ab_icons.dart'; +import '../../design/ab_tokens.dart'; +import '../../design/widgets/ab_button.dart'; +import '../../models/handler_state.dart'; +import 'handler_layout.dart'; + +/// Retires the report. The only thing that does, on either side of the wire. +const handlerDismissLabel = 'Dismiss'; + +/// The way out to the free-text reply sheet, prefilled with the refused text. +const handlerReplyInsteadLabel = 'Reply instead…'; + +/// Inline card for an escalation the bridge raised because a harness guard — +/// the reply-shape rules, the §5.3 destructive floor, or the runaway guard — +/// refused an action Handler wanted to take (`kind: 'guard_blocked'`). +/// +/// It is a REPORT, not a question: the action was never taken, so nothing the +/// agent or the user does next answers it and no later pause supersedes it. +/// That is why it carries an explicit [onDismiss] where every other row is +/// retired by the user simply carrying on. +/// +/// The refused text is rendered in full rather than summarised. It is the one +/// thing the card exists to show — a rejection the user cannot read is one they +/// cannot judge — and it is deliberately NOT offered as a one-tap: a chip here +/// would re-send the exact text a guard just refused, with the thinnest possible +/// human in the loop. +class HandlerBlockedActionCard extends StatelessWidget { + const HandlerBlockedActionCard({ + super.key, + required this.escalation, + required this.onDismiss, + required this.onReply, + this.trailing, + }); + + final HandlerEscalation escalation; + + /// Puts `handler:dismiss` on the wire. Null disables the button — the report + /// stays readable either way, which is the state it is worth most in. + final VoidCallback? onDismiss; + + /// Opens the free-text reply sheet on the refused draft. Offered because the + /// user reading what Handler wanted to send is often the moment they decide to + /// send some of it themselves. + final VoidCallback? onReply; + + /// Session/time metadata, supplied by the caller so it matches the free-text + /// rows sharing the section. + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final p = context.antgrid; + final e = escalation; + // The same full-bleed band the decision card uses, so a report and a card + // stacked in one section share a left edge and one background fill. + return Container( + padding: const EdgeInsets.symmetric( + horizontal: handlerGutter, + vertical: AbTokens.space12, + ), + decoration: BoxDecoration( + color: p.bgSurface, + border: Border( + top: BorderSide(color: p.borderDefault), + bottom: BorderSide(color: p.borderDefault), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HandlerRail(icon: AbIcons.shield, color: p.warning), + const SizedBox(width: AbTokens.space8), + Expanded( + child: Text( + e.question, + style: AbTokens.sansStyle(fontWeight: FontWeight.w600), + ), + ), + if (trailing != null) ...[ + const SizedBox(width: AbTokens.space8), + trailing!, + ], + ], + ), + Padding( + padding: const EdgeInsets.only(left: handlerRailInset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (e.floorRule != null) ...[ + const SizedBox(height: AbTokens.space2), + Text( + 'Safety floor: ${e.floorRule}', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + fontWeight: FontWeight.w600, + color: p.warning, + ), + ), + ], + const SizedBox(height: AbTokens.space2), + Text( + e.reasoning, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.textMuted, + ), + ), + if (e.draftReply.isNotEmpty) ...[ + const SizedBox(height: AbTokens.space8), + Text( + 'Handler wanted to send', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.textMuted, + ), + ), + const SizedBox(height: AbTokens.space2), + Text( + e.draftReply, + // Mono: this is verbatim what would have landed in the + // session, not chrome. Unbounded for the same reason the + // decision card's choice text is — an ellipsis hides the + // half the user has to read to judge the refusal. + style: AbTokens.monoStyle( + fontSize: AbTokens.fontXs, + color: p.textMuted, + ), + ), + ], + const SizedBox(height: AbTokens.space8), + Row( + children: [ + AbButton( + label: handlerDismissLabel, + variant: AbButtonVariant.primary, + onTap: onDismiss, + ), + const SizedBox(width: AbTokens.space8), + AbButton(label: handlerReplyInsteadLabel, onTap: onReply), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/handler/handler_pa_bar.dart b/app/lib/widgets/handler/handler_pa_bar.dart index e7b3f12b..5782f1d5 100644 --- a/app/lib/widgets/handler/handler_pa_bar.dart +++ b/app/lib/widgets/handler/handler_pa_bar.dart @@ -64,6 +64,13 @@ String handlerPaStatusLabel(HandlerSessionState session, {DateTime? now}) { /// answering them, and never says which — so the line has to promise clearing, /// not answers. /// +/// Two kinds are exempt from that clearing on the bridge and so must be exempt +/// from the promise here: `resolve_in_session` (only the transcript's own +/// resolve carries the id) and `guard_blocked` (a report of an action Handler +/// could not take, retired only by its card's Dismiss). This prose is a +/// hand-mirror of that rule; getting it wrong promises the user something the +/// bridge refuses to do. +/// /// Nothing is ever blocked. Handler's whole premise is acting while you are /// away, so a lock the user has to remember to undo would be left in the wrong /// position exactly when it matters. @@ -79,46 +86,60 @@ String? handlerTypingHint( HandlerRunState.watching => null, }; -/// An option-based prompt (`kind: 'resolve_in_session'`) is the one row a typed -/// line neither answers nor clears — only the transcript's permission card or -/// question form carries the id that resolves it, which is why +/// An option-based prompt (`kind: 'resolve_in_session'`) is one of the two rows +/// a typed line neither answers nor clears — only the transcript's permission +/// card or question form carries the id that resolves it, which is why /// `handler_screen.dart`'s `answer()` routes there instead of opening the reply /// sheet. int _prompts(HandlerSessionState session) => session.escalations.where((e) => e.kind == 'resolve_in_session').length; +/// The other one. A `guard_blocked` row reports an action Handler wanted to take +/// and a guard refused, so there is no pause for a line to supersede — the +/// bridge keeps it standing and only the card's Dismiss retires it. A hint that +/// counted it would promise clearing the bridge will not do. +int _reports(HandlerSessionState session) => + session.escalations.where((e) => e.kind == 'guard_blocked').length; + /// Plural because an agent can be stopped on several at once — parallel tool /// calls raise a permission prompt per call, and the bridge now carries a row /// for each. String _promptSubject(int prompts) => prompts == 1 ? 'the prompt' : '$prompts prompts'; -String _needsYouHint(HandlerSessionState session) { +String? _needsYouHint(HandlerSessionState session) { final prompts = _prompts(session); if (prompts > 0) { // Both halves or neither. The redirect alone reads as "typing here does // nothing", and a user who types anyway loses the free-text questions // queued behind the prompt — the silent clearing this whole line exists to // stop, merely moved to the mixed case. - // Counted off the bridge's own total, for the same reason the branch below is — - // a row the lenient parse dropped must not shrink the number of questions this - // line promises to clear. Floored: the two arrive in one snapshot but the parse - // can only ever lose rows, never invent them. - final others = math.max(0, session.pendingEscalations - prompts); + final others = _others(session, prompts); final answer = 'Answer ${_promptSubject(prompts)} in the transcript'; if (others == 0) return '$answer — not here'; final questions = others == 1 ? 'question' : '$others questions'; return '$answer — a message here clears the other $questions'; } - // Kinds come from the per-session rows, the number from the count the bridge - // folds off that same list, so a row the lenient parse dropped never shrinks - // the total on screen. - final pending = session.pendingEscalations; + final pending = _others(session, 0); + // A session standing only on reports is at needs_you with nothing a typed line + // would clear, so the bar has nothing to warn about. + if (pending == 0) return null; return pending > 1 ? 'Your next message clears all $pending questions, answered or not' : 'Your next message clears this question, answered or not'; } +/// The questions a submitted line actually clears: everything the bridge counts, +/// minus the two kinds it keeps standing. +/// +/// Counted off the bridge's own total rather than the parsed rows, for the same +/// reason the prompt count is subtracted from it — a row the lenient parse +/// dropped must not shrink the number this line promises to clear. Floored: the +/// two arrive in one snapshot but the parse can only ever lose rows, never +/// invent them. +int _others(HandlerSessionState session, int prompts) => + math.max(0, session.pendingEscalations - prompts - _reports(session)); + /// A park ends on the first submitted line either way, but a prompt raised /// before the park survives it (`enterPark` never touches `s.escalations`), and /// the engine lands such a session back on `needs_you` rather than resuming — diff --git a/app/lib/widgets/handler/handler_screen.dart b/app/lib/widgets/handler/handler_screen.dart index 8a720bad..f41dbfe2 100644 --- a/app/lib/widgets/handler/handler_screen.dart +++ b/app/lib/widgets/handler/handler_screen.dart @@ -20,6 +20,7 @@ import '../../providers/providers.dart'; import '../../providers/sessions.dart'; import '../../util/relative_time.dart'; import 'handler_backlog_drawer.dart'; +import 'handler_blocked_action_card.dart'; import 'handler_decision_card.dart'; import 'handler_item_status.dart'; import 'handler_layout.dart'; @@ -123,7 +124,25 @@ class HandlerScreen extends ConsumerWidget { SliverList.list( children: [ for (final e in state.escalations) - if (e.choices != null) + // First in the chain, and a cheap floor rather than a live + // case: the bridge never mints choices for a report, so this + // and the decision card can never both want the row. + if (e.kind == 'guard_blocked') + HandlerBlockedActionCard( + escalation: e, + trailing: meta(e.terminalId, e.at), + // Re-resolved through the container for the same reason + // `answer` re-resolves after its sheet: the build-time + // instance can be disposed by the time a tap lands. + onDismiss: service == null + ? null + : () => focusedServiceOrNull( + container, + (s) => s.handlerService, + )?.dismiss(e), + onReply: service == null ? null : () => answer(e), + ) + else if (e.choices != null) HandlerDecisionCard( escalation: e, trailing: meta(e.terminalId, e.at), @@ -849,6 +868,10 @@ String _itemDecisionLabel(String decision) { // the audit trail prevention was traded for, so it is never conditional // on what Handler decided afterwards. 'floor_warning' => ('Flagged: ${r.reason}', p.warning), + // A completion the harness refused to bank. The status snapshot that + // follows is identical to the one before it, so this row is the only trace + // of a session that will now not wrap up on its own. + 'evidence_rejected' => ('Completion not verified: ${r.reason}', p.warning), 'wrapped_up' => ('Wrapped up', null), 'parked' => ('Paused: ${r.reason}', null), 'resumed' => ('Resumed: ${r.reason}', null), @@ -870,6 +893,7 @@ String _itemDecisionLabel(String decision) { 'item_skipped' => (AbIcons.close, p.textMuted), 'instruction_dropped' => (AbIcons.warning, p.textMuted), 'floor_warning' => (AbIcons.shield, p.warning), + 'evidence_rejected' => (AbIcons.warning, p.warning), 'wrapped_up' => (AbIcons.check, p.textMuted), 'parked' => (AbIcons.stop, p.warning), 'resumed' => (AbIcons.start, p.textMuted), @@ -908,6 +932,7 @@ Widget? _activitySubtitle(HandlerActivityRecord r, AbColors p) { case 'item_skipped': case 'item_failed': case 'instruction_dropped': + case 'evidence_rejected': return detail == null ? null : Text(detail, style: sans); default: return Text(r.decision, style: mono); diff --git a/app/lib/widgets/projects_drawer.dart b/app/lib/widgets/projects_drawer.dart index b077115e..3c4b9e1e 100644 --- a/app/lib/widgets/projects_drawer.dart +++ b/app/lib/widgets/projects_drawer.dart @@ -662,7 +662,9 @@ class _AdvertisedProjectRow extends ConsumerWidget { builder: (context, hovered, pointerOver) => AbListRow( horizontalPadding: 0, density: AbRowDensity.sm, - hoverable: true, + // No `hoverable`: matches the local project row, which never took + // it — the fill previews selection, and this row's tap expands. + // See `DrawerBand` for the rule. leading: DrawerProjectLeading( expanded: expanded, pointerOver: pointerOver, diff --git a/app/lib/widgets/recent_sessions/recent_session_row_widget.dart b/app/lib/widgets/recent_sessions/recent_session_row_widget.dart index fc5c3a5c..4c592914 100644 --- a/app/lib/widgets/recent_sessions/recent_session_row_widget.dart +++ b/app/lib/widgets/recent_sessions/recent_session_row_widget.dart @@ -30,6 +30,7 @@ import '../agent_work_status_dot.dart'; import '../session_delete_flow.dart'; import '../session_deleting_badge.dart'; import '../session_isolation_badge.dart'; +import '../session_shared_workspace_badge.dart'; /// One row in the Recent tab: agent mark (status-badged) · session name · /// project · relative time · delete affordance (desktop hover). @@ -210,6 +211,7 @@ class _RecentSessionRowWidgetState context: context, sessionName: row.session.name, checkoutKind: row.session.checkoutKind, + sharedWorkspace: row.session.sharedWorkspace, // A Recent row is a cache entry, so this surface can't promise anything // about a process: the session may have no agent running to terminate. sharedBody: 'This permanently deletes "${row.session.name}".', @@ -323,6 +325,7 @@ class _DesktopLayout extends StatelessWidget { // long name ellipsizes around them rather than pushing them off // the row. SessionIsolationBadge(session: row.session, setup: setup), + SessionSharedWorkspaceBadge(session: row.session), SessionDeletingBadge(deleting: deleting), const SizedBox(width: AbTokens.space12), ], @@ -436,6 +439,7 @@ class _MobileLayout extends StatelessWidget { const SizedBox(width: AbTokens.space12), Expanded(child: _SessionName(name: row.session.name)), SessionIsolationBadge(session: row.session, setup: setup), + SessionSharedWorkspaceBadge(session: row.session), SessionDeletingBadge(deleting: deleting), const SizedBox(width: AbTokens.space8), // Only a custom launch command belongs on this line: an agent diff --git a/app/lib/widgets/session_delete_flow.dart b/app/lib/widgets/session_delete_flow.dart index d1e256bb..12c6c4a4 100644 --- a/app/lib/widgets/session_delete_flow.dart +++ b/app/lib/widgets/session_delete_flow.dart @@ -48,10 +48,19 @@ Future confirmAndDeleteSession({ required String checkoutKind, required String sharedBody, required SessionDeleter delete, + bool sharedWorkspace = false, void Function(bool inFlight)? onInFlight, }) async { final (String title, String consequence) = switch (checkoutKind) { 'main' => ('Delete session?', sharedBody), + // A member of a shared workspace is detached, not reclaimed: the bridge + // keeps the directory and the branch for the sessions still in it, so the + // arm below would promise a removal that cannot happen. + 'managed-worktree' when sharedWorkspace => ( + 'Delete isolated session?', + '$sharedBody Its working directory is shared with other sessions and is ' + 'kept.', + ), // Adds only what a managed checkout costs on top of the surface's wording — // whether a process dies is the surface's to claim, not this arm's. 'managed-worktree' => ( diff --git a/app/lib/widgets/session_fork_dialog.dart b/app/lib/widgets/session_fork_dialog.dart new file mode 100644 index 00000000..72909b3c --- /dev/null +++ b/app/lib/widgets/session_fork_dialog.dart @@ -0,0 +1,146 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart' show Dialog, Navigator, showDialog; + +import '../design/ab_colors.dart'; +import '../design/ab_tokens.dart'; +import '../design/widgets/ab_button.dart'; +import '../design/widgets/ab_dialog.dart'; +import '../design/widgets/ab_segmented.dart'; + +/// The two workspaces a fork can run in, spelled the way `session:fork` spells +/// them, so nothing has to translate between the control the user touched and +/// the frame it produces. +const String forkWorkspaceCopy = 'copy'; +const String forkWorkspaceCurrent = 'current'; + +/// Asks which workspace a fork of this session should run in. Returns +/// [forkWorkspaceCopy] or [forkWorkspaceCurrent], or null if the user +/// cancelled or dismissed. +/// +/// A segmented control rather than [AbConfirmDialog]'s opt-in toggle: that +/// toggle is documented for "a second consequence the user may accept alongside +/// the primary one — never for restating the primary action", and this is not a +/// consequence to accept but the choice itself. Both alternatives have to be +/// legible without touching anything, which is what [AbSegmented] exists for. +/// +/// [isolatedSource] is what "this workspace" MEANS, and it changes the promise +/// rather than the wording: sharing an isolated session's checkout puts two +/// agents somewhere only they can see, while sharing a main-tree session's puts +/// the fork where every ordinary session already lives. +Future promptSessionFork( + BuildContext context, { + required bool isolatedSource, +}) { + return showDialog( + context: context, + builder: (_) => _SessionForkDialog(isolatedSource: isolatedSource), + ); +} + +class _SessionForkDialog extends StatefulWidget { + const _SessionForkDialog({required this.isolatedSource}); + + final bool isolatedSource; + + @override + State<_SessionForkDialog> createState() => _SessionForkDialogState(); +} + +class _SessionForkDialogState extends State<_SessionForkDialog> { + // The workspace nothing else is standing in. A fork that lands beside a + // running agent is the answer the user has to ask for. + String _workspace = forkWorkspaceCopy; + + void _cancel() => Navigator.of(context).pop(); + void _confirm() => Navigator.of(context).pop(_workspace); + + /// The consequence of the CURRENT pick, stated as what happens to the user's + /// work rather than as what Antgrid does. The uncommitted-changes clause is + /// the one thing here a user cannot recover by looking, so it is a sentence + /// of its own and not a subordinate clause. + String get _consequence => switch (_workspace) { + forkWorkspaceCurrent when widget.isolatedSource => + 'Both sessions work in this one directory at the same time — the same ' + 'files, the same branch, the same commits.', + forkWorkspaceCurrent => + 'The fork works in your main directory, alongside every other session ' + 'there — the same files, the same branch.', + _ when widget.isolatedSource => + 'A workspace of its own, taken from this session\'s last commit. Work ' + 'you have not committed stays here and the fork never sees it.', + _ => + 'A workspace of its own, taken from your last commit. Work you have not ' + 'committed stays in your main directory and the fork never sees it.', + }; + + @override + Widget build(BuildContext context) { + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Padding( + padding: const EdgeInsets.all(AbTokens.space16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + abDialogTitle('Fork session', onClose: _cancel), + const SizedBox(height: AbTokens.space12), + Text( + 'The fork picks the conversation up where this session left ' + 'it, and runs in:', + style: AbTokens.sansStyle( + fontSize: AbTokens.fontSm, + color: context.antgrid.textSecondary, + ), + ), + const SizedBox(height: AbTokens.space12), + // Left-aligned rather than stretched: [AbSegmented]'s own Row is + // `mainAxisSize.min`, so the surrounding Column's stretch hands it + // tight constraints it cannot use — a border box wider than its + // cells at best, and a fractional overflow at worst. + Align( + alignment: Alignment.centerLeft, + child: AbSegmented( + segments: const [ + AbSegment( + value: forkWorkspaceCopy, + label: 'New workspace', + ), + AbSegment( + value: forkWorkspaceCurrent, + label: 'This workspace', + ), + ], + selected: _workspace, + onSelect: (v) => setState(() => _workspace = v), + ), + ), + const SizedBox(height: AbTokens.space8), + Text( + _consequence, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: context.antgrid.textMuted, + ), + ), + const SizedBox(height: AbTokens.space16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AbButton(label: 'Cancel', onTap: _cancel), + const SizedBox(width: AbTokens.space8), + AbButton( + label: 'Fork', + variant: AbButtonVariant.primary, + onTap: _confirm, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/session_row.dart b/app/lib/widgets/session_row.dart index efee8a0d..e56d3e14 100644 --- a/app/lib/widgets/session_row.dart +++ b/app/lib/widgets/session_row.dart @@ -37,8 +37,10 @@ import 'agent_work_status_dot.dart'; import 'drawer_entry_row.dart' show activateDrawerEntryById, ensureRemoteOnline; import 'session_delete_flow.dart'; import 'session_deleting_badge.dart'; +import 'session_fork_dialog.dart'; import 'session_isolation_badge.dart'; import 'session_rename_dialog.dart'; +import 'session_shared_workspace_badge.dart'; import 'session_start_refusal.dart'; /// One row in the sessions sub-tree of [ProjectsDrawer]. Tapping focuses the @@ -53,6 +55,13 @@ import 'session_start_refusal.dart'; /// 14px line at 1.2 line-height. const double _dotOpticalYBias = 0.45; +/// Shown when a `session:start` the user explicitly asked for gets no reply. +/// One literal because the row tap and the kebab's Fork make the user the same +/// promise, and a start that is still pending must not read as a failed one. +const String _startNoAnswerMessage = + "The agent didn't answer. If the session doesn't come up in a moment, try " + 'again.'; + class SessionRow extends ConsumerStatefulWidget { final String entryId; final SessionEntry session; @@ -302,6 +311,7 @@ class _SessionRowState extends ConsumerState { // persisted cache, which carries no setup state at all. setup: ref.watch(sessionSetupProvider(session.id)), ), + SessionSharedWorkspaceBadge(session: session), SessionDeletingBadge(deleting: deleting), ], ), @@ -422,11 +432,7 @@ class _SessionRowState extends ConsumerState { // Leaving the activeSessionId set while the surface never switches is // the worst of both — a tap that visibly did nothing. if (refusalHost.mounted) { - showAbSnackBar( - refusalHost, - "The agent didn't answer. If the session doesn't come up in a " - 'moment, try again.', - ); + showAbSnackBar(refusalHost, _startNoAnswerMessage); } } // A different project can be activated while start() is in flight. The @@ -453,18 +459,8 @@ class _SessionRowState extends ConsumerState { _showFocusedSessionSurface(ref); } - void _showFocusedSessionSurface(ProviderContainer ref) { - ref.read(workbenchSurfaceProvider.notifier).set(WorkbenchSurface.workspace); - ref - .read(navControllerProvider.notifier) - .commit( - NavLocation( - target: ref.read(selectedTargetProvider), - surface: WorkbenchSurface.workspace, - sessionId: session.id, - ), - ); - } + void _showFocusedSessionSurface(ProviderContainer ref) => + _showSessionSurface(ref, session.id); /// Inline rename field. Enter (onSubmitted) and blur (onFocusChange) /// commit; Escape (intercepted by the wrapping [Focus]) cancels. @@ -518,7 +514,24 @@ class _SessionRowState extends ConsumerState { } } -enum _SessionAction { start, stop, rename, archive, delete } +/// Puts the workspace surface in front of the user on [sessionId], and records +/// it as the nav entry. Top-level so the row tap and the kebab's Fork land the +/// user in exactly the same place — a forked session the user is not looking at +/// is indistinguishable from a menu item that did nothing. +void _showSessionSurface(ProviderContainer ref, String sessionId) { + ref.read(workbenchSurfaceProvider.notifier).set(WorkbenchSurface.workspace); + ref + .read(navControllerProvider.notifier) + .commit( + NavLocation( + target: ref.read(selectedTargetProvider), + surface: WorkbenchSurface.workspace, + sessionId: sessionId, + ), + ); +} + +enum _SessionAction { start, stop, fork, rename, archive, delete } /// One kebab-menu outcome. Sealed rather than a flat enum because the /// working-directory rows carry which app was picked, and because the dispatch @@ -634,6 +647,26 @@ class _SessionMenu extends ConsumerWidget { session.running ? _SessionAction.stop : _SessionAction.start, ), ), + if (session.forkSupported) + AbMenuItem( + label: 'Fork session', + value: const _RowAction(_SessionAction.fork), + // Greyed rather than dropped: `forkSupported` answers whether the + // AGENT can be forked, which is a permanent fact about the tool, + // while having something to fork is a fact about this session that + // its first turn fixes. Hiding the row for the second would teach + // the user the tool cannot do it at all. + // + // What the bridge actually reads is a native conversation id or the + // live terminal's scrollback (`captureForkTranscript`), and both + // inputs are already on the wire — so this mirrors the precondition + // rather than asking for it. Drift costs a refusal the branch below + // now reports, never a silence. + enabled: session.running || session.agentSessionId != null, + disabledReason: + 'This session has nothing to fork yet. Start it and let the ' + 'agent reply first.', + ), const AbMenuItem( label: 'Rename', value: _RowAction(_SessionAction.rename), @@ -715,6 +748,66 @@ class _SessionMenu extends ConsumerWidget { } case _SessionAction.stop: await svc.stopSession(session.id); + case _SessionAction.fork: + final workspace = await promptSessionFork( + anchor, + isolatedSource: sessionIsIsolated(session), + ); + if (workspace == null || !anchor.mounted) return; + // Caught per-branch, per the contract above: every fork refusal the + // bridge documents — no captured transcript yet, a conversation past + // the handoff cap, a custom-command session, a missing checkout — + // arrives as a typed exception carrying the bridge's own sentence, + // and without this the menu item just appears to do nothing. + try { + final fork = await svc.fork(session.id, workspace: workspace); + // An `ok` carrying no session: there is nothing to start and + // nothing to land in, so say so rather than returning as though the + // fork had happened. The same answer the New Session canvas treats + // as a refused create. + if (fork == null) { + if (anchor.mounted) { + reportSessionNotice( + anchor, + sessionForkRefusalCopy(null, null), + ); + } + return; + } + // Started before the focus switch, not after: focusing another + // project's row remounts the shell, whose bootstrap re-lists the + // sessions and auto-starts the one it adopts. Issuing the start + // first puts it ahead of that list on the same stream — the + // ordering the New Session canvas keeps, for the same reason. + // + // Neither failure abandons the focus below. The fork EXISTS by + // here and is the user's; a start that was refused is a session to + // land in and read the reason from, and one that never answered may + // still be coming up. + try { + final started = await svc.start(fork.id, raiseRefusal: true); + if (started == null && anchor.mounted) { + reportSessionNotice( + anchor, + sessionStartRefusalCopy(null, null), + ); + } + } on SessionOperationException catch (error) { + if (anchor.mounted) reportStartRefusal(anchor, error); + } on TimeoutException { + if (anchor.mounted) { + reportSessionNotice(anchor, _startNoAnswerMessage); + } + } + if (anchor.mounted) await _focusSession(anchor, ref, svc, fork.id); + } on SessionOperationException catch (error) { + if (anchor.mounted) { + reportSessionNotice( + anchor, + sessionForkRefusalCopy(error.errorCode, error.message), + ); + } + } case _SessionAction.rename: final name = await promptSessionRename(anchor, session.name); if (name != null && name.trim().isNotEmpty) { @@ -739,6 +832,31 @@ class _SessionMenu extends ConsumerWidget { } } + /// Moves the user into [sessionId] in THIS row's project — the same + /// two-branch handshake the row tap performs. A focused project is a direct + /// write; another project's row seeds the intent and switches first, and a + /// switch that fails has to clear that intent or it leaks into the next, + /// unrelated project open. + Future _focusSession( + BuildContext anchor, + ProviderContainer ref, + SessionsService svc, + String sessionId, + ) async { + if (ref.read(selectedRegistrationIdProvider) == entryId) { + ref.read(activeSessionIdProvider.notifier).set(sessionId); + svc.focus(sessionId); + _showSessionSurface(ref, sessionId); + return; + } + ref.read(pendingActiveSessionIdProvider.notifier).set(sessionId); + if (!await activateDrawerEntryById(anchor, ref, entryId)) { + ref.read(pendingActiveSessionIdProvider.notifier).set(null); + return; + } + _showSessionSurface(ref, sessionId); + } + Future _deleteSession( BuildContext context, ProviderContainer ref, @@ -750,6 +868,7 @@ class _SessionMenu extends ConsumerWidget { context: context, sessionName: session.name, checkoutKind: session.checkoutKind, + sharedWorkspace: session.sharedWorkspace, sharedBody: 'This permanently deletes "${session.name}" and terminates its agent process.', delete: ({force, deleteBranch}) => diff --git a/app/lib/widgets/session_shared_workspace_badge.dart b/app/lib/widgets/session_shared_workspace_badge.dart new file mode 100644 index 00000000..89abfad3 --- /dev/null +++ b/app/lib/widgets/session_shared_workspace_badge.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; + +import '../design/ab_colors.dart'; +import '../design/ab_icons.dart'; +import '../design/ab_tokens.dart'; +import '../design/widgets/ab_icon.dart'; +import '../design/widgets/ab_tooltip.dart'; +import '../models/session_entry.dart'; + +/// The one marker that says this session's workspace is not its own — another +/// session is working in the same directory, on the same branch, right now. +/// Mounted wherever a session is named: the drawer row, the Recent/search row, +/// the agent breadcrumb. +/// +/// Renders nothing for a session that has its workspace to itself, so every +/// call site can mount it unconditionally and none of them re-derives what +/// "shared" means. Paired with `SessionIsolationBadge`, which answers the other +/// half — where the session runs, rather than who else is there — so the two +/// can both show and neither restates the other. +/// +/// A glyph rather than the count, matching that badge: this sits beside a +/// session NAME in rows that are already tight, and a number that moves as +/// sessions come and go is motion the reader cannot act on. The count is the +/// tooltip's job — hover on a pointer, tap on touch — which is also the only +/// place with room to say what sharing actually costs. +class SessionSharedWorkspaceBadge extends StatelessWidget { + const SessionSharedWorkspaceBadge({super.key, required this.session}); + + final SessionEntry session; + + @override + Widget build(BuildContext context) { + if (!session.sharedWorkspace) return const SizedBox.shrink(); + // The bridge floors the count at 1 and the model defaults it to 1, so an + // older bridge that sets the flag and omits the number lands here at zero + // others. Say the weaker true thing rather than "0 other sessions". + final others = session.workspaceMemberCount - 1; + // The badge owns its own leading gap so a call site reserves no space for a + // widget that usually renders nothing. + return Padding( + padding: const EdgeInsets.only(left: AbTokens.space6), + child: AbTooltip( + message: others > 0 + ? 'Shared workspace — ${others == 1 ? '1 other session works' : '$others other sessions work'} ' + 'in this directory. Every one of them edits the same files ' + 'and commits to the same branch, at the same time.' + : 'Shared workspace — other sessions work in this directory, ' + 'editing the same files and committing to the same branch.', + triggerMode: TooltipTriggerMode.tap, + child: AbIcon( + AbIcons.sharedWorkspace, + size: _glyphSize, + color: context.antgrid.textMuted, + ), + ), + ); + } +} + +/// Matched to the row text beside it rather than to [AbTokens.iconButtonGlyph]: +/// this glyph is a marker on a line of text, not a control. +const double _glyphSize = AbTokens.fontSm; diff --git a/app/lib/widgets/session_start_refusal.dart b/app/lib/widgets/session_start_refusal.dart index e9da6c05..874b5ea7 100644 --- a/app/lib/widgets/session_start_refusal.dart +++ b/app/lib/widgets/session_start_refusal.dart @@ -21,19 +21,32 @@ String sessionStartRefusalCopy(String? code, String? message) { return sessionRefusalCopy(code, message, 'Could not start this session.'); } -/// Reports [error] on the root navigator's context rather than [context]'s own: -/// a session tap can dispose the row that fired it (mobile pops the drawer, a -/// cross-project switch rebuilds it), and a message the user asked for must not -/// vanish with the widget. Same reason `recent_session_row_widget.dart`'s onTap -/// hands `openRecentSession` the navigator's context. Falls back to [context] -/// where there is no Navigator (widget tests). -void reportStartRefusal(BuildContext context, SessionOperationException error) { +/// What a user is told when the bridge refuses `session:fork`. +/// +/// Its own fallback rather than [sessionStartRefusalCopy]'s: a fork that never +/// happened and a session that never started are different answers, and the +/// fallback is exactly what a user reads when the refusal carries no code and +/// no sentence of its own — a bare `ok: false`, which is a bridge declining to +/// explain rather than a bridge that said nothing. +String sessionForkRefusalCopy(String? code, String? message) => + sessionRefusalCopy(code, message, 'Could not fork this session.'); + +void reportStartRefusal(BuildContext context, SessionOperationException error) => + reportSessionNotice( + context, + sessionStartRefusalCopy(error.errorCode, error.message), + ); + +/// Reports [message] about a session on the root navigator's context rather +/// than [context]'s own: a session tap can dispose the row that fired it +/// (mobile pops the drawer, a cross-project switch rebuilds it), and an answer +/// the user asked for must not vanish with the widget. Same reason +/// `recent_session_row_widget.dart`'s onTap hands `openRecentSession` the +/// navigator's context. Falls back to [context] where there is no Navigator +/// (widget tests). +void reportSessionNotice(BuildContext context, String message) { final host = Navigator.maybeOf(context, rootNavigator: true)?.context ?? context; if (!host.mounted) return; - showAbSnackBar( - host, - sessionStartRefusalCopy(error.errorCode, error.message), - duration: const Duration(seconds: 8), - ); + showAbSnackBar(host, message, duration: const Duration(seconds: 8)); } diff --git a/app/lib/widgets/update_row.dart b/app/lib/widgets/update_row.dart index 643aa7f6..88cfc965 100644 --- a/app/lib/widgets/update_row.dart +++ b/app/lib/widgets/update_row.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,17 +5,23 @@ import '../design/ab_colors.dart'; import '../design/ab_icons.dart'; import '../design/ab_tokens.dart'; import '../design/widgets/ab_icon.dart'; +import '../design/widgets/ab_progress_rule.dart'; import '../providers/update_available.dart'; +import '../update/update_install_controller.dart'; import '../update/update_strategy.dart'; +import '../util/detached.dart'; /// Persistent "Update available" drawer row, shown directly above the account /// footer while a newer version is waiting ([updateAvailableProvider]). /// Collapses to nothing otherwise, so up-to-date builds and platforms without /// an update path pay no layout cost. /// -/// The row survives dismissal/cancellation of the platform's install dialog -/// on purpose — the update stays pending until the app restarts, so the -/// affordance must remain clickable whenever the user is ready. +/// The row survives a declined or cancelled install on purpose — the update +/// stays pending until the app restarts, so the affordance must remain +/// clickable whenever the user is ready. It goes inert for exactly as long as +/// an attempt is live: the first seconds of a Windows install are silent (the +/// Store re-scans its pending set before showing anything of its own), and a +/// second tap in that window starts a second install. class UpdateRow extends ConsumerWidget { const UpdateRow({super.key}); @@ -28,14 +32,25 @@ class UpdateRow extends ConsumerWidget { // The row only renders while `updateAvailableProvider` is lit, and only // the platform's UpdateStrategy check can light it — so the same - // strategy object carries this row's copy AND its tap's install route - // (single per-platform table, see update_strategy.dart). Strategies - // never throw, surface their own UI, and tolerate a repeat tap by - // re-opening the flow. + // strategy object carries this row's copy (single per-platform table, + // see update_strategy.dart) while the controller owns the tap, shared + // with the update toasts so both cannot start an install at once. final strategy = ref.watch(updateStrategyProvider); assert(strategy != null, 'UpdateRow lit on a platform with no strategy'); if (strategy == null) return const SizedBox.shrink(); + final install = ref.watch(updateInstallControllerProvider); + final live = install.canStart; + final title = switch (install) { + // 0 is the pre-download plateau, not progress — the Store re-scans and + // shows both consent dialogs before the first byte, so a hard "0%" would + // read as stalled for the whole of it. + UpdateInstallWorking(:final percent) when percent > 0 => + 'Updating... $percent%', + UpdateInstallWorking() || UpdateInstallDone() => 'Updating...', + _ => strategy.rowTitle, + }; + final p = context.antgrid; return Container( decoration: BoxDecoration( @@ -43,34 +58,74 @@ class UpdateRow extends ConsumerWidget { ), child: InkWell( hoverColor: p.bgElevated, - onTap: () => unawaited(strategy.install(context)), + onTap: live + ? () => detached( + 'UpdateRow', + 'install sequence', + () => ref + .read(updateInstallControllerProvider.notifier) + .start(context), + ) + : null, child: SizedBox( height: AbTokens.commandTrayHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AbTokens.space16), - child: Row( - children: [ - AbIcon(AbIcons.arrowDown, size: 12, color: p.accent), - const SizedBox(width: AbTokens.space10), - Expanded( - child: Text( - strategy.rowTitle, - style: AbTokens.sansStyle( - fontSize: AbTokens.fontXs, - color: p.textSecondary, + child: Stack( + // Tight constraints for the Row, so the label stays centred in the + // tray height rather than hugging the top. + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AbTokens.space16, + ), + child: Row( + children: [ + AbIcon( + AbIcons.arrowDown, + size: 12, + color: live ? p.accent : p.textMuted, ), - overflow: TextOverflow.ellipsis, - ), + const SizedBox(width: AbTokens.space10), + Expanded( + child: Text( + title, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // An action label on a row that refuses taps reads as a + // dead button, so it goes away while one is running. + if (live) + Text( + strategy.rowActionLabel, + style: AbTokens.sansStyle( + fontSize: AbTokens.fontXs, + color: p.accent, + ), + ), + ], ), - Text( - strategy.rowActionLabel, - style: AbTokens.sansStyle( - fontSize: AbTokens.fontXs, - color: p.accent, + ), + // Overlaid rather than stacked in a Column: the row must not + // change height when the rule appears. + if (install is UpdateInstallWorking) + Positioned( + left: 0, + right: 0, + bottom: 0, + // Indeterminate until the platform actually ticks: a rule + // pinned at zero for minutes is indistinguishable from a + // stuck one. + child: AbProgressRule( + fraction: install.percent == 0 + ? null + : install.percent / 100, ), ), - ], - ), + ], ), ), ), diff --git a/app/lib/widgets/window_title_bar.dart b/app/lib/widgets/window_title_bar.dart index 5f0b849c..37b71c5f 100644 --- a/app/lib/widgets/window_title_bar.dart +++ b/app/lib/widgets/window_title_bar.dart @@ -27,6 +27,7 @@ import '../window/window_capabilities.dart'; import '../window/window_chrome.dart'; import 'agent_panel.dart'; import 'session_isolation_badge.dart'; +import 'session_shared_workspace_badge.dart'; import 'session_mode_control.dart'; import 'session_search_field.dart'; @@ -497,13 +498,15 @@ class TitleBarBreadcrumb extends ConsumerWidget { : EditableSessionLeaf(session: active), ), ), - if (active != null) + if (active != null) ...[ SessionIsolationBadge( session: active, // The live list's answer, never `active.setup`: the entry can come // from the persisted cache, which carries no setup state at all. setup: ref.watch(activeSessionSetupProvider), ), + SessionSharedWorkspaceBadge(session: active), + ], if (gitBranch != null) ...[ const SizedBox(width: AbTokens.space8), // Bounded, not Flexible: the breadcrumb is the only child that should diff --git a/app/pubspec.lock b/app/pubspec.lock index d9114c67..85877c78 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -682,8 +682,8 @@ packages: dependency: "direct overridden" description: path: "pkgs/vte/ghostty_vte" - ref: "6cd393196ed301afa1d8ada7a996cc345b899b4a" - resolved-ref: "6cd393196ed301afa1d8ada7a996cc345b899b4a" + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 + resolved-ref: c262d5f2002d26b2116b2c5c943a46a63f994133 url: "https://github.com/antgrid-ai/dart_terminal.git" source: git version: "0.1.4+antgrid.1" @@ -691,8 +691,8 @@ packages: dependency: "direct main" description: path: "pkgs/vte/ghostty_vte_flutter" - ref: "6cd393196ed301afa1d8ada7a996cc345b899b4a" - resolved-ref: "6cd393196ed301afa1d8ada7a996cc345b899b4a" + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 + resolved-ref: c262d5f2002d26b2116b2c5c943a46a63f994133 url: "https://github.com/antgrid-ai/dart_terminal.git" source: git version: "0.1.4+antgrid.1" @@ -1204,8 +1204,8 @@ packages: dependency: "direct overridden" description: path: "pkgs/pty/portable_pty" - ref: "6cd393196ed301afa1d8ada7a996cc345b899b4a" - resolved-ref: "6cd393196ed301afa1d8ada7a996cc345b899b4a" + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 + resolved-ref: c262d5f2002d26b2116b2c5c943a46a63f994133 url: "https://github.com/antgrid-ai/dart_terminal.git" source: git version: "0.0.6+antgrid.2" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 64c921cf..50d21dae 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -191,17 +191,17 @@ dependency_overrides: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/vte/ghostty_vte_flutter - ref: 6cd393196ed301afa1d8ada7a996cc345b899b4a + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 ghostty_vte: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/vte/ghostty_vte - ref: 6cd393196ed301afa1d8ada7a996cc345b899b4a + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 portable_pty: git: url: https://github.com/antgrid-ai/dart_terminal.git path: pkgs/pty/portable_pty - ref: 6cd393196ed301afa1d8ada7a996cc345b899b4a + ref: c262d5f2002d26b2116b2c5c943a46a63f994133 # Microsoft Store packaging (MSIX). The Store signs the package itself, so no # code-signing cert is used on this path (unlike the Inno Setup installer in diff --git a/app/test/helpers/test_store_overrides.dart b/app/test/helpers/test_store_overrides.dart index 2d9a4d91..c90c82aa 100644 --- a/app/test/helpers/test_store_overrides.dart +++ b/app/test/helpers/test_store_overrides.dart @@ -1,4 +1,4 @@ -// Test helper that materializes the four persistent-store providers main() +// Test helper that materializes the persistent-store providers main() // expects to be overridden. Tests that mount widgets reading from // `recentAgentsProvider`, `projectsProvider`, etc. can call // [buildTestStoreOverrides] inside their test setup to satisfy those @@ -17,6 +17,7 @@ import 'package:antgrid/providers/first_run.dart'; import 'package:antgrid/providers/projects.dart'; import 'package:antgrid/providers/recent_agents.dart'; import 'package:antgrid/providers/recent_ports.dart'; +import 'package:antgrid/providers/update_available.dart'; import 'package:antgrid/project/project_session_registry.dart' show projectStatusCacheProvider; import 'package:antgrid/project/project_status_cache.dart'; @@ -28,6 +29,7 @@ import 'package:antgrid/storage/first_run_store.dart'; import 'package:antgrid/storage/project_store.dart'; import 'package:antgrid/storage/recent_agents_store.dart'; import 'package:antgrid/storage/recent_ports_store.dart'; +import 'package:antgrid/storage/update_handoff_store.dart'; class TestStoreOverrides { final List overrides; @@ -77,6 +79,7 @@ Future buildTestStoreOverrides() async { cachedSessionsStore, recentPortsStore, firstRunStore, + updateHandoffStore, prefs, ) = await ( ProjectStore.open(), @@ -86,6 +89,7 @@ Future buildTestStoreOverrides() async { CachedSessionsStore.open(), RecentPortsStore.open(), FirstRunStore.open(), + UpdateHandoffStore.open(), openAppSettingsPrefs(), ).wait; // Deterministic, NOT eagerly created: ProjectStatusCache.testInstance only @@ -108,6 +112,7 @@ Future buildTestStoreOverrides() async { cachedSessionsStoreProvider.overrideWithValue(cachedSessionsStore), recentPortsStoreProvider.overrideWithValue(recentPortsStore), firstRunStoreProvider.overrideWithValue(firstRunStore), + updateHandoffStoreProvider.overrideWithValue(updateHandoffStore), projectStatusCacheProvider.overrideWithValue( ProjectStatusCache.testInstance(root: statusCacheRoot), ), diff --git a/app/test/launcher/host_controller_test.dart b/app/test/launcher/host_controller_test.dart index d03e7075..261ce983 100644 --- a/app/test/launcher/host_controller_test.dart +++ b/app/test/launcher/host_controller_test.dart @@ -686,4 +686,58 @@ void main() { expect(cmd.preargs, isEmpty); }); }); + + group('spawn seal', () { + // A Windows Store install drains the host and then leaves the app fully + // interactive for the Store's whole window. Anything that spawns in that + // window hands the update a live PTY tree to force-kill. + HostController sealable({required void Function() onSpawn}) => + HostController( + readHost: () async => null, + pidAlive: (pid) async => false, + ping: (h) async => false, + devMode: () => false, + spawnHost: () async { + onSpawn(); + return _host(); + }, + ); + + test('a sealed controller refuses to spawn', () async { + var spawns = 0; + final c = sealable(onSpawn: () => spawns++); + c.sealSpawns(); + + await expectLater(c.ensureHost(), throwsA(isA())); + expect(spawns, 0); + }); + + test('unsealing restores spawning', () async { + var spawns = 0; + final c = sealable(onSpawn: () => spawns++); + c.sealSpawns(); + await expectLater(c.ensureHost(), throwsA(isA())); + + c.unsealSpawns(); + final h = await c.ensureHost(); + + expect(h.controlPort, 6000); + expect(spawns, 1); + }); + + test('sealing does not poison the single-flight slot', () async { + // The refusal returns before `_inFlight` is ever set, so a seal that + // rejects must not leave a dead future cached for the next caller. + var spawns = 0; + final c = sealable(onSpawn: () => spawns++); + c.sealSpawns(); + await expectLater(c.ensureHost(), throwsA(isA())); + c.unsealSpawns(); + + final results = await Future.wait([c.ensureHost(), c.ensureHost()]); + + expect(results, hasLength(2)); + expect(spawns, 1, reason: 'concurrent callers still share one spawn'); + }); + }); } diff --git a/app/test/launcher/host_teardown_test.dart b/app/test/launcher/host_teardown_test.dart new file mode 100644 index 00000000..a27f96e0 --- /dev/null +++ b/app/test/launcher/host_teardown_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:ui' show AppExitResponse; + +import 'package:antgrid/launcher/host_controller.dart'; +import 'package:antgrid/launcher/host_teardown.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Host extends HostController { + _Host(this._drain); + + final Future Function() _drain; + int drains = 0; + + @override + Future shutdownOwnedHost() { + drains++; + return _drain(); + } +} + +void main() { + group('HostTeardownObserver', () { + test('lets a quick drain finish before exiting', () async { + final host = _Host(() async {}); + final observer = HostTeardownObserver( + host: host, + budget: const Duration(seconds: 5), + ); + + expect(await observer.didRequestAppExit(), AppExitResponse.exit); + expect(host.drains, 1); + }); + + test('exits on the budget rather than waiting on a wedged host', () async { + // The regression this guards: on macOS Sparkle waits on the process + // before swapping the bundle, so an unbounded await here stalls the + // update behind the quit — not just the quit. + final host = _Host(() => Completer().future); + final observer = HostTeardownObserver( + host: host, + budget: const Duration(milliseconds: 50), + ); + + expect( + await observer.didRequestAppExit().timeout(const Duration(seconds: 2)), + AppExitResponse.exit, + ); + expect(host.drains, 1); + }); + + test('a throwing drain still exits', () async { + final host = _Host(() async => throw StateError('no host')); + final observer = HostTeardownObserver(host: host); + + expect(await observer.didRequestAppExit(), AppExitResponse.exit); + }); + }); +} diff --git a/app/test/launcher/terminate_tree_test.dart b/app/test/launcher/terminate_tree_test.dart new file mode 100644 index 00000000..565c1716 --- /dev/null +++ b/app/test/launcher/terminate_tree_test.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:antgrid/launcher/discovery.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Records every signal the escalation sends, in order, as `:`. +/// The sign of the pid is the whole point of these tests — a negative pid names +/// a process group, a positive one names a single process — so it is kept +/// verbatim rather than normalised. +class _Signals { + final List sent = []; + + /// [groupExists] models the only branch that matters: whether the host leads + /// a process group. It does not on the real spawn, which is why the fallback + /// is load-bearing rather than defensive. + bool Function(int, ProcessSignal) send({bool groupExists = false}) => + (pid, signal) { + sent.add('$pid:$signal'); + return pid > 0 || groupExists; + }; +} + +void main() { + group('terminateTreePosix', () { + test('SIGTERMs first so the bridge can sweep its own PTY trees', () async { + // The bridge traps SIGTERM and walks its terminals with killProcessTree, + // which is the only thing that reaches a PTY grandchild — SIGKILL cannot + // be trapped, so a straight kill leaves them behind. + final s = _Signals(); + var probes = 0; + await terminateTreePosix( + 4242, + send: s.send(), + alive: (_) async { + probes++; + return false; + }, + delay: (_) async {}, + ); + + expect(s.sent, ['-4242:SIGTERM', '4242:SIGTERM']); + expect(probes, 1, reason: 'a host that exits is not polled again'); + }); + + test('falls back to the bare pid when the host leads no group', () async { + // The real case: HostController spawns with ProcessStartMode.normal, so + // the host stays in the app's group and `-pid` is a reliable ESRCH. + // killPid REPORTS that as false rather than throwing, so a fallback keyed + // on exceptions alone would never signal the host at all. + final s = _Signals(); + await terminateTreePosix( + 7, + send: s.send(groupExists: false), + alive: (_) async => false, + delay: (_) async {}, + ); + + expect(s.sent, ['-7:SIGTERM', '7:SIGTERM']); + }); + + test('signals the group alone when one exists', () async { + final s = _Signals(); + await terminateTreePosix( + 7, + send: s.send(groupExists: true), + alive: (_) async => false, + delay: (_) async {}, + ); + + expect( + s.sent, + ['-7:SIGTERM'], + reason: 'a delivered group signal already reached the leader', + ); + }); + + test('escalates to SIGKILL when the grace elapses', () async { + final s = _Signals(); + var waited = Duration.zero; + await terminateTreePosix( + 99, + send: s.send(), + alive: (_) async => true, + delay: (d) async => waited += d, + grace: const Duration(milliseconds: 300), + ); + + expect(s.sent, [ + '-99:SIGTERM', + '99:SIGTERM', + '-99:SIGKILL', + '99:SIGKILL', + ]); + expect(waited, const Duration(milliseconds: 300)); + }); + + test('never escalates against a host that exited during the grace', () async { + // Escalation is not free: SIGKILL on a pid the OS may already have reused + // is a signal delivered to something else entirely. + final s = _Signals(); + var polls = 0; + await terminateTreePosix( + 5, + send: s.send(), + alive: (_) async => ++polls < 3, + delay: (_) async {}, + grace: const Duration(seconds: 5), + ); + + expect(s.sent, ['-5:SIGTERM', '5:SIGTERM']); + expect(polls, 3); + }); + }); +} diff --git a/app/test/models/handler_state_test.dart b/app/test/models/handler_state_test.dart index ae30f0fd..1f86c59f 100644 --- a/app/test/models/handler_state_test.dart +++ b/app/test/models/handler_state_test.dart @@ -273,5 +273,21 @@ void main() { expect(e.choices, isNull); expect(e.choiceById('approve'), isNull); }); + + test('a guard_blocked escalation never carries choices', () { + // The row exists BECAUSE a guard refused this exact text, so a one-tap + // would re-send it with the thinnest possible human in the loop. The + // bridge refuses to mint these; this is the app's own floor. + final e = HandlerEscalation.fromWire( + 't1', + escalationWire(choices: [approve, reject], kind: 'guard_blocked'), + )!; + expect(e.kind, 'guard_blocked'); + expect(e.choices, isNull); + expect(e.choiceById('approve'), isNull); + // Still answerable in the user's own words — the draft is what the reply + // sheet opens on. + expect(e.draftReply, isNotEmpty); + }); }); } diff --git a/app/test/models/session_entry_test.dart b/app/test/models/session_entry_test.dart index 263672e7..d5ffb82b 100644 --- a/app/test/models/session_entry_test.dart +++ b/app/test/models/session_entry_test.dart @@ -346,4 +346,32 @@ void main() { expect(entry.copyWith(setup: done).setup, done); }); }); + + test('parses and round-trips forkedFromSessionId', () { + final e = SessionEntry.fromJson({ + 'id': 'a', + 'name': 'n', + 'createdAt': 1, + 'lastUsedAt': 1, + 'archived': false, + 'running': false, + 'forkedFromSessionId': 'source-1', + }); + expect(e.forkedFromSessionId, 'source-1'); + expect(e.toJson()['forkedFromSessionId'], 'source-1'); + expect(e.copyWith(running: true).forkedFromSessionId, 'source-1'); + }); + + test('a session that is not a fork carries no provenance', () { + final e = SessionEntry.fromJson({ + 'id': 'a', + 'name': 'n', + 'createdAt': 1, + 'lastUsedAt': 1, + 'archived': false, + 'running': false, + }); + expect(e.forkedFromSessionId, isNull); + expect(e.toJson().containsKey('forkedFromSessionId'), isFalse); + }); } diff --git a/app/test/project/handler_classification_test.dart b/app/test/project/handler_classification_test.dart index 2703b32c..fa2cc47e 100644 --- a/app/test/project/handler_classification_test.dart +++ b/app/test/project/handler_classification_test.dart @@ -17,8 +17,9 @@ void main() { expect(classifyAbMessageByType('handler:snapshot'), MessageTier.heavy); }); - test('handler:configure and handler:undo are outbound only', () { + test('handler:configure, handler:undo and handler:dismiss are outbound only', () { expect(classifyAbMessageByType('handler:configure'), MessageTier.ignore); expect(classifyAbMessageByType('handler:undo'), MessageTier.ignore); + expect(classifyAbMessageByType('handler:dismiss'), MessageTier.ignore); }); } diff --git a/app/test/services/handler_service_outbound_test.dart b/app/test/services/handler_service_outbound_test.dart index 116c8659..7634b9bd 100644 --- a/app/test/services/handler_service_outbound_test.dart +++ b/app/test/services/handler_service_outbound_test.dart @@ -664,4 +664,153 @@ void main() { await svc.dispose(); await session.close(); }); + + group('guard-rejection reports (handler:dismiss)', () { + /// A `handler:status` snapshot replaying [escalations] on one armed session, + /// which is how a report reaches the app after a reconnect or a restart. + Map statusFrame(List> escalations) => { + 'projectId': 'p', + 'sessions': [ + { + 'terminalId': 't9', + 'notifyOnly': false, + 'state': escalations.isEmpty ? 'watching' : 'needs_you', + 'pendingEscalations': escalations.length, + 'armedAt': 1, + 'goal': 'ship it', + 'backlog': [], + 'escalations': escalations, + }, + ], + }; + + Map row({ + String escalationId = 'b1', + String? kind = 'guard_blocked', + int at = 1, + }) => { + 'escalationId': escalationId, + 'question': 'Handler did not send its reply', + 'reasoning': 'reply contains control characters', + 'draftReply': '/code-review --fix', + 'urgency': 'normal', + 'at': at, + 'kind': ?kind, + }; + + HandlerEscalation only(HandlerService svc, String id) => + svc.currentState.escalations.firstWhere((e) => e.escalationId == id); + + test('dismiss sends handler:dismiss and drops only that row', () async { + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = HandlerService.fromSession(session); + final sub = session.heavyStream.listen((_) {}); + + t.emit('handler:status', statusFrame([row(), row(escalationId: 'b2', at: 2)])); + await Future.delayed(Duration.zero); + expect(svc.currentState.escalations, hasLength(2)); + + svc.dismiss(only(svc, 'b1')); + + final sent = t.sent.firstWhere((m) => m['type'] == 'handler:dismiss'); + expect(sent['projectId'], 'p'); + expect(sent['terminalId'], 't9'); + expect(sent['escalationId'], 'b1'); + // A sibling report is a separate refusal the user has not read yet. + expect( + svc.currentState.escalations.map((e) => e.escalationId), + ['b2'], + ); + expect(svc.currentState.sessions['t9']!.pendingEscalations, 1); + + await sub.cancel(); + await svc.dispose(); + await session.close(); + }); + + test('dismiss on a reply or resolve_in_session row sends nothing', () async { + // The app-side mirror of the bridge's refusal: a live question dropped by + // a Dismiss would vanish more silently than any path that exists today. + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = HandlerService.fromSession(session); + final sub = session.heavyStream.listen((_) {}); + + t.emit('handler:status', statusFrame([ + row(escalationId: 'r1', kind: null), + row(escalationId: 'p1', kind: 'resolve_in_session', at: 2), + ])); + await Future.delayed(Duration.zero); + + svc.dismiss(only(svc, 'r1')); + svc.dismiss(only(svc, 'p1')); + expect(t.sent.any((m) => m['type'] == 'handler:dismiss'), isFalse); + expect(svc.currentState.escalations, hasLength(2)); + + await sub.cancel(); + await svc.dispose(); + await session.close(); + }); + + test('replying to a report sends the reply AND its dismiss, and leaves a sibling standing', + () async { + // The bridge cannot tell the user's line apart from an unrelated one — the + // whole reason the kind exists — so sending your own words has to carry + // the acknowledgement with it. + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = HandlerService.fromSession(session); + final sub = session.heavyStream.listen((_) {}); + + t.emit('handler:status', statusFrame([row(), row(escalationId: 'b2', at: 2)])); + await Future.delayed(Duration.zero); + + expect(svc.reply(only(svc, 'b1'), 'run the review yourself'), isTrue); + expect(t.sent.where((m) => m['type'] == 'terminal:input'), hasLength(1)); + final dismissed = t.sent.where((m) => m['type'] == 'handler:dismiss'); + expect(dismissed, hasLength(1)); + expect(dismissed.single['escalationId'], 'b1'); + expect(svc.currentState.escalations.map((e) => e.escalationId), ['b2']); + + await sub.cancel(); + await svc.dispose(); + await session.close(); + }); + + test('an unrelated reply on the same terminal leaves the reports standing', () async { + // The reported bug, app-side: the optimistic drop must mirror the bridge's + // clearing rule, or the row disappears locally and comes back on the next + // snapshot — or worse, reads as retired. + final t = FakeAgentTransport(); + final session = await _newSession(t); + final svc = HandlerService.fromSession(session); + final sub = session.heavyStream.listen((_) {}); + + t.emit('handler:status', statusFrame([ + row(), + row(escalationId: 'q1', kind: null, at: 2), + ])); + await Future.delayed(Duration.zero); + + expect(svc.reply(only(svc, 'q1'), 'never mind, do something else'), isTrue); + expect(t.sent.any((m) => m['type'] == 'handler:dismiss'), isFalse); + expect(svc.currentState.escalations.map((e) => e.escalationId), ['b1']); + expect(svc.currentState.sessions['t9']!.pendingEscalations, 1); + expect( + svc.currentState.sessions['t9']!.runState, + HandlerRunState.needsYou, + ); + + // …and the row is still there after the bridge replays it, which is what + // the answered-set suppression would have quietly undone. + t.emit('handler:status', statusFrame([row()])); + await Future.delayed(Duration.zero); + expect(svc.currentState.escalations.map((e) => e.escalationId), ['b1']); + + await sub.cancel(); + await svc.dispose(); + await session.close(); + }); + }); } diff --git a/app/test/storage/update_handoff_store_test.dart b/app/test/storage/update_handoff_store_test.dart new file mode 100644 index 00000000..91aa8f83 --- /dev/null +++ b/app/test/storage/update_handoff_store_test.dart @@ -0,0 +1,72 @@ +import 'package:antgrid/storage/update_handoff_store.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/prefs_test_mock.dart'; + +// Raw key: under `flutter test` the storage scope prefix is empty +// (see storage_scope.dart), so fixtures may seed the bare literal. +const _key = 'antgrid.update_handoff_version.v1'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('an ordinary launch has nothing to announce', () async { + useInMemoryPrefs(); + final store = await UpdateHandoffStore.open(); + expect(await store.consume('1.20677.101'), isNull); + }); + + test( + 'a mark older than the running build is the update that landed', + () async { + useInMemoryPrefs({_key: '1.20677.100'}); + final store = await UpdateHandoffStore.open(); + expect(await store.consume('1.20677.101'), '1.20677.100'); + }, + ); + + test( + 'the mark is consumed, so the announcement fires at most once', + () async { + useInMemoryPrefs({_key: '1.20677.100'}); + final store = await UpdateHandoffStore.open(); + await store.consume('1.20677.101'); + expect(await store.consume('1.20677.101'), isNull); + }, + ); + + test('a hand-off that replaced nothing announces nothing', () async { + // Windows relaunches with the same argument after a crash; an unchanged + // version is exactly how that case is told apart from a real update. + useInMemoryPrefs({_key: '1.20677.101'}); + final store = await UpdateHandoffStore.open(); + expect(await store.consume('1.20677.101'), isNull); + }); + + test( + 'a mark newer than the running build is a rollback, not an update', + () async { + // Trivial to reach on Linux, where installing an older AppImage is a file + // copy — announcing "updated to" the older build would be plainly wrong. + useInMemoryPrefs({_key: '1.20678.1'}); + final store = await UpdateHandoffStore.open(); + expect(await store.consume('1.20677.101'), isNull); + }, + ); + + test('an unparseable version still announces', () async { + // A local build reports `dev`. Erring towards the announcement is the + // cheaper mistake: swallowing a real update's is the expensive one. + useInMemoryPrefs({_key: 'dev'}); + final store = await UpdateHandoffStore.open(); + expect(await store.consume('1.20677.101'), 'dev'); + }); + + test('clear drops a mark for an install that never happened', () async { + useInMemoryPrefs(); + final store = await UpdateHandoffStore.open(); + await store.markHandoff('1.20677.100'); + await store.clear(); + expect(await store.consume('1.20677.101'), isNull); + }); +} diff --git a/app/test/update/update_gate_test.dart b/app/test/update/update_gate_test.dart index c98e64a6..2270866a 100644 --- a/app/test/update/update_gate_test.dart +++ b/app/test/update/update_gate_test.dart @@ -1,21 +1,37 @@ +import 'dart:async'; + import 'package:antgrid/design/widgets/ab_toast.dart'; import 'package:antgrid/providers/update_available.dart'; import 'package:antgrid/update/update_gate.dart'; +import 'package:antgrid/update/update_install_controller.dart'; import 'package:antgrid/update/update_strategy.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakeStrategy extends UpdateStrategy { - _FakeStrategy(this.outcome, {this.isActive = true}); + _FakeStrategy( + this.outcome, { + this.isActive = true, + this.note, + this.retracted, + }); final UpdateCheckOutcome outcome; final bool isActive; + final String? note; + final Stream? retracted; final List litArgs = []; int installs = 0; @override bool get active => isActive; + @override + String? get updatedNote => note; + + @override + Stream? get updateRetracted => retracted; + @override Future check({required bool rowAlreadyLit}) async { litArgs.add(rowAlreadyLit); @@ -23,18 +39,46 @@ class _FakeStrategy extends UpdateStrategy { } @override - Future install(BuildContext context) async { + Future install(BuildContext context) async { installs++; + return UpdateInstallResult.handedOff; + } +} + +/// Stands in for the real install sequence. Both toast actions must land here +/// rather than on the strategy: the confirm dialog, the host drain and the +/// debounce that stops a toast and the drawer row starting two installs all +/// live in the controller. +class _SpyController extends UpdateInstallController { + int starts = 0; + final List confirmArgs = []; + + /// Deliberately skips `super.build()`: the real one resolves + /// `hostControllerProvider`, reaching the process-global launcher singleton + /// and attaching an `unsealSpawns()` to this container's disposal. + @override + UpdateInstallState build() => const UpdateInstallIdle(); + + @override + Future start(BuildContext context, {bool confirm = true}) async { + starts++; + confirmArgs.add(confirm); } } -Future _pumpGate( +Future<({ProviderContainer container, _SpyController install})> _pumpGate( WidgetTester tester, UpdateStrategy? strategy, { bool preLit = false, + String? afterUpdate, }) async { + final spy = _SpyController(); final container = ProviderContainer( - overrides: [updateStrategyProvider.overrideWithValue(strategy)], + overrides: [ + updateStrategyProvider.overrideWithValue(strategy), + updateInstallControllerProvider.overrideWith(() => spy), + afterUpdateLaunchProvider.overrideWithValue(afterUpdate), + ], ); addTearDown(container.dispose); if (preLit) container.read(updateAvailableProvider.notifier).set(true); @@ -48,47 +92,61 @@ Future _pumpGate( // overlay insert it may trigger. await tester.pump(); await tester.pump(); - return container; + return (container: container, install: spy); } +/// The binding's own observer walk — the same list `SystemChannels.lifecycle` +/// drives — so this exercises the gate's registration, not just its handler. +void resume(WidgetTester tester) => + // ignore: invalid_use_of_protected_member + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + void main() { testWidgets('update-available lights the row and announces with a toast', ( tester, ) async { final strategy = _FakeStrategy(UpdateCheckOutcome.updateAvailable); - final container = await _pumpGate(tester, strategy); + final h = await _pumpGate(tester, strategy); - expect(container.read(updateAvailableProvider), isTrue); + expect(h.container.read(updateAvailableProvider), isTrue); expect(strategy.litArgs, [false]); expect(find.byType(AbToast), findsOneWidget); expect(find.text('Update available'), findsOneWidget); - // The toast's action routes to the same strategy install as the row. + // The toast's action runs the same install sequence as the drawer row. await tester.tap(find.text('Update')); await tester.pump(); - expect(strategy.installs, 1); + expect(h.install.starts, 1); + expect(strategy.installs, 0); await tester.pump(const Duration(seconds: 11)); // expire the toast timer }); - testWidgets('a quiet outcome lights the row without a toast', (tester) async { - // Windows' mandatory tier: the auto-launched Store dialog is already on - // screen, so the gate must not stack an announcement on top of it. + testWidgets('a quiet outcome installs itself, unasked but not undrained', ( + tester, + ) async { + // Windows' mandatory tier. Quiet means the gate asks nothing and toasts + // nothing — the Store's own dialog is the announcement — but the install + // still runs through the controller, which is what shuts the bridge down + // before the MSIX is replaced over it. final strategy = _FakeStrategy(UpdateCheckOutcome.updateAvailableQuiet); - final container = await _pumpGate(tester, strategy); + final h = await _pumpGate(tester, strategy); - expect(container.read(updateAvailableProvider), isTrue); + expect(h.container.read(updateAvailableProvider), isTrue); expect(find.byType(AbToast), findsNothing); + expect(h.install.starts, 1); + expect(h.install.confirmArgs, [false]); + expect(strategy.installs, 0, reason: 'the strategy must not self-install'); }); testWidgets('an already-lit row is not re-announced', (tester) async { final strategy = _FakeStrategy(UpdateCheckOutcome.updateAvailable); - final container = await _pumpGate(tester, strategy, preLit: true); + final h = await _pumpGate(tester, strategy, preLit: true); // Windows' optional tier keeps returning updateAvailable while lit — // the row must stay latched without a toast on every throttled check. expect(strategy.litArgs, [true]); - expect(container.read(updateAvailableProvider), isTrue); + expect(h.container.read(updateAvailableProvider), isTrue); expect(find.byType(AbToast), findsNothing); }); @@ -96,16 +154,17 @@ void main() { tester, ) async { final strategy = _FakeStrategy(UpdateCheckOutcome.restartReady); - final container = await _pumpGate(tester, strategy); + final h = await _pumpGate(tester, strategy); // A missed 30-second toast must leave the drawer row as a durable - // affordance; its tap runs the same install (completeFlexibleUpdate). - expect(container.read(updateAvailableProvider), isTrue); + // affordance; its tap runs the same install sequence. + expect(h.container.read(updateAvailableProvider), isTrue); expect(find.text('Update ready'), findsOneWidget); await tester.tap(find.text('Restart')); await tester.pump(); - expect(strategy.installs, 1); + expect(h.install.starts, 1); + expect(strategy.installs, 0); await tester.pump(const Duration(seconds: 31)); // expire the toast timer }); @@ -118,9 +177,9 @@ void main() { // without the latch guard the same toast re-appears for the whole // process lifetime over a row already offering 'Restart'. final strategy = _FakeStrategy(UpdateCheckOutcome.restartReady); - final container = await _pumpGate(tester, strategy, preLit: true); + final h = await _pumpGate(tester, strategy, preLit: true); - expect(container.read(updateAvailableProvider), isTrue); + expect(h.container.read(updateAvailableProvider), isTrue); expect(find.byType(AbToast), findsNothing); }); @@ -129,18 +188,120 @@ void main() { UpdateCheckOutcome.updateAvailable, isActive: false, ); - final container = await _pumpGate(tester, strategy); + final h = await _pumpGate(tester, strategy); expect(strategy.litArgs, isEmpty); - expect(container.read(updateAvailableProvider), isFalse); + expect(h.container.read(updateAvailableProvider), isFalse); expect(find.byType(AbToast), findsNothing); }); testWidgets('a platform with no strategy is an inert pass-through', ( tester, ) async { - final container = await _pumpGate(tester, null); - expect(container.read(updateAvailableProvider), isFalse); + final h = await _pumpGate(tester, null); + expect(h.container.read(updateAvailableProvider), isFalse); + expect(find.byType(AbToast), findsNothing); + }); + + testWidgets('a relaunch after an install accounts for the lost sessions', ( + tester, + ) async { + // The only trace of the update the user is left with: the sequence shut + // their agents down and nothing restores them, so the relaunch has to say + // so rather than come up looking like an ordinary start. + await _pumpGate( + tester, + _FakeStrategy( + UpdateCheckOutcome.none, + note: kUpdateStoppedSessionsNote, + ), + afterUpdate: '1.20677.100', + ); + + final toast = tester.widget(find.byType(AbToast)); + expect( + toast.description, + 'Replaced 1.20677.100. Open project sessions were stopped.', + ); + + await tester.pump(const Duration(seconds: 9)); // expire the toast timer + }); + + testWidgets('a platform that only opened a page claims no lost sessions', ( + tester, + ) async { + // Linux replaces an AppImage by hand: whatever stopped the user's sessions + // was their own quit, possibly days before this launch. + await _pumpGate( + tester, + _FakeStrategy(UpdateCheckOutcome.none), + afterUpdate: '1.20677.100', + ); + + final toast = tester.widget(find.byType(AbToast)); + expect(toast.description, 'Replaced 1.20677.100.'); + + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('the platform retracting an update puts the row out', ( + tester, + ) async { + // macOS reads the appcast itself, but Sparkle additionally honours + // minimumSystemVersion and the item channel — so it can refuse what our + // read advertised, and without this the row is an Update button that can + // never do anything for the rest of the process. + final retracted = StreamController.broadcast(); + addTearDown(retracted.close); + final h = await _pumpGate( + tester, + _FakeStrategy(UpdateCheckOutcome.updateAvailable, retracted: retracted.stream), + ); + expect(h.container.read(updateAvailableProvider), isTrue); + + retracted.add(null); + await tester.pump(); + + expect(h.container.read(updateAvailableProvider), isFalse); + await tester.pump(const Duration(seconds: 11)); + }); + + testWidgets('an ordinary launch announces nothing', (tester) async { + await _pumpGate(tester, _FakeStrategy(UpdateCheckOutcome.none)); expect(find.byType(AbToast), findsNothing); }); + + testWidgets('a resume inside the throttle window re-checks nothing', ( + tester, + ) async { + // The gate observes the lifecycle, but rapid background/foreground cycling + // must not hammer the update source — nor stack a second announcement of + // the update the first check already toasted. + final strategy = _FakeStrategy(UpdateCheckOutcome.updateAvailable); + await _pumpGate(tester, strategy); + expect(strategy.litArgs, [false]); + + resume(tester); + await tester.pump(); + await tester.pump(); + + expect(strategy.litArgs, [false]); + expect(find.byType(AbToast), findsOneWidget); + + await tester.pump(const Duration(seconds: 11)); // expire the toast timer + }); + + testWidgets('a resume after the gate is gone checks nothing', (tester) async { + // The observer is removed in dispose; a late lifecycle event must not + // reach a disposed ConsumerState's `ref`. + final strategy = _FakeStrategy(UpdateCheckOutcome.updateAvailable); + await _pumpGate(tester, strategy); + await tester.pump(const Duration(seconds: 11)); // expire the toast timer + + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + resume(tester); + await tester.pump(); + + expect(strategy.litArgs, [false]); + }); } diff --git a/app/test/update/update_install_controller_test.dart b/app/test/update/update_install_controller_test.dart new file mode 100644 index 00000000..9485999b --- /dev/null +++ b/app/test/update/update_install_controller_test.dart @@ -0,0 +1,677 @@ +import 'dart:async'; + +import 'package:antgrid/config/build_info.dart'; +import 'package:antgrid/design/widgets/ab_confirm_dialog.dart'; +import 'package:antgrid/launcher/host_controller.dart'; +import 'package:antgrid/launcher/host_discovery.dart' show HostFile; +import 'package:antgrid/design/widgets/ab_toast.dart'; +import 'package:antgrid/project/project_session_registry.dart'; +import 'package:antgrid/providers/control_plane.dart' + show hostControllerProvider; +import 'package:antgrid/providers/update_available.dart'; +import 'package:antgrid/storage/update_handoff_store.dart'; +import 'package:antgrid/update/update_install_controller.dart'; +import 'package:antgrid/update/update_strategy.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Both fakes append to one shared list, which is the only way to assert the +/// thing the sequence exists for: the host is drained BEFORE the platform is +/// handed the update. +class _FakeStrategy extends UpdateStrategy { + _FakeStrategy( + this.log, { + this.endsSession = true, + this.result = UpdateInstallResult.handedOff, + this.throwOnInstall = false, + this.version, + this.progress, + }); + + final List log; + final bool endsSession; + final UpdateInstallResult result; + final bool throwOnInstall; + final String? version; + final Stream? progress; + int installs = 0; + + @override + bool get active => true; + + @override + bool get installEndsSession => endsSession; + + @override + Stream? get installProgress => progress; + + @override + String? get pendingVersion => version; + + @override + Future check({required bool rowAlreadyLit}) async => + UpdateCheckOutcome.none; + + @override + Future install(BuildContext context) async { + installs++; + log.add('install'); + if (throwOnInstall) throw StateError('install blew up'); + return result; + } +} + +class _FakeHost extends HostController { + _FakeHost(this.log, {Future Function()? onDrain}) : _onDrain = onDrain; + + final List log; + final Future Function()? _onDrain; + int drains = 0; + int rearms = 0; + int seals = 0; + int unseals = 0; + + /// Seal/unseal deliberately stay OUT of [log]: the shared list pins the + /// drain-before-install ordering, and counters keep that assertion readable. + @override + void sealSpawns() { + seals++; + super.sealSpawns(); + } + + @override + void unsealSpawns() { + unseals++; + super.unsealSpawns(); + } + + @override + Future shutdownOwnedHost() async { + drains++; + log.add('drain'); + final hook = _onDrain; + if (hook != null) await hook(); + } + + @override + Future ensureHost() async { + rearms++; + log.add('rearm'); + throw StateError('no host in a widget test'); + } +} + +class _FakeHandoff implements UpdateHandoffSink { + final List marked = []; + int clears = 0; + + @override + Future markHandoff(String version) async => marked.add(version); + + @override + Future clear() async => clears++; +} + +class _Harness { + _Harness({ + required this.container, + required this.context, + required this.log, + required this.strategy, + required this.host, + required this.handoff, + }); + + final ProviderContainer container; + final BuildContext context; + final List log; + final _FakeStrategy strategy; + final _FakeHost host; + final _FakeHandoff handoff; + + UpdateInstallState get state => + container.read(updateInstallControllerProvider); + + Future start({bool confirm = true}) => container + .read(updateInstallControllerProvider.notifier) + .start(context, confirm: confirm); +} + +Future<_Harness> _pump( + WidgetTester tester, { + bool endsSession = true, + UpdateInstallResult result = UpdateInstallResult.handedOff, + bool throwOnInstall = false, + String? version, + Stream? progress, + Future Function()? onDrain, +}) async { + final log = []; + final strategy = _FakeStrategy( + log, + endsSession: endsSession, + result: result, + throwOnInstall: throwOnInstall, + version: version, + progress: progress, + ); + final host = _FakeHost(log, onDrain: onDrain); + final handoff = _FakeHandoff(); + final container = ProviderContainer( + overrides: [ + updateStrategyProvider.overrideWithValue(strategy), + hostControllerProvider.overrideWithValue(host), + updateHandoffStoreProvider.overrideWithValue(handoff), + ], + ); + addTearDown(container.dispose); + + late BuildContext captured; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + return _Harness( + container: container, + context: captured, + log: log, + strategy: strategy, + host: host, + handoff: handoff, + ); +} + +void main() { + testWidgets('confirms, then drains the host BEFORE handing the update over', ( + tester, + ) async { + // The whole point of the sequence: `didRequestAppExit` never fires on + // Windows, so if the bridge isn't shut down here it is killed by the job + // object as the Store replaces the package. + final h = await _pump(tester, version: '1.20677.173.0'); + unawaited(h.start()); + await tester.pumpAndSettle(); + + expect(find.text('Install update and restart?'), findsOneWidget); + expect(h.state, const UpdateInstallConfirming()); + expect(h.log, isEmpty); + + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.log, ['drain', 'install']); + expect(h.state, const UpdateInstallDone()); + }); + + testWidgets('declining touches neither the host nor the platform', ( + tester, + ) async { + final progress = StreamController.broadcast(); + addTearDown(progress.close); + final h = await _pump(tester, progress: progress.stream); + unawaited(h.start()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(h.log, isEmpty); + expect(h.host.drains, 0); + expect(h.strategy.installs, 0); + expect(progress.hasListener, isFalse); + expect(h.state, const UpdateInstallIdle()); + }); + + testWidgets('a not-installed outcome leaves the affordance retryable', ( + tester, + ) async { + // The Store collapses a declined consent dialog, a low-battery refusal and + // a download still in flight into one "not completed" bucket, so this must + // never lock the user out of trying again. + final h = await _pump(tester, result: UpdateInstallResult.notInstalled); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + // What `UpdateRow` treats as tappable is idle-or-failed. + expect( + h.state, + const UpdateInstallFailed(UpdateInstallResult.notInstalled), + ); + + unawaited(h.start()); + await tester.pumpAndSettle(); + expect(find.text('Install update and restart?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(h.strategy.installs, 1); + + await tester.pump(const Duration(seconds: 9)); // expire the toast timer + }); + + testWidgets('an unreachable install route surfaces as a failure state', ( + tester, + ) async { + final h = await _pump(tester, result: UpdateInstallResult.unavailable); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.state, const UpdateInstallFailed(UpdateInstallResult.unavailable)); + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('a strategy that throws is reported, not propagated', ( + tester, + ) async { + final h = await _pump(tester, throwOnInstall: true); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.state, const UpdateInstallFailed(UpdateInstallResult.unavailable)); + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('a drain that throws still hands the update over', ( + tester, + ) async { + // Best-effort by design: the user has already decided, so a wedged host + // must not be able to veto the update. + final h = await _pump( + tester, + onDrain: () async { + throw StateError('wedged host'); + }, + ); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.log, ['drain', 'install']); + expect(h.state, const UpdateInstallDone()); + }); + + testWidgets('a drain that hangs is timed out and the install proceeds', ( + tester, + ) async { + final blocked = Completer(); + final h = await _pump(tester, onDrain: () => blocked.future); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.log, ['drain']); + expect(h.state, const UpdateInstallWorking(0)); + + await tester.pump(const Duration(seconds: 9)); // trip the 8s ceiling + await tester.pump(); + + expect(h.log, ['drain', 'install']); + expect(h.state, const UpdateInstallDone()); + }); + + testWidgets('progress ticks drive the state and are dropped on success', ( + tester, + ) async { + final progress = StreamController.broadcast(); + addTearDown(progress.close); + final blocked = Completer(); + final h = await _pump( + tester, + progress: progress.stream, + onDrain: () => blocked.future, + ); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(progress.hasListener, isTrue); + progress.add(42); + await tester.pump(); + expect(h.state, const UpdateInstallWorking(42)); + + blocked.complete(); + await tester.pumpAndSettle(); + await tester.pump(); + + expect(h.state, const UpdateInstallDone()); + expect(progress.hasListener, isFalse); + }); + + testWidgets('the progress subscription is dropped on the failure path too', ( + tester, + ) async { + final progress = StreamController.broadcast(); + addTearDown(progress.close); + final h = await _pump( + tester, + result: UpdateInstallResult.notInstalled, + progress: progress.stream, + ); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + await tester.pump(); + + expect( + h.state, + const UpdateInstallFailed(UpdateInstallResult.notInstalled), + ); + expect(progress.hasListener, isFalse); + + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('the confirm body omits the session clause when none are open', ( + tester, + ) async { + final h = await _pump(tester, version: '1.20677.173.0'); + unawaited(h.start()); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Version 1.20677.173.0 installs over a closed app'), + findsOneWidget, + ); + expect(find.textContaining('will stop'), findsNothing); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('open projects are counted, and an unknown version is nameless', ( + tester, + ) async { + final h = await _pump(tester); + h.container + .read(projectSessionRegistryProvider.notifier) + .touch('p1', isLocal: true); + unawaited(h.start()); + await tester.pumpAndSettle(); + + expect( + find.textContaining('1 open project session will stop.'), + findsOneWidget, + ); + expect( + find.textContaining('This update installs over a closed app'), + findsOneWidget, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('a platform whose install ends nothing skips confirm and drain', ( + tester, + ) async { + // Linux opens a browser tab: there is nothing of ours to unwind and + // nothing worth a confirmation click. + final h = await _pump(tester, endsSession: false); + await h.start(); + await tester.pump(); + + expect(find.byType(AbConfirmDialog), findsNothing); + expect(h.host.drains, 0); + expect(h.log, ['install']); + // Idle, not Done: the user can close the browser tab without downloading, + // and Done is the one state the row refuses to leave. + expect(h.state, const UpdateInstallIdle()); + }); + + testWidgets('spawning is sealed before the drain, not after', (tester) async { + // Asserted from INSIDE the drain: sealing afterwards would leave open + // exactly the window the seal exists to close. + _FakeHost? host; + bool? sealedDuringDrain; + final h = await _pump( + tester, + // Runs after `host` is assigned below — the drain is several frames away. + onDrain: () async => sealedDuringDrain = host!.spawnSealed, + ); + host = h.host; + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(sealedDuringDrain, isTrue); + }); + + testWidgets('a hand-off leaves the seal on, so nothing respawns', ( + tester, + ) async { + // The process is dying; anything that spawned here would hand the Store a + // live PTY tree to force-kill. + final h = await _pump(tester); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.host.seals, 1); + expect(h.host.unseals, 0); + expect(h.host.spawnSealed, isTrue); + }); + + testWidgets('an install that did not happen lifts the seal', (tester) async { + // Otherwise a declined consent dialog leaves the machine unable to start + // any agent until the app is restarted. + final h = await _pump(tester, result: UpdateInstallResult.notInstalled); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.host.spawnSealed, isFalse); + expect(h.host.rearms, 1); + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('a platform that ends nothing never seals', (tester) async { + final h = await _pump(tester, endsSession: false); + await h.start(); + await tester.pump(); + + expect(h.host.seals, 0); + expect(h.host.spawnSealed, isFalse); + }); + + testWidgets('a hand-off records the build being replaced', (tester) async { + // `--after-update` alone cannot prove an update happened: Windows relaunches + // with the same argument after a crash. The recorded version is the proof. + final h = await _pump(tester); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.handoff.marked, [BuildInfo.version]); + expect(h.handoff.clears, 0); + }); + + testWidgets('an install that did not happen clears the record', ( + tester, + ) async { + // Left behind, it would be found by the next crash relaunch and announced + // as an update the user never got. + final h = await _pump(tester, result: UpdateInstallResult.notInstalled); + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.handoff.marked, [BuildInfo.version]); + expect(h.handoff.clears, 1); + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('a platform that ends nothing records on the way OUT', ( + tester, + ) async { + // Sparkle and the browser both return promptly, so the mark can wait for + // proof the hand-off happened — which is why this path needs no clear. + final h = await _pump(tester, endsSession: false); + await h.start(); + await tester.pump(); + + expect(h.handoff.marked, [BuildInfo.version]); + expect(h.handoff.clears, 0); + }); + + testWidgets('a hand-off that never happened records nothing', (tester) async { + // The asymmetry pays for itself here: nothing was marked, so nothing has + // to be unmarked, and no later launch can find a stale mark to misread. + final h = await _pump( + tester, + endsSession: false, + result: UpdateInstallResult.unavailable, + ); + await h.start(); + await tester.pump(); + + expect(h.handoff.marked, isEmpty); + expect(h.handoff.clears, 0); + + await tester.pump(const Duration(seconds: 9)); // expire the failure toast + }); + + testWidgets('a browser hand-off can be repeated', (tester) async { + final h = await _pump(tester, endsSession: false); + await h.start(); + await tester.pump(); + await h.start(); + await tester.pump(); + + expect(h.strategy.installs, 2); + }); + + testWidgets('a second entry while the confirm is up is dropped', ( + tester, + ) async { + final h = await _pump(tester); + unawaited(h.start()); + await tester.pumpAndSettle(); + unawaited(h.start()); + await tester.pumpAndSettle(); + + expect(find.text('Install update and restart?'), findsOneWidget); + + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + expect(h.host.drains, 1); + expect(h.strategy.installs, 1); + }); + + testWidgets('a platform-initiated install skips the dialog, not the drain', ( + tester, + ) async { + final h = await _pump(tester); + await h.start(confirm: false); + await tester.pumpAndSettle(); + + // The Windows mandatory tier: the user is not being asked, but the bridge + // is still shut down before the MSIX is replaced over it. + expect(find.byType(AbConfirmDialog), findsNothing); + expect(h.log, ['drain', 'install']); + expect(h.state, const UpdateInstallDone()); + }); + + testWidgets( + 'an update that turns out not to be pending stops offering itself', + (tester) async { + final h = await _pump(tester, result: UpdateInstallResult.nothingPending); + h.container.read(updateAvailableProvider.notifier).set(true); + + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + // The Store answering "nothing pending" is the only evidence that outranks + // the check that lit the row; leaving it lit offers an install that can + // now only ever repeat this toast. + expect(h.container.read(updateAvailableProvider), isFalse); + expect(h.state, const UpdateInstallIdle()); + expect(find.text('Already up to date'), findsOneWidget); + + await tester.pump(const Duration(seconds: 9)); + }, + ); + + testWidgets('a failed attempt does not leave the row lit-but-unbacked', ( + tester, + ) async { + final h = await _pump(tester, result: UpdateInstallResult.unavailable); + h.container.read(updateAvailableProvider.notifier).set(true); + + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + // Only the platform's own "nothing pending" un-lights it — a transient + // failure must not hide an update that is still waiting. + expect(h.container.read(updateAvailableProvider), isTrue); + + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('a drain that bought nothing is undone again', (tester) async { + final h = await _pump(tester, result: UpdateInstallResult.notInstalled); + + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + // shutdownOwnedHost cancels supervised respawn, so without this the user + // is left on a dead bridge with no banner and nothing to bring it back. + expect(h.log, ['drain', 'install', 'rearm']); + expect( + h.state, + const UpdateInstallFailed(UpdateInstallResult.notInstalled), + ); + // The toast has to own up to what the abandoned attempt already cost. + final toast = tester.widget(find.byType(AbToast)); + expect(toast.title, 'Update not installed'); + expect(toast.description, contains('Project sessions were stopped')); + + await tester.pump(const Duration(seconds: 9)); + }); + + testWidgets('a real hand-off never re-arms the host', (tester) async { + final h = await _pump(tester); + + unawaited(h.start()); + await tester.pumpAndSettle(); + await tester.tap(find.text('Install & restart')); + await tester.pumpAndSettle(); + + // The process is going away; spawning a bridge into the Store's window is + // the exact tree the update must not find alive. + expect(h.host.rearms, 0); + expect(h.log, ['drain', 'install']); + }); +} diff --git a/app/test/update/update_strategy_test.dart b/app/test/update/update_strategy_test.dart index 9e7f65f6..993c8edf 100644 --- a/app/test/update/update_strategy_test.dart +++ b/app/test/update/update_strategy_test.dart @@ -1,28 +1,55 @@ +import 'dart:async'; + import 'package:antgrid/update/github_release_update_service.dart'; import 'package:antgrid/update/in_app_update_service.dart'; import 'package:antgrid/update/ios_app_store_update_service.dart'; import 'package:antgrid/update/macos_appcast_update_service.dart'; +import 'package:antgrid/update/macos_sparkle_update_service.dart'; import 'package:antgrid/update/update_strategy.dart'; import 'package:antgrid/update/windows_store_update_service.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; class _FakeStore extends WindowsStoreUpdateService { - _FakeStore(this.reply); - final StoreUpdateCheck reply; + _FakeStore( + this.reply, { + this.version, + this.outcome = StoreInstallOutcome.completed, + }); + + /// Mutable so a suite can let an optional update escalate to mandatory + /// between two checks — the case the optional tier keeps checking for. + StoreUpdateCheck reply; + final String? version; + final StoreInstallOutcome outcome; int checks = 0; int installs = 0; + /// Overridden because the real getter has a side effect: it installs a + /// PROCESS-GLOBAL handler on the live `antgrid/store_update` channel and + /// latches a static that no suite resets, so one unmocked read leaks into + /// every test that runs after it. @override - Future checkForUpdates() async { + Stream get downloadProgress => _stream; + final StreamController progress = StreamController.broadcast(); + // Cached: `StreamController.stream` hands back a fresh wrapper per read. + late final Stream _stream = progress.stream; + + @override + Future checkForUpdates() async { checks++; - return reply; + return reply == StoreUpdateCheck.none + ? StoreUpdateStatus.none + : StoreUpdateStatus(check: reply, version: version); } @override - Future requestDownloadAndInstall() async { + Future requestDownloadAndInstall() async { installs++; + return outcome; } } @@ -50,11 +77,25 @@ class _FakeAppcast extends MacosAppcastUpdateService { } } +class _FakeSparkle extends MacosSparkleUpdateService { + final List calls = []; + + @override + Future configureFeed() async => calls.add('configureFeed'); + + @override + Future startUpdate() async => calls.add('startUpdate'); +} + class _FakeAppStore extends IosAppStoreUpdateService { - _FakeAppStore(this.result); + _FakeAppStore(this.result, {this.url}); final bool result; + final String? url; int calls = 0; + @override + String? get listingUrl => url; + @override Future isUpdateAvailable() async { calls++; @@ -63,14 +104,60 @@ class _FakeAppStore extends IosAppStoreUpdateService { } class _FakePlay extends InAppUpdateService { - const _FakePlay(this.decision); + _FakePlay(this.decision); final UpdateDecision decision; + int completes = 0; @override Future checkAndStart() async => decision; + + @override + Future completeFlexibleUpdate() async { + completes++; + } } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // The browser/App Store hand-offs go through url_launcher, whose default + // platform implementation is the method channel — unregistered under + // `flutter test`, so without this their install() dead-ends in the + // could-not-open SnackBar instead of the path being asserted. + List mockUrlLauncher() { + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final launched = []; + messenger.setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'launch': + launched.add((call.arguments as Map)['url'] as String); + return true; + case 'canLaunch': + return true; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + return launched; + } + + Future pumpContext(WidgetTester tester) async { + late BuildContext captured; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + captured = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + return captured; + } + test('the provider table covers every platform with an update path', () { // The provider is THE per-platform matrix — UpdateGate and UpdateRow // both resolve it, so a strategy existing here proves the platform @@ -102,12 +189,21 @@ void main() { await s.check(rowAlreadyLit: false), UpdateCheckOutcome.updateAvailableQuiet, ); - expect(store.installs, 1); + // The strategy REPORTS; the gate installs. Starting the Store here + // would skip the bridge drain the whole sequence exists to perform. + expect(store.installs, 0); // Latch spent: no further Store round-trips, no dialog re-pop. expect(await s.check(rowAlreadyLit: true), UpdateCheckOutcome.none); expect(store.checks, 1); - expect(store.installs, 1); + expect(store.installs, 0); + + // A third check must stay just as quiet — the latch is what stops the + // system dialog re-popping on every ≥30-min refocus for the whole + // process lifetime, not only on the check straight after it. + expect(await s.check(rowAlreadyLit: false), UpdateCheckOutcome.none); + expect(store.checks, 1); + expect(store.installs, 0); }, ); @@ -130,6 +226,130 @@ void main() { expect(store.installs, 0); }, ); + + test( + 'an escalation to mandatory auto-launches on the later check', + () async { + final store = _FakeStore(StoreUpdateCheck.optional); + final s = WindowsStoreStrategy(service: store); + + expect( + await s.check(rowAlreadyLit: false), + UpdateCheckOutcome.updateAvailable, + ); + expect(store.installs, 0); + + store.reply = StoreUpdateCheck.mandatory; + expect( + await s.check(rowAlreadyLit: true), + UpdateCheckOutcome.updateAvailableQuiet, + ); + expect(store.installs, 0); + }, + ); + + test('row copy names the restart the tap performs', () { + final s = WindowsStoreStrategy( + service: _FakeStore(StoreUpdateCheck.none), + ); + expect(s.rowTitle, 'Update available'); + // The tap CLOSES the app; 'Update' would hide the part of it the user + // cannot take back. + expect(s.rowActionLabel, 'Install & restart'); + }); + + test('install ends the session and reports download progress', () { + // Both are Windows-only; every other strategy's own test pins the + // opposite, which is what keeps the controller from showing a + // quit-and-drain confirmation on a platform that neither quits nor + // drains. + expect( + WindowsStoreStrategy( + service: _FakeStore(StoreUpdateCheck.none), + ).installEndsSession, + isTrue, + ); + final store = _FakeStore(StoreUpdateCheck.none); + addTearDown(store.progress.close); + // Identity, not isNotNull: the point is that the strategy forwards the + // SERVICE's stream, which a non-null stream that never emits satisfies + // just as well. + expect( + WindowsStoreStrategy(service: store).installProgress, + same(store.downloadProgress), + ); + }); + + test('pendingVersion is only what the last check actually saw', () async { + final withVersion = WindowsStoreStrategy( + service: _FakeStore( + StoreUpdateCheck.optional, + version: '1.20677.173.0', + ), + ); + expect(withVersion.pendingVersion, isNull, reason: 'no check yet'); + await withVersion.check(rowAlreadyLit: false); + expect(withVersion.pendingVersion, '1.20677.173.0'); + + // Unknown is common (the Store reports "" for it) and must stay null so + // copy that names the version falls back rather than naming nothing. + final nameless = WindowsStoreStrategy( + service: _FakeStore(StoreUpdateCheck.optional), + ); + await nameless.check(rowAlreadyLit: false); + expect(nameless.pendingVersion, isNull); + + // A later check that finds nothing must forget the name too, or the + // confirm dialog offers to install a version that is no longer pending. + final store = _FakeStore( + StoreUpdateCheck.optional, + version: '1.20677.173.0', + ); + final cleared = WindowsStoreStrategy(service: store); + await cleared.check(rowAlreadyLit: false); + expect(cleared.pendingVersion, '1.20677.173.0'); + store.reply = StoreUpdateCheck.none; + expect(await cleared.check(rowAlreadyLit: true), UpdateCheckOutcome.none); + expect(cleared.pendingVersion, isNull); + }); + + testWidgets('every Store outcome maps to its install result', ( + tester, + ) async { + final context = await pumpContext(tester); + const cases = <(StoreInstallOutcome, UpdateInstallResult)>[ + (StoreInstallOutcome.completed, UpdateInstallResult.handedOff), + (StoreInstallOutcome.cancelled, UpdateInstallResult.notInstalled), + (StoreInstallOutcome.none, UpdateInstallResult.nothingPending), + (StoreInstallOutcome.unavailable, UpdateInstallResult.unavailable), + ]; + for (final (outcome, expected) in cases) { + final store = _FakeStore(StoreUpdateCheck.optional, outcome: outcome); + expect( + await WindowsStoreStrategy(service: store).install(context), + expected, + reason: '$outcome', + ); + expect(store.installs, 1); + } + }); + + testWidgets('a refused install leaves the row tappable again', ( + tester, + ) async { + // 'cancelled' is the Store's whole not-installed bucket (a declined + // dialog, a Wi-Fi refusal, a download still in flight), so a repeat tap + // must reach the Store again rather than being latched off. + final context = await pumpContext(tester); + final store = _FakeStore( + StoreUpdateCheck.optional, + outcome: StoreInstallOutcome.cancelled, + ); + final s = WindowsStoreStrategy(service: store); + expect(await s.install(context), UpdateInstallResult.notInstalled); + expect(await s.install(context), UpdateInstallResult.notInstalled); + expect(store.installs, 2); + }); }); group('LinuxBrowserStrategy', () { @@ -147,6 +367,21 @@ void main() { UpdateCheckOutcome.updateAvailable, ); }); + + testWidgets('install opens the releases page and hands off', ( + tester, + ) async { + final launched = mockUrlLauncher(); + final context = await pumpContext(tester); + final s = LinuxBrowserStrategy(releases: _FakeReleases(true)); + expect(await s.install(context), UpdateInstallResult.handedOff); + expect(launched, [GithubReleaseUpdateService.latestDownloadPageUrl]); + expect(s.installEndsSession, isFalse); + expect(s.installProgress, isNull); + expect(s.pendingVersion, isNull); + expect(s.rowTitle, 'Update available'); + expect(s.rowActionLabel, 'Update'); + }); }); group('MacosSparkleStrategy', () { @@ -173,6 +408,27 @@ void main() { final s = MacosSparkleStrategy(appcast: _FakeAppcast(false)); expect(await s.check(rowAlreadyLit: false), UpdateCheckOutcome.none); }); + + testWidgets('install re-asserts the feed before opening Sparkle', ( + tester, + ) async { + // Order is the point: prepare() is fire-and-forget at startup and + // swallows failures, and a feed-less Sparkle errors silently on every + // startUpdate. + final context = await pumpContext(tester); + final sparkle = _FakeSparkle(); + final s = MacosSparkleStrategy( + sparkle: sparkle, + appcast: _FakeAppcast(true), + ); + expect(await s.install(context), UpdateInstallResult.handedOff); + expect(sparkle.calls, ['configureFeed', 'startUpdate']); + expect(s.installEndsSession, isFalse); + expect(s.installProgress, isNull); + expect(s.pendingVersion, isNull); + expect(s.rowTitle, 'Update available'); + expect(s.rowActionLabel, 'Update'); + }); }); group('IosAppStoreStrategy', () { @@ -190,13 +446,28 @@ void main() { UpdateCheckOutcome.updateAvailable, ); }); + + testWidgets('install opens the cached listing and hands off', ( + tester, + ) async { + final launched = mockUrlLauncher(); + final context = await pumpContext(tester); + final s = IosAppStoreStrategy( + service: _FakeAppStore(true, url: 'https://apps.apple.com/app/id123'), + ); + expect(await s.install(context), UpdateInstallResult.handedOff); + expect(launched, ['https://apps.apple.com/app/id123']); + expect(s.installEndsSession, isFalse); + expect(s.installProgress, isNull); + expect(s.pendingVersion, isNull); + expect(s.rowTitle, 'Update available'); + expect(s.rowActionLabel, 'Update'); + }); }); group('PlayUpdateStrategy', () { test('row copy promises the restart its install performs', () { - final s = PlayUpdateStrategy( - service: const _FakePlay(UpdateDecision.none), - ); + final s = PlayUpdateStrategy(service: _FakePlay(UpdateDecision.none)); // completeFlexibleUpdate restarts the app in place — the generic // 'Update available / Update' copy would promise less than the tap does. expect(s.rowTitle, 'Update ready'); @@ -208,17 +479,32 @@ void main() { () async { expect( await PlayUpdateStrategy( - service: const _FakePlay(UpdateDecision.flexibleReady), + service: _FakePlay(UpdateDecision.flexibleReady), ).check(rowAlreadyLit: false), UpdateCheckOutcome.restartReady, ); expect( await PlayUpdateStrategy( - service: const _FakePlay(UpdateDecision.none), + service: _FakePlay(UpdateDecision.none), ).check(rowAlreadyLit: false), UpdateCheckOutcome.none, ); }, ); + + testWidgets('install completes the flexible update and hands off', ( + tester, + ) async { + final context = await pumpContext(tester); + final play = _FakePlay(UpdateDecision.flexibleReady); + final s = PlayUpdateStrategy(service: play); + expect(await s.install(context), UpdateInstallResult.handedOff); + expect(play.completes, 1); + // Play restarts the app in place with nothing of ours to unwind, so the + // confirm-and-drain path the Windows tap takes must not reach here. + expect(s.installEndsSession, isFalse); + expect(s.installProgress, isNull); + expect(s.pendingVersion, isNull); + }); }); } diff --git a/app/test/update/windows_store_update_decision_test.dart b/app/test/update/windows_store_update_decision_test.dart index 60261cf4..adaa60c9 100644 --- a/app/test/update/windows_store_update_decision_test.dart +++ b/app/test/update/windows_store_update_decision_test.dart @@ -1,10 +1,45 @@ import 'package:antgrid/update/windows_store_update_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -// Exercises the pure reply→decision mapping only. The runner's method channel -// (windows/runner/store_update_channel.cpp) does not run under `flutter test`, -// and StoreContext itself needs an MSIX-packaged Store install to exercise. +// The pure reply→decision mappings, plus the thin service wrapper driven over +// a MOCKED `antgrid/store_update` channel. The runner's native side +// (windows/runner/store_update_channel.cpp) does not run under `flutter test` +// and StoreContext itself needs an MSIX-packaged Store install, so the mock +// standing in for it is the closest this suite can get to the real channel — +// which makes the strings it exchanges the contract worth pinning. void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('antgrid/store_update'); + const codec = StandardMethodCodec(); + const service = WindowsStoreUpdateService(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + // Answers the next outbound call on the channel, cleared at test end. + void mockChannel(Future? Function(MethodCall call) handler) { + messenger.setMockMethodCallHandler(channel, handler); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + } + + // Delivers an INBOUND call, the direction the native progress callback + // uses; the arguments value is the whole method-call argument, as on the wire. + Future emitNative(String method, Object? arguments) async { + await messenger.handlePlatformMessage( + channel.name, + codec.encodeMethodCall(MethodCall(method, arguments)), + null, + ); + await pumpEventQueue(); + } + + void onWindows() { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + } + group('decideStoreUpdate', () { test('null reply (channel error path) → none', () { expect(decideStoreUpdate(null), StoreUpdateCheck.none); @@ -52,5 +87,276 @@ void main() { StoreUpdateCheck.optional, ); }); + + test('an unmodelled key alongside the known ones is tolerated', () { + // The native reply grows fields over time (`version` already did); one + // the Dart side does not model must not turn into "no update". + expect( + decideStoreUpdate({ + 'updateCount': 1, + 'mandatory': false, + 'version': '1.20677.173.0', + 'somethingNew': 42, + }), + StoreUpdateCheck.optional, + ); + }); + }); + + group('storeUpdateVersion', () { + test('a four-part Store version comes back verbatim', () { + expect(storeUpdateVersion({'version': '1.20677.173.0'}), '1.20677.173.0'); + }); + + test('surrounding whitespace is trimmed', () { + expect(storeUpdateVersion({'version': ' 1.2.3 '}), '1.2.3'); + }); + + test('null reply → null', () { + expect(storeUpdateVersion(null), isNull); + }); + + test('absent key → null', () { + expect(storeUpdateVersion({'updateCount': 1}), isNull); + }); + + test('the empty string the native side sends for "unknown" → null', () { + // "" is the contract's placeholder, not a version — rendering it would + // leave an empty name in copy that promises one. + expect(storeUpdateVersion({'version': ''}), isNull); + }); + + test('a non-string value → null, not a crash', () { + expect(storeUpdateVersion({'version': 12}), isNull); + }); + + test('anything that is not dotted decimals → null', () { + for (final raw in ['v1.2.3', '1.2.3-beta', 'unknown', '1..2', '.1']) { + expect(storeUpdateVersion({'version': raw}), isNull, reason: raw); + } + }); + }); + + group('decodeStoreInstallOutcome', () { + test('each contract string maps to its outcome', () { + expect( + decodeStoreInstallOutcome('completed'), + StoreInstallOutcome.completed, + ); + expect( + decodeStoreInstallOutcome('cancelled'), + StoreInstallOutcome.cancelled, + ); + expect(decodeStoreInstallOutcome('none'), StoreInstallOutcome.none); + }); + + test('anything outside the contract → null, never a throw', () { + for (final raw in [null, '', 'failed', 'Completed', 3, true]) { + expect(decodeStoreInstallOutcome(raw), isNull, reason: '$raw'); + } + }); + }); + + group('WindowsStoreUpdateService.requestDownloadAndInstall', () { + test( + 'each native success string reaches the caller as its outcome', + () async { + onWindows(); + for (final (reply, expected) in const <(String, StoreInstallOutcome)>[ + ('completed', StoreInstallOutcome.completed), + ('cancelled', StoreInstallOutcome.cancelled), + ('none', StoreInstallOutcome.none), + ]) { + mockChannel((call) async { + expect(call.method, 'requestDownloadAndInstall'); + return reply; + }); + expect(await service.requestDownloadAndInstall(), expected); + } + }, + ); + + test( + 'an unrecognised string degrades to unavailable, not a throw', + () async { + onWindows(); + mockChannel((_) async => 'exploded'); + expect( + await service.requestDownloadAndInstall(), + StoreInstallOutcome.unavailable, + ); + }, + ); + + test('a null reply degrades to unavailable', () async { + onWindows(); + mockChannel((_) async => null); + expect( + await service.requestDownloadAndInstall(), + StoreInstallOutcome.unavailable, + ); + }); + + test('store_unavailable (no MSIX identity) → unavailable', () async { + onWindows(); + mockChannel( + (_) async => throw PlatformException( + code: 'store_unavailable', + message: 'StoreContext requires package identity', + ), + ); + expect( + await service.requestDownloadAndInstall(), + StoreInstallOutcome.unavailable, + ); + }); + + test('a missing native implementation → unavailable', () async { + onWindows(); + mockChannel((_) async => throw MissingPluginException()); + expect( + await service.requestDownloadAndInstall(), + StoreInstallOutcome.unavailable, + ); + }); + + test('off Windows it answers unavailable without a channel call', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var calls = 0; + mockChannel((_) async { + calls++; + return 'completed'; + }); + expect( + await service.requestDownloadAndInstall(), + StoreInstallOutcome.unavailable, + ); + expect(calls, 0); + }); + }); + + group('WindowsStoreUpdateService.checkForUpdates', () { + test('a pending update carries its version through', () async { + onWindows(); + mockChannel((call) async { + expect(call.method, 'checkForUpdates'); + return { + 'updateCount': 1, + 'mandatory': true, + 'version': '1.20677.173.0', + }; + }); + expect( + await service.checkForUpdates(), + const StoreUpdateStatus( + check: StoreUpdateCheck.mandatory, + version: '1.20677.173.0', + ), + ); + }); + + test('an unusable version degrades to null, keeping the check', () async { + onWindows(); + mockChannel( + (_) async => {'updateCount': 1, 'version': ''}, + ); + final status = await service.checkForUpdates(); + expect(status.check, StoreUpdateCheck.optional); + expect(status.version, isNull); + }); + + test('a version on a no-update reply is not carried', () async { + onWindows(); + mockChannel( + (_) async => {'updateCount': 0, 'version': '1.2.3.4'}, + ); + expect(await service.checkForUpdates(), StoreUpdateStatus.none); + }); + + test('a channel failure resolves to none, never a throw', () async { + onWindows(); + mockChannel( + (_) async => throw PlatformException(code: 'store_unavailable'), + ); + expect(await service.checkForUpdates(), StoreUpdateStatus.none); + }); + + test('off Windows it answers none without a channel call', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var calls = 0; + mockChannel((_) async { + calls++; + return {'updateCount': 1}; + }); + expect(await service.checkForUpdates(), StoreUpdateStatus.none); + expect(calls, 0); + }); + }); + + group('WindowsStoreUpdateService.downloadProgress', () { + test('native ticks arrive in order, as whole percent', () async { + final seen = []; + final sub = service.downloadProgress.listen(seen.add); + addTearDown(sub.cancel); + + await emitNative('downloadProgress', 0); + await emitNative('downloadProgress', 37); + await emitNative('downloadProgress', 100); + + expect(seen, [0, 37, 100]); + }); + + test('a tick with nobody listening is dropped, not buffered', () async { + // Unbuffered by design: the two consent dialogs are on screen for the + // first ticks, and a late listener replaying them would paint a progress + // rule that jumps backwards. + await emitNative('downloadProgress', 11); + + final seen = []; + final sub = service.downloadProgress.listen(seen.add); + addTearDown(sub.cancel); + await pumpEventQueue(); + expect(seen, isEmpty); + + await emitNative('downloadProgress', 22); + expect(seen, [22]); + }); + + test('a malformed payload is ignored and the stream stays live', () async { + final seen = []; + final sub = service.downloadProgress.listen(seen.add); + addTearDown(sub.cancel); + + await emitNative('downloadProgress', 'half'); + await emitNative('downloadProgress', {'percent': 50}); + await emitNative('downloadProgress', null); + await emitNative('downloadProgress', double.nan); + await emitNative('downloadProgress', double.infinity); + expect(seen, isEmpty); + + await emitNative('downloadProgress', 55); + expect(seen, [55]); + }); + + test('an inbound call for another method is ignored', () async { + final seen = []; + final sub = service.downloadProgress.listen(seen.add); + addTearDown(sub.cancel); + + await emitNative('somethingElse', 99); + expect(seen, isEmpty); + }); + + test('an out-of-range tick is clamped rather than dropped', () async { + final seen = []; + final sub = service.downloadProgress.listen(seen.add); + addTearDown(sub.cancel); + + await emitNative('downloadProgress', -5); + await emitNative('downloadProgress', 137); + expect(seen, [0, 100]); + }); }); } diff --git a/app/test/widgets/handler/handler_pa_bar_test.dart b/app/test/widgets/handler/handler_pa_bar_test.dart index a4ec2934..521a16c1 100644 --- a/app/test/widgets/handler/handler_pa_bar_test.dart +++ b/app/test/widgets/handler/handler_pa_bar_test.dart @@ -41,7 +41,8 @@ HandlerInstructionItem _item(String id, String text, String status) => HandlerInstructionItem(id: id, text: text, status: status, createdAt: 1); /// [kind] null is the free-text row; 'resolve_in_session' is the option-based -/// prompt only the transcript can resolve. +/// prompt only the transcript can resolve; 'guard_blocked' is the report of an +/// action Handler could not take, which only its card's Dismiss retires. HandlerEscalation _escalation(String id, {String? kind}) => HandlerEscalation( escalationId: id, terminalId: 't1', @@ -424,6 +425,52 @@ void main() { ); }); + test('a report is not counted among the questions a message clears', () { + // onUserReply keeps a guard_blocked row standing, so counting it would + // promise clearing the bridge refuses to do. + expect( + handlerTypingHint( + _armed( + runState: HandlerRunState.needsYou, + escalations: [ + _escalation('e1'), + _escalation('b1', kind: 'guard_blocked'), + ], + ), + ), + 'Your next message clears this question, answered or not', + ); + }); + + test('a session standing only on reports warns about nothing', () { + // needs_you with nothing a typed line would clear: the bar has no promise + // to make, and the report goes away through its own Dismiss. + expect( + handlerTypingHint( + _armed( + runState: HandlerRunState.needsYou, + escalations: [_escalation('b1', kind: 'guard_blocked')], + ), + ), + isNull, + ); + }); + + test('a report beside a prompt shrinks neither count wrongly', () { + expect( + handlerTypingHint( + _armed( + runState: HandlerRunState.needsYou, + escalations: [ + _escalation('e1', kind: 'resolve_in_session'), + _escalation('b1', kind: 'guard_blocked'), + ], + ), + ), + 'Answer the prompt in the transcript — not here', + ); + }); + test('handling is the one state with a second writer on the session', () { expect( handlerTypingHint(_armed(runState: HandlerRunState.handling)), diff --git a/app/test/widgets/handler/handler_screen_test.dart b/app/test/widgets/handler/handler_screen_test.dart index 5021a861..8b04451f 100644 --- a/app/test/widgets/handler/handler_screen_test.dart +++ b/app/test/widgets/handler/handler_screen_test.dart @@ -8,6 +8,7 @@ import 'package:antgrid/providers/providers.dart'; import 'package:antgrid/services/handler_service.dart'; import 'package:antgrid/storage/cached_sessions_store.dart'; import 'package:antgrid/test_helpers/fake_agent_transport.dart'; +import 'package:antgrid/widgets/handler/handler_blocked_action_card.dart'; import 'package:antgrid/widgets/handler/handler_decision_card.dart'; import 'package:antgrid/widgets/handler/handler_screen.dart'; import 'package:flutter/foundation.dart'; @@ -351,6 +352,38 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + // A refused completion moves nothing, so the status snapshot after it is + // identical to the one before — this row is the only trace the user gets of a + // session that will now not wrap up on its own. + testWidgets('a refused completion renders with its reason and detail', ( + tester, + ) async { + await pumpHandlerScreen( + tester, + stateWith(sessions: {'t1': sessionState('t1')}).copyWith( + activity: const [ + HandlerActivityRecord( + recordId: 'r1', + at: 1, + terminalId: 't1', + decision: 'evidence_rejected', + reason: 'run /code-review --fix', + detail: 'done needs evidence showing /code-review itself being run', + ), + ], + ), + ); + expect( + find.textContaining('Completion not verified: run /code-review --fix'), + findsOneWidget, + ); + expect( + find.textContaining('showing /code-review itself being run'), + findsOneWidget, + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('session without a judge override shows the resolved default', ( tester, ) async { @@ -1059,4 +1092,38 @@ void main() { debugDefaultTargetPlatformOverride = null; }, ); + + testWidgets( + 'a guard_blocked row renders the refused text and dismisses it on the wire', + (tester) async { + // The row exists because a guard refused this exact text, so the card has + // to SHOW it — a rejection the user cannot read is one they cannot judge — + // and offer the one control that retires it. + final t = await pumpLiveHandlerScreen(tester); + t.emit('handler:status', armedStatusJson(escalations: [ + { + 'escalationId': 'b1', + 'question': 'Handler did not send its reply', + 'reasoning': 'slash command /code-review is not in this catalog', + 'draftReply': '/code-review --fix', + 'urgency': 'normal', + 'at': 1, + 'kind': 'guard_blocked', + }, + ])); + await pumpDelivery(tester); + + expect(find.byType(HandlerBlockedActionCard), findsOneWidget); + expect(find.text('/code-review --fix'), findsOneWidget); + + await tester.tap(find.text(handlerDismissLabel)); + await tester.pump(); + + final sent = t.sent.where((m) => m['type'] == 'handler:dismiss').toList(); + expect(sent, hasLength(1)); + expect(sent.single['projectId'], 'p'); + expect(sent.single['terminalId'], 't1'); + expect(sent.single['escalationId'], 'b1'); + }, + ); } diff --git a/app/test/widgets/session_fork_dialog_test.dart b/app/test/widgets/session_fork_dialog_test.dart new file mode 100644 index 00000000..e7de3e0e --- /dev/null +++ b/app/test/widgets/session_fork_dialog_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/widgets/session_fork_dialog.dart'; + +String? _answer; +bool _answered = false; + +/// Opens the dialog from a button, the way the kebab menu does, and records +/// what it answered into [_answer] / [_answered]. +Future _open(WidgetTester tester, {required bool isolatedSource}) async { + _answer = null; + _answered = false; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + _answer = await promptSessionFork( + context, + isolatedSource: isolatedSource, + ); + _answered = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('both workspaces are readable before anything is touched', ( + tester, + ) async { + await _open(tester, isolatedSource: true); + // The whole reason this is a segmented control and not the confirm dialog's + // opt-in toggle: a mode choice has to show what it is not. Upper case + // because that is how AbSegmented paints every cell label. + expect(find.text('NEW WORKSPACE'), findsOneWidget); + expect(find.text('THIS WORKSPACE'), findsOneWidget); + }); + + testWidgets('the default fork takes a workspace of its own', (tester) async { + await _open(tester, isolatedSource: true); + // The uncommitted-changes consequence is the one thing a user cannot + // recover by looking, so the default arm has to say it outright. + expect( + find.textContaining('Work you have not committed stays here'), + findsOneWidget, + ); + await tester.tap(find.text('Fork')); + await tester.pumpAndSettle(); + expect(_answer, forkWorkspaceCopy); + }); + + testWidgets('picking this workspace names the concurrency it buys', ( + tester, + ) async { + await _open(tester, isolatedSource: true); + await tester.tap(find.text('THIS WORKSPACE')); + await tester.pumpAndSettle(); + expect( + find.textContaining('Both sessions work in this one directory'), + findsOneWidget, + ); + await tester.tap(find.text('Fork')); + await tester.pumpAndSettle(); + expect(_answer, forkWorkspaceCurrent); + }); + + // "This workspace" means something different for a session on the main tree: + // the fork lands where every ordinary session already is, and the work it + // would not see sits in the main directory rather than in a private checkout. + testWidgets('a main-tree session is told where its work stays', ( + tester, + ) async { + await _open(tester, isolatedSource: false); + expect( + find.textContaining( + 'Work you have not committed stays in your main directory', + ), + findsOneWidget, + ); + await tester.tap(find.text('THIS WORKSPACE')); + await tester.pumpAndSettle(); + expect( + find.textContaining('alongside every other session there'), + findsOneWidget, + ); + }); + + testWidgets('cancelling forks nothing', (tester) async { + await _open(tester, isolatedSource: true); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(_answered, isTrue); + expect(_answer, isNull); + }); +} diff --git a/app/test/widgets/session_shared_workspace_badge_test.dart b/app/test/widgets/session_shared_workspace_badge_test.dart new file mode 100644 index 00000000..e40fd896 --- /dev/null +++ b/app/test/widgets/session_shared_workspace_badge_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:antgrid/design/ab_icons.dart'; +import 'package:antgrid/design/theme_presets.dart'; +import 'package:antgrid/design/widgets/ab_icon.dart'; +import 'package:antgrid/models/session_entry.dart'; +import 'package:antgrid/widgets/session_shared_workspace_badge.dart'; + +SessionEntry _session({bool shared = false, int members = 1}) => SessionEntry( + id: 's1', + name: 'Fix auth bug', + createdAt: 0, + lastUsedAt: 0, + archived: false, + running: false, + sharedWorkspace: shared, + workspaceMemberCount: members, +); + +Widget _wrap(SessionEntry session) => MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: >[kDefaultPalette], + ), + home: Scaffold( + body: Center(child: SessionSharedWorkspaceBadge(session: session)), + ), +); + +Finder _badgeGlyph() => find.byWidgetPredicate( + (w) => w is AbIcon && w.icon == AbIcons.sharedWorkspace, +); + +void main() { + testWidgets('a session that owns its workspace wears no badge', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_session())); + expect(_badgeGlyph(), findsNothing); + }); + + testWidgets('a shared workspace is badged, and the count is in the tooltip', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_session(shared: true, members: 3))); + expect(_badgeGlyph(), findsOneWidget); + // The count belongs to the tooltip, never to the row: a number that moves + // as sessions come and go is motion beside a name the user is scanning. + expect(find.textContaining('3'), findsNothing); + expect( + find.byTooltip( + 'Shared workspace — 2 other sessions work in this directory. Every one ' + 'of them edits the same files and commits to the same branch, at the ' + 'same time.', + ), + findsOneWidget, + ); + }); + + testWidgets('one other session is counted in the singular', (tester) async { + await tester.pumpWidget(_wrap(_session(shared: true, members: 2))); + expect( + find.byTooltip( + 'Shared workspace — 1 other session works in this directory. Every one ' + 'of them edits the same files and commits to the same branch, at the ' + 'same time.', + ), + findsOneWidget, + ); + }); + + // An older bridge can set the flag and omit the count, which the model + // defaults to 1. The badge still has something true to say, and must not + // offer to count zero other sessions. + testWidgets('a shared session with no count still explains itself', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_session(shared: true))); + expect(_badgeGlyph(), findsOneWidget); + expect( + find.byTooltip( + 'Shared workspace — other sessions work in this directory, editing the ' + 'same files and committing to the same branch.', + ), + findsOneWidget, + ); + }); +} diff --git a/app/test/widgets/update_row_test.dart b/app/test/widgets/update_row_test.dart index fe37522f..36949241 100644 --- a/app/test/widgets/update_row_test.dart +++ b/app/test/widgets/update_row_test.dart @@ -1,4 +1,6 @@ +import 'package:antgrid/design/widgets/ab_progress_rule.dart'; import 'package:antgrid/providers/update_available.dart'; +import 'package:antgrid/update/update_install_controller.dart'; import 'package:antgrid/update/update_strategy.dart'; import 'package:antgrid/widgets/update_row.dart'; import 'package:flutter/material.dart'; @@ -16,8 +18,9 @@ class _FakeStrategy extends UpdateStrategy { UpdateCheckOutcome.none; @override - Future install(BuildContext context) async { + Future install(BuildContext context) async { installs++; + return UpdateInstallResult.handedOff; } } @@ -29,13 +32,40 @@ class _RestartCopyStrategy extends _FakeStrategy { String get rowActionLabel => 'Restart'; } -Future _pumpRow( +/// Stands in for the real sequence and seeds the state the row renders. The +/// row's only job is to hand the tap over, so entries here — not what the +/// sequence then does — are what these tests assert. +class _SpyController extends UpdateInstallController { + _SpyController(this._seed); + + final UpdateInstallState _seed; + int starts = 0; + + /// Deliberately skips `super.build()`: the real one resolves + /// `hostControllerProvider`, reaching the process-global launcher singleton + /// and attaching an `unsealSpawns()` to this container's disposal. The row + /// only renders state, so the seed is the whole contract here. + @override + UpdateInstallState build() => _seed; + + @override + Future start(BuildContext context, {bool confirm = true}) async { + starts++; + } +} + +Future<({ProviderContainer container, _SpyController install})> _pumpRow( WidgetTester tester, UpdateStrategy? strategy, { bool lit = true, + UpdateInstallState install = const UpdateInstallIdle(), }) async { + final spy = _SpyController(install); final container = ProviderContainer( - overrides: [updateStrategyProvider.overrideWithValue(strategy)], + overrides: [ + updateStrategyProvider.overrideWithValue(strategy), + updateInstallControllerProvider.overrideWith(() => spy), + ], ); addTearDown(container.dispose); if (lit) container.read(updateAvailableProvider.notifier).set(true); @@ -45,7 +75,7 @@ Future _pumpRow( child: const MaterialApp(home: Scaffold(body: UpdateRow())), ), ); - return container; + return (container: container, install: spy); } void main() { @@ -56,18 +86,104 @@ void main() { expect(find.byType(InkWell), findsNothing); }); - testWidgets('lit row renders the strategy copy and taps into install', ( + testWidgets('lit row renders the strategy copy and taps into the sequence', ( tester, ) async { final strategy = _FakeStrategy(); - await _pumpRow(tester, strategy); + final h = await _pumpRow(tester, strategy); + + expect(find.text('Update available'), findsOneWidget); + expect(find.text('Update'), findsOneWidget); + + await tester.tap(find.byType(InkWell)); + await tester.pump(); + // Through the controller, never straight at the strategy: the confirm, + // the host drain and the debounce all live there. + expect(h.install.starts, 1); + expect(strategy.installs, 0); + }); + + testWidgets('a running install reports progress and refuses a second tap', ( + tester, + ) async { + // The first seconds of a Windows install are silent — the Store re-scans + // its pending set before showing anything of its own — which is long enough + // for an impatient second tap to start a second install. + final h = await _pumpRow( + tester, + _FakeStrategy(), + install: const UpdateInstallWorking(42), + ); + + expect(find.text('Updating... 42%'), findsOneWidget); + expect( + tester.widget(find.byType(AbProgressRule)).fraction, + 0.42, + ); + // An action label on a row that refuses taps reads as a dead button. + expect(find.text('Update'), findsNothing); + + await tester.tap(find.byType(InkWell), warnIfMissed: false); + await tester.pump(); + expect(h.install.starts, 0); + }); + + testWidgets('the pre-download plateau reads as waiting, not as stuck at 0%', ( + tester, + ) async { + // Nothing ticks until the Store has re-scanned and taken the user through + // both of its consent dialogs — minutes, potentially. A hard "0%" over a + // rule pinned at zero is indistinguishable from a wedged install. + await _pumpRow( + tester, + _FakeStrategy(), + install: const UpdateInstallWorking(0), + ); + + expect(find.text('Updating...'), findsOneWidget); + expect( + tester.widget(find.byType(AbProgressRule)).fraction, + isNull, + reason: 'indeterminate, not zero', + ); + }); + + testWidgets('a handed-over install never becomes tappable again', ( + tester, + ) async { + // Done is reached only where the process is already dying around the row; + // offering a second install there would be offering a second restart. + final h = await _pumpRow( + tester, + _FakeStrategy(), + install: const UpdateInstallDone(), + ); + + expect(find.text('Updating...'), findsOneWidget); + expect(find.text('Update'), findsNothing); + + await tester.tap(find.byType(InkWell), warnIfMissed: false); + await tester.pump(); + expect(h.install.starts, 0); + }); + + testWidgets('a failed attempt leaves the row offering the update again', ( + tester, + ) async { + // The update stays pending until the app restarts, so a declined or + // cancelled install must not spend the affordance. + final h = await _pumpRow( + tester, + _FakeStrategy(), + install: const UpdateInstallFailed(UpdateInstallResult.notInstalled), + ); expect(find.text('Update available'), findsOneWidget); expect(find.text('Update'), findsOneWidget); await tester.tap(find.byType(InkWell)); await tester.pump(); - expect(strategy.installs, 1); + expect(h.install.starts, 1); }); testWidgets('a strategy with stronger install semantics owns its copy', ( diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp index 1a4f1a16..6f7e6326 100644 --- a/app/windows/runner/main.cpp +++ b/app/windows/runner/main.cpp @@ -6,6 +6,14 @@ #include "utils.h" #include "app_links/app_links_plugin_c_api.h" +// Passed back to us on the command line when Windows relaunches the process +// after applying a Store update. Nothing in Dart reads it, deliberately: the +// same argument comes back after a crash, a hang and a reboot-to-patch, so it +// is not evidence an update happened. The version UpdateHandoffStore records +// at hand-off is (app/lib/storage/update_handoff_store.dart). The registration +// still needs a command line, and this one names the case it exists for. +constexpr const wchar_t kRestartCommandLine[] = L"--after-update"; + // Window class + title alone aren't enough to identify "our" instance: every // antgrid build (dev debug build, a locally installed release, the packaged // MSIX) produces a window with this same class and title, so a naive @@ -95,6 +103,23 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + // A Store update force-kills this process (ForceTargetApplicationShutdown), + // so without a restart registration the user is simply left with no app. + // The flags must stay 0: every RESTART_NO_* bit subtracts a case Windows + // would otherwise relaunch us for, patching included. Windows honours the + // registration only once the process has been alive ~60s, which any + // user-initiated update click is well past. + HRESULT restart_registration = + ::RegisterApplicationRestart(kRestartCommandLine, 0); + if (FAILED(restart_registration)) { + wchar_t warning[160]; + ::wsprintfW(warning, + L"antgrid: RegisterApplicationRestart failed (0x%08X); the app " + L"will not relaunch itself after a Store update", + restart_registration); + ::OutputDebugStringW(warning); + } + flutter::DartProject project(L"data"); std::vector command_line_arguments = diff --git a/app/windows/runner/store_update_channel.cpp b/app/windows/runner/store_update_channel.cpp index 5737046a..a7e65e52 100644 --- a/app/windows/runner/store_update_channel.cpp +++ b/app/windows/runner/store_update_channel.cpp @@ -2,10 +2,13 @@ #include #include +#include #include #include #include +#include +#include #include #include #include @@ -14,13 +17,23 @@ namespace { using flutter::EncodableMap; using flutter::EncodableValue; +using winrt::Windows::ApplicationModel::PackageVersion; using winrt::Windows::Services::Store::StoreContext; using winrt::Windows::Services::Store::StorePackageUpdate; using winrt::Windows::Services::Store::StorePackageUpdateState; +using winrt::Windows::Services::Store::StorePackageUpdateStatus; +using MethodChannelPtr = flutter::MethodChannel*; using MethodResult = std::shared_ptr>; +// Wire contract with Dart's WindowsStoreUpdateService — matched literally on +// both sides, so a rename here is a silent no-op there. +constexpr char kProgressMethod[] = "downloadProgress"; +constexpr char kOutcomeCompleted[] = "completed"; +constexpr char kOutcomeCancelled[] = "cancelled"; +constexpr char kOutcomeNone[] = "none"; + std::string DescribeError() { try { throw; @@ -45,31 +58,113 @@ StoreContext RequireStoreContext() { return context; } -// Both coroutines below run on the platform (STA) thread, hop to the thread -// pool while the Store async op runs, and must hop back before touching -// |result| — method results may only be invoked on the platform thread, which -// keeps pumping messages so the apartment_context resume can land. +std::string FormatPackageVersion(PackageVersion const& version) { + return std::to_string(version.Major) + "." + std::to_string(version.Minor) + + "." + std::to_string(version.Build) + "." + + std::to_string(version.Revision); +} + +// Only Completed means the user actually has the new build. Every other +// terminal state — an explicit cancel, and the Store's own error states, which +// this channel has no separate reply for — reports as a cancel: the update +// stays pending and offering it again is the recoverable answer, whereas +// calling it done would record a version that was never installed. +const char* OutcomeFor(StorePackageUpdateState state) { + return state == StorePackageUpdateState::Completed ? kOutcomeCompleted + : kOutcomeCancelled; +} + +int32_t ProgressPercent(StorePackageUpdateStatus const& status) { + // TotalDownloadProgress spans every package in the submitted set; the + // per-package fields describe only the one this callback is about, so they + // are a fallback for a Store build that leaves the total at zero. + double fraction = status.TotalDownloadProgress; + if (!(fraction > 0.0)) { + fraction = status.PackageDownloadProgress; + } + if (!(fraction > 0.0) && status.PackageDownloadSizeInBytes > 0) { + fraction = static_cast(status.PackageBytesDownloaded) / + static_cast(status.PackageDownloadSizeInBytes); + } + // Negated comparisons so a NaN from the Store lands on 0 rather than + // propagating through the cast. + if (!(fraction > 0.0)) return 0; + if (fraction >= 1.0) return 100; + return static_cast(fraction * 100.0); +} + +// The coroutines below start on the platform (STA) thread, hop to the thread +// pool while a Store async op runs, and must hop back before touching +// |result| or |channel| — the engine's messenger may only be used on the +// platform thread, which keeps pumping messages so the apartment_context +// resume can land. // // The hop back is fenced two ways for shutdown: the resume itself sits in a // try/catch (if the STA is gone it throws, and an exception escaping a // fire_and_forget is std::terminate), and |alive| is re-checked afterwards — // the window can close while the async op is in flight, tearing down the -// engine whose messenger the MethodResult replies through. +// engine that both the MethodResult and the channel reply through. + +// Progress arrives on an arbitrary pool thread, which may touch neither +// |channel| nor |alive|; this carries an already-computed percent across to +// the platform thread under the same fence as the reply paths. +winrt::fire_and_forget EmitDownloadProgress( + winrt::apartment_context platform_thread, MethodChannelPtr channel, + std::shared_ptr alive, int32_t percent) { + try { + co_await platform_thread; + } catch (...) { + co_return; // STA gone — process is shutting down, nobody left to tell. + } + if (!*alive) co_return; + try { + channel->InvokeMethod(kProgressMethod, + std::make_unique(percent)); + } catch (...) { + // An exception escaping a fire_and_forget is std::terminate, and a + // progress tick is the last thing that should be allowed to take the + // process down. + } +} winrt::fire_and_forget CheckForUpdates(MethodResult result, std::shared_ptr alive) { winrt::apartment_context platform_thread; int32_t update_count = 0; bool mandatory = false; + std::string version; std::optional error; try { StoreContext context = RequireStoreContext(); + // The only scan a check performs. This call is itself what enqueues the + // pending update with the Store (its fulfilment events name it), so a + // second one is a side effect, not a free re-read. auto updates = co_await context.GetAppAndOptionalStorePackageUpdatesAsync(); update_count = static_cast(updates.Size()); + bool version_read = false; for (StorePackageUpdate const& update : updates) { if (update.Mandatory()) { mandatory = true; + } + // The app's own package leads the list and optional packages follow, so + // the first entry is the version the user is being offered. + if (!version_read) { + version_read = true; + // Fenced apart from the scan above: this string is confirm-dialog + // copy, while `update_count` decides whether an update EXISTS. Sharing + // one try would let a package whose projection refuses to answer + // report a real pending update — a mandatory one included — as + // "nothing to install". + try { + version = FormatPackageVersion(update.Package().Id().Version()); + } catch (...) { + // The dialog omits the version; everything else still works. + } + } + // Nothing further can change: the version came from the first entry and + // mandatory only ever goes true. + if (mandatory) { break; } } @@ -88,14 +183,16 @@ winrt::fire_and_forget CheckForUpdates(MethodResult result, result->Success(EncodableValue(EncodableMap{ {EncodableValue("updateCount"), EncodableValue(update_count)}, {EncodableValue("mandatory"), EncodableValue(mandatory)}, + {EncodableValue("version"), EncodableValue(version)}, })); } } -winrt::fire_and_forget DownloadAndInstall(HWND window, MethodResult result, +winrt::fire_and_forget DownloadAndInstall(HWND window, MethodChannelPtr channel, + MethodResult result, std::shared_ptr alive) { winrt::apartment_context platform_thread; - bool completed = false; + std::string outcome; std::optional error; try { StoreContext context = RequireStoreContext(); @@ -104,15 +201,31 @@ winrt::fire_and_forget DownloadAndInstall(HWND window, MethodResult result, // pending set may have changed while the prompt sat on screen. auto updates = co_await context.GetAppAndOptionalStorePackageUpdatesAsync(); - if (updates.Size() > 0) { + if (updates.Size() == 0) { + outcome = kOutcomeNone; + } else { + // The install request must be issued from the UI thread; the scan above + // resumes wherever WinRT completed it, and requesting off-thread is what + // leaves the Store's consent dialogs unparented and out of order. + co_await platform_thread; + if (!*alive) co_return; + auto operation = + context.RequestDownloadAndInstallStorePackageUpdatesAsync(updates); + auto last_percent = std::make_shared>(-1); + operation.Progress([platform_thread, channel, alive, last_percent]( + auto const&, + StorePackageUpdateStatus const& status) { + int32_t percent = ProgressPercent(status); + // The Store reports per received chunk; whole-percent deltas are all + // the UI can show and all the binary messenger should carry. + if (last_percent->exchange(percent) == percent) return; + EmitDownloadProgress(platform_thread, channel, alive, percent); + }); // The Store owns the download/progress UI from here. A mandatory update // may terminate the process to install, in which case this never // resumes — that is expected. - auto update_result = - co_await context.RequestDownloadAndInstallStorePackageUpdatesAsync( - updates); - completed = - update_result.OverallState() == StorePackageUpdateState::Completed; + auto update_result = co_await operation; + outcome = OutcomeFor(update_result.OverallState()); } } catch (...) { error = DescribeError(); @@ -126,7 +239,7 @@ winrt::fire_and_forget DownloadAndInstall(HWND window, MethodResult result, if (error) { result->Error("store_unavailable", *error); } else { - result->Success(EncodableValue(completed)); + result->Success(EncodableValue(outcome)); } } @@ -160,7 +273,11 @@ void StoreUpdateChannel::HandleMethodCall( if (call.method_name() == "checkForUpdates") { CheckForUpdates(std::move(shared_result), alive_); } else if (call.method_name() == "requestDownloadAndInstall") { - DownloadAndInstall(window_, std::move(shared_result), alive_); + // The raw channel is safe to hand over: it is destroyed on the platform + // thread by the same destructor that clears |alive_|, which every use of + // it re-checks after hopping back there. + DownloadAndInstall(window_, channel_.get(), std::move(shared_result), + alive_); } else { shared_result->NotImplemented(); } diff --git a/app/windows/runner/store_update_channel.h b/app/windows/runner/store_update_channel.h index bc1c1af3..d38c9a0b 100644 --- a/app/windows/runner/store_update_channel.h +++ b/app/windows/runner/store_update_channel.h @@ -12,6 +12,11 @@ // WinRT StoreContext API. StoreContext only works with MSIX package identity // (Store-installed builds); without it every call replies with a // "store_unavailable" error that the Dart side degrades to "no update". +// +// Inbound: "checkForUpdates" -> {updateCount, mandatory, version}; +// "requestDownloadAndInstall" -> "completed" | "cancelled" | "none". +// Outbound: "downloadProgress" carries an int percent (0-100) while an install +// is in flight. class StoreUpdateChannel { public: // |messenger| and |window| must outlive this object. @@ -33,8 +38,10 @@ class StoreUpdateChannel { // Cleared by the destructor. In-flight Store coroutines outlive the channel // (their async ops run for seconds to minutes on the thread pool); each one // re-checks this after hopping back to the platform thread so it never - // replies through an engine that was torn down while it was away. Only ever - // touched on the platform thread, so a plain bool is race-free. + // replies through an engine that was torn down while it was away, and the + // progress emitter re-checks it for the same reason before dereferencing + // |channel_|. Only ever touched on the platform thread, so a plain bool is + // race-free. std::shared_ptr alive_ = std::make_shared(true); }; diff --git a/bridge/CLAUDE.md b/bridge/CLAUDE.md index 6e069316..51f21aae 100644 --- a/bridge/CLAUDE.md +++ b/bridge/CLAUDE.md @@ -20,6 +20,20 @@ derived from `toPosts`). Nothing in `app/` changes: `BY_HOOK_NAME`, the tools ad `handlerObservable`, and the judge / transcript / title dispatch all derive from that one table. +`AgentSpec.headless` is the one exception to "absence is the honest answer". +It declares the agent's VERIFIED non-interactive argvs keyed by how far each may +reach, and two callers share them: naming a session (`agents/title-generate.ts`) +and the Handler's judge (`handler/judge.ts`). A missing entry still means "no +argv has been run against this CLI" — but for naming, `resolveHeadless` then +BORROWS the first installed agent that has one, because the whole job is inlined +into the prompt. A judge is never borrowed, and `judgeCapable` is exactly "has a +non-sealed entry". The field's own docstring carries the rules a new entry must +satisfy (no writes, and how it keeps the run out of the user's `--resume`); add +one only after running it. A naming spawn runs in a throwaway cwd, NEVER the +session's checkout — see `headlessScratchCwd`. History a CLI offers no switch to +skip is redirected per spawn instead (`HeadlessCommand.scratchEnv`, a fresh dir +the runner deletes after), so never list a var that also carries credentials. + The `agent:tools` advert (and the loopback `tools:list` reply) carries TWO arrays, and the split is load-bearing. `tools[]` is the PATH probe — what this machine can actually launch. `agents[]` (`agent-catalog.ts`, projected from the diff --git a/bridge/plugin/antigravity/post-title.js b/bridge/plugin/antigravity/post-title.js index 22dc33e6..fbac1a6e 100644 --- a/bridge/plugin/antigravity/post-title.js +++ b/bridge/plugin/antigravity/post-title.js @@ -15,10 +15,10 @@ import { join } from "node:path"; // (mirrors cursor's post-notify.js). // The title is deliberately resolved bridge-side, not here: this hook runs under // bare `node`, which has no reliable sqlite reader for agy's global -// conversation_summaries.db (kept as a bonus upgrade path — see -// resolveStructuredTitle — but confirmed NOT populated during a live CLI -// session, even after a clean /exit; transcriptPath is the reliable source, see -// resolveAntigravityTranscriptTitle in agents/antigravity/title.ts). +// conversation_summaries.db — and nothing reads that DB in any case, since +// agy's own generated name is not a name we apply (see ResolvedTitle). +// transcriptPath is the source, see resolveAntigravityTranscriptTitle in +// agents/antigravity/title.ts. // // agy feeds the hook payload as JSON on stdin and reads a JSON object on // stdout; we always emit `{}` so the agent loop is never gated or mutated — a diff --git a/bridge/plugin/opencode/plugin.ts b/bridge/plugin/opencode/plugin.ts index 0eb53693..eb9fc24f 100644 --- a/bridge/plugin/opencode/plugin.ts +++ b/bridge/plugin/opencode/plugin.ts @@ -54,6 +54,11 @@ export const AntgridSessionNamer: Plugin = async () => ({ // named the conversation (title arrives on a later session.updated). The // bridge captures the id unconditionally and uses the title only if present. if (!sessionId) return; + // Root only. A subagent is a session of its own and updates like one, and + // the bridge stores whatever id this posts as the slot's resume id — so + // forwarding a child's would resume the terminal into the subagent's + // conversation instead of the user's. + if (info?.parentID) return; try { await fetch(`http://127.0.0.1:${port}/session-title`, { method: "POST", diff --git a/bridge/src/agent-catalog.ts b/bridge/src/agent-catalog.ts index bc7e487e..75723e81 100644 --- a/bridge/src/agent-catalog.ts +++ b/bridge/src/agent-catalog.ts @@ -2,7 +2,7 @@ // as known-agents.ts / agent-resume.ts: one concern, one file, no per-agent // knowledge of its own. -import { AGENTS, handlerObservable } from "./agents/registry"; +import { AGENTS, handlerObservable, judgeCapable } from "./agents/registry"; import type { AgentKey, AgentSpec } from "./agents/types"; import type { AgentDescriptor } from "./protocol"; @@ -20,7 +20,7 @@ export function buildAgentCatalog(): AgentDescriptor[] { tool, label: spec.label, chatCapable: spec.driver !== undefined, - judgeCapable: spec.judge !== undefined, + judgeCapable: judgeCapable(tool), handler: { terminal: handlerObservable(tool, "terminal"), chat: handlerObservable(tool, "chat"), diff --git a/bridge/src/agent-core.ts b/bridge/src/agent-core.ts index bbac5133..4f8aa8d5 100644 --- a/bridge/src/agent-core.ts +++ b/bridge/src/agent-core.ts @@ -21,9 +21,9 @@ import { loadConfig, findConfigFile, projectName, type AbConfig } from "./config import { buildConfigFromBootstrap, consoleBootstrapIO, writeConfigYaml } from "./bootstrap"; import { resolveAgent, listKnownTools, oscTitleForNaming, isOscTitleUnusable } from "./known-agents"; import { augmentAgentLaunch } from "./agent-launch-augmenter"; -import { CHECKOUT_VARIABLE_MESSAGE_TYPES, createMessage, HandlerConfigureWire, HandlerInstructWire, HandlerUndoWire, type AbMessage, type RpcRequest, type SessionEntry, type WorkStatus } from "./protocol"; +import { CHECKOUT_VARIABLE_MESSAGE_TYPES, createMessage, HandlerConfigureWire, HandlerDismissWire, HandlerInstructWire, HandlerUndoWire, type AbMessage, type RpcRequest, type SessionEntry, type WorkStatus } from "./protocol"; import { parseTunnelMessage } from "./tunnel-protocol"; -import { startApiServer, type ApiServerHandle, type SessionTitleBody } from "./api-server"; +import { startApiServer, type ApiServerHandle } from "./api-server"; import { MessageBus, type InboundSource } from "./message-bus"; import { resolveAbDir } from "./antgrid-dir"; import { computeProjectId } from "./project-id"; @@ -42,7 +42,8 @@ import { SessionNamer } from "./session-namer"; import { antigravityCliHome } from "./agents/antigravity/title"; import { AntigravityTitleWatcher } from "./agents/antigravity/title-watcher"; import { resolveStructuredTitle } from "./agents/title-dispatch"; -import { generateSessionTitle } from "./agents/title-generate"; +import { buildTitleContext, generateTitleFromContext } from "./agents/title-generate"; +import { TitleAttempts, type TitleOutcome } from "./agents/title-attempts"; import { agentSpec, BY_HOOK_NAME, handlerObservable } from "./agents/registry"; import { HandlerEngine, type HandlerEvent } from "./handler/engine"; import { createEntitlementReader, type TierClaimSource } from "./entitlement"; @@ -353,6 +354,12 @@ export interface BuildAgentCoreOptions { * {@link AgentCore.refreshSessionWork} when the reduction moves — the list * is otherwise only re-emitted when the sessions themselves change. */ sessionWorkStatusFor?: (sessionId: string) => WorkStatus | undefined; + /** True when this slot's own turn already ended, so an injected hook's + * ambiguous `awaiting_input` can only be its post-completion idle nudge. The + * owner answers from the same work-status reduction that already drops the + * nudge's phone push; an absent hook forwards, which is the direction a + * supervisor has to fail in. */ + isStaleIdleNudge?: (sessionId: string) => boolean; /** Relay base URL of the machine socket this core attaches to. Host-supplied * in remote mode: only a standalone agent with an explicit `relayUrl:` in its * antgrid.yaml can learn it from config, so without this a host-spawned @@ -542,12 +549,21 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise:`. The /session-title post repeats every turn, - // so without this a session whose agent never names itself would pay a model - // call per turn, forever. Keyed by agent session, not slot, so a resume or a - // fresh thread in the same slot gets one more attempt. - const titleGenAttempted = new Set(); + // Title-generation budget per conversation, per terminal. The /session-title + // post repeats every turn, so without this a session whose agent never names + // itself would pay a model call per turn, forever. Keyed by agent session + // where there is one, not by slot, so a fresh thread in the same slot gets a + // fresh budget — see TitleTarget for why a chat slot keys on itself instead. + const titleAttempts = new TitleAttempts(); + /** Conversation id for the attempt gate — see TitleTarget for why a chat slot + * keys on itself rather than on an agent session id. */ + const titleAttemptKey = (target: { terminalId: string; agentSessionId?: string }) => + target.agentSessionId ?? target.terminalId; + function forgetTitleAttempts(terminalId: string): void { + titleAttempts.forget(terminalId); + } + /** Which agent each LIVE chat slot runs — see the driverFactory that fills it. */ + const chatTools = new Map(); let structured: StructuredAgentManager | null = null; // Holds this core's api-server handle. Declared before `manager` so the // TerminalManager's late-bound getApiPort getter can read `apiServer.port` @@ -862,6 +878,22 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise s.name === msg.terminalId); const isSession = !!sessions?.get(msg.terminalId); @@ -1182,6 +1214,29 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise sendAb(createMessage("session:result", { + requestId: msg.requestId, ok: true, session: entry, checkoutId: entry.checkoutId, + })), + (err) => sendAb(createMessage("session:result", { + requestId: msg.requestId, + ok: false, + error: err instanceof Error ? err.message : "Could not fork the session.", + ...(err instanceof WorktreeError ? { errorCode: err.code } : {}), + })), + ); + break; + } case "session:start": case "session:stop": case "session:rename": @@ -1451,15 +1506,17 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + prompt: (id, text, commandId) => { // requestId is required by AgentPromptMessage; drivers use it only // for send-correlation, so a fresh UUID is sufficient. void structured?.handleAgentMessage(createMessage("agent:prompt", { sessionId: id, requestId: crypto.randomUUID(), text, - })); + ...(commandId ? { commandId } : {}), + }), { injected: true }); }, getTranscriptPath: (id) => sessions?.getAgentTranscriptPath(id), getSnapshot: (id) => structured?.getTranscriptSnapshot(id) ?? Promise.resolve([]), + commandCatalog: (id) => structured?.commandCatalog(id), }), }), sendAb: (msg) => sendAb(msg), @@ -2267,7 +2324,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise dropSessionReplay(sessionId), onAgentSession: (sessionId, agentSessionId) => sessions?.setAgentSession(sessionId, agentSessionId), onSetConfig: (sessionId, key, value) => sessions?.setSessionConfig(sessionId, key, value), + // The bridge hands every chat prompt to the driver itself, so a chat + // session is named from its first message with no hook in the loop — the + // only naming path an agent that ships neither hooks nor a title of its + // own (opencode) has. + onUserPrompt: (sessionId, text) => { + const tool = chatTools.get(sessionId); + if (!tool) return; + maybeGenerateTitle(tool, { terminalId: sessionId, prompt: text }) + .catch((err) => log.error("Title generation failed: %s", err)); + }, driverFactory: (sessionId, tool, send) => { // Chat mode is gated on isChatCapableTool, which IS "the spec has a // driver" — so an unreachable tool here means the two disagreed. const driver = agentSpec(tool)?.driver; if (!driver) throw new Error(`tool "${tool}" has no chat driver`); + // Recorded where the driver is built, so the prompt tap names a session + // with the agent it is actually talking to: a SessionEntry carries + // `tool` only when it overrode the project default. + chatTools.set(sessionId, tool); const sessionCheckoutId = sessions?.get(sessionId)?.checkoutId ?? "main"; const sessionRuntime = checkoutRuntimes.runtime(sessionCheckoutId) ?? mainRuntime; return driver({ @@ -2399,9 +2470,6 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise buildChatSpawnAugment(tool, sessionId, apiServer?.port ?? null, abDir), onAgentSession: (agentSessionId) => sessions?.setAgentSession(sessionId, agentSessionId), - // The driver only forwards its backend's OWN generated name (opencode - // filters to the root session), never an echo of the first message. - onTitle: (title) => namer?.onStructuredTitle(sessionId, title, "generated"), onLifecycle: (evt) => { handlerEngine.handleEvent({ terminalId: sessionId, ...evt }) .catch((err) => logger.error("Handler lifecycle event failed: %s", err)); @@ -2509,7 +2577,8 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise sessions?.applyAutoName(id, name), }); - // agy fires no hook on a `/rename` or when it writes its own generated - // conversation name, so neither would reach the sidebar until the next turn. - // Watch agy's title sources and route the current best name through the namer - // (same debounce + manual-wins precedence as every other title signal). - // No-ops if agy isn't installed. + // agy fires no hook on a `/rename`, so it would not reach the sidebar until + // the next turn. Watch its command log and route the rename through the + // namer (same debounce + precedence as every other title signal). No-ops if + // agy isn't installed. antigravityTitleWatcher = new AntigravityTitleWatcher( antigravityCliHome(), (conversationId, title) => { const slot = sessions?.findSlotByAgentSession(conversationId); - // Always a real name: the watcher reports only `/rename` and agy's own - // summaries, never the first-message fallback (see its docstring). - if (slot) namer?.onStructuredTitle(slot, title, "generated"); + // Always the user's own name: the watcher reports only `/rename` now, + // never agy's generated name and never the first-message fallback. + if (slot) namer?.onStructuredTitle(slot, title, "manual"); }, ); antigravityTitleWatcher.start(); @@ -2806,35 +2874,100 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { - const tool = body.agent ? BY_HOOK_NAME[body.agent] : undefined; - if (!tool || !body.sessionId) return; - if (sessions && !sessions.isAutoNameable(body.terminalId)) return; - const key = `${body.terminalId}:${body.sessionId}`; - // Claim the slot BEFORE awaiting: two turns can end while the first spawn is - // still running, and both would otherwise pass the check. - if (titleGenAttempted.has(key)) return; - titleGenAttempted.add(key); - const title = await generateSessionTitle({ + async function maybeGenerateTitle( + tool: string, target: TitleTarget, fallback?: string, + ): Promise { + if (sessions && !sessions.isAutoNameable(target.terminalId)) return; + // Keyed by SLOT, unlike everything below it. A mode flip keeps the session's + // name and its rank (see the isFlipping exemptions) but re-keys the attempt, + // because a terminal keys on the agent's session id and a chat slot on + // itself — so the count alone would let the flipped session rename itself + // from whatever the user typed next. + if (namer?.hasFinalTitle(target.terminalId)) return; + + const key = titleAttemptKey(target); + // Tested twice, and the two tests answer different questions. This one is a + // pure early-out: every turn of the session posts here, and without it each + // one pays the transcript read below only to be refused after it. It claims + // nothing, so it cannot burn an attempt. + if (titleAttempts.refused(target.terminalId, key)) return; + + // Read the conversation BEFORE claiming the attempt. The budget is per + // agent session, and the first post of a session arrives from SessionStart + // — before the user has typed — so claiming first spent a Claude session's + // whole budget on an empty transcript, and the turn that finally had + // something to name from was refused. + // + // A post carrying `prompt` needs no read at all: the hook handed us the + // message the user just submitted, which is both the context and the reason + // this fires before the transcript has been written. + const submitted = target.prompt?.trim(); + const context = submitted || await buildTitleContext({ tool, - cwd: project.path, - transcriptPath: body.transcriptPath, - agentSessionId: body.sessionId, + transcriptPath: target.transcriptPath, + agentSessionId: target.agentSessionId, fallbackContext: fallback, }); - // Re-check: the spawn takes tens of seconds, and the user may have renamed - // the session (or Claude may have written its own title) in that window. - if (!title || (sessions && !sessions.isAutoNameable(body.terminalId))) return; - log.info("generated a session title for %s (%s)", body.terminalId, tool); - namer?.onStructuredTitle(body.terminalId, title, "generated"); + if (!context) return; + + // Claimed BEFORE awaiting the spawn: two turns can end while the first is + // still running, and both would otherwise pass the early-out above. The + // transcript read already awaited, but this check-and-set does not, so only + // one caller gets past it. + if (!titleAttempts.begin(target.terminalId, key)) return; + // Only the paths that reach a verdict overwrite this. Every other exit is a + // title thrown away for reasons unrelated to generating it, which releases + // the claim without spending the budget. + let outcome: TitleOutcome = "abandoned"; + try { + // No cwd: a naming spawn runs in a throwaway directory of its own + // (headlessScratchCwd), never this session's checkout. + const result = await generateTitleFromContext(context, { tool }); + if (!result.ok) { + outcome = result.reason; + return; + } + // Re-check: the spawn takes tens of seconds, and the user may have renamed + // the session in that window. + if (sessions && !sessions.isAutoNameable(target.terminalId)) return; + // …or started a NEW conversation in the same slot (`/clear`), whose own + // title this would outrank at `self` and which carries a budget of its + // own. The title describes the conversation it was generated from, not + // the slot. Only answerable when we were naming a conversation the agent + // had identified; see TitleTarget. + const live = sessions?.get(target.terminalId)?.agentSessionId; + if (target.agentSessionId && live && live !== target.agentSessionId) return; + outcome = "named"; + log.info("generated a session title for %s (%s)", target.terminalId, tool); + namer?.onStructuredTitle(target.terminalId, result.title, "self"); + } finally { + titleAttempts.settle(target.terminalId, key, outcome); + } } // Start local API server for MCP/hook integration (works in both modes) @@ -2870,31 +3003,60 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise { + if (!hookTool || !body.sessionId || chatSlot) return; + maybeGenerateTitle(hookTool, { + terminalId: body.terminalId, + agentSessionId: body.sessionId, + prompt: body.prompt, + transcriptPath: body.transcriptPath, + }, fallback).catch((err) => log.error("Title generation failed: %s", err)); + }; + // A post carrying the submitted prompt is PRE-turn (Claude's + // UserPromptSubmit). Naming starts here rather than at the turn-end post, + // which on a real task is many minutes later — and the prompt text is the + // whole context, so nothing on disk is read. Nothing else on this path + // applies: no title has been written for a turn that has not run. + // + // Trimmed, matching the test maybeGenerateTitle applies to it: a + // whitespace-only submission is not context, and taking this branch for + // one would name nothing AND skip the native read it displaced. + if (body.prompt?.trim()) { + nameFromHook(); return; } + // opencode's plugin still posts its server-generated conversation name + // inline; the schema declares no field for it, so it is stripped rather + // than applied — we name sessions ourselves (see ResolvedTitle), and an + // older installed plugin must keep parsing. + // + // Claude/Codex/Copilot/Antigravity post only correlation ids, so the + // manual-rename-or-opening-prompt read comes off their on-disk session + // files (async, off the event loop). resolveStructuredTitle swallows its + // own errors, so the unawaited promise can't reject. const resolved = await resolveStructuredTitle(body.agent, { sessionId: body.sessionId, transcriptPath: body.transcriptPath, }); - // Apply the native read first either way: even a first-message title beats - // "Session 3" while generation is in flight, and it's what we keep if - // generation fails. Pass the kind through — this is the one signal that - // repeats an unchanged placeholder every turn, and the namer needs it to - // refuse to overwrite a real title with one (see SessionNamer.TitleRank). + // Apply the native read first either way: a first-message title beats + // "Session 3" while generation is in flight, and it is what we keep if + // generation fails. Pass the kind through — the first-message signal + // repeats the same opening prompt on every turn of the session, and only + // the rank stops it overwriting the generated name (see TitleRank). if (resolved) namer?.onStructuredTitle(body.terminalId, resolved.title, resolved.kind); - if (!resolved || resolved.kind === "first-message") { - void maybeGenerateTitle(body, resolved?.title); - } + if (!resolved || resolved.kind === "first-message") nameFromHook(resolved?.title); }, onHookAlive: (terminalId) => { hookAlivePinged.add(terminalId); }, onTurnStart: (terminalId) => opts.onTurnStart?.(terminalId), + isStaleIdleNudge: (terminalId) => opts.isStaleIdleNudge?.(terminalId) ?? false, }); const TranscriptSnapshotParams = z.object({ sessionId: z.string() }); diff --git a/bridge/src/agents/antigravity/title-watcher.ts b/bridge/src/agents/antigravity/title-watcher.ts index a7b2dce0..85dc5f6e 100644 --- a/bridge/src/agents/antigravity/title-watcher.ts +++ b/bridge/src/agents/antigravity/title-watcher.ts @@ -2,29 +2,28 @@ import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { logger } from "../../logger"; -import { parseAntigravityRenames, readAntigravitySummaries } from "./title"; +import { parseAntigravityRenames } from "./title"; -/** True for the agy files a conversation title can change in: the command log - * (a `/rename`) and the summaries DB (agy's generated `preview`, WAL-journaled - * so writes land in `conversation_summaries.db-wal`). Everything else in the - * home dir — especially the churning `cli.log` — is ignored. */ +/** True for the agy file a user-chosen conversation name can change in: the + * command log, where `/rename` is recorded. agy's OWN names live in + * conversation_summaries.db and are deliberately not watched — we generate our + * titles rather than wait on agy's. Everything else in the home dir — + * especially the churning `cli.log` — is ignored. */ function isTitleSource(filename: string): boolean { - return filename === "history.jsonl" || filename.startsWith("conversation_summaries.db"); + return filename === "history.jsonl"; } /** - * Watches agy's title sources and reports the current best name per conversation - * as it changes, so the app follows both a `/rename` (instant) and agy's own - * generated name (which agy writes a beat AFTER the turn) without waiting for the - * next hook fire — agy triggers no hook on either event. + * Watches agy's command log and reports each conversation's `/rename` as it + * happens, so the app follows a rename instantly — agy fires no hook on one, so + * without this it would not surface until the next turn. * - * The reported title uses the same precedence as the resolver: a live `/rename` - * (history.jsonl) outranks agy's `title`/`preview` (conversation_summaries.db). - * The first-user-message fallback is deliberately NOT here — that one is instant - * and already supplied by the hook → resolver path; the watcher only delivers - * the two lagging/idle upgrades. Because a lower source is only chosen when the - * higher one is absent, and every scan reconsiders both, an upgrade never - * regresses a session's name back down. + * Renames ONLY. agy also names conversations itself in conversation_summaries.db + * (its `preview`), and that is read nowhere: we generate our own name from the + * first user message rather than waiting to see whether agy writes one (see + * ResolvedTitle). + * The first-user-message fallback is not here either — that one is instant and + * already supplied by the hook -> resolver path. * * Reports only CHANGED titles (deduped per conversation), and only ones produced * after `start()` — the current state is seeded silently so a resume doesn't @@ -49,9 +48,8 @@ export class AntigravityTitleWatcher { // swallowed by a seed still in flight. this.seed(); try { - // Watch the DIRECTORY, not the individual files: agy rewrites history.jsonl - // via atomic replace (drops a file-level watch on Windows) and journals the - // summaries DB through a separate -wal file. + // Watch the DIRECTORY, not the file: agy rewrites history.jsonl via atomic + // replace, which drops a file-level watch on Windows. this.watcher = watch(this.home, (_event, filename) => { // A null filename (some platforms/events omit it) is unclassifiable, so // scan rather than risk dropping a real title change; a named event is @@ -112,9 +110,7 @@ export class AntigravityTitleWatcher { } private apply(renames: Map, seedOnly: boolean): void { - const summaries = readAntigravitySummaries(this.home); - for (const cid of new Set([...renames.keys(), ...summaries.keys()])) { - const title = renames.get(cid) ?? summaries.get(cid); + for (const [cid, title] of renames) { if (!title || this.lastSeen.get(cid) === title) continue; this.lastSeen.set(cid, title); if (!seedOnly) this.onTitle(cid, title); diff --git a/bridge/src/agents/antigravity/title.ts b/bridge/src/agents/antigravity/title.ts index 7feb0829..4938de85 100644 --- a/bridge/src/agents/antigravity/title.ts +++ b/bridge/src/agents/antigravity/title.ts @@ -1,8 +1,7 @@ -import { Database } from "bun:sqlite"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ResolvedTitle } from "../types"; -import { firstMessage, generated, readOrNull } from "../title-read"; +import { firstMessage, manualTitle, readOrNull } from "../title-read"; /** * Antigravity transcript title: first `type:"USER_INPUT"` line's `content`, @@ -65,42 +64,6 @@ export function parseAntigravityRenames(raw: string): Map { return out; } -/** - * agy's own conversation names, keyed by conversationId, from the global - * `conversation_summaries.db`: the manual `title` (its copy of the user's - * `/rename`) if set, else the model-generated `preview` (e.g. "Casual Greeting - * And Introduction"). Fully-empty rows (a brand-new conversation agy hasn't - * named yet) are omitted so the caller falls through to the first user message. - * - * agy writes this DB asynchronously AFTER a turn (WAL mode), so it's the lagging - * upgrade path, not the instant one — history.jsonl carries a `/rename` live. - * Opened read-only with busy_timeout=0 (never wait on agy's write lock); any - * error (locked, missing, schema drift) yields an empty map. Mirrors the codex/ - * copilot sqlite title readers. - */ -export function readAntigravitySummaries(antigravityHome: string): Map { - const out = new Map(); - let db: Database | null = null; - try { - db = new Database(join(antigravityHome, "conversation_summaries.db"), { readonly: true }); - db.exec("PRAGMA busy_timeout = 0"); - const rows = db - .query("SELECT conversation_id, title, preview FROM conversation_summaries") - .all() as Array<{ conversation_id?: string; title?: string | null; preview?: string | null }>; - for (const r of rows) { - const cid = (r.conversation_id ?? "").trim(); - if (!cid) continue; - const name = (r.title ?? "").trim() || (r.preview ?? "").trim(); - if (name) out.set(cid, name); - } - } catch { - // locked / missing / schema drift — leave the map empty - } finally { - db?.close(); - } - return out; -} - /** The latest `/rename` title the user set for `conversationId`, or null. Reads * agy's global history.jsonl under `antigravityHome`. Never throws. */ export async function resolveAntigravityRename( @@ -129,16 +92,19 @@ export async function resolveAntigravityTranscriptTitle(transcriptPath: string): } /** - * agy's best available conversation name, in the same precedence order claude - * uses (manual rename > the agent's own title > first message): - * 1. `/rename` from history.jsonl — manual intent, captured live. - * 2. agy's own name from conversation_summaries.db — its manual `title` or - * generated `preview` (the lagging upgrade path). - * 3. first user message from the transcript — until agy names it. - * Resolving all three here (not only in the watcher) keeps a Stop/PreInvocation - * re-derive from clobbering a higher source back down, and makes the name - * survive resume. Only (3) is a `first-message` — the other two are real names, - * so a generated title is never bought for them. + * agy's best available conversation name, in the same precedence order every + * other resolver uses (manual rename > first message): + * 1. `/rename` from history.jsonl — the user's own name, captured live. + * 2. first user message from the transcript — until our generated name lands. + * + * agy's OWN name (conversation_summaries.db: its copy of the rename, or the + * generated `preview`) would sit between the two and is deliberately not read — + * see ResolvedTitle. Leaving it out costs nothing on (1): `/rename` is logged to + * history.jsonl, so the user's intent still reaches us from a source agy writes + * synchronously, rather than one it fills in a beat later. + * + * Resolving both here (not only in the watcher) keeps a Stop/PreInvocation + * re-derive from clobbering the rename back down, and makes it survive resume. */ export async function resolveAntigravityTitle( conversationId: string, @@ -146,9 +112,7 @@ export async function resolveAntigravityTitle( transcriptPath?: string, ): Promise { const rename = await resolveAntigravityRename(conversationId, antigravityHome); - if (rename) return generated(rename); - const summary = readAntigravitySummaries(antigravityHome).get(conversationId); - if (summary) return generated(summary); + if (rename) return manualTitle(rename); if (!transcriptPath) return null; const first = await resolveAntigravityTranscriptTitle(transcriptPath); return first ? firstMessage(first) : null; diff --git a/bridge/src/agents/claude-code/fork.ts b/bridge/src/agents/claude-code/fork.ts new file mode 100644 index 00000000..b16bdfc9 --- /dev/null +++ b/bridge/src/agents/claude-code/fork.ts @@ -0,0 +1,27 @@ +import { forkHandoffOrTerminal } from "../fork-handoff"; +import type { ForkHandoffOpts } from "../types"; +import { messageText, readTranscript } from "./transcript"; +import { readClaudeTranscript } from "./transcript-read"; + +/** `--fork-session` makes Claude create a new conversation instead of resuming + * the source. Verified against the interactive CLI's resume syntax. */ +export function claudeNativeForkArgs(sessionId: string): string[] { + return ["--resume", sessionId, "--fork-session"]; +} + +/** Claude hook transcripts are JSONL files, normalized by its reader. */ +export async function claudeForkHandoff(opts: ForkHandoffOpts): Promise { + const transcript = await readTranscript(opts); + if (transcript.msgs.length > 0) return forkHandoffOrTerminal("Claude Code", transcript.msgs, opts); + // Chat-mode Claude sessions report an SDK id rather than a hook path. The + // SDK itself exposes no read API, but the same local JSONL store backs its + // cold-resume replay, so use that verified reader before refusing the fork. + const entries = opts.agentSessionId + ? await readClaudeTranscript(opts.projectPath, opts.agentSessionId) + : []; + return forkHandoffOrTerminal( + "Claude Code", + entries.map((entry) => messageText(entry?.message?.content) ?? ""), + opts, + ); +} diff --git a/bridge/src/agents/claude-code/hooks.ts b/bridge/src/agents/claude-code/hooks.ts index 6db0f010..85e5f670 100644 --- a/bridge/src/agents/claude-code/hooks.ts +++ b/bridge/src/agents/claude-code/hooks.ts @@ -65,13 +65,17 @@ const ClaudePayloadSchema = z.object({ session_id: z.string().nullish(), transcript_path: z.string().nullish(), message: z.string().nullish(), + // UserPromptSubmit only: the text the user just submitted. Verified against + // the shipped CLI, whose hook input for that event is + // `{hook_event_name:"UserPromptSubmit", prompt, session_title, ...}`. + prompt: z.string().nullish(), // StopFailure only. Left a bare string rather than the CLI's enum so a value // added upstream still classifies (as a transient) instead of failing the // parse and dropping the event. error: z.string().nullish(), }); -// "user-prompt" (→ /turn-start) is Claude-specific: Claude exposes a +// "user-prompt" (→ /turn-start + /session-title) is Claude-specific: Claude exposes a // UserPromptSubmit hook that fires before each new turn, and it is the ONLY // turn-start signal a terminal-mode Claude session has (chat sessions get // precise `agent:turn-start` frames from their driver instead). @@ -106,6 +110,17 @@ function claudeStopFailureEvent(errorClass: string): "limit_hit" | "turn_failed" return CLAUDE_FATAL_STOP_ERRORS.has(errorClass) ? "turn_end" : "turn_failed"; } +// A submission the model can name a task from. A slash command is the user +// invoking a command, not describing what they want done — "/clear", "/commit" +// and their arguments name the command, so a title generated from one describes +// the tool rather than the session, and the attempt it spends is gone. +// Withholding `prompt` does not drop the post: it falls through to the on-disk +// read, which is what a session without a pre-turn hook already does. +function namesTheSession(prompt: string | null | undefined): boolean { + const text = prompt?.trim(); + return !!text && !text.startsWith("/"); +} + export async function toPosts( invocation: HookInvocation, { port, terminalId, readStdin }: HookPostCtx, @@ -116,7 +131,12 @@ export async function toPosts( if (invocation.event === "session-start" || invocation.event === "stop") { posts.push( titlePost(port, terminalId, input.session_id, "claude", { - transcriptPath: input.transcript_path ?? "", + // Omitted when absent, never "": setAgentSession falls back to the path + // it already holds only for a NULLISH one, so an empty string overwrites + // it — and this post repeats for the life of the session, so a single + // report without a path would cost the handler's judge and the resume + // preflight the real one. + ...(input.transcript_path ? { transcriptPath: input.transcript_path } : {}), }), ); } @@ -130,6 +150,18 @@ export async function toPosts( path: "/turn-start", body: { ...(terminalId ? { terminalId } : {}) }, }); + // Name the session from the prompt the user just submitted. This is the + // whole reason Claude's naming does not wait for the turn to end, and it is + // Claude-only because no other agent exposes a pre-turn hook — the rest + // reach the same code from their turn-END post, minutes later on a real + // task. The bridge treats `prompt` as "name this now", so it must not ride + // any other event. + posts.push( + titlePost(port, terminalId, input.session_id, "claude", { + ...(namesTheSession(input.prompt) ? { prompt: input.prompt } : {}), + ...(input.transcript_path ? { transcriptPath: input.transcript_path } : {}), + }), + ); } if (invocation.event === "stop") { posts.push({ @@ -190,6 +222,14 @@ export async function toPosts( } } if (invocation.event === "notification") { + // Claude Code fires this same "notification" hook, with the identical + // "Claude is waiting for your input" message, both for a genuine mid-turn + // block (e.g. a question tool with no stop event yet) and for its generic + // post-completion idle nudge. We can't tell those apart from the message + // alone, so tag it "awaiting_input" rather than "permission_request" and + // let work-status.ts's turn-state-aware reduction decide whether it's a + // live call-to-action or a stale nudge to ignore. + const isWaitingNudge = !!input.message && /waiting/i.test(input.message); if (terminalId) { posts.push({ port, @@ -200,17 +240,14 @@ export async function toPosts( event: "awaiting_input", transcriptPath: input.transcript_path ?? "", sessionId: input.session_id ?? "", + // The same reading the /notify below branches on, carried so the host's + // stale-nudge drop cannot classify as an idle nudge the very invocation + // it is about to record as a live block: this POST is decided before + // that one has folded into the reduction it reads. + idleNudge: isWaitingNudge, }, }); } - // Claude Code fires this same "notification" hook, with the identical - // "Claude is waiting for your input" message, both for a genuine mid-turn - // block (e.g. a question tool with no stop event yet) and for its generic - // post-completion idle nudge. We can't tell those apart from the message - // alone, so tag it "awaiting_input" rather than "permission_request" and - // let work-status.ts's turn-state-aware reduction decide whether it's a - // live call-to-action or a stale nudge to ignore. - const isWaitingNudge = !!input.message && /waiting/i.test(input.message); posts.push({ port, path: "/notify", diff --git a/bridge/src/agents/claude-code/spawn.ts b/bridge/src/agents/claude-code/spawn.ts index dc7f68f4..7f10840d 100644 --- a/bridge/src/agents/claude-code/spawn.ts +++ b/bridge/src/agents/claude-code/spawn.ts @@ -67,6 +67,11 @@ export function buildClaudeEnv(base?: Record): Recor env.CLAUDE_CODE_ENTRYPOINT ??= "cli"; // first-party rate-limit lane env.ENABLE_TOOL_SEARCH ??= "auto:2"; // trim upfront MCP token load env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT ??= "0"; // don't 300s-kill interactive tools + // Antgrid owns the session lifecycle; a conversation that hands itself to + // Claude Code's own background supervisor leaves the slot pointing at a job + // this bridge does not manage. Chat mode surfaces claude's slash commands + // (chat-backend's ingestCommands), so `/background` is reachable here too. + env.CLAUDE_CODE_DISABLE_AGENT_VIEW ??= "1"; if (process.platform === "win32" && env.USERPROFILE) env.HOME = env.USERPROFILE; // stable ~/.claude for resume return env; } diff --git a/bridge/src/agents/claude-code/title.ts b/bridge/src/agents/claude-code/title.ts index 809fa774..3bb83030 100644 --- a/bridge/src/agents/claude-code/title.ts +++ b/bridge/src/agents/claude-code/title.ts @@ -1,5 +1,5 @@ import type { ResolvedTitle } from "../types"; -import { firstMessage, generated, readOrNull } from "../title-read"; +import { firstMessage, manualTitle, readOrNull } from "../title-read"; /** Extract plain text from a Claude message `content` field (string or parts array). * Returns the FIRST text part — not the same function as ./transcript.ts's @@ -18,44 +18,37 @@ function messageText(content: unknown): string | null { } /** A title field that would actually name something, else null. Blank is not - * nullish, so storing `" "` would let the `??` chain in the resolver return - * it and suppress a lower-precedence title that is genuinely present. One - * helper rather than the check repeated per slot: a copy that omits the - * `.trim()` fails silently, applying no name and hiding the real one. */ + * nullish, so a `" "` kept here would be returned as a manual rename and + * suppress the first-message fallback that is genuinely present, applying no + * name at all. */ function titleField(value: unknown): string | null { return typeof value === "string" && value.trim() ? value : null; } /** * Claude transcript title, in precedence order: - * 1. LAST {type:"custom-title", customTitle} — the name the user gave the - * conversation, so it outranks anything Claude picked for itself. - * 2. LAST {type:"ai-title", aiTitle} — Claude's own generated title. - * 3. LAST {type:"summary", summary} — a compaction summary. Effectively dead: - * current transcript formats emit none at all. - * 4. First {type:"user"} message's text. + * 1. LAST {type:"custom-title", customTitle} — the name the USER gave the + * conversation with `/rename`. `LAST` wins because it can be revised in + * place over a long session. + * 2. First {type:"user"} message's text. * - * The two title records COEXIST rather than one superseding the other: a - * renamed conversation restates BOTH on every turn, adjacently. So precedence - * here is by type and not by file position, neither spelling may be retired as - * legacy, and `LAST` wins within a type only because a value can be revised in - * place over a long session. + * Claude's OWN title rides the same file as {type:"ai-title", aiTitle} (and, + * historically, {type:"summary"}). Both are deliberately not read: we generate + * our own name now (see agents/title-generate.ts), and reading Claude's meant + * a session got a good name in the interactive TUI and an echo of the opening + * prompt in every headless/SDK run — which is every chat-mode session. Note the + * records COEXIST rather than superseding each other: a renamed conversation + * restates both every turn, so `custom-title` is a distinct signal from + * `ai-title` and not a stale copy of it. * - * Branch 4 is not rare — most transcripts carry no title record at all, because - * Claude writes one only in the interactive TUI, never in headless/SDK runs - * (which is every chat-mode session). That was measured against `custom-title` - * before `ai-title` existed and has not been re-measured since — worth knowing, - * because the caller spends a model call on the branch-4 answer (see - * maybeGenerateTitle). A title that IS written lands early, so a missing one - * means absent, not late. Never throws. + * Branch 2 is the norm, not an edge case, and it exists to hold the slot until + * the generated name lands rather than to be a title. Never throws. */ export async function resolveClaudeTranscriptTitle(transcriptPath: string): Promise { const raw = await readOrNull(transcriptPath); if (raw === null) return null; const lines = raw.split("\n"); let firstUser: string | null = null; - let lastSummary: string | null = null; - let lastAi: string | null = null; let lastCustom: string | null = null; for (const line of lines) { const t = line.trim(); @@ -64,16 +57,11 @@ export async function resolveClaudeTranscriptTitle(transcriptPath: string): Prom try { obj = JSON.parse(t); } catch { continue; } if (obj?.type === "custom-title") { lastCustom = titleField(obj.customTitle) ?? lastCustom; - } else if (obj?.type === "ai-title") { - lastAi = titleField(obj.aiTitle) ?? lastAi; - } else if (obj?.type === "summary") { - lastSummary = titleField(obj.summary) ?? lastSummary; } else if (obj?.type === "user" && firstUser === null) { const text = messageText(obj.message?.content); if (text) firstUser = text; } } - const real = lastCustom ?? lastAi ?? lastSummary; - if (real) return generated(real); + if (lastCustom) return manualTitle(lastCustom); return firstUser ? firstMessage(firstUser) : null; } diff --git a/bridge/src/agents/codex/fork.ts b/bridge/src/agents/codex/fork.ts new file mode 100644 index 00000000..d49237db --- /dev/null +++ b/bridge/src/agents/codex/fork.ts @@ -0,0 +1,13 @@ +import { forkHandoffOrTerminal } from "../fork-handoff"; +import type { ForkHandoffOpts } from "../types"; +import { readTranscript } from "./transcript"; + +/** Codex's `fork` subcommand creates a new session from the supplied source. */ +export function codexNativeForkArgs(sessionId: string): string[] { + return ["fork", sessionId]; +} + +/** Codex resolves the captured thread id to its rollout before normalizing it. */ +export async function codexForkHandoff(opts: ForkHandoffOpts): Promise { + return forkHandoffOrTerminal("Codex", (await readTranscript(opts)).msgs, opts); +} diff --git a/bridge/src/agents/codex/title.ts b/bridge/src/agents/codex/title.ts index b51325dd..7f6292fb 100644 --- a/bridge/src/agents/codex/title.ts +++ b/bridge/src/agents/codex/title.ts @@ -3,7 +3,7 @@ import { readdir } from "node:fs/promises"; import { readdirSync } from "node:fs"; import { join } from "node:path"; import type { ResolvedTitle } from "../types"; -import { firstMessage, generated, readOrNull } from "../title-read"; +import { firstMessage } from "../title-read"; /** Parse the schema-version out of a `state_.sqlite` filename, or -1. */ function stateDbVersion(filename: string): number { @@ -20,10 +20,10 @@ function stateDbVersion(filename: string): number { * The CLI writes the first user message into `title` and leaves it there: of 310 * local threads, every one whose `title` differs from `first_user_message` was * written by the Codex DESKTOP app (all 32 appear verbatim in - * session_index.jsonl, which only that app writes). So `title` is reported as - * "generated" ONLY where it actually diverges from first_user_message — equal - * means the CLI never named the thread, and the caller has to generate one. - * Opened read-only; never throws (returns null on any error). + * session_index.jsonl, which only that app writes). So a divergent `title` is + * the desktop app naming the thread for itself, which we deliberately do not + * read — `first_user_message` is the column that answers what this resolver is + * for. Opened read-only; never throws (returns null on any error). */ export async function resolveCodexThreadTitle(threadId: string, codexHome: string): Promise { let dbPath: string; @@ -47,11 +47,16 @@ export async function resolveCodexThreadTitle(threadId: string, codexHome: strin if (!row) return null; const title = (row.title ?? "").trim(); const first = (row.first_user_message ?? "").trim(); - if (title) return title === first ? firstMessage(title) : generated(title); - return first ? firstMessage(first) : null; + if (first) return firstMessage(first); + // Only reachable with an empty first_user_message, where `title` cannot be + // told apart from a desktop-written name. Taken as the opening prompt + // anyway: it holds the slot until our own generated name lands, and the + // alternative is leaving the session unnamed on a column we can't read. + return title ? firstMessage(title) : null; } catch { - // DB locked, missing table, or schema drift — fall through to the caller's - // session_index.jsonl fallback rather than failing the title resolution. + // DB locked, missing table, or schema drift. Unnameable is not fatal: the + // caller keeps whatever name the session already has, and the generated + // title lands on its own path. return null; } finally { db?.close(); @@ -91,28 +96,3 @@ export function codexThreadExistsSync(threadId: string, codexHome: string): bool db?.close(); } } - -/** - * Legacy/desktop-app fallback: Codex's *desktop app* writes conversation titles - * to ~/.codex/session_index.jsonl as append-only {id, thread_name, updated_at} - * lines (the LAST line for an id is current). Every name in here is one the - * desktop app generated, so a hit is unambiguously a real title. The CLI does - * not write this file at all, which is why bridge-spawned sessions never appear - * in it. Scan from the end and return the first match. Never throws. - */ -export async function resolveCodexThreadName(threadId: string, codexHome: string): Promise { - const raw = await readOrNull(join(codexHome, "session_index.jsonl")); - if (raw === null) return null; - const lines = raw.split("\n"); - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim(); - if (!line) continue; - try { - const e = JSON.parse(line) as { id?: string; thread_name?: string }; - if (e.id === threadId && typeof e.thread_name === "string") return generated(e.thread_name); - } catch { - // partial/garbage line (e.g. mid-write tail) — skip - } - } - return null; -} diff --git a/bridge/src/agents/fork-handoff.ts b/bridge/src/agents/fork-handoff.ts new file mode 100644 index 00000000..523e289b --- /dev/null +++ b/bridge/src/agents/fork-handoff.ts @@ -0,0 +1,29 @@ +import type { AgentForkSupport, ForkHandoffOpts } from "./types"; + +/** A stable, provider-neutral envelope. It is deliberately text rather than a + * provider-native resume token: forks must launch a fresh conversation. */ +export function normalizedForkHandoff(agent: string, messages: readonly string[]): string { + const body = messages.map((message) => message.trim()).filter(Boolean).join("\n\n"); + return body ? `[${agent} conversation]\n${body}` : ""; +} + +/** The one fallback ladder every adapter shares: the provider's own conversation + * store when it has one to give, otherwise what the terminal saw. Shared rather + * than restated per agent so a change to the fallback policy — a cap, a + * different envelope — cannot land on some agents and not others. */ +export function forkHandoffOrTerminal( + agent: string, + messages: readonly string[], + opts: ForkHandoffOpts, +): string { + return normalizedForkHandoff(agent, messages) + || normalizedForkHandoff(agent, opts.terminalTranscript ? [opts.terminalTranscript] : []); +} + +/** The explicit adapter for agents with no readable native conversation store. */ +export function terminalForkHandoff(agent: string): AgentForkSupport { + return { + kind: "terminal-transcript", + handoff: async (opts: ForkHandoffOpts) => forkHandoffOrTerminal(agent, [], opts), + }; +} diff --git a/bridge/src/agents/github-copilot/title.ts b/bridge/src/agents/github-copilot/title.ts index 916afcee..a3462212 100644 --- a/bridge/src/agents/github-copilot/title.ts +++ b/bridge/src/agents/github-copilot/title.ts @@ -1,23 +1,27 @@ import { Database } from "bun:sqlite"; import { join } from "node:path"; import type { ResolvedTitle } from "../types"; -import { firstMessage, generated } from "../title-read"; +import { firstMessage } from "../title-read"; /** - * Copilot writes a lazy generated summary to session-store.db. When it has not - * generated one yet, the first user turn is the best available slot title. + * The first user turn of a Copilot session, from session-store.db. + * + * Copilot also writes a lazy generated summary to the `sessions` row, and it is + * deliberately not read: we name sessions ourselves (see ResolvedTitle), and + * depending on it meant a title appeared only once Copilot got round to writing + * one — never for a short session, and never at a predictable moment. */ export async function resolveCopilotSessionTitle(sessionId: string, copilotHome: string): Promise { let db: Database | null = null; try { db = new Database(join(copilotHome, "session-store.db"), { readonly: true }); db.exec("PRAGMA busy_timeout = 0"); + // Still SELECTed: a session row that does not exist is a different answer + // from one with no turns yet, and only the first is "unknown session". const session = db - .query("SELECT summary FROM sessions WHERE id = ?") - .get(sessionId) as { summary?: string | null } | null; + .query("SELECT id FROM sessions WHERE id = ?") + .get(sessionId) as { id?: string | null } | null; if (!session) return null; - const summary = (session.summary ?? "").trim(); - if (summary) return generated(summary); const turn = db .query("SELECT user_message FROM turns WHERE session_id = ? AND trim(coalesce(user_message, '')) <> '' ORDER BY turn_index ASC LIMIT 1") diff --git a/bridge/src/agents/headless.ts b/bridge/src/agents/headless.ts new file mode 100644 index 00000000..08d14ebf --- /dev/null +++ b/bridge/src/agents/headless.ts @@ -0,0 +1,228 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { logger } from "../logger"; +import { killChildTree, stripInheritedCertOverrides } from "../terminal-session"; +import { detectInstalledTools } from "../tool-detector"; +import { AGENTS, agentSpec } from "./registry"; +import { pickHeadlessFrom, type HeadlessCommand, type HeadlessNeed, type HeadlessReach } from "./types"; + +const log = logger.child({ component: "headless" }); + +/** The tightest entry this agent declares that still satisfies `need`. */ +export function pickHeadless( + tool: string, need: HeadlessNeed, +): { reach: HeadlessReach; command: HeadlessCommand } | null { + return pickHeadlessFrom(agentSpec(tool)?.headless, need); +} + +/** + * Which CLI actually runs a `need: "none"` call for a session of `tool`. + * + * The session's own agent when it declares a usable entry, else the first + * INSTALLED agent that does. Borrowing is sound only for this need: the whole + * job is inlined into the prompt and the spawn gets no repo access, no tools and + * no transcript path, so nothing about it is specific to the agent whose work is + * being done. It exists so a one-shot call does not depend on whether a + * particular vendor's argv has been verified yet. + * + * Registry order decides the borrow, so it is stable across runs rather than + * dependent on PATH order. Null = nothing on this machine can serve it — + * including for a `tool` that is not a registry key at all, where borrowing + * would be doing work on behalf of an agent we know nothing about. + * + * A `need: "repo"` call is NEVER borrowed. That need is the Handler's judge, and + * `judgeCapable` gates the whole feature per tool: silently running another + * vendor's agent there would arm a supervisor the user never chose, over a + * working tree, on an account they did not pick. + */ +export function resolveHeadless(tool: string, need: HeadlessNeed, installedTools?: string[]): + { tool: string; reach: HeadlessReach; command: HeadlessCommand } | null { + const own = pickHeadless(tool, need); + if (own) return { tool, ...own }; + if (need !== "none" || !agentSpec(tool)) return null; + const installed = new Set(installedTools ?? detectInstalledTools().map((t) => t.tool)); + for (const key of Object.keys(AGENTS)) { + if (!installed.has(key)) continue; + const borrowed = pickHeadless(key, need); + if (borrowed) return { tool: key, ...borrowed }; + } + return null; +} + +/** + * The working directory for a `need: "none"` spawn: an empty, throwaway one. + * + * These CLIs write a session unconditionally — none of the shipped argvs is + * `sealed`, and most have no ephemeral switch — but every resume surface worth + * worrying about is scoped to the working directory (kimi's --continue says so + * outright; agy's was measured). Running here rather than in the user's checkout + * means a naming spawn can never surface in the picker they actually use. + * + * It is not a sandbox and must not be read as one: the agent keeps whatever + * tools its argv allows. What changes is that there is nothing here to act on, + * which is exactly right for a call whose whole input is already in the prompt. + * Two things fall out for free — the agent finds no repo to read, and no + * project-tier Antgrid hook config either, closing the one gap headlessEnv + * cannot reach by unsetting ANTGRID_TERMINAL_ID. + * + * Never the checkout: a naming run has no use for the tree, and pointing it + * there is what put these sessions in the user's own --continue. + */ +export function headlessScratchCwd(): string { + const dir = join(tmpdir(), "antgrid-headless", "cwd"); + // Best effort: a spawn whose cwd does not exist fails, and that failure is + // already the same null every other headless failure returns. Throwing here + // would instead reject the caller's promise on a path that has nothing to do + // with the model call. + try { mkdirSync(dir, { recursive: true }); } catch { /* the spawn reports it */ } + return dir; +} + +/** How long a killed tree gets to release the stdout pipe before the reads are + * abandoned and the timeout is reported on its own. */ +const ABANDON_GRACE_MS = 2_000; + +export interface HeadlessResult { + stdout: string; + /** Exit code, or null when the process was killed at the timeout. */ + code: number | null; + timedOut: boolean; +} + +/** + * One spawn of an agent CLI, no retry. + * + * Returns whatever was captured even on a non-zero exit or a timeout, and the + * caller decides: a judge parses partial output because a CLI may write a whole + * answer and merely linger on exit, while a caller that would accept any short + * line has to reject a failed run (a refusal like "Invalid API key · Please run + * /login" is six words and parses as a perfectly good title). Null = the spawn + * itself failed. + */ +export async function runHeadless( + cmd: string[], + opts: { + cwd: string; + timeoutMs: number; + spawn?: typeof Bun.spawn; + env?: Record; + /** Env vars to point at a directory created for this spawn and deleted + * after it — see HeadlessCommand.scratchEnv. */ + scratchEnv?: string[]; + }, +): Promise { + const spawn = opts.spawn ?? Bun.spawn; + let timer: ReturnType | undefined; + let abandonTimer: ReturnType | undefined; + const scratch = makeScratchHome(opts.scratchEnv); + try { + const proc = spawn(cmd, { + cwd: opts.cwd, stdout: "pipe", stderr: "ignore", + env: headlessEnv({ ...opts.env, ...scratch?.env }), + }); + let timedOut = false; + // Resolves only if the timeout fires AND the tree kill fails to end the + // reads below. Nothing here is racing the happy path: on it, this promise + // is simply never settled and both awaits win outright. + const abandoned = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + // The TREE, not the handle. Every one of these argvs is reached through + // a launcher script, so the handle is a `cmd.exe`/`sh` wrapper and the + // real agent is its child holding the inherited stdout pipe: killing + // the wrapper alone leaves that pipe open, `proc.stdout` never reaches + // EOF, and this call never settles. The judge awaits it with no outer + // deadline of its own, so that hang wedges a supervised session in + // "handling" for the life of the process. + void killChildTree(proc); + // POSIX cannot reach past the direct child (Bun.spawn starts no process + // group), so the kill above is a best effort there and this is the + // backstop that makes the budget an actual bound. + abandonTimer = setTimeout(() => resolve(null), ABANDON_GRACE_MS); + }, opts.timeoutMs); + }); + const stdout = await Promise.race([ + new Response(proc.stdout).text(), abandoned.then(() => ""), + ]); + const code = await Promise.race([proc.exited, abandoned]); + return { stdout, code: timedOut ? null : code, timedOut }; + } catch { + return null; + } finally { + // Must be `finally`, not a tail call: killing the process mid-read rejects + // the stdout read, and an un-cleared timer then stays armed for the full + // budget holding the dead process alive. + clearTimeout(timer); + clearTimeout(abandonTimer); + scratch?.dispose(); + } +} + +/** + * A private directory for one spawn's redirected state, or null when the + * command asked for none. + * + * Deleted on the way out, which is the whole point: these vars exist because + * the CLI has no ephemeral switch, and a fixed path would accumulate a session + * per call in a temp dir the OS does not reclaim on Windows. + */ +function makeScratchHome(vars?: string[]): + { env: Record; dispose: () => void } | null { + if (!vars?.length) return null; + let dir: string; + try { + dir = mkdtempSync(join(tmpdir(), "antgrid-headless-")); + } catch (err) { + // Falling through to the inherited value would run the spawn against the + // user's REAL agent home and write a session into their history, which is + // the one outcome these entries exist to prevent. + log.warn("no scratch home for a headless spawn: %s", err); + return null; + } + return { + env: Object.fromEntries(vars.map((name) => [name, dir])), + dispose: () => { + // A spawn that outlived its kill still holds its store open, and Windows + // refuses to unlink an open file. One leaked directory on the timeout + // path beats throwing from a `finally` that owes the caller a result. + try { rmSync(dir, { recursive: true, force: true }); } catch { /* leaked */ } + }, + }; +} + +/** + * The environment a headless spawn runs under. + * + * Bun.spawn's `env` REPLACES rather than merges, so this starts from the + * bridge's own environment — the agent's auth lives there and PATH with it — + * and edits two things out: + * + * - the inherited TLS/proxy overrides every other agent spawn in the tree + * strips, because one inherited from the bridge's launcher breaks the + * agent's outbound TLS (see agents/claude-code/spawn.ts). + * - ANTGRID_TERMINAL_ID, stripped rather than merely left unset. agy and + * opencode install their Antgrid hooks GLOBALLY, so this spawn fires them + * too; both no-op without that variable, which is the only thing keeping a + * one-shot call from posting /session-title and /notify for a conversation + * that does not exist (bridge/plugin/antigravity/post-title.js). It does + * NOT silence the project-tier hooks `antgrid plugin install` writes — + * those resolve the port from `$ANTGRID_DIR/api.port` when the env is + * absent, so a call made inside a project that has them still fires one + * (bridge/plugin/hooks/on-stop). + */ +function headlessEnv(overrides?: Record): Record { + const { ANTGRID_TERMINAL_ID: _drop, ...inherited } = process.env; + const env = stripInheritedCertOverrides({ ...inherited } as Record); + return overrides ? { ...env, ...overrides } : env; +} + +/** Logs a borrow once per call site that takes one, so a machine naming + * everything with one vendor's CLI is visible in the log rather than only in + * that vendor's bill. */ +export function logBorrow(need: HeadlessNeed, requested: string, actual: string): void { + if (requested === actual) return; + log.info("running a %s call for %s with %s (no verified headless argv for %s)", + need, requested, actual, requested); +} diff --git a/bridge/src/agents/opencode/chat-backend.ts b/bridge/src/agents/opencode/chat-backend.ts index a8d71c68..e971e875 100644 --- a/bridge/src/agents/opencode/chat-backend.ts +++ b/bridge/src/agents/opencode/chat-backend.ts @@ -52,7 +52,6 @@ export interface OpencodeDriverOpts { sessionId: string; client: OpencodeClientLike; sendMessage: (msg: AbMessage) => void; - onTitle?: (title: string) => void; onLifecycle?: (evt: DriverLifecycleEvent) => void; } @@ -72,7 +71,6 @@ export class OpencodeDriver extends ChatSession { }; private readonly client: OpencodeClientLike; - private readonly onTitle?: (title: string) => void; private readonly onLifecycle?: (evt: DriverLifecycleEvent) => void; private rootSessionId = ""; @@ -101,7 +99,6 @@ export class OpencodeDriver extends ChatSession { constructor(opts: OpencodeDriverOpts) { super({ sessionId: opts.sessionId, sendMessage: opts.sendMessage }); this.client = opts.client; - this.onTitle = opts.onTitle; this.onLifecycle = opts.onLifecycle; } @@ -422,7 +419,9 @@ export class OpencodeDriver extends ChatSession { case "message.part.updated": return this.onPartUpdated(p); case "message.part.delta": return this.onPartDelta(p); case "session.created": return this.onSessionCreated(p); - case "session.updated": return this.onSessionUpdated(p); + // session.updated carries only opencode's own generated conversation + // title, which we deliberately do not apply (see ResolvedTitle) — so the + // event has nothing left to handle. case "session.idle": return this.onSessionIdle(p); case "session.status": return this.onSessionStatus(p); case "session.error": return this.onSessionError(p); @@ -503,19 +502,6 @@ export class OpencodeDriver extends ChatSession { }); } - // opencode's server generates a conversation title and pushes it on - // session.updated. Forward it to the namer (per-session-safe: this driver == - // this chat session). Root only, NOT inTree: subtask children get their own - // generated titles ("Find TODOs … (@explore subagent)"), and accepting one - // would rename the whole chat session after whatever a subagent last did. - private onSessionUpdated(p: any): void { - const info = p?.info ?? {}; - if (info.id !== this.rootSessionId) return; - const title = typeof info.title === "string" ? info.title.trim() : ""; - if (!title) return; - this.onTitle?.(title); - } - private flipSubtask(sid: string, status: "completed" | "error" | "cancelled", rawError?: any): void { const itemId = this.subtaskItem.get(sid); if (!itemId) return; diff --git a/bridge/src/agents/opencode/driver.ts b/bridge/src/agents/opencode/driver.ts index 6d03e189..b898d5ba 100644 --- a/bridge/src/agents/opencode/driver.ts +++ b/bridge/src/agents/opencode/driver.ts @@ -30,7 +30,7 @@ export function createDriver(ctx: DriverCtx): StructuredDriver { }; ctx.emitUpdateCheck(); return new OpencodeDriver({ - sessionId: ctx.sessionId, client: lazy, sendMessage: ctx.send, onTitle: ctx.onTitle, + sessionId: ctx.sessionId, client: lazy, sendMessage: ctx.send, onLifecycle: ctx.onLifecycle, }); } diff --git a/bridge/src/agents/opencode/fork.ts b/bridge/src/agents/opencode/fork.ts new file mode 100644 index 00000000..92f38091 --- /dev/null +++ b/bridge/src/agents/opencode/fork.ts @@ -0,0 +1,13 @@ +import { forkHandoffOrTerminal } from "../fork-handoff"; +import type { ForkHandoffOpts } from "../types"; +import { readTranscript } from "./transcript"; + +/** `opencode --session --fork` clones instead of resuming the source. */ +export function opencodeNativeForkArgs(sessionId: string): string[] { + return ["--session", sessionId, "--fork"]; +} + +/** OpenCode's adapter reads the session rows from its local SQLite store. */ +export async function opencodeForkHandoff(opts: ForkHandoffOpts): Promise { + return forkHandoffOrTerminal("OpenCode", (await readTranscript(opts)).msgs, opts); +} diff --git a/bridge/src/agents/registry.ts b/bridge/src/agents/registry.ts index 58bc7100..191865d8 100644 --- a/bridge/src/agents/registry.ts +++ b/bridge/src/agents/registry.ts @@ -12,11 +12,7 @@ import { join } from "node:path"; import { readCodexVersionJson, codexHomeDir } from "./codex/home"; import { antigravityCliHome, resolveAntigravityTitle } from "./antigravity/title"; import { resolveClaudeTranscriptTitle } from "./claude-code/title"; -import { - codexThreadExistsSync, - resolveCodexThreadName, - resolveCodexThreadTitle, -} from "./codex/title"; +import { codexThreadExistsSync, resolveCodexThreadTitle } from "./codex/title"; import { copilotSessionExistsSync, resolveCopilotSessionTitle } from "./github-copilot/title"; import { injectConfig } from "./config-inject"; import * as antigravityHooks from "./antigravity/hooks"; @@ -31,7 +27,12 @@ import { createDriver as createOpencodeDriver } from "./opencode/driver"; import { lastAssistantText, readTranscript as readClaudeTranscript } from "./claude-code/transcript"; import { readTranscript as readCodexTranscript } from "./codex/transcript"; import { readTranscript as readOpencodeTranscript } from "./opencode/transcript"; -import type { AgentKey, AgentSpec } from "./types"; +import { claudeForkHandoff, claudeNativeForkArgs } from "./claude-code/fork"; +import { codexForkHandoff, codexNativeForkArgs } from "./codex/fork"; +import { opencodeForkHandoff, opencodeNativeForkArgs } from "./opencode/fork"; +import { terminalForkHandoff } from "./fork-handoff"; + +import { pickHeadlessFrom, type AgentKey, type AgentSpec } from "./types"; export const AGENTS: Record = { "claude-code": { @@ -43,27 +44,54 @@ export const AGENTS: Record = { titleSource: "structured", resume: (id) => ["--resume", id], initialPrompt: (p) => ["--", p], + fork: { + kind: "native-fork", + handoff: claudeForkHandoff, + nativeForkArgs: claudeNativeForkArgs, + }, hooks: claudeHooks, notifyBodyFromTranscript: lastAssistantText, driver: createClaudeDriver, - judge: { - tier: "readonly", - // The prompt goes BEFORE --allowedTools, not last. --allowedTools is - // variadic, so a trailing prompt is parsed as one more tool name and - // claude exits 1 with "Input must be provided ... when using --print" — - // i.e. every judge call silently fails closed. Verified against the real - // CLI; keep the prompt ahead of any variadic flag added here later. - // --no-session-persistence keeps judge runs out of the user's own history: - // a supervisor pass is machine bookkeeping, and one per agent pause buries - // the sessions the user actually started under /resume. Valid only with - // --print, which this argv already uses. - cmd: (prompt, model) => [ - "claude", "-p", prompt, "--no-session-persistence", "--allowedTools", - "Read,Grep,Glob,Bash(git status:*),Bash(git diff:*),Bash(git log:*)", - ...(model ? ["--model", model] : []), - ], + // No "sealed" entry: an allowlist naming this agent's read tools is what a + // sealed argv would have to omit, and `--allowedTools` with an empty value + // has not been run against the real CLI. Until it is, naming takes the + // readonly entry below — which is what it already ran under, since the + // previous naming argv denied only Bash/Edit/Write/NotebookEdit and left + // Read, Grep and Glob allowed. + // + // NOT `--bare`, which looks made for this (skips hooks, plugins, memory): + // it also forces ANTHROPIC_API_KEY-only auth and never reads OAuth or the + // keychain, so it fails closed for every subscription user. + headless: { + readonly: { + // The prompt goes BEFORE --allowedTools, not last. --allowedTools is + // variadic, so a trailing prompt is parsed as one more tool name and + // claude exits 1 with "Input must be provided ... when using --print" — + // i.e. every call silently fails closed. Verified against the real CLI; + // keep the prompt ahead of any variadic flag added here later. + cmd: (prompt, model) => [ + "claude", "-p", prompt, "--no-session-persistence", "--allowedTools", + "Read,Grep,Glob,Bash(git status:*),Bash(git diff:*),Bash(git log:*)", + ...(model ? ["--model", model] : []), + ], + // --no-session-persistence keeps these runs out of the user's own + // history: a supervisor pass or a naming call is machine bookkeeping, + // and one per agent pause buries the sessions the user actually started + // under /resume. Valid only with --print, which this argv already uses. + noHistory: "flag", + }, }, transcript: readClaudeTranscript, + // Antgrid owns the session lifecycle: a conversation that hands itself to + // claude's own background supervisor exits the PTY, leaves the slot + // resuming an id a job we don't manage still holds, and relocates its cwd + // out of the session's checkout. Forced, not `??=` like buildClaudeEnv's + // defaults — no per-machine preference makes that outcome survivable here. + // Closes `/background`, `--bg`, `--routine`, `claude agents`. Does NOT + // close the two-press LEFT-ARROW gesture: its only guard is the + // machine-wide `leftArrowOpensAgents` global-config key, and the fleet + // gate never reaches the REPL keymap (measured on the 2.1.247 binary). + env: () => ({ CLAUDE_CODE_DISABLE_AGENT_VIEW: "1" }), resumable: ({ transcriptPath }) => !transcriptPath || existsSync(transcriptPath), resolveTitle: async ({ transcriptPath }) => transcriptPath ? await resolveClaudeTranscriptTitle(transcriptPath) : null, @@ -83,38 +111,44 @@ export const AGENTS: Record = { resume: (id) => ["resume", id], resumeIsSubcommand: true, initialPrompt: (p) => ["--", p], + fork: { + kind: "native-fork", + handoff: codexForkHandoff, + nativeForkArgs: codexNativeForkArgs, + }, hooks: codexHooks, driver: createCodexDriver, - judge: { - tier: "readonly", - // --skip-git-repo-check because a project need not be a git repo: without - // it codex exits 1 on "Not inside a trusted directory" and the judge fails - // closed for every non-repo project. It does not widen the tier — the - // read-only sandbox is what makes this argv provably read-only, and that - // check only guards against writes in untracked dirs. - // --ephemeral is codex's equivalent of claude's --no-session-persistence: - // no rollout file, so `codex exec resume --last` still points at the user's - // own work rather than at whichever supervisor pass ran most recently. - cmd: (prompt, model) => [ - "codex", "exec", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check", - ...(model ? ["-m", model] : []), prompt, - ], + // codex offers no sandbox tighter than read-only, so there is no sealed + // entry to write: `--sandbox read-only` is the floor. + headless: { + readonly: { + // --skip-git-repo-check because a project need not be a git repo: + // without it codex exits 1 on "Not inside a trusted directory" and every + // call fails closed for non-repo projects. It does not widen the reach — + // the read-only sandbox is what makes this argv provably read-only, and + // that check only guards against writes in untracked dirs. + cmd: (prompt, model) => [ + "codex", "exec", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check", + ...(model ? ["-m", model] : []), prompt, + ], + // --ephemeral is codex's equivalent of claude's --no-session-persistence: + // no rollout file, so `codex exec resume --last` still points at the + // user's own work rather than at whichever bookkeeping pass ran most + // recently. + noHistory: "flag", + }, }, transcript: readCodexTranscript, // null = the DB is undeterminable (missing/locked/schema drift), which is // not a confirmation that the thread is gone. resumable: ({ agentSessionId, codexHome }) => codexThreadExistsSync(agentSessionId, codexHome ?? join(homedir(), ".codex")) ?? true, - resolveTitle: async ({ sessionId, codexHome }) => { - const home = codexHome ?? join(homedir(), ".codex"); - // Prefer the desktop app's richer generated title (session_index.jsonl) when - // it has indexed this thread; otherwise use the CLI's live state DB, which is - // the only source populated for bridge-spawned `codex-tui` sessions. - return ( - (await resolveCodexThreadName(sessionId, home)) ?? - (await resolveCodexThreadTitle(sessionId, home)) - ); - }, + // The CLI's live state DB is the only source populated for bridge-spawned + // `codex-tui` sessions. session_index.jsonl is not read at all: every name + // in it is one the Codex DESKTOP app generated, and we name sessions + // ourselves (see ResolvedTitle). + resolveTitle: async ({ sessionId, codexHome }) => + await resolveCodexThreadTitle(sessionId, codexHome ?? join(homedir(), ".codex")), update: { npmPackage: "@openai/codex", command: "codex", @@ -134,36 +168,49 @@ export const AGENTS: Record = { titleSource: "structured", resume: (id) => ["--session", id], initialPrompt: (p) => ["--prompt", p], + fork: { + kind: "native-fork", + handoff: opencodeForkHandoff, + nativeForkArgs: opencodeNativeForkArgs, + }, hooks: opencodeHooks, driver: createOpencodeDriver, - judge: { - // --agent plan selects opencode's built-in restricted Plan agent - // (edits denied by default; non-interactive `run` without --auto fails - // permission asks closed). Config-level, not flag-proven like claude's - // --allowedTools, so the tier stays "transcript": no tool hints, and no - // transcript-path handed to a judge whose restriction we can't verify. - tier: "transcript", - cmd: (prompt, model) => - ["opencode", "run", "--agent", "plan", ...(model ? ["--model", model] : []), prompt], - // opencode has no --ephemeral, so persistence is redirected instead of - // disabled: the whole session store is one SQLite file, and OPENCODE_DB - // takes `:memory:` verbatim (opencode's own tests and its desktop dev build - // use the same override), so the judge's session is never written anywhere. - // - // Safe for auth, which is the question this turns on: model credentials - // live in auth.json under the DATA dir, read via OPENCODE_AUTH_CONTENT or - // the file — never through the database. (The `credential` table alongside - // `session` is connector secrets, not provider auth.) For the same reason - // never redirect XDG_DATA_HOME to achieve this: that WOULD move auth.json. - // - // What the scratch DB does lose is the `account` row, so an opencode-account - // token is absent for this spawn — no session sharing, and no account-backed - // remote config. A judge shares nothing; remote config is the live risk if a - // team serves the judge's model settings that way. - env: { OPENCODE_DB: ":memory:" }, + headless: { + // "transcript", not "readonly": --agent plan selects opencode's built-in + // restricted Plan agent (edits denied by default; non-interactive `run` + // without --auto fails permission asks closed), which is config-level + // rather than flag-proven like claude's --allowedTools. So no tool hints, + // and no transcript path handed to a spawn whose restriction we can't + // verify. + transcript: { + cmd: (prompt, model) => + ["opencode", "run", "--agent", "plan", ...(model ? ["--model", model] : []), prompt], + // opencode has no --ephemeral, so persistence is redirected instead of + // disabled: the whole session store is one SQLite file, and OPENCODE_DB + // takes `:memory:` verbatim (opencode's own tests and its desktop dev + // build use the same override), so the spawn's session is never written + // anywhere. + // + // Safe for auth, which is the question this turns on: model credentials + // live in auth.json under the DATA dir, read via OPENCODE_AUTH_CONTENT + // or the file — never through the database. (The `credential` table + // alongside `session` is connector secrets, not provider auth.) For the + // same reason never redirect XDG_DATA_HOME to achieve this: that WOULD + // move auth.json. + // + // What the scratch DB does lose is the `account` row, so an + // opencode-account token is absent for this spawn — no session sharing, + // and no account-backed remote config. Neither caller shares anything; + // remote config is the live risk if a team serves model settings that way. + env: { OPENCODE_DB: ":memory:" }, + noHistory: "ephemeral-store", + }, }, transcript: readOpencodeTranscript, - // No resolveTitle: opencode's plugin posts the title inline. + // No resolveTitle: opencode writes no name of its own that we read. The + // one it generates arrives inline on the plugin's post and is dropped + // (see ResolvedTitle), so an opencode session is named only by + // generation off the transcript above. env: ({ abDir }) => injectConfig("OPENCODE_TUI_CONFIG", abDir, "opencode-tui.json", { attention: { enabled: true }, @@ -179,8 +226,16 @@ export const AGENTS: Record = { titleSource: "osc", resume: (id) => ["--resume", id], initialPrompt: (p) => ["--", p], + fork: terminalForkHandoff("Cursor"), hooks: cursorHooks, augmentsDefaultSpec: true, + // No headless entry. `-p --mode ask` reads like the right argv and has + // never been run: cursor-agent exits 1 on every invocation without an + // `agent login` or CURSOR_API_KEY, so nothing about that argv's reach has + // been observed. Absence is the honest answer, and it is not only about a + // wrong label — ANY non-sealed reach makes the agent judge-capable, which + // would arm a supervisor over the user's working tree on an argv nobody + // has run. Naming is unaffected: a "none" call borrows an installed agent. }, "github-copilot": { bin: "copilot", @@ -192,6 +247,7 @@ export const AGENTS: Record = { // Copilot's optional-value --resume drops a space-separated value. resume: (id) => [`--resume=${id}`], initialPrompt: () => [], + fork: terminalForkHandoff("GitHub Copilot"), hooks: copilotHooks, augmentsDefaultSpec: true, resumable: ({ agentSessionId, copilotHome }) => @@ -206,6 +262,32 @@ export const AGENTS: Record = { ), // No `update`: github-copilot ships no self-updater (IDE-bound), so a // request for one fails soft via updateSpecFor → null. + headless: { + // "readonly", not "sealed": -p reads the working tree with no flag asking + // it to. Measured — it answered a "read package.json" prompt even under + // --deny-tool, whose value is optional and which therefore denies nothing + // when passed bare. Writes are the other half and they fail CLOSED: with + // --allow-all-tools withheld, a write hits a permission ask that + // non-interactive mode cannot answer ("unable to create the file due to + // permission restrictions"), which is what the entry relies on since no + // flag expresses read-only directly. + readonly: { + cmd: (prompt, model) => [ + "copilot", "-p", prompt, "--silent", + ...(model ? ["--model", model] : []), + ], + // Copilot has no ephemeral flag — a -p run writes session-store.db and a + // whole session-state// tree — so the home is redirected to a + // directory that lives only as long as the spawn. + // Safe for auth, and that is NOT the generalization it looks like: + // credentials do not live under COPILOT_HOME at all (no GH_TOKEN or + // GITHUB_TOKEN path either), so a run against an EMPTY scratch home + // still authenticates. Measured, because the opposite is true of vibe, + // where the same move would take the credentials with it. + scratchEnv: ["COPILOT_HOME"], + noHistory: "ephemeral-store", + }, + }, }, // Plugin-tier, but through agy's own GLOBAL `~/.gemini/config/hooks.json` // (see ./antigravity/hooks.ts) — it has no per-spawn hook channel, the same @@ -225,6 +307,7 @@ export const AGENTS: Record = { // args. See `agy --help`. resume: (id) => ["--conversation", id], initialPrompt: (p) => ["--prompt-interactive", p], + fork: terminalForkHandoff("Antigravity"), hooks: antigravityHooks, augmentsDefaultSpec: true, resolveTitle: async ({ sessionId, transcriptPath, antigravityHome }) => @@ -246,10 +329,25 @@ export const AGENTS: Record = { titleSource: "osc", resume: () => [], initialPrompt: () => [], + // Kilo documents `--session --fork`, but this integration does not + // observe a Kilo-native id. Do not advertise an unreachable native path. + fork: terminalForkHandoff("Kilo"), env: ({ abDir }) => injectConfig("KILO_TUI_CONFIG", abDir, "kilo-tui.json", { attention: { enabled: true }, }), + headless: { + // Kilo is an opencode fork down to the env-var names, so this is + // opencode's entry with the prefix changed — see it for why "transcript" + // rather than "readonly", and why the store is redirected rather than the + // data dir (auth lives beside the DB, not inside it). + transcript: { + cmd: (prompt, model) => + ["kilo", "run", "--agent", "plan", ...(model ? ["--model", model] : []), prompt], + env: { KILO_DB: ":memory:" }, + noHistory: "ephemeral-store", + }, + }, }, // Signals only with a bare terminal bell (no OSC 9/777). Since the bell now // rings audibly instead of raising a desktop notification, kimi is heard, not @@ -263,6 +361,7 @@ export const AGENTS: Record = { titleSource: "osc", resume: () => [], initialPrompt: () => [], + fork: terminalForkHandoff("Kimi"), }, // Textual TUI: notifications default ON, fails CLOSED on Textual focus // (DEC 1004-derived), so the default-blur drives it — no injection. @@ -275,6 +374,22 @@ export const AGENTS: Record = { titleSource: "osc", resume: () => [], initialPrompt: () => [], + // No headless entry, and `-p --agent ask` must not come back as one. Read + // against mistralai/mistral-vibe v2.24.5: `ask` is the APPROVAL-gated + // profile ("Requires approval for tool executions"), not a read-only one — + // that is `plan`, the only builtin pinning write_file and edit to + // permission "never". What makes ask LOOK read-only is that programmatic + // mode denies every callback it is handed (cli/programmatic.py), so a write + // fails closed on an approval it cannot answer. + // + // That is config-level, never argv-level, which is the whole distinction + // HeadlessReach draws: an agent profile is just another config layer, `ask` + // contributes no bypass_tool_permissions key, and the loop returns EXECUTE + // before consulting any permission the moment a user's own config sets one + // — no approval is raised, so nothing is denied. Even `--agent plan` falls + // to the same switch, so the best reach available here is "transcript", and + // it stays unrun besides (no MISTRAL_API_KEY on any machine measured). + fork: terminalForkHandoff("Mistral Vibe"), }, }; @@ -307,7 +422,11 @@ export function agentSpec(tool: string): AgentSpec | undefined { * always-present and let an unknown tool through. */ export function judgeCapable(tool: string): boolean { - return agentSpec(tool)?.judge !== undefined; + // "repo", never "has a headless entry at all": a judge reads the working tree, + // so a sealed argv cannot serve one — and treating any one-shot capability as + // a judge would arm the Handler for every agent that can merely answer a + // question (see AgentSpec.headless). + return pickHeadlessFrom(agentSpec(tool)?.headless, "repo") !== null; } /** diff --git a/bridge/src/agents/title-attempts.ts b/bridge/src/agents/title-attempts.ts new file mode 100644 index 00000000..f6327d24 --- /dev/null +++ b/bridge/src/agents/title-attempts.ts @@ -0,0 +1,102 @@ +/** How a claimed attempt ended — see {@link TitleAttempts.settle}. */ +export type TitleOutcome = + /** A title was generated and applied. Nothing tries again. */ + | "named" + /** The spawn ran and produced no usable title (signed-out CLI, timeout, + * rambling answer). Counts against the budget; a later turn may retry. */ + | "failed" + /** Nothing installed can serve the call at all. Not this attempt's failure, + * so it does not count against the budget — it ends it. */ + | "unavailable" + /** The generated title was thrown away for a reason unrelated to generating + * it: the user renamed the session mid-spawn, or the conversation moved on. + * Releases the claim and records nothing. */ + | "abandoned"; + +interface AttemptState { + /** A spawn is running for this conversation right now. Mutual exclusion only; + * always cleared, however the spawn ends. */ + inFlight: boolean; + /** Spawns that ran and produced no usable title. Bounded, because the cause + * is usually not transient at all and each attempt costs a ~45s budget. */ + failures: number; + /** Named, or given up on. Terminal either way — only `forget` reopens it. */ + done: boolean; +} + +/** + * Whether a session may spend another title-generation spawn. + * + * Three fields rather than the single "already attempted" flag this replaces, + * because that flag answered three questions with one bit and got two of them + * wrong: a spawn that FAILED — a signed-out CLI, a timeout — marked the + * conversation spent, so the session could never be named afterwards even once + * the cause was fixed. Collapsing them back into a bare retry count + * reintroduces the other half: a count cannot also exclude a second spawn while + * the first is still running, and two turns ending at once both start one. + * + * Keyed by conversation within terminal, never by a flat `:` + * string: terminal ids contain colons of their own (`:setup`), so + * in a flat key space one terminal's release reaches another's entries by + * prefix. + */ +export class TitleAttempts { + private readonly byTerminal = new Map>(); + + constructor(private readonly maxFailures = 2) {} + + /** + * Whether a further spawn would be refused. Reads only, so it is safe as the + * early-out on a hot path — every turn of every session posts a title, and + * without it each one pays a transcript read only to be refused after it. + */ + refused(terminalId: string, conversationId: string): boolean { + const state = this.byTerminal.get(terminalId)?.get(conversationId); + if (!state) return false; + return state.done || state.inFlight || state.failures >= this.maxFailures; + } + + /** + * Claim the conversation for one spawn; false when it is refused. + * + * Check and set together and without an await between them, which is what + * makes it exclusion rather than a hint: two turns can end while a first + * spawn is still running, and a caller that re-read {@link refused} and then + * claimed would let both through. + * + * Every true MUST be paired with a {@link settle} in a `finally` — a claim + * that outlives its spawn is the permanent refusal this class exists to + * remove. + */ + begin(terminalId: string, conversationId: string): boolean { + if (this.refused(terminalId, conversationId)) return false; + let perTerminal = this.byTerminal.get(terminalId); + if (!perTerminal) this.byTerminal.set(terminalId, (perTerminal = new Map())); + const state = perTerminal.get(conversationId) + ?? { inFlight: false, failures: 0, done: false }; + state.inFlight = true; + perTerminal.set(conversationId, state); + return true; + } + + /** Release a claim and record how it ended. */ + settle(terminalId: string, conversationId: string, outcome: TitleOutcome): void { + const state = this.byTerminal.get(terminalId)?.get(conversationId); + if (!state) return; + state.inFlight = false; + if (outcome === "named" || outcome === "unavailable") state.done = true; + else if (outcome === "failed") state.failures += 1; + } + + /** + * Released with the namer's buffered title, never separately. The two halves + * answer the same question — has this slot been named — and a `forget` that + * dropped only the rank left a session whose generated name the next + * first-message read overwrote, with generation refused forever after. A + * resume reuses the agent session id, so the key alone cannot tell the runs + * apart. + */ + forget(terminalId: string): void { + this.byTerminal.delete(terminalId); + } +} diff --git a/bridge/src/agents/title-generate.ts b/bridge/src/agents/title-generate.ts index fda85e01..16b0e091 100644 --- a/bridge/src/agents/title-generate.ts +++ b/bridge/src/agents/title-generate.ts @@ -1,18 +1,29 @@ import { logger } from "../logger"; +import { headlessScratchCwd, logBorrow, resolveHeadless, runHeadless } from "./headless"; import { agentSpec } from "./registry"; const log = logger.child({ component: "title-generate" }); /** Matches SessionNamer's cap so a title that survives here survives there. */ const MAX_TITLE_LEN = 60; -/** Enough of the opening exchange to name it; more only costs judge tokens. */ +/** Enough of the opening exchange to name it; more only costs tokens. */ const MAX_MSGS = 4; const MAX_CONTEXT_CHARS = 4_000; const DEFAULT_TIMEOUT_MS = 45_000; +// "often only the opening request" and "not the wording" both earn their place: +// this runs at the first user message for Claude, where the model has one +// message to work from and paraphrases it back unless told to name the +// underlying task. An echo is the exact outcome generating a title exists to +// avoid. "an excerpt" is not hedging either — every reader returns the LAST +// maxMsgs messages (AgentSpec.transcript), so an agent reached from its +// turn-END post hands the model the middle of a session, and calling that the +// start had it name whatever the session had drifted to. const PROMPT_HEAD = - "Below is the start of a coding session. Reply with a title for it: " + - "3 to 6 words, imperative mood, no quotes, no trailing period, no preamble. " + + "Below is an excerpt of a coding session — often only the opening request. " + + "Reply with a title for the session's overall task: 3 to 6 words naming the " + + "task, not the wording. " + + "Imperative mood, no quotes, no trailing period, no preamble. " + "Output the title alone on a single line.\n\n"; /** @@ -41,67 +52,33 @@ function buildPrompt(context: string): string { return PROMPT_HEAD + context.slice(0, MAX_CONTEXT_CHARS); } -/** One spawn, no retry: a title is advisory, and the session already has a - * usable name. Returns stdout, or null on spawn failure/timeout. */ -async function spawnOnce( - cmd: string[], cwd: string, timeoutMs: number, spawn: typeof Bun.spawn, -): Promise { - let timer: ReturnType | undefined; - try { - const proc = spawn(cmd, { cwd, stdout: "pipe", stderr: "ignore" }); - let timedOut = false; - timer = setTimeout(() => { - timedOut = true; - try { proc.kill(); } catch { /* already gone */ } - }, timeoutMs); - const out = await new Response(proc.stdout).text(); - await proc.exited; - return timedOut ? null : out; - } catch { - return null; - } finally { - // Same reason as the handler's judge spawn: killing mid-read rejects the - // stdout promise, and an uncleared timer would hold the dead proc alive. - clearTimeout(timer); - } -} - /** - * Name a session by asking the agent's own headless CLI, for the sessions no - * agent will name for us. + * The conversation text a title is written from, or null when there is nothing + * to name yet. * - * This exists because there is nothing left to read: codex never generates a - * thread title (only its desktop app does), and Claude only writes one in the - * interactive TUI — so chat-mode and short sessions bottom out at "echo the - * user's first message" on every agent. See ResolvedTitle in ./types. + * Split out from {@link generateTitleFromContext} so the caller can spend its + * one-shot-per-session gate only once it knows a name is actually reachable. A + * combined call cannot: the gate has to be claimed BEFORE any await to keep two + * turns ending at once from both spawning, so claiming it around the whole + * thing burned the attempt on the SessionStart post — which arrives before the + * user has typed anything and therefore always resolves to no context at all. * - * Reuses `spec.judge.cmd`, which is already the vetted headless one-shot for - * this tool (and already carries its auth). The tier is irrelevant here, unlike - * in the handler: the conversation is inlined into the prompt, so the judge - * needs no tool access and is handed no transcript path. - * - * Never throws — every failure is a null and the caller keeps the name it has. + * Never throws; a transcript that cannot be read falls back to `fallbackContext`. */ -export async function generateSessionTitle(opts: { +export async function buildTitleContext(opts: { /** Registry key (`claude-code`), not a hook name. */ tool: string; - cwd: string; transcriptPath?: string; agentSessionId?: string; - /** Used when the agent exposes no readable transcript; normally the - * first-message title we are trying to improve on. */ + /** Used when the agent exposes no readable transcript, or has not written the + * turn yet — the first user message, or the first-message title we are + * trying to improve on. */ fallbackContext?: string; - model?: string; - timeoutMs?: number; // Test seams; production callers omit these. - spawn?: typeof Bun.spawn; codexHome?: string; opencodeDbPath?: string; }): Promise { const spec = agentSpec(opts.tool); - const judge = spec?.judge; - if (!judge) return null; - let context = ""; if (spec?.transcript) { try { @@ -118,13 +95,68 @@ export async function generateSessionTitle(opts: { } } if (!context.trim()) context = opts.fallbackContext?.trim() ?? ""; - if (!context) return null; + return context || null; +} + +/** + * A generated title, or why there is none. + * + * The two reasons are NOT interchangeable to the caller, which is the whole + * point of returning one rather than a bare null: "failed" is a spawn that ran + * and did not produce a usable title — a signed-out CLI, a timeout, a rambling + * answer — any of which the next turn may not repeat, so it is worth a bounded + * retry. "unavailable" is the machine's answer, not this attempt's: no + * installed agent declares an argv that can serve the call at all, so every + * retry would re-read a transcript to reach the same refusal. + */ +export type TitleGeneration = + | { ok: true; title: string } + | { ok: false; reason: "unavailable" | "failed" }; - const prompt = buildPrompt(context); - const stdout = await spawnOnce( - judge.cmd(prompt, opts.model), opts.cwd, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, - opts.spawn ?? Bun.spawn, - ); - if (stdout === null) return null; - return parseTitleFromOutput(stdout); +/** + * Name a session by asking a headless CLI, rather than waiting to see whether + * the agent names it for us. + * + * Agents disagree about this completely — codex's CLI never names a thread + * (only its desktop app does), Claude writes one in the interactive TUI and + * never in a headless/SDK run, Copilot fills one in eventually — so depending + * on them meant the quality and the timing of a session's name were decided by + * which agent it happened to run. See ResolvedTitle in ./types. + * + * One caller of AgentSpec.headless among several, and the one that needs the + * least: the conversation is inlined into the prompt, so it asks for `need: + * "none"` and takes whichever installed agent can serve it (see resolveHeadless). + * + * Never throws — every failure is a `reason` and the caller keeps the name it has. + */ +export async function generateTitleFromContext(context: string, opts: { + tool: string; + model?: string; + timeoutMs?: number; + spawn?: typeof Bun.spawn; + /** Test seam; production reads PATH via detectInstalledTools(). */ + installedTools?: string[]; +}): Promise { + // `need: "none"` — the conversation is inlined into the prompt, so this asks + // for the tightest argv the agent has rather than one that can reach the repo. + const picked = resolveHeadless(opts.tool, "none", opts.installedTools); + if (!picked) return { ok: false, reason: "unavailable" }; + logBorrow("none", opts.tool, picked.tool); + const result = await runHeadless(picked.command.cmd(buildPrompt(context), opts.model), { + cwd: headlessScratchCwd(), + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + spawn: opts.spawn, + env: picked.command.env, + scratchEnv: picked.command.scratchEnv, + }); + // A timeout or a non-zero exit discards the output rather than parsing it. + // These CLIs print their refusals to STDOUT and they are short: "Invalid API + // key · Please run /login" clears every one of parseTitleFromOutput's checks + // and reads as a title. With the `self` rank outranking the first-message + // re-read, that error string would be the session's name for good. + if (!result || result.code !== 0) return { ok: false, reason: "failed" }; + const title = parseTitleFromOutput(result.stdout); + // An unparseable answer is a failed attempt, not an absent capability: the + // spawn worked and the model rambled, which the next turn may not repeat. + return title ? { ok: true, title } : { ok: false, reason: "failed" }; } diff --git a/bridge/src/agents/title-read.ts b/bridge/src/agents/title-read.ts index 03970d29..1ec2b491 100644 --- a/bridge/src/agents/title-read.ts +++ b/bridge/src/agents/title-read.ts @@ -5,7 +5,9 @@ import { readFile } from "node:fs/promises"; import type { ResolvedTitle } from "./types"; -export const generated = (title: string): ResolvedTitle => ({ title, kind: "generated" }); +/** A name the USER typed at the agent. There is deliberately no constructor for + * a name the agent generated for itself — see ResolvedTitle. */ +export const manualTitle = (title: string): ResolvedTitle => ({ title, kind: "manual" }); export const firstMessage = (title: string): ResolvedTitle => ({ title, kind: "first-message" }); /** Read a UTF-8 file, returning null on any error (missing / unreadable). Async diff --git a/bridge/src/agents/types.ts b/bridge/src/agents/types.ts index 15d26308..41900e6d 100644 --- a/bridge/src/agents/types.ts +++ b/bridge/src/agents/types.ts @@ -2,7 +2,6 @@ import type { HookCommand } from "../hook-command"; import type { HookInvocation, HookPath, HookPost } from "./hook-posts"; import type { AbMessage } from "../protocol"; import type { StructuredDriver } from "../structured/structured-manager"; -import type { JudgeTier } from "../handler/decision"; import type { HandlerEvent } from "../handler/engine"; /** @@ -123,8 +122,6 @@ export interface DriverCtx { chatAugment: () => { args: string[]; env: Record }; /** Persist the agent-native resume id for this slot (overwrite-latest). */ onAgentSession: (agentSessionId: string) => void; - /** Hand a driver-supplied session title to the namer. */ - onTitle: (title: string) => void; /** Report that the provider stopped serving this session (limit or outage) so * an armed Handler parks instead of going silent. Optional: a driver with no * structured signal for it changes nothing by leaving it unused. */ @@ -151,6 +148,26 @@ export interface TranscriptOpts { opencodeDbPath?: string; } +/** The bridge-owned source material an agent adapter may turn into a fork + * handoff. Neither field is ever client supplied. */ +export interface ForkHandoffOpts extends TranscriptOpts { + projectPath: string; + terminalTranscript?: string; +} + +/** Required fork contract for every registry agent. The adapter owns the + * provider-specific read and the normalized handoff shape. */ +export interface AgentForkSupport { + /** `native-fork` means a terminal launch can ask the provider to clone a + * concrete native session. The handoff remains the safe fallback when that + * native id was never observed. */ + kind: "native-fork" | "native-transcript" | "terminal-transcript"; + handoff: (opts: ForkHandoffOpts) => Promise; + /** Provider-verified CLI fork invocation. It creates a new native identity, + * unlike resume, and is used only for terminal launches. */ + nativeForkArgs?: (agentSessionId: string) => string[]; +} + /** Inputs to a spec's `resolveTitle`. The `*Home` fields are test seams. */ export interface TitleArgs { sessionId: string; @@ -171,21 +188,130 @@ export interface ResumableArgs { /** * A title read from an agent's own store, tagged with how good it actually is. * - * `kind` is load-bearing, not description. Every resolver has a last-resort - * branch that echoes the user's opening prompt, because that is better than no - * name at all — but it is NOT a title, and the caller has to be able to tell - * the two apart to decide whether generating one is worth a model call. The - * resolvers are the only code that knows which branch it took, so they report - * it rather than leaving the caller to guess from the string. + * `kind` is load-bearing, not description, and there are deliberately only two + * values a resolver may report: + * + * "manual" — a name the USER typed at the agent (Claude's + * `custom-title`, agy's `/rename`). Their intent, so it + * outranks the one we generate. + * "first-message" — an echo of the opening prompt. Better than "Session 3" + * and nothing else; it is what we generate a title to + * REPLACE, and the caller keeps it only until ours lands. * - * Measured, not assumed: codex NEVER generates a title (its CLI writes the - * first user message into `threads.title`; the desktop app is what fills in a - * real one), and Claude writes its own title only in the interactive TUI, never - * in headless/SDK runs. So "first-message" is the common case, not an edge one. + * There is no value for a title the AGENT generated for itself. Reading those + * made naming depend on whether a given agent happens to name conversations, + * how good its names are, and when it writes them — which differed per agent + * and left the sessions of agents that never name (codex's CLI, every + * headless/SDK run) echoing the opening prompt forever. Antgrid names sessions + * itself now, uniformly, from the first user message (see ./title-generate.ts). + * A resolver that finds an agent-written name must drop it, not report it. */ export interface ResolvedTitle { title: string; - kind: "generated" | "first-message"; + kind: "manual" | "first-message"; +} + +/** + * How far a headless spawn of an agent's CLI may reach. + * + * It describes the ARGV, not the agent: one CLI has a different shape per reach + * (claude denies write tools for a naming run and allows read tools for a + * judge), so an agent declares one entry per reach it has a verified command + * for. Ordered tightest-first — {@link pickHeadless} relies on that. + * + * "sealed" — no tools at all; everything the model may use is in the prompt. + * "readonly" — the argv provably restricts the tool to reads. + * "transcript" — the restriction is config-level and unverified, so the spawn + * is never handed a transcript path it could act on. + */ +export const HEADLESS_REACHES = ["sealed", "readonly", "transcript"] as const; +export type HeadlessReach = (typeof HEADLESS_REACHES)[number]; + +/** The reaches a judge may run at. A sealed argv cannot read the repo, which is + * the one thing a supervisor pass exists to do. */ +export type JudgeTier = Exclude; + +/** + * One verified non-interactive invocation of an agent's CLI: a prompt in, the + * model's answer on stdout, nothing persisted. + * + * Its own type because the runner may execute one agent's command for ANOTHER + * agent's work — see resolveHeadless in ./headless.ts. + */ +export interface HeadlessCommand { + cmd: (prompt: string, model?: string) => string[]; + /** Merged over the bridge's environment for this spawn ONLY — never for a + * terminal session, which is the spec-level `env`. */ + env?: Record; + /** + * Env vars to point at a private, empty directory that lives exactly as long + * as the spawn — the runner creates one, sets every name here to it, and + * deletes it afterwards (see runHeadless). + * + * For a CLI with no ephemeral switch, whose state therefore has to be + * redirected rather than turned off. A fixed path under the temp dir is not + * enough: it is the AGENT's store, so it keeps every session it is ever + * pointed at — one measured copilot run left ~51KB of session-state plus a + * 352KB uncheckpointed WAL, in a directory Windows never reclaims. Per-spawn + * makes the ceiling one call rather than the machine's lifetime. + * + * Only for state that is safe to lose: a var carrying CREDENTIALS must never + * be listed, or every spawn starts signed out (COPILOT_HOME is listable + * precisely because copilot's auth is not under it — measured; vibe's home is + * the counter-example). + */ + scratchEnv?: string[]; + /** + * HOW this argv keeps the run out of the user's own history. Stated rather + * than assumed because nothing else can check it: no passing test can tell a + * spawn that persisted a session from one that did not, and every run that + * does shows up in the user's own `--resume` picker forever. + * + * "flag" — an explicit off switch, argv or env (claude's + * --no-session-persistence, codex's --ephemeral). + * "ephemeral-store" — `env` or `scratchEnv` points the agent's store + * somewhere disposable (opencode's OPENCODE_DB=:memory:, + * copilot's per-spawn COPILOT_HOME). + * "stateless" — the CLI writes no history in this mode at all. + * + * An agent whose CLI offers none of the three gets no entry at that reach: + * absence costs a borrowed spawn, a wrong claim costs the user's history. + */ + noHistory: "flag" | "ephemeral-store" | "stateless"; +} + +/** + * What the CALLER needs from a headless spawn, which is not the same question + * as what an argv permits (see {@link HeadlessReach}). + * + * "none" — everything the model may use is inlined into the prompt, so the + * tightest available entry wins. + * "repo" — reading the working tree IS the work, so a sealed argv cannot + * serve it however much tighter it would be. + */ +export type HeadlessNeed = "none" | "repo"; + +const REACHES_FOR: Record = { + none: HEADLESS_REACHES, + repo: HEADLESS_REACHES.filter((r) => r !== "sealed"), +}; + +/** + * The tightest declared entry that still satisfies `need`, or null. + * + * Pure and spec-shaped (it takes the map, not a tool name) so `judgeCapable` in + * ./registry.ts can answer from it without importing ./headless.ts, which + * imports the registry back. + */ +export function pickHeadlessFrom( + declared: AgentSpec["headless"], need: HeadlessNeed, +): { reach: HeadlessReach; command: HeadlessCommand } | null { + if (!declared) return null; + for (const reach of REACHES_FOR[need]) { + const command = declared[reach]; + if (command) return { reach, command }; + } + return null; } export interface HookProfile { @@ -341,8 +467,11 @@ export interface AgentSpec { * submitted. `[]` = no VERIFIED interactive form; see initial-prompt.ts for * why an unverified `--` separator is worse than the misparse it guards. */ initialPrompt: (prompt: string) => string[]; + /** Every registered agent must explicitly say how it supplies fork context. */ + fork: AgentForkSupport; /** Extra launch environment, applied only on the registry-key launch path. - * Absent = the agent needs no generated config to notify. */ + * Either a generated config file the agent needs in order to notify, or a + * behaviour switch the bridge must pin for every spawn. Absent = neither. */ env?: (ctx: LaunchEnvCtx) => Record; /** How this agent updates itself in place. The registry key is the `tool` id * the update path keys by, so it is not restated here. @@ -361,21 +490,36 @@ export interface AgentSpec { notifyBodyFromTranscript?: (transcriptPath: string) => Promise; /** Presence is what makes a tool chat-capable; there is no separate list. */ driver?: SpecDriverFactory; - /** Headless one-shot judge for the supervisor. Absent = no VERIFIED headless - * judge for this tool, which gates the Handler off (escalate-only) rather - * than guessing an argv. `tier` and `cmd` are one field precisely because - * they must never drift: "readonly" asserts the argv provably restricts the - * tool to reads, "transcript" that the restriction is config-level and - * unverified — and the tier is what decides whether the judge is handed a - * transcript path it could act on. */ - /** `env` is merged over the bridge's own environment for the judge spawn - * ONLY — never for a terminal session (that is the spec-level `env`). It - * exists for agents whose session persistence can't be turned off by a flag. */ - judge?: { - tier: JudgeTier; - cmd: (prompt: string, model?: string) => string[]; - env?: Record; - }; + /** + * Verified non-interactive invocations of this agent's CLI, keyed by how far + * each one may reach (see {@link HeadlessReach}). One capability, several + * consumers: naming a session (./headless.ts via ./title-generate.ts) and the + * supervisor's judge (../handler/judge.ts) are two callers of the same spawn, + * and every future one-shot model call belongs here rather than growing a + * third near-identical field. + * + * The reach a caller needs is what selects the entry, and the two questions + * are NOT the same: + * - naming wants the TIGHTEST entry available; the conversation is inlined + * into the prompt, so it needs no repo access at all. + * - the judge REQUIRES at least "readonly" — reading the repo is the work — + * and its reach decides whether it may be handed a transcript path it + * could act on. `judgeCapable` therefore stays "has a non-sealed entry", + * never "has any entry": collapsing the two is what made "can this agent + * name a session" mean "does this agent have a vetted SUPERVISOR", a much + * higher bar that left every other agent's sessions unnamed. + * + * Every entry must also hold NO WRITES: the model is asked for text and given + * no transcript path, so deny tools where a flag can and rely on + * non-interactive permission denial where it can't. Its no-history half is + * declared per entry — see {@link HeadlessCommand.noHistory}. + * + * A missing reach = no argv VERIFIED against the real CLI at that reach. For + * naming that is not "this agent cannot be named": the prompt carries its own + * context, so ./headless.ts borrows another installed agent's command rather + * than guessing this one's flags. Add an entry only after running it. + */ + headless?: Partial>; /** Returns messages AND, only when the source is a followable file, its path. * Never synthesize a path: a "transcript"-tier judge has no verified * read-only restriction, so it gets no file hint it could not follow. */ @@ -386,9 +530,10 @@ export interface AgentSpec { * agent has no store-existence check (the honest answer, not a default); * callers treat absence as resumable. */ resumable?: (args: ResumableArgs) => boolean; - /** Reads this agent's own session name, tagged `generated` vs `first-message` - * (see ResolvedTitle — the tag drives whether we spend a model call). Absent - * = the agent has no on-disk name to read: opencode pushes its title inline - * on the loopback post, cursor-agent stores none at all. */ + /** Reads the name for this agent's session that is NOT one the agent chose + * for itself, tagged `manual` vs `first-message` (see ResolvedTitle — the tag + * drives whether we spend a model call). Absent = the agent has neither on + * disk: opencode and cursor-agent store no rename of their own that we read. + */ resolveTitle?: (args: TitleArgs) => Promise; } diff --git a/bridge/src/api-server.ts b/bridge/src/api-server.ts index 7fc3e47d..96131e17 100644 --- a/bridge/src/api-server.ts +++ b/bridge/src/api-server.ts @@ -1,9 +1,9 @@ import { join } from "node:path"; -import { homedir } from "node:os"; -import { writeFileSync, unlinkSync, readFileSync } from "node:fs"; +import { writeFileSync, unlinkSync, readFileSync, mkdirSync } from "node:fs"; import { spawn } from "node:child_process"; import { z } from "zod"; import { logger } from "./logger"; +import { resolveAbDir } from "./antgrid-dir"; const log = logger.child({ component: "api-server" }); import { createMessage, type AbMessage } from "./protocol"; import { AGENTS, BY_HOOK_NAME } from "./agents/registry"; @@ -24,6 +24,16 @@ export interface AgentContext { onSessionTitle?: (body: SessionTitleBody) => void; /** Forwarded a validated /handler-event POST from an injected agent hook. */ onHandlerEvent?: (body: HandlerEventBody) => void; + /** True when a hook's `awaiting_input` for this slot can only be the generic + * post-completion idle nudge, because the slot's own turn already ended. + * Wired in buildAgentCore to the owner's work-status reduction — the same + * fold that already skips the nudge's phone push, so the two rules cannot + * drift apart. + * + * ABSENT MEANS FORWARD, and that is the safe direction: a genuine mid-turn + * block that never reaches the Handler leaves a blocked agent unsupervised, + * with no further event able to raise it. */ + isStaleIdleNudge?: (terminalId: string) => boolean; /** Called when an injected hook pings /hook-alive, the drift probe for any * agent whose `hooks.posts` declares that path. */ onHookAlive?: (terminalId: string) => void; @@ -37,7 +47,6 @@ export interface AgentContext { } const VERSION = "0.1.0"; -const PORT_FILE = join(process.env.ANTGRID_DIR ?? join(homedir(), ".antgrid"), "api.port"); /** * The hook-name vocabulary a loopback post may identify itself by (`claude`, @@ -66,7 +75,11 @@ export const NotifyBodySchema = z.object({ export const SessionTitleSchema = z.object({ terminalId: z.string().min(1), sessionId: z.string().min(1), - title: z.string().optional(), + /** The message the user just submitted, from an agent with a PRE-turn hook + * (only Claude has one). Its presence is what makes this post a request to + * name the session now rather than a report that a turn ended, so it must + * never be set on a turn-end post. */ + prompt: z.string().optional(), transcriptPath: z.string().optional(), agent: z.enum(HOOK_AGENT_NAMES).optional(), titleOnly: z.boolean().optional(), @@ -83,6 +96,12 @@ const HandlerEventSchema = z.object({ // the engine's fallback wait) and what the driver called the failure. resetsAt: z.number().optional(), errorClass: z.string().optional(), + // `awaiting_input` only: the poster's own reading of the message it saw — true + // when it looks like the agent's generic idle nudge, false when the same hook + // classified it as a live block. Nothing else can tell them apart, and the + // paired /notify from the SAME invocation is decided on it, so the drop below + // has to read it or the two answers disagree. Absent = the poster did not say. + idleNudge: z.boolean().optional(), }); export type HandlerEventBody = z.infer; @@ -323,6 +342,29 @@ export function startApiServer(ctx: AgentContext): ApiServerHandle { try { body = await req.json(); } catch { return json({ error: "Invalid JSON body" }, 400); } const parsed = HandlerEventSchema.safeParse(body); if (!parsed.success) return json({ error: "Invalid body" }, 400); + // The agent's notification hook is stateless: it fires the identical + // "waiting for your input" signal for a real mid-turn block and for its + // idle nudge after the turn already ended. Only the host knows which, + // and forwarding the second costs the Handler a context assemble plus a + // judge spawn on a turn nothing can change any more. Asked for this ONE + // event kind — the turn/limit kinds are unambiguous and must never be + // gated on turn state. Still 200: the hook must not see a failure. + // + // BOTH halves are required. The reduction is read before the paired + // /notify of the same hook invocation has folded into it (that POST is + // issued alongside this one and lands after), so turn state alone would + // classify a genuine mid-turn block as an idle nudge whenever the slot's + // last notification was a turn-end — reachable from a lost /turn-start, or + // from Handler's own park push, which writes task_complete for the slot. + // The Handler would then never hear the block while the same invocation's + // /notify dotted the session "needs you", and nothing re-raises it. + // `idleNudge` is the poster's own reading of the message, which is exactly + // what /notify branches on: unless it says this is the nudge shape, forward. + if (parsed.data.event === "awaiting_input" && parsed.data.idleNudge === true + && ctx.isStaleIdleNudge?.(parsed.data.terminalId)) { + log.debug("Dropped a post-completion idle nudge for %s", parsed.data.terminalId); + return json({ ok: true, stale: true }); + } ctx.onHandlerEvent?.(parsed.data); return json({ ok: true }); } @@ -339,8 +381,15 @@ export function startApiServer(ctx: AgentContext): ApiServerHandle { // ANTGRID_API_PORT env var stamped into each terminal is the real source of // truth; this file is a fallback for processes that lack that env (legacy / // single-core). See stop() for why removal is guarded. + // Resolved per server, never at module load: ANTGRID_DIR is the process-wide + // override every other reader honours live (`resolveAbDir`, and hook-runner's + // own fallback), so a path frozen at import time answers for whatever the env + // held when the first module in the graph loaded. The directory may not exist + // yet on a first launch, and losing the file costs hook discovery silently. + const portFile = join(resolveAbDir(), "api.port"); try { - writeFileSync(PORT_FILE, String(port), { mode: 0o600 }); + mkdirSync(join(portFile, ".."), { recursive: true }); + writeFileSync(portFile, String(port), { mode: 0o600 }); } catch (err) { log.warn("Failed to write API port file: %s", err); } @@ -358,7 +407,7 @@ export function startApiServer(ctx: AgentContext): ApiServerHandle { // the singleton host a later core may have overwritten it; an // unconditional unlink would delete a sibling core's live pointer. try { - if (readFileSync(PORT_FILE, "utf8").trim() === String(port)) unlinkSync(PORT_FILE); + if (readFileSync(portFile, "utf8").trim() === String(port)) unlinkSync(portFile); } catch { // Port file may not exist or be unreadable — nothing to clean up. } diff --git a/bridge/src/handler/authorization.ts b/bridge/src/handler/authorization.ts index 6f3fa6ff..ef5573c6 100644 --- a/bridge/src/handler/authorization.ts +++ b/bridge/src/handler/authorization.ts @@ -235,6 +235,10 @@ export function authorizeInstruction( // — same pattern, a target the user said nothing about. Naming one of these still // works; it needs the second grade of lift (the literal one) to do it. const OUTSIDE_ROOTED = /(?:^|[\s'"=])~[/\\]|(?:^|[\s'"=/\\])\.\.[/\\]/; +// Deliberately WIDER than the floor's own ABS_PATH, which requires an interior separator +// so a slash command is not read as a path: this one answers whether the text names a +// target outside the project, and a bare root is a real answer to that. Narrowing the two +// in lockstep would let "recursively delete the build dirs" authorize `rm -rf /tmp`. const ABSOLUTE_TARGET = /(?:^|[\s'"=])(\/[^\s'"]+|[A-Za-z]:[\\/][^\s'"]+)/g; function namesTargetOutsideProject(flaggedText: string, projectPath: string): boolean { diff --git a/bridge/src/handler/backlog.ts b/bridge/src/handler/backlog.ts index eec83257..bac829e9 100644 --- a/bridge/src/handler/backlog.ts +++ b/bridge/src/handler/backlog.ts @@ -5,8 +5,24 @@ // self-certification is only safe while the item vocabulary stays user-authored // and finite. `applyTransitions` is where that bound is enforced (spec §2.1): // the prompt restates it, but the prompt is the component being constrained. +// +// The §2.1 bound is unchanged by the citation gate below: `done` is still minted +// only by a transition naming a user-authored id, and nothing here can create +// one. What the gate narrows is WHEN — "carries an evidence field" means "cited +// something that is actually in the record the judge was shown", not merely "the +// string is not empty". It cannot tell a correct attribution from a real quote +// about the wrong subject; that judgement needs a reader, and +// the reader is the role that was collapsed. The command anchor is the one +// narrow substitute available without one. import { z } from "zod"; +import { + MIN_EVIDENCE_CHARS, + citationSegments, + commandTokens, + containsInOrder, + normalizeForCitation, +} from "./evidence"; export const ItemStatus = z.enum(["queued", "active", "done", "blocked", "skipped", "failed"]); export type ItemStatus = z.infer; @@ -40,10 +56,26 @@ export const ItemTransitionSchema = z.object({ }); export type ItemTransition = z.infer; +// Why a transition was refused, for callers that must act on the KIND rather +// than show the sentence. The engine surfaces the three evidence codes to the +// user and feeds them back into the next prompt; the other three are harness +// invariants a judge cannot usefully be taught around. +// +// The anchor has its own code because it is the one rule that can be UNSATISFIABLE +// rather than unsatisfied — see anchorTokens — so the engine has to count it +// separately from a citation the judge could simply have got right. +export type RejectionCode = + | "malformed" + | "unknown_id" + | "already_terminal" + | "missing_evidence" + | "unverified_evidence" + | "missing_command_anchor"; + export interface TransitionResult { backlog: InstructionItem[]; applied: { item: InstructionItem; from: ItemStatus; at: number }[]; - rejected: { transition: ItemTransition; reason: string }[]; + rejected: { transition: ItemTransition; reason: string; code: RejectionCode }[]; // True only when an item REACHED `done`. This is the sole input to // guard.recordProgress(), which zeroes the consecutive-auto-reply cap, so a // finite backlog has to yield finite progress — which is what makes the @@ -53,17 +85,130 @@ export interface TransitionResult { const TERMINAL: ReadonlySet = new Set(["done", "skipped", "failed"]); +/** §2.2's one-way door, asked about a single item. Exported so nothing outside + * re-lists the three statuses: a copy that drifts would let a caller act on an + * item this module considers closed. */ +export function isTerminalStatus(status: ItemStatus): boolean { + return TERMINAL.has(status); +} + // `dependsOn` is the one field an item shares by reference, so a shallow spread // would hand the caller's array back out of a function documented as pure. function cloneItem(i: InstructionItem): InstructionItem { return i.dependsOn ? { ...i, dependsOn: [...i.dependsOn] } : { ...i }; } +/** Whether a terminal transition's evidence is a citation, and — for `done` on an + * item that names a slash command — a citation about THAT command. + * + * Both rules are deliberately blunt, because the alternative to a blunt + * deterministic rule here is a second model call grading the first one's + * homework. Grounding alone would not have caught the incident this exists for + * (the judge quoted a genuine sentence about a different review); the anchor is + * what catches that shape, and grounding is what stops the anchor being + * satisfied by simply typing the command name into the evidence field. + * + * `normalizedCorpus` empty is not treated as "nothing is citable": a slot whose + * scrollback has not landed, or a chat render that failed, would otherwise have + * every terminal transition refused forever with the runaway guard as its only + * exit. Grounding is skipped there and the anchor is not, so an absent corpus + * fails toward the old behaviour rather than toward permanent refusal. */ +function checkCitation( + t: ItemTransition, + item: InstructionItem, + normalizedCorpus: string, + ctx: TransitionContext, +): { reason: string; code: RejectionCode } | null { + const raw = (t.evidence ?? "").trim(); + if (raw === "") return { reason: `${t.status} requires evidence`, code: "missing_evidence" }; + + const quote = normalizeForCitation(raw); + const segments = citationSegments(quote); + const material = segments.join("").length; + if (segments.length === 0 || material < MIN_EVIDENCE_CHARS) { + return { reason: `${t.status} evidence is too short to be a quote`, code: "unverified_evidence" }; + } + // The item's own line is the one string a judge can always produce without + // having read anything, and renderBacklog puts it in the prompt — so quoting it + // back is self-certification wearing a citation's clothes. + if (quote === normalizeForCitation(item.text)) { + return { + reason: `${t.status} evidence only repeats the item's own text`, + code: "unverified_evidence", + }; + } + if (normalizedCorpus.length >= MIN_EVIDENCE_CHARS && !containsInOrder(normalizedCorpus, segments)) { + return { + reason: `${t.status} evidence is not in the context you were shown — quote it exactly`, + code: "unverified_evidence", + }; + } + // `done` only. A skip or a failure says the work did NOT happen, so demanding a + // quote of the command being run would ask for the very record that does not + // exist — and would make "correctly did not happen" unsayable again, which is + // the §2.2 deadlock this whole vocabulary was widened to remove. + if (t.status === "done" && !ctx.anchorWaived?.has(item.id)) { + const tokens = anchorTokens(item, ctx); + if (tokens.length > 0 && !tokens.some((tok) => quote.includes(tok))) { + return { + reason: `done needs evidence showing ${tokens[0]} itself being run`, + code: "missing_command_anchor", + }; + } + } + return null; +} + +/** The slash-command tokens in an item's own text that this session could + * actually invoke. + * + * `commandTokens` reads shape alone, and shape cannot tell a command from a + * route or a path segment in the user's own wording: "fix the /login redirect" + * yields `/login`, which no honest quote about that work will ever contain — so + * the anchor demands the impossible and the item can never close. A catalog is + * the one thing that can answer it, and it answers the same way checkReplyShape + * does: a verb the harness would refuse to send is not a command this session + * names. + * + * An absent or EMPTY catalog means "none available" (a PTY, discovery that has + * not landed), never "this agent has none" — the same reading buildDecidePrompt + * and checkReplyShape take — so the anchor still applies there and the caller's + * `anchorWaived` is what keeps a false token from being permanent. */ +function anchorTokens(item: InstructionItem, ctx: TransitionContext): string[] { + const tokens = commandTokens(item.text); + if (!ctx.commandNames?.length) return tokens; + const names = new Set(ctx.commandNames.map((n) => n.toLowerCase().replace(/^\//, ""))); + return tokens.filter((tok) => names.has(tok.slice(1))); +} + +/** What the judge was shown, and what this session can invoke. Every field is + * the caller's answer about THIS pass — none of it may be inferred here. */ +export interface TransitionContext { + evidenceCorpus: string; + /** Command names as the catalog spells them (no leading slash required). + * Absent/empty = no catalog available for this session. */ + commandNames?: readonly string[]; + /** Items whose command anchor has been refused often enough that the caller + * has ruled the token unsatisfiable. Grounding still applies to them: what is + * waived is the demand for a token, never the demand for a real quote. */ + anchorWaived?: ReadonlySet; +} + export function applyTransitions( backlog: InstructionItem[], transitions: ItemTransition[], now: number, + // Required rather than optional: the one production call site has to answer + // "what was the judge actually shown", and a default would let a future caller + // inherit an unenforced gate in silence — the failure mode this gate exists to + // end, one level up. + ctx: TransitionContext, ): TransitionResult { + // Normalizing 12k of context is cheap but not free, and most decisions carry no + // terminal transition at all — so it is paid once, on the first one seen. + let normalizedCorpus: string | undefined; + const corpus = () => (normalizedCorpus ??= normalizeForCitation(ctx.evidenceCorpus)); + const next = backlog.map(cloneItem); const byId = new Map(next.map((i) => [i.id, i])); const applied: TransitionResult["applied"] = []; @@ -77,7 +222,7 @@ export function applyTransitions( // deadlock §2.2 exists to remove. const parsed = ItemTransitionSchema.safeParse(raw); if (!parsed.success) { - rejected.push({ transition: { ...raw }, reason: "malformed transition" }); + rejected.push({ transition: { ...raw }, reason: "malformed transition", code: "malformed" }); continue; } const t = parsed.data; @@ -87,7 +232,7 @@ export function applyTransitions( // mint progress. Rejections are returned rather than dropped so the engine // can log the attempt. if (!item) { - rejected.push({ transition: t, reason: "unknown item id" }); + rejected.push({ transition: t, reason: "unknown item id", code: "unknown_id" }); continue; } // §2.2's terminal states are one-way. An evaluator able to walk an item back @@ -97,12 +242,15 @@ export function applyTransitions( // skipped item is a user tap in the backlog drawer (§4.3) — the wrap-up // summary is a push notification with no tap target — not an evaluator move. if (TERMINAL.has(item.status)) { - rejected.push({ transition: t, reason: `${item.status} is terminal` }); + rejected.push({ transition: t, reason: `${item.status} is terminal`, code: "already_terminal" }); continue; } - if (TERMINAL.has(t.status) && (t.evidence ?? "").trim() === "") { - rejected.push({ transition: t, reason: `${t.status} requires evidence` }); - continue; + if (TERMINAL.has(t.status)) { + const bad = checkCitation(t, item, corpus(), ctx); + if (bad) { + rejected.push({ transition: t, reason: bad.reason, code: bad.code }); + continue; + } } const from = item.status; if (t.status === "done") progressed = true; diff --git a/bridge/src/handler/config.ts b/bridge/src/handler/config.ts index d64ffe03..3d477539 100644 --- a/bridge/src/handler/config.ts +++ b/bridge/src/handler/config.ts @@ -35,7 +35,8 @@ export interface ActivityRecord { // renders as an unknown row at runtime, never as a build error. decision: "continue" | "handle" | "escalate" | "armed" | "goal_edited" | "item_done" | "item_blocked" | "item_skipped" | "item_failed" - | "instruction_dropped" | "floor_warning" | "wrapped_up" | "parked" | "resumed"; + | "instruction_dropped" | "floor_warning" | "evidence_rejected" + | "wrapped_up" | "parked" | "resumed"; reason: string; detail?: string; } diff --git a/bridge/src/handler/decision.ts b/bridge/src/handler/decision.ts index 78fe9fb5..28f32192 100644 --- a/bridge/src/handler/decision.ts +++ b/bridge/src/handler/decision.ts @@ -1,8 +1,11 @@ // bridge/src/handler/decision.ts import { z } from "zod"; import { agentSpec } from "../agents/registry"; +import { pickHeadlessFrom, type HeadlessCommand, type JudgeTier } from "../agents/types"; +import type { CapCommand } from "../structured/chat-session"; import { ItemTransitionSchema } from "./backlog"; import { extractJsonObject } from "./json-extract"; +import { MAX_REPLY_CHARS } from "./reply-shape"; export const HandlerDecisionSchema = z.object({ decision: z.enum(["continue", "handle", "escalate"]), @@ -26,30 +29,61 @@ export const HandlerDecisionSchema = z.object({ }); export type HandlerDecision = z.infer; -export type JudgeTier = "readonly" | "transcript"; +// Re-exported, not redefined: the reaches an agent declares live on +// AgentSpec.headless, and a second spelling here could drift from them. +export type { JudgeTier } from "../agents/types"; -// Judge argv for an arbitrary tool string, read off the one place a tool is -// described. Tier and argv come back together because they are one field on the -// spec — a "readonly" claim and the flags that enforce it can no longer drift. -// Null = no VERIFIED headless judge for this tool, which gates Handler off. -export function buildJudgeCommand( - tool: string, model: string | undefined, prompt: string, -): { cmd: string[]; tier: JudgeTier; env?: Record } | null { - const judge = agentSpec(tool)?.judge; - return judge ? { cmd: judge.cmd(prompt, model), tier: judge.tier, env: judge.env } : null; +// The judge command for an arbitrary tool string, read off the one place a tool +// is described. Tier and command come back together because they are one field +// on the spec — a "readonly" claim and the flags that enforce it can no longer +// drift. Null = no VERIFIED headless judge for this tool, which gates Handler +// off. The COMMAND, not one built argv: a judge run retries with a second +// prompt, and both attempts must come from the same entry. +export function pickJudge( + tool: string, +): { command: HeadlessCommand; tier: JudgeTier } | null { + const picked = pickHeadlessFrom(agentSpec(tool)?.headless, "repo"); + // "repo" cannot select a sealed entry; the check is what lets the reach narrow + // to a JudgeTier without a cast, rather than a case that can actually happen. + if (!picked || picked.reach === "sealed") return null; + return { command: picked.command, tier: picked.reach }; +} + +// The CLI name, not the AgentKey and not the display label: the judge is +// reading a transcript the agent itself wrote, where `claude` appears and +// `claude-code` (our routing key) never does. +function supervisedName(tool: string): string { + return agentSpec(tool)?.bin ?? tool; +} + +// Command names and descriptions come verbatim from filesystem frontmatter and +// can carry newlines that would break the one-entry-per-line rendering. +function promptLine(s: string): string { + return s.replace(/\s+/g, " ").trim(); } // The transition rules below restate what applyTransitions enforces. That is // belt-and-braces, not the guard — a prompt cannot bind the component it is // addressed to (spec §2.1). It earns its place by making well-formed output the // likely one: an evaluator that answers in prose gets its progress dropped, and -// the item then sits open with nothing explaining why. +// the item then sits open with nothing explaining why. The refused-transitions +// section is the same bargain one pass later: the harness has already dropped +// those moves, and stating why is what stops the next pass re-citing identically. export function buildDecidePrompt(opts: { goal: string; backlogText: string; context: string; transcriptPath?: string; floorWarnings?: string[]; + evidenceRejections?: string[]; + // The agent being SUPERVISED, never the judge running this prompt — a + // per-session judge pick can point at a different CLI entirely. + agentTool?: string; + // Non-empty or absent: an empty catalog is indistinguishable from a failed + // or not-yet-landed discovery, so it is never announced as a complete set. + commands?: CapCommand[]; }): string { return [ - "You are a supervisor standing in for the user while a coding agent works.", + opts.agentTool + ? `You are a supervisor standing in for the user while the coding agent \`${supervisedName(opts.agentTool)}\` works.` + : "You are a supervisor standing in for the user while a coding agent works.", "Decide whether to let the agent continue, answer it on the user's behalf, or escalate to the user.", "", "SESSION GOAL (the user's own words):", @@ -62,7 +96,8 @@ export function buildDecidePrompt(opts: { "- Report every item whose state changed as one entry in `transitions`.", "- Use ONLY the ids listed above, copied exactly. Any other id is discarded, and you cannot create an item — if the agent did something the backlog does not cover, describe it in `reason` instead.", "- Statuses: `queued` (waiting its turn — use it to revive a blocked item whose precondition is now met), `active` (being worked on now), `done` (finished), `blocked` (a precondition or dependency is unmet), `skipped` (no longer applicable — its condition turned out false, or a later item supersedes it), `failed` (attempted and could not be completed).", - "- Every transition to `done`, `skipped` or `failed` MUST carry `evidence`: a short verbatim quote from the context or transcript. One without it is discarded and the item stays open, so quote rather than paraphrase.", + "- Every transition to `done`, `skipped` or `failed` MUST carry `evidence`: a short verbatim quote copied character-for-character out of the RECENT CONTEXT block below, at least a phrase long. The harness searches that block for your quote — a paraphrase, a summary, or a quote from anywhere else is discarded and the item stays open. Never quote the item's own wording back; that says nothing about what happened.", + "- If an item names a slash command, `done` additionally requires a quote showing THAT command being invoked. A quote about some other, similar step does not close it, however real the quote is.", "- Report `done` only on evidence the work actually happened (test output, exit codes, a diff), never on intent or belief. `outcome` is your one-line summary for the user and never substitutes for evidence.", "- An item the agent has already satisfied on its own is `done` with that evidence — do not drive it again.", "", @@ -70,6 +105,9 @@ export function buildDecidePrompt(opts: { "- Escalating always trumps making progress: if the next step on an item needs the user, escalate instead of transitioning it.", "- If you cannot answer with high confidence, escalate. A wrong auto-reply is the expensive failure.", "- Safety limits are enforced after your decision; never attempt to bypass them.", + `- \`reply\` is free text typed at the agent and submitted as ONE line, under ${MAX_REPLY_CHARS} characters. Write one line: a line break would submit early, so any you write are collapsed to spaces before sending.`, + "- `action` with `kind: \"slash_command\"` types a command at the agent instead. `value` is `\"/verb\"` or `\"/verb \"` — the verb is a single token with no spaces and no further `/`.", + "- Set either `reply` or `action`, never both. A decision carrying both is refused and reaches the agent as nothing.", // The point of turning the floor advisory (§5.1) is that the Assistant sees // which of its own proposals were dangerous. Stating that these are its past // replies, not the agent's commands, is what makes them actionable. @@ -81,13 +119,54 @@ export function buildDecidePrompt(opts: { "Weigh them when composing this reply. If the same risk is unavoidable here, escalate instead of repeating it.", ] : []), + // Named as REFUSED, not as failed: the moves were well-formed and the judge + // has no other way to learn they never landed — the backlog it is handed next + // pass simply shows the items still open, which reads as work not yet done. + ...(opts.evidenceRejections?.length + ? [ + "", + "TRANSITIONS THE HARNESS REFUSED LAST PASS — those items are still open:", + ...opts.evidenceRejections.map((r) => `- ${r}`), + "Cite differently or leave the item open; the same quote gets the same answer.", + ] + : []), + // Two statements, never an empty header: an absent catalog is a real answer + // (a PTY session has none and cannot get one), and announcing a "complete + // set" that is empty would read as "this agent has no commands" — which is + // exactly the case an absent catalog CANNOT distinguish. + ...(opts.commands?.length + ? [ + "", + "AVAILABLE COMMANDS (the complete set for this session) — invoke one through `action`, never by typing it in `reply`:", + ...opts.commands.map((c) => { + const parts = [`- /${promptLine(c.name)}`]; + if (c.argHint) parts.push(`(args: ${promptLine(c.argHint)})`); + if (c.description) parts.push(`— ${promptLine(c.description)}`); + return parts.join(" "); + }), + "A `value` whose verb is not on this list is refused and reaches the agent as nothing.", + ] + : [ + "", + "No command catalog is available for this session. Prefer plain instructions; use a slash command only if the goal or backlog names one explicitly.", + ]), "", "RECENT CONTEXT:", opts.context, - ...(opts.transcriptPath ? ["", `Fuller transcript at ${opts.transcriptPath} — read it if the excerpt is insufficient.`] : []), + // The transcript is background for REASONING and never a citation source: the + // harness grounds evidence against the RECENT CONTEXT block alone (it is the + // only text it holds), so an unqualified invitation to read further is an + // invitation to cite quotes every terminal transition then gets refused for. + ...(opts.transcriptPath + ? [ + "", + `Fuller transcript at ${opts.transcriptPath} — read it if the excerpt is insufficient.`, + "Read it for background only: every `evidence` quote must still be copied out of the RECENT CONTEXT block above, which is the only text the harness can search. If what closes an item is not in that block, leave the item open and say so in `reason`.", + ] + : []), "", "Respond with ONLY a single JSON object, no prose, matching exactly:", - '{"decision":"continue|handle|escalate","confidence":0.0,"reason":"...","reply":"(when handle) text to send the agent","action":{"kind":"slash_command|none","value":"/cmd"},"notify":{"title":"...","body":"...","draftReply":"...","urgency":"normal|high"},"transitions":[{"id":"...","status":"queued|active|done|blocked|skipped|failed","evidence":"verbatim quote","outcome":"..."}]}', + '{"decision":"continue|handle|escalate","confidence":0.0,"reason":"...","reply":"(when handle, and only if action is omitted) text to send the agent","action":{"kind":"slash_command|none","value":"/verb "},"notify":{"title":"...","body":"...","draftReply":"...","urgency":"normal|high"},"transitions":[{"id":"...","status":"queued|active|done|blocked|skipped|failed","evidence":"verbatim quote","outcome":"..."}]}', ].join("\n"); } @@ -100,6 +179,19 @@ export function buildRetryPrompt(originalPrompt: string, validationError: string ].join("\n"); } +// Distinct from buildRetryPrompt on purpose: a decision that parsed cleanly and +// then failed a harness rule has perfectly valid JSON, and telling it to fix its +// JSON teaches it to change the one thing it got right. +export function buildShapeRetryPrompt(originalPrompt: string, rejection: string): string { + return [ + originalPrompt, + "", + `Your previous response was valid JSON, but the harness refused to send it: ${rejection}`, + "It never reached the agent. Answer again obeying the rules above, or escalate instead if you cannot.", + "Respond again with ONLY the single JSON object.", + ].join("\n"); +} + export function parseDecisionFromOutput(stdout: string): { decision: HandlerDecision | null; error?: string } { const obj = extractJsonObject(stdout); if (obj === null) return { decision: null, error: "no JSON object found in output" }; diff --git a/bridge/src/handler/destructive-floor.ts b/bridge/src/handler/destructive-floor.ts index 8856e374..f48e3568 100644 --- a/bridge/src/handler/destructive-floor.ts +++ b/bridge/src/handler/destructive-floor.ts @@ -100,7 +100,27 @@ const SECRETS: RegExp[] = [ /\bAKIA[0-9A-Z]{16}\b/, ]; -const ABS_PATH = /(?:^|[\s'"=])(\/[^\s'"]+|[A-Za-z]:\\[^\s'"]+)/g; +// A path claim needs an INTERIOR separator. A slash command is "/"-shaped too, and +// reading `/code-review` as an out-of-project path teaches the Assistant that its own +// commands are dangerous — in the one channel (§5.1) that exists to teach it which of +// its proposals actually are. reply-shape's VERB rule forbids a "/" inside a verb, so +// an interior separator is precisely the shape a slash command can never have. +// +// The cost is the bare single-segment roots — `/tmp`, `/etc`, `/opt` — which no longer +// register as a mention. Acting on one is still caught: the four tiers above scan the +// full text, and authorization.ts's own outside-target check still reads them, so no +// pattern lift can authorize `rm -rf /tmp`. +// +// The drive-letter branch keeps single-segment coverage (`C:\Temp`), because no slash +// command can be spelled that way and there is nothing to disambiguate. +// +// `\/+` absorbs repeated leading slashes: `//etc/shadow` names the same file as +// `/etc/shadow` on every POSIX kernel, and the leading anchor allows no restart on +// an interior slash — so without it that spelling matched NEITHER branch. It cost +// the warning and, because quickChoicesFor withholds its one-tap chip on any floor +// hit, handed the draft a live Approve chip the single-slash spelling does not get. +// Unlike the bare roots below it, that shape is chosen by whoever wrote the text. +const ABS_PATH = /(?:^|[\s'"=])(\/+[^\s'"/]+\/[^\s'"]*|[A-Za-z]:\\[^\s'"]+)/g; // One synthetic key for every outside-project path, because this tier is lifted // literally (§5.4) — the path is the claim, the pattern never is. @@ -156,13 +176,15 @@ function scan( * delete AND outside-project path" makes a better call than one reading whichever * happened to be listed first. * - * pathCheckText scopes the outside-project check to the judge's free-text reply, not the - * full probe: engine.ts rejects a slash_command action value that isn't a single "/"-free, - * whitespace-free token before it ever reaches this floor, so by the time it's joined onto - * the probe it cannot be a filesystem path — but it's still "/"-shaped, and callers - * conventionally join it right after a newline, which the ABS_PATH anchor reads as a path - * start. The other tiers still scan the full `text` (the default for pathCheckText too) so - * a command value smuggling one of those patterns is still caught. + * pathCheckText scopes the outside-project check away from a slash command's VERB and + * nothing else: engine.ts passes the judge's reply plus the command's argument tail, and + * withholds the verb alone. What makes that safe is reply-shape's VERB rule, which forbids + * both separators inside a verb — so no verb the harness will send can carry a path shape, + * whatever ABS_PATH happens to accept. Catalog membership is NOT the reason: a PTY session + * has no catalog and its verb is typed at the agent verbatim. Its arguments are ordinary + * free text and DO get scanned, because an absolute path there is a real one. The other + * tiers still scan the full `text` (the default for pathCheckText too), so a verb smuggling + * one of those patterns is caught. */ export function classifyDestructive(text: string, projectPath: string, pathCheckText: string = text): FloorResult { const hard: FloorWarning[] = []; diff --git a/bridge/src/handler/engine.ts b/bridge/src/handler/engine.ts index d7dd83df..963992b1 100644 --- a/bridge/src/handler/engine.ts +++ b/bridge/src/handler/engine.ts @@ -1,4 +1,5 @@ // bridge/src/handler/engine.ts +import { createHash } from "node:crypto"; import { createMessage, type AbMessage } from "../protocol"; import { classifyDestructive, describeWarning, type FloorWarning } from "./destructive-floor"; import { @@ -20,13 +21,15 @@ import { } from "./config"; import { loadHandlerSession, saveHandlerSession, EscalationChoiceSchema, - type EscalationChoice, type HandlerSessionRecord, type OpenEscalation, + type EscalationChoice, type EscalationKind, type HandlerSessionRecord, type OpenEscalation, } from "./session-store"; import { - allTerminal, applyTransitions, propagateBlocked, renderBacklog, summarize, - type InstructionItem, type ItemStatus, + allTerminal, applyTransitions, isTerminalStatus, propagateBlocked, renderBacklog, summarize, + type InstructionItem, type ItemStatus, type RejectionCode, } from "./backlog"; import { stripAnsi } from "./context"; +import { checkReplyShape, findCommand, oneLine, replyShape } from "./reply-shape"; +import type { CapCommand } from "../structured/chat-session"; import type { SessionAdapter } from "./session-adapter"; import { handlerObservable, judgeCapable } from "../agents/registry"; import { createEntitlementReader, type EntitlementReader } from "../entitlement"; @@ -104,11 +107,43 @@ const NO_GOAL = "(no goal set)"; // and that shows up as worse decisions, nowhere near this file. const MAX_BACKLOG_ITEMS = 100; +// How many `guard_blocked` reports may stand at once. A judge that keeps +// proposing a refused action raises one row per distinct (reason, draft), and +// nothing but the user retires them — so past this the OLDEST is dropped. +// Dropping the newest would hide the situation the session is actually in, and +// every report is preserved verbatim in the activity feed either way: the row is +// only the reminder that one is there. +const MAX_BLOCKED_REPORTS = 5; + +// What a `guard_blocked` row asks, and — through push/compose.ts — the body of +// its notification. Engine-authored rather than taken from `notify.body`: a judge +// that filled the notify block while deciding `handle` described the pause it was +// answering, not the reply a guard then refused. +const BLOCKED_QUESTION = "Handler did not send its reply"; + // How many past floor warnings ride along in the next decide prompt. Enough to // show a pattern the Assistant keeps repeating, short enough that a long session // does not spend its context budget re-reading its own history. const MAX_REMEMBERED_WARNINGS = 5; +// The same trade for refused transitions, set lower: a refusal names one item and +// says how to cite it, so the lesson is carried by the most recent few. A longer +// list mostly repeats itself, and every line of it is context the judge spends +// not reading the agent. +const MAX_REMEMBERED_REJECTIONS = 3; + +// How many times one item may be refused for its command anchor ALONE before the +// anchor is dropped for it. The anchor reads the item's own text for a slash +// command, and a token-shaped route or path in the user's wording ("fix the +// /login redirect") names a command nothing will ever run — so its demand is not +// merely unmet, it is unmeetable, and a permanent refusal costs the session its +// wrap-up and eventually a runaway report the user has to dismiss by hand. +// Grounding is untouched by the waiver: what is dropped is the demand for a +// token, never the demand for a real quote. Three, so a judge that could satisfy +// the anchor is asked for it twice more after the first miss — the refusal is fed +// back into the prompt each time — before the harness concludes it cannot be met. +const MAX_ANCHOR_REFUSALS = 3; + // The item outcomes the activity feed carries a kind for. A skip is as // consequential as a completion (§4.3), so they stay distinguishable without // parsing the reason text. @@ -127,11 +162,13 @@ const SUMMARY_GROUPS: [SummaryStatus, string][] = [ ["skipped", "Skipped"], ]; -// Item text and the goal are free text the user typed or extraction produced, so -// either can carry newlines that would render a push body as a broken multi-line -// notification. -function oneLine(s: string): string { - return s.replace(/\s+/g, " ").trim(); +// Identity of the evidence a decide pass reasoned over (see lastJudgedContextHash). +// Deliberately NOT RunawayGuard's 32-bit djb2: a collision there false-escalates, +// which is the safe direction, but a collision HERE skips a real pause and no +// further event raises it again. Over a 12k-char context that margin has to be +// cryptographic, and the cost is one hash per judge call. +function contextHash(text: string): string { + return createHash("sha256").update(text).digest("hex"); } // `??` is the wrong operator against a judge decision: `notify.body`/`notify.draftReply` @@ -203,7 +240,7 @@ const REJECT_CHOICE_TEXT = "Do not proceed. Wait for my instructions."; * one control away, in the user's own words, through the PA bar. */ export function quickChoicesFor(p: { - kind?: "reply" | "resolve_in_session"; + kind?: EscalationKind; floorRule?: string; draftReply: string; projectPath: string; @@ -214,6 +251,12 @@ export function quickChoicesFor(p: { // such a prompt cannot consume — which is what `kind` exists to say. So any chip // offered here would be a button that does nothing at all. if (p.kind === "resolve_in_session") return undefined; + // A report exists BECAUSE a guard refused this exact text, so a one-tap that + // re-sent it would be the thinnest human in the loop there is. The reply sheet + // costs the same send and makes the user read what was refused first. The §5.3 + // case already falls out through `floorRule`; this covers the shape and runaway + // rejections, which set no rule. + if (p.kind === "guard_blocked") return undefined; // Escalations stack per terminal (nothing serializes the blocking-prompt path). // An agent blocked on a permission/question reads nothing until that prompt is // resolved, so a one-tap offered beside an unanswered one sends text into a @@ -316,6 +359,23 @@ interface ArmedSession { // into the next decide prompt. Deliberately not persisted: the activity log is // the durable audit trail, and this copy exists only to shape the next call. floorWarnings: string[]; + // Terminal transitions the citation gate refused last pass, fed back the way + // floorWarnings are. Not persisted for the same reason limitParks is not: a + // restart is itself a break in continuity, and the activity feed already holds + // the durable trail. Carries the item id, not just the rendered line: the + // prompt section states the refused items are STILL OPEN, so an entry has to be + // dropped when its item closes or it contradicts the backlog beside it. + evidenceRejections: { id: string; line: string }[]; + // Per item, how many times the command anchor ALONE has refused a completion. + // The waiver it feeds is the only exit from an item whose text carries a + // command-shaped token that is not a command (see MAX_ANCHOR_REFUSALS). Not + // persisted, like the two above it. + anchorRefusals: Map; + // Items that have already spent their one `evidence_rejected` feed row. A judge + // that keeps re-citing the same way is refused every pass, and one row per + // ITEM — not per attempt — is what keeps the feed a record of what happened to + // the backlog rather than a transcript of the judge's retries. + evidenceRejected: Set; // What the user's own instructions authorized for this session (§5.4). Not // persisted, unlike the backlog those instructions also produced: rebuilding it // after a restart could only come from the stored item text, which extraction @@ -329,6 +389,23 @@ interface ArmedSession { limitParks: number; // One push per park episode: a re-park is the same wait, not a new one. parkPushSent?: boolean; + // Fingerprint of the context the last COMPLETED decide pass reasoned over. An + // event whose context hashes the same brings the judge no information it has + // not already ruled on, so it is skipped rather than judged again (claude's + // post-completion idle nudge raises one such event per turn; work-status.ts + // filters it for the status dot, the /handler-event path does not). + // + // Keyed on the CONTEXT ALONE, never the backlog: a backlog move is the judge's + // own bookkeeping, and re-judging because a prior pass unblocked an item is the + // self-referential loop this exists to break — an item was once marked `done` on + // a re-read of the very message that had just completed its dependency. New + // evidence has to come from the agent. User-originated input (a re-arm, stacked + // instructions, a submitted line) clears it; a transition never does. + // + // Not persisted, for the reason limitParks is not: a restart is itself a break + // in continuity, and a stale hash surviving one would skip a pause that nothing + // re-raises. + lastJudgedContextHash?: string; // Outage park born of a judge failure: the pause was never judged, so the // wake re-runs this event instead of nudging past supervision. retryEvent?: HandlerEvent; @@ -339,10 +416,41 @@ interface ArmedSession { parkAwaitingJudge?: boolean; } +// The rejections a REFUSED CITATION produces, as opposed to the harness +// invariants (a minted id, a walked-back completion) a judge cannot be taught +// around. Only these reach the user's feed and the next decide prompt. +const EVIDENCE_CODES: ReadonlySet = new Set([ + "missing_evidence", "unverified_evidence", "missing_command_anchor", +]); + +// Items whose command anchor has been refused so often that the harness stops +// asking — the token in their text is not a command anything can run, so no +// wording of the truth would ever satisfy it (see MAX_ANCHOR_REFUSALS). +function waivedAnchors(s: ArmedSession): ReadonlySet { + const waived = new Set(); + for (const [id, n] of s.anchorRefusals) if (n >= MAX_ANCHOR_REFUSALS) waived.add(id); + return waived; +} + +// Escalations that are a QUESTION waiting on the human, as opposed to a report +// of something Handler could not do. Every rule that means "somebody is already +// being waited on" reads this rather than the row count: a `guard_blocked` row +// is retired by an explicit dismiss alone, so a report nobody has got round to +// would otherwise silence the session — no further escalation, no park nudge, no +// wrap-up — for the rest of its life. +function pendingQuestions(s: ArmedSession): number { + return s.escalations.filter((e) => e.kind !== "guard_blocked").length; +} + // Where a session lands once whatever it was doing is over — a judged decision, // a submitted line, the end of a park. An escalation the user never answered // outranks all three: "watching" with a pending row is a session the app draws // as quiet over an agent that is still waiting. +// +// Reports count here, unlike in pendingQuestions: `pendingEscalations` on the +// wire is a count of ROWS, so resting at "watching" over a listed row would have +// the Needs-you list and the run-state pill disagree about the same escalation. +// The way out of that state is the row's own Dismiss, not a state that hides it. function restingState(s: ArmedSession): "watching" | "needs_you" { return s.escalations.length > 0 ? "needs_you" : "watching"; } @@ -556,6 +664,10 @@ export class HandlerEngine { const goalChanged = p.goal !== undefined && p.goal.trim() !== existing.goal.trim(); if (p.goal !== undefined) existing.goal = p.goal; if (backlog !== undefined) existing.backlog = backlog; + // A configure is the user restating what this session is for, so the last + // pass's verdict no longer covers the same question — the next event is + // judged even if the agent has not moved. + existing.lastJudgedContextHash = undefined; existing.notifyOnly = p.notifyOnly; this.applyJudgeChoice(existing, p); this.persist(p.terminalId, existing, true); @@ -613,6 +725,9 @@ export class HandlerEngine { transientFailures: resumed?.transientFailures ?? 0, limitParks: 0, floorWarnings: [], + evidenceRejections: [], + evidenceRejected: new Set(), + anchorRefusals: new Map(), auth: createAuthorization(), }; this.applyJudgeChoice(s, p); @@ -804,6 +919,11 @@ export class HandlerEngine { }; })]; + // Work the user stacked on an agent that may already be idle: without this the + // staleness guard would skip the very pass meant to pick the new items up, + // and no further event would arrive to raise them. + s.lastJudgedContextHash = undefined; + // No propagateBlocked: every item here is `queued` with intra-batch deps, so // there is nothing to derive. The decide path already runs it each pass. this.persist(terminalId, s, true); @@ -842,6 +962,11 @@ export class HandlerEngine { // comes first: typing into an armed terminal must not cost one disk write + // one encrypted status broadcast per character. if (!/[\r\n]/.test(data)) return; + // A submitted line ordinarily reaches the transcript and moves the hash by + // itself; cleared anyway because the two are written by different processes + // and the guard must never be the reason a human's own instruction goes + // unjudged. + s.lastJudgedContextHash = undefined; // A human at the keyboard ends a park — before the nothing-changed // early-return below, which a parked session normally satisfies. const unparked = this.unparkIfParked(terminalId, s); @@ -857,6 +982,13 @@ export class HandlerEngine { // "watching" while the agent stays blocked, and nothing would re-raise it — // escalation needs a NEW event, and a blocked agent emits none. // + // A `guard_blocked` row is not one either, for the opposite reason: it reports + // an action Handler could NOT take, so there is no pause for a later one to + // supersede and nothing a typed line could be an answer to. Clearing it is + // exactly how a report reached disk and then vanished on an unrelated line, + // leaving the user never knowing Handler had wanted to act. `handler:dismiss` + // is the only thing that retires one. + // // A resolve retires the row for the prompt it names, plus any row too old to // carry an id at all. Never every row: drivers hold a MAP of pending prompts // (parallel tool calls open two at once), and dropping the sibling's row would @@ -868,8 +1000,9 @@ export class HandlerEngine { // already answered — one nothing can ever retire, since the resolve that would name // it has been and gone. if (resolved !== undefined) this.dropQueuedPrompts(terminalId, resolved); - const kept = s.escalations.filter((e) => e.kind === "resolve_in_session" - && (resolved === undefined || (e.promptId !== undefined && e.promptId !== resolved))); + const kept = s.escalations.filter((e) => e.kind === "guard_blocked" + || (e.kind === "resolve_in_session" + && (resolved === undefined || (e.promptId !== undefined && e.promptId !== resolved)))); const cleared = kept.length < s.escalations.length; if (!unparked && !cleared) return; s.escalations = kept; @@ -930,8 +1063,11 @@ export class HandlerEngine { // Before the no-op return: a parked session normally has no escalations at // all, so an unpark placed after it would be dead code. const unparked = this.unparkIfParked(terminalId, s); + // An id-less retraction means every PROMPT is gone. A `guard_blocked` row is + // not a prompt — it carries no promptId, so the id-ed arm already keeps it, + // and no driver ever had anything to withdraw. const kept = promptId === undefined - ? [] + ? s.escalations.filter((e) => e.kind === "guard_blocked") : s.escalations.filter((e) => e.promptId !== promptId); if (!unparked && kept.length === s.escalations.length) return; s.escalations = kept; @@ -1087,11 +1223,11 @@ export class HandlerEngine { } // Notify-only: escalate without spending a judge call. One unanswered - // escalation at a time — while the user hasn't responded, every further + // question at a time — while the user hasn't responded, every further // pause says the same thing ("agent is waiting"), so re-escalating each // one would only pile up pushes and pending rows. if (s.notifyOnly) { - if (s.escalations.length > 0) return; + if (pendingQuestions(s) > 0) return; const body = await this.outputSnippet(evt.terminalId); // The await yields the event loop: a concurrent disarm/exit may have // dropped this session, and escalating would re-persist it as armed. @@ -1111,8 +1247,23 @@ export class HandlerEngine { // The act-on-decision body below keeps its own catch — parking there would // re-judge a decision that was already made and acted on. let decision: HandlerDecision | null; + // Assigned only once the pass is known to be worth judging, and banked only + // after a verdict comes back: a judge outage parks and re-runs THIS event, so + // banking the hash up front would make the retry skip the pause it exists to + // re-judge. + let judgedHash: string | undefined; + // Read once, before the judge runs, and reused by the handle branch below: the + // membership rule must be checked against the catalog the judge was actually + // shown, not one a driver re-published while it was thinking. + let catalog: CapCommand[] | undefined; + // The material this pass's judge was shown, and the only corpus a citation can + // be checked against. Declared out here for the same reason `catalog` is: the + // gate below must grade the evidence against what the judge actually read, not + // against whatever the terminal has scrolled to since. + let judgedContext = ""; try { const tool = this.deps.tool(evt.terminalId); + catalog = this.deps.adapter.commandCatalog(evt.terminalId); const transcriptPath = evt.transcriptPath ?? this.deps.adapter.transcriptPath(evt.terminalId); const ctx = await assembleContext({ tool, transcriptPath, @@ -1121,6 +1272,33 @@ export class HandlerEngine { recentKind: this.deps.adapter.outputKind(evt.terminalId), purpose: "decide", }); + judgedContext = ctx.text; + // Nothing has happened since the last pass reached a verdict, so a second + // judge call can only re-rule on evidence already ruled on — and a judge + // that answers differently the second time is answering from noise. Skipped + // silently: this is the judged path's half of the notify-only rule that one + // unanswered escalation is enough, and a duplicate row would say the same + // thing the open one already says. + const hash = contextHash(ctx.text); + if (hash === s.lastJudgedContextHash) { + // assembleContext awaited the filesystem; a concurrent disarm may have + // dropped this session, and resting it would re-persist it as armed. + if (this.sessions.get(evt.terminalId) === s) { + s.state = restingState(s); + this.emitStatus(); + } + return; + } + judgedHash = hash; + // One retry, spent INSIDE the judge call so it shares that call's total budget + // and structurally cannot wrap the destructive floor or the runaway guard — + // those are safety verdicts, and a retry loop around them is a bypass. Only + // rules the prompt states are retried; a catalog miss is not one of them. + const retryIfShape = (d: HandlerDecision): string | null => { + if (d.decision !== "handle") return null; + const r = checkReplyShape(replyShape(d), catalog); + return r?.retryable ? r.reason : null; + }; const runDecisionFn = this.deps.runDecisionFn ?? defaultRunDecision; decision = await runDecisionFn({ tool: s.judgeTool ?? tool, model: s.judgeModel, goal: s.goal, @@ -1128,6 +1306,12 @@ export class HandlerEngine { context: ctx.text, transcriptPath: ctx.transcriptPath ?? transcriptPath, cwd: this.deps.projectPath(evt.terminalId), floorWarnings: s.floorWarnings, + evidenceRejections: s.evidenceRejections.map((r) => r.line), + // The SUPERVISED agent, not the judge: `tool:` above is `s.judgeTool ?? tool`, + // and a per-session judge pick can name a different CLI entirely. + agentTool: tool, + commands: catalog, + retryIfShape, }); } catch { if (this.sessions.get(evt.terminalId) === s) this.onJudgeUnavailable(evt, s); @@ -1144,56 +1328,52 @@ export class HandlerEngine { // A judge that answered proves the provider is serving us again. s.transientFailures = 0; s.limitParks = 0; + s.lastJudgedContextHash = judgedHash; // A rejection must not strand state in "handling" — reset before rethrowing so // the next event isn't ignored and the app's status pill reflects reality. try { - this.absorbTransitions(evt.terminalId, s, decision); + this.absorbTransitions(evt.terminalId, s, decision, judgedContext, catalog); // Wrap-up is checked AFTER acting on the decision (see each branch below): // a final `handle` reply must reach the agent before disarm, and an // escalation never wraps up — wake-rules trump completion. if (decision.decision === "handle") { - const reply = (decision.reply ?? "").trim(); - const actionText = decision.action?.kind === "slash_command" ? decision.action.value : ""; - const probe = `${reply}\n${actionText}`; - if (!reply && !actionText) return this.escalate(evt.terminalId, s, decision, "empty reply"); - - // Harness guards on the gate-bypassing inject channel (alongside the floor): a - // compromised/hallucinating judge must not inject an unbounded blob or smuggle - // multiple commands via embedded newlines/control chars in one "handle". - const MAX_REPLY_CHARS = 4096; - const written = actionText || reply; - if (written.length > MAX_REPLY_CHARS) { - return this.escalate(evt.terminalId, s, decision, `reply too long (${written.length} > ${MAX_REPLY_CHARS})`); - } - if (/[\x00-\x1f\x7f]/.test(written)) { - return this.escalate(evt.terminalId, s, decision, "reply contains control characters"); - } - // action.value is judge-generated free text (decision.ts has no allowlist), so a - // hallucinating judge could shape it like a filesystem path rather than a command - // verb. Require a single "/"-free, whitespace-free token so classifyDestructive's - // path check — scoped to `reply` only, not this value — never needs to reconsider it. - if (actionText && !/^\/[^\s/\\]+$/.test(actionText)) { - return this.escalate(evt.terminalId, s, decision, "slash command value is not a simple verb"); + // Every shape rule lives in reply-shape.ts because the judge is re-asked + // against the SAME function (see retryIfShape above): a rule the retry teaches + // but this gate does not enforce — or the reverse — is the exact failure this + // path exists to end. + const shape = replyShape(decision); + const rejection = checkReplyShape(shape, catalog); + // forcedReason only: floorRule is the §5.3 hard floor's alone, and setting it + // here would suppress the escalation card's one-tap choices. + // + // `guard_blocked`, like the two rejections below it: this row reports an + // action Handler wanted to take and a harness guard refused, so no later + // pause supersedes it and a typed line is not an answer to it. Every OTHER + // escalate() call site in this file is a question about the AGENT and keeps + // its kind. + if (rejection) { + return this.escalate(evt.terminalId, s, decision, rejection.reason, undefined, "guard_blocked"); } - // Chat adapters can't inject a slash command (drivers need a commandId - // the judge doesn't have); surfacing it beats silently sending "/x" as - // literal prompt text the agent would misread. - if (actionText && !this.deps.adapter.supportsSlashCommands(evt.terminalId)) { - return this.escalate(evt.terminalId, s, decision, "slash commands are not supported in chat sessions"); - } + // The probe is the whole command line — args included — because that is what + // reaches the agent, and what the runaway guard must hash to notice a repeat. + const probe = `${shape.reply}\n${shape.actionText}`; + // The path check gets the reply plus the ARGUMENT TAIL and never the verb: the + // VERB rule checkReplyShape just applied forbids both path separators inside a + // verb, while an absolute path in the args is a real one the floor has to see. + const pathText = `${shape.reply}\n${shape.args}`; // The floor's ONE call site (spec §5). It inspects the text Handler is // about to inject, never the commands the agent goes on to run. const projectPath = this.deps.projectPath(evt.terminalId); - const floor = classifyDestructive(probe, projectPath, reply); + const floor = classifyDestructive(probe, projectPath, pathText); // Checked before the partition, and never against it: §5.3 is liftable by // nothing, so no instruction can reach this branch. if (floor.hard.length > 0) { const reason = describeWarning(floor.hard[0]!); - return this.escalate(evt.terminalId, s, decision, `floor: ${reason}`, reason); + return this.escalate(evt.terminalId, s, decision, `floor: ${reason}`, reason, "guard_blocked"); } // §5.4: what the user's own instructions already authorized drops out of the // warning stream. It stays a separate list rather than being filtered away @@ -1202,7 +1382,9 @@ export class HandlerEngine { const { warn, authorized } = partitionWarnings(s.auth, floor.warnings, probe, projectPath); const guardReason = this.guard.check(evt.terminalId, probe); - if (guardReason) return this.escalate(evt.terminalId, s, decision, guardReason); + if (guardReason) { + return this.escalate(evt.terminalId, s, decision, guardReason, undefined, "guard_blocked"); + } if (authorized.length > 0) { log.info("handler floor: %d warning(s) authorized by instruction for %s", @@ -1213,7 +1395,7 @@ export class HandlerEngine { // §5.4 drops the warning, never the safety net ("I asked for it" is not the // same as "I wanted that exact result"). const snapshots = warn.length + authorized.length > 0 - ? await this.prepareSnapshots(evt.terminalId, written) + ? await this.prepareSnapshots(evt.terminalId, shape.written) : []; // The snapshot pass awaits git and the filesystem; a concurrent disarm may // have dropped this session, and injecting into one the user stopped is the @@ -1226,7 +1408,14 @@ export class HandlerEngine { return; } - this.deps.adapter.injectReply(evt.terminalId, written); + // A catalog hit routes on the driver's own command id, so a chat transport + // sends the argument tail alone; a PTY ignores it and types the whole line. + // A miss against an AVAILABLE catalog never gets here (checkReplyShape + // escalated it); a session with no catalog degrades to plain text, which the + // agent rejects visibly rather than the supervisor refusing in advance. + const command = findCommand(catalog, shape.verb); + this.deps.adapter.injectReply(evt.terminalId, shape.written, + command ? { id: command.id, args: shape.args } : undefined); this.guard.recordAutoReply(evt.terminalId, probe); // Both recorded after the inject and before the handle row, so the feed reads // as "what was saved, what was flagged, then what was sent". Auditability is @@ -1234,7 +1423,7 @@ export class HandlerEngine { // Assistant's own view of the risk. this.recordSnapshots(evt.terminalId, s, snapshots, [...warn, ...authorized]); this.noteFloorWarnings(evt.terminalId, s, warn); - this.record(evt.terminalId, "handle", decision.reason, written); + this.record(evt.terminalId, "handle", decision.reason, shape.written); if (this.maybeWrapUp(evt.terminalId, s)) return; s.state = restingState(s); this.persist(evt.terminalId, s, true); @@ -1300,9 +1489,9 @@ export class HandlerEngine { // not count. Past the ceiling, waiting is no longer the answer. if (!refresh && ++s.limitParks >= LIMIT_PARK_CEILING) { const unparked = this.unparkIfParked(evt.terminalId, s); - // One unanswered escalation is enough: until the user responds, every + // One unanswered question is enough: until the user responds, every // further limit says the same thing. - if (s.escalations.length === 0) { + if (pendingQuestions(s) === 0) { this.escalate(evt.terminalId, s, { decision: "escalate", confidence: 0, reason: "provider limit outlasted repeated waits", notify: { @@ -1345,10 +1534,10 @@ export class HandlerEngine { // normal escalation path and the phone gets an answerable row. Every // driver burns its own retry budget first, so three of these in a row // means the blip was not a blip. The counter stays at the ceiling until a - // judged turn or a human line clears it — so escalate only while nothing - // is already pending, or a stuck judge would append an identical row (and - // a push) on every single failure from here on. - if (s.escalations.length === 0) { + // judged turn or a human line clears it — so escalate only while no + // question is already pending, or a stuck judge would append an identical + // row (and a push) on every single failure from here on. + if (pendingQuestions(s) === 0) { this.escalate(evt.terminalId, s, { decision: "escalate", confidence: 0, reason: "repeated transient failures", notify: { @@ -1433,8 +1622,10 @@ export class HandlerEngine { // The nudge is an unsupervised submitted line (injectReply appends CR), so // it must never land while a question is waiting on the human: it would // answer a pending permission prompt on their behalf. The park is over - // either way — the human is the resume path now. - if (s.escalations.length > 0) return; + // either way — the human is the resume path now. A report answers nothing, + // so it is not one of those: leaving it in the count would strand every + // parked session that happened to be holding one. + if (pendingQuestions(s) > 0) return; // Notify-only means "tell me, never act" — so the wake is a notification, // not a nudge. Lifecycle events route ahead of the notify-only branch in // handleEventInner (a park is a fact, not a verdict), which is what lets a @@ -1460,8 +1651,17 @@ export class HandlerEngine { // Move backlog items on the evaluator's word — inside the bounds // applyTransitions enforces — and count real progress toward the runaway guard: // progress is evidence of non-looping. - private absorbTransitions(terminalId: string, s: ArmedSession, decision: HandlerDecision): void { - const result = applyTransitions(s.backlog, decision.transitions ?? [], this.now()); + private absorbTransitions( + terminalId: string, s: ArmedSession, decision: HandlerDecision, evidenceCorpus: string, + catalog?: CapCommand[], + ): void { + // The catalog the judge was actually shown, so the anchor asks about the same + // commands the prompt listed and checkReplyShape would accept. + const result = applyTransitions(s.backlog, decision.transitions ?? [], this.now(), { + evidenceCorpus, + commandNames: catalog?.map((c) => c.name), + anchorWaived: waivedAnchors(s), + }); // A rejection is an attempted invariant violation — a minted id, a terminal // move with no evidence, a completed item walked back — and this log is the // only place it can surface: the item simply does not move, so no downstream @@ -1470,6 +1670,28 @@ export class HandlerEngine { for (const r of result.rejected) { log.warn("handler transition rejected for %s: %s (id=%s status=%s)", terminalId, r.reason, r.transition.id, r.transition.status); + if (!EVIDENCE_CODES.has(r.code)) continue; + const item = result.backlog.find((i) => i.id === r.transition.id); + if (!item) continue; + // Counted per item, not per session: the waiver answers "this item's token + // cannot be quoted", which says nothing about the next item's. + if (r.code === "missing_command_anchor") { + s.anchorRefusals.set(item.id, (s.anchorRefusals.get(item.id) ?? 0) + 1); + } + // A refused completion is the one rejection the user has to be able to see: + // the item does not move, so the next status snapshot is identical to the + // last, and a session that will now never wrap up looks exactly like one + // still working. The log line above reaches nobody who is not tailing it. + if (!s.evidenceRejected.has(item.id)) { + s.evidenceRejected.add(item.id); + this.record(terminalId, "evidence_rejected", item.text, r.reason); + } + s.evidenceRejections.push({ + id: item.id, line: `"${oneLine(item.text).slice(0, 80)}" — ${r.reason}`, + }); + if (s.evidenceRejections.length > MAX_REMEMBERED_REJECTIONS) { + s.evidenceRejections = s.evidenceRejections.slice(-MAX_REMEMBERED_REJECTIONS); + } } // Blocking is derived, never judged (§3.3): an item is blocked because // something it depends on is, which is why it carries no evidence and why the @@ -1478,6 +1700,14 @@ export class HandlerEngine { const wasBlocked = new Set(result.backlog.filter((i) => i.status === "blocked").map((i) => i.id)); s.backlog = propagateBlocked(result.backlog); const byId = new Map(s.backlog.map((i) => [i.id, i])); + // The prompt section these feed is headed "those items are still open", and an + // item can close on a later pass — on a second, better-cited transition. Kept + // any longer, the section contradicts the BACKLOG block rendered beside it and + // asks the judge to re-cite work it has already closed. + s.evidenceRejections = s.evidenceRejections.filter((r) => { + const item = byId.get(r.id); + return item !== undefined && !isTerminalStatus(item.status); + }); for (const a of result.applied) { const kind = ITEM_DECISION[a.item.status]; @@ -1512,12 +1742,16 @@ export class HandlerEngine { // outstanding: an earlier escalate may already have banked the transitions // that completed the backlog (absorbTransitions runs on every decision, // including escalate), so a later handle/continue could otherwise auto-disarm - // and silently bury the unanswered escalation. - if (s.escalations.length > 0) return false; + // and silently bury the unanswered escalation. A `guard_blocked` report is + // not such a question — nothing is waiting on it — and holding the wrap-up + // open for one would leave a finished session armed until somebody tapped + // Dismiss; the push below is what carries the reports out instead. + if (pendingQuestions(s) > 0) return false; if (!allTerminal(s.backlog)) return false; this.record(terminalId, "wrapped_up", "every backlog item resolved", s.goal || NO_GOAL); this.deps.sendPush?.( - `Handler: done — ${oneLine(s.goal) || "session complete"}${this.wrapUpSummary(s.backlog)}${this.undoNote(terminalId)}`, + `Handler: done — ${oneLine(s.goal) || "session complete"}${this.wrapUpSummary(s.backlog)}` + + `${this.blockedNote(s)}${this.undoNote(terminalId)}`, terminalId, ); this.disarm(terminalId); @@ -1550,6 +1784,15 @@ export class HandlerEngine { return open.length > 0 ? `. ${open.length} flagged action(s) can still be undone` : ""; } + // The disarm takes the rows off the app with it — the app rebuilds its + // escalation list from the status snapshot, and a wrapped-up session is no + // longer in one — so this push is the last chance to say a guard refused + // something. The reports themselves survive in the activity feed. + private blockedNote(s: ArmedSession): string { + const reports = s.escalations.length - pendingQuestions(s); + return reports > 0 ? `. ${reports} action(s) Handler could not take — see the activity feed` : ""; + } + // Last non-empty output lines (PTY scrollback or rendered chat snapshot), // ANSI-stripped and capped — gives a notify-only escalation enough context // to act on from the lock screen. @@ -1562,7 +1805,7 @@ export class HandlerEngine { private escalate( terminalId: string, s: ArmedSession, decision: HandlerDecision, - forcedReason?: string, floorRule?: string, kind?: "reply" | "resolve_in_session", + forcedReason?: string, floorRule?: string, kind?: EscalationKind, promptId?: string, ): void { const reason = forcedReason ?? decision.reason; @@ -1572,9 +1815,27 @@ export class HandlerEngine { // chars and caps length, so `quickChoicesFor` withholds the one-tap chip on exactly // the drafts a guard would have refused. const draftReply = firstFilled(decision.notify?.draftReply, decision.reply) ?? ""; + const blocked = kind === "guard_blocked"; + // Nothing retires a report but the user, so an identical repeat would cost + // them a second Dismiss for a situation the standing row already describes in + // the same words. The feed still gets its row: that Handler was refused AGAIN + // is the fact worth keeping, and the feed is where it is durable. + if (blocked && s.escalations.some((e) => e.kind === "guard_blocked" + && e.reasoning === reason && e.draftReply === draftReply)) { + this.record(terminalId, "escalate", reason, draftReply === "" ? undefined : previewForUser(draftReply)); + // The three lines the normal path ends with, minus the push and the row. + // Every guard_blocked call site is a `return this.escalate(...)` out of the + // handle branch, which set "handling" before the judge call and resets it + // nowhere else — so returning early without this leaves the pill reporting + // work nobody is doing until the next event happens to land. + s.state = "needs_you"; + this.persist(terminalId, s, true); + this.emitStatus(); + return; + } const esc: OpenEscalation = { escalationId: this.id("esc"), - question: firstFilled(decision.notify?.body) ?? "Agent needs you", + question: blocked ? BLOCKED_QUESTION : firstFilled(decision.notify?.body) ?? "Agent needs you", reasoning: reason, draftReply, urgency: decision.notify?.urgency ?? "normal", @@ -1589,6 +1850,17 @@ export class HandlerEngine { this.deps.sendAb(createMessage("handler:escalation", { projectId: this.deps.projectId, terminalId, ...escalationWire(esc), })); + // Reports accumulate where questions cannot: a judge proposing a refused + // action again with a different reason raises a fresh row, and only the user + // takes any of them away. The oldest goes rather than the newest, so the list + // always describes the situation the session is in now. + if (blocked) { + const standing = s.escalations.filter((e) => e.kind === "guard_blocked"); + if (standing.length >= MAX_BLOCKED_REPORTS) { + const oldest = standing[0]!; + s.escalations = s.escalations.filter((e) => e !== oldest); + } + } s.escalations.push(esc); s.state = "needs_you"; // The activity row is read, never injected, so the control chars that forced some @@ -1706,6 +1978,42 @@ export class HandlerEngine { .catch((err: unknown) => log.warn("handler trash cleanup failed for %s: %s", terminalId, err)); } + /** + * Retire one `guard_blocked` report — the user saying they have read it. It is + * the ONLY thing that takes such a row away: it reports an action Handler never + * took, so no agent event and no typed line can be an answer to it. + * + * Refuses every other kind, deliberately. A `reply` row is already retired by + * the user's own submitted line, and dismissing one would drop a live question + * more silently than any path that exists today; a `resolve_in_session` row is + * refused for the reason onUserReply refuses to clear one — the agent stays + * blocked and no further event re-raises it. + * + * Idempotent in every direction the app can get wrong: an id this session no + * longer holds (a second tap racing the status frame that already dropped the + * row) resyncs the sender rather than failing. + */ + dismissEscalation(terminalId: string, escalationId: string): void { + const s = this.sessions.get(terminalId); + const esc = s?.escalations.find((e) => e.escalationId === escalationId); + if (!s || !esc || esc.kind !== "guard_blocked") { + // The sender is holding a row this session no longer has, or one it may not + // retire this way. A status resync is what removes it from their list. + log.warn("handler dismiss ignored: %s on %s", escalationId, terminalId); + this.emitStatus(); + return; + } + // No new activity kind: the `escalate` row is the durable trace of the + // refusal, and a dismissal is the user acknowledging their own read. + s.escalations = s.escalations.filter((e) => e !== esc); + // A park is not over because a report was read: the timer is still armed and + // parkKind/parkedUntil still describe the wait, so resting here would leave + // the state and the countdown chip describing different sessions. + if (s.state !== "parked") s.state = restingState(s); + this.persist(terminalId, s, true); + this.emitStatus(); + } + /** * Perform the undo an advertised snapshot promised (§5.2). * diff --git a/bridge/src/handler/evidence.ts b/bridge/src/handler/evidence.ts new file mode 100644 index 00000000..857cc308 --- /dev/null +++ b/bridge/src/handler/evidence.ts @@ -0,0 +1,86 @@ +// bridge/src/handler/evidence.ts + +// Citation primitives for the §2.1 evidence gate. Deliberately dependency-free — +// backlog.ts imports this, and backlog.ts is the module every other handler file +// leans on, so a single import the other way would close a cycle. +// +// What these can and cannot answer: together they decide whether a quote is +// GROUNDED (it really occurs in the material the judge was shown) and whether a +// completion claim NAMES the command the item asked for. Neither can tell a +// correct attribution from a real quote about the wrong subject — that judgement +// needs a reader, and the collapsed Auditor role is exactly the reader we +// refuse to re-introduce. + +/** Shortest run of characters that still reads as a citation rather than a + * gesture. Below this a quote occurs in almost any corpus by accident, so + * grounding would wave it through and the gate would mean nothing. Set low + * enough that a terse-but-real citation ("exit code 0") survives; a shorter one + * ("exit 0") does not, and that is the cost of having a floor at all. */ +export const MIN_EVIDENCE_CHARS = 10; + +// Judges rewrite punctuation on the way out — smart quotes from a markdown +// pipeline, an em dash for a hyphen, a non-breaking space in a wrapped line — so +// a raw substring test rejects honest citations for typography alone. Ellipsis +// is folded to the ASCII spelling here so citationSegments only has to know one. +// Written as escapes, never literals: this table is the one place the exact code +// points matter, and a literal NBSP is indistinguishable from a space in every +// editor that will ever open this file. +const SUBSTITUTIONS: [RegExp, string][] = [ + [/[\u2018\u2019\u201b]/g, "'"], + [/[\u201c\u201d\u201f]/g, '"'], + [/[\u2013\u2014\u2212]/g, "-"], + [/[\u00a0\u2007\u202f]/g, " "], + [/\u2026/g, "..."], +]; + +export function normalizeForCitation(s: string): string { + let out = s.toLowerCase(); + for (const [re, to] of SUBSTITUTIONS) out = out.replace(re, to); + return out.replace(/\s+/g, " ").trim(); +} + +/** Split a normalized quote at its elisions. A judge that drops the middle of a + * line is still citing honestly, so each surviving run is matched separately — + * but only runs long enough to discriminate. Two- and three-character fragments + * ("in", "the") occur everywhere, so a quote spliced down to those would clear + * grounding on a corpus that never contained it. */ +export function citationSegments(normalized: string): string[] { + return normalized.split(/\.{3,}/).map((p) => p.trim()).filter((p) => p.length >= 4); +} + +/** Sequential containment: every segment must appear, and in the order the judge + * wrote them. An unordered check would let a quote be assembled backwards out of + * fragments that never sat together. */ +export function containsInOrder(hay: string, segments: string[]): boolean { + let cursor = 0; + for (const seg of segments) { + const at = hay.indexOf(seg, cursor); + if (at < 0) return false; + cursor = at + seg.length; + } + return true; +} + +// The token shape mirrors reply-shape.ts's VERB — one token, no second `/`, no +// backslash — because that is the only thing the harness will ever actually +// INVOKE, so it is the only shape an item can be asking for. The second-slash +// rule is what keeps `/tmp/foo` out: a path fragment is not a command, and +// treating one as an item's named command would demand evidence of a command +// that does not exist. A leading letter is required for the same reason `1/2` is +// not a match — a digit after the slash is arithmetic or a path, never a verb. +const TOKEN_SCAN = /(?:^|[\s"'`(\[])(\/[^\s"'`)\]]+)/g; +const COMMAND_TOKEN = /^\/[a-z][^\s/\\]*$/; + +/** Every slash-command-shaped token in a piece of text, lowercased and deduped. + * Read off USER-authored item text, never judge output: it is what the item + * asked for, and the whole point is that the judge cannot choose it. */ +export function commandTokens(text: string): string[] { + const found = new Set(); + for (const m of text.matchAll(TOKEN_SCAN)) { + // Sentence punctuation rides along on a token quoted in prose ("run /init.") + // and is never part of the verb. + const token = m[1]!.toLowerCase().replace(/[.,;:!?]+$/, ""); + if (COMMAND_TOKEN.test(token)) found.add(token); + } + return [...found]; +} diff --git a/bridge/src/handler/judge.ts b/bridge/src/handler/judge.ts index d6e4280e..06a019e1 100644 --- a/bridge/src/handler/judge.ts +++ b/bridge/src/handler/judge.ts @@ -1,47 +1,12 @@ // bridge/src/handler/judge.ts -import { agentSpec } from "../agents/registry"; +import { runHeadless } from "../agents/headless"; +import type { CapCommand } from "../structured/chat-session"; import { - buildDecidePrompt, buildRetryPrompt, parseDecisionFromOutput, + buildDecidePrompt, buildRetryPrompt, buildShapeRetryPrompt, parseDecisionFromOutput, pickJudge, type HandlerDecision, } from "./decision"; import { buildExtractPrompt, parseItemsFromOutput, type ExtractedItem } from "./extract"; -// One spawn of the agent's own CLI (reusing its auth). Spawn failure → null. -// A timeout still returns whatever stdout was captured before the kill — the -// CLI may have written a complete answer and merely lingered on exit, so the -// caller parses it before deciding; `timedOut` tells the caller to skip the -// retry leg (retry exists for malformed output, not for a hung judge). -async function spawnJudge( - cmd: string[], cwd: string, timeoutMs: number, spawn: typeof Bun.spawn, - env?: Record, -): Promise<{ stdout: string; timedOut: boolean } | null> { - let timer: ReturnType | undefined; - try { - // Spread process.env: Bun.spawn's `env` REPLACES the environment rather than - // merging, so passing the overrides alone would strip PATH and the agent's - // own auth vars out from under the judge. - const proc = spawn(cmd, { - cwd, stdout: "pipe", stderr: "ignore", - ...(env ? { env: { ...process.env, ...env } } : {}), - }); - let timedOut = false; - timer = setTimeout(() => { - timedOut = true; - try { proc.kill(); } catch { /* already gone */ } - }, timeoutMs); - const out = await new Response(proc.stdout).text(); - await proc.exited; - return { stdout: out, timedOut }; - } catch { - return null; - } finally { - // Must be finally, not a tail call: killing the proc mid-read rejects the - // stdout read, and an un-cleared timer then stays armed for the full budget - // holding the dead proc alive — once per judge call, on every failure. - clearTimeout(timer); - } -} - // Eval-only judge override (Task 16's e2e harness): the spawned agent process can't // have fakes injected in-process, so swap the CLI for a scripted bun script. Gated // on ANTGRID_EVAL_TEST like the /test/open-pairing-window hook — inert in production. @@ -50,9 +15,17 @@ function resolveCmd(cmd: string[], prompt: string): string[] { return script ? ["bun", script, prompt] : cmd; } -// Shared retry-once shape: build prompt → spawn → parse; on parse failure re-spawn -// with the validation error appended; second failure fails closed (null). Timeout -// and spawn failure fail closed IMMEDIATELY — no retry. +// Shared retry-once shape: build prompt → spawn → parse. A parse failure OR a +// caller `retryIf` rejection each earn exactly one re-spawn; the second answer is +// final. Timeout and spawn failure earn none. +// +// Null means ONE thing — no attempt produced a value — because that is what a +// caller reads as a judge outage: HandlerEngine parks the session and re-runs the +// event on it. So a value that PARSED and was then rejected always beats null on +// the way out, on every leg (timeout, spent budget, failed retry spawn); parking +// on it would swallow a decision the caller's own gate would have escalated with +// its text attached. Null is left for a tool with no repo-reach judge, a failed +// spawn, and output that never parsed at all. // // timeoutMs is a TOTAL budget across both attempts, not per-spawn: the retry gets // only the time the first attempt left unspent. This keeps worst-case wall time at @@ -64,44 +37,81 @@ async function runWithRetry(opts: { spawn?: typeof Bun.spawn; transcriptPath?: string; makePrompt: (transcriptPath?: string) => string; parse: (stdout: string) => { value: T | null; error?: string }; + // Re-ask once when the parsed value is well-formed JSON but breaks a caller + // rule the prompt states (a non-null string is that rejection's reason). + // Deliberately a CALLER hook rather than a rule of this module: the caller's + // safety verdicts live above this function and must stay unreachable from + // the retry — a retry loop around a safety verdict is a bypass. + retryIf?: (value: T) => string | null; }): Promise { const spawn = opts.spawn ?? Bun.spawn; - // Tier first: transcript-tier judges have no Read tool, so a transcript-path - // hint would be an instruction they cannot follow. No judge on the spec means - // no verified headless judge for this tool — Handler stays escalate-only. - const judge = agentSpec(opts.tool)?.judge; + // Reach first: a transcript-reach judge has no Read tool, so a transcript-path + // hint would be an instruction it cannot follow. No "repo" entry on the spec + // means no verified headless judge for this tool — Handler stays + // escalate-only, and a sealed entry is never promoted into one just because + // the agent can answer a question (see AgentSpec.headless). + const judge = pickJudge(opts.tool); if (!judge) return null; const path = judge.tier === "readonly" ? opts.transcriptPath : undefined; + // Output is parsed whatever the exit code says: a judge answers in JSON, so a + // failed run cannot masquerade as a verdict the way a one-line refusal can + // masquerade as a title (see runHeadless). + const run = (p: string, timeoutMs: number) => runHeadless( + resolveCmd(judge.command.cmd(p, opts.model), p), + { + cwd: opts.cwd, timeoutMs, spawn, + env: judge.command.env, scratchEnv: judge.command.scratchEnv, + }, + ); const prompt = opts.makePrompt(path); const started = Date.now(); - const out1 = await spawnJudge(resolveCmd(judge.cmd(prompt, opts.model), prompt), opts.cwd, opts.timeoutMs, spawn, judge.env); + const out1 = await run(prompt, opts.timeoutMs); if (out1 === null) return null; const r1 = opts.parse(out1.stdout); - if (r1.value) return r1.value; - if (out1.timedOut) return null; // hung judge with unusable output: no retry + const shapeError = r1.value ? opts.retryIf?.(r1.value) ?? null : null; + if (r1.value && !shapeError) return r1.value; + // A shape-rejected value always beats null on the way out: null means "the + // judge could not run" to every caller, and a caller that parks on an outage + // would silently swallow a decision its own guards would have escalated with + // the text attached. Null still comes back where it always did — a first + // attempt whose output would not parse at all. + if (out1.timedOut) return r1.value; // hung judge with unusable output: no retry // Budget spent by the first attempt is gone; the retry runs only within what // remains. If none is left, fail closed rather than start a full second timeout. const remaining = opts.timeoutMs - (Date.now() - started); - if (remaining <= 0) return null; - const retryPrompt = buildRetryPrompt(prompt, r1.error ?? "invalid output"); - const out2 = await spawnJudge(resolveCmd(judge.cmd(retryPrompt, opts.model), retryPrompt), opts.cwd, remaining, spawn, judge.env); - if (out2 === null) return null; - return opts.parse(out2.stdout).value; + if (remaining <= 0) return r1.value; + const retryPrompt = shapeError + ? buildShapeRetryPrompt(prompt, shapeError) + : buildRetryPrompt(prompt, r1.error ?? "invalid output"); + const out2 = await run(retryPrompt, remaining); + if (out2 === null) return r1.value; + // Exactly one retry: the second answer is final even if it breaks the same + // rule, and the caller's own gate escalates it from there. + return opts.parse(out2.stdout).value ?? r1.value; } export async function runDecision(opts: { + // `tool` is the JUDGE's CLI (a per-session pick may name a different agent + // from the one being watched); `agentTool` is the agent under supervision, + // and only the prompt reads it. tool: string; model?: string; goal: string; backlogText: string; context: string; transcriptPath?: string; cwd: string; timeoutMs?: number; spawn?: typeof Bun.spawn; floorWarnings?: string[]; + evidenceRejections?: string[]; + agentTool?: string; + commands?: CapCommand[]; + retryIfShape?: (decision: HandlerDecision) => string | null; }): Promise { return runWithRetry({ tool: opts.tool, model: opts.model, cwd: opts.cwd, timeoutMs: opts.timeoutMs ?? 45_000, spawn: opts.spawn, transcriptPath: opts.transcriptPath, + retryIf: opts.retryIfShape, makePrompt: (path) => buildDecidePrompt({ goal: opts.goal, backlogText: opts.backlogText, context: opts.context, transcriptPath: path, - floorWarnings: opts.floorWarnings, + floorWarnings: opts.floorWarnings, evidenceRejections: opts.evidenceRejections, + agentTool: opts.agentTool, commands: opts.commands, }), parse: (stdout) => { const r = parseDecisionFromOutput(stdout); diff --git a/bridge/src/handler/reply-shape.ts b/bridge/src/handler/reply-shape.ts new file mode 100644 index 00000000..789563ed --- /dev/null +++ b/bridge/src/handler/reply-shape.ts @@ -0,0 +1,109 @@ +// bridge/src/handler/reply-shape.ts +import type { CapCommand } from "../structured/chat-session"; +import type { HandlerDecision } from "./decision"; + +// Harness guards on the gate-bypassing inject channel (alongside the destructive +// floor, which the engine applies separately): a compromised or hallucinating +// judge must not inject an unbounded blob, nor smuggle several commands into one +// "handle" via embedded newlines or control chars. +export const MAX_REPLY_CHARS = 4096; +const VERB = /^\/[^\s/\\]+$/; +const CONTROL_CHARS = /[\x00-\x1f\x7f]/; + +// Exported because the engine flattens the same way for its push bodies, where a +// stray newline renders as a broken multi-line notification. +export function oneLine(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +/** Split a slash_command value on its FIRST run of whitespace. The tail keeps its + * internal spacing: it is typed at the agent verbatim, and a control character + * hiding in it must still reach the guard below rather than be normalized away. */ +export function splitSlashCommand(value: string): { verb: string; args: string } { + const v = value.trim(); + const i = v.search(/\s/); + return i < 0 ? { verb: v, args: "" } : { verb: v.slice(0, i), args: v.slice(i).trimStart() }; +} + +/** Matched on `name`, never on `id`: an id is per-backend routing metadata whose + * prefix differs by agent (`cmd:`, `skill:`, `builtin:`) — codex emits no `cmd:` + * ids at all — while `name` is the bare verb on every one of them. Case- + * insensitive because names come verbatim from filesystem paths and frontmatter. */ +export function findCommand(catalog: CapCommand[] | undefined, verb: string): CapCommand | undefined { + if (!verb) return undefined; + const name = verb.replace(/^\//, "").toLowerCase(); + return catalog?.find((c) => c.name.toLowerCase() === name); +} + +export interface ReplyShape { + /** The free-text reply, flattened to the one line injectReply will submit. */ + reply: string; + /** The whole trimmed slash_command value, verb and args together. */ + actionText: string; + verb: string; + args: string; + /** What is injected, recorded in the activity feed and hashed by the runaway + * guard: the full command line, never one half of the split. */ + written: string; +} + +export interface ShapeRejection { + reason: string; + /** Whether re-asking the judge could fix it. False for catalog membership: the + * catalog is already in the prompt, so a second miss names a command that does + * not exist rather than a formatting slip. */ + retryable: boolean; +} + +export function replyShape(decision: HandlerDecision): ReplyShape { + // Flattened here, before anything reads it: injectReply submits with a trailing + // CR, so a line break INSIDE the reply submits early and turns one decision into + // several commands. Judges write ordinary paragraphs, so collapse to the single + // line that will actually be typed rather than refusing the reply. Only + // whitespace collapses — Ctrl-C, EOF and escape have no formatting reading and + // still fail the control-char rule below. + const reply = oneLine(decision.reply ?? ""); + const actionText = (decision.action?.kind === "slash_command" ? decision.action.value : "").trim(); + const { verb, args } = actionText ? splitSlashCommand(actionText) : { verb: "", args: "" }; + return { reply, actionText, verb, args, written: actionText || reply }; +} + +/** The one place a "handle" is refused for its SHAPE. Safety verdicts (the + * destructive floor, the runaway guard) live in the engine and deliberately not + * here: this function is also the judge's retry trigger, and a retry around a + * safety verdict is a bypass. */ +export function checkReplyShape(shape: ReplyShape, catalog: CapCommand[] | undefined): ShapeRejection | null { + if (!shape.reply && !shape.actionText) return { reason: "empty reply", retryable: false }; + // Setting both is a judge error, not a preference to resolve here. Picking one + // sends the other nowhere — unvalidated, unguarded and unread — and injecting + // both would be exactly the two-commands-from-one-decision the control-char rule + // exists to stop. The prompt states the rule, which is what makes this teachable. + if (shape.reply && shape.actionText) { + return { reason: "set either reply or action, not both", retryable: true }; + } + if (shape.written.length > MAX_REPLY_CHARS) { + return { reason: `reply too long (${shape.written.length} > ${MAX_REPLY_CHARS})`, retryable: true }; + } + if (CONTROL_CHARS.test(shape.written)) { + return { reason: "reply contains control characters", retryable: true }; + } + if (!shape.actionText) return null; + // The VERB alone carries the shape rule; the argument tail is free text the + // destructive floor inspects instead. + if (!VERB.test(shape.verb)) { + return { reason: "slash command value is not a simple verb", retryable: true }; + } + // Membership is conditional on a catalog being NON-EMPTY, matching the branch + // buildDecidePrompt renders on (`opts.commands?.length`): an empty array is told + // "no catalog available, prefer plain instructions" and must not then be refused + // every verb it emits — the silent-refusal loop this design exists to end, + // inverted. Absent or empty means "none available" (a PTY, a driver reporting + // none, discovery that has not landed) — not "this agent has no commands" — and + // refusing on that would ground every terminal session. There, an invented + // command reaches the agent, is rejected, and shows up in the next context: + // visible and recoverable. + if (catalog?.length && !findCommand(catalog, shape.verb)) { + return { reason: `slash command ${shape.verb} is not in this session's command catalog`, retryable: false }; + } + return null; +} diff --git a/bridge/src/handler/session-adapter.ts b/bridge/src/handler/session-adapter.ts index 0efee1e6..8c7046e1 100644 --- a/bridge/src/handler/session-adapter.ts +++ b/bridge/src/handler/session-adapter.ts @@ -1,9 +1,17 @@ +import type { CapCommand } from "../structured/chat-session"; + // Transport seam (spec §Session adapter seam): everything above this interface — // goal + backlog, judge, floors, runaway guard — is transport-agnostic. The PTY // adapter writes to a live terminal; the structured (chat) adapter lives in // structured-adapter.ts and rides the driver's prompt path. + +/** A catalog hit the engine already resolved: the chat transport routes on + * `id` and sends `args` as its text, while a PTY ignores it — the verb is + * already inside `text` there, and a terminal has no routing channel. */ +export interface InjectCommand { id: string; args: string } + export interface SessionAdapter { - injectReply(sessionId: string, text: string): void; + injectReply(sessionId: string, text: string, command?: InjectCommand): void; // Promise for chat sessions (context is rendered from an async driver // snapshot); plain string for PTY. The engine awaits either. recentOutput(sessionId: string): string | Promise; @@ -12,9 +20,12 @@ export interface SessionAdapter { // earns the full per-purpose budget). See assembleContext's recentKind. outputKind(sessionId: string): "pty" | "rendered"; transcriptPath(sessionId: string): string | undefined; - // Slash commands are typed text in a PTY; structured drivers need a commandId - // the judge doesn't have, so chat adapters refuse and the engine escalates. - supportsSlashCommands(sessionId: string): boolean; + // Non-empty or nothing: undefined means "no catalog available for this + // session", which a PTY always answers and a chat session answers until its + // driver reports one. The engine requires membership only when a catalog IS + // available — an invented verb typed at a terminal is visible and + // recoverable, an invented allowlist verdict is not. + commandCatalog(sessionId: string): CapCommand[] | undefined; } export function createPtyAdapter(deps: { @@ -29,7 +40,7 @@ export function createPtyAdapter(deps: { recentOutput: (id) => deps.getRecentOutput(id), outputKind: () => "pty", transcriptPath: (id) => deps.getTranscriptPath(id), - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }; } @@ -43,10 +54,10 @@ export function createDispatchAdapter(deps: { }): SessionAdapter { const pick = (id: string) => (deps.isChat(id) ? deps.chat : deps.pty); return { - injectReply: (id, text) => pick(id).injectReply(id, text), + injectReply: (id, text, command) => pick(id).injectReply(id, text, command), recentOutput: (id) => pick(id).recentOutput(id), outputKind: (id) => pick(id).outputKind(id), transcriptPath: (id) => pick(id).transcriptPath(id), - supportsSlashCommands: (id) => pick(id).supportsSlashCommands(id), + commandCatalog: (id) => pick(id).commandCatalog(id), }; } diff --git a/bridge/src/handler/session-store.ts b/bridge/src/handler/session-store.ts index e835ee96..ff8634f3 100644 --- a/bridge/src/handler/session-store.ts +++ b/bridge/src/handler/session-store.ts @@ -29,6 +29,23 @@ export type EscalationChoice = z.infer; const uniqueChoiceIds = (cs: EscalationChoice[]): boolean => new Set(cs.map((c) => c.choiceId)).size === cs.length; +// What answers an escalation, which is also what retires it: +// - `reply` (or absent) — a pause-question about the agent. Any submitted line +// supersedes it, because each pause supersedes the last. +// - `resolve_in_session` — an option-based agent prompt only the chat resolve +// RPC can answer. +// - `guard_blocked` — a REPORT that a harness guard refused an action Handler +// wanted to take. Nothing the agent or the user does next answers it: the +// action was never taken, so no later pause supersedes it and no resolve +// names it. Only an explicit dismiss retires one (see the clearing rule in +// onUserReply, and dismissEscalation in engine.ts). +// +// The enum only ever widens, so a record written before a member existed still +// parses; the reverse — an older bridge reading a newer record — fails the whole +// record and comes back disarmed, which is the trade `version`'s note already owns. +export const EscalationKindSchema = z.enum(["reply", "resolve_in_session", "guard_blocked"]); +export type EscalationKind = z.infer; + // An unanswered escalation. The engine keeps the full payload (not just a // count) so a phone that reconnects — or an app that restarts — can rebuild an // answerable "needs you" row from the status snapshot instead of showing a @@ -43,7 +60,7 @@ export const OpenEscalationSchema = z.object({ draftReply: z.string(), urgency: z.enum(["normal", "high"]), floorRule: z.string().optional(), - kind: z.enum(["reply", "resolve_in_session"]).optional(), + kind: EscalationKindSchema.optional(), // `resolve_in_session` only: the driver's permissionId/questionId, so the // resolve RPC retires the row for the prompt it answered and leaves a second // prompt on the same terminal pending. Engine-internal — deliberately NOT in diff --git a/bridge/src/handler/structured-adapter.ts b/bridge/src/handler/structured-adapter.ts index adbac036..a626f50d 100644 --- a/bridge/src/handler/structured-adapter.ts +++ b/bridge/src/handler/structured-adapter.ts @@ -1,6 +1,7 @@ // bridge/src/handler/structured-adapter.ts import type { AbMessage } from "../protocol"; import type { SessionAdapter } from "./session-adapter"; +import type { CapCommand } from "../structured/chat-session"; import { DECIDE_MAX_CHARS } from "./context"; // Flatten a driver transcript snapshot into judge-readable plain text. @@ -33,16 +34,21 @@ export function renderSnapshotText(frames: AbMessage[], maxChars = DECIDE_MAX_CH } export function createStructuredAdapter(deps: { - prompt: (sessionId: string, text: string) => void; + prompt: (sessionId: string, text: string, commandId?: string) => void; getTranscriptPath: (sessionId: string) => string | undefined; getSnapshot: (sessionId: string) => Promise; + commandCatalog: (sessionId: string) => CapCommand[] | undefined; }): SessionAdapter { return { // Auto-replies ride the same driver prompt path as an app-sent agent:prompt. // NEVER resolvePermission/resolveQuestion here: approving a pending tool // call is a human-only act — the destructive floor inspects reply text and // cannot see structured tool calls (see engine's forced-escalation events). - injectReply: (id, text) => deps.prompt(id, text), + // + // No command => a plain prompt of the WHOLE text, slash and all. Handing a + // driver a commandId it does not recognize is strictly worse than handing + // it none: every backend drops the verb and sends the bare args. + injectReply: (id, text, command) => deps.prompt(id, command ? command.args : text, command?.id), recentOutput: async (id) => { // Fail closed: a dead driver yields empty context, and the judge's own // unavailable/low-confidence path escalates — never throw from the seam. @@ -51,6 +57,6 @@ export function createStructuredAdapter(deps: { }, outputKind: () => "rendered", transcriptPath: (id) => deps.getTranscriptPath(id), - supportsSlashCommands: () => false, + commandCatalog: (id) => deps.commandCatalog(id), }; } diff --git a/bridge/src/host-server.ts b/bridge/src/host-server.ts index bc83013e..2a45a673 100644 --- a/bridge/src/host-server.ts +++ b/bridge/src/host-server.ts @@ -1873,6 +1873,17 @@ export class HostServer { if (!isManagedCheckoutKind(session.checkoutKind)) { return SessionManager.deletePersisted(resolveAbDir(), projectId, sessionId); } + // A forked "current workspace" session shares its checkout with siblings, so + // removing the worktree here would delete THEIR working tree — with the + // dirty/unpushed preflight having only ever looked at the shared tree, and + // `force` from a dialog that described this row's changes. Detach instead, + // mirroring SessionManager.deleteAttachedMemberLocked. `CheckoutRecord`'s + // singular `sessionId` is informational (nothing reads it), so the row can + // simply go. + const members = persisted.filter((entry) => entry.checkoutId === session.checkoutId); + if (members.length > 1) { + return SessionManager.deletePersisted(resolveAbDir(), projectId, sessionId); + } if (options.removeCheckout === false) { throw new WorktreeError("WORKTREE_CONFLICT", "An isolated session must remove its managed worktree when deleted."); } diff --git a/bridge/src/project-core.ts b/bridge/src/project-core.ts index 1c17f119..49fa4fee 100644 --- a/bridge/src/project-core.ts +++ b/bridge/src/project-core.ts @@ -6,7 +6,7 @@ import { createRelayPromotion, type RelayPromotionController, type RelayPromotio import type { AttachStreamOpts, StreamHandle } from "./stream-mux"; import { createMessage, type AbMessage, type SessionEntry, type WorkStatus } from "./protocol"; import type { DeleteSessionOptions } from "./session-manager"; -import { answerRequest, clientFocusState, clientGone, closeTurn, initialWorkStatus, reduceWorkStatus, sessionFocus, turnStart, userReply, type WorkStatusState } from "./work-status"; +import { answerRequest, clientFocusState, clientGone, closeTurn, initialWorkStatus, isStaleIdleNudge, reduceWorkStatus, sessionFocus, turnStart, userReply, type WorkStatusState } from "./work-status"; import { logger } from "./logger"; const log = logger.child({ component: "project-core" }); import { createPushDispatcher } from "./push/push-dispatcher"; @@ -303,6 +303,11 @@ export class ProjectCore { // one of its own. Read lazily — the fold that answers it runs after the // frame this feeds, so `refreshSessionWork` below is what re-emits. sessionWorkStatusFor: (id) => this._work.sessionStatuses.get(id), + // The same reduction, and the same question `attachRelayStream`'s push + // subscriber asks via `_lastNotificationRedundant` — asked here too so the + // Handler never pays a context assemble plus a judge spawn for a nudge on + // a turn that already finished. + isStaleIdleNudge: (id) => isStaleIdleNudge(this._work, id), relayUrl: this.deps.relayUrl, }); this.core = core; diff --git a/bridge/src/protocol.ts b/bridge/src/protocol.ts index c5a525c0..b0cc868f 100644 --- a/bridge/src/protocol.ts +++ b/bridge/src/protocol.ts @@ -854,7 +854,14 @@ const OpenEscalationWire = z.object({ // "resolve_in_session" = an option-based prompt (permission / structured // question) that must be resolved in the chat UI — injected text can't // answer it, and auto-approval is deliberately impossible (see engine). - kind: z.enum(["reply", "resolve_in_session"]).optional(), + // + // "guard_blocked" = a REPORT that a harness guard (reply shape, the §5.3 hard + // floor, the runaway guard) refused an action Handler wanted to take. A typed + // line does not answer it — the action was never taken — so only + // `handler:dismiss` retires one, and the bridge never mints `choices` for it: + // this row exists BECAUSE a guard refused this exact text, and a one-tap that + // re-sent it would be the thinnest human in the loop there is. + kind: z.enum(["reply", "resolve_in_session", "guard_blocked"]).optional(), // §4.6 quick choices, optional exactly the way `kind` is: absent means "free-text // reply", so an app that predates this renders its reply sheet unchanged. Two is // the floor because one chip is a card with no alternative, and the free-text @@ -918,6 +925,25 @@ const HandlerUndoMessage = BaseMessage.extend({ projectId: z.string(), }).extend(HandlerUndoWire.shape); +// The user acknowledging a `guard_blocked` escalation — the only thing that +// retires one, since nothing the agent or the user does next answers a report +// about an action Handler never took. Payload-only for the same reason as +// HandlerUndoWire: parseMessageFast admits on the discriminator alone, so +// agent-core re-parses with this before the engine sees it. +// +// It carries a terminalId where handler:undo carries none, because an escalation +// lives on one supervised session while a snapshot is project-scoped and names +// its own session through the store. +export const HandlerDismissWire = z.object({ + terminalId: z.string(), + escalationId: z.string(), +}); + +const HandlerDismissMessage = BaseMessage.extend({ + type: z.literal("handler:dismiss"), + projectId: z.string(), +}).extend(HandlerDismissWire.shape); + const HandlerSessionSnapshot = z.object({ terminalId: z.string(), notifyOnly: z.boolean(), @@ -979,7 +1005,8 @@ const HandlerActivityMessage = BaseMessage.extend({ "continue", "handle", "escalate", "armed", "goal_edited", "item_done", "item_blocked", "item_skipped", "item_failed", - "instruction_dropped", "floor_warning", "wrapped_up", "parked", "resumed", + "instruction_dropped", "floor_warning", "evidence_rejected", + "wrapped_up", "parked", "resumed", ]), reason: z.string(), detail: z.string().optional(), @@ -1149,6 +1176,13 @@ const SessionEntrySchema = z.object({ deleting: z.boolean().default(false), tool: z.string().optional(), command: z.string().optional(), + // A current bridge derives this from the registry adapter. False by default + // keeps the menu hidden against an older bridge that cannot parse session:fork. + forkSupported: z.boolean().default(false), + // The session this one was forked from. Provenance rather than a link: the + // source may be renamed, archived or deleted, and no surface resolves it + // back — it is what survives the derived name once either side is renamed. + forkedFromSessionId: z.string().optional(), // Raw, shell-interpreted CLI-args string passed verbatim (not an argv array). args: z.string().optional(), mode: z.enum(["terminal", "chat"]).default("terminal"), @@ -1176,6 +1210,8 @@ const SessionEntrySchema = z.object({ checkoutKind: z.enum(["main", "managed-worktree", "external-worktree"]).default("main"), checkoutBranch: z.string().nullable().optional(), checkoutState: z.enum(["ready", "missing", "failed"]).default("ready"), + sharedWorkspace: z.boolean().default(false), + workspaceMemberCount: z.number().int().positive().default(1), // Provisioning of this session's own checkout (`worktree.setup`). Orthogonal // to `checkoutState`, deliberately: that answers "is this workspace usable", // this one "has provisioning finished" — a checkout is `ready` while setup is @@ -1234,6 +1270,16 @@ const SessionCreateMessage = BaseMessage.extend({ } }); +// A fork names only an existing bridge-owned session and its workspace policy. +// In particular it never accepts an agent-native id, transcript, path, command +// or argv from a client: all of those are local authority held by the bridge. +const SessionForkMessage = BaseMessage.extend({ + type: z.literal("session:fork"), + requestId: z.string(), + sourceSessionId: z.string(), + workspace: z.enum(["copy", "current"]), +}); + const SessionStartMessage = BaseMessage.extend({ type: z.literal("session:start"), requestId: z.string(), @@ -1788,6 +1834,7 @@ export const AbMessageSchema = z.discriminatedUnion("type", [ HandlerActivityMessage, HandlerSnapshotMessage, HandlerUndoMessage, + HandlerDismissMessage, GitStatusMessage, GitDiffRequestMessage, GitDiffContentMessage, @@ -1819,6 +1866,7 @@ export const AbMessageSchema = z.discriminatedUnion("type", [ SessionListMessage, SessionListResultMessage, SessionCreateMessage, + SessionForkMessage, SessionStartMessage, SessionStopMessage, SessionRenameMessage, @@ -1911,6 +1959,7 @@ export type HandlerEscalationMsg = z.infer; export type HandlerActivityMsg = z.infer; export type HandlerSnapshotMsg = z.infer; export type HandlerUndoMsg = z.infer; +export type HandlerDismissMsg = z.infer; export type GitStatus = z.infer; export type GitDiffRequest = z.infer; export type GitDiffContent = z.infer; @@ -1957,6 +2006,7 @@ export type SessionEntry = z.infer; export type SessionList = z.infer; export type SessionListResult = z.infer; export type SessionCreate = z.infer; +export type SessionFork = z.infer; export type SessionStart = z.infer; export type SessionStop = z.infer; export type SessionRename = z.infer; @@ -2090,7 +2140,7 @@ const KNOWN_TYPES = new Set([ "agent:disconnecting", "agent:projects", "agent:tools", "stream-ready", "stream-invalid", "control:result", "app:ready", "command:run", "command:output", "command:done", "notification:push", "push:register", "handler:configure", "handler:instruct", "handler:status", "handler:escalation", "handler:activity", - "handler:snapshot", "handler:undo", + "handler:snapshot", "handler:undo", "handler:dismiss", "git:status", "git:diff", "git:diff-content", "git:list-branches", "git:branches", "git:checkout", "git:checkout-result", "git:commit", "git:commit-result", "git:discard", "git:discard-result", @@ -2105,7 +2155,7 @@ const KNOWN_TYPES = new Set([ "config:read", "config:read-result", "config:write", "config:write-result", "config:changed", "config:detect-tools", "config:detect-tools-result", "session:list", "session:list:result", - "session:create", "session:start", "session:stop", + "session:create", "session:fork", "session:start", "session:stop", "session:rename", "session:archive", "session:unarchive", "session:delete", "session:set-mode", "session:setup", "session:focus", "session:result", "session:updated", diff --git a/bridge/src/session-manager.ts b/bridge/src/session-manager.ts index 86a65444..126ad3bb 100644 --- a/bridge/src/session-manager.ts +++ b/bridge/src/session-manager.ts @@ -12,6 +12,10 @@ import { } from "./known-agents"; import { augmentAgentLaunch, injectsHookAliveProbe } from "./agent-launch-augmenter"; import { agentSpec } from "./agents/registry"; +// Aliased: this module declares its own, unrelated `AgentSpec` (the launch +// triple) right below. +import type { AgentSpec as RegistryAgentSpec } from "./agents/types"; +import { stripAnsi } from "./handler/context"; import { resumeArgv, sessionResumable } from "./agent-resume"; import { isChatCapableTool } from "./structured/chat-capable"; import { initialPromptArgv } from "./initial-prompt"; @@ -34,6 +38,8 @@ import { import { CheckoutStore } from "./worktrees/checkout-store"; import { WorktreeError, type WorktreeManager } from "./worktrees/worktree-manager"; import { logWorktreeEvent, worktreeErrorCode } from "./worktrees/worktree-log"; +import { createKeyedLock } from "./keyed-lock"; +import { runGit } from "./worktrees/project-resolver"; export interface AgentSpec { command: string; @@ -55,6 +61,12 @@ export interface SessionLaunchSpec { baseBranch?: string; } +export type ForkWorkspace = "copy" | "current"; + +// The handoff is deliberately bounded before any durable row or worktree is +// created. A fork must preserve context, never silently trim a conversation. +export const MAX_FORK_TRANSCRIPT_BYTES = 128 * 1024; + export interface DeleteSessionOptions { force?: boolean; /** Managed sessions own their checkout, so retaining it is forbidden. */ @@ -201,6 +213,19 @@ interface PersistedEntry { checkoutKind: CheckoutKind; checkoutBranch?: string | null; checkoutState: CheckoutState; + /** A one-shot normalized conversation handoff, deleted after first launch. */ + forkTranscript?: string; + /** One-shot provider-native fork argv; never reaches the client. */ + forkNativeArgs?: string[]; + /** Whether `forkNativeArgs` has already been spawned once. Bounds the wait + * for a provider identity that a mismatched CLI never reports. */ + forkNativeAttempted?: boolean; + conversationStart?: "fresh" | "resume" | "fork"; + /** The session `fork()` copied this one from. Provenance, not a link: the + * source may be renamed, archived or deleted, and nothing resolves it back. + * It is what still answers "forked from what" once the derived name below + * has been renamed away on either side. */ + forkedFromSessionId?: string; } /** On-disk shape written by `flush()`; validated on read by PersistedFileSchema. */ @@ -233,6 +258,11 @@ const PersistedEntrySchema = z checkoutKind: z.enum(CHECKOUT_KINDS).optional().catch(undefined), checkoutBranch: z.string().nullable().optional().catch(undefined), checkoutState: z.enum(CHECKOUT_STATES).optional().catch(undefined), + forkTranscript: z.string().optional().catch(undefined), + forkNativeArgs: z.array(z.string()).optional().catch(undefined), + forkNativeAttempted: z.boolean().optional().catch(undefined), + conversationStart: z.enum(["fresh", "resume", "fork"]).optional().catch(undefined), + forkedFromSessionId: z.string().optional().catch(undefined), }) .transform((s): PersistedEntry => { const createdAt = s.createdAt ?? Date.now(); @@ -263,6 +293,11 @@ const PersistedEntrySchema = z checkoutKind: s.checkoutId ? (s.checkoutKind ?? "main") : "main", checkoutBranch: s.checkoutBranch, checkoutState: s.checkoutState ?? "ready", + forkTranscript: s.forkTranscript, + forkNativeArgs: s.forkNativeArgs, + forkNativeAttempted: s.forkNativeAttempted, + conversationStart: s.conversationStart ?? (s.agentSessionId ? "resume" : "fresh"), + forkedFromSessionId: s.forkedFromSessionId, }; }); @@ -385,6 +420,8 @@ interface SetupRuntime { export class SessionManager { private entries = new Map(); + /** Serializes attach, promotion and member removal for one checkout. */ + private readonly withCheckoutMembership = createKeyedLock(); private observers = new Set<() => void>(); private flushTimer: ReturnType | null = null; private activityEmitTimer: ReturnType | null = null; @@ -413,8 +450,11 @@ export class SessionManager { * `deleting` wire flag and `isCheckoutDeleting` — one set, one lifetime, so * the flag an app saw and the refusal it then gets can never disagree. * Populated only by `deleteManaged`; any future checkout-removal path outside - * this class must flag itself here or it goes unguarded. */ - private readonly deleting = new Map(); + * this class must flag itself here or it goes unguarded. A `null` value is a + * delete that reclaims NO checkout (detaching one member of a shared + * workspace) — the row still reports `deleting`, but `isCheckoutDeleting` + * must not refuse the siblings that keep working in that directory. */ + private readonly deleting = new Map(); // Memoized sessionResumable() answers, keyed by session id and tagged with the // agentSessionId they were computed for. toWire() runs per entry on every // changed() emit and the real check does existsSync + a bun:sqlite query, so @@ -536,6 +576,13 @@ export class SessionManager { // the delete, and this peek has no core to ask. deleting: false, tool: e.tool, command: e.command, args: e.args, mode: e.mode, + // Pessimistic, unlike `agentSessionResumable` below: hiding a control + // the live list then shows is recoverable; offering Fork on a cold peek + // that cannot resolve the checkout's agent spec is a menu item that can + // only fail. + forkSupported: false, + sharedWorkspace: false, + workspaceMemberCount: 1, // Optimistic, like `running: false` above: the peek has no live core, // and the real check needs the agent-store overrides only an instance // holds. A wrong `true` shows a control the live list then hides; a @@ -612,6 +659,75 @@ export class SessionManager { return this.toWire(entry); } + /** Create a fresh registry-agent conversation from bridge-owned context. */ + async fork(sourceSessionId: string, workspace: ForkWorkspace): Promise { + const source = this.entries.get(sourceSessionId); + if (!source) throw new Error(`session not found: ${sourceSessionId}`); + if (source.command) throw new Error("Custom-command sessions cannot be forked."); + const sourcePath = await this.checkoutPathForFork(source); + const sourceAgentSpec = source.checkoutId === "main" + ? this.agentSpec + : await this.opts.resolveAgentSpec?.(source.checkoutId) ?? this.agentSpec; + const tool = source.tool ?? sourceAgentSpec.name; + const adapter = agentSpec(tool); + if (!adapter) throw new Error("This session's agent does not support transcript forks."); + const nativeForkArgs = source.mode === "terminal" && source.agentSessionId + ? adapter.fork.nativeForkArgs?.(source.agentSessionId) + : undefined; + const transcript = nativeForkArgs?.length + ? undefined + : await this.captureForkTranscript(source, adapter, sourcePath); + if (transcript && Buffer.byteLength(transcript, "utf8") > MAX_FORK_TRANSCRIPT_BYTES) { + throw new Error("This conversation is too large to fork. Compact or summarize it first."); + } + + // Never inherit raw command/args. A fork launches the registry default. + const entry = this.buildEntry(undefined, { tool, mode: source.mode }); + // Named after its source rather than left as the next "Session N": what a + // fork is FOR is that it came from somewhere, and the drawer row is where + // the user reads that. Assigned here instead of passed to buildEntry so + // `manuallyRenamed` stays false — a derived name is not a name the user + // chose, and the agent's own conversation title must still win over it. + entry.name = this.forkName(source.name); + entry.forkedFromSessionId = source.id; + entry.conversationStart = "fork"; + entry.forkTranscript = transcript; + entry.forkNativeArgs = nativeForkArgs; + if (workspace === "current") { + return this.withCheckoutMembership(source.checkoutId, async () => { + // Re-read under the membership lock: a concurrent delete may have + // promoted or removed the source while transcript capture was pending. + const liveSource = this.entries.get(sourceSessionId); + if (!liveSource) throw new Error(`session not found: ${sourceSessionId}`); + entry.checkoutId = liveSource.checkoutId; + entry.checkoutKind = liveSource.checkoutKind; + entry.checkoutBranch = liveSource.checkoutBranch; + entry.checkoutState = liveSource.checkoutState; + this.entries.set(entry.id, entry); + try { + await this.flushNowOrThrow(); + } catch (error) { + this.entries.delete(entry.id); + throw error; + } + this.notifyObservers(); + if (entry.checkoutId !== "main") this.reannounceCheckout(entry.checkoutId); + return this.toWire(entry); + }); + } + + return this.createWorktree( + undefined, + { tool, mode: source.mode, isolation: "worktree" }, + entry, + // A thunk, not a value: an argument expression is evaluated before the + // call, so resolving HEAD eagerly here would answer "no committed HEAD" + // for a non-Git project that createWorktree's own preflight names + // correctly as NOT_GIT_REPOSITORY. + () => this.committedHead(sourcePath), + ); + } + private buildEntry(name: string | undefined, spec: SessionLaunchSpec | undefined): PersistedEntry { let finalName: string; if (name === undefined) { @@ -642,6 +758,7 @@ export class SessionManager { checkoutId: "main", checkoutKind: "main", checkoutState: "ready", + conversationStart: "fresh", }; // A new chat session inherits the last-used selection for its tool so it // opens on the model/mode/effort the user actually works with, not the @@ -653,20 +770,72 @@ export class SessionManager { return entry; } + private async checkoutPathForFork(source: PersistedEntry): Promise { + if (source.checkoutId === "main") return this.projectPath; + const checkout = await this.opts.resolveCheckout?.(source.checkoutId); + if (!checkout || !existsSync(checkout.path)) { + throw new WorktreeError("WORKTREE_MISSING", "The source worktree is no longer available."); + } + return checkout.path; + } + + /** The source checkout's HEAD, as an object id `git worktree add` can use. + * Through `runGit` like every other git call on this path: a synchronous + * spawn here blocks the one loop that is also forwarding every PTY and relay + * frame on the machine, for as long as process creation takes. Only the + * shape is checked — `resolveBase` re-verifies the value against the + * repository before it becomes a Git argument. */ + private async committedHead(path: string): Promise { + const head = await runGit(["rev-parse", "--verify", "HEAD"], path); + // Any hex object id: a repository created with `--object-format=sha256` + // answers 64 characters, and rejecting it would make its forks impossible. + if (head.exitCode === 0 && /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(head.stdout.trim())) { + return head.stdout.trim(); + } + throw new WorktreeError("WORKTREE_CONFLICT", "The source checkout has no committed HEAD to copy."); + } + + private async captureForkTranscript(source: PersistedEntry, adapter: RegistryAgentSpec, projectPath: string): Promise { + const scrollback = this.tm.getScrollback(source.id)?.text; + const text = await adapter.fork.handoff({ + maxMsgs: 500, + projectPath, + agentSessionId: source.agentSessionId, + transcriptPath: source.agentTranscriptPath, + codexHome: this.opts.codexHome, + // Stripped here, at the single point every adapter's terminal fallback + // draws from: `getScrollback` is raw PTY output, and a TUI's tail is + // mostly CSI/OSC redraw. It becomes an agent's opening prompt — and for + // the no-argv agents it is typed back into a PTY, where those bytes read + // as keys. + terminalTranscript: scrollback ? stripAnsi(scrollback) : undefined, + }); + if (!text.trim()) { + throw new Error("This session has no captured transcript to fork yet."); + } + return text; + } + /** * Isolated creation deliberately has no visible session until all host-owned * state is usable and durable. The old synchronous shared API remains intact * for compatibility with pre-worktree callers; the wire handler always awaits * either result. */ - private async createWorktree(name: string | undefined, spec: SessionLaunchSpec): Promise { + private async createWorktree( + name: string | undefined, + spec: SessionLaunchSpec, + preparedEntry?: PersistedEntry, + resolveBaseCommit?: () => Promise, + ): Promise { if (!this.opts.worktreeSessionsSupported || !this.opts.worktreeManager) { throw new WorktreeError("WORKTREE_UNSUPPORTED", "This bridge cannot isolate sessions yet."); } if (!await this.opts.isGitRepository?.()) { throw new WorktreeError("NOT_GIT_REPOSITORY", "This project is not a Git repository."); } - const entry = this.buildEntry(name, spec); + const baseCommit = await resolveBaseCommit?.(); + const entry = preparedEntry ?? this.buildEntry(name, spec); let checkout: CheckoutRecord | undefined; let runtimePrepared = false; try { @@ -676,6 +845,7 @@ export class SessionManager { sessionId: entry.id, sessionName: undefined, // new session won't have a name baseBranch: spec.baseBranch, + baseCommit, }); // Services are held back until setup finishes: auto-starting `bun run dev` // against a worktree whose `node_modules` has not been provisioned yet is @@ -794,6 +964,11 @@ export class SessionManager { entry.agentSessionId = agentSessionId; entry.agentTranscriptPath = nextPath; this.resumableCache.delete(id); + // A native fork command starts its new conversation as the PTY launches. + // Keep its one-shot argv until the provider reports that new identity; a + // successful OS spawn alone cannot distinguish an invalid source id from a + // real provider fork. + if (this.awaitingNativeForkIdentity(entry)) this.completeForkLaunch(entry); this.changed(); } @@ -860,7 +1035,26 @@ export class SessionManager { const entry = this.entries.get(id); if (!entry) return false; if (isManagedCheckoutKind(entry.checkoutKind)) { - return this.deleteManaged(entry, options); + // Refused BEFORE the lock, never queued behind it: a second delete is an + // immediate answer the caller is owed, and serializing it would leave the + // app waiting out the whole first teardown for a refusal. + // Rejected, not thrown: this arm's contract is a promise, and a sync + // throw would escape every caller that only awaits the result. + if (this.deleting.has(id)) { + return Promise.reject(new WorktreeError( + "WORKTREE_DELETE_IN_PROGRESS", "This isolated session is already being deleted.")); + } + // BOTH arms under the membership key, and the count read inside it. The + // count is what picks between detaching a member and `git worktree + // remove`, so reading it outside lets a concurrent fork attach to the + // checkout this call is about to destroy — and lets two member deletes + // both see a survivor that the first one then removes. + return this.withCheckoutMembership(entry.checkoutId, async () => { + if (!this.entries.has(entry.id)) return false; + return this.membersForCheckout(entry.checkoutId).length > 1 + ? this.deleteAttachedMemberLocked(entry) + : this.deleteManaged(entry, options); + }); } if (this.tm.has(id)) this.tm.kill(id); this.dropSession(id); @@ -888,8 +1082,8 @@ export class SessionManager { /** Flag a session's delete as in flight and announce it. Emits without * persisting — `deleting` is runtime state that must never dirty * sessions.json — which is also why this is not `changed()`. */ - private markDeleting(entry: PersistedEntry): void { - this.deleting.set(entry.id, entry.checkoutId); + private markDeleting(entry: PersistedEntry, checkoutId: string | null = entry.checkoutId): void { + this.deleting.set(entry.id, checkoutId); this.notifyObservers(); } @@ -1596,11 +1790,12 @@ export class SessionManager { this.opts.onStartChat?.({ sessionId: id, tool: chatTool, - resumeId: this.resumeIdFor(chatTool, entry), + resumeId: entry.conversationStart === "fork" ? undefined : this.resumeIdFor(chatTool, entry), config: entry.config, - initialPrompt, + initialPrompt: this.forkInitialPrompt(entry, initialPrompt), }); entry.lastUsedAt = Date.now(); + this.completeForkLaunch(entry); this.changed(); if (chatAlreadyRunning) this.reannounceCheckout(entry.checkoutId); return; @@ -1631,6 +1826,13 @@ export class SessionManager { // Resume tokens (`--resume `, `--session `, or codex's `resume // ` subcommand). Captured here, appended LAST in the spawn block. let resumeArgs: string[] = []; + // Provider-native fork args are one-shot like the transcript handoff. They + // create a fresh provider session and therefore replace, never combine + // with, resume args. + const nativeForkArgs = entry.forkNativeArgs ?? []; + if (nativeForkArgs.length > 0 && initialPrompt?.trim()) { + throw new Error("This provider-native fork starts immediately; send a prompt after the fork opens."); + } // Set only when an augmenter branch actually reports it (currently // cursor-agent's hooks.json write); undefined means "trust the registry's // static notificationSource" for every other tool. See LaunchAugmentation. @@ -1652,7 +1854,7 @@ export class SessionManager { // Resume the slot's last-active conversation (held in resumeArgs, appended // LAST in the spawn block so it lands after any per-session args — required // for codex's `resume` subcommand). - resumeArgs = this.resumeArgsFor(entry.tool, entry); + resumeArgs = entry.conversationStart === "fork" ? [] : this.resumeArgsFor(entry.tool, entry); } else if (entry.command) { // Custom command lines never resume and get no per-spawn hooks: the user // owns the whole line, so we don't inject title/resume integration and no @@ -1669,7 +1871,7 @@ export class SessionManager { baseArgs = [...baseArgs, ...aug.args]; launchEnv = { ...launchEnv, ...aug.env }; notificationsInjected = aug.notificationsInjected; - resumeArgs = this.resumeArgsFor(sessionAgentSpec.name, entry); + resumeArgs = entry.conversationStart === "fork" ? [] : this.resumeArgsFor(sessionAgentSpec.name, entry); } } if (!base) { @@ -1681,9 +1883,11 @@ export class SessionManager { // One-shot first prompt, appended LAST (after resume tokens — codex's // `resume ` is a subcommand and the positional prompt must follow it). + // Built once: the PTY fallback below hands the same string to the terminal. + const launchPrompt = this.forkInitialPrompt(entry, initialPrompt) ?? ""; const promptArgs = isCustomLine ? [] - : initialPromptArgv(entry.tool ?? sessionAgentSpec.name, initialPrompt ?? ""); + : initialPromptArgv(entry.tool ?? sessionAgentSpec.name, launchPrompt); // No per-session args: spawn `base` with its default argv (the antgrid.yaml // flags on the fallback path, empty otherwise) — preserves no-shell direct @@ -1711,8 +1915,12 @@ export class SessionManager { // boundary cannot swallow them. const resumeAfterRaw = agentSpec(entry.tool ?? sessionAgentSpec.name)?.resumeIsSubcommand === true; - const beforeRaw = resumeAfterRaw ? [] : resumeArgs.map(shellQuoteArg); - const afterRaw = resumeAfterRaw ? resumeArgs.map(shellQuoteArg) : []; + // Native fork args obey the same rule for the same reason: codex's `fork` + // is a subcommand exactly as its `resume` is, so placing it before the + // user's raw args hands the user's global flags to the subcommand. + const conversationArgs = [...resumeArgs, ...nativeForkArgs].map(shellQuoteArg); + const beforeRaw = resumeAfterRaw ? [] : conversationArgs; + const afterRaw = resumeAfterRaw ? conversationArgs : []; const foldedPrompt = foldPromptIntoLine ? promptArgs.map(shellQuoteArg) : []; command = [...head, ...beforeRaw, argsStr, ...afterRaw, ...foldedPrompt].join(" "); } @@ -1723,7 +1931,7 @@ export class SessionManager { ? foldPromptIntoLine ? [] : [...promptArgs] - : [...baseArgs, ...resumeArgs, ...promptArgs]; + : [...baseArgs, ...resumeArgs, ...nativeForkArgs, ...promptArgs]; // Geometry is left to TerminalManager, which spawns at whatever size the // pane's driver last reported (80x24 only for the first terminal of a @@ -1756,10 +1964,126 @@ export class SessionManager { ? entry.tool ?? sessionAgentSpec.name : undefined, }); + // Some registry agents have no verified launch-argv form for an opening + // prompt. Their PTY still buffers input during startup, which gives every + // registered terminal agent the same transcript-fork capability without + // inventing unsupported CLI flags. + if (entry.conversationStart === "fork" && entry.forkTranscript && promptArgs.length === 0) { + this.tm.write(id, `${launchPrompt}\r`); + } entry.lastUsedAt = Date.now(); + // Transcript forks have been handed to the spawned process. Native forks + // wait for setAgentSession() to receive their newly minted provider id — + // but for ONE launch only. A CLI predating the fork flag, or a source + // conversation the provider has since pruned, never reports an id, and + // holding the argv indefinitely re-forks the source on every start while + // the forced-empty resumeArgs stop the session ever holding a conversation + // of its own. The second launch drops it and starts fresh instead. + if (!this.awaitingNativeForkIdentity(entry)) this.completeForkLaunch(entry); + else if (entry.forkNativeAttempted) this.completeForkLaunch(entry); + else entry.forkNativeAttempted = true; this.changed(); } + /** Sessions sharing one MANAGED checkout. `main` is not a shared workspace — + * every ordinary session carries `checkoutId: "main"`, so counting it would + * report every project with two plain sessions as shared, exactly the trap + * `isCheckoutDeleting` asserts against. */ + private membersForCheckout(checkoutId: string): PersistedEntry[] { + if (checkoutId === "main") return []; + return Array.from(this.entries.values()).filter((entry) => entry.checkoutId === checkoutId); + } + + /** `membersForCheckout(...).length` without the array. `toWire` runs once per + * entry on a list that is rebuilt on every observer emit, so materializing a + * copy of the session map per row makes that emit O(n²). */ + private countMembersForCheckout(checkoutId: string): number { + if (checkoutId === "main") return 0; + let count = 0; + for (const entry of this.entries.values()) { + if (entry.checkoutId === checkoutId) count++; + } + return count; + } + + /** Delete one attached member without reclaiming the checkout it shares. + * Caller holds `withCheckoutMembership` for this checkout. */ + private async deleteAttachedMemberLocked(entry: PersistedEntry): Promise { + if (this.deleting.has(entry.id)) { + throw new WorktreeError("WORKTREE_DELETE_IN_PROGRESS", "This session is already being deleted."); + } + // `null`, not the checkout id: this delete reclaims NOTHING, and flagging + // the checkout would make agent-core refuse every checkout-variable verb — + // keystrokes, file reads, git — for the SIBLINGS that keep working in it, + // under a message saying their workspace is being deleted. + this.markDeleting(entry, null); + try { + // Checked, as deleteManaged does: dropSession() tombstones the terminal, + // so forgetting a member whose agent never died leaves that process alive + // inside the shared worktree with no row and nothing able to kill it. + if (!await this.stopAndAwait(entry.id)) { + throw new WorktreeError("WORKTREE_DELETE_FAILED", "The session did not stop before it could be detached."); + } + const survivors = this.membersForCheckout(entry.checkoutId) + .filter((candidate) => candidate.id !== entry.id) + // A visible member first: handing the workspace to an archived row + // strands the worktree and its branch behind a session the default + // list never shows. + .sort((a, b) => + Number(a.archived) - Number(b.archived) + || b.lastUsedAt - a.lastUsedAt || b.createdAt - a.createdAt || a.id.localeCompare(b.id)); + const successor = survivors[0]; + if (!successor) throw new Error("No checkout member remains to own this workspace."); + await this.checkoutStore().update(entry.checkoutId, (record) => + record.sessionId === entry.id ? { ...record, sessionId: successor.id } : record, + ); + this.dropSession(entry.id); + this.clearDeleting(entry.id); + // Emit owed even when the flush fails: the flag is already cleared, so the + // catch below can no longer emit and the app's last view of this row would + // stay `deleting: true` forever. + try { + await this.flushNowOrThrow(); + } finally { + this.notifyObservers(); + } + return true; + } catch (error) { + if (this.clearDeleting(entry.id)) this.notifyObservers(); + throw error; + } + } + + /** Returns [initialPrompt] UNCHANGED for a non-fork start — `undefined` must + * stay `undefined`, not collapse to `""`: it is what every existing caller + * passes to `onStartChat` for "no opening prompt". */ + private forkInitialPrompt(entry: PersistedEntry, initialPrompt?: string): string | undefined { + if (!entry.forkTranscript) return initialPrompt; + const extra = initialPrompt?.trim(); + return [ + "Start a fresh conversation using this captured transcript as context. Continue the work; do not resume its native session.", + "", + entry.forkTranscript, + "", + extra, + ].filter((part): part is string => !!part).join("\n\n"); + } + + private completeForkLaunch(entry: PersistedEntry): void { + if (entry.conversationStart !== "fork") return; + // The handoff survives a bridge restart between create and launch, but no + // longer than the first successful spawn. A later stop/start is a normal + // fresh conversation and must not replay the old transcript. + entry.forkTranscript = undefined; + entry.forkNativeArgs = undefined; + entry.forkNativeAttempted = undefined; + entry.conversationStart = "fresh"; + } + + private awaitingNativeForkIdentity(entry: PersistedEntry): boolean { + return entry.conversationStart === "fork" && (entry.forkNativeArgs?.length ?? 0) > 0; + } + /** Initiates teardown; it is NOT complete when this returns. The chat branch * hands back the driver's teardown promise so a caller that restarts this * slot on another runtime can wait it out (see stopAndAwait); every existing @@ -2003,6 +2327,7 @@ export class SessionManager { // transcript hydration on it (see the app's hydrateAttachedChatIfNeeded), so // withholding it leaves a session started on another device rendering empty. private toWire(e: PersistedEntry): SessionEntry { + const memberCount = this.countMembersForCheckout(e.checkoutId); return { id: e.id, name: e.name, @@ -2013,6 +2338,8 @@ export class SessionManager { deleting: this.deleting.has(e.id), tool: e.tool, command: e.command, + forkSupported: !e.command && !!agentSpec(e.tool ?? this.agentSpec.name), + forkedFromSessionId: e.forkedFromSessionId, args: e.args, mode: e.mode, agentSessionResumable: this.agentSessionResumable(e), @@ -2028,6 +2355,10 @@ export class SessionManager { checkoutKind: e.checkoutKind, checkoutBranch: e.checkoutBranch, checkoutState: e.checkoutState, + sharedWorkspace: memberCount > 1, + // Floored at 1: `main` counts no members, and the wire schema requires a + // positive integer. + workspaceMemberCount: Math.max(memberCount, 1), setup: this.setupWire(e.id), }; } @@ -2100,6 +2431,18 @@ export class SessionManager { return resumable; } + /** `" fork"`, numbered on collision the way [nextDefaultName] numbers + * slots. The suffix is stripped from the source first, so a fork of a fork + * reads "Auth fork 2" rather than "Auth fork fork". */ + private forkName(sourceName: string): string { + const base = sourceName.replace(/ fork(?: \d+)?$/, "").trim() || sourceName; + const taken = new Set(Array.from(this.entries.values(), (e) => e.name)); + let candidate = `${base} fork`; + // Terminates: every miss consumes one of the finitely many taken names. + for (let n = 2; taken.has(candidate); n++) candidate = `${base} fork ${n}`; + return candidate; + } + private nextDefaultName(): string { let max = 0; for (const e of this.entries.values()) { diff --git a/bridge/src/session-namer.ts b/bridge/src/session-namer.ts index f68c4dc8..50d436aa 100644 --- a/bridge/src/session-namer.ts +++ b/bridge/src/session-namer.ts @@ -19,13 +19,29 @@ function sanitizeTitle(raw: string): string { .slice(0, MAX_TITLE_LEN); } -/** Precedence within the structured signal: the resolvers' own `kind`, aliased - * rather than restated so the two cannot drift. `agents/types.ts` declares only - * types, so this import is erased and drags no agent code in here. */ -export type TitleRank = ResolvedTitle["kind"]; +/** + * Precedence within the structured signal, strongest first. The two values a + * resolver can report are aliased off `ResolvedTitle["kind"]` rather than + * restated, so those cannot drift; "self" is the one rank no resolver can + * produce, because it is the title WE generated (agents/title-generate.ts). + * + * `agents/types.ts` declares only types, so the import is erased and drags no + * agent code in here. + */ +export type TitleRank = ResolvedTitle["kind"] | "self"; + +/** Higher wins. A title never loses to a weaker one, which is what stops the + * per-turn first-message re-read — the same opening prompt, restated on every + * turn of the session — from undoing a real name. */ +const RANK_ORDER: Record = { + "first-message": 0, + self: 1, + manual: 2, +}; interface Signals { - /** Title and rank travel together: a rank stored without the title it + /** Sanitized at ingest, so `title` is exactly the name that will be applied. + * Title and rank travel together: a rank stored without the title it * describes would latch the precedence guard against a name nobody applied. */ structured?: { title: string; rank: TitleRank }; osc?: string; @@ -38,8 +54,8 @@ interface Signals { * manual-wins check lives in the sink (SessionManager.applyAutoName), so this * unit stays pure policy. * - * Within the structured signal, latest wins EXCEPT that a `first-message` title - * may not displace a `generated` one — see onStructuredTitle. + * Within the structured signal, latest wins EXCEPT that a title may not + * displace a STRONGER-ranked one — see onStructuredTitle and RANK_ORDER. */ export class SessionNamer { private readonly signals = new Map(); @@ -52,24 +68,44 @@ export class SessionNamer { ) {} /** - * `rank` is required, not defaulted: `generated` is the dominant value, so a - * caller that omitted it would silently latch this slot against every later - * real title — the exact bug the guard exists to prevent, reintroduced with - * no compile error. Stating it at each call site makes that a build decision. + * `rank` is required, not defaulted: any default is the dominant value for + * some caller, and getting it wrong silently latches this slot against every + * later real title — the exact bug the ordering exists to prevent, + * reintroduced with no compile error. Stating it at each call site makes that + * a build decision. * * Only the per-turn native transcript read yields `first-message`, and it - * re-yields the SAME opening prompt every turn — so without the guard the - * turn after a real title landed would revert the session to that prompt, and - * title generation is attempted once per agent session, so nothing restores it. + * re-yields the SAME opening prompt on every turn of the session — so without + * the ordering, the turn after a generated title landed would revert the + * session to that prompt, and naming is attempted once per agent session, so + * nothing would restore it. */ onStructuredTitle(id: string, title: string, rank: TitleRank): void { - // Rank only a title that can actually become a name. One that sanitizes - // away applies nothing, and arming the guard on it would block every later - // first-message title on behalf of a name the user never saw. - if (!sanitizeTitle(title)) return; - const current = this.signals.get(id); - if (rank === "first-message" && current?.structured?.rank === "generated") return; - this.update(id, (s) => { s.structured = { title, rank }; }, current); + const held = this.signals.get(id)?.structured; + if (held && RANK_ORDER[rank] < RANK_ORDER[held.rank]) return; + // Normalize at ingest, so the stored title IS the name that will be applied + // and `flush` never has to re-derive it. One that sanitizes away applies + // nothing, and latching the slot on it would block every later title on + // behalf of a name the user never saw. + const name = sanitizeTitle(title); + if (!name) return; + this.update(id, (s) => { s.structured = { title: name, rank }; }); + } + + /** + * True when this slot already holds a name no model call should try to improve + * on: one we generated, or one the user chose. + * + * Read by the naming gate, which is keyed by CONVERSATION while this is keyed + * by slot — and a mode flip carries the name across the runtime swap while + * changing the key underneath it (a terminal keys on the agent's session id, a + * chat slot on itself). Without this the flipped session spends a second spawn + * and renames itself mid-conversation, defeating the very exemption that kept + * the name. + */ + hasFinalTitle(id: string): boolean { + const held = this.signals.get(id)?.structured; + return !!held && RANK_ORDER[held.rank] >= RANK_ORDER.self; } onOscTitle(id: string, title: string): void { @@ -81,7 +117,7 @@ export class SessionNamer { for (const id of this.dirty) { const s = this.signals.get(id); if (!s) continue; - const name = sanitizeTitle(s.structured?.title ?? s.osc ?? ""); + const name = s.structured?.title ?? sanitizeTitle(s.osc ?? ""); if (name) this.sink.applyAutoName(id, name); } this.dirty.clear(); @@ -103,18 +139,20 @@ export class SessionNamer { * Drop the structured title + rank for a slot whose AGENT CONVERSATION changed * under a still-live PTY (Claude `/clear`, codex `/new`). The rank describes a * conversation, not a slot, and that PTY never exits — so `forget` never runs - * and the previous topic's `generated` rank would veto the new conversation's - * first-message title for the life of the process. + * and the previous topic's rank would veto the new conversation's own title + * for the life of the process. * * OSC filler is deliberately left: it tracks the terminal, not the - * conversation. Nothing is marked dirty either — dropping a title is not a - * reason to rename the session; the new conversation's own first turn is. + * conversation. The slot is dropped from `dirty` rather than merely not added + * to it — dropping a title is not a reason to rename the session, and a flush + * already armed by the title being removed would otherwise fall through to + * that OSC filler and rename the session to terminal chrome. */ forgetStructuredTitle(id: string): void { const s = this.signals.get(id); if (!s?.structured) return; delete s.structured; - this.signals.set(id, s); + this.dirty.delete(id); } /** @@ -129,8 +167,8 @@ export class SessionNamer { this.signals.clear(); } - private update(id: string, mut: (s: Signals) => void, known?: Signals): void { - const s = known ?? this.signals.get(id) ?? {}; + private update(id: string, mut: (s: Signals) => void): void { + const s = this.signals.get(id) ?? {}; mut(s); this.signals.set(id, s); this.dirty.add(id); diff --git a/bridge/src/structured/chat-session.ts b/bridge/src/structured/chat-session.ts index a115dbc6..111aa1de 100644 --- a/bridge/src/structured/chat-session.ts +++ b/bridge/src/structured/chat-session.ts @@ -289,6 +289,26 @@ export abstract class ChatSession implements StructuredDriver { } } + /** This session's slash commands, or undefined when no catalog is available. + * Empty is NEVER surfaced as empty: an empty list means any of "discovery + * threw", "discovery hasn't landed yet" and "this agent has no commands", and + * a supervisor told it holds the complete set on that basis would refuse + * every command the agent really has. Deliberately NOT gated on + * `capsDiscovered` — that flag opens on the MODEL list, and claude ingests + * commands outside it, so gating here would drop a catalog that exists. + * + * `builtin:*` entries are excluded because invoking one sends the agent no + * turn at all: `prompt()` above short-circuits `builtin:compact` into + * `compact()`, and codex's `sendPrompt` intercepts `builtin:review` the same + * way — a supervisor that injected one would then wait for a turn end nothing + * raises. Every backend appends `builtin:compact` unconditionally at the end + * of ingest, so without this filter a session with no real commands would + * claim a complete catalog of one. */ + commandCatalog(): CapCommand[] | undefined { + const promptable = this.capCommands.filter((c) => !c.id.startsWith("builtin:")); + return promptable.length ? promptable : undefined; + } + resolvePermission(permissionId: string, optionId: string): void { const pending = this.pendingApprovals.get(permissionId); if (!pending) return; diff --git a/bridge/src/structured/structured-manager.ts b/bridge/src/structured/structured-manager.ts index 8cda5cc0..dc96d754 100644 --- a/bridge/src/structured/structured-manager.ts +++ b/bridge/src/structured/structured-manager.ts @@ -1,5 +1,6 @@ import { createMessage, type AbMessage } from "../protocol"; import { isChatCapableTool } from "./chat-capable"; +import type { CapCommand } from "./chat-session"; // Structural type the manager needs from a driver (CodexDriver satisfies it). export interface StructuredDriver { @@ -35,6 +36,10 @@ export interface StructuredDriver { // implements nothing — the app only offers a stop for a task the session // itself advertised — so the no-op is an invariant, not a silent default. stopTask?(taskId: string): Promise; + // This session's slash commands, or undefined when there is no catalog to + // offer. Optional for the same reason as stopTask: presence IS the + // capability, so there is no second list to keep in lockstep. + commandCatalog?(): CapCommand[] | undefined; // May be async: a driver whose backend holds a process-global lock (codex's // ~/.codex sqlite) resolves only once that process has fully exited, so a // restart doesn't race the dying one for the lock. @@ -63,6 +68,11 @@ export interface StructuredAgentManagerOpts { // Overwrite-latest per key. Separate from the driver's own setConfig (which // applies it live) — this is only the durable write. onSetConfig?: (sessionId: string, key: string, value: string) => void; + // Called with the text of every user prompt this manager delivers. A chat + // session has no hook to carry its first message — the bridge itself is what + // hands a prompt to the driver — so this is the only place one can be named + // from what the user actually asked for. + onUserPrompt?: (sessionId: string, text: string) => void; } export class StructuredAgentManager { @@ -86,6 +96,7 @@ export class StructuredAgentManager { private readonly onAgentSession: (sessionId: string, agentSessionId: string) => void; private readonly dropSessionReplay?: (sessionId: string) => void; private readonly onSetConfig?: (sessionId: string, key: string, value: string) => void; + private readonly onUserPrompt?: (sessionId: string, text: string) => void; constructor(opts: StructuredAgentManagerOpts) { this.factory = opts.driverFactory; @@ -93,6 +104,7 @@ export class StructuredAgentManager { this.onAgentSession = opts.onAgentSession; this.dropSessionReplay = opts.dropSessionReplay; this.onSetConfig = opts.onSetConfig; + this.onUserPrompt = opts.onUserPrompt; } async startChat(opts: { sessionId: string; tool: string; resumeId?: string; config?: Record; initialPrompt?: string }): Promise { @@ -103,13 +115,13 @@ export class StructuredAgentManager { if (this.drivers.has(sessionId)) { // already running — idempotent, but a racing duplicate start still // carries the caller's own initialPrompt and must deliver it once. - await this.deliverInitialPrompt(sessionId, initialPrompt); + await this.deliverInitialPrompt(sessionId, initialPrompt, resumeId); return; } const inflight = this.starting.get(sessionId); if (inflight) { await inflight; - await this.deliverInitialPrompt(sessionId, initialPrompt); + await this.deliverInitialPrompt(sessionId, initialPrompt, resumeId); return; } @@ -177,7 +189,7 @@ export class StructuredAgentManager { // prompt() so the transcript records it exactly like an app-sent // agent:prompt. After start, so the config replay above (model/effort) // applies to this first turn. - await this.deliverInitialPrompt(sessionId, initialPrompt); + await this.deliverInitialPrompt(sessionId, initialPrompt, resumeId); } // A prompt failure is surfaced as agent:error but must NOT tear down the @@ -185,7 +197,9 @@ export class StructuredAgentManager { // lost, and the app can resend. Called from all three startChat exits so a // racing duplicate start still delivers exactly once per call that carried // a prompt. - private async deliverInitialPrompt(sessionId: string, initialPrompt: string | undefined): Promise { + private async deliverInitialPrompt( + sessionId: string, initialPrompt: string | undefined, resumeId?: string, + ): Promise { const initial = initialPrompt?.trim(); if (!initial) return; // At-most-once per session lifetime: a replayed session:start must not @@ -196,6 +210,14 @@ export class StructuredAgentManager { const driver = this.drivers.get(sessionId); if (!driver) return; this.initialPromptDelivered.add(sessionId); + // Before the delivery, not after: this is the message the session gets named + // from, and a prompt() that rejects still tells us what the user asked for. + // + // A RESUME is exempt. Its first message continues a conversation that + // already has a name ("yes, carry on with step 3" names nothing), and the + // stop that preceded it released the slot's title and its attempt — so + // without this the resumed session renames itself from the continuation. + if (!resumeId) this.onUserPrompt?.(sessionId, initial); try { await driver.prompt(initial); } catch (err) { @@ -261,13 +283,26 @@ export class StructuredAgentManager { }); } - /** Dispatch one inbound agent:* control message. */ - async handleAgentMessage(msg: AbMessage): Promise { + /** + * Dispatch one inbound agent:* control message. + * + * `injected` marks a prompt the Handler wrote on the user's behalf rather than + * one the user sent. It is an option, not a wire field: the frame never leaves + * this process (the supervisor's adapter builds it in memory), so putting it on + * the protocol would let a remote client claim it. + */ + async handleAgentMessage(msg: AbMessage, opts: { injected?: boolean } = {}): Promise { try { switch (msg.type) { case "agent:prompt": { const driver = this.drivers.get(msg.sessionId); if (!driver) throw new Error("chat session not started"); + // A slash command's `text` is only its arguments, which name nothing — + // and neither does a supervisor nudge, which is this component talking + // to itself and would otherwise name the session "continue". + if (!msg.commandId && !opts.injected && msg.text.trim()) { + this.onUserPrompt?.(msg.sessionId, msg.text); + } await driver.prompt(msg.text, msg.commandId); break; } @@ -350,6 +385,14 @@ export class StructuredAgentManager { return driver.getTranscriptSnapshot(); } + /** `sessionId`'s slash commands, or undefined when none are available — the + * session isn't running, its driver reports no catalog, or discovery has + * produced nothing yet. All three are the same answer to the one caller that + * asks (the Handler): do not claim to know this session's commands. */ + commandCatalog(sessionId: string): CapCommand[] | undefined { + return this.drivers.get(sessionId)?.commandCatalog?.(); + } + disposeAll(): Promise { // Join in-flight starts, not just `drivers`: a session mid-spawn isn't in // `drivers` yet, so a drivers-only sweep would leave its process running (a diff --git a/bridge/src/work-status.ts b/bridge/src/work-status.ts index e04ff200..882ad349 100644 --- a/bridge/src/work-status.ts +++ b/bridge/src/work-status.ts @@ -118,6 +118,26 @@ function isCallToAction(n: NotificationType): boolean { return n === "permission_request" || n === "awaiting_input" || n === "error"; } +/** Has [sessionId]'s OWN turn already ended? The only window in which an + * agent's "waiting for your input" signal can be the generic post-completion + * idle nudge rather than a live mid-turn block — the hook fires the identical + * text for both and cannot tell them apart, so the host answers it from turn + * state. A pure read; it changes nothing. + * + * The window is exactly "between turns": every path that begins a turn + * ({@link turnStart}, an inferred open in {@link userReply}, + * {@link answerRequest}) drops the entry. + * + * The CALLER chooses the key, and an id with no entry of its own answers + * false. A caller that cannot prove its id was folded under its own key must + * pass the raw id and take that false rather than reading the + * {@link UNATTRIBUTED_TURN} fallback: one session's task_complete swallowing + * another's genuine first block is a permanent drop (nothing is recorded, so + * nothing can raise it again). */ +export function isStaleIdleNudge(state: WorkStatusState, sessionId: string): boolean { + return state.notifications.get(sessionId) === "task_complete"; +} + /** Rollup order for the project row. `unread` outranks `done` and nothing else: * it is a "come and look" nudge, never a claim that the agent is still busy. */ const RANK: Record = { @@ -657,7 +677,7 @@ function foldNotification( // unattributed hooks would otherwise have one session's task_complete swallow // a different session's genuine first block, and the drop is permanent // (nothing is recorded, so the dot never lights up). - const stale = msg.notificationType === "awaiting_input" && own === "task_complete"; + const stale = msg.notificationType === "awaiting_input" && isStaleIdleNudge(prev, key); if (msg.notificationType === own || stale) { if (activeTurns === prev.activeTurns && pendingTurns === prev.pendingTurns diff --git a/bridge/src/worktrees/worktree-manager.ts b/bridge/src/worktrees/worktree-manager.ts index 787d25e6..d4ce24b6 100644 --- a/bridge/src/worktrees/worktree-manager.ts +++ b/bridge/src/worktrees/worktree-manager.ts @@ -117,6 +117,8 @@ export interface PrepareWorktreeArgs { sessionId: string; sessionName?: string; baseBranch?: string; + /** A bridge-resolved immutable commit. Never accepted from a client. */ + baseCommit?: string; } export interface RemoveWorktreeArgs { @@ -193,7 +195,7 @@ export class WorktreeManager { // fails with NOT_GIT_REPOSITORY rather than on a reconcile symptom. await this.reconcileLocked(args.projectId, repoPath); - const base = await this.resolveBase(repoPath, args.baseBranch); + const base = await this.resolveBase(repoPath, args.baseBranch, args.baseCommit); const checkoutId = this.newCheckoutId(); const wtRoot = resolve(this.abDir, WORKTREE_ROOT_DIR); const root = resolve(wtRoot, projectRootName(repoPath, args.projectId)); @@ -703,7 +705,22 @@ export class WorktreeManager { return status.stdout.trim().length > 0; } - private async resolveBase(repoPath: string, baseBranch?: string): Promise<{ ref: string | null; commit: string }> { + private async resolveBase(repoPath: string, baseBranch?: string, baseCommit?: string): Promise<{ ref: string | null; commit: string }> { + if (baseCommit) { + // `baseCommit` is produced by SessionManager from `git rev-parse HEAD` + // on an existing checkout. Verify it anyway: this is the last boundary + // before it becomes a Git argument that creates a worktree. + // 40 hex (sha1) or 64 (a `--object-format=sha256` repository). Keep in + // lockstep with SessionManager.committedHead, the only producer. + if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(baseCommit)) { + throw new WorktreeError("WORKTREE_CONFLICT", "The source checkout has no valid committed HEAD."); + } + const verified = await this.git(["rev-parse", "--verify", `${baseCommit}^{commit}`], repoPath); + if (verified.exitCode !== 0) { + throw new WorktreeError("WORKTREE_CONFLICT", "The source checkout's committed HEAD is unavailable."); + } + return { ref: null, commit: verified.stdout.trim() }; + } if (baseBranch) { const branches = await this.git(["for-each-ref", "--format=%(refname:short)", "refs/heads"], repoPath); if (branches.exitCode !== 0 || !branches.stdout.split(/\r?\n/).map((name) => name.trim()).includes(baseBranch)) { diff --git a/bridge/tests/agent-spec-characterization.test.ts b/bridge/tests/agent-spec-characterization.test.ts index afab542f..c9c726e4 100644 --- a/bridge/tests/agent-spec-characterization.test.ts +++ b/bridge/tests/agent-spec-characterization.test.ts @@ -649,28 +649,64 @@ describe("hook posts", () => { ]); }); - test("stop coerces a null transcript on the title and handler posts, omits it on notify", async () => { - // The asymmetry is deliberate and load-bearing: /notify spreads the key - // away, /session-title and /handler-event send "". + test("stop omits a null transcript everywhere but the handler post", async () => { + // /session-title omits it because the path it carries is PERSISTED: + // setAgentSession falls back to the stored path only for a nullish + // report, so "" would overwrite the real one for the rest of the session. + // /handler-event is per-event state that nothing keeps, so its "" stands. const posts = await hookPosts({ agent: name, event: "stop", stdin: JSON.stringify({ session_id: "s4", transcript_path: null }), }); expect(posts.map((p) => p.body)).toEqual([ - { terminalId: TERM, sessionId: "s4", agent: "claude", transcriptPath: "" }, + { terminalId: TERM, sessionId: "s4", agent: "claude" }, { type: "task_complete", agent: "claude", terminalId: TERM }, { terminalId: TERM, agent: "claude", event: "turn_end", transcriptPath: "", sessionId: "s4" }, ]); }); - test("user-prompt posts only a turn-start", async () => { + // The prompt rides along, so the session can be named at the first message + // instead of waiting for a turn to end. + test("user-prompt posts a turn-start and a title request carrying the prompt", async () => { const posts = await hookPosts({ agent: name, event: "user-prompt", stdin: JSON.stringify({ session_id: "s1", prompt: "hi" }), }); - expect(posts).toEqual([{ port: PORT, path: "/turn-start", body: { terminalId: TERM } }]); + expect(posts).toEqual([ + { port: PORT, path: "/turn-start", body: { terminalId: TERM } }, + { + port: PORT, + path: "/session-title", + body: { + terminalId: TERM, + sessionId: "s1", + agent: "claude", + prompt: "hi", + }, + }, + ]); + }); + + // A slash command is the user invoking a command, not describing a task. + // The post still goes out — it carries the resume id — but withholding the + // prompt sends naming down the on-disk read instead of spending the + // session's one attempt on a title that describes the command. + test("user-prompt withholds a slash command from the title request", async () => { + const posts = await hookPosts({ + agent: name, + event: "user-prompt", + stdin: JSON.stringify({ session_id: "s1", prompt: "/commit --amend" }), + }); + expect(posts).toEqual([ + { port: PORT, path: "/turn-start", body: { terminalId: TERM } }, + { + port: PORT, + path: "/session-title", + body: { terminalId: TERM, sessionId: "s1", agent: "claude" }, + }, + ]); }); test("a non-waiting notification is a permission_request", async () => { @@ -689,6 +725,10 @@ describe("hook posts", () => { event: "awaiting_input", transcriptPath: "/t", sessionId: "s3", + // The half that keeps the host's stale-nudge drop from silencing a + // genuine block: it must read the SAME classification the /notify + // below carries, which lands after it. + idleNudge: false, }, }, { @@ -706,7 +746,10 @@ describe("hook posts", () => { stdin: JSON.stringify({ message: "Claude is WAITING for your input" }), }); expect(posts.map((p) => p.body)).toEqual([ - { terminalId: TERM, agent: "claude", event: "awaiting_input", transcriptPath: "", sessionId: "" }, + { + terminalId: TERM, agent: "claude", event: "awaiting_input", + transcriptPath: "", sessionId: "", idleNudge: true, + }, { type: "awaiting_input", terminalId: TERM, message: "Claude is WAITING for your input" }, ]); }); diff --git a/bridge/tests/agent-tools.test.ts b/bridge/tests/agent-tools.test.ts index 0c2fd07e..fc7afe9c 100644 --- a/bridge/tests/agent-tools.test.ts +++ b/bridge/tests/agent-tools.test.ts @@ -52,7 +52,20 @@ test("buildAgentCatalog describes the whole registry in declaration order", () = handler: { terminal: true, chat: true }, }); // The agent the PATH probe can find but the Handler can never observe — the - // distinction the descriptor exists to carry. + // distinction the descriptor exists to carry. Judge-capable and unobservable + // are independent: it declares a headless argv that reaches the repo, and + // still reports no turn boundaries for anything to watch. + expect(byKey["kilo"]).toEqual({ + tool: "kilo", + label: "Kilo", + chatCapable: false, + judgeCapable: true, + handler: { terminal: false, chat: false }, + }); + // Detectable and nameable, but not a judge: cursor-agent declares no headless + // argv VERIFIED at any reach. The one it used to carry was written from its + // help output and never run, which is the whole distance between naming (a + // borrowed "none" call) and arming a supervisor over the working tree. expect(byKey["cursor-agent"]).toEqual({ tool: "cursor-agent", label: "Cursor", @@ -60,14 +73,15 @@ test("buildAgentCatalog describes the whole registry in declaration order", () = judgeCapable: false, handler: { terminal: false, chat: false }, }); - expect(byKey["opencode"].handler).toEqual({ terminal: true, chat: true }); - expect(byKey["kilo"]).toEqual({ - tool: "kilo", - label: "Kilo", + // The same verdict reached the other way: no headless block at all. + expect(byKey["kimi"]).toEqual({ + tool: "kimi", + label: "Kimi", chatCapable: false, judgeCapable: false, handler: { terminal: false, chat: false }, }); + expect(byKey["opencode"].handler).toEqual({ terminal: true, chat: true }); }); test("agent:tools carries the descriptor array through the schema", () => { diff --git a/bridge/tests/antigravity-title-watcher.test.ts b/bridge/tests/antigravity-title-watcher.test.ts index b3865c64..52fe8d16 100644 --- a/bridge/tests/antigravity-title-watcher.test.ts +++ b/bridge/tests/antigravity-title-watcher.test.ts @@ -43,24 +43,19 @@ test("emits (conversationId, name) once when a /rename is appended after start", expect(calls).toEqual([["c1", "my chat"]]); }); -test("emits agy's generated preview when it lands in conversation_summaries.db", async () => { +// agy writes its own conversation preview into that DB, and we deliberately do +// not read it: Antgrid names the session itself, so the only title this watcher +// carries is one the user typed (see ResolvedTitle in agents/types.ts). +test("ignores agy's generated preview, and still emits a later /rename", async () => { const home = tmp(); const calls: Array<[string, string]> = []; makeWatcher(home, calls).start(); writeSummariesDb(home, [{ id: "c1", preview: "Casual Greeting And Introduction" }]); await Bun.sleep(300); - expect(calls).toEqual([["c1", "Casual Greeting And Introduction"]]); -}); - -test("a live /rename outranks a generated preview for the same conversation", async () => { - const home = tmp(); - const calls: Array<[string, string]> = []; - makeWatcher(home, calls).start(); - writeSummariesDb(home, [{ id: "c1", preview: "Generated Name" }]); - await Bun.sleep(300); + expect(calls).toEqual([]); appendFileSync(join(home, "history.jsonl"), line("c1", "/rename mine")); await Bun.sleep(300); - expect(calls).toEqual([["c1", "Generated Name"], ["c1", "mine"]]); + expect(calls).toEqual([["c1", "mine"]]); }); test("does not re-emit a title that has not changed", async () => { @@ -77,7 +72,6 @@ test("does not re-emit a title that has not changed", async () => { test("seeds pre-existing titles at start without emitting them", async () => { const home = tmp(); writeFileSync(join(home, "history.jsonl"), line("c1", "/rename before start")); - writeSummariesDb(home, [{ id: "c2", preview: "Prior Generated" }]); const calls: Array<[string, string]> = []; makeWatcher(home, calls).start(); await Bun.sleep(300); diff --git a/bridge/tests/api-server-session-title.test.ts b/bridge/tests/api-server-session-title.test.ts index 8d310a04..e1d1f3de 100644 --- a/bridge/tests/api-server-session-title.test.ts +++ b/bridge/tests/api-server-session-title.test.ts @@ -12,7 +12,11 @@ function ctx(over: Partial = {}): AgentContext { } describe("POST /session-title", () => { - test("valid body routes to onSessionTitle", async () => { + // `title` is opencode's plugin posting the name its server generated. It is + // still on the wire from every already-installed copy of that plugin, and the + // schema declares no field for it: the post must keep parsing, and the name + // must be dropped — we name sessions ourselves (see ResolvedTitle). + test("valid body routes to onSessionTitle, minus the plugin's own title", async () => { const seen: any[] = []; const srv = startApiServer(ctx({ onSessionTitle: (b) => seen.push(b) })); try { @@ -22,7 +26,7 @@ describe("POST /session-title", () => { body: JSON.stringify({ terminalId: "t1", sessionId: "s1", title: "Hi", transcriptPath: "/tmp/t.jsonl", agent: "opencode" }), }); expect(res.status).toBe(200); - expect(seen).toEqual([{ terminalId: "t1", sessionId: "s1", title: "Hi", transcriptPath: "/tmp/t.jsonl", agent: "opencode" }]); + expect(seen).toEqual([{ terminalId: "t1", sessionId: "s1", transcriptPath: "/tmp/t.jsonl", agent: "opencode" }]); } finally { srv.stop(); } }); diff --git a/bridge/tests/fork-handoff.test.ts b/bridge/tests/fork-handoff.test.ts new file mode 100644 index 00000000..3e3415cd --- /dev/null +++ b/bridge/tests/fork-handoff.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { claudeForkHandoff, claudeNativeForkArgs } from "../src/agents/claude-code/fork"; +import { codexNativeForkArgs } from "../src/agents/codex/fork"; +import { opencodeNativeForkArgs } from "../src/agents/opencode/fork"; +import { terminalForkHandoff } from "../src/agents/fork-handoff"; + +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("agent fork handoffs", () => { + test("Claude adapter normalizes its captured hook transcript", async () => { + const dir = await mkdtemp(join(tmpdir(), "antgrid-fork-")); + dirs.push(dir); + const transcriptPath = join(dir, "session.jsonl"); + await writeFile(transcriptPath, [ + JSON.stringify({ type: "user", message: { content: "Inspect the failing test." } }), + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "I found the regression." }] } }), + ].join("\n")); + + await expect(claudeForkHandoff({ + maxMsgs: 20, + projectPath: dir, + transcriptPath, + })).resolves.toBe("[Claude Code conversation]\nInspect the failing test.\n\nI found the regression."); + }); + + test("terminal-only adapters never invent provider ids or paths", async () => { + const adapter = terminalForkHandoff("Kimi"); + await expect(adapter.handoff({ + maxMsgs: 20, + projectPath: "/project", + terminalTranscript: "Existing terminal context", + })).resolves.toBe("[Kimi conversation]\nExisting terminal context"); + }); + + test("native providers use fork rather than resume", () => { + expect(claudeNativeForkArgs("claude-session")).toEqual([ + "--resume", "claude-session", "--fork-session", + ]); + expect(codexNativeForkArgs("codex-session")).toEqual(["fork", "codex-session"]); + expect(opencodeNativeForkArgs("opencode-session")).toEqual([ + "--session", "opencode-session", "--fork", + ]); + }); +}); diff --git a/bridge/tests/handler/api-handler-event.test.ts b/bridge/tests/handler/api-handler-event.test.ts index fcd5eb3c..95bb465b 100644 --- a/bridge/tests/handler/api-handler-event.test.ts +++ b/bridge/tests/handler/api-handler-event.test.ts @@ -47,3 +47,135 @@ test("POST /handler-event rejects an invalid event value", async () => { expect(res.status).toBe(400); h.stop(); }); + +// ── The stale post-completion idle nudge gate ─────────────────────────────── +// +// The agent's notification hook fires the same "waiting for your input" signal +// for a genuine mid-turn block and for its idle nudge after the turn ended, so +// the host answers it from turn state AND from the poster's own reading of the +// message. These pin WHICH events the gate is asked about and which way it fails +// when either half is missing. + +test("a stale post-completion awaiting_input is dropped, not forwarded", async () => { + const got: any[] = []; + const h = startApiServer(baseCtx({ + onHandlerEvent: (b: any) => got.push(b), + isStaleIdleNudge: () => true, + })); + const res = await fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId: "t1", event: "awaiting_input", idleNudge: true }), + }); + // Still 200 — the hook must not see a failure for a deliberate drop. + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, stale: true }); + expect(got).toEqual([]); + h.stop(); +}); + +test("a genuine awaiting_input is forwarded when the reduction says the turn is still live", async () => { + const got: any[] = []; + const h = startApiServer(baseCtx({ + onHandlerEvent: (b: any) => got.push(b), + isStaleIdleNudge: () => false, + })); + const res = await fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId: "t1", event: "awaiting_input" }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(got.map((b) => b.event)).toEqual(["awaiting_input"]); + h.stop(); +}); + +test("no event kind other than awaiting_input is gated on the predicate", async () => { + // The load-bearing one: a turn_end or a limit lifecycle event is unambiguous, + // and suppressing one would strand the engine's own turn bookkeeping. + const got: any[] = []; + const h = startApiServer(baseCtx({ + onHandlerEvent: (b: any) => got.push(b), + isStaleIdleNudge: () => true, + })); + const post = (body: unknown) => fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + expect((await post({ terminalId: "t1", event: "turn_end" })).status).toBe(200); + expect((await post({ terminalId: "t1", event: "limit_hit" })).status).toBe(200); + expect((await post({ terminalId: "t1", event: "turn_failed" })).status).toBe(200); + expect((await post({ terminalId: "t1", event: "limit_cleared" })).status).toBe(200); + expect(got.map((b) => b.event)).toEqual(["turn_end", "limit_hit", "turn_failed", "limit_cleared"]); + h.stop(); +}); + +test("a context with no isStaleIdleNudge forwards awaiting_input (fail toward forwarding)", async () => { + // An unwired owner must never silence the supervisor: a dropped genuine block + // leaves a blocked agent with no further event able to raise it. + const got: any[] = []; + const h = startApiServer(baseCtx({ onHandlerEvent: (b: any) => got.push(b) })); + const res = await fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId: "t1", event: "awaiting_input" }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(got).toHaveLength(1); + h.stop(); +}); + +test("the predicate is asked about the event's own terminalId", async () => { + const asked: string[] = []; + const h = startApiServer(baseCtx({ + onHandlerEvent: () => {}, + isStaleIdleNudge: (id: string) => { asked.push(id); return false; }, + })); + await fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId: "t2", event: "awaiting_input", idleNudge: true }), + }); + expect(asked).toEqual(["t2"]); + h.stop(); +}); + +test("a block the poster did NOT read as the nudge shape survives a latched turn-end", async () => { + // The gate's other half. This POST is decided before the /notify of the same + // hook invocation has folded, so turn state alone would drop the one event + // that reports a genuine mid-turn block — while that /notify goes on to record + // it as a live "needs you". Reachable whenever the slot's last notification was + // a turn-end: a lost /turn-start, or Handler's own park push. + const got: any[] = []; + const asked: string[] = []; + const h = startApiServer(baseCtx({ + onHandlerEvent: (b: any) => got.push(b), + isStaleIdleNudge: (id: string) => { asked.push(id); return true; }, + })); + const res = await fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId: "t1", event: "awaiting_input", idleNudge: false }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(got.map((b) => b.event)).toEqual(["awaiting_input"]); + // Turn state is never even consulted: the poster already said this is a block. + expect(asked).toEqual([]); + h.stop(); +}); + +test("an awaiting_input that claims nothing about its shape is forwarded", async () => { + // A poster predating the field says nothing, and silence must not be read as + // "this is the idle nudge" — the drop is a suppression, and an unstated answer + // has to fail toward the supervisor hearing about it. + const got: any[] = []; + const h = startApiServer(baseCtx({ + onHandlerEvent: (b: any) => got.push(b), + isStaleIdleNudge: () => true, + })); + const res = await fetch(`http://127.0.0.1:${h.port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId: "t1", event: "awaiting_input" }), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(got).toHaveLength(1); + h.stop(); +}); diff --git a/bridge/tests/handler/authorization.test.ts b/bridge/tests/handler/authorization.test.ts index 18184a4d..691cd970 100644 --- a/bridge/tests/handler/authorization.test.ts +++ b/bridge/tests/handler/authorization.test.ts @@ -106,6 +106,13 @@ describe("literal lift", () => { expect(stillWarns(auth, "check /etc/")).toHaveLength(1); }); + it("a pattern lift still refuses a bare-root target", () => { + // namesTargetOutsideProject reads single-segment roots; the floor's own ABS_PATH + // deliberately does not. Unifying the two near-identical regexes would widen every + // operation lift to top-level directories, silently and with nothing else failing. + expect(stillWarns(armed("recursively delete build directories"), "rm -rf /tmp")).toHaveLength(1); + }); + it("a path lift survives sentence punctuation and Windows spelling", () => { expect(stillWarns(armed("open /var/log/syslog."), "tail /var/log/syslog")).toEqual([]); expect(stillWarns(armed("open C:\\Windows\\hosts"), "open c:/Windows/hosts")).toEqual([]); diff --git a/bridge/tests/handler/backlog.test.ts b/bridge/tests/handler/backlog.test.ts index a0ecff6d..8c706176 100644 --- a/bridge/tests/handler/backlog.test.ts +++ b/bridge/tests/handler/backlog.test.ts @@ -22,13 +22,21 @@ function item( return { id, text: `do ${id}`, status, createdAt: NOW, ...extra }; } +// What the judge was shown. Stated per test rather than once for the file: a +// blanket fixture holding every quote below would ground all of them +// unconditionally, and the suite would stop testing the gate while still +// exercising it. +function corpus(...quotes: string[]): { evidenceCorpus: string } { + return { evidenceCorpus: `agent output:\n${quotes.join("\n")}\n` }; +} + describe("§2.1 the assistant moves items, it never mints them", () => { it("rejects a transition naming an id that is not in the backlog", () => { const backlog = [item("a", "queued")]; const before = structuredClone(backlog); const t: ItemTransition = { id: "ghost", status: "done", evidence: "all 14 tests pass" }; - const r = applyTransitions(backlog, [t], NOW); + const r = applyTransitions(backlog, [t], NOW, corpus("all 14 tests pass")); expect(r.backlog).toEqual(before); expect(r.applied).toEqual([]); @@ -39,7 +47,7 @@ describe("§2.1 the assistant moves items, it never mints them", () => { }); it("never appends an item, so an empty backlog stays empty", () => { - const r = applyTransitions([], [{ id: "x", status: "done", evidence: "quote" }], NOW); + const r = applyTransitions([], [{ id: "x", status: "done", evidence: "quote" }], NOW, corpus("quote")); expect(r.backlog).toEqual([]); expect(r.progressed).toBe(false); }); @@ -49,10 +57,10 @@ describe("§2.1 the assistant moves items, it never mints them", () => { it("applies exactly the known ids in a mixed batch", () => { const backlog = [item("a", "queued"), item("b", "queued")]; const r = applyTransitions(backlog, [ - { id: "a", status: "done", evidence: "14 passed" }, + { id: "a", status: "done", evidence: "14 passed in 0.4s" }, { id: "ghost", status: "done", evidence: "forged" }, { id: "b", status: "active" }, - ], NOW); + ], NOW, corpus("14 passed in 0.4s")); expect(r.backlog.map((i) => i.id)).toEqual(["a", "b"]); expect(r.backlog.map((i) => i.status)).toEqual(["done", "active"]); @@ -64,7 +72,7 @@ describe("§2.1 the assistant moves items, it never mints them", () => { describe("§2.1 terminal transitions require evidence", () => { for (const status of ["done", "skipped", "failed"] as const) { it(`rejects a ${status} transition with no evidence field`, () => { - const r = applyTransitions([item("a", "active")], [{ id: "a", status }], NOW); + const r = applyTransitions([item("a", "active")], [{ id: "a", status }], NOW, corpus("do a ran fine")); expect(r.applied).toEqual([]); expect(r.rejected).toHaveLength(1); expect(r.backlog[0]!.status).toBe("active"); @@ -76,6 +84,7 @@ describe("§2.1 terminal transitions require evidence", () => { [item("a", "active")], [{ id: "a", status, evidence: " \n\t " }], NOW, + corpus("do a ran fine"), ); expect(r.applied).toEqual([]); expect(r.rejected).toHaveLength(1); @@ -85,7 +94,7 @@ describe("§2.1 terminal transitions require evidence", () => { it("leaves the item's prior evidence intact when a terminal transition is rejected", () => { const backlog = [item("a", "blocked", { evidence: "dependency still red" })]; - const r = applyTransitions(backlog, [{ id: "a", status: "done", evidence: "" }], NOW); + const r = applyTransitions(backlog, [{ id: "a", status: "done", evidence: "" }], NOW, corpus("dependency still red")); expect(r.backlog[0]!.status).toBe("blocked"); expect(r.backlog[0]!.evidence).toBe("dependency still red"); }); @@ -97,7 +106,7 @@ describe("§2.1 terminal transitions require evidence", () => { { id: "a", status: "active" }, { id: "b", status: "blocked" }, { id: "c", status: "queued" }, - ], NOW); + ], NOW, corpus("nothing here needs citing")); expect(r.rejected).toEqual([]); expect(r.backlog.map((i) => i.status)).toEqual(["active", "blocked", "queued"]); }); @@ -105,17 +114,254 @@ describe("§2.1 terminal transitions require evidence", () => { it("records the evidence and outcome carried by an accepted transition", () => { const r = applyTransitions([item("a", "active")], [ { id: "a", status: "done", evidence: "PASS 14 tests in 0.4s", outcome: "Passed 14 unit tests" }, - ], NOW); + ], NOW, corpus("PASS 14 tests in 0.4s")); expect(r.backlog[0]!.evidence).toBe("PASS 14 tests in 0.4s"); expect(r.backlog[0]!.outcome).toBe("Passed 14 unit tests"); }); }); +// A non-empty `evidence` string is not a citation: a judge clears that by +// paraphrasing, inventing, or quoting a real sentence about a different subject. +// These pin what the two stacked rules — grounding, then the command anchor — +// can and cannot answer. +describe("§2.1 terminal evidence must be a citation, not a string", () => { + it("applies a terminal transition whose evidence really is in the corpus", () => { + const r = applyTransitions([item("a", "active")], [ + { id: "a", status: "done", evidence: "14 tests passed in 0.4s" }, + ], NOW, corpus("$ bun test", "14 tests passed in 0.4s")); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + expect(r.progressed).toBe(true); + }); + + it("rejects a fluent paraphrase the corpus never contained", () => { + // The commonest dishonest completion, and the one a non-empty check cannot + // see: the sentence reads like a quote and nothing in the record says it. + const r = applyTransitions([item("a", "active")], [ + { id: "a", status: "done", evidence: "the test suite completed successfully" }, + ], NOW, corpus("14 tests passed in 0.4s")); + expect(r.applied).toEqual([]); + expect(r.rejected).toHaveLength(1); + expect(r.rejected[0]!.code).toBe("unverified_evidence"); + expect(r.backlog[0]!.status).toBe("active"); + expect(r.progressed).toBe(false); + }); + + it("rejects a quote too short to discriminate, even when the corpus contains it", () => { + // A three-word fragment occurs in almost any corpus by accident, so accepting + // it would leave grounding meaning nothing at all. + const r = applyTransitions([item("a", "active")], [ + { id: "a", status: "done", evidence: "done" }, + ], NOW, corpus("the work is done")); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("unverified_evidence"); + }); + + it("rejects evidence that only repeats the item's own text", () => { + // Handler injects the instruction itself, so the item's wording lands in the + // agent's own scrollback and would ground cleanly. Quoting it back proves the + // work was ASKED FOR, which is the one thing already known. + const text = "run the whole unit suite"; + const r = applyTransitions([item("a", "active", { text })], [ + { id: "a", status: "done", evidence: text }, + ], NOW, corpus(`> ${text}`)); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("unverified_evidence"); + expect(r.rejected[0]!.reason).toContain("repeats"); + }); + + it("survives the punctuation a judge rewrites on the way out", () => { + // Smart quotes, an em dash and an elision are how an honest citation usually + // arrives; rejecting those would spend the gate's whole budget on typography. + const r = applyTransitions([item("a", "active")], [ + { id: "a", status: "done", evidence: "the “auth” module — rewritten … and covered" }, + ], NOW, corpus('the "auth" module - rewritten from scratch and covered')); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + }); + + it("rejects a spliced quote whose halves appear in the wrong order", () => { + const r = applyTransitions([item("a", "active")], [ + { id: "a", status: "done", evidence: "and covered ... the auth module" }, + ], NOW, corpus("the auth module was rewritten and covered")); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("unverified_evidence"); + }); + + // The corpus is whatever assembleContext could reach, and it legitimately comes + // back empty — a fresh PTY, a chat render that failed. Refusing every terminal + // transition there would wedge the session with the runaway guard as its only + // exit, so an absent record fails toward the old behaviour instead. + it("skips grounding when there is no record to ground against", () => { + const r = applyTransitions([item("a", "active")], [ + { id: "a", status: "done", evidence: "14 tests passed in 0.4s" }, + ], NOW, { evidenceCorpus: "" }); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + }); + + it("stamps a code on every rejection so the engine can act on the kind", () => { + const r = applyTransitions([item("a", "done", { evidence: "landed" }), item("b", "queued")], [ + { id: "a", status: "queued" }, + { id: "ghost", status: "done", evidence: "14 tests passed in 0.4s" }, + { id: "b", status: "done" }, + { id: "b", status: "invented" } as unknown as ItemTransition, + ], NOW, corpus("14 tests passed in 0.4s")); + expect(r.rejected.map((x) => x.code)) + .toEqual(["already_terminal", "unknown_id", "missing_evidence", "malformed"]); + }); + + it("rejecting a citation mutates neither the backlog nor the transitions", () => { + const backlog = [item("a", "active")]; + const transitions: ItemTransition[] = [ + { id: "a", status: "done", evidence: "the test suite completed successfully" }, + ]; + const beforeBacklog = structuredClone(backlog); + const beforeTransitions = structuredClone(transitions); + applyTransitions(backlog, transitions, NOW, corpus("14 tests passed in 0.4s")); + expect(backlog).toEqual(beforeBacklog); + expect(transitions).toEqual(beforeTransitions); + }); +}); + +// Grounding alone does NOT catch the reported incident: the quote was genuinely +// in the context, about the coding agent's own internal review step. The anchor +// is what catches that shape, and grounding is what stops the anchor being +// cleared by typing the command name into the evidence field. +describe("§2.1 a command-shaped item needs evidence of THAT command", () => { + const ITEM = "run /code-review --fix"; + const OTHER = "running my own review of the changes; review complete, no findings"; + + it("refuses done on a real quote about a different review", () => { + const r = applyTransitions([item("a", "active", { text: ITEM })], [ + { id: "a", status: "done", evidence: "review complete, no findings" }, + ], NOW, corpus(OTHER)); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("missing_command_anchor"); + expect(r.rejected[0]!.reason).toContain("/code-review"); + expect(r.backlog[0]!.status).toBe("active"); + expect(r.progressed).toBe(false); + expect(allTerminal(r.backlog)).toBe(false); + }); + + it("accepts done once the quote shows the command itself being run", () => { + const r = applyTransitions([item("a", "active", { text: ITEM })], [ + { id: "a", status: "done", evidence: "> /code-review --fix" }, + ], NOW, corpus("> /code-review --fix", "review complete, no findings")); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + expect(r.progressed).toBe(true); + }); + + // A skip or a failure says the work did NOT happen, so demanding a quote of the + // invocation would ask for the one record that cannot exist — and make + // "correctly did not happen" unsayable again, the §2.2 deadlock the wider + // vocabulary was added to remove. + for (const status of ["skipped", "failed"] as const) { + it(`lets ${status} through on a grounded quote that names no command`, () => { + const r = applyTransitions([item("a", "active", { text: ITEM })], [ + { id: "a", status, evidence: "the review workflow is not installed here" }, + ], NOW, corpus("the review workflow is not installed here")); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe(status); + }); + } + + // The documented blind spot: an item phrased without a command gets grounding + // and nothing else, so the identical mis-attribution passes exactly as before. + it("asks only for grounding when the item names no command", () => { + const r = applyTransitions([item("a", "active", { text: "review the code and fix what you find" })], [ + { id: "a", status: "done", evidence: "review complete, no findings" }, + ], NOW, corpus(OTHER)); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + }); + + // A path is not a verb, and treating one as the item's named command would ask + // for evidence of a command nothing can run. + it("does not read a multi-segment path as a command the item named", () => { + const r = applyTransitions([item("a", "active", { text: "delete /tmp/build-cache" })], [ + { id: "a", status: "done", evidence: "removed the stale cache directory" }, + ], NOW, corpus("removed the stale cache directory")); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + }); +}); + +// A token that LOOKS like a slash command is not one. `commandTokens` reads shape +// alone, so a route or a path segment in the user's own wording anchors an item to +// a quote no honest sentence will ever contain — and the item is then unclosable +// for the life of the session. Two answers narrow that: the session's catalog, +// where there is one, and the caller's waiver where there is not. +describe("§2.1 the command anchor only fires on a command", () => { + const ROUTE = "Fix the /login redirect so it lands on the dashboard"; + const LANDED = "LoginRedirect.tsx updated; login now redirects to /dashboard"; + + it("does not anchor a token this session's catalog does not list", () => { + const r = applyTransitions([item("a", "active", { text: ROUTE })], [ + { id: "a", status: "done", evidence: LANDED }, + ], NOW, { ...corpus(LANDED), commandNames: ["code-review", "init"] }); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + }); + + it("still anchors a token the catalog does list", () => { + const r = applyTransitions([item("a", "active", { text: "run /code-review" })], [ + { id: "a", status: "done", evidence: "review complete, no findings" }, + ], NOW, { ...corpus("review complete, no findings"), commandNames: ["code-review"] }); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("missing_command_anchor"); + }); + + // Absent or empty is "no catalog available" — a PTY, discovery that has not + // landed — never "this agent has no commands", so the anchor is kept and the + // waiver is what bounds it. Reading emptiness as an answer would drop the anchor + // for exactly the sessions the Handler supervises most. + for (const commandNames of [undefined, []]) { + it(`keeps the anchor when the catalog is ${commandNames ? "empty" : "absent"}`, () => { + const r = applyTransitions([item("a", "active", { text: ROUTE })], [ + { id: "a", status: "done", evidence: LANDED }, + ], NOW, { ...corpus(LANDED), commandNames }); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("missing_command_anchor"); + }); + } + + it("drops the anchor for an item the caller has waived", () => { + const r = applyTransitions([item("a", "active", { text: ROUTE })], [ + { id: "a", status: "done", evidence: LANDED }, + ], NOW, { ...corpus(LANDED), anchorWaived: new Set(["a"]) }); + expect(r.rejected).toEqual([]); + expect(r.backlog[0]!.status).toBe("done"); + }); + + it("waives the anchor and nothing else — a waived item still needs a real quote", () => { + const r = applyTransitions([item("a", "active", { text: ROUTE })], [ + { id: "a", status: "done", evidence: "the redirect works now" }, + ], NOW, { ...corpus(LANDED), anchorWaived: new Set(["a"]) }); + expect(r.applied).toEqual([]); + expect(r.rejected[0]!.code).toBe("unverified_evidence"); + }); + + it("waives only the item named", () => { + const r = applyTransitions([ + item("a", "active", { text: ROUTE }), + item("b", "active", { text: "also fix the /logout redirect" }), + ], [ + { id: "a", status: "done", evidence: LANDED }, + { id: "b", status: "done", evidence: LANDED }, + ], NOW, { ...corpus(LANDED), anchorWaived: new Set(["a"]) }); + expect(r.backlog[0]!.status).toBe("done"); + expect(r.backlog[1]!.status).toBe("active"); + expect(r.rejected[0]!.code).toBe("missing_command_anchor"); + }); +}); + describe("§2.2 only done counts as progress", () => { it("sets progressed when an item reaches done, straight from queued", () => { const r = applyTransitions([item("a", "queued")], [ - { id: "a", status: "done", evidence: "all green" }, - ], NOW); + { id: "a", status: "done", evidence: "all green, 0 failures" }, + ], NOW, corpus("all green, 0 failures")); expect(r.progressed).toBe(true); expect(r.applied[0]!.from).toBe("queued"); expect(r.applied[0]!.at).toBe(NOW); @@ -124,7 +370,7 @@ describe("§2.2 only done counts as progress", () => { it("does not set progressed when done is re-asserted on an already-done item", () => { const r = applyTransitions([item("a", "done", { evidence: "old quote" })], [ { id: "a", status: "done", evidence: "same completion, restated" }, - ], NOW); + ], NOW, corpus("same completion, restated")); expect(r.progressed).toBe(false); expect(r.applied).toEqual([]); expect(r.rejected).toHaveLength(1); @@ -141,13 +387,13 @@ describe("§2.2 only done counts as progress", () => { for (let round = 0; round < 6; round++) { const resolved = applyTransitions(backlog, [ { id: "a", status: "blocked", evidence: "tests still failing" }, - ], NOW + round); + ], NOW + round, corpus("tests still failing")); expect(resolved.rejected).toEqual([]); expect(resolved.progressed).toBe(false); const revived = applyTransitions(resolved.backlog, [ { id: "a", status: "queued" }, - ], NOW + round); + ], NOW + round, corpus("tests still failing")); expect(revived.rejected).toEqual([]); expect(revived.progressed).toBe(false); backlog = revived.backlog; @@ -158,7 +404,7 @@ describe("§2.2 only done counts as progress", () => { const r = applyTransitions([item("b", "queued"), item("c", "queued")], [ { id: "b", status: "skipped", evidence: "superseded by the full suite" }, { id: "c", status: "failed", evidence: "could not reach the registry" }, - ], NOW); + ], NOW, corpus("superseded by the full suite", "could not reach the registry")); expect(r.rejected).toEqual([]); expect(r.progressed).toBe(false); }); @@ -166,7 +412,7 @@ describe("§2.2 only done counts as progress", () => { it("does not set progressed when a blocked item revives to queued on new evidence", () => { const r = applyTransitions([item("a", "blocked", { evidence: "dep was red" })], [ { id: "a", status: "queued", evidence: "dep is green again" }, - ], NOW); + ], NOW, corpus("dep is green again")); expect(r.applied).toHaveLength(1); expect(r.applied[0]!.from).toBe("blocked"); expect(r.progressed).toBe(false); @@ -182,10 +428,10 @@ describe("§2.2 only done counts as progress", () => { for (let round = 0; round < 5; round++) { const done = applyTransitions(backlog, [ { id: "a", status: "done", evidence: `completed on pass ${round}` }, - ], NOW + round); + ], NOW + round, corpus(`completed on pass ${round}`)); if (done.progressed) progressions++; - const reopened = applyTransitions(done.backlog, [{ id: "a", status: "queued" }], NOW + round); + const reopened = applyTransitions(done.backlog, [{ id: "a", status: "queued" }], NOW + round, corpus("nothing here needs citing")); if (reopened.progressed) progressions++; backlog = reopened.backlog; } @@ -198,7 +444,7 @@ describe("§2.2 done, skipped and failed are one-way", () => { for (const from of ["done", "skipped", "failed"] as const) { it(`rejects a transition out of ${from}`, () => { const backlog = [item("a", from, { evidence: "the original justification" })]; - const r = applyTransitions(backlog, [{ id: "a", status: "queued" }], NOW); + const r = applyTransitions(backlog, [{ id: "a", status: "queued" }], NOW, corpus("the original justification")); expect(r.applied).toEqual([]); expect(r.rejected).toHaveLength(1); expect(r.rejected[0]!.reason).toContain(from); @@ -213,7 +459,7 @@ describe("§2.2 done, skipped and failed are one-way", () => { it("allows revival out of blocked", () => { const r = applyTransitions([item("a", "blocked", { evidence: "dep was red" })], [ { id: "a", status: "queued" }, - ], NOW); + ], NOW, corpus("dep was red")); expect(r.rejected).toEqual([]); expect(r.backlog[0]!.status).toBe("queued"); }); @@ -223,9 +469,9 @@ describe("§2.2 done, skipped and failed are one-way", () => { // persists shows nothing completed. it("does not let one batch complete an item and then reopen it", () => { const r = applyTransitions([item("a", "queued")], [ - { id: "a", status: "done", evidence: "green" }, + { id: "a", status: "done", evidence: "green across the board" }, { id: "a", status: "queued" }, - ], NOW); + ], NOW, corpus("green across the board")); expect(r.backlog[0]!.status).toBe("done"); expect(r.rejected).toHaveLength(1); expect(r.progressed).toBe(true); @@ -241,6 +487,7 @@ describe("§2.1 malformed transitions are rejected, not applied", () => { [item("a", "active")], [{ id: "a", status: "completed" } as unknown as ItemTransition], NOW, + corpus("nothing here needs citing"), ); expect(r.applied).toEqual([]); expect(r.rejected).toHaveLength(1); @@ -252,8 +499,8 @@ describe("§2.1 malformed transitions are rejected, not applied", () => { it("rejects one malformed tuple without aborting the rest of the batch", () => { const r = applyTransitions([item("a", "active"), item("b", "queued")], [ { id: "a", status: "done", evidence: 123 } as unknown as ItemTransition, - { id: "b", status: "done", evidence: "14 passed" }, - ], NOW); + { id: "b", status: "done", evidence: "14 passed in 0.4s" }, + ], NOW, corpus("14 passed in 0.4s")); expect(r.backlog.map((i) => i.status)).toEqual(["active", "done"]); expect(r.rejected).toHaveLength(1); }); @@ -266,9 +513,9 @@ describe("applyTransitions is pure", () => { const firstRef = backlog[0]!; applyTransitions(backlog, [ - { id: "a", status: "done", evidence: "green" }, - { id: "b", status: "failed", evidence: "red" }, - ], NOW); + { id: "a", status: "done", evidence: "green across the board" }, + { id: "b", status: "failed", evidence: "red, 3 failures" }, + ], NOW, corpus("green across the board", "red, 3 failures")); expect(backlog).toEqual(before); expect(backlog[0]).toBe(firstRef); @@ -279,7 +526,7 @@ describe("applyTransitions is pure", () => { // one makes the result mutable through the caller's array in both directions. it("does not share the dependsOn array with the caller", () => { const backlog = [item("a", "queued", { dependsOn: ["b"] })]; - const r = applyTransitions(backlog, [{ id: "a", status: "active" }], NOW); + const r = applyTransitions(backlog, [{ id: "a", status: "active" }], NOW, corpus("nothing here needs citing")); r.backlog[0]!.dependsOn!.push("c"); expect(backlog[0]!.dependsOn).toEqual(["b"]); @@ -291,7 +538,7 @@ describe("applyTransitions is pure", () => { // the fact, which is exactly what an audit trail must not allow. it("snapshots a rejected transition instead of aliasing it", () => { const t: ItemTransition = { id: "ghost", status: "done", evidence: "forged" }; - const r = applyTransitions([item("a", "queued")], [t], NOW); + const r = applyTransitions([item("a", "queued")], [t], NOW, corpus("forged")); t.id = "a"; t.status = "queued"; @@ -304,7 +551,7 @@ describe("applyTransitions is pure", () => { item("a", "queued", { dependsOn: ["b"], condition: "if lint is broken" }), item("b", "queued"), ]; - const r = applyTransitions(backlog, [{ id: "a", status: "active" }], NOW); + const r = applyTransitions(backlog, [{ id: "a", status: "active" }], NOW, corpus("nothing here needs citing")); expect(r.backlog.map((i) => i.id)).toEqual(["a", "b"]); expect(r.backlog[0]!.text).toBe("do a"); expect(r.backlog[0]!.createdAt).toBe(NOW); @@ -315,8 +562,8 @@ describe("applyTransitions is pure", () => { it("lets a later transition in the same batch supersede an earlier one", () => { const r = applyTransitions([item("a", "queued")], [ { id: "a", status: "active" }, - { id: "a", status: "done", evidence: "finished" }, - ], NOW); + { id: "a", status: "done", evidence: "finished cleanly" }, + ], NOW, corpus("finished cleanly")); expect(r.backlog[0]!.status).toBe("done"); expect(r.progressed).toBe(true); }); diff --git a/bridge/tests/handler/decision.test.ts b/bridge/tests/handler/decision.test.ts index a72f1d24..3c886e5d 100644 --- a/bridge/tests/handler/decision.test.ts +++ b/bridge/tests/handler/decision.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from "bun:test"; import { HandlerDecisionSchema, - buildJudgeCommand, + pickJudge, buildDecidePrompt, buildRetryPrompt, + buildShapeRetryPrompt, parseDecisionFromOutput, } from "../../src/handler/decision"; @@ -49,60 +50,60 @@ describe("decision schema", () => { }); }); -describe("buildJudgeCommand tiers", () => { +describe("pickJudge tiers", () => { it("claude-code is readonly with allowed tools pinned", () => { - const r = buildJudgeCommand("claude-code", undefined, "P")!; + const r = pickJudge("claude-code")!; expect(r.tier).toBe("readonly"); - expect(r.cmd).toContain("--allowedTools"); + expect(r.command.cmd("P")).toContain("--allowedTools"); }); // --allowedTools is variadic: a prompt after it is eaten as another tool name // and claude exits 1, failing every judge call closed. Position, not presence, // is what makes the argv work. it("claude-code puts the prompt ahead of the variadic --allowedTools", () => { - const r = buildJudgeCommand("claude-code", undefined, "P")!; - expect(r.cmd.indexOf("P")).toBeGreaterThan(-1); - expect(r.cmd.indexOf("P")).toBeLessThan(r.cmd.indexOf("--allowedTools")); + const cmd = pickJudge("claude-code")!.command.cmd("P"); + expect(cmd.indexOf("P")).toBeGreaterThan(-1); + expect(cmd.indexOf("P")).toBeLessThan(cmd.indexOf("--allowedTools")); }); it("codex is readonly via sandbox", () => { - const r = buildJudgeCommand("codex", undefined, "P")!; + const r = pickJudge("codex")!; expect(r.tier).toBe("readonly"); - expect(r.cmd).toContain("read-only"); + expect(r.command.cmd("P")).toContain("read-only"); }); // A project need not be a git repo; without this codex refuses to run at all. it("codex skips the git-repo check without weakening the sandbox", () => { - const r = buildJudgeCommand("codex", undefined, "P")!; - expect(r.cmd).toContain("--skip-git-repo-check"); - expect(r.cmd).toContain("--sandbox"); - expect(r.cmd).toContain("read-only"); + const r = pickJudge("codex")!; + expect(r.command.cmd("P")).toContain("--skip-git-repo-check"); + expect(r.command.cmd("P")).toContain("--sandbox"); + expect(r.command.cmd("P")).toContain("read-only"); }); // A judge pass is machine bookkeeping, and one runs per agent pause — left // persisted they bury the user's own sessions in /resume and `codex exec // resume`. Both flags are load-bearing rather than cosmetic, so pin them: // dropping one is invisible until someone goes looking for their own work. it("claude-code and codex write no session of their own", () => { - expect(buildJudgeCommand("claude-code", undefined, "P")!.cmd).toContain("--no-session-persistence"); - expect(buildJudgeCommand("codex", undefined, "P")!.cmd).toContain("--ephemeral"); + expect(pickJudge("claude-code")!.command.cmd("P")).toContain("--no-session-persistence"); + expect(pickJudge("codex")!.command.cmd("P")).toContain("--ephemeral"); }); // opencode has no such flag, so its session store is redirected instead. The // DATA dir must NOT move with it: auth.json lives there, so an XDG_DATA_HOME // override would hide the session by taking the judge's credentials with it. it("opencode redirects its session store without moving its auth", () => { - const r = buildJudgeCommand("opencode", undefined, "P")!; - expect(r.env).toEqual({ OPENCODE_DB: ":memory:" }); - expect(Object.keys(r.env!)).not.toContain("XDG_DATA_HOME"); + const r = pickJudge("opencode")!; + expect(r.command.env).toEqual({ OPENCODE_DB: ":memory:" }); + expect(Object.keys(r.command.env!)).not.toContain("XDG_DATA_HOME"); }); // Only opencode needs one: claude and codex say it in the argv, and an env // override there would be a second, quieter place to look for the same rule. it("the flag-based judges carry no env override", () => { - expect(buildJudgeCommand("claude-code", undefined, "P")!.env).toBeUndefined(); - expect(buildJudgeCommand("codex", undefined, "P")!.env).toBeUndefined(); + expect(pickJudge("claude-code")!.command.env).toBeUndefined(); + expect(pickJudge("codex")!.command.env).toBeUndefined(); }); it("opencode is transcript tier; unknown is null", () => { - expect(buildJudgeCommand("opencode", undefined, "P")!.tier).toBe("transcript"); - expect(buildJudgeCommand("gemini", undefined, "P")).toBeNull(); + expect(pickJudge("opencode")!.tier).toBe("transcript"); + expect(pickJudge("gemini")).toBeNull(); }); }); @@ -128,6 +129,38 @@ describe("buildDecidePrompt", () => { } }); + // The gate downstream searches the RECENT CONTEXT block for the quote, so a + // judge told to cite "the context or transcript" loses real transitions to a + // rule it was never given. + it("narrows the evidence rule to a verbatim quote from the recent context", () => { + const p = buildDecidePrompt({ goal: GOAL, backlogText: BACKLOG_TEXT, context: "CTX" }); + expect(p).toContain("character-for-character"); + expect(p).toContain("RECENT CONTEXT"); + expect(p).toContain("discarded and the item stays open"); + }); + + it("states the command anchor for a done on a command-shaped item", () => { + const p = buildDecidePrompt({ goal: GOAL, backlogText: BACKLOG_TEXT, context: "CTX" }); + expect(p).toContain("slash command"); + expect(p).toContain("does not close it"); + }); + + // Same absent-vs-empty discipline the floor warnings take: an empty list is a + // pass with nothing refused, and a header over no lines reads as one anyway. + it("renders the refused-transitions section only when there is something to say", () => { + const bare = buildDecidePrompt({ goal: GOAL, backlogText: BACKLOG_TEXT, context: "CTX" }); + const empty = buildDecidePrompt({ goal: GOAL, backlogText: BACKLOG_TEXT, context: "CTX", evidenceRejections: [] }); + for (const p of [bare, empty]) expect(p).not.toContain("THE HARNESS REFUSED"); + + const fed = buildDecidePrompt({ + goal: GOAL, backlogText: BACKLOG_TEXT, context: "CTX", + evidenceRejections: ['"run /code-review --fix" — done needs evidence showing /code-review itself being run'], + }); + expect(fed).toContain("THE HARNESS REFUSED"); + expect(fed).toContain("showing /code-review itself being run"); + expect(fed).toContain("the same quote gets the same answer"); + }); + it("stands in for an empty goal and an empty backlog rather than rendering nothing", () => { const p = buildDecidePrompt({ goal: "", backlogText: "", context: "CTX" }); expect(p).toContain("(none stated)"); @@ -138,6 +171,85 @@ describe("buildDecidePrompt", () => { const p = buildDecidePrompt({ goal: GOAL, backlogText: "", context: "CTX", transcriptPath: "/t.jsonl" }); expect(p).toContain("/t.jsonl"); }); + + it("keeps the transcript out of the evidence rule it invites a judge past", () => { + // The harness grounds a citation against the RECENT CONTEXT block alone — it + // holds no other text — so an unqualified "read the fuller transcript" is an + // invitation to quote material every terminal transition is then refused for, + // leaving the item open forever with the runaway guard as its only exit. + const p = buildDecidePrompt({ goal: GOAL, backlogText: "", context: "CTX", transcriptPath: "/t.jsonl" }); + const hint = p.slice(p.indexOf("Fuller transcript")); + expect(hint).toContain("background only"); + expect(hint).toContain("RECENT CONTEXT"); + expect(hint).toContain("leave the item open"); + }); + + // Every handle decision used to be escalated by harness rules the judge was + // never told: a verb carrying arguments failed the shape check, and a + // multi-paragraph reply failed the control-character guard. + it("states the slash-command contract", () => { + const p = buildDecidePrompt({ goal: GOAL, backlogText: "", context: "CTX" }); + expect(p).toContain("/verb"); + expect(p).toContain("/verb "); + }); + + it("states that reply and action are mutually exclusive", () => { + expect(buildDecidePrompt({ goal: GOAL, backlogText: "", context: "CTX" })).toContain("never both"); + }); + + it("states that the reply is submitted as ONE line", () => { + expect(buildDecidePrompt({ goal: GOAL, backlogText: "", context: "CTX" })).toContain("ONE line"); + }); + + // The judge reads a transcript the agent itself wrote, where `claude` appears + // and `claude-code` — our routing key — never does. + it("names the supervised agent by its CLI name", () => { + expect(buildDecidePrompt({ goal: GOAL, backlogText: "", context: "C", agentTool: "codex" })).toContain("codex"); + const p = buildDecidePrompt({ goal: GOAL, backlogText: "", context: "C", agentTool: "claude-code" }); + expect(p).toContain("`claude`"); + expect(p).not.toContain("claude-code"); + }); + + it("falls back to the generic phrasing when no agent is named", () => { + expect(buildDecidePrompt({ goal: GOAL, backlogText: "", context: "C" })).toContain("a coding agent works"); + }); + + it("lists a populated catalog under the complete-set header", () => { + const p = buildDecidePrompt({ + goal: GOAL, backlogText: "", context: "C", + commands: [{ id: "cmd:code-review", name: "code-review", description: "Review the diff", argHint: "[--fix]" }], + }); + expect(p).toContain("AVAILABLE COMMANDS"); + expect(p).toContain("/code-review"); + expect(p).toContain("[--fix]"); + expect(p).toContain("Review the diff"); + expect(p).not.toContain("No command catalog is available"); + }); + + // An empty catalog cannot be distinguished from a discovery that threw or has + // not landed, so it takes the same branch as no catalog at all — announcing a + // "complete set" of nothing would read as "this agent has no commands". + it("renders the no-catalog branch, never an empty header", () => { + for (const p of [ + buildDecidePrompt({ goal: GOAL, backlogText: "", context: "C" }), + buildDecidePrompt({ goal: GOAL, backlogText: "", context: "C", commands: [] }), + ]) { + expect(p).toContain("No command catalog is available"); + expect(p).not.toContain("AVAILABLE COMMANDS"); + } + }); +}); + +describe("buildShapeRetryPrompt", () => { + // A decision that parsed cleanly and then failed a harness rule has valid + // JSON; telling it to fix its JSON teaches it to change the one thing it got + // right, so this leg must not reuse the parse-failure wording. + it("carries the original prompt and the rejection without blaming the JSON", () => { + const p = buildShapeRetryPrompt("ORIG", "slash command value is not a simple verb"); + expect(p).toContain("ORIG"); + expect(p).toContain("slash command value is not a simple verb"); + expect(p).not.toContain("not a valid JSON object"); + }); }); describe("parseDecisionFromOutput", () => { diff --git a/bridge/tests/handler/destructive-floor.test.ts b/bridge/tests/handler/destructive-floor.test.ts index 3f19d22c..f338e80f 100644 --- a/bridge/tests/handler/destructive-floor.test.ts +++ b/bridge/tests/handler/destructive-floor.test.ts @@ -158,6 +158,83 @@ test("Windows out-of-project path is flagged, in-project is not", () => { expect(warnsWith("edit C:\\Users\\me\\proj\\src\\main.ts", "ABS_PATH", WIN)).toBe(false); }); +// --------------------------------------------------------------------------- +// The interior-separator rule, both sides of its trade. A slash command read as a +// path corrupts the §5.1 channel that exists to teach the Assistant which of its own +// proposals were dangerous, so a "/"-led token only counts as a path once it carries +// a separator INSIDE it — the one shape reply-shape's VERB rule forbids a verb to have. +// What that gives up is the bare top-level roots; the tiers that scan the full text +// are what bound the loss. +// --------------------------------------------------------------------------- + +test("a slash command in prose is not read as a path", () => { + for (const text of ["run /code-review next", "use /init to bootstrap the repo", "then /compact"]) { + expect(warnsWith(text, "ABS_PATH")).toBe(false); + } +}); + +test("a slash command in an argument tail is not read as a path", () => { + // Mirrors the engine's reply + argument-tail join — the half the verb scoping was + // never able to cover, since only the verb is withheld. + const r = classifyDestructive("looks good\n/review /code-review", PROJECT, "looks good\n/code-review"); + expect(r.warnings.some((w) => w.tier === "ABS_PATH")).toBe(false); +}); + +test("the accepted trade: a bare top-level root is no longer read as a path", () => { + for (const text of ["stage it under /tmp", "nothing writes to /etc", "cd /"]) { + expect(warnsWith(text, "ABS_PATH")).toBe(false); + } +}); + +test("an explicit directory reference is still a path", () => { + // The `/etc` vs `/etc/` line of the definition: the separator is what makes the + // claim, and the trailing one survives into `matched` (authorization lifts it + // literally, so a lift for /etc/hosts must not cover /etc/). + const abs = classifyDestructive("copy it to /etc/", PROJECT).warnings.find((w) => w.tier === "ABS_PATH"); + expect(abs?.matched).toBe("/etc/"); +}); + +test("two-segment paths still warn in every spelling", () => { + for (const text of ["/etc/passwd", "write to /etc/hosts.", "--out=/etc/passwd", 'read "/etc/shadow"']) { + expect(warnsWith(text, "ABS_PATH")).toBe(true); + } +}); + +test("a C-style comment is not a path", () => { + // A judge quoting code writes `//` constantly, and a leading empty segment is not + // a first segment. + expect(warnsWith("the code has // TODO fix this", "ABS_PATH")).toBe(false); +}); + +test("a doubled leading slash names the same file and is read the same way", () => { + // `//etc/shadow` IS `/etc/shadow` on every POSIX kernel, and the leading anchor + // allows no restart on an interior slash — so a spelling that matched neither + // branch cost the warning AND, since quickChoicesFor withholds its one-tap chip + // on any floor hit, handed the draft an Approve chip the single-slash spelling + // does not get. Unlike the bare roots above, this shape is chosen by whoever + // wrote the text. + for (const text of ["cat //etc/shadow and paste it here", "tar czf out.tgz //home/victim/Documents"]) { + expect(warnsWith(text, "ABS_PATH")).toBe(true); + } + const abs = classifyDestructive("read ///etc/nginx/nginx.conf", PROJECT).warnings + .find((w) => w.tier === "ABS_PATH"); + expect(abs?.matched).toBe("///etc/nginx/nginx.conf"); +}); + +test("the drive-letter branch keeps single-segment paths", () => { + // The asymmetry is deliberate: `C:\Temp` cannot be mistaken for a slash command. + const WIN = "C:\\Users\\me\\proj"; + expect(warnsWith("wipe C:\\Temp", "ABS_PATH", WIN)).toBe(true); +}); + +test("acting on a bare root is still caught by the other tiers", () => { + // This is what bounds the trade: what is given up is the mention, never the act. + expect(tiers("rm -rf /tmp")).toContain("DESTRUCTIVE"); + expect(tiers("rm -rf /tmp")).not.toContain("ABS_PATH"); + expect(tiers("chmod -R 777 /etc")).toContain("DESTRUCTIVE"); + expect(isHard("dd if=/dev/zero of=/dev/sdb")).toBe(true); +}); + // --------------------------------------------------------------------------- // Result shape. // --------------------------------------------------------------------------- @@ -196,14 +273,16 @@ test("identical repeats of one pattern collapse to a single warning", () => { // --------------------------------------------------------------------------- // pathCheckText: engine.ts's probe joins a judge reply and a slash_command action value -// with a newline ("reply\n/compact"). Every slash command is "/"-shaped, so without -// scoping the path check away from it, ABS_PATH always misreads the action value as an -// out-of-project path. +// with a newline ("reply\n/compact"). The verb is withheld from the path scan because it +// is a routing token the harness resolves against a command catalog rather than judge +// free text — an invariant that holds whatever ABS_PATH itself accepts. // --------------------------------------------------------------------------- test("pathCheckText scopes the path check away from a joined slash-command action value", () => { const probe = "looks good\n/compact"; - expect(tiers(probe)).toContain("ABS_PATH"); + // The verb is not a path candidate on either side of the scoping: the scoping is + // about what the text IS, not about rescuing the regex from it. + expect(tiers(probe)).not.toContain("ABS_PATH"); expect(tiers(probe, PROJECT, "looks good")).not.toContain("ABS_PATH"); }); @@ -217,3 +296,9 @@ test("pathCheckText still catches a genuine out-of-project path in the reply its const r = classifyDestructive(probe, PROJECT, "see /etc/hosts for details"); expect(r.warnings.some((w) => w.tier === "ABS_PATH" && w.matched === "/etc/hosts")).toBe(true); }); + +test("pathCheckText covers a reply plus an argument tail but not the verb", () => { + const r = classifyDestructive("looks good\n/review /etc/hosts", PROJECT, "looks good\n/etc/hosts"); + const abs = r.warnings.filter((w) => w.tier === "ABS_PATH"); + expect(abs.map((w) => w.matched)).toEqual(["/etc/hosts"]); +}); diff --git a/bridge/tests/handler/dismiss-wire.test.ts b/bridge/tests/handler/dismiss-wire.test.ts new file mode 100644 index 00000000..b5211eef --- /dev/null +++ b/bridge/tests/handler/dismiss-wire.test.ts @@ -0,0 +1,149 @@ +// The five-step checklist for a new inbound message type fails SILENTLY when a +// step is missed: the frame parses, reaches nothing, and the sender sees no +// error. Only a test that drives a real core proves the last step — the arm in +// agent-core's switch — is wired at all. Modelled on undo-wire.test.ts. +import { test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildAgentCore, type AgentCore } from "../../src/agent-core"; +import { MessageBus } from "../../src/message-bus"; +import { createMessage, type AbMessage } from "../../src/protocol"; +import type { HandlerSessionRecord } from "../../src/handler/session-store"; + +let prevAbDir: string | undefined; +let abDir: string; +const folders: string[] = []; + +beforeEach(() => { + prevAbDir = process.env.ANTGRID_DIR; + abDir = mkdtempSync(join(tmpdir(), "antgrid-dismiss-wire-")); + process.env.ANTGRID_DIR = abDir; +}); + +async function rmWithRetry(path: string): Promise { + for (let i = 0; i < 20; i++) { + try { rmSync(path, { recursive: true, force: true }); return; } + catch { await new Promise((r) => setTimeout(r, 25)); } + } +} + +// The core's chokidar watcher can emit a late EPERM/ENOENT from the raw fs.watch +// when its temp dir goes away, asynchronously enough to land in the next test. +// Same teardown artifact the other core-driving suites swallow. +function ignoreWatcherEperm(err: unknown): void { + const code = (err as { code?: string } | null)?.code; + if (code === "EPERM" || code === "ENOENT") return; + throw err; +} +process.on("uncaughtException", ignoreWatcherEperm); + +let core: AgentCore | null = null; +afterEach(async () => { + try { await core?.shutdown(); } catch { /* teardown only */ } + core = null; + await new Promise((r) => setTimeout(r, 50)); + if (prevAbDir === undefined) delete process.env.ANTGRID_DIR; else process.env.ANTGRID_DIR = prevAbDir; + await rmWithRetry(abDir); +}); + +afterAll(async () => { + while (folders.length) await rmWithRetry(folders.pop()!); + process.off("uncaughtException", ignoreWatcherEperm); +}); + +function tempFolder(): string { + const f = mkdtempSync(join(tmpdir(), "antgrid-dismiss-wire-proj-")); + writeFileSync(join(f, "antgrid.yaml"), "name: test-dismiss-wire\nagent:\n tool: claude-code\n"); + folders.push(f); + return f; +} + +async function waitFor(pred: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (pred()) return true; + await new Promise((r) => setTimeout(r, 15)); + } + return pred(); +} + +interface StatusFrame { + sessions: Array<{ terminalId: string; state: string; escalations: Array<{ escalationId: string }> }>; +} +const statuses = (sent: AbMessage[]) => + sent.filter((m) => m.type === "handler:status") as never as StatusFrame[]; + +test("handler:dismiss reaches the engine, and a malformed one resyncs without disarming", async () => { + const folder = tempFolder(); + core = await buildAgentCore({ + folder, + mode: "local", + identity: { deviceId: "agent-dev", deviceName: "agent-dev", createdAt: new Date().toISOString() }, + }); + + // Raising a real report needs a judge spawn, so the row is planted on disk and + // rehydrated by the arm below — the same path a bridge restart takes, and the + // one that carries a report across it. + const rec: HandlerSessionRecord = { + version: 2, terminalId: "t1", armed: false, suspended: true, + goal: "migrate auth", backlog: [], notifyOnly: true, armedAt: 1, + escalations: [{ + escalationId: "b1", question: "Handler did not send its reply", + reasoning: "reply contains control characters", draftReply: "yes", + urgency: "normal", kind: "guard_blocked", at: 2, + }], + }; + mkdirSync(join(core.abDir, "agents", core.projectId), { recursive: true }); + writeFileSync( + join(core.abDir, "agents", core.projectId, "handler-session-t1.json"), + JSON.stringify(rec), + ); + + const bus = new MessageBus(); + const sent: AbMessage[] = []; + bus.subscribe({ deliver: (m) => sent.push(m) }); + core.attachTransport(bus); + core.onHandshakeComplete(); + await waitFor(() => sent.some((m) => m.type === "agent:status")); + + bus.dispatchInbound(createMessage("handler:configure", { + projectId: core.projectId, terminalId: "t1", armed: true, notifyOnly: true, + }), "control", "loopback"); + expect(await waitFor(() => statuses(sent).some((s) => s.sessions.length === 1))).toBe(true); + // A report survives the suspend→re-arm gap intact: it names nothing in the + // runtime that went away. + expect(statuses(sent).at(-1)!.sessions[0]!.escalations.map((e) => e.escalationId)).toEqual(["b1"]); + expect(statuses(sent).at(-1)!.sessions[0]!.state).toBe("needs_you"); + + // A malformed payload must not tear down the armed session it names a row on. + // Its resync emit is invisible here (the bus drops a byte-identical replay + // frame), so the disarm is ruled out by the next state change instead: arming a + // second slot must still report the first, with its row. + sent.length = 0; + bus.dispatchInbound({ + ...createMessage("handler:dismiss", { + projectId: core.projectId, terminalId: "t1", escalationId: "b1", + }), + escalationId: 7, + } as never, "control", "loopback"); + bus.dispatchInbound(createMessage("handler:configure", { + projectId: core.projectId, terminalId: "t2", armed: true, notifyOnly: true, + }), "control", "loopback"); + expect(await waitFor(() => statuses(sent).some((s) => s.sessions.length === 2))).toBe(true); + const armed = statuses(sent).at(-1)!.sessions; + expect(armed.map((x) => x.terminalId).sort()).toEqual(["t1", "t2"]); + expect(armed.find((x) => x.terminalId === "t1")!.escalations).toHaveLength(1); + + // The well-formed one reaches the engine, retires the row, and rests the slot. + sent.length = 0; + bus.dispatchInbound(createMessage("handler:dismiss", { + projectId: core.projectId, terminalId: "t1", escalationId: "b1", + }), "control", "loopback"); + expect(await waitFor(() => statuses(sent).some( + (s) => s.sessions.find((x) => x.terminalId === "t1")?.escalations.length === 0, + ))).toBe(true); + const after = statuses(sent).at(-1)!.sessions.find((x) => x.terminalId === "t1")!; + expect(after.state).toBe("watching"); + expect(statuses(sent).every((s) => s.sessions.length === 2)).toBe(true); +}); diff --git a/bridge/tests/handler/engine.test.ts b/bridge/tests/handler/engine.test.ts index 05bb87a1..9551cb04 100644 --- a/bridge/tests/handler/engine.test.ts +++ b/bridge/tests/handler/engine.test.ts @@ -14,6 +14,8 @@ import type { InstructionItem, ItemTransition } from "../../src/handler/backlog" import { MAX_ITEM_CHARS, type ExtractedItem } from "../../src/handler/extract"; import type { HandlerSessionRecord } from "../../src/handler/session-store"; import { MAX_STORED, type StoredSnapshot } from "../../src/handler/snapshot-store"; +import type { InjectCommand } from "../../src/handler/session-adapter"; +import type { CapCommand } from "../../src/structured/chat-session"; import { planSnapshots, type SnapshotEntry, type SnapshotOutcome } from "../../src/handler/snapshot"; const GOAL = "Migrate auth"; @@ -31,6 +33,25 @@ function sessionRecord(over: Partial = {}): HandlerSession interface FakeTimer { ms: number; fn: () => void; cancelled: boolean; fired: boolean } +// A terminal transition's evidence is graded against the material the judge was +// shown, so a fixture citing text the fake output never contained has every +// transition in this file refused — and the suite then passes while asserting +// nothing about the transitions it thinks it is exercising. This is that +// material, written out once: deliberately NOT derived from whatever a test +// happens to cite, because a corpus that echoes the test's own evidence makes +// the gate unfalsifiable here. +const EVIDENCE_TAIL = [ + "tests passed", + "already applied upstream", + "ran to completion", + "moot after the rewrite", + "shipped to main", + "compiler said no", + "merged upstream", + "no longer needed", + "reverted by hand", +].join("\n"); + function makeEngine(overrides: Record = {}) { const sent: AbMessage[] = []; const injected: Array<[string, string]> = []; @@ -43,14 +64,22 @@ function makeEngine(overrides: Record = {}) { // records, and every test in this file arms at least one session. let stored: StoredSnapshot[] = []; const trashed: string[] = []; + // Every judged pause in production follows fresh agent output, so a CONSTANT + // tail would make two distinct pauses indistinguishable to the staleness guard + // (engine.ts's lastJudgedContextHash) and collapse the second into a skip. That + // is a fixture artifact, not a scenario — the suites below fire several events + // per session on purpose. + let ptyReads = 0; const engine = new HandlerEngine({ projectId: "proj", projectPath: () => "/proj", tool: () => "claude-code", abDir: "/tmp/unused", adapter: { - injectReply: (id: string, t: string) => injected.push([id, t]), - recentOutput: () => "pty-tail", + injectReply: (id: string, t: string) => { injected.push([id, t]); }, + // The counter stays LAST so outputSnippet's last-three-lines rule still sees + // it: a notify-only escalation asserts on "pty-tail" reaching the phone. + recentOutput: () => `${EVIDENCE_TAIL}\npty-tail ${ptyReads++}`, transcriptPath: () => "/t.jsonl", outputKind: () => "pty", - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, sendAb: (m: AbMessage) => sent.push(m), sendPush: (m: string) => pushes.push(m), @@ -735,7 +764,7 @@ describe("backlog transitions", () => { it("a completed item is one-way: a later transition on it is rejected", async () => { const { engine, activity } = makeEngine({ runDecisionFn: async () => decide({ - transitions: [{ id: "a", status: "done", evidence: "ran" }], + transitions: [{ id: "a", status: "done", evidence: "ran to completion" }], }), }); engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a"), item("b")], notifyOnly: false }); @@ -751,7 +780,7 @@ describe("backlog transitions", () => { const progressed: string[] = []; const orig = guard.recordProgress.bind(guard); guard.recordProgress = (id: string) => { progressed.push(id); orig(id); }; - let transitions: ItemTransition[] = [{ id: "a", status: "skipped", evidence: "moot" }]; + let transitions: ItemTransition[] = [{ id: "a", status: "skipped", evidence: "moot after the rewrite" }]; const { engine } = makeEngine({ guard, runDecisionFn: async () => decide({ transitions }) }); engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a"), item("b")], notifyOnly: false }); @@ -760,11 +789,104 @@ describe("backlog transitions", () => { // backlog could hold the consecutive-auto-reply cap open forever. expect(progressed).toEqual([]); - transitions = [{ id: "b", status: "done", evidence: "shipped" }]; + transitions = [{ id: "b", status: "done", evidence: "shipped to main" }]; await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); expect(progressed).toEqual(["t1"]); }); + // Claude's post-completion idle nudge raises a second handler event over output + // the judge has already ruled on (work-status.ts filters it for the status dot; + // the /handler-event path does not). A second verdict on identical evidence is + // drawn from noise, and one such pass marked an item `done` off a re-read of a + // message it had already judged. + describe("stale-context guard", () => { + const frozen = { + // Constant BETWEEN passes, which is the point here — and long enough to + // ground the citation these tests move an item on. + recentOutput: () => "same tail · shipped to main", + transcriptPath: () => undefined, + }; + + it("skips a second pass over context the judge has already ruled on", async () => { + let judged = 0; + const { engine, sent } = makeEngine({ + adapter: { injectReply: () => {}, outputKind: () => "pty", commandCatalog: () => undefined, ...frozen }, + runDecisionFn: async () => { judged++; return decide({ decision: "escalate" }); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "t1", event: "awaiting_input" }); + expect(judged).toBe(1); + // Skipped, not parked or re-escalated: the open row already says what a + // second one would. + expect(statusOf(sent).pendingEscalations).toBe(1); + expect(statusOf(sent).state).toBe("needs_you"); + }); + + // The incident this guard exists for. The first pass marked the DEPENDENCY + // done, which unblocked the item behind it; keying the hash on the backlog too + // would read that as news and hand the judge a second look at the same message + // — where it found a sentence that merely resembled the unblocked item. + it("a backlog move of its own is not news enough to re-judge", async () => { + let judged = 0; + const transitions: ItemTransition[] = [{ id: "a", status: "done", evidence: "shipped to main" }]; + const { engine } = makeEngine({ + adapter: { injectReply: () => {}, outputKind: () => "pty", commandCatalog: () => undefined, ...frozen }, + runDecisionFn: async () => { judged++; return decide({ transitions }); }, + }); + engine.arm({ + terminalId: "t1", goal: GOAL, notifyOnly: false, + backlog: [item("a"), item("b", { dependsOn: ["a"] })], + }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "t1", event: "awaiting_input" }); + expect(judged).toBe(1); + }); + + it("a judge outage still re-judges the pause it failed on", async () => { + let judged = 0; + const { engine, timers } = makeEngine({ + adapter: { injectReply: () => {}, outputKind: () => "pty", commandCatalog: () => undefined, ...frozen }, + runDecisionFn: async () => { judged++; if (judged === 1) throw new Error("judge down"); return decide({}); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(judged).toBe(1); + // The outage park re-runs THIS event on wake. Banking the hash before a + // verdict came back would make that retry skip the very pause it exists for, + // and no further event would raise it. + timers.at(-1)!.fn(); + await drain(); + expect(judged).toBe(2); + }); + + it("a submitted line reopens the pass the guard would have skipped", async () => { + let judged = 0; + const { engine } = makeEngine({ + adapter: { injectReply: () => {}, outputKind: () => "pty", commandCatalog: () => undefined, ...frozen }, + runDecisionFn: async () => { judged++; return decide({ decision: "escalate" }); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + engine.onUserReply("t1", "carry on\r"); + await engine.handleEvent({ terminalId: "t1", event: "awaiting_input" }); + expect(judged).toBe(2); + }); + + it("a re-arm reopens it too — the user restated what the session is for", async () => { + let judged = 0; + const { engine } = makeEngine({ + adapter: { injectReply: () => {}, outputKind: () => "pty", commandCatalog: () => undefined, ...frozen }, + runDecisionFn: async () => { judged++; return decide({ decision: "escalate" }); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + engine.arm({ terminalId: "t1", goal: "a different goal", backlog: [item("a")], notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "awaiting_input" }); + expect(judged).toBe(2); + }); + }); + it("failing a dependency derives a block on its dependents and names what they wait on", async () => { const { engine, sent, activity } = makeEngine({ runDecisionFn: async () => decide({ @@ -799,6 +921,258 @@ describe("backlog transitions", () => { }); }); +// A non-empty `evidence` string is not a citation: a judge clears that by +// paraphrasing, inventing, or — the incident these tests exist for — quoting a +// real sentence about something else entirely. The gate grades the +// quote against the context THIS pass was judged on, and against the command the +// item names. What it cannot do is judge attribution; that limit is stated in +// backlog.ts and pinned in backlog.test.ts. +describe("evidence citations", () => { + const PTY = { + injectReply: () => {}, + transcriptPath: () => undefined, + outputKind: () => "pty" as const, + commandCatalog: () => undefined, + }; + + it("grades the citation against THIS pass's context, not a pass already gone", async () => { + // Scrollback moves on. A quote that was honest two passes ago is no longer + // checkable, and accepting it would make the corpus the whole session's + // history — which is not the window the judge is reasoning over. + const tails = ["the migration landed cleanly", "compiling the workspace now"]; + let n = 0; + let transitions: ItemTransition[] = []; + const { engine, sent } = makeEngine({ + adapter: { ...PTY, recentOutput: () => tails[n++] ?? "" }, + runDecisionFn: async () => decide({ transitions }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + transitions = [{ id: "a", status: "done", evidence: "the migration landed cleanly" }]; + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(statusOf(sent).backlog[0].status).toBe("queued"); + }); + + it("writes one evidence_rejected row carrying the item and the reason, and no item_done", async () => { + const { engine, activity } = makeEngine({ + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: "everything is finished and green" }], + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + const rows = records(activity, "evidence_rejected") as Array<{ reason: string; detail?: string }>; + expect(rows).toHaveLength(1); + expect(rows[0].reason).toBe("item a"); + expect(rows[0].detail).toContain("not in the context"); + expect(records(activity, "item_done")).toHaveLength(0); + }); + + it("a repeat refusal on the same item is logged again but not fed to the user twice", async () => { + // One row per ITEM: the feed is a history of what happened to the backlog, + // not a transcript of how many ways the judge tried to close one line. + const { engine, activity } = makeEngine({ + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: "everything is finished and green" }], + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); + const logged = await capturingWarnings(async () => { + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + }); + expect(records(activity, "evidence_rejected")).toHaveLength(1); + expect(logged.match(/not in the context/g)).toHaveLength(2); + }); + + it("a refused completion lifts no runaway cap and wraps nothing up", async () => { + // The failure direction of the whole gate: the session stays open with the + // item unfinished, rather than the user being told work landed that did not. + const guard = new RunawayGuard(); + const progressed: string[] = []; + const orig = guard.recordProgress.bind(guard); + guard.recordProgress = (id: string) => { progressed.push(id); orig(id); }; + const { engine, sent, activity } = makeEngine({ + guard, + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: "I completed the whole backlog" }], + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(progressed).toEqual([]); + expect(records(activity, "wrapped_up")).toHaveLength(0); + expect(statusOf(sent).backlog[0].status).toBe("queued"); + }); + + it("feeds the refusals into the next decide prompt, keeping only the most recent few", async () => { + const seen: Array = []; + let transitions: ItemTransition[] = []; + const { engine } = makeEngine({ + runDecisionFn: async (o: { evidenceRejections?: string[] }) => { + seen.push(o.evidenceRejections ? [...o.evidenceRejections] : undefined); + return decide({ transitions }); + }, + }); + engine.arm({ + terminalId: "t1", goal: GOAL, notifyOnly: false, + backlog: ["a", "b", "c", "d"].map((id) => item(id)), + }); + for (const id of ["a", "b", "c", "d"]) { + transitions = [{ id, status: "done", evidence: `nothing on record about ${id}` }]; + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + } + transitions = []; + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + + expect(seen[0]).toEqual([]); + expect(seen[1]?.[0]).toContain("item a"); + const last = seen.at(-1)!; + expect(last).toHaveLength(3); + // The oldest is what falls off: a stale refusal teaches less than a fresh one. + expect(last[0]).toContain("item b"); + expect(last[2]).toContain("item d"); + }); + + it("a grounded, anchored completion still wraps up and pushes", async () => { + // The gate is a narrowing, not a blanket refusal — an honest citation of the + // command the item named closes it exactly as before. + const { engine, activity, pushes } = makeEngine({ + adapter: { + ...PTY, + recentOutput: () => "> /code-review --fix\nreview complete, no findings", + }, + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: "> /code-review --fix" }], + }), + }); + engine.arm({ + terminalId: "t1", goal: GOAL, notifyOnly: false, + backlog: [item("a", { text: "run /code-review --fix" })], + }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(records(activity, "wrapped_up")).toHaveLength(1); + expect(pushes).toHaveLength(1); + }); + + it("refuses a real quote about a different review on a command-shaped item", async () => { + // The reported incident, end to end: the sentence is genuinely in the context + // and says nothing about the command the item asked for. + const { engine, sent, activity } = makeEngine({ + adapter: { + ...PTY, + recentOutput: () => "running my own review of the changes\nreview complete, no findings", + }, + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: "review complete, no findings" }], + }), + }); + engine.arm({ + terminalId: "t1", goal: GOAL, notifyOnly: false, + backlog: [item("a", { text: "run /code-review --fix" })], + }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(statusOf(sent).backlog[0].status).toBe("queued"); + expect(records(activity, "wrapped_up")).toHaveLength(0); + const rows = records(activity, "evidence_rejected") as Array<{ detail?: string }>; + expect(rows[0]?.detail).toContain("/code-review"); + }); + + // `commandTokens` reads shape alone, so a route or a path segment in the user's + // own wording anchors an item to a quote no honest sentence can contain. Left + // alone that is permanent: the item never closes, wrap-up never fires, no + // progress is banked, and the runaway cap eventually raises a report the user + // has to dismiss by hand. Two answers, and the session gets whichever it can. + describe("an item whose text carries a command-shaped token that is not a command", () => { + const ROUTE = "Fix the /login redirect so it lands on the dashboard"; + const LANDED = "LoginRedirect.tsx updated; login now redirects to /dashboard"; + + /** Fresh output each pass — a repeated context is skipped unjudged by + * lastJudgedContextHash, which would stall the refusal count this exercises. */ + function routeEngine(over: Record = {}) { + let n = 0; + return makeEngine({ + adapter: { ...PTY, recentOutput: () => `${LANDED} +pass ${n++}` }, + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: LANDED }], + }), + ...over, + }); + } + + it("closes on the first pass when the session's catalog says the token is no command", async () => { + const { engine, activity } = routeEngine({ + adapter: { + ...PTY, + recentOutput: () => LANDED, + commandCatalog: () => [{ id: "cmd:code-review", name: "code-review" }], + }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false, backlog: [item("a", { text: ROUTE })] }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(records(activity, "wrapped_up")).toHaveLength(1); + }); + + it("gives up the anchor after a bounded number of refusals when there is no catalog", async () => { + // A PTY has no catalog and cannot get one, so nothing can prove the token is + // not a command — the anchor is asked for and then, once it has plainly gone + // unanswered, dropped. Grounding is what still holds. + const guard = new RunawayGuard(); + const progressed: string[] = []; + const orig = guard.recordProgress.bind(guard); + guard.recordProgress = (id: string) => { progressed.push(id); orig(id); }; + const { engine, sent, activity } = routeEngine({ guard }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false, backlog: [item("a", { text: ROUTE })] }); + for (let i = 0; i < 3; i++) await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(statusOf(sent).backlog[0].status).toBe("queued"); + expect(progressed).toEqual([]); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(records(activity, "wrapped_up")).toHaveLength(1); + expect(progressed).toEqual(["t1"]); + }); + + it("the waiver buys nothing for a quote that is not in the context", async () => { + // What is waived is the demand for a token, never the demand for a citation. + let n = 0; + const { engine, sent } = makeEngine({ + adapter: { ...PTY, recentOutput: () => `${LANDED} +pass ${n++}` }, + runDecisionFn: async () => decide({ + transitions: [{ id: "a", status: "done", evidence: "the redirect works now" }], + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false, backlog: [item("a", { text: ROUTE })] }); + for (let i = 0; i < 5; i++) await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(statusOf(sent).backlog[0].status).toBe("queued"); + }); + }); + + it("stops telling the judge an item is still open once it has closed", async () => { + // The section is headed "those items are still open". An entry that outlives + // its item's completion contradicts the BACKLOG block in the same prompt and + // asks for a re-citation of work already banked. + const seen: Array = []; + let evidence = "nothing on record about a"; + let n = 0; + const { engine } = makeEngine({ + adapter: { ...PTY, recentOutput: () => `merged upstream +pass ${n++}` }, + runDecisionFn: async (o: { evidenceRejections?: string[] }) => { + seen.push(o.evidenceRejections ? [...o.evidenceRejections] : undefined); + return decide({ transitions: [{ id: "a", status: "done", evidence }] }); + }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false, backlog: [item("a"), item("b")] }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + evidence = "merged upstream"; + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(seen[1]?.[0]).toContain("item a"); + expect(seen[2]).toEqual([]); + }); +}); + describe("wrap-up", () => { it("wraps up once every item is terminal, including failed and skipped ones", async () => { // The deadlock fix: an item nobody could reach used to hold the session @@ -808,7 +1182,7 @@ describe("wrap-up", () => { sendPush: (m: string) => pushes.push(m), runDecisionFn: async () => decide({ transitions: [ - { id: "a", status: "done", evidence: "merged" }, + { id: "a", status: "done", evidence: "merged upstream" }, { id: "b", status: "failed", evidence: "compiler said no" }, { id: "c", status: "skipped", evidence: "no longer needed" }, ], @@ -839,7 +1213,7 @@ describe("wrap-up", () => { const { engine } = makeEngine({ sendPush: (m: string) => pushes.push(m), runDecisionFn: async () => decide({ - transitions: ["a", "b", "c", "d"].map((id) => ({ id, status: "skipped" as const, evidence: "moot" })), + transitions: ["a", "b", "c", "d"].map((id) => ({ id, status: "skipped" as const, evidence: "moot after the rewrite" })), }), }); engine.arm({ @@ -863,7 +1237,7 @@ describe("wrap-up", () => { const { engine, injected, activity } = makeEngine({ runDecisionFn: async () => decide({ decision: "handle", reply: "yes, finish", - transitions: [{ id: "a", status: "done", evidence: "ran" }], + transitions: [{ id: "a", status: "done", evidence: "ran to completion" }], }), }); engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); @@ -876,7 +1250,7 @@ describe("wrap-up", () => { const { engine, sent, activity } = makeEngine({ runDecisionFn: async () => decide({ decision: "escalate", - transitions: [{ id: "a", status: "done", evidence: "ran" }], + transitions: [{ id: "a", status: "done", evidence: "ran to completion" }], }), }); engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); @@ -891,7 +1265,7 @@ describe("wrap-up", () => { // backlog, so without the guard the next continue auto-disarms and silently // buries the unanswered question. let d: HandlerDecision = decide({ - decision: "escalate", transitions: [{ id: "a", status: "done", evidence: "ran" }], + decision: "escalate", transitions: [{ id: "a", status: "done", evidence: "ran to completion" }], }); const { engine, sent, activity } = makeEngine({ runDecisionFn: async () => d }); engine.arm({ terminalId: "t1", goal: GOAL, backlog: [item("a")], notifyOnly: false }); @@ -941,44 +1315,56 @@ describe("chat blocking prompts and slash guard", () => { expect(esc.kind).toBeUndefined(); }); - it("slash_command handle escalates instead of injecting when the adapter refuses", async () => { - const injected: Array<[string, string]> = []; - const { engine, sent } = makeEngine({ - adapter: { - injectReply: (id: string, t: string) => injected.push([id, t]), - recentOutput: () => "", - transcriptPath: () => undefined, - outputKind: () => "pty", - supportsSlashCommands: () => false, - }, + it("a bare slash command injects as typed", async () => { + const { engine, sent, injected } = makeEngine({ + // Default projectPath "/proj" (not "/") — this is the real production shape, + // and relies on the engine withholding the VERB from classifyDestructive's + // pathCheckText, or "/compact" reads as an out-of-project path. runDecisionFn: async () => decide({ decision: "handle", action: { kind: "slash_command", value: "/compact" } }), }); engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); - expect(injected).toHaveLength(0); - expect(sent.some((m) => m.type === "handler:escalation")).toBe(true); + expect(injected).toEqual([["c1", "/compact"]]); + expect(sent.some((m) => m.type === "handler:escalation")).toBe(false); }); - it("slash_command handle injects via adapter when slash commands are supported", async () => { + it("flattens a multi-line reply into the one line it will submit as", async () => { + // The control-char guard exists to stop several commands riding in on one + // decision, not to refuse paragraph breaks — and a judge asked to stand in for + // the user writes prose. Every such reply escalated before this. const { engine, sent, injected } = makeEngine({ - // Default projectPath "/proj" (not "/") — this is the real production shape, - // and relies on classifyDestructive's pathCheckText scoping (destructive-floor.ts) - // to not misread the "/compact" action value as an out-of-project path. - // makeEngine()'s default adapter already has supportsSlashCommands: () => true. - runDecisionFn: async () => - decide({ decision: "handle", action: { kind: "slash_command", value: "/compact" } }), + runDecisionFn: async () => decide({ + decision: "handle", + reply: "Good call on defect 3.\n\nDig deeper before you fix it.", + }), }); - engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); - await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); - expect(injected).toEqual([["c1", "/compact"]]); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toEqual([["t1", "Good call on defect 3. Dig deeper before you fix it."]]); expect(sent.some((m) => m.type === "handler:escalation")).toBe(false); }); + it("still escalates a reply carrying a control char that is not whitespace", async () => { + // Ctrl-C/EOF/escape have no formatting reading: flattening must not launder them + // into keystrokes the judge gets to send unsupervised. + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => decide({ + decision: "handle", + reply: "pick option two\x1b[B", + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { reasoning: string }; + expect(esc.reasoning).toBe("reply contains control characters"); + }); + it("slash_command handle escalates instead of injecting when the value is not a simple verb", async () => { // action.value is judge-generated free text with no allowlist (decision.ts) — a - // hallucinating judge could shape it like a path. This must escalate rather than - // reach classifyDestructive, whose path check is scoped away from action values. + // hallucinating judge could shape it like a path. The "/" sits inside the first + // token, so the verb itself fails the shape rule and nothing is injected. const injected: Array<[string, string]> = []; const { engine, sent } = makeEngine({ adapter: { @@ -986,7 +1372,7 @@ describe("chat blocking prompts and slash guard", () => { recentOutput: () => "", transcriptPath: () => undefined, outputKind: () => "pty", - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, runDecisionFn: async () => decide({ decision: "handle", action: { kind: "slash_command", value: "/etc/hosts" } }), @@ -997,6 +1383,259 @@ describe("chat blocking prompts and slash guard", () => { expect(sent.some((m) => m.type === "handler:escalation")).toBe(true); }); + // A judge that emitted "/code-review --fix" used to escalate on the whole-value + // verb rule, so no "handle" carrying arguments ever reached an agent. + describe("slash command arguments", () => { + it("an argument tail injects the whole line", async () => { + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => + decide({ decision: "handle", action: { kind: "slash_command", value: "/code-review --fix" } }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toEqual([["t1", "/code-review --fix"]]); + expect(sent.some((m) => m.type === "handler:escalation")).toBe(false); + }); + + it("a malformed verb still escalates when it carries arguments", async () => { + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => + decide({ decision: "handle", action: { kind: "slash_command", value: "/etc/hosts --force" } }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { reasoning: string }; + expect(esc.reasoning).toBe("slash command value is not a simple verb"); + }); + + it("the floor sees an absolute path in the argument tail", async () => { + // The whole reason the tail joins pathCheckText: withholding it would wave + // through the one half of a slash command that CAN name a path. + const { engine, activity, injected } = makeEngine({ + runDecisionFn: async () => + decide({ decision: "handle", action: { kind: "slash_command", value: "/review /etc/passwd" } }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + const rows = records(activity, "floor_warning") as Array<{ reason: string }>; + expect(rows.map((r) => r.reason)).toEqual([ + "absolute path outside project: /etc/passwd", + ]); + // Advisory, per §5.1: the warning is the outcome, not a block. + expect(injected).toEqual([["t1", "/review /etc/passwd"]]); + }); + + it("the verb itself raises no floor warning", async () => { + const { engine, activity, injected } = makeEngine({ + runDecisionFn: async () => + decide({ decision: "handle", action: { kind: "slash_command", value: "/compact" } }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(records(activity, "floor_warning")).toHaveLength(0); + expect(injected).toEqual([["t1", "/compact"]]); + }); + + it("a hard pattern in the argument tail still blocks", async () => { + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => + decide({ decision: "handle", action: { kind: "slash_command", value: "/run mkfs.ext4 /dev/sdb" } }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { floorRule?: string }; + expect(esc.floorRule).toContain("mkfs.ext4"); + }); + }); + + // Setting both used to drop `reply` silently — unvalidated, unguarded and never + // sent — while the guards inspected only the action that won. + describe("reply and action are exclusive", () => { + it("setting both escalates and injects nothing", async () => { + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => decide({ + decision: "handle", reply: "carry on", + action: { kind: "slash_command", value: "/compact" }, + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as + { reasoning: string; draftReply: string }; + expect(esc.reasoning).toBe("set either reply or action, not both"); + // What makes the discard non-silent: the reply the old code dropped + // unvalidated and unsent is the draft the user is shown and can edit. + expect(esc.draftReply).toBe("carry on"); + }); + + it("an action of kind none beside a reply is not both", async () => { + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => decide({ + decision: "handle", reply: "carry on", action: { kind: "none", value: "" }, + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toEqual([["t1", "carry on"]]); + expect(sent.some((m) => m.type === "handler:escalation")).toBe(false); + }); + + it("a whitespace-only reply beside a command is not both", async () => { + const { engine, injected } = makeEngine({ + runDecisionFn: async () => decide({ + decision: "handle", reply: " \n ", + action: { kind: "slash_command", value: "/compact" }, + }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toEqual([["t1", "/compact"]]); + }); + + it("an empty reply with no action still escalates", async () => { + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => decide({ decision: "handle", reply: " " }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { reasoning: string }; + expect(esc.reasoning).toBe("empty reply"); + }); + }); + + // The catalog is the whole reliability rule: a populated one bounds what the + // judge may name, an absent one bounds nothing, and there is no third state. + describe("command catalog", () => { + const CATALOG: CapCommand[] = [{ id: "cmd:code-review", name: "code-review" }]; + + function withCatalog(catalog: CapCommand[] | undefined, value: string) { + const injected: Array<[string, string]> = []; + const commands: Array = []; + const engine = makeEngine({ + adapter: { + injectReply: (id: string, t: string, c?: InjectCommand) => { + injected.push([id, t]); + commands.push(c); + }, + recentOutput: () => "pty-tail", + transcriptPath: () => undefined, + outputKind: () => "pty", + commandCatalog: () => catalog, + }, + runDecisionFn: async () => + decide({ decision: "handle", action: { kind: "slash_command", value } }), + }); + return { ...engine, injected, commands }; + } + + it("a catalog hit routes on the driver's own command id with the tail as its text", async () => { + const { engine, sent, injected, commands } = withCatalog(CATALOG, "/code-review --fix"); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + expect(injected).toEqual([["c1", "/code-review --fix"]]); + expect(commands).toEqual([{ id: "cmd:code-review", args: "--fix" }]); + expect(sent.some((m) => m.type === "handler:escalation")).toBe(false); + }); + + it("a verb outside a populated catalog escalates and injects nothing", async () => { + const { engine, sent, injected } = withCatalog(CATALOG, "/invented --fix"); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { reasoning: string }; + expect(esc.reasoning).toContain("/invented"); + }); + + it("membership is matched on the verb, never on the argument tail", async () => { + const { engine, injected } = withCatalog(CATALOG, "/fix code-review"); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + expect(injected).toHaveLength(0); + }); + + it("with no catalog an invented verb reaches the agent as plain text", async () => { + // The user's explicit choice for PTY: the agent rejects it visibly, which + // lands in the next context, rather than the supervisor refusing in advance. + const { engine, sent, injected, commands } = withCatalog(undefined, "/invented arg"); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + expect(injected).toEqual([["c1", "/invented arg"]]); + expect(commands).toEqual([undefined]); + expect(sent.some((m) => m.type === "handler:escalation")).toBe(false); + }); + + it("the decide prompt gets the catalog and the SUPERVISED tool, not the judge's", async () => { + const opts: Array<{ tool: string; agentTool?: string; commands?: CapCommand[] }> = []; + const { engine } = makeEngine({ + tool: () => "claude-code", + adapter: { + injectReply: () => {}, + recentOutput: () => "pty-tail", + transcriptPath: () => undefined, + outputKind: () => "pty", + commandCatalog: () => CATALOG, + }, + runDecisionFn: async (o: { tool: string; agentTool?: string; commands?: CapCommand[] }) => { + opts.push(o); + return decide({}); + }, + }); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false, judgeTool: "codex" }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + expect(opts[0]!.tool).toBe("codex"); + expect(opts[0]!.agentTool).toBe("claude-code"); + expect(opts[0]!.commands).toEqual(CATALOG); + }); + }); + + // The shape retry lives inside runDecision and is invisible through this stub; + // what these pin is that the engine adds no retry of its own around a SAFETY + // verdict, where a second ask would be a bypass rather than a correction. + describe("safety verdicts are never re-asked", () => { + it("a hard-floor rejection spends exactly one judge call", async () => { + let judged = 0; + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => { judged++; return decide({ decision: "handle", reply: "mkfs.ext4 /dev/sdb" }); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(judged).toBe(1); + expect(injected).toHaveLength(0); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { floorRule?: string }; + expect(esc.floorRule).toContain("mkfs.ext4"); + }); + + it("a runaway-guard rejection spends exactly one judge call on the capped pass", async () => { + let judged = 0; + const { engine, sent, injected } = makeEngine({ + runDecisionFn: async () => { judged++; return decide({ decision: "handle", reply: "same again" }); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(judged).toBe(2); + expect(injected).toEqual([["t1", "same again"]]); + const esc = sent.find((m) => m.type === "handler:escalation") as never as { reasoning: string }; + expect(esc.reasoning).toContain("circular exchange"); + }); + + it("an advisory floor warning spends one judge call and still injects", async () => { + let judged = 0; + const { engine, activity, injected } = makeEngine({ + runDecisionFn: async () => { judged++; return decide({ decision: "handle", reply: "rm -rf node_modules" }); }, + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(judged).toBe(1); + expect(records(activity, "floor_warning")).toHaveLength(1); + expect(injected).toEqual([["t1", "rm -rf node_modules"]]); + }); + }); + it("judges an isolated session in its own checkout, and floors paths by that checkout", async () => { // An isolated session's agent runs inside a managed worktree. If the judge // and the destructive floor stayed on the main checkout, the floor would @@ -1011,7 +1650,7 @@ describe("chat blocking prompts and slash guard", () => { recentOutput: () => "pty-tail", transcriptPath: () => undefined, outputKind: () => "pty", - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, runDecisionFn: async (args: { cwd?: string }) => { cwds.push(args.cwd); @@ -1040,7 +1679,7 @@ describe("chat blocking prompts and slash guard", () => { recentOutput: () => "pty-tail", transcriptPath: () => undefined, outputKind: () => "pty", - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, runDecisionFn: async () => decide({ decision: "handle", reply: "rm /proj/src/main.ts" }), }); @@ -1131,7 +1770,7 @@ describe("chat blocking prompts and slash guard", () => { recentOutput: async () => { aStarted = true; await gate; return "pty-tail"; }, transcriptPath: () => undefined, outputKind: () => "pty", - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, runDecisionFn: async () => decide({}), }); @@ -1219,7 +1858,7 @@ test("decide context for codex resolves the rollout path for the judge", async ( adapter: { injectReply: () => {}, recentOutput: () => "pty-tail", transcriptPath: () => undefined, outputKind: () => "pty" as const, - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, runDecisionFn: async (o: { context: string; transcriptPath?: string }) => { decideCalls.push({ context: o.context, transcriptPath: o.transcriptPath }); @@ -1256,7 +1895,7 @@ test("opencode decide context reads the db but hands the judge no path", async ( adapter: { injectReply: () => {}, recentOutput: () => "pty-tail", transcriptPath: () => undefined, outputKind: () => "pty" as const, - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, runDecisionFn: async (o: { context: string; transcriptPath?: string }) => { decideCalls.push({ context: o.context, transcriptPath: o.transcriptPath }); @@ -1330,6 +1969,16 @@ describe("quick-choice escalations (§4.6)", () => { expect(choicesOf(sent)).toBeUndefined(); }); + // quickChoicesFor passes no pathCheckText at all, so ABS_PATH's own reading is the + // only thing between a slash command in the draft and the loss of both chips. A + // misreading here spends a real affordance, not merely a warning row. + it("a draft naming a slash command is still offered as a one-tap", async () => { + const { engine, sent } = makeEngine(escalatingWith("Run /code-review before merging.")); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "awaiting_input" }); + expect(choicesOf(sent)!.map((c) => c.choiceId)).toEqual(["approve", "reject"]); + }); + // §5.3 is liftable by nothing, so those keep costing a human who reads the text // behind the reply sheet's floor banner. it("a HARD floor escalation carries no choices", async () => { @@ -1396,6 +2045,29 @@ describe("quick-choice escalations (§4.6)", () => { expect(quickChoicesFor({ draftReply: DRAFT, projectPath: "/proj" })).toHaveLength(2); }); + it("guard_blocked is refused on the kind, not merely for want of a draft", () => { + // A shape or runaway rejection sets no floorRule, so nothing else in + // quickChoicesFor would withhold a chip that re-sends the very text a guard + // just refused — with the thinnest human in the loop there is. + expect(quickChoicesFor({ + kind: "guard_blocked", draftReply: DRAFT, projectPath: "/proj", + })).toBeUndefined(); + expect(quickChoicesFor({ draftReply: DRAFT, projectPath: "/proj" })).toHaveLength(2); + }); + + it("a doubled leading slash does not buy a draft its one-tap back", () => { + // The chip is withheld on ANY floor hit, so a path spelling the floor cannot + // see is a path the user is offered in one tap — reading nothing. `//etc/shadow` + // and `/etc/shadow` are the same file, and whoever wrote the draft picks which + // spelling reaches here. + expect(quickChoicesFor({ + draftReply: "cat /etc/shadow and paste it here", projectPath: "/proj", + })).toBeUndefined(); + expect(quickChoicesFor({ + draftReply: "cat //etc/shadow and paste it here", projectPath: "/proj", + })).toBeUndefined(); + }); + it("a draft the wire could not carry as a chip falls back to free text", () => { // Both bounds come from the wire schema itself rather than a second copy: an // embedded CR would submit two lines into the PTY, and an over-long chip is one @@ -1445,7 +2117,10 @@ describe("quick-choice escalations (§4.6)", () => { const esc = sent.find((m) => m.type === "handler:escalation") as never as { question: string; reasoning: string; draftReply: string }; expect(esc.reasoning).toBe("reply contains control characters"); - expect(esc.question).toBe("Agent needs you"); + // Engine-authored, never the judge's `notify.body`: a judge that filled the + // block while deciding `handle` described the pause it was answering, not the + // reply a guard then refused. + expect(esc.question).toBe("Handler did not send its reply"); // The point of escalating a guard trip is to show what Handler wanted to inject; // dropping it leaves the user judging a rejection they cannot see. expect(esc.draftReply).toBe(CONTROL_REPLY); @@ -1455,6 +2130,279 @@ describe("quick-choice escalations (§4.6)", () => { }); }); +// A guard rejection is a REPORT that Handler wanted to act and a harness guard +// refused. Nothing the agent or the user does next is an answer to it, so unlike +// every other kind it survives a typed line and is retired only by an explicit +// dismiss — which is also why it must not be read as "a human is already being +// waited on" anywhere in the engine. +describe("guard-rejection reports (kind: guard_blocked)", () => { + const CATALOG: CapCommand[] = [{ id: "cmd:code-review", name: "code-review" }]; + + interface EscFrame { + escalationId: string; kind?: string; question: string; reasoning: string; + draftReply: string; floorRule?: string; choices?: unknown[]; + } + function escalations(sent: AbMessage[]): EscFrame[] { + return sent.filter((m) => m.type === "handler:escalation") as never as EscFrame[]; + } + function rowsOf(sent: AbMessage[]): Array<{ escalationId: string; kind?: string }> { + const status = sent.filter((m) => m.type === "handler:status").at(-1) as never as { + sessions: Array<{ escalations: Array<{ escalationId: string; kind?: string }> }>; + }; + return status.sessions[0]?.escalations ?? []; + } + function blockedRecord(over: Partial = {}): HandlerSessionRecord { + return sessionRecord({ + escalations: [{ + escalationId: "b0", question: "Handler did not send its reply", + reasoning: "reply contains control characters", draftReply: "yes\x1b[B", + urgency: "normal", at: 1, kind: "guard_blocked", + }], + ...over, + }); + } + // Same varying tail the shared fixture uses: a constant one would make two + // distinct pauses hash the same and the second would be skipped unjudged. + function withCatalog(overrides: Record = {}) { + const injected: Array<[string, string]> = []; + let reads = 0; + const engine = makeEngine({ + adapter: { + injectReply: (id: string, t: string) => { injected.push([id, t]); }, + recentOutput: () => `pty-tail ${reads++}`, + transcriptPath: () => undefined, + outputKind: () => "pty", + commandCatalog: () => CATALOG, + }, + ...overrides, + }); + return { ...engine, injected }; + } + + // The reported symptom's own source row: Handler picked a slash command the + // session's catalog does not carry, the shape guard refused it, and the user + // has to be able to learn that happened. + it("a shape rejection escalates with kind guard_blocked and no choices", async () => { + const { engine, sent, injected } = withCatalog({ + runDecisionFn: async () => decide({ + decision: "handle", action: { kind: "slash_command", value: "/invented --fix" }, + }), + }); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + expect(injected).toHaveLength(0); + const [esc] = escalations(sent); + expect(esc!.kind).toBe("guard_blocked"); + expect(esc!.question).toBe("Handler did not send its reply"); + expect(esc!.reasoning).toContain("/invented"); + expect(esc!.choices).toBeUndefined(); + expect(statusOf(sent).state).toBe("needs_you"); + }); + + it("a hard-floor rejection and a runaway rejection carry the same kind", async () => { + const floor = makeEngine({ + runDecisionFn: async () => decide({ decision: "handle", reply: "mkfs.ext4 /dev/sdb" }), + }); + floor.engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await floor.engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + const [floored] = escalations(floor.sent); + expect(floored!.kind).toBe("guard_blocked"); + // The §5.3 rule still rides the row: the card names which floor refused it. + expect(floored!.floorRule).toBeTruthy(); + + const runaway = makeEngine({ + runDecisionFn: async () => decide({ decision: "handle", reply: "carry on" }), + }); + runaway.engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await runaway.engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await runaway.engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(runaway.injected).toEqual([["t1", "carry on"]]); + const [circular] = escalations(runaway.sent); + expect(circular!.reasoning).toContain("circular exchange"); + expect(circular!.kind).toBe("guard_blocked"); + expect(circular!.floorRule).toBeUndefined(); + }); + + // The bug itself: an unrelated typed line took the report to disk with it, and + // the user never learned Handler had wanted to act. + it("a submitted line clears the reply rows beside a guard_blocked row and leaves it standing", async () => { + let d: HandlerDecision = decide({ decision: "handle", reply: "mkfs.ext4 /dev/sdb" }); + const { engine, sent, saved } = makeEngine({ runDecisionFn: async () => d }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + d = decide({ decision: "escalate" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + expect(rowsOf(sent).map((e) => e.kind)).toEqual(["guard_blocked", undefined]); + + engine.onUserReply("t1", "never mind, do something else\r"); + expect(rowsOf(sent).map((e) => e.kind)).toEqual(["guard_blocked"]); + expect(statusOf(sent).state).toBe("needs_you"); + // …and on disk, which is where the report vanished from before. + const rec = saved.at(-1) as HandlerSessionRecord; + expect(rec.escalations.map((e) => e.kind)).toEqual(["guard_blocked"]); + }); + + it("an id-less prompt retraction keeps guard_blocked rows", async () => { + // An id-less retraction means every PROMPT is gone; a report is not a prompt, + // and no driver ever had anything to withdraw. + let d: HandlerDecision = decide({ decision: "handle", reply: "mkfs.ext4 /dev/sdb" }); + const { engine, sent } = makeEngine({ runDecisionFn: async () => d }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + d = decide({ decision: "escalate" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + engine.onPromptRetracted("t1"); + expect(rowsOf(sent).map((e) => e.kind)).toEqual(["guard_blocked"]); + expect(statusOf(sent).state).toBe("needs_you"); + }); + + it("dismissEscalation retires exactly the named row and rests the session", async () => { + const { engine, sent, saved } = makeEngine({ + runDecisionFn: async () => decide({ decision: "handle", reply: "mkfs.ext4 /dev/sdb" }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + const id = escalations(sent)[0]!.escalationId; + engine.dismissEscalation("t1", id); + expect(rowsOf(sent)).toHaveLength(0); + expect(statusOf(sent).state).toBe("watching"); + expect((saved.at(-1) as HandlerSessionRecord).escalations).toEqual([]); + }); + + it("an unknown id, a reply row and a resolve_in_session row are all refused with a resync", async () => { + const { engine, sent } = makeEngine({ runDecisionFn: async () => decide({ decision: "escalate" }) }); + engine.arm({ terminalId: "c1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "c1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "c1", event: "permission_request", detail: "Bash: ls", promptId: "p1" }); + const [reply, prompt] = escalations(sent); + expect(rowsOf(sent)).toHaveLength(2); + + const statuses = () => sent.filter((m) => m.type === "handler:status").length; + for (const id of ["nope", reply!.escalationId, prompt!.escalationId]) { + const before = statuses(); + engine.dismissEscalation("c1", id); + // A refusal is not silence: the sender is holding a row it may not retire + // this way, and the resync is what puts their list back. + expect(statuses()).toBe(before + 1); + expect(rowsOf(sent)).toHaveLength(2); + } + // An unarmed terminal is the same answer, and must not throw. + expect(() => engine.dismissEscalation("t-unknown", "nope")).not.toThrow(); + }); + + it("a standing guard_blocked row suppresses no further escalation", async () => { + // notify-only: one unanswered QUESTION is enough, but a report is not one — + // reading it as one would silence the session for the rest of its life. + const notify = makeEngine({ loadSessionFn: () => blockedRecord() }); + notify.engine.arm({ terminalId: "t1", notifyOnly: true }); + await notify.engine.handleEvent({ terminalId: "t1", event: "awaiting_input" }); + expect(escalations(notify.sent)).toHaveLength(1); + + // The transient ceiling. + const transient = makeEngine({ + loadSessionFn: () => blockedRecord(), runDecisionFn: async () => decide({}), + }); + transient.engine.arm({ terminalId: "t1", notifyOnly: false }); + for (let i = 0; i < 3; i++) { + await transient.engine.handleEvent({ terminalId: "t1", event: "turn_failed" }); + const t = transient.timers.at(-1)!; + if (!t.fired && !t.cancelled) t.fn(); + } + expect(escalations(transient.sent).map((e) => e.reasoning)).toContain("repeated transient failures"); + + // The limit-park ceiling. + const limit = makeEngine({ loadSessionFn: () => blockedRecord() }); + limit.engine.arm({ terminalId: "t1", notifyOnly: false }); + for (let i = 0; i < LIMIT_PARK_CEILING; i++) { + await limit.engine.handleEvent({ terminalId: "t1", event: "limit_hit" }); + const t = limit.timers.at(-1)!; + if (!t.fired && !t.cancelled) t.fn(); + } + expect(escalations(limit.sent)).toHaveLength(1); + }); + + it("a standing guard_blocked row neither blocks wrap-up nor the park-timer nudge", async () => { + const { engine, activity, pushes } = makeEngine({ + loadSessionFn: () => blockedRecord({ backlog: [item("a")] }), + runDecisionFn: async () => decide({ transitions: [{ id: "a", status: "done", evidence: "ran to completion" }] }), + }); + engine.arm({ terminalId: "t1", notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + // Holding the wrap-up open would leave a finished session armed until somebody + // tapped Dismiss — so the push carries the report out instead. + expect(records(activity, "wrapped_up")).toHaveLength(1); + expect(pushes.at(-1)).toContain("1 action(s) Handler could not take"); + + const parked = makeEngine({ loadSessionFn: () => blockedRecord() }); + parked.engine.arm({ terminalId: "t1", notifyOnly: false }); + await parked.engine.handleEvent({ terminalId: "t1", event: "limit_hit" }); + parked.timers.at(-1)!.fn(); + // The nudge answers nothing a report asked, so a report must not strand it. + expect(parked.injected).toEqual([["t1", "continue"]]); + }); + + it("a guard_blocked row survives a suspend and a re-arm", () => { + const { engine, sent } = makeEngine({ + loadSessionFn: () => blockedRecord({ + armed: false, suspended: true, + escalations: [ + { + escalationId: "b0", question: "Handler did not send its reply", reasoning: "r", + draftReply: "d", urgency: "normal", at: 1, kind: "guard_blocked", + }, + { + escalationId: "p0", question: "Agent requests permission", reasoning: "r", + draftReply: "", urgency: "high", at: 2, kind: "resolve_in_session", promptId: "p1", + }, + ], + }), + }); + engine.arm({ terminalId: "t1", notifyOnly: false }); + // The prompt row names a driver a restart rebuilt empty; the report names + // nothing that had to survive the runtime. + expect(rowsOf(sent).map((e) => e.escalationId)).toEqual(["b0"]); + expect(statusOf(sent).state).toBe("needs_you"); + }); + + it("an identical repeat report is recorded in the feed but not raised twice", async () => { + const { engine, sent, activity } = makeEngine({ + runDecisionFn: async () => decide({ decision: "handle", reply: "mkfs.ext4 /dev/sdb" }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + // A second copy costs the user a second Dismiss for a situation the open row + // already describes in the same words — but the feed still says it happened + // again, which is the fact worth keeping. + expect(escalations(sent)).toHaveLength(1); + expect(rowsOf(sent)).toHaveLength(1); + expect(records(activity, "escalate")).toHaveLength(2); + // The handle branch sets "handling" before the judge call and resets it + // nowhere else, so a dedup that returned early would leave the pill reporting + // work nobody is doing until the next event happened to land. + expect(statusOf(sent).state).toBe("needs_you"); + }); + + it("a sixth standing report drops the oldest", async () => { + let n = 0; + const { engine, sent } = makeEngine({ + // Distinct drafts, so each is a genuinely different refusal rather than the + // repeat the dedup above absorbs. + runDecisionFn: async () => decide({ decision: "handle", reply: `do thing ${n++}\x1b[B` }), + }); + engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); + for (let i = 0; i < 6; i++) await engine.handleEvent({ terminalId: "t1", event: "turn_end" }); + const raised = escalations(sent).map((e) => e.escalationId); + expect(raised).toHaveLength(6); + const standing = rowsOf(sent).map((e) => e.escalationId); + expect(standing).toHaveLength(5); + // The oldest goes rather than the newest: the list has to describe the + // situation the session is in now, and every report is in the feed regardless. + expect(standing).not.toContain(raised[0]); + expect(standing.at(-1)).toBe(raised[5]); + }); +}); + // Lets a re-enqueued event (the timer's re-judge) drain without real timers. async function drain(): Promise { for (let i = 0; i < 30; i++) await Promise.resolve(); @@ -2048,7 +2996,7 @@ describe("instruct (extraction)", () => { it("a tool with no judge lands the raw text as one item without spawning", async () => { let spawned = 0; const { engine, sent } = makeEngine({ - tool: () => "kilo", + tool: () => "kimi", runExtractionFn: async () => { spawned++; return []; }, }); engine.arm({ terminalId: "t1", notifyOnly: false }); @@ -2278,7 +3226,7 @@ describe("instruct (extraction)", () => { it("the raw fallback is held to the same per-item cap the extractor is", async () => { // renderBacklog interpolates every item into every later decide prompt, and // the fallback is the expected path on a rate-limited account. - const { engine, sent } = makeEngine({ tool: () => "kilo" }); + const { engine, sent } = makeEngine({ tool: () => "kimi" }); engine.arm({ terminalId: "t1", notifyOnly: false }); engine.instruct({ terminalId: "t1", text: "z".repeat(9_000) }); await settle(); @@ -2344,7 +3292,7 @@ describe("arm-time extraction (§3.2)", () => { it("a judge-less tool lands the goal as one raw item rather than failing", async () => { let spawned = 0; const { engine, sent } = makeEngine({ - tool: () => "kilo", + tool: () => "kimi", runExtractionFn: async () => { spawned += 1; return []; }, }); engine.arm({ terminalId: "t1", goal: "ship it", notifyOnly: false }); @@ -2703,7 +3651,7 @@ describe("snapshot-before-act (§5.2)", () => { const { engine, pushes } = makeEngine({ runDecisionFn: async () => decide({ decision: "handle", reply: RESET, - transitions: [{ id: "i1", status: "done", evidence: "reverted" }], + transitions: [{ id: "i1", status: "done", evidence: "reverted by hand" }], }), takeSnapshotsFn: snapshotter([]), }); @@ -2877,7 +3825,7 @@ describe("observabilityFor", () => { }); it("reports escalate_only when the slot is visible but its judge cannot run headless", () => { - const { engine } = makeEngine({ observable: () => true, tool: () => "cursor-agent" }); + const { engine } = makeEngine({ observable: () => true, tool: () => "kimi" }); expect(engine.observabilityFor("t1")).toBe("escalate_only"); }); @@ -2889,7 +3837,7 @@ describe("observabilityFor", () => { it("prefers the session's stored judge over the observed session's own tool", () => { const { engine } = makeEngine({ observable: () => true, - tool: () => "cursor-agent", + tool: () => "kimi", loadSessionFn: () => ({ terminalId: "t1", armed: false, judgeTool: "claude-code" }), }); expect(engine.observabilityFor("t1")).toBe("full"); @@ -2919,7 +3867,7 @@ describe("observabilityFor", () => { }); it("separates escalate_only from unsupported on the snapshot", () => { - const { engine, sent } = makeEngine({ observable: () => true, tool: () => "cursor-agent" }); + const { engine, sent } = makeEngine({ observable: () => true, tool: () => "kimi" }); engine.arm({ terminalId: "t1", goal: GOAL, notifyOnly: false }); expect(statusOf(sent).observability).toBe("escalate_only"); }); diff --git a/bridge/tests/handler/entitlement-gate.test.ts b/bridge/tests/handler/entitlement-gate.test.ts index e88ac8ac..ffbc02f5 100644 --- a/bridge/tests/handler/entitlement-gate.test.ts +++ b/bridge/tests/handler/entitlement-gate.test.ts @@ -48,7 +48,7 @@ function makeEngine(claim?: () => TierClaim, over: Record = {}) recentOutput: () => "pty-tail", transcriptPath: () => "/t.jsonl", outputKind: () => "pty", - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, sendAb: (m: AbMessage) => sent.push(m), sendPush: () => {}, diff --git a/bridge/tests/handler/evidence.test.ts b/bridge/tests/handler/evidence.test.ts new file mode 100644 index 00000000..863e346b --- /dev/null +++ b/bridge/tests/handler/evidence.test.ts @@ -0,0 +1,118 @@ +// bridge/tests/handler/evidence.test.ts +import { describe, it, expect } from "bun:test"; +import { + MIN_EVIDENCE_CHARS, + citationSegments, + commandTokens, + containsInOrder, + normalizeForCitation, +} from "../../src/handler/evidence"; + +describe("normalizeForCitation", () => { + // An honest judge quoting out of a markdown pipeline delivers the same sentence + // in different code points. Rejecting that spends the whole gate on typography + // and teaches nothing about whether the work happened. + it("makes a rewritten quote equal to its plain-ASCII source", () => { + const source = 'the "auth" module - rewritten and covered'; + const quoted = "the \u201cauth\u201d module \u2014 rewritten\u00a0and covered"; + expect(normalizeForCitation(quoted)).toBe(normalizeForCitation(source)); + }); + + it("folds the ellipsis character to the ASCII spelling so only one is ever matched", () => { + expect(normalizeForCitation("first\u2026last")).toBe(normalizeForCitation("first...last")); + }); + + it("is case-insensitive and trims", () => { + expect(normalizeForCitation(" PASS 14 Tests ")).toBe("pass 14 tests"); + }); +}); + +describe("citationSegments", () => { + it("keeps a quote with no elision as one segment", () => { + expect(citationSegments("14 tests passed in 0.4s")).toEqual(["14 tests passed in 0.4s"]); + }); + + it("splits at an elision and trims each side", () => { + expect(citationSegments("the suite ... passed cleanly")) + .toEqual(["the suite", "passed cleanly"]); + }); + + // Two- and three-character runs occur in every corpus, so a quote spliced down + // to those would ground against material that never contained it. + it("drops fragments too short to discriminate", () => { + expect(citationSegments("in ... the ... of")).toEqual([]); + }); +}); + +describe("containsInOrder", () => { + const HAY = "the auth module was rewritten and covered by tests"; + + it("accepts an elided quote whose halves appear in order", () => { + expect(containsInOrder(HAY, ["the auth module", "covered by tests"])).toBe(true); + }); + + // Without the advancing cursor a quote could be assembled backwards out of + // fragments that never sat together in the record. + it("rejects the same halves in reverse order", () => { + expect(containsInOrder(HAY, ["covered by tests", "the auth module"])).toBe(false); + }); + + it("rejects a segment that is absent altogether", () => { + expect(containsInOrder(HAY, ["the auth module", "shipped to prod"])).toBe(false); + }); + + // No segments means nothing was checked, which is a caller's problem to catch — + // the length floor above this is what refuses it. + it("accepts an empty segment list", () => { + expect(containsInOrder(HAY, [])).toBe(true); + }); +}); + +describe("commandTokens", () => { + it("finds a slash command in prose and strips sentence punctuation", () => { + expect(commandTokens("run /code-review --fix")).toEqual(["/code-review"]); + expect(commandTokens("then run /init.")).toEqual(["/init"]); + }); + + it("is case-insensitive and deduplicates", () => { + expect(commandTokens("run /Compact then /compact again")).toEqual(["/compact"]); + }); + + // A path is not a verb: the harness can only ever invoke the shape reply-shape's + // VERB accepts, so anything with a second separator is not what the item asked + // for and must not impose an anchor no evidence could satisfy. + it("ignores a multi-segment path and a bare fraction", () => { + expect(commandTokens("copy it to /tmp/foo")).toEqual([]); + expect(commandTokens("open /tmp\\foo")).toEqual([]); + expect(commandTokens("1/2 of the suite")).toEqual([]); + }); + + it("requires a boundary before the slash", () => { + expect(commandTokens("https://example.com/init")).toEqual([]); + }); + + it("finds a token quoted or bracketed", () => { + expect(commandTokens('the "/compact" step')).toEqual(["/compact"]); + expect(commandTokens("(/compact)")).toEqual(["/compact"]); + }); + + // The documented cost of mirroring VERB rather than a command catalog: a + // single-segment path is verb-shaped, and `.claude/commands/*.md` lets a user + // name a command anything, so the two cannot be told apart by shape. + it("reads a single-segment absolute path as a command", () => { + expect(commandTokens("cd /usr and run make")).toEqual(["/usr"]); + }); + + it("finds nothing in an item that names no command", () => { + expect(commandTokens("review the code and fix what you find")).toEqual([]); + }); +}); + +describe("MIN_EVIDENCE_CHARS", () => { + // A judgement call with no data behind it, pinned so a change to it is a + // deliberate edit rather than a drift: "exit code 0" survives, "exit 0" does not. + it("is set where a terse but real citation still clears it", () => { + expect("exit code 0".length).toBeGreaterThanOrEqual(MIN_EVIDENCE_CHARS); + expect("exit 0".length).toBeLessThan(MIN_EVIDENCE_CHARS); + }); +}); diff --git a/bridge/tests/handler/extract.test.ts b/bridge/tests/handler/extract.test.ts index 367d01a7..aa657966 100644 --- a/bridge/tests/handler/extract.test.ts +++ b/bridge/tests/handler/extract.test.ts @@ -297,10 +297,10 @@ describe("runExtraction", () => { // the raw instruction as a single item rather than failing. it("returns null for tools without a judge, without spawning", async () => { const { spawn, calls } = fakeSpawn([GOOD]); - // kilo is a registry agent whose spec declares no judge — an unknown tool + // kimi is a registry agent whose spec declares no judge — an unknown tool // would exercise the missing-spec branch instead, and stop covering this one // the day that name gains a spec. - const items = await runExtraction({ tool: "kilo", text: "run the tests", cwd: ".", spawn }); + const items = await runExtraction({ tool: "kimi", text: "run the tests", cwd: ".", spawn }); expect(items).toBeNull(); expect(calls.length).toBe(0); }); diff --git a/bridge/tests/handler/judge.test.ts b/bridge/tests/handler/judge.test.ts index e5f22370..c1f20982 100644 --- a/bridge/tests/handler/judge.test.ts +++ b/bridge/tests/handler/judge.test.ts @@ -1,6 +1,6 @@ // bridge/tests/handler/judge.test.ts import { describe, it, expect } from "bun:test"; -import { runDecision } from "../../src/handler/judge"; +import { runDecision, runExtraction } from "../../src/handler/judge"; const GOAL = "migrate the auth module"; const BACKLOG_TEXT = "- id=i1 [queued] run the tests"; @@ -76,9 +76,100 @@ describe("runDecision", () => { expect(d).toBeNull(); expect(calls.length).toBe(1); // no retry leg after a timeout }); + + // The shape rules live in the caller's gate, so a judge that breaks one has + // produced perfectly valid JSON. Re-asking is the only thing that lets it + // learn the rule the prompt now states. + it("re-asks once when a parsed decision fails the caller's shape rule", async () => { + const bad = JSON.stringify({ + decision: "handle", confidence: 0.9, reason: "r", + action: { kind: "slash_command", value: "/etc/hosts" }, + }); + const { spawn, calls } = fakeSpawn([bad, GOOD]); + const d = await runDecision({ + tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn, + retryIfShape: (x) => x.action?.value === "/etc/hosts" ? "slash command value is not a simple verb" : null, + }); + expect(calls.length).toBe(2); + expect(d?.decision).toBe("continue"); + expect(calls[1].join(" ")).toContain("valid JSON, but the harness refused"); + expect(calls[1].join(" ")).toContain("slash command value is not a simple verb"); + }); + + // Never null: null reads as a judge outage upstream, which parks the session + // instead of escalating the decision with its own text attached. + it("spends exactly one shape retry and returns the second answer anyway", async () => { + const bad = JSON.stringify({ + decision: "handle", confidence: 0.9, reason: "r", + action: { kind: "slash_command", value: "/etc/hosts" }, + }); + const { spawn, calls } = fakeSpawn([bad, bad]); + const d = await runDecision({ + tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn, + retryIfShape: () => "still not a simple verb", + }); + expect(calls.length).toBe(2); + expect(d).not.toBeNull(); + }); + + // The shape hook second-guesses a value that PARSED, so a first attempt that + // produced none never reaches it and the retry it earns is the JSON one. The + // second attempt is final either way, so nothing consults the hook again. + it("does not consult retryIfShape for output that never parsed", async () => { + const seen: string[] = []; + const { spawn, calls } = fakeSpawn(["garbage", GOOD]); + const d = await runDecision({ + tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn, + retryIfShape: (x) => { seen.push(x.decision); return null; }, + }); + expect(seen).toEqual([]); + expect(d?.decision).toBe("continue"); + expect(calls[1].join(" ")).toContain("was not a valid JSON object"); + }); + + it("passes the supervised agent and the catalog into the prompt", async () => { + const { spawn, calls } = fakeSpawn([GOOD]); + await runDecision({ + tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn, + agentTool: "codex", commands: [{ id: "skill:x", name: "x" }], + }); + expect(calls[0].join(" ")).toContain("codex"); + expect(calls[0].join(" ")).toContain("/x"); + }); }); // The registry declares the override; this is the wiring that has to apply it. +// The judge is the one caller that MUST run in the working tree: reading it is +// the work. A naming spawn deliberately runs in a throwaway directory instead +// (headlessScratchCwd), and nothing but these assertions stops that from being +// reused here — where it would leave the judge reasoning about an empty dir and +// still returning a confident verdict. +describe("judge cwd", () => { + function cwdCapturingSpawn(out: string) { + const cwds: (string | undefined)[] = []; + const spawn = ((_cmd: string[], opts: { cwd?: string }) => { + cwds.push(opts.cwd); + return { stdout: new Response(out).body, exited: Promise.resolve(0), kill() {} }; + }) as unknown as typeof Bun.spawn; + return { spawn, cwds }; + } + + it("spawns a decision in the cwd it was handed", async () => { + const { spawn, cwds } = cwdCapturingSpawn(GOOD); + await runDecision({ + tool: "claude-code", goal: GOAL, backlogText: "", context: "C", + cwd: "/checkout/here", spawn, + }); + expect(cwds[0]).toBe("/checkout/here"); + }); + + it("spawns an extraction in the cwd it was handed", async () => { + const { spawn, cwds } = cwdCapturingSpawn('{"items":[]}'); + await runExtraction({ tool: "claude-code", text: "do a thing", cwd: "/checkout/here", spawn }); + expect(cwds[0]).toBe("/checkout/here"); + }); +}); + describe("judge env overrides", () => { function envCapturingSpawn(out: string) { const envs: (Record | undefined)[] = []; @@ -89,23 +180,60 @@ describe("judge env overrides", () => { return { spawn, envs }; } + // A key the test owns, rather than whichever one `process.env` happens to + // enumerate first: the runner DELETES keys from the spawn env + // (ANTGRID_TERMINAL_ID always, the TLS/proxy overrides on demand), so probing + // an arbitrary inherited key can pick one of those and fail a correct + // implementation depending on how the box is configured. + const MARKER = "ANTGRID_TEST_INHERITED_MARKER"; + async function withInheritedMarker(run: () => Promise): Promise { + const prior = process.env[MARKER]; + process.env[MARKER] = "kept"; + try { await run(); } finally { + if (prior === undefined) delete process.env[MARKER]; + else process.env[MARKER] = prior; + } + } + // Merged, never substituted: Bun.spawn REPLACES the environment when `env` is // passed, so handing it the override alone would strip PATH and the agent's // own credentials out from under the judge — which fails as "no judge output" // rather than as anything that names the environment. it("merges the agent's judge env over the inherited one", async () => { const { spawn, envs } = envCapturingSpawn(GOOD); - await runDecision({ tool: "opencode", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn }); + await withInheritedMarker(async () => { + await runDecision({ tool: "opencode", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn }); + }); expect(envs[0]?.OPENCODE_DB).toBe(":memory:"); - const inherited = Object.keys(process.env)[0]; - expect(envs[0]?.[inherited]).toBe(process.env[inherited]); + expect(envs[0]?.[MARKER]).toBe("kept"); }); - // An agent that declares none must get NO env object — passing an empty one - // would be a full environment wipe, not a no-op. - it("passes no env at all for an agent that declares none", async () => { + // An agent that declares none still gets the FULL inherited environment, never + // an empty object: Bun.spawn substitutes rather than merges, so an empty env is + // a wipe that takes PATH and the agent's credentials with it. + it("inherits the whole environment for an agent that declares none", async () => { const { spawn, envs } = envCapturingSpawn(GOOD); - await runDecision({ tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn }); - expect(envs[0]).toBeUndefined(); + await withInheritedMarker(async () => { + await runDecision({ tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn }); + }); + expect(envs[0]?.[MARKER]).toBe("kept"); + }); + + // Stripped by the shared runner for every headless spawn, whatever the agent + // declares: agy and opencode install their Antgrid hooks GLOBALLY, and this is + // the only thing keeping a supervisor pass from posting session/notify traffic + // for a conversation that does not exist. + it("strips ANTGRID_TERMINAL_ID from every judge spawn", async () => { + const prior = process.env.ANTGRID_TERMINAL_ID; + process.env.ANTGRID_TERMINAL_ID = "term-1"; + try { + const { spawn, envs } = envCapturingSpawn(GOOD); + await runDecision({ tool: "claude-code", goal: GOAL, backlogText: "", context: "C", cwd: ".", spawn }); + expect(envs[0]).toBeDefined(); + expect(envs[0]?.ANTGRID_TERMINAL_ID).toBeUndefined(); + } finally { + if (prior === undefined) delete process.env.ANTGRID_TERMINAL_ID; + else process.env.ANTGRID_TERMINAL_ID = prior; + } }); }); diff --git a/bridge/tests/handler/protocol.test.ts b/bridge/tests/handler/protocol.test.ts index f50794f8..6628ed0e 100644 --- a/bridge/tests/handler/protocol.test.ts +++ b/bridge/tests/handler/protocol.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, it } from "bun:test"; import { createMessage, parseMessage, parseMessageFast, - HandlerConfigureWire, HandlerInstructWire, HandlerUndoWire, + HandlerConfigureWire, HandlerInstructWire, HandlerUndoWire, HandlerDismissWire, type HandlerInstructionItem, } from "../../src/protocol"; @@ -86,7 +86,7 @@ describe("handler wire", () => { test("activity accepts the item-outcome kinds; escalation accepts floorRule", () => { for (const decision of ["armed", "goal_edited", "item_done", "item_blocked", - "item_skipped", "item_failed", "wrapped_up"] as const) { + "item_skipped", "item_failed", "evidence_rejected", "wrapped_up"] as const) { const act = createMessage("handler:activity", { projectId: "p", recordId: "r", at: 1, terminalId: "t1", decision, reason: "done", }); @@ -118,6 +118,9 @@ describe("handler wire", () => { expect(parseMessage(JSON.stringify(createMessage("handler:escalation", { ...base, kind: "resolve_in_session", })))).toBeTruthy(); + expect(parseMessage(JSON.stringify(createMessage("handler:escalation", { + ...base, kind: "guard_blocked", + })))).toBeTruthy(); expect(parseMessage(JSON.stringify({ ...createMessage("handler:escalation", base), kind: "bogus", }))).toBeNull(); @@ -168,6 +171,10 @@ describe("handler wire", () => { escalations: [{ escalationId: "e1", question: "q", reasoning: "r", draftReply: "", urgency: "high", at: 2, kind: "resolve_in_session", + }, { + escalationId: "e3", question: "Handler did not send its reply", + reasoning: "reply contains control characters", draftReply: "yes", + urgency: "normal", at: 4, kind: "guard_blocked", }, { escalationId: "e2", question: "q", reasoning: "r", draftReply: "ship it", urgency: "normal", at: 3, @@ -179,7 +186,10 @@ describe("handler wire", () => { }], }); const parsed = parseMessage(JSON.stringify(msg)) as any; - expect(parsed.sessions[0].escalations[1].choices[0].text).toBe("ship it"); + expect(parsed.sessions[0].escalations[2].choices[0].text).toBe("ship it"); + // The app rebuilds its rows wholesale from this snapshot, and the kind is + // what tells it a Dismiss is the only thing that retires one. + expect(parsed.sessions[0].escalations[1].kind).toBe("guard_blocked"); }); test("status carries a parked session with its park fields", () => { @@ -460,3 +470,33 @@ describe("handler:snapshot / handler:undo (§5.2)", () => { }); } }); + +describe("handler:dismiss", () => { + test("the dismiss verb routes through both parse paths", () => { + const msg = createMessage("handler:dismiss", { projectId: "p", terminalId: "t1", escalationId: "e1" }); + const parsed = parseMessage(JSON.stringify(msg)) as any; + expect(parsed.terminalId).toBe("t1"); + expect(parsed.escalationId).toBe("e1"); + // The hot path admits it on the discriminator alone, which is why agent-core + // re-parses with HandlerDismissWire before the engine drops a row. + expect(parseMessageFast(JSON.stringify(msg))?.type).toBe("handler:dismiss"); + }); + + const cases: Array<{ name: string; payload: Record; valid: boolean }> = [ + { name: "a well-formed dismiss", payload: { terminalId: "t1", escalationId: "e1" }, valid: true }, + { name: "missing escalationId", payload: { terminalId: "t1" }, valid: false }, + { name: "non-string escalationId", payload: { terminalId: "t1", escalationId: 7 }, valid: false }, + { name: "missing terminalId", payload: { escalationId: "e1" }, valid: false }, + ]; + + for (const c of cases) { + it(`payload and envelope agree on ${c.name}`, () => { + const viaPayload = HandlerDismissWire.safeParse(c.payload).success; + const viaEnvelope = parseMessage(JSON.stringify({ + id: crypto.randomUUID(), timestamp: 1, type: "handler:dismiss", projectId: "p", ...c.payload, + })) !== null; + expect(viaPayload).toBe(viaEnvelope); + expect(viaPayload).toBe(c.valid); + }); + } +}); diff --git a/bridge/tests/handler/reply-shape.test.ts b/bridge/tests/handler/reply-shape.test.ts new file mode 100644 index 00000000..e5864e0a --- /dev/null +++ b/bridge/tests/handler/reply-shape.test.ts @@ -0,0 +1,122 @@ +// bridge/tests/handler/reply-shape.test.ts +import { describe, it, expect } from "bun:test"; +import { + MAX_REPLY_CHARS, checkReplyShape, findCommand, oneLine, replyShape, splitSlashCommand, +} from "../../src/handler/reply-shape"; +import type { CapCommand } from "../../src/structured/chat-session"; +import type { HandlerDecision } from "../../src/handler/decision"; + +function handle(over: Partial): HandlerDecision { + return { decision: "handle", confidence: 0.9, reason: "r", ...over } as HandlerDecision; +} +function slash(value: string): HandlerDecision { + return handle({ action: { kind: "slash_command", value } }); +} + +describe("splitSlashCommand", () => { + it("splits on the FIRST run of whitespace and keeps the tail's own spacing", () => { + expect(splitSlashCommand("/review src/a.ts src/b.ts")) + .toEqual({ verb: "/review", args: "src/a.ts src/b.ts" }); + }); + it("a bare verb has no arguments", () => { + expect(splitSlashCommand("/compact")).toEqual({ verb: "/compact", args: "" }); + }); + it("a trailing space is not an argument", () => { + expect(splitSlashCommand(" /compact ")).toEqual({ verb: "/compact", args: "" }); + }); +}); + +describe("replyShape", () => { + it("written is the trimmed action value, internal spacing intact", () => { + // The whole line is what reaches the agent and what the runaway guard hashes, + // so the split must not normalize what it will type. + expect(replyShape(slash(" /review a.ts b.ts ")).written).toBe("/review a.ts b.ts"); + }); + it("a reply is flattened to the one line injectReply will submit", () => { + expect(replyShape(handle({ reply: "line one\n\nline two" })).reply).toBe("line one line two"); + }); + it("an action of kind none contributes nothing", () => { + const shape = replyShape(handle({ reply: "carry on", action: { kind: "none", value: "" } })); + expect(shape.actionText).toBe(""); + expect(shape.written).toBe("carry on"); + }); +}); + +describe("findCommand", () => { + const catalog: CapCommand[] = [{ id: "cmd:code-review", name: "Code-Review" }]; + it("matches case-insensitively, ignoring the leading slash", () => { + expect(findCommand(catalog, "/code-review")?.id).toBe("cmd:code-review"); + }); + it("returns undefined for an unknown verb, an empty verb and an absent catalog", () => { + expect(findCommand(catalog, "/nope")).toBeUndefined(); + expect(findCommand(catalog, "")).toBeUndefined(); + expect(findCommand(undefined, "/code-review")).toBeUndefined(); + }); + it("never matches an id prefix — the id is per-backend routing metadata", () => { + expect(findCommand([{ id: "cmd:review", name: "review" }], "/cmd")).toBeUndefined(); + }); +}); + +describe("checkReplyShape", () => { + const catalog: CapCommand[] = [{ id: "cmd:review", name: "review" }]; + + it("an empty decision is rejected and is not worth re-asking", () => { + expect(checkReplyShape(replyShape(handle({})), undefined)) + .toEqual({ reason: "empty reply", retryable: false }); + }); + + it("setting both reply and action is a teachable judge error", () => { + const shape = replyShape(handle({ reply: "carry on", action: { kind: "slash_command", value: "/compact" } })); + expect(checkReplyShape(shape, undefined)) + .toEqual({ reason: "set either reply or action, not both", retryable: true }); + }); + + it("an over-length reply is retryable", () => { + const shape = replyShape(handle({ reply: "x".repeat(MAX_REPLY_CHARS + 1) })); + expect(checkReplyShape(shape, undefined)?.retryable).toBe(true); + expect(checkReplyShape(shape, undefined)?.reason).toContain("reply too long"); + }); + + it("a control char that is not whitespace is retryable", () => { + expect(checkReplyShape(replyShape(handle({ reply: "pick two\x1b[B" })), undefined)) + .toEqual({ reason: "reply contains control characters", retryable: true }); + }); + + it("a path-shaped verb is rejected even when it carries arguments", () => { + expect(checkReplyShape(replyShape(slash("/etc/hosts --force")), undefined)) + .toEqual({ reason: "slash command value is not a simple verb", retryable: true }); + }); + + it("a backslash in the verb is rejected too", () => { + expect(checkReplyShape(replyShape(slash("/c\\windows")), undefined)?.retryable).toBe(true); + }); + + it("a verb outside a populated catalog is refused and is NOT worth re-asking", () => { + const r = checkReplyShape(replyShape(slash("/invented")), catalog); + expect(r?.retryable).toBe(false); + expect(r?.reason).toContain("/invented"); + }); + + it("a catalog member passes with its arguments attached", () => { + expect(checkReplyShape(replyShape(slash("/review --fix src/a.ts")), catalog)).toBeNull(); + }); + + it("an invented verb passes when no catalog is available", () => { + // A PTY, a driver reporting none, discovery that has not landed: refusing here + // would ground every terminal session on a catalog nobody can produce. + expect(checkReplyShape(replyShape(slash("/invented arg")), undefined)).toBeNull(); + }); + + it("an EMPTY catalog reads the same as an absent one", () => { + // buildDecidePrompt renders the no-catalog sentence on `commands?.length`, so + // refusing here would tell the judge no catalog exists and then reject every + // command it emits against one. + expect(checkReplyShape(replyShape(slash("/invented arg")), [])).toBeNull(); + }); +}); + +describe("oneLine", () => { + it("collapses every whitespace run and trims", () => { + expect(oneLine(" a\n\n b\t c ")).toBe("a b c"); + }); +}); diff --git a/bridge/tests/handler/session-adapter.test.ts b/bridge/tests/handler/session-adapter.test.ts index 0189b44a..0a82be6a 100644 --- a/bridge/tests/handler/session-adapter.test.ts +++ b/bridge/tests/handler/session-adapter.test.ts @@ -11,36 +11,40 @@ describe("createPtyAdapter", () => { getTranscriptPath: (id) => (id === "t1" ? "/p.jsonl" : undefined), }); a.injectReply("t1", "yes"); - expect(writes).toEqual([["t1", "yes\r"]]); + // A terminal has no routing channel: the resolved command is ignored and the + // verb rides in `text`, still submitted by exactly one CR. + a.injectReply("t1", "/compact", { id: "builtin:compact", args: "" }); + expect(writes).toEqual([["t1", "yes\r"], ["t1", "/compact\r"]]); expect(a.recentOutput("t1")).toBe("scrollback"); expect(a.transcriptPath("t1")).toBe("/p.jsonl"); expect(a.transcriptPath("t2")).toBeUndefined(); + expect(a.commandCatalog("t1")).toBeUndefined(); }); }); describe("createDispatchAdapter", () => { const calls: string[] = []; const fake = (name: string): SessionAdapter => ({ - injectReply: (id) => { calls.push(`${name}:inject:${id}`); }, + injectReply: (id, _text, command) => { calls.push(`${name}:inject:${id}:${command?.id ?? "-"}`); }, recentOutput: (id) => `${name}:recent:${id}`, outputKind: () => (name === "pty" ? "pty" : "rendered"), transcriptPath: (id) => `${name}:path:${id}`, - supportsSlashCommands: () => name === "pty", + commandCatalog: () => (name === "pty" ? undefined : [{ id: "cmd:x", name: "x" }]), }); - it("routes every method by isChat and delegates supportsSlashCommands", async () => { + it("routes every method by isChat, catalog included", async () => { calls.length = 0; const a = createDispatchAdapter({ isChat: (id) => id.startsWith("chat"), pty: fake("pty"), chat: fake("chat"), }); - a.injectReply("chat-1", "x"); + a.injectReply("chat-1", "x", { id: "cmd:x", args: "arg" }); a.injectReply("term-1", "x"); - expect(calls).toEqual(["chat:inject:chat-1", "pty:inject:term-1"]); + expect(calls).toEqual(["chat:inject:chat-1:cmd:x", "pty:inject:term-1:-"]); expect(await a.recentOutput("chat-1")).toBe("chat:recent:chat-1"); expect(a.transcriptPath("term-1")).toBe("pty:path:term-1"); - expect(a.supportsSlashCommands("term-1")).toBe(true); - expect(a.supportsSlashCommands("chat-1")).toBe(false); + expect(a.commandCatalog("term-1")).toBeUndefined(); + expect(a.commandCatalog("chat-1")).toEqual([{ id: "cmd:x", name: "x" }]); }); }); diff --git a/bridge/tests/handler/session-store.test.ts b/bridge/tests/handler/session-store.test.ts index 7a895748..e35f17d3 100644 --- a/bridge/tests/handler/session-store.test.ts +++ b/bridge/tests/handler/session-store.test.ts @@ -76,6 +76,22 @@ describe("session record round-trip", () => { expect(loaded?.escalations[0].kind).toBe("resolve_in_session"); }); + it("round-trips a guard_blocked escalation, so a restart still owes the user a dismiss", () => { + // The persisted mirror of the widened enum: nothing but an explicit dismiss + // retires one, so losing it on a restart is the reported bug by another route. + const abDir = tmpAbDir(); + saveHandlerSession(abDir, "proj", record({ + escalations: [{ + escalationId: "b1", question: "Handler did not send its reply", + reasoning: "reply contains control characters", draftReply: "yes", + urgency: "normal", kind: "guard_blocked", at: 2, + }], + })); + const loaded = loadHandlerSession(abDir, "proj", "t1"); + expect(loaded?.escalations[0].kind).toBe("guard_blocked"); + expect(loaded?.escalations[0].draftReply).toBe("yes"); + }); + it("round-trips quick choices, so a restart re-offers the same card", () => { // The card is replayed from this record on re-arm and from every status // snapshot; a persisted escalation that lost its choices would come back as a diff --git a/bridge/tests/handler/stale-nudge-wire.test.ts b/bridge/tests/handler/stale-nudge-wire.test.ts new file mode 100644 index 00000000..e4f334fe --- /dev/null +++ b/bridge/tests/handler/stale-nudge-wire.test.ts @@ -0,0 +1,138 @@ +// The suppression is threaded owner → core → api-server, and a missed hop is +// SILENT: everything typechecks, the api-server suite still passes (it injects +// the predicate directly), and the only symptom is the Handler quietly still +// paying a context assemble plus a judge spawn for every post-completion nudge. +// So this drives a real ProjectCore and reaches its api-server the way an +// injected hook does, which is the only thing that proves all three legs are +// connected. +import { test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { ProjectCore } from "../../src/project-core"; +import { initialWorkStatus, reduceWorkStatus } from "../../src/work-status"; +import type { AbMessage } from "../../src/protocol"; + +// The port a hook posts to is discovered from the api-server's own port file — +// the fallback path in `hook-runner.ts`, and the only handle a test has on a +// core's loopback port. ANTGRID_DIR is pinned per test so that file is this +// suite's own: it is one shared path every started core overwrites, and the +// unpinned one is whatever bridge the developer is actually running. +let prevAbDir: string | undefined; +let stateDir: string; +beforeEach(() => { + prevAbDir = process.env.ANTGRID_DIR; + stateDir = mkdtempSync(join(tmpdir(), "antgrid-stale-nudge-state-")); + process.env.ANTGRID_DIR = stateDir; +}); + +// A late EPERM/ENOENT from the raw fs.watch when a temp folder goes away lands +// asynchronously, sometimes in the next test — the same teardown artifact the +// other core-driving suites swallow. +function ignoreWatcherEperm(err: unknown): void { + const code = (err as { code?: string } | null)?.code; + if (code === "EPERM" || code === "ENOENT") return; + throw err; +} +process.on("uncaughtException", ignoreWatcherEperm); +afterAll(() => { process.off("uncaughtException", ignoreWatcherEperm); }); + +const cleanup: Array<() => void | Promise> = []; +// LIFO + awaited: the core stops watching before its folder is removed. +afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) try { await fn(); } catch {} }); +// Registered second, so it runs after the cleanup above: a core still shutting +// down is still reading its abDir. +afterEach(() => { + if (prevAbDir === undefined) delete process.env.ANTGRID_DIR; + else process.env.ANTGRID_DIR = prevAbDir; + rmSync(stateDir, { recursive: true, force: true }); +}); + +/** A started local core plus the loopback api-server port its hooks post to. */ +async function startCore(): Promise<{ core: ProjectCore; port: number }> { + const folder = mkdtempSync(join(tmpdir(), "antgrid-stale-nudge-proj-")); + const core = new ProjectCore({ + folder, + mode: "local", + identity: { deviceId: randomUUID(), deviceName: "local", createdAt: new Date().toISOString() }, + }); + cleanup.push(() => rmSync(folder, { recursive: true, force: true })); + cleanup.push(() => core.shutdown()); + await core.start(); + const port = Number(readFileSync(join(stateDir, "api.port"), "utf8")); + expect(port).toBeGreaterThan(0); + return { core, port }; +} + +/** Put the owner's reduction in the state a finished turn on [sessionId] leaves + * it in — built with the real reducer, then installed, because replaying the + * frames through the core's own subscriber cannot survive: a per-session move + * makes it re-emit its (empty) REAL session list, which prunes a session no + * PTY ever backed. The session list is part of the fixture on purpose — a + * notification for an id that was never listed running falls back to the + * project-wide key, and that attribution must never suppress anything. */ +function endTurn(core: ProjectCore, sessionId: string): void { + const frames = [ + { + id: "m", timestamp: 0, type: "session:updated", + sessions: [{ id: sessionId, name: sessionId, createdAt: 0, lastUsedAt: 0, archived: false, running: true }], + }, + { id: "m", timestamp: 0, type: "notification:push", notificationType: "task_complete", sessionId }, + ] as unknown as AbMessage[]; + const work = frames.reduce(reduceWorkStatus, initialWorkStatus); + expect(work.notifications.get(sessionId)).toBe("task_complete"); + (core as any)._work = work; +} + +function postHandlerEvent(port: number, body: unknown): Promise { + return fetch(`http://127.0.0.1:${port}/handler-event`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); +} + +// `idleNudge` is the hook's own reading of the message it saw, and the drop needs +// it as well as the turn state — so every post below states it the way the real +// claude hook would for the case it describes. +test("a nudge arriving after the slot's own turn ended is dropped end to end", async () => { + const { core, port } = await startCore(); + endTurn(core, "term-1"); + const res = await postHandlerEvent(port, { terminalId: "term-1", event: "awaiting_input", idleNudge: true }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, stale: true }); +}); + +test("a nudge for a slot whose turn never ended still reaches the core", async () => { + const { port } = await startCore(); + const res = await postHandlerEvent(port, { terminalId: "term-1", event: "awaiting_input", idleNudge: true }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); +}); + +test("a sibling's turn-end cannot suppress this slot's nudge", async () => { + const { core, port } = await startCore(); + endTurn(core, "term-1"); + const res = await postHandlerEvent(port, { terminalId: "term-2", event: "awaiting_input", idleNudge: true }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); +}); + +test("a genuine block reaches the core even from a latched turn-end", async () => { + // The reduction the drop reads is exactly one hook invocation behind: this + // event's own /notify — `permission_request`, because the hook did not read the + // message as the nudge — has not folded yet, so turn state alone would drop the + // block and then light the session up as needing the user anyway. + const { core, port } = await startCore(); + endTurn(core, "term-1"); + const res = await postHandlerEvent(port, { terminalId: "term-1", event: "awaiting_input", idleNudge: false }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); +}); + +test("turn_end is forwarded from the same latched state", async () => { + const { core, port } = await startCore(); + endTurn(core, "term-1"); + const res = await postHandlerEvent(port, { terminalId: "term-1", event: "turn_end" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); +}); diff --git a/bridge/tests/handler/structured-adapter.test.ts b/bridge/tests/handler/structured-adapter.test.ts index 6fb20ec4..ac2fb90a 100644 --- a/bridge/tests/handler/structured-adapter.test.ts +++ b/bridge/tests/handler/structured-adapter.test.ts @@ -51,30 +51,106 @@ describe("renderSnapshotText", () => { }); describe("createStructuredAdapter", () => { - it("injectReply forwards to prompt; slash commands unsupported", () => { - const prompts: Array<[string, string]> = []; + it("a plain reply forwards to prompt with no commandId", () => { + const prompts: Array<[string, string, string | undefined]> = []; const a = createStructuredAdapter({ - prompt: (id, text) => prompts.push([id, text]), + prompt: (id, text, commandId) => prompts.push([id, text, commandId]), getTranscriptPath: () => "/x.jsonl", getSnapshot: async () => [], + commandCatalog: () => undefined, }); a.injectReply("s1", "continue"); - expect(prompts).toEqual([["s1", "continue"]]); - expect(a.supportsSlashCommands("s1")).toBe(false); + expect(prompts).toEqual([["s1", "continue", undefined]]); expect(a.transcriptPath("s1")).toBe("/x.jsonl"); }); + it("a resolved command sends the argument tail under its command id", () => { + const prompts: Array<[string, string, string | undefined]> = []; + const a = createStructuredAdapter({ + prompt: (id, text, commandId) => prompts.push([id, text, commandId]), + getTranscriptPath: () => undefined, + getSnapshot: async () => [], + commandCatalog: () => undefined, + }); + a.injectReply("s1", "/code-review --fix", { id: "cmd:code-review", args: "--fix" }); + expect(prompts).toEqual([["s1", "--fix", "cmd:code-review"]]); + }); + + it("a bare verb sends empty text alongside its command id", () => { + const prompts: Array<[string, string, string | undefined]> = []; + const a = createStructuredAdapter({ + prompt: (id, text, commandId) => prompts.push([id, text, commandId]), + getTranscriptPath: () => undefined, + getSnapshot: async () => [], + commandCatalog: () => undefined, + }); + a.injectReply("s1", "/compact", { id: "builtin:compact", args: "" }); + expect(prompts).toEqual([["s1", "", "builtin:compact"]]); + }); + + it("an unresolved slash command degrades to an ordinary plain prompt", () => { + const prompts: Array<[string, string, string | undefined]> = []; + const a = createStructuredAdapter({ + prompt: (id, text, commandId) => prompts.push([id, text, commandId]), + getTranscriptPath: () => undefined, + getSnapshot: async () => [], + commandCatalog: () => undefined, + }); + // Never a made-up commandId: every backend would drop the verb and send the + // bare args, so the whole line goes as text and the agent rejects it visibly. + a.injectReply("s1", "/invented arg"); + expect(prompts).toEqual([["s1", "/invented arg", undefined]]); + }); + + it("commandCatalog is read from the dep, and undefined stays undefined", () => { + const withCatalog = createStructuredAdapter({ + prompt: () => {}, + getTranscriptPath: () => undefined, + getSnapshot: async () => [], + commandCatalog: () => [{ id: "cmd:x", name: "x" }], + }); + expect(withCatalog.commandCatalog("s1")).toEqual([{ id: "cmd:x", name: "x" }]); + const without = createStructuredAdapter({ + prompt: () => {}, + getTranscriptPath: () => undefined, + getSnapshot: async () => [], + commandCatalog: () => undefined, + }); + expect(without.commandCatalog("s1")).toBeUndefined(); + }); + + it("injection never resolves a permission or a question", () => { + const touched: string[] = []; + const base = { + prompt: () => {}, + getTranscriptPath: () => undefined, + getSnapshot: async () => [] as AbMessage[], + commandCatalog: () => undefined, + // Off the deps contract on purpose: approving a pending tool call is a + // human-only act, so the seam must not reach for either. + resolvePermission: () => { throw new Error("resolvePermission from the inject seam"); }, + resolveQuestion: () => { throw new Error("resolveQuestion from the inject seam"); }, + }; + const deps = new Proxy(base, { + get(target, prop, recv) { touched.push(String(prop)); return Reflect.get(target, prop, recv); }, + }); + createStructuredAdapter(deps).injectReply("s1", "continue"); + expect(touched).toEqual(["prompt"]); + }); + it("recentOutput renders the snapshot and fails closed to empty on error", async () => { const ok = createStructuredAdapter({ prompt: () => {}, getTranscriptPath: () => undefined, getSnapshot: async () => [item("agent:item-added", "a1", { kind: "message", role: "assistant", text: "hi" })], + commandCatalog: () => undefined, }); expect(await ok.recentOutput("s1")).toBe("assistant: hi"); const bad = createStructuredAdapter({ prompt: () => {}, getTranscriptPath: () => undefined, getSnapshot: async () => { throw new Error("driver gone"); }, + commandCatalog: () => undefined, }); expect(await bad.recentOutput("s1")).toBe(""); }); diff --git a/bridge/tests/headless.test.ts b/bridge/tests/headless.test.ts new file mode 100644 index 00000000..e95bc3f1 --- /dev/null +++ b/bridge/tests/headless.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; + +import { pickHeadless, resolveHeadless, runHeadless } from "../src/agents/headless"; +import { AGENTS, judgeCapable } from "../src/agents/registry"; +import { pickHeadlessFrom, type AgentKey, type AgentSpec } from "../src/agents/types"; + +const cmd = (label: string) => ({ + cmd: () => [label], + noHistory: "flag" as const, +}); + +describe("reach selection", () => { + // "none" takes the tightest available; "repo" cannot take a sealed argv at all, + // because reading the working tree is the work it exists to do. + test("a need takes the tightest entry that can serve it", () => { + const all: AgentSpec["headless"] = { + sealed: cmd("sealed"), readonly: cmd("readonly"), transcript: cmd("transcript"), + }; + expect(pickHeadlessFrom(all, "none")?.reach).toBe("sealed"); + expect(pickHeadlessFrom(all, "repo")?.reach).toBe("readonly"); + expect(pickHeadlessFrom({ transcript: cmd("t") }, "repo")?.reach).toBe("transcript"); + expect(pickHeadlessFrom({ readonly: cmd("r") }, "none")?.reach).toBe("readonly"); + }); + + test("a sealed-only agent can be named but can never judge", () => { + const sealedOnly: AgentSpec["headless"] = { sealed: cmd("sealed") }; + expect(pickHeadlessFrom(sealedOnly, "none")).not.toBeNull(); + expect(pickHeadlessFrom(sealedOnly, "repo")).toBeNull(); + }); + + test("no headless block at all serves neither need", () => { + expect(pickHeadlessFrom(undefined, "none")).toBeNull(); + expect(pickHeadlessFrom(undefined, "repo")).toBeNull(); + }); +}); + +// The predicate that gates the Handler, pinned against the registry itself: an +// agent becomes judge-capable by declaring an argv that reaches the repo, never +// by merely being able to answer a question (see AgentSpec.headless). +describe("judgeCapable", () => { + // Written out, not recomputed from the registry. Arming the Handler for an + // agent is a user-visible capability change (it reaches the app's judge picker + // through agent-catalog.ts), and a predicate re-derived from the same table it + // is checking passes for ANY registry — including one where adding a headless + // entry so an agent's SESSIONS can be NAMED also gave it a supervisor over the + // working tree. Adding an agent here is the point at which that has to be a + // decision. + const JUDGE_CAPABLE = new Set([ + "claude-code", "codex", "opencode", "github-copilot", "kilo", + ]); + + test("is exactly the set of agents we have armed a judge for", () => { + for (const key of Object.keys(AGENTS) as AgentKey[]) { + expect([key, judgeCapable(key)]).toEqual([key, JUDGE_CAPABLE.has(key)]); + } + }); + + // The two halves of the rule the set above encodes: a non-sealed entry is what + // makes an agent judge-capable, and a sealed-only one never is. + test("tracks the reach an agent declares, never merely having an entry", () => { + for (const key of Object.keys(AGENTS) as AgentKey[]) { + const h = AGENTS[key].headless; + expect([key, judgeCapable(key)]).toEqual( + [key, h?.readonly !== undefined || h?.transcript !== undefined], + ); + } + }); + + test("an unknown tool is not judge-capable", () => { + expect(judgeCapable("not-an-agent")).toBe(false); + }); +}); + +// A registry agent that declares no headless entry at all. Each test asserts +// that precondition, so giving it one fails here rather than quietly turning +// these into assertions about nothing. +const NO_HEADLESS = "kimi"; + +describe("resolveHeadless", () => { + test("prefers the session's own agent", () => { + const picked = resolveHeadless("codex", "none", ["claude-code", "codex"]); + expect(picked?.tool).toBe("codex"); + }); + + // An agent whose argv nobody has verified still gets named, by whichever + // installed agent can serve the call — the whole job is inlined in the prompt. + test("borrows an installed agent for a 'none' call", () => { + expect(AGENTS[NO_HEADLESS].headless).toBeUndefined(); + expect(resolveHeadless(NO_HEADLESS, "none", ["claude-code"])?.tool).toBe("claude-code"); + expect(resolveHeadless(NO_HEADLESS, "none", [])).toBeNull(); + }); + + // Borrowing a judge would arm a supervisor the user never chose, over their + // working tree, on an account they did not pick. + test("never borrows for a 'repo' call", () => { + expect(pickHeadless(NO_HEADLESS, "repo")).toBeNull(); + expect(resolveHeadless(NO_HEADLESS, "repo", ["claude-code"])).toBeNull(); + }); + + test("an unknown tool borrows nothing", () => { + expect(resolveHeadless("not-an-agent", "none", ["claude-code"])).toBeNull(); + }); +}); + +describe("runHeadless", () => { + // The budget has to be a real bound, not a hope. Every argv here is reached + // through a launcher script, so the handle is a wrapper and the agent is its + // child holding the inherited stdout pipe — a kill that misses the child + // leaves that pipe open and the stdout read never reaches EOF. The judge + // awaits this with no outer deadline of its own, so a hang there wedges a + // supervised session in "handling" for the life of the process. + test("a spawn that survives its kill still settles", async () => { + const spawn = (() => ({ + // Never closes: what an orphaned grandchild's inherited pipe looks like. + stdout: new ReadableStream({ start() {} }), + exited: new Promise(() => {}), + kill() { /* the survivor this test is about */ }, + })) as unknown as typeof Bun.spawn; + + const result = await runHeadless(["agent", "-p", "x"], { + cwd: process.cwd(), timeoutMs: 10, spawn, + }); + expect(result).toEqual({ stdout: "", code: null, timedOut: true }); + }, 10_000); +}); + +// The state redirect for a CLI with no ephemeral switch. What matters is the +// LIFETIME: a fixed path would keep a session per call in a temp dir Windows +// never reclaims, so the directory has to be gone by the time the call returns. +describe("scratchEnv", () => { + /** Records the env it was handed and whether the scratch dir was real at + * spawn time — the moment that matters, since the runner deletes it after. */ + function envCapturingSpawn(stdout = "ok") { + const seen: Array> = []; + const existed: boolean[] = []; + const spawn = ((_cmd: string[], o: Record) => { + const env = o.env as Record; + seen.push(env); + existed.push(Boolean(env.COPILOT_HOME) && existsSync(env.COPILOT_HOME)); + return { + stdout: new Response(stdout).body, + exited: Promise.resolve(0), + kill() {}, + }; + }) as unknown as typeof Bun.spawn; + return { spawn, seen, existed }; + } + + const run = (spawn: typeof Bun.spawn, scratchEnv?: string[]) => runHeadless( + ["agent", "-p", "x"], { cwd: process.cwd(), timeoutMs: 5_000, spawn, scratchEnv }, + ); + + test("the spawn sees a real directory, and it is gone once the call returns", async () => { + const { spawn, seen, existed } = envCapturingSpawn(); + await run(spawn, ["COPILOT_HOME"]); + expect(existed[0]).toBe(true); + expect(existsSync(seen[0]!.COPILOT_HOME!)).toBe(false); + }); + + test("every spawn gets its own, so calls cannot accumulate in one store", async () => { + const { spawn, seen } = envCapturingSpawn(); + await run(spawn, ["COPILOT_HOME"]); + await run(spawn, ["COPILOT_HOME"]); + expect(seen[0]!.COPILOT_HOME).not.toBe(seen[1]!.COPILOT_HOME); + }); + + test("several vars share the one directory", async () => { + const { spawn, seen } = envCapturingSpawn(); + await run(spawn, ["COPILOT_HOME", "OTHER_HOME"]); + expect(seen[0]!.OTHER_HOME).toBe(seen[0]!.COPILOT_HOME!); + }); + + // Absence must leave the inherited value alone rather than blank it: an agent + // that reads its home from the environment would otherwise be redirected by a + // command that never asked to be. + test("a command that asks for none has the var untouched", async () => { + const prior = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = "the-user-s-own-home"; + try { + const { spawn, seen } = envCapturingSpawn(); + await run(spawn); + expect(seen[0]!.COPILOT_HOME).toBe("the-user-s-own-home"); + } finally { + if (prior === undefined) delete process.env.COPILOT_HOME; + else process.env.COPILOT_HOME = prior; + } + }); +}); diff --git a/bridge/tests/hook-runner.test.ts b/bridge/tests/hook-runner.test.ts index eeef9bcd..00d89a7e 100644 --- a/bridge/tests/hook-runner.test.ts +++ b/bridge/tests/hook-runner.test.ts @@ -59,7 +59,7 @@ describe("Claude hooks", () => { ]); }); - test("user-prompt posts a turn-start (fresh turn → working)", async () => { + test("user-prompt posts a turn-start and a title request (fresh turn → working)", async () => { const h = harness({ agent: "claude", event: "user-prompt", @@ -68,6 +68,17 @@ describe("Claude hooks", () => { await h.run(); expect(h.posts).toEqual([ { port: 43123, path: "/turn-start", body: { terminalId: "term-1" } }, + { + port: 43123, + path: "/session-title", + body: { + terminalId: "term-1", + sessionId: "s1", + agent: "claude", + prompt: "hi", + transcriptPath: "/tmp/t.jsonl", + }, + }, ]); }); @@ -105,7 +116,10 @@ describe("Claude hooks", () => { { port: 43123, path: "/handler-event", - body: { terminalId: "term-1", agent: "claude", event: "awaiting_input", transcriptPath: "/t", sessionId: "s3" }, + body: { + terminalId: "term-1", agent: "claude", event: "awaiting_input", + transcriptPath: "/t", sessionId: "s3", idleNudge: false, + }, }, ])); }); @@ -125,7 +139,7 @@ describe("Claude hooks", () => { { port: 43123, path: "/session-title", - body: { terminalId: "term-1", sessionId: "s4", transcriptPath: "", agent: "claude" }, + body: { terminalId: "term-1", sessionId: "s4", agent: "claude" }, }, ])); expect(h.posts.length).toBe(3); @@ -276,7 +290,10 @@ describe("Claude hooks", () => { { port: 43123, path: "/handler-event", - body: { terminalId: "term-1", agent: "claude", event: "awaiting_input", transcriptPath: "", sessionId: "" }, + body: { + terminalId: "term-1", agent: "claude", event: "awaiting_input", + transcriptPath: "", sessionId: "", idleNudge: true, + }, }, ])); }); diff --git a/bridge/tests/known-agents.test.ts b/bridge/tests/known-agents.test.ts index 0e581e7e..fd414c4b 100644 --- a/bridge/tests/known-agents.test.ts +++ b/bridge/tests/known-agents.test.ts @@ -127,6 +127,13 @@ test("opencode env writer is idempotent", () => { expect(a.OPENCODE_TUI_CONFIG).toBe(b.OPENCODE_TUI_CONFIG); }); +// Antgrid manages the session lifecycle; a self-backgrounded claude leaves the +// slot resuming an id a job outside this bridge still holds. +test("claude env disables claude's own background agent view", () => { + const base = mkdtempSync(join(tmpdir(), "ab-known-agents-")); + expect(resolveAgentEnv("claude-code", base).CLAUDE_CODE_DISABLE_AGENT_VIEW).toBe("1"); +}); + test("kilo env points KILO_TUI_CONFIG at an attention-enabled config file", () => { const base = mkdtempSync(join(tmpdir(), "ab-known-agents-")); const env = resolveAgentEnv("kilo", base); @@ -142,11 +149,11 @@ test("kilo env points KILO_TUI_CONFIG at an attention-enabled config file", () = test("entry-only agents get no extra launch env", () => { const base = mkdtempSync(join(tmpdir(), "ab-known-agents-")); // These notify by default (or via the focus-routing default-blur) so they - // need no injected config — only a registry entry. + // need no injected config — only a registry entry. claude-code is absent + // deliberately: its launch env pins a behaviour switch, not notifications. expect(resolveAgentEnv("kimi", base)).toEqual({}); expect(resolveAgentEnv("mistral-vibe", base)).toEqual({}); expect(resolveAgentEnv("codex", base)).toEqual({}); - expect(resolveAgentEnv("claude-code", base)).toEqual({}); expect(resolveAgentEnv("antigravity", base)).toEqual({}); }); diff --git a/bridge/tests/opencode-title.test.ts b/bridge/tests/opencode-title.test.ts index cd013d2f..1ff3a7fc 100644 --- a/bridge/tests/opencode-title.test.ts +++ b/bridge/tests/opencode-title.test.ts @@ -40,14 +40,16 @@ function makeClient(rootId = "sess-1") { return { client, push, done, created }; } + const tick = () => new Promise((r) => setTimeout(r, 0)); -describe("OpencodeDriver session.updated → onTitle", () => { - // The whole auto-naming feature hangs off this argument. opencode generates a - // title from the first prompt ONLY for a session that doesn't already have - // one; any title passed here disables generation for good, so session.updated - // never carries a real title and the app stays on "Session N". - it("creates the session with no title so opencode will generate one", async () => { +describe("OpencodeDriver session naming", () => { + // Antgrid names its own sessions (see ResolvedTitle), so opencode's title is + // neither read nor written: session.updated carries only opencode's own + // generated name and is dropped, and nothing is stamped onto opencode's + // session record here either. Passing one would rename the conversation in + // the user's own opencode history off a name they never chose. + it("creates the session without stamping a title on it", async () => { const { client, created } = makeClient(); const driver = new OpencodeDriver({ sessionId: "s1", client, sendMessage: () => {} }); await driver.start(); @@ -55,38 +57,28 @@ describe("OpencodeDriver session.updated → onTitle", () => { expect(created[0]!.title).toBeUndefined(); }); - it("forwards the root session's generated title", async () => { - const { client, push } = makeClient("sess-1"); - const titles: string[] = []; + // opencode is the one agent with no vendor integration to fall back on: it + // ships no hook, writes no rename we read, and its plugin's inline title is + // dropped. So the driver ignoring session.updated is the whole of the claim + // that our generated name survives — a case re-added to the event switch + // renames the chat after whatever opencode last called the conversation, + // including a subagent's own title. + it("drops session.updated, for the root session and for a subtask alike", async () => { + const { client, push, done } = makeClient("sess-1"); + const sent: unknown[] = []; const driver = new OpencodeDriver({ - sessionId: "s1", client, sendMessage: () => {}, onTitle: (t) => titles.push(t), + sessionId: "s1", client, sendMessage: (m) => { sent.push(m); }, }); - await driver.start(); // rootSessionId = "sess-1" + await driver.start(); + sent.length = 0; push({ type: "session.updated", properties: { info: { id: "sess-1", title: "Fix the login bug" } } }); - await tick(); - expect(titles).toEqual(["Fix the login bug"]); - - // A title for an unrelated session id belongs to another driver — ignored. - push({ type: "session.updated", properties: { info: { id: "other", title: "x" } } }); - await tick(); - expect(titles).toEqual(["Fix the login bug"]); - }); - - it("ignores a subtask child's own title", async () => { - const { client, push } = makeClient("sess-1"); - const titles: string[] = []; - const driver = new OpencodeDriver({ - sessionId: "s1", client, sendMessage: () => {}, onTitle: (t) => titles.push(t), + push({ + type: "session.updated", + properties: { info: { id: "sess-child", parentID: "sess-1", title: "Find TODOs" } }, }); - await driver.start(); - - push({ type: "session.created", properties: { info: { id: "sess-child", parentID: "sess-1", agent: "explore" } } }); - await tick(); - // opencode titles child sessions too; taking one would rename the chat - // session after whatever a subagent happened to do last. - push({ type: "session.updated", properties: { info: { id: "sess-child", title: "Find TODOs (@explore subagent)" } } }); await tick(); - expect(titles).toEqual([]); + expect(sent).toEqual([]); + done(); }); }); diff --git a/bridge/tests/protocol.test.ts b/bridge/tests/protocol.test.ts index fd6ea182..2b6a6bd8 100644 --- a/bridge/tests/protocol.test.ts +++ b/bridge/tests/protocol.test.ts @@ -99,7 +99,7 @@ describe("session:* messages", () => { sessions: [{ id: "s1", name: "Session 1", createdAt: 1, lastUsedAt: 2, archived: false, running: true, deleting: false, mode: "terminal" as const, - agentSessionResumable: true, + agentSessionResumable: true, forkSupported: false, sharedWorkspace: false, workspaceMemberCount: 1, checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const, }], }); @@ -129,10 +129,10 @@ describe("session:* messages", () => { it("session:result and session:updated roundtrip", () => { const result = createMessage("session:result", { requestId: "r", ok: true, - session: { id: "s", name: "S", createdAt: 1, lastUsedAt: 2, archived: false, running: false, deleting: false, mode: "terminal" as const, agentSessionResumable: true, checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const }, + session: { id: "s", name: "S", createdAt: 1, lastUsedAt: 2, archived: false, running: false, deleting: false, mode: "terminal" as const, agentSessionResumable: true, forkSupported: false, sharedWorkspace: false, workspaceMemberCount: 1, checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const }, }); const updated = createMessage("session:updated", { - sessions: [{ id: "s", name: "S", createdAt: 1, lastUsedAt: 2, archived: false, running: false, deleting: false, mode: "terminal" as const, agentSessionResumable: true, checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const }], + sessions: [{ id: "s", name: "S", createdAt: 1, lastUsedAt: 2, archived: false, running: false, deleting: false, mode: "terminal" as const, agentSessionResumable: true, forkSupported: false, sharedWorkspace: false, workspaceMemberCount: 1, checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const }], }); expect(AbMessageSchema.safeParse(JSON.parse(JSON.stringify(result))).success).toBe(true); expect(AbMessageSchema.safeParse(JSON.parse(JSON.stringify(updated))).success).toBe(true); @@ -164,7 +164,7 @@ describe("session:* messages", () => { const entry = { id: "s1", name: "n", createdAt: 1, lastUsedAt: 1, archived: false, running: false, deleting: false, command: "my-agent --serve", mode: "terminal" as const, - agentSessionResumable: true, + agentSessionResumable: true, forkSupported: false, sharedWorkspace: false, workspaceMemberCount: 1, checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const, }; const msg = createMessage("session:list:result", { requestId: "r", sessions: [entry] }); @@ -179,7 +179,7 @@ describe("session:* messages", () => { const entry = { id: "s1", name: "n", createdAt: 1, lastUsedAt: 1, archived: false, running: false, deleting: false, mode: "terminal" as const, - agentSessionResumable: true, + agentSessionResumable: true, forkSupported: false, sharedWorkspace: false, workspaceMemberCount: 1, agentSessionId: "cop-1", agentTranscriptPath: "/tmp/copilot-transcript.json", checkoutId: "main", checkoutKind: "main" as const, checkoutState: "ready" as const, @@ -252,7 +252,7 @@ describe("session:* messages", () => { const entry = { id: "s1", name: "n", createdAt: 1, lastUsedAt: 1, archived: false, running: false, deleting: true, mode: "terminal" as const, - agentSessionResumable: true, + agentSessionResumable: true, forkSupported: false, sharedWorkspace: false, workspaceMemberCount: 1, checkoutId: "c1", checkoutKind: "managed-worktree" as const, checkoutState: "ready" as const, }; const msg = createMessage("session:list:result", { requestId: "r", sessions: [entry] }); diff --git a/bridge/tests/session-manager-fork.test.ts b/bridge/tests/session-manager-fork.test.ts new file mode 100644 index 00000000..27213401 --- /dev/null +++ b/bridge/tests/session-manager-fork.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SessionManager } from "../src/session-manager"; + +// Every fork here takes the NATIVE argv path (terminal mode + a reported +// agentSessionId), so none of them reads a transcript — `getScrollback` is +// present only so a regression that reaches for it fails loudly rather than on +// a missing method. +function makeTerm() { + const spawned = new Set(); + return { + spawn: (cfg: { terminalId?: string }) => { spawned.add(cfg.terminalId!); return cfg.terminalId!; }, + kill: (id: string) => { spawned.delete(id); }, + forget: (id: string) => { spawned.delete(id); }, + treeKilled: () => Promise.resolve(), + has: (id: string) => spawned.has(id), + getScrollback: () => null, + }; +} + +function makeManager(dir: string) { + return new SessionManager({ + projectId: "p1", storeDir: dir, projectPath: dir, + terminalManager: makeTerm() as any, + agentSpec: { command: "claude", name: "claude-code" }, + sendMessage: () => {}, + }); +} + +describe("SessionManager.fork naming and provenance", () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "antgrid-fork-")); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it("names a fork after its source and records where it came from", async () => { + const sm = makeManager(dir); + const source = sm.create("Auth refactor"); + sm.setAgentSession(source.id, "native-1"); + + const first = await sm.fork(source.id, "current"); + expect(first.name).toBe("Auth refactor fork"); + expect(first.forkedFromSessionId).toBe(source.id); + + const second = await sm.fork(source.id, "current"); + expect(second.name).toBe("Auth refactor fork 2"); + + // A fork of a fork is still a fork of the same work: the suffix is stripped + // before it is re-applied, so the name never grows "fork fork". + sm.setAgentSession(first.id, "native-2"); + const nested = await sm.fork(first.id, "current"); + expect(nested.name).toBe("Auth refactor fork 3"); + expect(nested.forkedFromSessionId).toBe(first.id); + }); + + it("leaves the derived name auto-nameable so the agent's own title still wins", async () => { + const sm = makeManager(dir); + const source = sm.create("Auth refactor"); + sm.setAgentSession(source.id, "native-1"); + + const forked = await sm.fork(source.id, "current"); + expect(sm.isAutoNameable(forked.id)).toBe(true); + sm.applyAutoName(forked.id, "Rotate the signing key"); + expect(sm.list().find((e) => e.id === forked.id)?.name).toBe("Rotate the signing key"); + }); + + it("keeps provenance across a reload, so a renamed fork still knows its source", async () => { + const sm = makeManager(dir); + const source = sm.create("Auth refactor"); + sm.setAgentSession(source.id, "native-1"); + const forked = await sm.fork(source.id, "current"); + sm.rename(forked.id, "Something else entirely"); + sm.flushNow(); + + const reloaded = makeManager(dir); + const row = reloaded.list().find((e) => e.id === forked.id); + expect(row?.name).toBe("Something else entirely"); + expect(row?.forkedFromSessionId).toBe(source.id); + }); +}); diff --git a/bridge/tests/session-mode-teardown.test.ts b/bridge/tests/session-mode-teardown.test.ts index 81e46e3b..cf509994 100644 --- a/bridge/tests/session-mode-teardown.test.ts +++ b/bridge/tests/session-mode-teardown.test.ts @@ -62,7 +62,7 @@ function makeCore(dir: string, opts: CoreOpts = {}) { recentOutput: () => "", outputKind: () => "pty", transcriptPath: () => undefined, - supportsSlashCommands: () => true, + commandCatalog: () => undefined, }, sendAb: (m: AbMessage) => sent.push(m), loadConfigFn: () => ({ version: 2, defaultNotifyOnly: false }), diff --git a/bridge/tests/session-namer.test.ts b/bridge/tests/session-namer.test.ts index aa93e70d..ec53976f 100644 --- a/bridge/tests/session-namer.test.ts +++ b/bridge/tests/session-namer.test.ts @@ -19,7 +19,7 @@ describe("SessionNamer", () => { const s = sink(); const n = new SessionNamer(s); n.onOscTitle("a", "osc filler"); - n.onStructuredTitle("a", "Structured", "generated"); + n.onStructuredTitle("a", "Structured", "self"); n.flush(); expect(s.calls).toEqual([["a", "Structured"]]); }); @@ -29,7 +29,7 @@ describe("SessionNamer", () => { const n = new SessionNamer(s); n.onOscTitle("a", "osc filler"); n.flush(); - n.onStructuredTitle("a", "Structured", "generated"); + n.onStructuredTitle("a", "Structured", "self"); n.flush(); expect(s.calls).toEqual([["a", "osc filler"], ["a", "Structured"]]); }); @@ -37,11 +37,11 @@ describe("SessionNamer", () => { test("sanitizes control chars, spinners, whitespace and caps length", () => { const s = sink(); const n = new SessionNamer(s); - n.onStructuredTitle("a", "\x1b[31m Fix\t the ⠉ bug \x07", "generated"); + n.onStructuredTitle("a", "\x1b[31m Fix\t the ⠉ bug \x07", "self"); n.flush(); expect(s.calls[0][1]).toBe("Fix the bug"); const long = "x".repeat(200); - n.onStructuredTitle("b", long, "generated"); + n.onStructuredTitle("b", long, "self"); n.flush(); expect(s.calls[1][1].length).toBe(60); }); @@ -68,7 +68,7 @@ describe("SessionNamer", () => { const s = sink(); const n = new SessionNamer(s); // A previous run set a structured title (which wins precedence). - n.onStructuredTitle("a", "Previous run", "generated"); + n.onStructuredTitle("a", "Previous run", "self"); n.forget("a"); // The restarted same-id session emits only an OSC title; without forget the // stale structured title would still win on flush. @@ -100,7 +100,7 @@ describe("SessionNamer", () => { const n = new SessionNamer(s, { debounceMs: 0 }); n.onStructuredTitle("a", "check and analyze this play requirement", "first-message"); n.flush(); - n.onStructuredTitle("a", "Audit Play Store requirement", "generated"); + n.onStructuredTitle("a", "Audit Play Store requirement", "self"); n.flush(); // Every later turn re-reads the same placeholder off the transcript. n.onStructuredTitle("a", "check and analyze this play requirement", "first-message"); @@ -124,7 +124,7 @@ describe("SessionNamer", () => { test("forget clears the rank, so a restarted session can be named again", () => { const s = sink(); const n = new SessionNamer(s, { debounceMs: 0 }); - n.onStructuredTitle("a", "Generated name", "generated"); + n.onStructuredTitle("a", "Generated name", "self"); n.flush(); n.forget("a"); n.onStructuredTitle("a", "fresh run opening prompt", "first-message"); @@ -136,9 +136,9 @@ describe("SessionNamer", () => { const s = sink(); const n = new SessionNamer(s, { debounceMs: 0 }); // Passes a resolver's `.trim()` check but nothing survives sanitizeTitle, - // so no name is applied — and it must not claim the `generated` rank on + // so no name is applied — and it must not claim the `self` rank on // behalf of a name the user never saw. - n.onStructuredTitle("a", "\x1b[2K⠋", "generated"); + n.onStructuredTitle("a", "\x1b[2K⠋", "self"); n.flush(); n.onStructuredTitle("a", "opening prompt", "first-message"); n.flush(); @@ -148,7 +148,7 @@ describe("SessionNamer", () => { test("forgetStructuredTitle lifts the rank so a new conversation can be named", () => { const s = sink(); const n = new SessionNamer(s, { debounceMs: 0 }); - n.onStructuredTitle("a", "Audit Play Store requirement", "generated"); + n.onStructuredTitle("a", "Audit Play Store requirement", "self"); n.flush(); // Same PTY, new conversation (`/clear`): nothing exits, so this is the only // release point. @@ -165,7 +165,7 @@ describe("SessionNamer", () => { const s = sink(); const n = new SessionNamer(s, { debounceMs: 0 }); n.onOscTitle("a", "osc filler"); - n.onStructuredTitle("a", "Generated name", "generated"); + n.onStructuredTitle("a", "Generated name", "self"); n.flush(); n.forgetStructuredTitle("a"); n.flush(); @@ -175,4 +175,55 @@ describe("SessionNamer", () => { n.flush(); expect(s.calls).toEqual([["a", "Generated name"], ["a", "osc filler two"]]); }); -}); \ No newline at end of file + // The naming gate is keyed by CONVERSATION and this is keyed by SLOT, which is + // the whole point: a mode flip carries the name across the runtime swap while + // re-keying the attempt underneath it, so only a slot-keyed question can stop + // the flipped session renaming itself from whatever the user types next. + test("hasFinalTitle is true only for a name worth protecting", () => { + const n = new SessionNamer(sink(), { debounceMs: 0 }); + expect(n.hasFinalTitle("a")).toBe(false); + n.onStructuredTitle("a", "open this file for me", "first-message"); + expect(n.hasFinalTitle("a")).toBe(false); + n.onStructuredTitle("a", "Fix session auto-naming", "self"); + expect(n.hasFinalTitle("a")).toBe(true); + n.onStructuredTitle("a", "Release blockers", "manual"); + expect(n.hasFinalTitle("a")).toBe(true); + // OSC filler is terminal chrome, never a name a model call should defer to. + n.forgetStructuredTitle("a"); + n.onOscTitle("a", "osc filler"); + expect(n.hasFinalTitle("a")).toBe(false); + }); + + test("a rename typed at the agent outranks the title we generated", () => { + const s = sink(); + const n = new SessionNamer(s, { debounceMs: 0 }); + n.onStructuredTitle("a", "Fix session auto-naming", "self"); + n.flush(); + // Claude `/rename`, agy `/rename`: the user naming it themselves, which is + // the one signal allowed to replace our generated name. + n.onStructuredTitle("a", "Release blockers", "manual"); + n.flush(); + expect(s.calls).toEqual([["a", "Fix session auto-naming"], ["a", "Release blockers"]]); + }); + + test("a generated title never displaces a rename typed at the agent", () => { + const s = sink(); + const n = new SessionNamer(s, { debounceMs: 0 }); + n.onStructuredTitle("a", "Release blockers", "manual"); + n.flush(); + // The generated name lands after the rename on a slow spawn; it loses. + n.onStructuredTitle("a", "Fix session auto-naming", "self"); + n.flush(); + expect(s.calls).toEqual([["a", "Release blockers"]]); + }); + + test("an equal rank still updates, so a revised rename applies", () => { + const s = sink(); + const n = new SessionNamer(s, { debounceMs: 0 }); + n.onStructuredTitle("a", "First name", "manual"); + n.flush(); + n.onStructuredTitle("a", "Second name", "manual"); + n.flush(); + expect(s.calls).toEqual([["a", "First name"], ["a", "Second name"]]); + }); +}); diff --git a/bridge/tests/spawn-claude.test.ts b/bridge/tests/spawn-claude.test.ts index d0427fcc..3e376204 100644 --- a/bridge/tests/spawn-claude.test.ts +++ b/bridge/tests/spawn-claude.test.ts @@ -12,12 +12,20 @@ describe("buildClaudeEnv", () => { expect(env.CLAUDE_CODE_ENTRYPOINT).toBe("cli"); expect(env.ENABLE_TOOL_SEARCH).toBe("auto:2"); expect(env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT).toBe("0"); + expect(env.CLAUDE_CODE_DISABLE_AGENT_VIEW).toBe("1"); }); it("does not clobber a user-set ENABLE_TOOL_SEARCH", () => { const env = buildClaudeEnv({ ENABLE_TOOL_SEARCH: "false", PATH: "/usr/bin" }); expect(env.ENABLE_TOOL_SEARCH).toBe("false"); }); + + // The gate is a bare truthiness check on the string, so "0" disables agent + // view too. Deferring still matters: an empty value is the only way back in. + it("does not clobber a user-set CLAUDE_CODE_DISABLE_AGENT_VIEW", () => { + const env = buildClaudeEnv({ CLAUDE_CODE_DISABLE_AGENT_VIEW: "", PATH: "/usr/bin" }); + expect(env.CLAUDE_CODE_DISABLE_AGENT_VIEW).toBe(""); + }); }); describe("resolveClaudeBinary", () => { diff --git a/bridge/tests/structured-manager-initial-prompt.test.ts b/bridge/tests/structured-manager-initial-prompt.test.ts index 2c72f026..aa4fa8f7 100644 --- a/bridge/tests/structured-manager-initial-prompt.test.ts +++ b/bridge/tests/structured-manager-initial-prompt.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; import { StructuredAgentManager, type StructuredDriver } from "../src/structured/structured-manager"; -import type { AbMessage } from "../src/protocol"; +import { createMessage, type AbMessage } from "../src/protocol"; function makeFakeDriver(overrides: Partial = {}): StructuredDriver & { prompts: string[] } { const prompts: string[] = []; @@ -79,3 +79,72 @@ describe("startChat initialPrompt", () => { expect(driver.prompts).toEqual(["first", "second"]); }); }); + +// A chat session has no hook to carry its first message, so this tap is the only +// thing that can name one from what the user actually asked for — the naming +// path for an agent that ships neither hooks nor a title of its own (opencode). +describe("onUserPrompt", () => { + function makeTapped(driver: StructuredDriver) { + const seen: Array<[string, string]> = []; + const mgr = new StructuredAgentManager({ + driverFactory: () => driver, + sendMessage: () => {}, + onAgentSession: () => {}, + onUserPrompt: (sessionId, text) => { seen.push([sessionId, text]); }, + }); + return { mgr, seen }; + } + + it("reports the initial prompt, once per session lifetime", async () => { + const { mgr, seen } = makeTapped(makeFakeDriver()); + await mgr.startChat({ sessionId: "s1", tool: "codex", initialPrompt: "add a login page" }); + await mgr.startChat({ sessionId: "s1", tool: "codex", initialPrompt: "add a login page" }); + expect(seen).toEqual([["s1", "add a login page"]]); + }); + + // The prompt is what the session gets named from, so a delivery that throws + // must still report it — the user asked for the same thing either way. + it("reports a prompt whose delivery fails", async () => { + const driver = makeFakeDriver({ prompt: async () => { throw new Error("boom"); } }); + const { mgr, seen } = makeTapped(driver); + await mgr.startChat({ sessionId: "s1", tool: "codex", initialPrompt: "hello" }); + expect(seen).toEqual([["s1", "hello"]]); + }); + + // The Handler writes on the user's behalf through this very path + // (structured-adapter's injectReply -> handleAgentMessage). Naming a session + // from a supervisor nudge would title it "continue". + it("does not report a prompt the Handler injected", async () => { + const { mgr, seen } = makeTapped(makeFakeDriver()); + await mgr.startChat({ sessionId: "s1", tool: "codex" }); + await mgr.handleAgentMessage(createMessage("agent:prompt", { + sessionId: "s1", requestId: "r1", text: "continue", + }), { injected: true }); + expect(seen).toEqual([]); + }); + + // A resume continues a conversation that already has a name, and its first + // message is the answer to a question ("yes, carry on") rather than the topic. + // The stop before it released the slot's title and its one attempt, so a tap + // here would rename the session from the continuation. + it("does not report the first message of a RESUMED conversation", async () => { + const { mgr, seen } = makeTapped(makeFakeDriver()); + await mgr.startChat({ + sessionId: "s1", tool: "codex", resumeId: "agent-native-id", + initialPrompt: "yes, carry on with step 3", + }); + expect(seen).toEqual([]); + }); + + it("reports an app-sent prompt, but not a slash command's arguments", async () => { + const { mgr, seen } = makeTapped(makeFakeDriver()); + await mgr.startChat({ sessionId: "s1", tool: "codex" }); + await mgr.handleAgentMessage(createMessage("agent:prompt", { + sessionId: "s1", requestId: "r1", text: "fix the flaky test", + })); + await mgr.handleAgentMessage(createMessage("agent:prompt", { + sessionId: "s1", requestId: "r2", text: "--all", commandId: "review", + })); + expect(seen).toEqual([["s1", "fix the flaky test"]]); + }); +}); diff --git a/bridge/tests/title-attempts.test.ts b/bridge/tests/title-attempts.test.ts new file mode 100644 index 00000000..a9bbfb94 --- /dev/null +++ b/bridge/tests/title-attempts.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; + +import { TitleAttempts } from "../src/agents/title-attempts"; + +const T = "term-1"; +const C = "conv-1"; + +describe("mutual exclusion", () => { + // The half a bare retry count cannot express: two turns ending at once must + // not both spawn, and the second is refused while the first is still running + // even though it has failed nothing. + test("a claim excludes a second spawn until it settles", () => { + const a = new TitleAttempts(); + expect(a.begin(T, C)).toBe(true); + expect(a.begin(T, C)).toBe(false); + expect(a.refused(T, C)).toBe(true); + a.settle(T, C, "failed"); + expect(a.begin(T, C)).toBe(true); + }); + + test("a claim on one conversation leaves the others alone", () => { + const a = new TitleAttempts(); + a.begin(T, C); + expect(a.begin(T, "conv-2")).toBe(true); + expect(a.begin("term-2", C)).toBe(true); + }); +}); + +describe("budget", () => { + // The regression this class was written for: a spawn that failed used to mark + // the conversation spent, so a session whose CLI was merely signed out could + // never be named afterwards — not even once the user logged in. + test("a failure is retriable, and the budget is bounded", () => { + const a = new TitleAttempts(2); + a.begin(T, C); + a.settle(T, C, "failed"); + expect(a.refused(T, C)).toBe(false); + + a.begin(T, C); + a.settle(T, C, "failed"); + expect(a.refused(T, C)).toBe(true); + expect(a.begin(T, C)).toBe(false); + }); + + test("an unavailable verdict ends the budget outright", () => { + // Nothing installed can serve the call, so a retry would re-read a + // transcript every turn to reach the same refusal. + const a = new TitleAttempts(2); + a.begin(T, C); + a.settle(T, C, "unavailable"); + expect(a.refused(T, C)).toBe(true); + }); + + test("a name is final", () => { + const a = new TitleAttempts(); + a.begin(T, C); + a.settle(T, C, "named"); + expect(a.refused(T, C)).toBe(true); + }); + + // A title discarded because the user renamed the session mid-spawn says + // nothing about whether generation works, so it must not consume budget — + // only release the claim. + test("an abandoned attempt costs nothing", () => { + const a = new TitleAttempts(2); + for (let i = 0; i < 5; i++) { + expect(a.begin(T, C)).toBe(true); + a.settle(T, C, "abandoned"); + } + expect(a.refused(T, C)).toBe(false); + }); +}); + +describe("forget", () => { + test("clears a terminal's conversations, and only that terminal's", () => { + const a = new TitleAttempts(); + a.begin(T, C); + a.settle(T, C, "named"); + a.begin("term-2", C); + a.settle("term-2", C, "named"); + + a.forget(T); + expect(a.refused(T, C)).toBe(false); + expect(a.refused("term-2", C)).toBe(true); + }); + + // Terminal ids carry colons of their own (`:setup`), which is why + // the state is nested rather than held under a flat `:` key — + // in a flat key space this release would reach the other terminal by prefix. + test("a release does not reach a terminal whose id extends it", () => { + const a = new TitleAttempts(); + a.begin("chk", C); + a.settle("chk", C, "named"); + a.begin("chk:setup", C); + a.settle("chk:setup", C, "named"); + + a.forget("chk"); + expect(a.refused("chk:setup", C)).toBe(true); + }); +}); + +describe("settle without a claim", () => { + test("is a no-op rather than a phantom failure", () => { + // forget() can land between a claim and its settle (terminal exit while a + // spawn is in flight); the late settle must not resurrect the entry. + const a = new TitleAttempts(1); + a.begin(T, C); + a.forget(T); + a.settle(T, C, "failed"); + expect(a.refused(T, C)).toBe(false); + }); +}); diff --git a/bridge/tests/title-generate.test.ts b/bridge/tests/title-generate.test.ts index eff9a663..28592972 100644 --- a/bridge/tests/title-generate.test.ts +++ b/bridge/tests/title-generate.test.ts @@ -2,27 +2,64 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { generateSessionTitle, parseTitleFromOutput } from "../src/agents/title-generate"; +import { + buildTitleContext, generateTitleFromContext, parseTitleFromOutput, +} from "../src/agents/title-generate"; function tmp() { return mkdtempSync(join(tmpdir(), "ab-tg-")); } /** - * Stands in for Bun.spawn: records the argv it was handed, replays `stdout`. + * Stands in for Bun.spawn: records the argv AND the options it was handed, + * replays `stdout`, and exits with `exitCode` (default 0). + * + * The options are captured, not dropped, because two of `titleCmd`'s + * invariants live only there — the per-agent `env` that keeps a naming run out + * of the user's real session store, and the ANTGRID_TERMINAL_ID strip that + * keeps it from firing the globally-installed hooks. Both are invisible in a + * passing test unless something asserts on the spawn options. + * * With `hang`, `exited` settles only on kill() — mirroring a real process that * outlives its budget, so the timeout path is what ends the call. */ -function fakeSpawn(stdout: string, opts: { hang?: boolean } = {}) { +function fakeSpawn( + stdout: string, opts: { hang?: boolean; exitCode?: number } = {}, +) { const calls: string[][] = []; - const spawn = ((cmd: string[]) => { + const options: Array> = []; + const spawn = ((cmd: string[], o?: Record) => { calls.push(cmd); + options.push(o ?? {}); let onKill: (() => void) | undefined; return { stdout: new Response(stdout).body, - exited: opts.hang ? new Promise((res) => { onKill = () => res(143); }) : Promise.resolve(0), + exited: opts.hang + ? new Promise((res) => { onKill = () => res(143); }) + : Promise.resolve(opts.exitCode ?? 0), kill() { onKill?.(); }, }; }) as unknown as typeof Bun.spawn; - return { spawn, calls }; + return { spawn, calls, options }; +} + +/** + * The two halves composed exactly as agent-core composes them. They ship apart + * so the one-shot-per-session gate can sit between them, and every case below is + * about the pair rather than either one — so the seam is restated here rather + * than duplicated into each test. + */ +async function generateSessionTitle(opts: { + tool: string; + cwd: string; + transcriptPath?: string; + fallbackContext?: string; + timeoutMs?: number; + spawn?: typeof Bun.spawn; + installedTools?: string[]; +}): Promise { + const context = await buildTitleContext(opts); + if (!context) return null; + const result = await generateTitleFromContext(context, opts); + return result.ok ? result.title : null; } describe("parseTitleFromOutput", () => { @@ -53,6 +90,20 @@ describe("parseTitleFromOutput", () => { }); describe("generateSessionTitle", () => { + // The other half of the judge's cwd invariant (see tests/handler/judge.test.ts): + // a naming spawn must NOT run in the session's tree. Everything it needs is in + // the prompt, and the agents whose resume picker is scoped by directory would + // otherwise file it where the user's own --continue finds it. + test("spawns in a throwaway directory, never the caller's cwd", async () => { + const { spawn, options } = fakeSpawn("Add retry to uploader"); + const callerCwd = tmp(); + await generateSessionTitle({ + tool: "claude-code", cwd: callerCwd, fallbackContext: "do a thing", spawn, + }); + expect(options[0]?.cwd).toBeDefined(); + expect(options[0]?.cwd).not.toBe(callerCwd); + }); + test("builds the prompt from the transcript and returns the parsed title", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, @@ -87,21 +138,67 @@ describe("generateSessionTitle", () => { expect(calls).toHaveLength(0); }); - // An agent with no vetted headless one-shot has nothing to ask; cursor-agent - // carries no `judge` in the registry. - test("an agent with no judge → null, without spawning", async () => { + // kimi carries no headless entry — nobody has run its headless argv — + // so its sessions are named by whichever installed agent has one, rather than + // being the only ones left echoing the opening prompt. + test("an agent with no titleCmd borrows an installed one", async () => { + const { spawn, calls } = fakeSpawn("Something useful\n"); + expect(await generateSessionTitle({ + tool: "kimi", cwd: tmp(), fallbackContext: "do a thing", spawn, + installedTools: ["claude-code"], + })).toBe("Something useful"); + expect(calls).toHaveLength(1); + expect(calls[0]![0]).toBe("claude"); + }); + + test("no installed agent can name it → null, without spawning", async () => { const { spawn, calls } = fakeSpawn("Something\n"); expect(await generateSessionTitle({ - tool: "cursor-agent", cwd: tmp(), fallbackContext: "do a thing", spawn, + tool: "kimi", cwd: tmp(), fallbackContext: "do a thing", spawn, + installedTools: [], })).toBeNull(); expect(calls).toHaveLength(0); }); - test("unknown tool → null", async () => { - const { spawn } = fakeSpawn("Something\n"); + // Borrowing is scoped to agents we know: a tool that is not a registry key is + // not a session we can describe. + test("unknown tool → null, without spawning", async () => { + const { spawn, calls } = fakeSpawn("Something\n"); expect(await generateSessionTitle({ tool: "not-an-agent", cwd: tmp(), fallbackContext: "x", spawn, + installedTools: ["claude-code"], })).toBeNull(); + expect(calls).toHaveLength(0); + }); + + // These CLIs print their refusals to stdout, short enough to clear every one + // of parseTitleFromOutput's checks. A `self`-ranked error string outranks the + // first-message re-read and the attempt is never repeated, so the wrong name + // would be permanent. + test("a non-zero exit yields null, however title-shaped the stdout is", async () => { + const { spawn } = fakeSpawn("Invalid API key\n", { exitCode: 1 }); + expect(await generateSessionTitle({ + tool: "claude-code", cwd: tmp(), fallbackContext: "do a thing", spawn, + })).toBeNull(); + }); + + // Both are declared on AgentSpec.titleCmd and neither has any other witness: + // OPENCODE_DB keeps the run out of the session store the transcript reader + // also READS, and the strip keeps it from firing the globally-installed + // opencode/agy hooks for a conversation that does not exist. + test("the spawn carries the agent's env and never ANTGRID_TERMINAL_ID", async () => { + process.env.ANTGRID_TERMINAL_ID = "term-1"; + try { + const { spawn, options } = fakeSpawn("Name the thing\n"); + expect(await generateSessionTitle({ + tool: "opencode", cwd: tmp(), fallbackContext: "do a thing", spawn, + })).toBe("Name the thing"); + const env = options[0]!.env as Record; + expect(env.OPENCODE_DB).toBe(":memory:"); + expect("ANTGRID_TERMINAL_ID" in env).toBe(false); + } finally { + delete process.env.ANTGRID_TERMINAL_ID; + } }); test("a timeout yields null rather than a half-written title", async () => { @@ -119,3 +216,47 @@ describe("generateSessionTitle", () => { })).toBeNull(); }); }); + +// agent-core retries a "failed" within a small budget and never retries an +// "unavailable" (see TitleAttemptState), so which one a given failure produces +// decides whether a session can still be named later. Pinned here because the +// two are one `ok: false` to the type system and nothing else would catch a +// swap. +describe("failure reasons", () => { + test("no installed agent can serve the call is 'unavailable'", async () => { + const { spawn, calls } = fakeSpawn("Add retry to uploader"); + const r = await generateTitleFromContext("ctx", { + tool: "kimi", spawn, installedTools: [], + }); + expect(r).toEqual({ ok: false, reason: "unavailable" }); + // Nothing ran: the refusal is the machine's, not this attempt's. + expect(calls.length).toBe(0); + }); + + test("a non-zero exit is 'failed', so the next turn may try again", async () => { + const { spawn } = fakeSpawn("Invalid API key · Please run /login", { exitCode: 1 }); + const r = await generateTitleFromContext("ctx", { + tool: "claude-code", spawn, installedTools: ["claude-code"], + }); + expect(r).toEqual({ ok: false, reason: "failed" }); + }); + + test("a clean exit with unusable output is 'failed', not a title", async () => { + // Long enough to fail parseTitleFromOutput's cap — the spawn worked and the + // model rambled. + const { spawn } = fakeSpawn("Sure! Here is a title that goes on and on and " + + "on well past any reasonable length for naming a session"); + const r = await generateTitleFromContext("ctx", { + tool: "claude-code", spawn, installedTools: ["claude-code"], + }); + expect(r).toEqual({ ok: false, reason: "failed" }); + }); + + test("a usable title comes back as ok", async () => { + const { spawn } = fakeSpawn("Add retry to uploader"); + const r = await generateTitleFromContext("ctx", { + tool: "claude-code", spawn, installedTools: ["claude-code"], + }); + expect(r).toEqual({ ok: true, title: "Add retry to uploader" }); + }); +}); diff --git a/bridge/tests/title-resolver-copilot.test.ts b/bridge/tests/title-resolver-copilot.test.ts index 6dc069dd..c0f4999f 100644 --- a/bridge/tests/title-resolver-copilot.test.ts +++ b/bridge/tests/title-resolver-copilot.test.ts @@ -42,13 +42,18 @@ function seed( } describe("resolveCopilotSessionTitle", () => { - test("returns summary when present", async () => { + test("ignores Copilot's own summary and reports the first user turn", async () => { const home = tmp(); - seed(home, [{ id: "sess-1", summary: "Fix the parser" }]); - expect(await resolveCopilotSessionTitle("sess-1", home)).toEqual({ title: "Fix the parser", kind: "generated" }); + seed(home, [{ id: "sess-1", summary: "Fix the parser" }], [ + { session_id: "sess-1", turn_index: 0, user_message: "the parser drops semicolons" }, + ]); + // We name sessions ourselves, so Copilot's summary is not a title source — + // depending on it meant a session got a name only once Copilot wrote one. + expect(await resolveCopilotSessionTitle("sess-1", home)) + .toEqual({ title: "the parser drops semicolons", kind: "first-message" }); }); - test("falls back to first-turn user_message when summary is null", async () => { + test("reports the first-turn user_message", async () => { const home = tmp(); seed(home, [{ id: "sess-2", summary: null }], [ { session_id: "sess-2", turn_index: 1, user_message: "second" }, @@ -95,8 +100,10 @@ describe("copilotSessionExistsSync", () => { describe("resolveStructuredTitle -> github-copilot", () => { test("routes to the copilot reader via copilotHome", async () => { const home = tmp(); - seed(home, [{ id: "sess-9", summary: "Routed title" }]); + seed(home, [{ id: "sess-9", summary: "Routed title" }], [ + { session_id: "sess-9", turn_index: 0, user_message: "routed message" }, + ]); const title = await resolveStructuredTitle("github-copilot", { sessionId: "sess-9" }, { copilotHome: home }); - expect(title).toEqual({ title: "Routed title", kind: "generated" }); + expect(title).toEqual({ title: "routed message", kind: "first-message" }); }); }); diff --git a/bridge/tests/title-resolver.test.ts b/bridge/tests/title-resolver.test.ts index cf75fa24..2ac5cfeb 100644 --- a/bridge/tests/title-resolver.test.ts +++ b/bridge/tests/title-resolver.test.ts @@ -3,11 +3,10 @@ import { Database } from "bun:sqlite"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveCodexThreadName, resolveCodexThreadTitle } from "../src/agents/codex/title"; +import { resolveCodexThreadTitle } from "../src/agents/codex/title"; import { resolveClaudeTranscriptTitle } from "../src/agents/claude-code/title"; import { parseAntigravityRenames, - readAntigravitySummaries, resolveAntigravityRename, resolveAntigravityTranscriptTitle, } from "../src/agents/antigravity/title"; @@ -30,37 +29,13 @@ function writeStateDb( db.close(); } -describe("resolveCodexThreadName", () => { - test("returns the most recent thread_name for the id", async () => { - const home = tmp(); - writeFileSync(join(home, "session_index.jsonl"), - `{"id":"t1","thread_name":"Old","updated_at":"2024-01-01T00:00:00Z"}\n` + - `{"id":"t2","thread_name":"Other","updated_at":"2024-01-01T00:00:00Z"}\n` + - `{"id":"t1","thread_name":"New title","updated_at":"2024-01-02T00:00:00Z"}\n`); - expect(await resolveCodexThreadName("t1", home)).toEqual({ title: "New title", kind: "generated" }); - }); - test("missing file → null", async () => { - expect(await resolveCodexThreadName("t1", tmp())).toBeNull(); - }); - test("garbage lines are skipped, not thrown", async () => { - const home = tmp(); - writeFileSync(join(home, "session_index.jsonl"), - `not json\n{"id":"t1","thread_name":"Good","updated_at":"z"}\n{partial`); - expect(await resolveCodexThreadName("t1", home)).toEqual({ title: "Good", kind: "generated" }); - }); - test("unknown id → null", async () => { - const home = tmp(); - writeFileSync(join(home, "session_index.jsonl"), - `{"id":"t1","thread_name":"Good","updated_at":"z"}\n`); - expect(await resolveCodexThreadName("nope", home)).toBeNull(); - }); -}); - describe("resolveCodexThreadTitle", () => { - test("reads title from the threads table", async () => { + // A divergent `title` is the Codex DESKTOP app naming the thread for itself. + // We name sessions ourselves, so first_user_message is the column that counts. + test("ignores a desktop-written title and reads first_user_message", async () => { const home = tmp(); writeStateDb(home, 5, [{ id: "t1", title: "My title", first_user_message: "do the thing" }]); - expect(await resolveCodexThreadTitle("t1", home)).toEqual({ title: "My title", kind: "generated" }); + expect(await resolveCodexThreadTitle("t1", home)).toEqual({ title: "do the thing", kind: "first-message" }); }); test("falls back to first_user_message when title is blank", async () => { const home = tmp(); @@ -78,28 +53,27 @@ describe("resolveCodexThreadTitle", () => { }); test("picks the newest state_.sqlite", async () => { const home = tmp(); - writeStateDb(home, 4, [{ id: "t1", title: "old schema" }]); - writeStateDb(home, 12, [{ id: "t1", title: "new schema" }]); - expect(await resolveCodexThreadTitle("t1", home)).toEqual({ title: "new schema", kind: "generated" }); + writeStateDb(home, 4, [{ id: "t1", first_user_message: "old schema" }]); + writeStateDb(home, 12, [{ id: "t1", first_user_message: "new schema" }]); + expect(await resolveCodexThreadTitle("t1", home)).toEqual({ title: "new schema", kind: "first-message" }); }); test("missing DB → null", async () => { expect(await resolveCodexThreadTitle("t1", tmp())).toBeNull(); }); test("unknown id → null", async () => { const home = tmp(); - writeStateDb(home, 5, [{ id: "t1", title: "x" }]); + writeStateDb(home, 5, [{ id: "t1", first_user_message: "x" }]); expect(await resolveCodexThreadTitle("nope", home)).toBeNull(); }); }); describe("resolveClaudeTranscriptTitle", () => { - test("prefers the manual /rename custom-title over a summary and user message", async () => { + test("reports the manual /rename custom-title, outranking the user message", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, `{"type":"custom-title","customTitle":"Renamed by user","sessionId":"s1"}\n` + - `{"type":"user","message":{"content":"first message text"}}\n` + - `{"type":"summary","summary":"A summary"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Renamed by user", kind: "generated" }); + `{"type":"user","message":{"content":"first message text"}}\n`); + expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Renamed by user", kind: "manual" }); }); test("uses the LAST custom-title when renamed more than once", async () => { const d = tmp(); const p = join(d, "t.jsonl"); @@ -107,85 +81,57 @@ describe("resolveClaudeTranscriptTitle", () => { `{"type":"custom-title","customTitle":"Old name","sessionId":"s1"}\n` + `{"type":"user","message":{"content":"hi"}}\n` + `{"type":"custom-title","customTitle":"New name","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "New name", kind: "generated" }); + expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "New name", kind: "manual" }); }); - test("a blank custom-title falls through to the summary", async () => { + test("a blank custom-title falls through to the first user message", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, `{"type":"custom-title","customTitle":" ","sessionId":"s1"}\n` + - `{"type":"summary","summary":"Real summary"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Real summary", kind: "generated" }); + `{"type":"user","message":{"content":"the real content"}}\n`); + expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "the real content", kind: "first-message" }); }); - test("prefers the last summary line", async () => { - const d = tmp(); const p = join(d, "t.jsonl"); - writeFileSync(p, - `{"type":"user","message":{"content":"first message text"}}\n` + - `{"type":"summary","summary":"First summary"}\n` + - `{"type":"summary","summary":"Latest summary"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Latest summary", kind: "generated" }); - }); - test("falls back to first user message when no summary", async () => { + test("falls back to the first user message", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, `{"type":"user","message":{"content":"do the thing"}}\n`); expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "do the thing", kind: "first-message" }); }); - test("an empty/blank summary does not suppress the first user message", async () => { - const d = tmp(); const p = join(d, "t.jsonl"); - writeFileSync(p, - `{"type":"user","message":{"content":"the real title"}}\n` + - `{"type":"summary","summary":" "}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "the real title", kind: "first-message" }); - }); test("handles array content (first text part)", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, `{"type":"user","message":{"content":[{"type":"text","text":"hello there"}]}}\n`); expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "hello there", kind: "first-message" }); }); - test("reads Claude's own ai-title (the current spelling of the generated title)", async () => { + // Claude's OWN name for the conversation. Both spellings are deliberately not + // read — we generate our own rather than depend on whether Claude wrote one, + // which it does in the interactive TUI and never in a headless/SDK run. + test("ignores Claude's ai-title", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, `{"type":"user","message":{"content":"please fix the flaky login test on windows ci"}}\n` + `{"type":"ai-title","aiTitle":"Fix flaky Windows login test","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Fix flaky Windows login test", kind: "generated" }); + expect(await resolveClaudeTranscriptTitle(p)) + .toEqual({ title: "please fix the flaky login test on windows ci", kind: "first-message" }); }); - test("uses the LAST ai-title — a title can be revised over a long session", async () => { + test("ignores a compaction summary", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, - `{"type":"ai-title","aiTitle":"Early guess","sessionId":"s1"}\n` + - `{"type":"user","message":{"content":"hi"}}\n` + - `{"type":"ai-title","aiTitle":"Settled topic","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Settled topic", kind: "generated" }); + `{"type":"user","message":{"content":"do the thing"}}\n` + + `{"type":"summary","summary":"A summary"}\n`); + expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "do the thing", kind: "first-message" }); }); - // Precedence is by TYPE, not file position: a renamed conversation restates - // both records every turn and the ai-title is normally the LATER of the pair, - // so this fixture uses the order that actually occurs on disk. - test("a user's custom-title outranks Claude's ai-title even when written first", async () => { + // The two records coexist — a renamed conversation restates both every turn, + // with the ai-title normally the LATER of the pair — so dropping ai-title must + // not take the rename with it. + test("a rename still resolves when Claude's own title follows it", async () => { const d = tmp(); const p = join(d, "t.jsonl"); writeFileSync(p, `{"type":"custom-title","customTitle":"Renamed by user","sessionId":"s1"}\n` + `{"type":"ai-title","aiTitle":"Claude's own name","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Renamed by user", kind: "generated" }); + expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Renamed by user", kind: "manual" }); }); - test("ai-title outranks a compaction summary", async () => { + test("an ai-title alone leaves the session unresolved rather than named", async () => { const d = tmp(); const p = join(d, "t.jsonl"); - writeFileSync(p, - `{"type":"summary","summary":"A summary"}\n` + - `{"type":"ai-title","aiTitle":"Real title","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Real title", kind: "generated" }); - }); - test("a blank ai-title does not suppress the first user message", async () => { - const d = tmp(); const p = join(d, "t.jsonl"); - writeFileSync(p, - `{"type":"user","message":{"content":"the real title"}}\n` + - `{"type":"ai-title","aiTitle":" ","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "the real title", kind: "first-message" }); - }); - test("a blank ai-title does not erase a real one seen earlier", async () => { - const d = tmp(); const p = join(d, "t.jsonl"); - writeFileSync(p, - `{"type":"ai-title","aiTitle":"Real title","sessionId":"s1"}\n` + - `{"type":"ai-title","aiTitle":" ","sessionId":"s1"}\n`); - expect(await resolveClaudeTranscriptTitle(p)).toEqual({ title: "Real title", kind: "generated" }); + writeFileSync(p, `{"type":"ai-title","aiTitle":"Claude's own name","sessionId":"s1"}\n`); + expect(await resolveClaudeTranscriptTitle(p)).toBeNull(); }); test("missing file → null", async () => { expect(await resolveClaudeTranscriptTitle(join(tmp(), "nope.jsonl"))).toBeNull(); @@ -222,40 +168,6 @@ describe("resolveAntigravityTranscriptTitle", () => { }); }); -/** Mirror the shape agy's conversation_summaries.db exposes (subset of columns - * we read): manual `title` and generated `preview` per conversation. */ -function writeSummariesDb( - home: string, - rows: Array<{ id: string; title?: string; preview?: string }>, -) { - const db = new Database(join(home, "conversation_summaries.db")); - db.run("CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT, preview TEXT)"); - const stmt = db.query("INSERT INTO conversation_summaries (conversation_id, title, preview) VALUES (?, ?, ?)"); - for (const r of rows) stmt.run(r.id, r.title ?? "", r.preview ?? ""); - db.close(); -} - -describe("readAntigravitySummaries", () => { - test("maps a conversation to its generated preview", () => { - const home = tmp(); - writeSummariesDb(home, [{ id: "c1", title: "", preview: "Casual Greeting And Introduction" }]); - expect(readAntigravitySummaries(home).get("c1")).toBe("Casual Greeting And Introduction"); - }); - test("prefers the manual title over the generated preview", () => { - const home = tmp(); - writeSummariesDb(home, [{ id: "c1", title: "renamed", preview: "generated name" }]); - expect(readAntigravitySummaries(home).get("c1")).toBe("renamed"); - }); - test("omits a conversation with neither title nor preview", () => { - const home = tmp(); - writeSummariesDb(home, [{ id: "c1", title: "", preview: "" }]); - expect(readAntigravitySummaries(home).has("c1")).toBe(false); - }); - test("missing db → empty map, no throw", () => { - expect(readAntigravitySummaries(tmp()).size).toBe(0); - }); -}); - describe("parseAntigravityRenames", () => { test("keeps the latest /rename per conversationId", () => { const raw = @@ -319,29 +231,9 @@ describe("resolveStructuredTitle dispatch", () => { `{"display":"/rename my chat","conversationId":"c1","type":"slash_command"}\n`); expect( await resolveStructuredTitle("antigravity", { sessionId: "c1", transcriptPath: p }, { antigravityHome: home }), - ).toEqual({ title: "my chat", kind: "generated" }); + ).toEqual({ title: "my chat", kind: "manual" }); }); - test("antigravity prefers agy's generated preview over the first user message", async () => { - const home = tmp(); - const p = join(home, "transcript_full.jsonl"); - writeFileSync(p, `{"type":"USER_INPUT","content":"hii"}\n`); - writeSummariesDb(home, [{ id: "c1", preview: "Casual Greeting And Introduction" }]); - expect( - await resolveStructuredTitle("antigravity", { sessionId: "c1", transcriptPath: p }, { antigravityHome: home }), - ).toEqual({ title: "Casual Greeting And Introduction", kind: "generated" }); - }); - test("antigravity prefers a live /rename over agy's generated preview", async () => { - const home = tmp(); - const p = join(home, "transcript_full.jsonl"); - writeFileSync(p, `{"type":"USER_INPUT","content":"hii"}\n`); - writeSummariesDb(home, [{ id: "c1", preview: "Casual Greeting And Introduction" }]); - writeFileSync(join(home, "history.jsonl"), - `{"display":"/rename my chat","conversationId":"c1","type":"slash_command"}\n`); - expect( - await resolveStructuredTitle("antigravity", { sessionId: "c1", transcriptPath: p }, { antigravityHome: home }), - ).toEqual({ title: "my chat", kind: "generated" }); - }); - test("antigravity falls back to the first user message with no rename or preview", async () => { + test("antigravity falls back to the first user message with no rename", async () => { const home = tmp(); const p = join(home, "transcript_full.jsonl"); writeFileSync(p, `{"type":"USER_INPUT","content":"first turn"}\n`); @@ -361,28 +253,26 @@ describe("resolveStructuredTitle dispatch", () => { }); - test("codex via sessionId (desktop session_index)", async () => { - const home = tmp(); - writeFileSync(join(home, "session_index.jsonl"), - `{"id":"abc","thread_name":"Codex title","updated_at":"z"}\n`); - expect(await resolveStructuredTitle("codex", { sessionId: "abc" }, { codexHome: home })).toEqual({ title: "Codex title", kind: "generated" }); - }); - test("codex falls back to the CLI state DB when session_index lacks the thread", async () => { + test("codex via sessionId reads the CLI state DB", async () => { const home = tmp(); - writeStateDb(home, 5, [{ id: "abc", title: "From state DB" }]); - expect(await resolveStructuredTitle("codex", { sessionId: "abc" }, { codexHome: home })).toEqual({ title: "From state DB", kind: "generated" }); + writeStateDb(home, 5, [{ id: "abc", first_user_message: "From state DB" }]); + expect(await resolveStructuredTitle("codex", { sessionId: "abc" }, { codexHome: home })) + .toEqual({ title: "From state DB", kind: "first-message" }); }); - test("codex prefers the desktop session_index over the state DB when both have the thread", async () => { + // session_index.jsonl is written only by the Codex DESKTOP app, and every + // name in it is one that app generated — so it is not consulted at all now. + test("codex ignores the desktop session_index", async () => { const home = tmp(); writeFileSync(join(home, "session_index.jsonl"), `{"id":"abc","thread_name":"Rich desktop title","updated_at":"z"}\n`); - writeStateDb(home, 5, [{ id: "abc", title: "CLI first-message title" }]); - expect(await resolveStructuredTitle("codex", { sessionId: "abc" }, { codexHome: home })).toEqual({ title: "Rich desktop title", kind: "generated" }); + writeStateDb(home, 5, [{ id: "abc", first_user_message: "what the user asked" }]); + expect(await resolveStructuredTitle("codex", { sessionId: "abc" }, { codexHome: home })) + .toEqual({ title: "what the user asked", kind: "first-message" }); }); test("claude via transcriptPath", async () => { const d = tmp(); const p = join(d, "t.jsonl"); - writeFileSync(p, `{"type":"summary","summary":"Claude title"}\n`); - expect(await resolveStructuredTitle("claude", { sessionId: "x", transcriptPath: p })).toEqual({ title: "Claude title", kind: "generated" }); + writeFileSync(p, `{"type":"custom-title","customTitle":"Claude title","sessionId":"x"}\n`); + expect(await resolveStructuredTitle("claude", { sessionId: "x", transcriptPath: p })).toEqual({ title: "Claude title", kind: "manual" }); }); test("claude without transcriptPath → null", async () => { expect(await resolveStructuredTitle("claude", { sessionId: "x" })).toBeNull(); diff --git a/bridge/tests/work-status.test.ts b/bridge/tests/work-status.test.ts index b1a485d2..08c4769c 100644 --- a/bridge/tests/work-status.test.ts +++ b/bridge/tests/work-status.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { answerRequest, clientFocusState, clientGone, closeTurn, initialWorkStatus, reduceWorkStatus, sessionFocus, turnStart, userReply, type WorkStatusState } from "../src/work-status"; +import { answerRequest, clientFocusState, clientGone, closeTurn, initialWorkStatus, isStaleIdleNudge, reduceWorkStatus, sessionFocus, turnStart, userReply, type WorkStatusState } from "../src/work-status"; import type { InboundSource } from "../src/message-bus"; /** The two client classes the read state distinguishes: the phone reaches a core @@ -157,6 +157,54 @@ test("a turn-start clears a stale awaiting_input (question answered, work resume expect(turnStart(blocked, "r0").status).toBe("working"); }); +// ── isStaleIdleNudge (the shared predicate) ───────────────────────────────── +// +// The same question the fold above asks itself, exported so the /handler-event +// path drops the nudge before the Handler pays for a context assemble and a +// judge spawn. One definition, so the two paths cannot drift. + +test("isStaleIdleNudge is true only after this session's OWN task_complete", () => { + expect(isStaleIdleNudge(fold([sessions(1), push("task_complete", "r0")]), "r0")).toBe(true); + expect(isStaleIdleNudge(fold([sessions(1), push("awaiting_input", "r0")]), "r0")).toBe(false); + expect(isStaleIdleNudge(fold([sessions(1)]), "r0")).toBe(false); +}); + +test("a recorded permission_request is never stale", () => { + // The paired /notify races the /handler-event POST and re-files the same hook + // firing as permission_request whenever its message isn't the waiting nudge. + // If that lands first it must UNLATCH the window — a genuine permission block + // dropped here has no further event able to raise it. + const s = fold([sessions(1), push("task_complete", "r0"), push("permission_request", "r0")]); + expect(isStaleIdleNudge(s, "r0")).toBe(false); +}); + +test("isStaleIdleNudge never reads the unattributed fallback", () => { + // Mirrors the attributed-vs-fallback rule the fold's own stale check keeps: + // a config-`terminals:` slot finishing must not silence a real first block on + // a session that has recorded nothing of its own. + const s = fold([sessions(2), push("task_complete", "service-terminal-1")]); + expect(s.notifications.get("")).toBe("task_complete"); + expect(isStaleIdleNudge(s, "r1")).toBe(false); +}); + +test("a turn-start closes the suppression window", () => { + const done = fold([sessions(1), push("task_complete", "r0")]); + expect(isStaleIdleNudge(turnStart(done, "r0"), "r0")).toBe(false); +}); + +test("a keystroke-inferred turn start also closes the window", () => { + // The agents with no pre-turn hook (codex/cursor/copilot) open their turn off + // a submitted keystroke — that has to unlatch the window too, or their whole + // next turn is unsupervised. + const done = fold([sessions(1, { tool: "codex" }), push("task_complete", "r0")]); + const typed = userReply(done, "r0", { typed: true }); + expect(isStaleIdleNudge(userReply(typed, "r0", { submitted: true }), "r0")).toBe(false); +}); + +test("an unknown session id is not stale", () => { + expect(isStaleIdleNudge(fold([sessions(1), push("task_complete", "r0")]), "not-a-session")).toBe(false); +}); + test("attention > error > working precedence: a fresh permission wins over a live turn", () => { // error then permission_request: latest turn-end signal wins. expect(fold([sessions(1), turnStartFrame("r0"), push("error"), push("permission_request")]).status).toBe("attention"); diff --git a/evals/tests/chat-session-opencode.test.ts b/evals/tests/chat-session-opencode.test.ts index f36a973a..9587fa7f 100644 --- a/evals/tests/chat-session-opencode.test.ts +++ b/evals/tests/chat-session-opencode.test.ts @@ -4,20 +4,20 @@ import { createMessage } from "../../bridge/src/protocol"; import { promptUntilTurnStart } from "../helpers/chat"; import { bindFirstProject } from "../support/stream"; -// End-to-end proof that a chat-mode opencode session auto-names itself from the -// conversation title. Unlike codex (notify.js) or claude (SDK), opencode's own -// server generates a title and pushes it on the session.updated event the -// OpencodeDriver already consumes; the driver's onTitle callback forwards it -// through SessionNamer → applyAutoName → session:updated. We assert the default +// End-to-end proof that a chat-mode opencode session auto-names itself. opencode +// ships neither hooks nor a title we read (its own generated one is dropped — +// see ResolvedTitle), so this is the agent that proves the naming path which +// depends on no vendor integration at all: StructuredAgentManager's onUserPrompt +// tap hands the bridge the first message, which names the session through +// SessionNamer → applyAutoName → session:updated. We assert the default // "Session N" name is replaced by a real title. // // Gated on a real `opencode` binary on PATH AND an explicit opt-in signalling -// that opencode auth (a configured model provider) is available: a generated -// title requires the server to actually run a model, and without auth opencode -// leaves the title at a placeholder so the session never renames. The env -// opt-in avoids a spurious failure on binary-present-but-unauthenticated boxes -// (most CI), matching how chat-session-codex.test.ts skips when its prereqs are -// absent. Set ANTGRID_EVAL_OPENCODE_AUTH=1 on a box with opencode auth to run it. +// that opencode auth (a configured model provider) is available: the turn this +// waits on has to actually run a model. The env opt-in avoids a spurious failure +// on binary-present-but-unauthenticated boxes (most CI), matching how +// chat-session-codex.test.ts skips when its prereqs are absent. Set +// ANTGRID_EVAL_OPENCODE_AUTH=1 on a box with opencode auth to run it. const HAVE_OPENCODE = Bun.which("opencode") !== null && process.env.ANTGRID_EVAL_OPENCODE_AUTH === "1"; @@ -89,9 +89,9 @@ describe.skipIf(!HAVE_OPENCODE)("chat-session opencode auto-title", () => { } expect(turnEnded).toBe(true); - // The title arrives on its own via session:updated once opencode's server - // generates it and emits session.updated. Poll until this session's name - // stops matching the default pattern. + // The title arrives on its own via session:updated once the naming spawn the + // first prompt started comes back. Poll until this session's name stops + // matching the default pattern. let renamed = false; const nameDeadline = Date.now() + 40_000; while (Date.now() < nameDeadline && !renamed) { diff --git a/site/src/components/sections/CrossAgent.astro b/site/src/components/sections/CrossAgent.astro index c019334a..bee900a2 100644 --- a/site/src/components/sections/CrossAgent.astro +++ b/site/src/components/sections/CrossAgent.astro @@ -2,6 +2,7 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Chip from "../ui/Chip.astro"; +import UseCase from "../ui/UseCase.astro"; // Keep in lockstep with AGENTS in bridge/src/agents/registry.ts — these are the // agents wired for notifications and session naming. Anything else still runs (the @@ -22,13 +23,15 @@ const steps = [ antgrid doesn't replace your coding agent — it's the command centre over the ones you already run. No new model, no new CLI to learn.

-
+
{agents.map((a) => ( {a} ))} any terminal agent
+ Pick the agent per task, not per tool — the command centre stays the same. +

How it works

{/* Numbered because setup genuinely is ordered — you can't run an agent diff --git a/site/src/components/sections/Fleet.astro b/site/src/components/sections/Fleet.astro index 9c7114c0..a112c486 100644 --- a/site/src/components/sections/Fleet.astro +++ b/site/src/components/sections/Fleet.astro @@ -2,6 +2,7 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Readout from "../ui/Readout.astro"; +import UseCase from "../ui/UseCase.astro"; type Row = { name: string; agent: string; task: string; time: string; state: "working" | "needs" | "done" }; type Machine = { host: string; tag?: string; rows: Row[] }; @@ -62,5 +63,7 @@ const doneCount = allRows.filter((r) => r.state === "done").length; ))} {workingCount} working · {doneCount} done + + Glance once, answer the one that's blocked, close the phone. No tabbing through six terminals to find it.
diff --git a/site/src/components/sections/Phases.astro b/site/src/components/sections/Phases.astro index 1f90eabf..81fbcee1 100644 --- a/site/src/components/sections/Phases.astro +++ b/site/src/components/sections/Phases.astro @@ -2,6 +2,7 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Readout from "../ui/Readout.astro"; +import UseCase from "../ui/UseCase.astro"; // The phase-gated workflow (research → validate → plan → implement, a human // checkpoint between each) is the industry-standard agent loop — Anthropic's @@ -10,11 +11,20 @@ import Readout from "../ui/Readout.astro"; // Handler answers the agent within the session and holds items on conditions — // it never starts work on its own, so "implement" below is WAITING on the // human's pick, not auto-dispatched. +// Three numbers, and the third changes register — the first two size the chore, +// the last is the guarantee. Kept to figures the page can stand behind: 4 and 0 +// are the mechanism, ~30 min is stated as a human delay, not a measured metric. +const stats = [ + { value: "4", label: "phases in a serious run — research, validate, plan, implement", accent: false }, + { value: "~30 min", label: "your delay at each gate, on the days you're at the desk", accent: false }, + { value: "0", label: "phases advanced on the agent's say-so", accent: true }, +]; + const phases = [ - { n: "01", name: "research", state: "done", note: "pushed back — shallow. second pass closed it" }, - { n: "02", name: "validate", state: "done", note: "no red flags — advanced" }, - { n: "03", name: "plan", state: "needs", note: "woke you — two viable approaches, your pick" }, - { n: "04", name: "implement", state: "queued", note: "starts when you pick" }, + { n: "01", name: "research", time: "00:36", state: "done", note: "pushed back — shallow. second pass closed it" }, + { n: "02", name: "validate", time: "02:34", state: "done", note: "no red flags — advanced" }, + { n: "03", name: "plan", time: "03:21", state: "needs", note: "woke you — two viable approaches, your pick" }, + { n: "04", name: "implement", time: "", state: "queued", note: "starts when you pick" }, ]; ---
@@ -29,11 +39,20 @@ const phases = [

+
+ {stats.map((s) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
- you · armed + you · armed · 23:47 research the provider swap; validate it against our rate limits; plan it — give me options before implementing
@@ -46,11 +65,22 @@ const phases = [ {p.state === "needs" && } {p.state === "queued" && }
-
{p.name}
+
+ {p.name} + {p.time && {p.time}} +
{p.note}
))}
+ + Type the sequence once, before you leave. You get woken for the call that's actually yours. + + {/* The limitation, on the page rather than in a code comment — a reader who + assumes autonomy here and discovers otherwise has been mis-sold. */} +

+ Handler answers the agent inside a running session and holds a phase until its condition is met. It never starts work on its own, and it never advances a phase on the agent's say-so. +

diff --git a/site/src/components/sections/Reach.astro b/site/src/components/sections/Reach.astro index 4f4ee022..e6de6555 100644 --- a/site/src/components/sections/Reach.astro +++ b/site/src/components/sections/Reach.astro @@ -3,6 +3,7 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Chip from "../ui/Chip.astro"; import Readout from "../ui/Readout.astro"; +import UseCase from "../ui/UseCase.astro"; // This section owns REACH — where the code lives, and what crossing the network // normally costs. It deliberately does not argue encryption: Privacy.astro owns @@ -29,12 +30,14 @@ const ours = ["install antgrid on the machine", "sign in"];

Getting back to that machine is the part that normally costs you an afternoon. antgrid needs no inbound port: the machine dials out, your app dials out, and they meet at the relay. Corporate NAT, hotel wifi, a phone on cellular — it's the same connection every time.

-
+
no VPN no port forwarding no SSH config no inbound ports
+ + The machine under your desk, reachable from a train — same repo, same branches, nothing copied anywhere.
diff --git a/site/src/components/sections/Worktrees.astro b/site/src/components/sections/Worktrees.astro index 2c26c6a5..12153dd5 100644 --- a/site/src/components/sections/Worktrees.astro +++ b/site/src/components/sections/Worktrees.astro @@ -3,6 +3,7 @@ import { Icon } from "astro-icon/components"; import Eyebrow from "../ui/Eyebrow.astro"; import Chip from "../ui/Chip.astro"; import Readout from "../ui/Readout.astro"; +import UseCase from "../ui/UseCase.astro"; // Framed as safety for Handler autonomy, never as parallelism-at-scale — the // "N agents in parallel" axis is a competitor's headline and first-party table @@ -26,11 +27,13 @@ const sessions = [

An agent is only safe to walk away from when its blast radius is a branch. When the work proves out, review and merge it. When it doesn't, throw the worktree away — main never knew.

-
+
own checkout, own branch your view follows the worktree main stays clean
+ + Walk away from a running agent without wondering what state you'll find your tree in.
diff --git a/site/src/components/ui/UseCase.astro b/site/src/components/ui/UseCase.astro new file mode 100644 index 00000000..5093688c --- /dev/null +++ b/site/src/components/ui/UseCase.astro @@ -0,0 +1,14 @@ +--- +// The consequence line under a section's argument: what the mechanism above +// actually buys the reader, in one sentence, second person. One per section, so +// every band answers "and therefore?" instead of leaving the reader to infer it. +// +// The arrow carries the accent and the sentence stays ink — six of these down the +// page in signal would spend the colour the live readouts need (see Readout.astro +// on scarcity). Mono because it is an aside off the argument, not more argument. +const { class: cls = "" } = Astro.props; +--- +

+ + +