From a0b9280df4dc0e3e0d64051c1e516bd0107b4cd2 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Sun, 16 Aug 2026 21:30:29 +0200 Subject: [PATCH 01/76] ci(fork): build unsigned Windows integration installer Signed-off-by: Robi Hermes --- .../workflows/windows-fork-integration.yml | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/windows-fork-integration.yml diff --git a/.github/workflows/windows-fork-integration.yml b/.github/workflows/windows-fork-integration.yml new file mode 100644 index 00000000000..f13ec09bc08 --- /dev/null +++ b/.github/workflows/windows-fork-integration.yml @@ -0,0 +1,106 @@ +name: Windows Fork Integration Build + +# Fork-only unsigned NSIS build for explicit testing. Never submit upstream. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build unsigned Windows integration installer + if: github.repository == 'Jari81/buzz' + runs-on: windows-latest + timeout-minutes: 90 + env: + TARGET: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Add Rust target + shell: bash + run: rustup target add "$TARGET" + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Derive test-only version + id: version + shell: bash + run: | + set -euo pipefail + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + if ! [[ "$BASE_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Desktop version '$BASE_VERSION' is not semver" + exit 1 + fi + VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))-fork.${GITHUB_RUN_NUMBER}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building unsigned test version $VERSION from $GITHUB_SHA" + + - name: Patch test-only version + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cd desktop + node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri + cargo update --workspace + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + . -> target + desktop/src-tauri -> target + shared-key: windows-fork-integration + + - name: Generate non-updating bundle config + shell: bash + run: node -e "require('fs').writeFileSync('desktop/src-tauri/tauri.canary.conf.json', JSON.stringify({bundle:{createUpdaterArtifacts:false}}))" + + - name: Build sidecars + shell: bash + run: | + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + + - name: Build unsigned Windows NSIS installer + shell: bash + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + run: cd desktop && pnpm tauri build --target "$TARGET" --bundles nsis --config src-tauri/tauri.canary.conf.json + + - name: Locate installer + id: artifact + shell: bash + run: | + set -euo pipefail + BUNDLE_DIR="desktop/src-tauri/target/${TARGET}/release/bundle" + EXE=$(find "$BUNDLE_DIR/nsis" -name '*.exe' -type f | head -1) + if [[ -z "$EXE" ]]; then + echo "::error::No NSIS installer found in $BUNDLE_DIR/nsis" + exit 1 + fi + echo "exe=$EXE" >> "$GITHUB_OUTPUT" + + - name: Upload unsigned installer + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-windows-integration-${{ github.sha }} + path: ${{ steps.artifact.outputs.exe }} + if-no-files-found: error + retention-days: 7 From 377ebbdfc64f912f4d5dcdeabb0eea240b6d97a2 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:50:53 -0500 Subject: [PATCH 02/76] fix(desktop): recover Windows notification permission Co-authored-by: Brad Groux Signed-off-by: Brad Groux --- desktop/src/features/notifications/hooks.ts | 24 ++++- .../notifications/lib/permission.test.mjs | 99 +++++++++++++++++++ .../features/notifications/lib/permission.ts | 26 +++++ desktop/src/shared/lib/platform.test.mjs | 34 +++++++ desktop/src/shared/lib/platform.ts | 9 ++ desktop/tests/e2e/profile.spec.ts | 96 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 22 +++++ 7 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/notifications/lib/permission.test.mjs create mode 100644 desktop/src/features/notifications/lib/permission.ts create mode 100644 desktop/src/shared/lib/platform.test.mjs diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 1a2cb4a9a53..3c21282cfd9 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -1,15 +1,18 @@ import * as React from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { scheduleAfterForegroundReady } from "@/shared/lib/foregroundReady"; +import { isWindowsPlatform } from "@/shared/lib/platform"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, type DesktopNotificationPermissionState, } from "./lib/desktop"; +import { ensureDesktopNotificationPermission } from "./lib/permission"; import { COMING_SOON_SLOTS, DEFAULT_SLOT_ALERTS_ENABLED, @@ -200,7 +203,16 @@ export function useNotificationSettings(pubkey?: string) { }, [normalizedPubkey, settings]); const refreshPermission = React.useEffectEvent(async () => { - const nextPermission = await getDesktopNotificationPermissionState(); + let nextPermission = await getDesktopNotificationPermissionState(); + // Windows Tauri boots with a false "denied" from the init shim before the + // app is registered as a notification sender. Apply the same one-shot + // recovery the toggle uses so the mount-time read does not write off a + // persisted desktopEnabled=true before the user touches anything. + nextPermission = await ensureDesktopNotificationPermission({ + currentPermission: nextPermission, + isWindowsTauri: isWindowsPlatform() && isTauri(), + requestAccess: requestDesktopNotificationAccess, + }); setPermission(nextPermission); return nextPermission; }); @@ -258,10 +270,12 @@ export function useNotificationSettings(pubkey?: string) { try { let nextPermission = await refreshPermission(); - if (nextPermission === "default") { - nextPermission = await requestDesktopNotificationAccess(); - setPermission(nextPermission); - } + nextPermission = await ensureDesktopNotificationPermission({ + currentPermission: nextPermission, + isWindowsTauri: isWindowsPlatform() && isTauri(), + requestAccess: requestDesktopNotificationAccess, + }); + setPermission(nextPermission); if (nextPermission !== "granted") { setSettings((current) => ({ diff --git a/desktop/src/features/notifications/lib/permission.test.mjs b/desktop/src/features/notifications/lib/permission.test.mjs new file mode 100644 index 00000000000..79797f97028 --- /dev/null +++ b/desktop/src/features/notifications/lib/permission.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureDesktopNotificationPermission } from "./permission.ts"; + +test("Windows Tauri retries a false denied permission and accepts the granted result", async () => { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: true, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1); +}); + +test("non-Windows-Tauri environments keep denied permission without requesting again", async () => { + for (const environment of [ + "Windows web", + "non-Windows Tauri", + "non-Windows web", + ]) { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: false, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "denied", environment); + assert.equal(requestCount, 0, environment); + } +}); + +test("default permission still requests access on every platform", async () => { + for (const isWindowsTauri of [false, true]) { + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "default", + isWindowsTauri, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1); + } +}); + +test("Windows Tauri recovers a false denied at boot so persisted desktopEnabled survives relaunch", async () => { + // Simulates the mount-time refreshPermission path: the init shim stamps + // "denied" before the app is registered as a notification sender, but a + // single requestPermission() returns "granted". Without this recovery the + // mount-time effect writes desktopEnabled=false before the user touches + // anything, requiring re-enabling after every restart. + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "denied", + isWindowsTauri: true, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 1, "boot-time recovery fires exactly once"); +}); + +test("Windows Tauri does not recover a genuine granted permission at boot", async () => { + // If the OS already grants permission, the boot-time read should not + // trigger an unnecessary requestPermission() call. + let requestCount = 0; + + const permission = await ensureDesktopNotificationPermission({ + currentPermission: "granted", + isWindowsTauri: true, + requestAccess: async () => { + requestCount += 1; + return "granted"; + }, + }); + + assert.equal(permission, "granted"); + assert.equal(requestCount, 0, "granted does not trigger a re-request"); +}); diff --git a/desktop/src/features/notifications/lib/permission.ts b/desktop/src/features/notifications/lib/permission.ts new file mode 100644 index 00000000000..f9f3a6a1a50 --- /dev/null +++ b/desktop/src/features/notifications/lib/permission.ts @@ -0,0 +1,26 @@ +import type { DesktopNotificationPermissionState } from "./desktop"; + +type EnsureDesktopNotificationPermissionOptions = { + currentPermission: DesktopNotificationPermissionState; + isWindowsTauri: boolean; + requestAccess: () => Promise; +}; + +/** + * Requests access for the normal default state and retries the Windows Tauri + * notification shim's known false-denied state. + */ +export async function ensureDesktopNotificationPermission({ + currentPermission, + isWindowsTauri, + requestAccess, +}: EnsureDesktopNotificationPermissionOptions): Promise { + if ( + currentPermission === "default" || + (currentPermission === "denied" && isWindowsTauri) + ) { + return requestAccess(); + } + + return currentPermission; +} diff --git a/desktop/src/shared/lib/platform.test.mjs b/desktop/src/shared/lib/platform.test.mjs new file mode 100644 index 00000000000..099bb132da0 --- /dev/null +++ b/desktop/src/shared/lib/platform.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isWindowsPlatform } from "./platform.ts"; + +function withNavigatorPlatform(platform, callback) { + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform, userAgent: "" }, + }); + + try { + callback(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator); + } else { + delete globalThis.navigator; + } + } +} + +test("Windows platform detection accepts Win32 without matching Darwin", () => { + withNavigatorPlatform("Win32", () => { + assert.equal(isWindowsPlatform(), true); + }); + withNavigatorPlatform("Darwin", () => { + assert.equal(isWindowsPlatform(), false); + }); +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944f..b8c2c3ecfc7 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean { ); } +/** Returns true on Windows desktops. */ +export function isWindowsPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return /^win/i.test(navigator.platform); +} + /** * The platform's normal application-shortcut modifier: * - macOS: Command (Meta) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 6c88b628ce8..cb31d6a7282 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -2319,6 +2319,102 @@ test("notification settings drive the Inbox badge and desktop alerts", async ({ await expect.poll(getAppBadgeCount).toBe(baseline); }); +test("Windows retries a false denied notification permission from settings", async ({ + page, +}) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + (window as Window & { isTauri?: boolean }).isTauri = true; + }); + await page.goto("/"); + + await page.evaluate(() => { + ( + window as Window & { + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; + } + ).__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?.("denied", "granted"); + }); + + await openSettings(page, "notifications"); + const desktopToggle = page.getByTestId("notifications-desktop-toggle"); + const desktopState = page.getByTestId("notifications-desktop-state"); + + await desktopToggle.click(); + await expect(desktopToggle).not.toBeChecked(); + await expect(desktopState).toContainText("Blocked"); + + await desktopToggle.click(); + await expect(desktopToggle).toBeChecked(); + await expect(desktopState).toContainText("On"); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number; + } + ).__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?.() ?? 0, + ), + ) + .toBe(1); +}); + +test("Windows boot-time recovery prevents false-denied from disabling persisted notifications", async ({ + page, +}) => { + // Simulates the scenario Joxyko reported: a persisted desktopEnabled=true + // is written off on every relaunch because the boot-time read sees the + // init shim's false "denied" before the app is registered as a notification + // sender. With the boot-time recovery in refreshPermission, the mount-time + // read should request once and see "granted", so the toggle stays on. + await page.addInitScript(() => { + Object.defineProperty(navigator, "platform", { + configurable: true, + value: "Win32", + }); + (window as Window & { isTauri?: boolean }).isTauri = true; + }); + await page.goto("/"); + + // Pre-seed a persisted desktopEnabled=true and set the shim to false-deny + // then grant on request — exactly what happens on a clean Windows relaunch. + await page.evaluate(() => { + const pubkey = (window as Window & { __BUZZ_E2E_PUBKEY__?: string }) + .__BUZZ_E2E_PUBKEY__; + if (pubkey) { + window.localStorage.setItem( + `buzz-notification-settings.v2:${pubkey}`, + JSON.stringify({ desktopEnabled: true, homeBadgeEnabled: true, notifyWhileViewing: false, sounds: {}, slotAlertsEnabled: {}, slotAlertsSnapshot: null }), + ); + } + ( + window as Window & { + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; + } + ).__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?.("denied", "granted"); + }); + + await openSettings(page, "notifications"); + const desktopToggle = page.getByTestId("notifications-desktop-toggle"); + const desktopState = page.getByTestId("notifications-desktop-state"); + + // The boot-time recovery should have fired during mount, so the toggle + // stays on without the user touching it. + await expect(desktopState).toContainText("On"); + await expect(desktopToggle).toBeChecked(); +}); + test("desktop notification clicks open the matching forum thread", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 9d288825e56..18af4ca9143 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -859,11 +859,18 @@ export async function installBridge(page: Page, options: BridgeOptions) { title: string; }> = []; const notificationInstances: MockNotification[] = []; + let notificationPermissionRequestCount = 0; + let notificationPermissionRequestResult: NotificationPermission | null = + null; class MockNotification extends EventTarget { static permission: NotificationPermission = "granted"; static async requestPermission(): Promise { + notificationPermissionRequestCount += 1; + if (notificationPermissionRequestResult) { + MockNotification.permission = notificationPermissionRequestResult; + } return MockNotification.permission; } @@ -896,10 +903,15 @@ export async function installBridge(page: Page, options: BridgeOptions) { __BUZZ_E2E_APP_BADGE_COUNT__?: number; __BUZZ_E2E_APP_BADGE_STATE__?: string; __BUZZ_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; + __BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__?: () => number; __BUZZ_E2E_NOTIFICATIONS__?: Array<{ body: string | null; title: string; }>; + __BUZZ_E2E_SET_NOTIFICATION_PERMISSION__?: ( + permission: NotificationPermission, + requestResult?: NotificationPermission, + ) => void; }; const currentConfig = testWindow.__BUZZ_E2E__ ?? {}; @@ -926,7 +938,17 @@ export async function installBridge(page: Page, options: BridgeOptions) { notification.onclick?.(event); return true; }; + testWindow.__BUZZ_E2E_GET_NOTIFICATION_PERMISSION_REQUEST_COUNT__ = () => + notificationPermissionRequestCount; testWindow.__BUZZ_E2E_NOTIFICATIONS__ = notificationLog; + testWindow.__BUZZ_E2E_SET_NOTIFICATION_PERMISSION__ = ( + permission, + requestResult, + ) => { + MockNotification.permission = permission; + notificationPermissionRequestCount = 0; + notificationPermissionRequestResult = requestResult ?? null; + }; }, { identity, From e1f7108992ba74771f4e812ac862460ec7730534 Mon Sep 17 00:00:00 2001 From: VSCteam <144167621+Joxyko@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:38:27 +0200 Subject: [PATCH 03/76] fix(desktop): repair the Windows notification shim's false denial on read On Windows the notification plugin's injected shim stamps `denied` at startup without ever consulting the backend, whose `permission_state()` returns `Granted` unconditionally on desktop. Its bootstrap short-circuits on the `__TEMPLATE_windows__` literal and compares its own freshly initialised `'default'` against `'granted'`. `getDesktopNotificationPermissionState()` trusted that value, so the mount-time read reported `denied` and the settings effect turned `desktopEnabled` off and persisted it on every launch -- which is why fixing only the enable path leaves a Windows user re-enabling alerts after each restart. Repair the value at the read instead of reporting it. Inside the Windows Tauri app a `denied` reading is never a real denial, and `requestPermission()` is the only call that reaches the backend and rewrites the shim's cached value. It is self-limiting: the next read short-circuits as `granted` and never requests again. Repairing at the read rather than at the toggle also covers `sendDesktopNotification()`, which gates delivery on `=== "granted"` and would otherwise drop notifications at boot. `denied` stays terminal on macOS, on Linux and on ordinary Windows web pages, and a denial that survives the request is still reported as `denied`. Refs #2445 Signed-off-by: VSCteam <144167621+Joxyko@users.noreply.github.com> --- .../notifications/lib/desktop.test.mjs | 119 +++++++++++++++++- .../src/features/notifications/lib/desktop.ts | 25 +++- desktop/src/shared/lib/platform.test.mjs | 34 ++--- 3 files changed, 161 insertions(+), 17 deletions(-) diff --git a/desktop/src/features/notifications/lib/desktop.test.mjs b/desktop/src/features/notifications/lib/desktop.test.mjs index 7fb8830683a..43ba6d5014a 100644 --- a/desktop/src/features/notifications/lib/desktop.test.mjs +++ b/desktop/src/features/notifications/lib/desktop.test.mjs @@ -23,7 +23,55 @@ class ThrowingNotification { globalThis.window = { Notification: ThrowingNotification }; -const { sendDesktopNotification } = await import("./desktop.ts"); +const { sendDesktopNotification, getDesktopNotificationPermissionState } = + await import("./desktop.ts"); + +// Stands in for the plugin's injected shim: `permission` is a cached value and +// only `requestPermission()` reaches the backend and rewrites it. +function shimNotification(initialPermission, grantedPermission = "granted") { + class ShimNotification { + static permission = initialPermission; + static requestCount = 0; + + static async requestPermission() { + ShimNotification.requestCount += 1; + ShimNotification.permission = grantedPermission; + return grantedPermission; + } + + close() {} + } + + return ShimNotification; +} + +async function withEnvironment({ platform, isTauri, notification }, callback) { + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + const originalNotification = window.Notification; + const originalIsTauri = globalThis.isTauri; + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform, userAgent: "" }, + }); + window.Notification = notification; + globalThis.isTauri = isTauri; + + try { + return await callback(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator); + } else { + delete globalThis.navigator; + } + window.Notification = originalNotification; + globalThis.isTauri = originalIsTauri; + } +} test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => { const warnings = []; @@ -50,3 +98,72 @@ test("constructor failure is a delivery miss and does not prevent a later notifi }, ]); }); + +test("Windows Tauri repairs the shim's false denied state on read", async () => { + const Notification = shimNotification("denied"); + + const permission = await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "granted"); + assert.equal(Notification.requestCount, 1); +}); + +test("the repaired state is cached, so later reads do not request again", async () => { + const Notification = shimNotification("denied"); + + await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + async () => { + await getDesktopNotificationPermissionState(); + const second = await getDesktopNotificationPermissionState(); + assert.equal(second, "granted"); + }, + ); + + assert.equal(Notification.requestCount, 1); +}); + +test("a denial that survives the request is reported as denied", async () => { + const Notification = shimNotification("denied", "denied"); + + const permission = await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "denied"); + assert.equal(Notification.requestCount, 1); +}); + +test("denied stays terminal outside the Windows Tauri app", async () => { + for (const environment of [ + { label: "Windows web", platform: "Win32", isTauri: false }, + { label: "Linux Tauri", platform: "Linux x86_64", isTauri: true }, + { label: "Linux web", platform: "Linux x86_64", isTauri: false }, + ]) { + const Notification = shimNotification("denied"); + + const permission = await withEnvironment( + { ...environment, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "denied", environment.label); + assert.equal(Notification.requestCount, 0, environment.label); + } +}); + +test("granted is returned untouched and never triggers a request", async () => { + const Notification = shimNotification("granted"); + + const permission = await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "granted"); + assert.equal(Notification.requestCount, 0); +}); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 45716de2d94..d0acd3bb507 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -6,7 +6,11 @@ import { onAction, requestPermission, } from "@tauri-apps/plugin-notification"; -import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; +import { + isLinuxPlatform, + isMacPlatform, + isWindowsPlatform, +} from "@/shared/lib/platform"; // Backend event emitted when a native Linux notification is clicked or a // queued macOS activation becomes available. See src-tauri notification code. @@ -147,6 +151,25 @@ export async function getDesktopNotificationPermissionState(): Promise { - withNavigatorPlatform("Win32", () => { - assert.equal(isWindowsPlatform(), true); - }); - withNavigatorPlatform("Darwin", () => { - assert.equal(isWindowsPlatform(), false); - }); +test("Windows detection accepts the platform strings WebView2 reports", () => { + for (const platform of ["Win32", "Win64", "Windows"]) { + withNavigator({ platform, userAgent: "" }, () => { + assert.equal(isWindowsPlatform(), true, platform); + }); + } +}); + +test("Windows detection rejects the other desktop platforms", () => { + for (const platform of ["MacIntel", "Darwin", "Linux x86_64"]) { + withNavigator({ platform, userAgent: "" }, () => { + assert.equal(isWindowsPlatform(), false, platform); + }); + } }); From dd65dfcc861109c523ff32fab236c8718aed6d61 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Thu, 20 Aug 2026 20:11:59 +0200 Subject: [PATCH 04/76] style(desktop): reformat profile.spec.ts per biome after cherry-picks PR #2483's e2e addition wasn't run through this fork's biome config; pnpm check --write reflows the long JSON.stringify argument. No behavior change. Signed-off-by: Robi Hermes --- desktop/tests/e2e/profile.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index cb31d6a7282..209fe7af4b6 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -2392,7 +2392,14 @@ test("Windows boot-time recovery prevents false-denied from disabling persisted if (pubkey) { window.localStorage.setItem( `buzz-notification-settings.v2:${pubkey}`, - JSON.stringify({ desktopEnabled: true, homeBadgeEnabled: true, notifyWhileViewing: false, sounds: {}, slotAlertsEnabled: {}, slotAlertsSnapshot: null }), + JSON.stringify({ + desktopEnabled: true, + homeBadgeEnabled: true, + notifyWhileViewing: false, + sounds: {}, + slotAlertsEnabled: {}, + slotAlertsSnapshot: null, + }), ); } ( From 4b6ff3fc309d0e763809b91e007c288750cd8aa8 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Thu, 20 Aug 2026 22:04:31 +0200 Subject: [PATCH 05/76] fix(desktop): show agent activity bar in DMs and threads BUZZ-DESKTOP-003. Two related rendering gaps on the fork: 1. In DM channels the composer activity bar stayed empty even while observer frames arrived (sidebar working badge proved it). getChannelAgentSessionAgents filtered relay agents by the declared channel_ids of their kind:10100 profile, and DM channels never appear there, so every relay agent fell out of activityAgents in its own DM. For DMs the channel participantPubkeys are now the authoritative admission signal. 2. Thread panels fed threadComposerBotTypingPubkeys only from bot typing entries (kind 20002), never from the observer store, so threads rendered "is typing" at best. Merge the channel-level working set (observer-derived turns + typing) so threads show the same activity bar as the main composer. Observer frames carry channel scope only, which is the finest resolution available. Both changes are additive desktop-only filters; relay and harness are untouched. Unit tests: 4975/4975 pass (9 new), typecheck clean. Signed-off-by: Robi Hermes --- .../src/features/channels/ui/ChannelPane.tsx | 19 +-- .../ui/channelComposerActivityMerge.test.mjs | 23 +++ .../ui/channelComposerActivityMerge.ts | 20 +++ .../ui/useChannelAgentSessions.test.mjs | 151 ++++++++++++++++++ .../channels/ui/useChannelAgentSessions.ts | 15 ++ 5 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/channels/ui/channelComposerActivityMerge.test.mjs create mode 100644 desktop/src/features/channels/ui/channelComposerActivityMerge.ts create mode 100644 desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95e3..e6c36ab6f37 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -38,6 +38,7 @@ import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPre import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; +import { mergeThreadComposerActivityPubkeys } from "@/features/channels/ui/channelComposerActivityMerge"; import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; import { containsWelcomePersonaMention, @@ -365,16 +366,16 @@ export const ChannelPane = React.memo(function ChannelPane({ hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; const threadComposerBotTypingPubkeys = React.useMemo(() => { if (!openThreadHeadId) return []; - return botTypingEntries + const threadTyping = botTypingEntries .filter((entry) => entry.threadHeadId === openThreadHeadId) - .map((entry) => entry.pubkey) - .filter( - (pubkey, index, all) => - all.findIndex( - (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), - ) === index, - ); - }, [botTypingEntries, openThreadHeadId]); + .map((entry) => entry.pubkey); + // BUZZ-DESKTOP-003: merge the channel-level working set so threads + // render the activity bar, not only "is typing". + return mergeThreadComposerActivityPubkeys( + threadTyping, + composerWorkingBotPubkeys, + ); + }, [botTypingEntries, composerWorkingBotPubkeys, openThreadHeadId]); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; const directMessageIntro = React.useMemo( diff --git a/desktop/src/features/channels/ui/channelComposerActivityMerge.test.mjs b/desktop/src/features/channels/ui/channelComposerActivityMerge.test.mjs new file mode 100644 index 00000000000..41259a60e23 --- /dev/null +++ b/desktop/src/features/channels/ui/channelComposerActivityMerge.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeThreadComposerActivityPubkeys } from "./channelComposerActivityMerge.ts"; + +test("returns thread typing when no channel-level working set exists", () => { + assert.deepEqual(mergeThreadComposerActivityPubkeys(["aaaa"], []), ["aaaa"]); +}); + +test("adds channel-level working pubkeys so threads get the activity bar", () => { + assert.deepEqual(mergeThreadComposerActivityPubkeys([], ["bbbb"]), ["bbbb"]); +}); + +test("merges both sources and dedupes case-insensitively, thread order first", () => { + assert.deepEqual( + mergeThreadComposerActivityPubkeys(["AAAA"], ["aaaa", "cccc"]), + ["AAAA", "cccc"], + ); +}); + +test("empty inputs yield an empty list", () => { + assert.deepEqual(mergeThreadComposerActivityPubkeys([], []), []); +}); diff --git a/desktop/src/features/channels/ui/channelComposerActivityMerge.ts b/desktop/src/features/channels/ui/channelComposerActivityMerge.ts new file mode 100644 index 00000000000..c9b6d561491 --- /dev/null +++ b/desktop/src/features/channels/ui/channelComposerActivityMerge.ts @@ -0,0 +1,20 @@ +/** + * BUZZ-DESKTOP-003: merge thread-scoped bot typing pubkeys with the + * channel-level working set (observer-derived turns + typing) for the + * thread composer activity bar. Observer frames carry channel scope only + * (no threadHeadId), so a thread previously rendered bot typing at best; + * merging the channel-level working set lets threads show the same + * activity bar as the main composer. Case-insensitive dedupe preserves + * first-seen order. + */ +export function mergeThreadComposerActivityPubkeys( + threadTypingPubkeys: readonly string[], + channelWorkingPubkeys: readonly string[], +): string[] { + return [...threadTypingPubkeys, ...channelWorkingPubkeys].filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); +} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs b/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs new file mode 100644 index 00000000000..73d167ad2bc --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildChannelAgentSessionCandidates, + getChannelAgentSessionAgents, +} from "./useChannelAgentSessions.ts"; + +const OWNER = + "dd57c78422bccf568feb2a7ae5bcf4d7ebefc2c6c54bf56a26faeb9e0b08d36b"; +const CLAUDE_HOST = + "8df15208c09bccecce4d77cbf73874fbaf1441f4a7747925e5e62e422d9f0a1b"; +const CLAUDE_DEV = + "302583f7ba1fff34e7d8814779bfe1cdc6c2382bda33a62a9266dc34fc959bf5"; + +function dmChannel(id, participantPubkeys) { + return { + id, + name: `dm-${id}`, + channelType: "dm", + visibility: "private", + description: "", + topic: null, + purpose: null, + memberCount: 2, + memberPubkeys: [OWNER, ...participantPubkeys], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys, + isMember: true, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +function streamChannel(id, name) { + return { ...dmChannel(id, [OWNER]), channelType: "stream", name }; +} + +function relayAgent(pubkey, channelIds, channels = []) { + return { + pubkey, + name: "relay-agent", + status: "deployed", + agentSource: "relay", + canInterruptTurn: false, + channelIds, + channels, + }; +} + +test("BUZZ-DESKTOP-003: relay agent is included inside its own DM despite missing declared channel scope", () => { + const dm = dmChannel("dm-uuid-1", [OWNER, CLAUDE_HOST]); + const agents = buildChannelAgentSessionCandidates({ + channelMembers: [], + managedAgents: [], + relayAgents: [ + { pubkey: CLAUDE_HOST, name: "Claude-Host", status: "online" }, + ], + }); + const result = getChannelAgentSessionAgents({ + activeChannel: dm, + activeChannelId: dm.id, + agents, + channelMembers: [], + }); + assert.deepEqual( + result.map((agent) => agent.pubkey), + [CLAUDE_HOST], + ); +}); + +test("BUZZ-DESKTOP-003: DM participant match is case-insensitive", () => { + const dm = dmChannel("dm-uuid-2", [OWNER, CLAUDE_HOST.toUpperCase()]); + const agents = buildChannelAgentSessionCandidates({ + channelMembers: [], + managedAgents: [], + relayAgents: [ + { pubkey: CLAUDE_HOST, name: "Claude-Host", status: "online" }, + ], + }); + const result = getChannelAgentSessionAgents({ + activeChannel: dm, + activeChannelId: dm.id, + agents, + channelMembers: [], + }); + assert.equal(result.length, 1); +}); + +test("BUZZ-DESKTOP-003: relay agent that is not a DM participant stays excluded", () => { + const dm = dmChannel("dm-uuid-3", [OWNER, CLAUDE_HOST]); + const agents = buildChannelAgentSessionCandidates({ + channelMembers: [], + managedAgents: [], + relayAgents: [{ pubkey: CLAUDE_DEV, name: "Claude-DEV", status: "online" }], + }); + const result = getChannelAgentSessionAgents({ + activeChannel: dm, + activeChannelId: dm.id, + agents, + channelMembers: [], + }); + assert.deepEqual(result, []); +}); + +test("BUZZ-DESKTOP-003: declared channel_ids path still works for normal channels", () => { + const stream = streamChannel( + "bb814c05-53bc-4b30-b7ef-8efe2f5f985c", + "patterndy-development", + ); + const agents = [ + relayAgent(CLAUDE_DEV, [stream.id]), + relayAgent(CLAUDE_HOST, ["4558ff4c-d01d-4361-a8d1-9783d1c75f82"]), + ]; + const result = getChannelAgentSessionAgents({ + activeChannel: stream, + activeChannelId: stream.id, + agents, + channelMembers: [], + }); + assert.deepEqual( + result.map((agent) => agent.pubkey), + [CLAUDE_DEV], + ); +}); + +test("BUZZ-DESKTOP-003: participant rule applies only to DM channels", () => { + // A stream channel whose participantPubkeys happen to contain the agent + // must NOT admit it via the DM rule — only declared scope (or real + // membership on the member-bot/managed paths) does. + const stream = { + ...streamChannel("stream-uuid-x", "some-channel"), + participantPubkeys: [OWNER, CLAUDE_HOST], + }; + const agents = buildChannelAgentSessionCandidates({ + channelMembers: [], + managedAgents: [], + relayAgents: [ + { pubkey: CLAUDE_HOST, name: "Claude-Host", status: "online" }, + ], + }); + const result = getChannelAgentSessionAgents({ + activeChannel: stream, + activeChannelId: stream.id, + agents, + channelMembers: [], + }); + assert.deepEqual(result, []); +}); diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 8420c373279..9d8374dc6f6 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -132,6 +132,17 @@ export function getChannelAgentSessionAgents({ ) : null; + // BUZZ-DESKTOP-003: DM channels never appear in a relay agent's declared + // channel_ids (kind:10100 profiles declare public channels only), so the + // declared-scope filter dropped every relay agent inside its own DM and + // the composer activity bar stayed empty even while observer frames were + // arriving (the sidebar working badge proved it). For DMs the channel's + // participantPubkeys are the authoritative membership signal instead. + const dmParticipantPubkeys = + activeChannel.channelType === "dm" + ? new Set(activeChannel.participantPubkeys.map(normalizePubkey)) + : null; + return agents.filter((agent) => { const normalizedPubkey = normalizePubkey(agent.pubkey); const channelIds = agent.channelIds ?? []; @@ -142,6 +153,10 @@ export function getChannelAgentSessionAgents({ channelIds.includes(activeChannelId) || channels.includes(activeChannel.name); + if (dmParticipantPubkeys?.has(normalizedPubkey)) { + return true; + } + if (agent.agentSource === "member-bot") { return botMemberPubkeys?.has(normalizedPubkey) ?? matchesDeclaredChannel; } From 08cc2a3c8de740df48262abf7ee3503be2c9e3fa Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Thu, 20 Aug 2026 20:03:34 +0200 Subject: [PATCH 06/76] wip(desktop): deriveAgentSessions groups observer events by sessionId Pure function building block for the planned Agent Sessions panel (session tree grouped across agents). Not wired into any UI yet. Signed-off-by: Robi Hermes --- .../agents/ui/agentSessionSummary.test.mjs | 127 ++++++++++++++++++ .../features/agents/ui/agentSessionSummary.ts | 95 +++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 desktop/src/features/agents/ui/agentSessionSummary.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionSummary.ts diff --git a/desktop/src/features/agents/ui/agentSessionSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionSummary.test.mjs new file mode 100644 index 00000000000..a386d73b161 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionSummary.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { deriveAgentSessions } from "./agentSessionSummary.ts"; + +function event(overrides) { + return { + seq: 0, + timestamp: "2026-08-20T08:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: null, + payload: {}, + ...overrides, + }; +} + +test("groups events by sessionId and counts distinct turns", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, turnId: "turn-1" }), + event({ seq: 2, turnId: "turn-1" }), + event({ seq: 3, turnId: "turn-2" }), + ]); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0].sessionId, "sess-1"); + assert.equal(sessions[0].turnCount, 2); +}); + +test("skips events with no sessionId", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, sessionId: null }), + event({ seq: 2, sessionId: "sess-1" }), + ]); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0].sessionId, "sess-1"); +}); + +test("orders sessions newest-last-activity first, using seq as tiebreak on equal timestamps", () => { + const sessions = deriveAgentSessions([ + event({ + seq: 1, + sessionId: "sess-old", + timestamp: "2026-08-20T08:00:00.000Z", + }), + event({ + seq: 2, + sessionId: "sess-new", + timestamp: "2026-08-20T08:00:00.000Z", + }), + ]); + + assert.deepEqual( + sessions.map((s) => s.sessionId), + ["sess-new", "sess-old"], + ); +}); + +test("marks a session errored when its last event is turn_error", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, kind: "turn_started" }), + event({ seq: 2, kind: "turn_error" }), + ]); + + assert.equal(sessions[0].status, "error"); +}); + +test("marks a session errored on agent_panic", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, kind: "turn_started" }), + event({ seq: 2, kind: "agent_panic" }), + ]); + + assert.equal(sessions[0].status, "error"); +}); + +test("marks a session completed when its last event is turn_completed", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, kind: "turn_started" }), + event({ seq: 2, kind: "turn_completed" }), + ]); + + assert.equal(sessions[0].status, "completed"); +}); + +test("a later turn_started after an earlier error moves the session back to running", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, kind: "turn_error" }), + event({ seq: 2, kind: "turn_started" }), + ]); + + assert.equal(sessions[0].status, "running"); +}); + +test("keeps the earliest channelId seen when later events omit it", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, channelId: "chan-1" }), + event({ seq: 2, channelId: null }), + ]); + + assert.equal(sessions[0].channelId, "chan-1"); +}); + +test("first/last timestamp span the full session regardless of input order", () => { + const sessions = deriveAgentSessions([ + event({ seq: 2, timestamp: "2026-08-20T08:05:00.000Z" }), + event({ seq: 1, timestamp: "2026-08-20T08:00:00.000Z" }), + ]); + + assert.equal(sessions[0].firstTimestamp, "2026-08-20T08:00:00.000Z"); + assert.equal(sessions[0].lastTimestamp, "2026-08-20T08:05:00.000Z"); +}); + +test("keeps distinct sessions separate across different channels", () => { + const sessions = deriveAgentSessions([ + event({ seq: 1, sessionId: "sess-a", channelId: "chan-a" }), + event({ seq: 2, sessionId: "sess-b", channelId: "chan-b" }), + ]); + + assert.equal(sessions.length, 2); + const byId = Object.fromEntries(sessions.map((s) => [s.sessionId, s])); + assert.equal(byId["sess-a"].channelId, "chan-a"); + assert.equal(byId["sess-b"].channelId, "chan-b"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionSummary.ts b/desktop/src/features/agents/ui/agentSessionSummary.ts new file mode 100644 index 00000000000..27578430d42 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionSummary.ts @@ -0,0 +1,95 @@ +import { compareObserverEvents } from "@/features/agents/observerRelayStore"; +import type { ObserverEvent } from "./agentSessionTypes"; + +export type AgentSessionStatus = "running" | "completed" | "error"; + +export type AgentSessionSummary = { + sessionId: string; + channelId: string | null; + turnCount: number; + firstTimestamp: string; + lastTimestamp: string; + status: AgentSessionStatus; +}; + +/** + * Group one agent's observer events by `sessionId` into per-session + * summaries for the session tree (turn count, span, last-known status), + * newest-activity-first. + * + * Pure function: the caller owns merging live + archive event windows + * before calling this, same contract as `buildTranscriptState`. Events with + * no `sessionId` (rare pre-session-resolution frames) are skipped — they + * have nothing to group by. + * + * "Last-known status" is a heuristic over the last event observed for the + * session in `compareObserverEvents` order (timestamp, then seq on a tie — + * reused rather than re-implemented to avoid drifting from transcript + * ordering): `turn_error`/`agent_panic` mark the session errored, + * `turn_completed` marks it completed, anything else leaves it "running". + * A session can go error -> running again if a later turn starts in the + * same session after an earlier turn's error; that reflects reality (the + * agent kept going), not a bug. + */ +export function deriveAgentSessions( + events: readonly ObserverEvent[], +): AgentSessionSummary[] { + const bySession = new Map< + string, + { + channelId: string | null; + turnIds: Set; + firstEvent: ObserverEvent; + lastEvent: ObserverEvent; + status: AgentSessionStatus; + } + >(); + + for (const event of events) { + const sessionId = event.sessionId; + if (!sessionId) continue; + + let entry = bySession.get(sessionId); + if (!entry) { + entry = { + channelId: event.channelId, + turnIds: new Set(), + firstEvent: event, + lastEvent: event, + status: "running", + }; + bySession.set(sessionId, entry); + } + + if (event.channelId && !entry.channelId) { + entry.channelId = event.channelId; + } + if (event.turnId) { + entry.turnIds.add(event.turnId); + } + if (compareObserverEvents(event, entry.firstEvent) < 0) { + entry.firstEvent = event; + } + if (compareObserverEvents(event, entry.lastEvent) >= 0) { + entry.lastEvent = event; + entry.status = statusForEventKind(event.kind); + } + } + + return Array.from(bySession.values()) + .sort((a, b) => compareObserverEvents(b.lastEvent, a.lastEvent)) + .map((entry) => ({ + sessionId: entry.lastEvent.sessionId as string, + channelId: entry.channelId, + turnCount: entry.turnIds.size, + firstTimestamp: entry.firstEvent.timestamp, + lastTimestamp: entry.lastEvent.timestamp, + status: entry.status, + })); +} + +function statusForEventKind(kind: string): AgentSessionStatus { + if (kind === "turn_error" || kind === "agent_panic") return "error"; + if (kind === "turn_completed") return "completed"; + return "running"; +} From f57e5098115fbdd0a5ff9b1e1c37b08f03d2712f Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Thu, 20 Aug 2026 23:58:59 +0200 Subject: [PATCH 07/76] feat(desktop): agent sessions panel, DM context ring, metric data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUZZ-DESKTOP-003 phase 1. Built on the notification-fix branch so the next canary installer carries both. - Agent Sessions panel behind preview flag agentSessions: sidebar button, /agent-sessions route, session tree (deriveAgentSessions, cherry-picked from Claude-Host WIP 9869cf8) + existing transcript components, per-session model/context%/cost header, stuck heuristic (open turn idle >15 min). - DM context ring in the composer toolbar: fill = latest archived kind:44200 turn.inputTokens for the channel / model context window; color steps at 60%/85% to surface degradation before the window fills. - Data layer reads decrypted kind:44200 payloads via the existing owner_p archive path — no Rust changes. modelContextLimits.json ships ~60 known windows (Claude/GPT/Alibaba token-plan Qwen/ Gemini/DeepSeek/Llama/Mistral) with prefix matching and the 200k->1M observed-max heuristic. - Empirically verified 2026-08-20: Claude-Host/Claude-Review/Robi publish kind:44200; Claude-DEV had zero all-time (harness-side detection issue, fixed separately as BUZZ-ACP-017). Tests: 5003/5003 unit (18 new), tsc clean, biome clean. Signed-off-by: Robi Hermes --- desktop/src/app/AppShell.helpers.ts | 8 + desktop/src/app/AppShell.tsx | 2 + .../src/app/navigation/useAppNavigation.ts | 12 + desktop/src/app/routeTree.gen.ts | 21 + desktop/src/app/routes.ts | 1 + desktop/src/app/routes/agent-sessions.tsx | 25 ++ .../agent-sessions/ui/AgentSessionsScreen.tsx | 390 ++++++++++++++++++ .../agents/lib/agentContextUsage.test.mjs | 93 +++++ .../features/agents/lib/agentContextUsage.ts | 181 ++++++++ .../agents/lib/channelContextUsage.test.mjs | 48 +++ .../agents/lib/channelContextUsage.ts | 95 +++++ .../agents/lib/modelContextLimits.json | 68 +++ .../agents/lib/modelContextLimits.test.mjs | 40 ++ .../features/agents/lib/modelContextLimits.ts | 73 ++++ .../src/features/channels/ui/ChannelPane.tsx | 36 ++ .../features/messages/ui/ContextUsageRing.tsx | 87 ++++ .../src/features/sidebar/ui/AppSidebar.tsx | 4 + .../sidebar/ui/AppSidebarPinnedHeader.tsx | 19 +- preview-features.json | 6 + 19 files changed, 1208 insertions(+), 1 deletion(-) create mode 100644 desktop/src/app/routes/agent-sessions.tsx create mode 100644 desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx create mode 100644 desktop/src/features/agents/lib/agentContextUsage.test.mjs create mode 100644 desktop/src/features/agents/lib/agentContextUsage.ts create mode 100644 desktop/src/features/agents/lib/channelContextUsage.test.mjs create mode 100644 desktop/src/features/agents/lib/channelContextUsage.ts create mode 100644 desktop/src/features/agents/lib/modelContextLimits.json create mode 100644 desktop/src/features/agents/lib/modelContextLimits.test.mjs create mode 100644 desktop/src/features/agents/lib/modelContextLimits.ts create mode 100644 desktop/src/features/messages/ui/ContextUsageRing.tsx diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e82..5cafcb6dc4d 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -7,6 +7,7 @@ export type AppView = | "channel" | "messages" | "agents" + | "agent-sessions" | "workflows" | "pulse" | "projects"; @@ -167,6 +168,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/agent-sessions") { + return { + selectedChannelId: null, + selectedView: "agent-sessions", + }; + } + if (pathname === "/workflows" || pathname.startsWith("/workflows/")) { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b720..9c317863e44 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -137,6 +137,7 @@ export function AppShell() { useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot const { goAgents, + goAgentSessions, goChannel, goHome, goNewMessage, @@ -848,6 +849,7 @@ export function AppShell() { await goChannel(directMessage.id); }} onSelectAgents={() => void goAgents()} + onSelectAgentSessions={() => void goAgentSessions()} onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6a..52c01ec6e90 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -70,6 +70,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goAgentSessions = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/agent-sessions", + }, + behavior, + ), + [commitNavigation], + ); + const goPulse = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -329,6 +340,7 @@ export function useAppNavigation() { closeSettings, closeWorkflowDetail, goAgents, + goAgentSessions, goChannel, goForumPost, goHome, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..9d351c4e783 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; import { Route as agentsRouteImport } from "./routes/agents"; +import { Route as agentSessionsRouteImport } from "./routes/agent-sessions"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; @@ -48,6 +49,11 @@ const agentsRoute = agentsRouteImport.update({ path: "/agents", getParentRoute: () => rootRouteImport, } as any); +const agentSessionsRoute = agentSessionsRouteImport.update({ + id: "/agent-sessions", + path: "/agent-sessions", + getParentRoute: () => rootRouteImport, +} as any); const indexRoute = indexRouteImport.update({ id: "/", path: "/", @@ -82,6 +88,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; + "/agent-sessions": typeof agentSessionsRoute; "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; @@ -96,6 +103,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { "/": typeof indexRoute; + "/agent-sessions": typeof agentSessionsRoute; "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; @@ -111,6 +119,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; + "/agent-sessions": typeof agentSessionsRoute; "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; @@ -127,6 +136,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; fullPaths: | "/" + | "/agent-sessions" | "/agents" | "/projects" | "/pulse" @@ -141,6 +151,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo; to: | "/" + | "/agent-sessions" | "/agents" | "/projects" | "/pulse" @@ -155,6 +166,7 @@ export interface FileRouteTypes { id: | "__root__" | "/" + | "/agent-sessions" | "/agents" | "/projects" | "/pulse" @@ -170,6 +182,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { indexRoute: typeof indexRoute; + agentSessionsRoute: typeof agentSessionsRoute; agentsRoute: typeof agentsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; @@ -227,6 +240,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof agentsRouteImport; parentRoute: typeof rootRouteImport; }; + "/agent-sessions": { + id: "/agent-sessions"; + path: "/agent-sessions"; + fullPath: "/agent-sessions"; + preLoaderRoute: typeof agentSessionsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/": { id: "/"; path: "/"; @@ -274,6 +294,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, + agentSessionsRoute: agentSessionsRoute, agentsRoute: agentsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..85d6288aff0 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -3,6 +3,7 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes"; export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), + route("/agent-sessions", "agent-sessions.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), diff --git a/desktop/src/app/routes/agent-sessions.tsx b/desktop/src/app/routes/agent-sessions.tsx new file mode 100644 index 00000000000..02c0b7507ba --- /dev/null +++ b/desktop/src/app/routes/agent-sessions.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const AgentSessionsScreen = React.lazy(async () => { + const module = await import( + "@/features/agent-sessions/ui/AgentSessionsScreen" + ); + return { default: module.AgentSessionsScreen }; +}); + +export const Route = createFileRoute("/agent-sessions")({ + component: AgentSessionsRouteComponent, +}); + +function AgentSessionsRouteComponent() { + usePreviewFeatureWarning("agentSessions"); + return ( + }> + + + ); +} diff --git a/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx b/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx new file mode 100644 index 00000000000..b8d012b1370 --- /dev/null +++ b/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx @@ -0,0 +1,390 @@ +import * as React from "react"; +import { + Bot, + Circle, + CircleAlert, + Loader2, + TerminalSquare, +} from "lucide-react"; + +import { + deriveAgentSessions, + type AgentSessionSummary, +} from "@/features/agents/ui/agentSessionSummary"; +import { buildTranscriptState } from "@/features/agents/ui/agentSessionTranscript"; +import { useObserverEvents } from "@/features/agents/ui/useObserverEvents"; +import { AgentSessionTranscriptList } from "@/features/agents/ui/AgentSessionTranscriptList"; +import { + useContextUsageArchive, + asTurnMetric, + formatTokenCount, +} from "@/features/agents/lib/agentContextUsage"; +import { resolveContextWindow } from "@/features/agents/lib/modelContextLimits"; +import { + useRelayAgentsQuery, + useManagedAgentsQuery, +} from "@/features/agents/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** + * BUZZ-DESKTOP-003 / Phase 1: the Agent Sessions panel. + * + * Left column: every agent with observer coverage (managed + relay), each + * expandable into its sessions (grouped by sessionId via deriveAgentSessions). + * Right column: the full transcript of the selected session. + * + * Data sources: + * - Live observer frames (kind 24200) via useObserverEvents — the same store + * the activity bar and existing session panels read. + * - Archived kind:44200 metrics for token/cost context per session. + */ + +type AgentIdentity = { + pubkey: string; + name: string; + source: "managed" | "relay"; +}; + +/** Stuck heuristic: no turn_completed/turn_error since the last event and + * last activity older than this threshold → possibly stuck. */ +const STUCK_THRESHOLD_MS = 15 * 60_000; + +function isPossiblyStuck(session: AgentSessionSummary): boolean { + if (session.status !== "running") return false; + const last = Date.parse(session.lastTimestamp); + return Number.isFinite(last) && Date.now() - last > STUCK_THRESHOLD_MS; +} + +function useAgentIdentities(): AgentIdentity[] { + const managed = useManagedAgentsQuery(); + const relay = useRelayAgentsQuery(); + + return React.useMemo(() => { + const byKey = new Map(); + for (const agent of relay.data ?? []) { + byKey.set(normalizePubkey(agent.pubkey), { + pubkey: agent.pubkey, + name: agent.name, + source: "relay", + }); + } + for (const agent of managed.data ?? []) { + const key = normalizePubkey(agent.pubkey); + if (!byKey.has(key)) { + byKey.set(key, { + pubkey: agent.pubkey, + name: agent.name, + source: "managed", + }); + } + } + return [...byKey.values()]; + }, [managed.data, relay.data]); +} + +/** One agent's session list derived from its live observer window. */ +function useAgentSessionTree(agent: AgentIdentity) { + const snapshot = useObserverEvents(true, agent.pubkey); + return React.useMemo(() => { + const sessions = deriveAgentSessions(snapshot.events); + return { sessions, eventCount: snapshot.events.length }; + }, [snapshot.events]); +} + +/** Token/cost context for one session from archived kind:44200 metrics. */ +function useSessionMetrics(sessionId: string | null) { + const identity = useIdentityQuery(); + const archive = useContextUsageArchive(identity.data?.pubkey); + + return React.useMemo(() => { + if (!sessionId || !archive.data) return null; + let model: string | null = null; + let maxInput = 0; + let lastInput: number | null = null; + let cost = 0; + let turns = 0; + for (const raw of archive.data) { + const payload = asTurnMetric(raw); + if (!payload || payload.sessionId !== sessionId) continue; + turns += 1; + if (payload.model && !model) model = payload.model; + const input = payload.turn?.inputTokens; + if (typeof input === "number" && Number.isFinite(input)) { + if (lastInput == null) lastInput = input; + if (input > maxInput) maxInput = input; + } + const c = payload.turn?.costUsd; + if (typeof c === "number" && Number.isFinite(c)) cost += c; + } + if (turns === 0) return null; + const window = resolveContextWindow(model, maxInput); + return { model, contextWindow: window, lastInput, cost, turns }; + }, [sessionId, archive.data]); +} + +function StatusIcon({ + status, + stuck, +}: { + status: AgentSessionSummary["status"]; + stuck: boolean; +}) { + if (status === "error") + return ; + if (stuck) + return ; + if (status === "running") + return ( + + ); + return ( + + ); +} + +function relativeTime(iso: string): string { + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return ""; + const mins = Math.max(0, Math.round((Date.now() - ts) / 60_000)); + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +function SessionRow({ + selected, + session, + onSelect, +}: { + selected: boolean; + session: AgentSessionSummary; + onSelect: () => void; +}) { + const stuck = isPossiblyStuck(session); + return ( + + ); +} + +/** One agent row with its session list. Owns its hook calls (one per + * component instance — hooks in a .map() would be invalid React). */ +function AgentTreeSection({ + agent, + selectedSessionId, + onSelect, +}: { + agent: AgentIdentity; + selectedSessionId: string | null; + onSelect: (agentPubkey: string, sessionId: string) => void; +}) { + const { sessions } = useAgentSessionTree(agent); + return ( +
+
+ + + {agent.name} + + {sessions.length} +
+ {sessions.length === 0 ? ( +

+ No observer events yet. +

+ ) : ( + sessions.map((session) => ( + onSelect(agent.pubkey, session.sessionId)} + selected={selectedSessionId === session.sessionId} + session={session} + /> + )) + )} +
+ ); +} + +export function AgentSessionsScreen() { + const agents = useAgentIdentities(); + const [selected, setSelected] = React.useState<{ + agentPubkey: string; + sessionId: string; + } | null>(null); + + // Observer ingestion is app-wide already (AppShell mounts + // useAgentObserverIngestion), so per-agent tree reads are cheap store + // lookups inside AgentTreeSection — no hook-in-loop here. + const selectedAgent = selected + ? (agents.find((a) => a.pubkey === selected.agentPubkey) ?? null) + : null; + const metrics = useSessionMetrics(selected?.sessionId ?? null); + + return ( +
+ {/* Left: agent → session tree */} + + + {/* Right: transcript + metrics for the selected session */} +
+ {selected && selectedAgent ? ( + + ) : ( +
+ +

+ Select a session to inspect its transcript. +

+
+ )} +
+
+ ); +} + +function SelectedSessionTranscript({ + agentName, + agentPubkey, + metrics, + sessionId, +}: { + agentName: string; + agentPubkey: string; + metrics: ReturnType; + sessionId: string; +}) { + const snapshot = useObserverEvents(true, agentPubkey); + const sessionEvents = React.useMemo( + () => snapshot.events.filter((e) => e.sessionId === sessionId), + [snapshot.events, sessionId], + ); + const transcript = React.useMemo( + () => buildTranscriptState(sessionEvents).items, + [sessionEvents], + ); + + const turnIds = React.useMemo(() => { + const ids = new Set(); + for (const e of sessionEvents) { + if (e.turnId) ids.add(e.turnId); + } + return ids.size; + }, [sessionEvents]); + + const channelId = React.useMemo( + () => sessionEvents.find((e) => e.channelId)?.channelId ?? null, + [sessionEvents], + ); + + const fillPct = + metrics?.lastInput != null && metrics.contextWindow > 0 + ? Math.round((metrics.lastInput / metrics.contextWindow) * 100) + : null; + + return ( + <> +
+

+ {agentName} ·{" "} + {sessionId.slice(0, 12)} +

+ + {turnIds} turn{turnIds === 1 ? "" : "s"} + + {metrics ? ( + <> + + {metrics.model ?? "model unknown"} + + {fillPct != null && metrics.lastInput != null ? ( + 85 + ? "text-destructive" + : fillPct > 60 + ? "text-amber-500" + : "text-muted-foreground", + )} + > + {fillPct}% ctx · {formatTokenCount(metrics.lastInput)}/ + {formatTokenCount(metrics.contextWindow)} + + ) : null} + {metrics.cost > 0 ? ( + + ${metrics.cost.toFixed(4)} + + ) : null} + + ) : ( + + no token metrics yet + + )} +
+
+ +
+ + ); +} diff --git a/desktop/src/features/agents/lib/agentContextUsage.test.mjs b/desktop/src/features/agents/lib/agentContextUsage.test.mjs new file mode 100644 index 00000000000..3174c6c5add --- /dev/null +++ b/desktop/src/features/agents/lib/agentContextUsage.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + asTurnMetric, + foldTurnMetrics, + formatTokenCount, +} from "./agentContextUsage.ts"; + +function metric(overrides = {}) { + return { + harness: "claude-code", + model: "claude-sonnet-4-20250514", + channelId: null, + sessionId: "sess-1", + turnId: "turn-1", + turnSeq: 1, + timestamp: "2026-08-20T18:00:00.000Z", + turn: { + inputTokens: 100_000, + outputTokens: 2_000, + totalTokens: 102_000, + costUsd: 0.05, + }, + cumulative: null, + deltaReliable: true, + stopReason: "end_turn", + ...overrides, + }; +} + +test("asTurnMetric accepts payload rows and rejects relay envelopes", () => { + assert.ok(asTurnMetric(metric())); + assert.equal(asTurnMetric({ kind: 44200, id: "abc", pubkey: "def" }), null); + assert.equal(asTurnMetric(null), null); + assert.equal(asTurnMetric([1, 2]), null); +}); + +test("foldTurnMetrics takes the newest turn as the fill proxy", () => { + // Archive rows arrive newest-first. + const rows = [ + metric({ turn: { inputTokens: 118_000, outputTokens: 1, totalTokens: 1, costUsd: null } }), + metric({ turn: { inputTokens: 90_000, outputTokens: 1, totalTokens: 1, costUsd: null } }), + ]; + const summary = foldTurnMetrics(rows); + assert.equal(summary.lastInputTokens, 118_000); + assert.equal(summary.maxInputTokens, 118_000); + assert.equal(summary.model, "claude-sonnet-4-20250514"); + assert.equal(summary.contextWindow, 200_000); + assert.ok(Math.abs(summary.usageRatio - 118_000 / 200_000) < 1e-9); + assert.equal(summary.reportCount, 2); +}); + +test("foldTurnMetrics sums per-turn costs", () => { + const rows = [metric({ turn: { inputTokens: 1, outputTokens: 1, totalTokens: 1, costUsd: 0.1 } }), + metric({ turn: { inputTokens: 1, outputTokens: 1, totalTokens: 1, costUsd: 0.25 } })]; + const summary = foldTurnMetrics(rows); + assert.ok(Math.abs(summary.totalCostUsd - 0.35) < 1e-9); +}); + +test("foldTurnMetrics handles missing turn blocks gracefully", () => { + const rows = [metric({ turn: null }), metric()]; + const summary = foldTurnMetrics(rows); + assert.equal(summary.lastInputTokens, 100_000); + assert.equal(summary.reportCount, 2); +}); + +test("foldTurnMetrics returns null ratio when no token data exists", () => { + const rows = [metric({ turn: null })]; + const summary = foldTurnMetrics(rows); + assert.equal(summary.usageRatio, null); + assert.equal(summary.lastInputTokens, null); +}); + +test("foldTurnMetrics promotes the window when a turn exceeded it", () => { + const rows = [metric({ turn: { inputTokens: 250_000, outputTokens: 1, totalTokens: 1, costUsd: null } })]; + const summary = foldTurnMetrics(rows); + assert.equal(summary.contextWindow, 1_048_576); + assert.ok(Math.abs(summary.usageRatio - 250_000 / 1_048_576) < 1e-9); +}); + +test("foldTurnMetrics collects distinct session ids newest-first", () => { + const rows = [metric({ sessionId: "s2" }), metric({ sessionId: "s1" }), metric({ sessionId: "s2" })]; + const summary = foldTurnMetrics(rows); + assert.deepEqual(summary.sessionIds, ["s2", "s1"]); +}); + +test("formatTokenCount renders k and M units", () => { + assert.equal(formatTokenCount(999), "999"); + assert.equal(formatTokenCount(1_000), "1k"); + assert.equal(formatTokenCount(118_400), "118.4k"); + assert.equal(formatTokenCount(1_048_576), "1.05M"); +}); diff --git a/desktop/src/features/agents/lib/agentContextUsage.ts b/desktop/src/features/agents/lib/agentContextUsage.ts new file mode 100644 index 00000000000..40272e00947 --- /dev/null +++ b/desktop/src/features/agents/lib/agentContextUsage.ts @@ -0,0 +1,181 @@ +import { useQuery } from "@tanstack/react-query"; + +import { resolveContextWindow } from "@/features/agents/lib/modelContextLimits"; +import { readArchivedEvents } from "@/shared/api/tauriArchive"; +import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; +import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible"; + +/** + * BUZZ-DESKTOP-003: per-agent context usage derived from archived NIP-AM + * kind:44200 turn metrics. + * + * Archive rows for kind 44200 store the DECRYPTED plaintext payload as + * `raw_json` (archive pipeline.rs), so `readArchivedEvents` returns payload + * objects directly — no owner-key handling on this side. The live read + * surface is the owner_p scope: metrics are p-addressed to the owner. + * + * Empirically verified 2026-08-20 against the self-hosted relay: Claude-Host, + * Claude-Review and Robi publish kind:44200 with model + sessionId + + * turn.inputTokens. Claude-DEV/Codex-DEV (Coder container) did not publish + * in the observed window — their harness build may predate the metric + * publisher or lack the owner context; the ring simply shows no data there. + */ + +/** camelCase wire shape of AgentTurnMetricPayload (buzz-core). */ +export type TurnMetricPayload = { + harness: string; + model: string | null; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + turnSeq: number | null; + timestamp: string; + turn: TokenCounts | null; + cumulative: TokenCounts | null; + deltaReliable: boolean; + stopReason: string | null; +}; + +/** + * Token counters cross as JSON numbers (Rust u64 serde). Precision loss is + * possible above 2^53 but context windows are orders of magnitude below. + */ +export type TokenCounts = { + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + costUsd: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; +}; + +export type AgentContextUsage = { + /** Model reported by the most recent turn metric. */ + model: string | null; + /** Context window resolved from the limits table + observed-max heuristic. */ + contextWindow: number; + /** Input tokens of the most recent turn (≈ context fill at that request). */ + lastInputTokens: number | null; + /** Highest input-token count observed across archived turns. */ + maxInputTokens: number; + /** lastInputTokens / contextWindow, clamped to [0, 1]. Null when no data. */ + usageRatio: number | null; + /** Cumulative turn cost across archived turns (USD), null when unreported. */ + totalCostUsd: number | null; + /** Number of archived turn metrics feeding this summary. */ + reportCount: number; + /** Distinct session ids observed, newest first. */ + sessionIds: string[]; +}; + +/** + * Parse one archived row into a payload. readArchivedEvents already + * JSON-parses raw_json; for kind 44200 rows that IS the payload object + * (the envelope was replaced by the decrypted payload at ingest). + */ +export function asTurnMetric(raw: unknown): TurnMetricPayload | null { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return null; + const candidate = raw as Record; + // Payloads carry harness + timestamp and lack relay-envelope fields. + if (typeof candidate.harness !== "string") return null; + if (typeof candidate.timestamp !== "string") return null; + return candidate as unknown as TurnMetricPayload; +} + +function asTokenNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + return null; +} + +/** + * Fold archived turn metrics (newest-first) into a context usage summary. + * Pure function — exported for unit tests. + */ +export function foldTurnMetrics(rows: readonly unknown[]): AgentContextUsage { + let model: string | null = null; + let lastInputTokens: number | null = null; + let maxInputTokens = 0; + let totalCostUsd: number | null = null; + let reportCount = 0; + const seenSessions: string[] = []; + + for (const raw of rows) { + const payload = asTurnMetric(raw); + if (!payload) continue; + reportCount += 1; + + if (payload.model && model == null) model = payload.model; + + if (payload.sessionId && !seenSessions.includes(payload.sessionId)) { + seenSessions.push(payload.sessionId); + } + + const turnInput = asTokenNumber(payload.turn?.inputTokens); + if (turnInput != null) { + // Rows are newest-first; first seen is the most recent turn. + if (lastInputTokens == null) lastInputTokens = turnInput; + if (turnInput > maxInputTokens) maxInputTokens = turnInput; + } + + const turnCost = payload.turn?.costUsd; + if (typeof turnCost === "number" && Number.isFinite(turnCost)) { + totalCostUsd = (totalCostUsd ?? 0) + turnCost; + } + } + + const contextWindow = resolveContextWindow(model, maxInputTokens); + const usageRatio = + lastInputTokens == null + ? null + : Math.min(1, Math.max(0, lastInputTokens / contextWindow)); + + return { + model, + contextWindow, + lastInputTokens, + maxInputTokens, + usageRatio, + totalCostUsd, + reportCount, + sessionIds: seenSessions, + }; +} + +/** Human-readable token count: 118.4k, 1.02M. */ +export function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(2).replace(/\.?0+$/, "")}M`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(tokens); +} + +const METRIC_PAGE_LIMIT = 200; +const EMPTY_ROWS: unknown[] = []; + +/** + * Query archived kind:44200 metrics for the current identity (owner_p scope + * = the owner's own pubkey). All owner-addressed metrics across the owner's + * agents land here; consumers slice by sessionId when per-agent attribution + * matters. + */ +export function useContextUsageArchive( + currentPubkey: string | null | undefined, +) { + const refetchInterval = useFocusedRefetchInterval(60_000); + return useQuery({ + queryKey: ["agent-context-usage-archive", currentPubkey ?? "none"], + queryFn: () => + readArchivedEvents("owner_p", currentPubkey ?? "", { + kinds: [KIND_AGENT_TURN_METRIC], + limit: METRIC_PAGE_LIMIT, + }).catch(() => EMPTY_ROWS), + enabled: Boolean(currentPubkey), + refetchInterval, + staleTime: 30_000, + }); +} diff --git a/desktop/src/features/agents/lib/channelContextUsage.test.mjs b/desktop/src/features/agents/lib/channelContextUsage.test.mjs new file mode 100644 index 00000000000..4645d565c79 --- /dev/null +++ b/desktop/src/features/agents/lib/channelContextUsage.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { foldChannelTurnMetrics } from "./channelContextUsage.ts"; + +function metric(channelId, overrides = {}) { + return { + harness: "claude-code", + model: "claude-sonnet-4-20250514", + channelId, + sessionId: "sess-1", + turnId: "turn-1", + turnSeq: 1, + timestamp: "2026-08-20T18:00:00.000Z", + turn: { inputTokens: 60_000, outputTokens: 500, totalTokens: 60_500, costUsd: null }, + cumulative: null, + deltaReliable: true, + stopReason: "end_turn", + ...overrides, + }; +} + +test("foldChannelTurnMetrics attributes metrics by channelId", () => { + const rows = [ + metric("dm-uuid", { turn: { inputTokens: 80_000, outputTokens: 1, totalTokens: 1, costUsd: null } }), + metric("other-channel"), + metric("dm-uuid", { turn: { inputTokens: 40_000, outputTokens: 1, totalTokens: 1, costUsd: null } }), + ]; + const usage = foldChannelTurnMetrics("dm-uuid", rows); + assert.equal(usage.reportCount, 2); + // Newest-first: 80k is the latest turn for dm-uuid. + assert.equal(usage.lastInputTokens, 80_000); + assert.equal(usage.usageRatio != null && usage.usageRatio > 0, true); +}); + +test("foldChannelTurnMetrics returns zero counts for a channel with no data", () => { + const usage = foldChannelTurnMetrics("dm-uuid", [metric("elsewhere")]); + assert.equal(usage.reportCount, 0); + assert.equal(usage.usageRatio, null); +}); + +test("foldChannelTurnMetrics ignores malformed rows", () => { + const usage = foldChannelTurnMetrics("dm-uuid", [ + { kind: 44200, id: "envelope" }, + metric("dm-uuid"), + ]); + assert.equal(usage.reportCount, 1); +}); diff --git a/desktop/src/features/agents/lib/channelContextUsage.ts b/desktop/src/features/agents/lib/channelContextUsage.ts new file mode 100644 index 00000000000..817998c5351 --- /dev/null +++ b/desktop/src/features/agents/lib/channelContextUsage.ts @@ -0,0 +1,95 @@ +import * as React from "react"; + +import { + useContextUsageArchive, + asTurnMetric, + type TurnMetricPayload, +} from "@/features/agents/lib/agentContextUsage"; +import { resolveContextWindow } from "@/features/agents/lib/modelContextLimits"; + +/** + * BUZZ-DESKTOP-003: context usage for ONE channel (the DM ring's data path). + * + * kind:44200 payloads carry `channelId`, so per-channel attribution works + * without agent-pubkey plumbing: in a DM, every metric whose channelId + * matches the DM belongs to that DM's agent. + */ + +export type ChannelContextUsage = { + model: string | null; + contextWindow: number; + lastInputTokens: number | null; + maxInputTokens: number; + usageRatio: number | null; + reportCount: number; +}; + +const EMPTY: ChannelContextUsage = { + model: null, + contextWindow: 0, + lastInputTokens: null, + maxInputTokens: 0, + usageRatio: null, + reportCount: 0, +}; + +function asTokenNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + return null; +} + +/** Filter + fold archived metrics to one channel. Pure, exported for tests. */ +export function foldChannelTurnMetrics( + channelId: string, + rows: readonly unknown[], +): ChannelContextUsage { + let model: string | null = null; + let lastInputTokens: number | null = null; + let maxInputTokens = 0; + let reportCount = 0; + + for (const raw of rows) { + const payload: TurnMetricPayload | null = asTurnMetric(raw); + if (!payload || payload.channelId !== channelId) continue; + reportCount += 1; + + if (payload.model && model == null) model = payload.model; + + const turnInput = asTokenNumber(payload.turn?.inputTokens); + if (turnInput != null) { + if (lastInputTokens == null) lastInputTokens = turnInput; + if (turnInput > maxInputTokens) maxInputTokens = turnInput; + } + } + + if (reportCount === 0) return EMPTY; + + const contextWindow = resolveContextWindow(model, maxInputTokens); + return { + model, + contextWindow, + lastInputTokens, + maxInputTokens, + usageRatio: + lastInputTokens == null + ? null + : Math.min(1, Math.max(0, lastInputTokens / contextWindow)), + reportCount, + }; +} + +/** Context usage for the given channel, or null when there is no data. */ +export function useChannelContextUsage( + channelId: string | null | undefined, + currentPubkey: string | null | undefined, +): ChannelContextUsage | null { + const archive = useContextUsageArchive(currentPubkey); + + return React.useMemo(() => { + if (!channelId || !archive.data || archive.data.length === 0) return null; + const folded = foldChannelTurnMetrics(channelId, archive.data); + return folded.reportCount > 0 ? folded : null; + }, [channelId, archive.data]); +} diff --git a/desktop/src/features/agents/lib/modelContextLimits.json b/desktop/src/features/agents/lib/modelContextLimits.json new file mode 100644 index 00000000000..28c66e10c5b --- /dev/null +++ b/desktop/src/features/agents/lib/modelContextLimits.json @@ -0,0 +1,68 @@ +{ + "version": 1, + "description": "Known context window sizes for common models. Used as fallback when the harness does not report contextLimit. Heuristic: assume 200k, promote to 1M once a turn exceeds 200k.", + "defaultContextWindow": 200000, + "models": { + "claude-sonnet-4-20250514": 200000, + "claude-sonnet-4-5-20250514": 200000, + "claude-opus-4-20250514": 200000, + "claude-3-5-sonnet-20241022": 200000, + "claude-3-5-haiku-20241022": 200000, + "claude-3-opus-20240229": 200000, + "claude-3-haiku-20240307": 200000, + "claude-sonnet-4-5": 200000, + "claude-opus-4": 200000, + "claude-sonnet-4": 200000, + "gpt-4o": 128000, + "gpt-4o-mini": 128000, + "gpt-4.1": 1047576, + "gpt-4.1-mini": 1047576, + "gpt-4.1-nano": 1047576, + "gpt-5": 272000, + "gpt-5-mini": 272000, + "gpt-5-nano": 272000, + "gpt-5-chat-latest": 272000, + "o3": 200000, + "o3-mini": 200000, + "o4-mini": 200000, + "gemini-2.5-pro": 1048576, + "gemini-2.5-flash": 1048576, + "gemini-2.5-flash-lite": 1048576, + "gemini-2.0-flash": 1048576, + "qwen3-235b-a22b": 131072, + "qwen3-32b": 131072, + "qwen3-30b-a3b": 131072, + "qwen3-14b": 131072, + "qwen3-8b": 131072, + "qwen3-4b": 131072, + "qwen3-coder-480b-a35b-instruct": 262144, + "qwen3-coder-30b-a3b-instruct": 262144, + "qwen3-coder-flash": 262144, + "qwen3.8-max": 131072, + "qwen3.8-plus": 131072, + "qwen3.7-max": 131072, + "qwen3.7-plus": 131072, + "gpt-5.6-sol": 272000, + "gpt-5.6": 272000, + "gpt-5.5": 272000, + "o3-pro": 200000, + "qwen-max": 131072, + "qwen-plus": 131072, + "qwen-turbo": 131072, + "deepseek-r1": 163840, + "deepseek-v3": 163840, + "deepseek-chat": 163840, + "llama-4-maverick": 1048576, + "llama-4-scout": 1048576, + "llama-3.3-70b-instruct": 131072, + "llama-3.1-405b-instruct": 131072, + "mistral-large-2411": 131072, + "mistral-medium-2505": 262144, + "mistral-small-2503": 131072, + "codestral-2501": 262144, + "command-r-plus": 128000, + "command-r": 128000, + "grok-3": 131072, + "grok-3-mini": 131072 + } +} diff --git a/desktop/src/features/agents/lib/modelContextLimits.test.mjs b/desktop/src/features/agents/lib/modelContextLimits.test.mjs new file mode 100644 index 00000000000..41d327f288c --- /dev/null +++ b/desktop/src/features/agents/lib/modelContextLimits.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveContextWindow } from "./modelContextLimits.ts"; + +test("exact model match resolves the table value", () => { + assert.equal(resolveContextWindow("claude-sonnet-4-20250514"), 200_000); + assert.equal(resolveContextWindow("gpt-4o"), 128_000); +}); + +test("match is case-insensitive and trims whitespace", () => { + assert.equal(resolveContextWindow(" CLAUDE-SONNET-4-20250514 "), 200_000); +}); + +test("provider prefixes are stripped before lookup", () => { + assert.equal(resolveContextWindow("anthropic/claude-sonnet-4-20250514"), 200_000); + assert.equal(resolveContextWindow("openai/gpt-4o"), 128_000); + assert.equal(resolveContextWindow("alibaba-token-plan/qwen3.8-max"), 131_072); +}); + +test("prefix match resolves dated model variants", () => { + assert.equal(resolveContextWindow("qwen3-coder-480b-a35b-instruct-2025"), 262_144); +}); + +test("unknown models fall back to the default window", () => { + assert.equal(resolveContextWindow("some-brand-new-model"), 200_000); + assert.equal(resolveContextWindow(null), 200_000); + assert.equal(resolveContextWindow(undefined), 200_000); +}); + +test("observed usage above the table value promotes to 1M", () => { + // claude-sonnet-4 is 200k in the table; a turn that already used 250k + // proves a larger window — promote. + assert.equal(resolveContextWindow("claude-sonnet-4-20250514", 250_000), 1_048_576); +}); + +test("observed usage at or below the table value keeps the table value", () => { + assert.equal(resolveContextWindow("claude-sonnet-4-20250514", 150_000), 200_000); + assert.equal(resolveContextWindow("claude-sonnet-4-20250514", 200_000), 200_000); +}); diff --git a/desktop/src/features/agents/lib/modelContextLimits.ts b/desktop/src/features/agents/lib/modelContextLimits.ts new file mode 100644 index 00000000000..f28566d8519 --- /dev/null +++ b/desktop/src/features/agents/lib/modelContextLimits.ts @@ -0,0 +1,73 @@ +/** + * BUZZ-DESKTOP-003: Model context window lookup with fallback heuristic. + * + * Resolution order: + * 1. Exact match in the static model limits table. + * 2. Prefix match (e.g. "claude-sonnet-4-20250514" matches "claude-sonnet-4"). + * 3. Observed-max heuristic: if any turn in this session exceeded the + * default window, promote to 1M (same logic as the VS-Code Buzz Monitor). + * 4. Default (200k). + */ +import limitsJson from "./modelContextLimits.json"; + +const DEFAULT_CONTEXT_WINDOW = + (limitsJson as { defaultContextWindow?: number }).defaultContextWindow ?? + 200_000; + +const MODEL_LIMITS: Record = ( + limitsJson as { models: Record } +).models; + +/** Sorted prefix keys for longest-prefix-first matching. */ +const PREFIX_KEYS = Object.keys(MODEL_LIMITS).sort( + (a, b) => b.length - a.length, +); + +/** + * Resolve the context window for a model identifier. + * + * @param modelId - Model string from session_config_captured or kind:44200. + * @param observedMaxInputTokens - Highest input-token count seen in this + * session so far. Used for the 200k→1M promotion heuristic. + * @returns Context window in tokens. + */ +export function resolveContextWindow( + modelId: string | null | undefined, + observedMaxInputTokens = 0, +): number { + if (!modelId) + return promoteIfObserved(DEFAULT_CONTEXT_WINDOW, observedMaxInputTokens); + + const normalized = modelId.trim().toLowerCase(); + + // Exact match + const exact = MODEL_LIMITS[normalized]; + if (exact) return promoteIfObserved(exact, observedMaxInputTokens); + + // Strip provider prefix (e.g. "anthropic/claude-sonnet-4" → "claude-sonnet-4") + const withoutProvider = normalized.includes("/") + ? normalized.slice(normalized.lastIndexOf("/") + 1) + : normalized; + if (withoutProvider !== normalized) { + const stripped = MODEL_LIMITS[withoutProvider]; + if (stripped) return promoteIfObserved(stripped, observedMaxInputTokens); + } + + // Prefix match (longest first) + for (const key of PREFIX_KEYS) { + if (withoutProvider.startsWith(key)) { + return promoteIfObserved(MODEL_LIMITS[key], observedMaxInputTokens); + } + } + + return promoteIfObserved(DEFAULT_CONTEXT_WINDOW, observedMaxInputTokens); +} + +/** + * If observed usage already exceeds the resolved window, the model likely + * has a larger context than our table knows. Promote to 1M. + */ +function promoteIfObserved(base: number, observed: number): number { + if (observed > base) return 1_048_576; + return base; +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index e6c36ab6f37..ce8ecfa66e0 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -6,6 +6,10 @@ import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { ComposerUploadProgressOverlay } from "@/features/messages/ui/ComposerUploadProgressOverlay"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { + ContextUsageRing, + formatTokenCount, +} from "@/features/messages/ui/ContextUsageRing"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; import { isModerationDm } from "@/features/moderation/lib/moderationDm"; @@ -36,6 +40,7 @@ import { useThreadViewMode } from "@/features/channels/lib/threadViewModePrefere import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch"; import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; +import { useChannelContextUsage } from "@/features/agents/lib/channelContextUsage"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; import { mergeThreadComposerActivityPubkeys } from "@/features/channels/ui/channelComposerActivityMerge"; @@ -388,6 +393,36 @@ export const ChannelPane = React.memo(function ChannelPane({ [activeChannel, currentPubkey, profiles], ); + // BUZZ-DESKTOP-003: DM context-fill ring. Only mounted for DM channels — + // channel-scoped metrics let us attribute usage to the DM's agent without + // agent-pubkey plumbing. + const isDmChannel = activeChannel?.channelType === "dm"; + const channelContextUsage = useChannelContextUsage( + isDmChannel ? (activeChannel?.id ?? null) : null, + currentPubkey, + ); + const dmContextRing = React.useMemo(() => { + if (!isDmChannel || !channelContextUsage) return null; + const { usageRatio, model, lastInputTokens, contextWindow } = + channelContextUsage; + const pct = + usageRatio == null ? null : Math.round(usageRatio * 100); + const label = [ + model ?? "unknown model", + pct == null ? "no usage data" : `${pct}% context used`, + lastInputTokens != null && contextWindow > 0 + ? `${formatTokenCount(lastInputTokens)}/${formatTokenCount(contextWindow)} tokens` + : null, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + ); + }, [channelContextUsage, isDmChannel]); + const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ beforeSend: () => @@ -715,6 +750,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onSend={handleSendMessage} profiles={profiles} showBackgroundUploadProgress={false} + toolbarExtraActions={dmContextRing} placeholder={ timeoutState.active ? "You're timed out by community moderators." diff --git a/desktop/src/features/messages/ui/ContextUsageRing.tsx b/desktop/src/features/messages/ui/ContextUsageRing.tsx new file mode 100644 index 00000000000..05c4d13399a --- /dev/null +++ b/desktop/src/features/messages/ui/ContextUsageRing.tsx @@ -0,0 +1,87 @@ +import { formatTokenCount } from "@/features/agents/lib/agentContextUsage"; +import { cn } from "@/shared/lib/cn"; + +export { formatTokenCount }; + +/** + * BUZZ-DESKTOP-003: compact SVG ring showing how much of an agent's context + * window is in use. Sits in the composer toolbar of DMs with agent + * participants. + * + * Visual contract (VS-Code Buzz Monitor parity): one filled ring — the arc + * fraction is the usage ratio, the remainder is a quiet track. Color steps + * up with usage so degradation long before 100 % is visible at a glance: + * < 60 % muted/foreground — normal operation + * 60–85 % amber/warning — precision starts dropping, consider /compact + * > 85 % destructive — hallucination risk spikes + */ + +type ContextUsageRingProps = { + className?: string; + /** Usage ratio in [0, 1]; null renders an empty track (no data yet). */ + ratio: number | null; + /** Diameter in px. */ + size?: number; + /** Accessible label, e.g. "Context 62 % used · sonnet-4.5 · 118k/200k". */ + label: string; +}; + +export function ContextUsageRing({ + className, + ratio, + size = 20, + label, +}: ContextUsageRingProps) { + const stroke = 3; + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const clamped = ratio == null ? 0 : Math.min(1, Math.max(0, ratio)); + const dashOffset = circumference * (1 - clamped); + + const tone = + ratio == null + ? "text-muted-foreground/40" + : ratio > 0.85 + ? "text-destructive" + : ratio > 0.6 + ? "text-amber-500" + : "text-muted-foreground"; + + return ( + + {/* Track */} + + {/* Usage arc, rotated so it starts at 12 o'clock */} + {clamped > 0 ? ( + + ) : null} + + ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 227572febe5..7b45efd38c8 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -101,6 +101,7 @@ type AppSidebarProps = { | "channel" | "messages" | "agents" + | "agent-sessions" | "workflows" | "pulse" | "projects"; @@ -142,6 +143,7 @@ type AppSidebarProps = { onRemoveCommunity: (id: string) => Promise; onCreateAgent: () => void; onSelectAgents: () => void; + onSelectAgentSessions: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -210,6 +212,7 @@ export function AppSidebar({ onRemoveCommunity, onCreateAgent, onSelectAgents, + onSelectAgentSessions, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -609,6 +612,7 @@ export function AppSidebar({ void; + onSelectAgentSessions: () => void; onSelectHome: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -89,6 +91,7 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, + onSelectAgentSessions, onSelectHome, onSelectProjects, onSelectPulse, @@ -179,6 +182,20 @@ export function AppSidebarPrimaryMenu({ + + + + + Agent Sessions + + + Date: Fri, 21 Aug 2026 10:22:46 +0200 Subject: [PATCH 08/76] fix(desktop): restore membership-first mention authorization (cherry-pick bc519523) The sessions-panel build (feat/agent-sessions-panel) branched from fix/windows-notification-permissions, which predates the integration branch carrying PR #5881 (membership-first mentions). The new canary client therefore fell back to directory channel_ids-only eligibility: agents added to a new channel were members but not mentionable. Cherry-picks bc519523 (authorize external member mentions) onto the sessions-panel branch: live channel membership now admits relay agents into the mention picker even when their kind:10100 profile is stale; response policy and allowlist remain enforced, send-boundary revalidation still fails closed. Regression 2026-08-21: Claude-Host added to 2 new channels -> member in both, absent from @-mention picker until profile re-publish. Fix removes the profile-dependency entirely (per LOCAL_MODIFICATIONS BUZZ-DESKTOP-002). Tests: 39/39 focused unit tests green, tsc --noEmit clean. Signed-off-by: Robi Hermes --- .../lib/agentAutocompleteEligibility.test.mjs | 36 ++++++ .../lib/agentAutocompleteEligibility.ts | 29 ++++- .../lib/agentMentionRevalidation.test.mjs | 99 +++++++++++++++- .../messages/lib/agentMentionRevalidation.ts | 77 ++++++++++--- .../src/features/messages/lib/useMentions.ts | 32 +++-- .../messages/ui/useMentionSendFlow.ts | 22 ++-- desktop/tests/e2e/mentions.spec.ts | 109 +++++++++++++++++- 7 files changed, 363 insertions(+), 41 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index d7a6e759635..3bb0c80dabf 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -164,6 +164,42 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); +test("getMentionableAgentPubkeys: admits authorized roster agents despite stale directory channels", () => { + const authorizedMember = { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }; + const unauthorizedMember = { + pubkey: PUB_C, + respondTo: "allowlist", + respondToAllowlist: [OTHER_OWNER_PUBKEY], + channelIds: [], + }; + const authorizedNonmember = { + pubkey: PUB_D, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }; + + assert.deepEqual( + getMentionableAgentPubkeys({ + eligibilityScope: { + type: "channel", + channelId: "general", + memberPubkeys: new Set([PUB_B, PUB_C]), + }, + managedAgentPubkeys: [], + currentPubkey: CURRENT_PUBKEY, + relayAgents: [authorizedMember, unauthorizedMember, authorizedNonmember], + sharedChannelIds: new Set(["general"]), + }), + new Set([PUB_B]), + ); +}); + test("getMentionableAgentPubkeys: scopes channel composers and fails closed without context", () => { const relayAgents = [ { diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index a4b235fa04c..62ff52f60a8 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -31,19 +31,35 @@ export function relayAgentIsSharedWithUser( } export function relayAgentCanRespondInChannel( - agent: Pick, + agent: Pick & + Partial>, channelId: string, currentPubkey?: string | null, + memberPubkeys: ReadonlySet = new Set(), ) { - return ( - agent.channelIds.includes(channelId) && - relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey) - ); + const hasChannelEvidence = + agent.channelIds.includes(channelId) || + (agent.pubkey !== undefined && + memberPubkeys.has(normalizePubkey(agent.pubkey))); + if (!hasChannelEvidence) return false; + + if (agent.respondTo === "allowlist" && currentPubkey) { + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return agent.respondToAllowlist + .map((pubkey) => normalizePubkey(pubkey)) + .includes(normalizedCurrentPubkey); + } + + return agent.respondTo === "anyone"; } export type AgentEligibilityScope = | { type: "community" } - | { type: "channel"; channelId: string } + | { + type: "channel"; + channelId: string; + memberPubkeys?: ReadonlySet; + } | { type: "managed-only" }; export function getMentionableAgentPubkeys({ @@ -73,6 +89,7 @@ export function getMentionableAgentPubkeys({ agent, eligibilityScope.channelId, currentPubkey, + eligibilityScope.memberPubkeys, ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs index 214c9a950fa..95928d729da 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs @@ -8,12 +8,16 @@ const AGENT = "b".repeat(64); const HUMAN = "c".repeat(64); const OTHER_OWNER = "d".repeat(64); -function options(refetchOwnerProfiles) { +function options(refetchOwnerProfiles, overrides = {}) { return { pubkeys: [HUMAN, AGENT], agentPubkeys: new Set([AGENT]), currentPubkey: CURRENT, - eligibilityScope: { type: "channel", channelId: "general" }, + eligibilityScope: { + type: "channel", + channelId: "general", + memberPubkeys: new Set([AGENT]), + }, sharedChannelIds: new Set(["general"]), ownerOnly: true, ownerPolicyError: null, @@ -29,7 +33,12 @@ function options(refetchOwnerProfiles) { ], error: null, }), + refetchChannelMembers: async () => ({ + data: [{ pubkey: AGENT }], + error: null, + }), refetchOwnerProfiles, + ...overrides, }; } @@ -72,3 +81,89 @@ for (const [name, refetchOwnerProfiles] of [ ); }); } + +test("channel revalidation drops an agent removed after selection", async () => { + const result = await revalidateAgentMentionPubkeys( + options( + async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [], + }), + { refetchChannelMembers: async () => ({ data: [], error: null }) }, + ), + ); + + assert.deepEqual(result, [HUMAN]); +}); + +test("channel revalidation fails closed when the fresh roster fails", async () => { + const result = await revalidateAgentMentionPubkeys( + options( + async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [], + }), + { + refetchChannelMembers: async () => ({ + data: [{ pubkey: AGENT }], + error: new Error("relay unavailable"), + }), + }, + ), + ); + + assert.deepEqual(result, [HUMAN]); +}); + +test("invite preflight revalidates policy before requiring membership", async () => { + let rosterReads = 0; + const result = await revalidateAgentMentionPubkeys( + options( + async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [], + }), + { + eligibilityScope: { + type: "channel", + channelId: "general", + memberPubkeys: new Set(), + }, + requireChannelMembership: false, + refetchChannelMembers: async () => { + rosterReads += 1; + return { data: [], error: null }; + }, + }, + ), + ); + + assert.equal(rosterReads, 0); + assert.deepEqual(result, [HUMAN, AGENT]); +}); + +test("fresh roster admits an authorized agent with stale directory channels", async () => { + const result = await revalidateAgentMentionPubkeys( + options( + async () => ({ + profiles: { [AGENT]: { ownerPubkey: CURRENT } }, + missing: [], + }), + { + refetchRelayAgents: async () => ({ + data: [ + { + pubkey: AGENT, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }, + ], + error: null, + }), + }, + ), + ); + + assert.deepEqual(result, [HUMAN, AGENT]); +}); diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts index d6c665ba67e..222aaa788fc 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -7,6 +7,7 @@ import { import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { getUsersBatch } from "@/shared/api/tauriProfiles"; import type { + ChannelMember, ManagedAgent, RelayAgent, UsersBatchResponse, @@ -20,6 +21,10 @@ type DirectoryResult = { error: Error | null; }; +type AgentMentionRevalidationOptions = { + requireChannelMembership?: boolean; +}; + export async function revalidateAgentMentionPubkeys({ pubkeys, agentPubkeys, @@ -30,7 +35,9 @@ export async function revalidateAgentMentionPubkeys({ ownerPolicyError, refetchManagedAgents, refetchRelayAgents, + refetchChannelMembers, refetchOwnerProfiles, + requireChannelMembership = true, }: { pubkeys: readonly string[]; agentPubkeys: ReadonlySet; @@ -41,7 +48,9 @@ export async function revalidateAgentMentionPubkeys({ ownerPolicyError: Error | null; refetchManagedAgents: () => Promise>; refetchRelayAgents: () => Promise>; + refetchChannelMembers?: () => Promise>; refetchOwnerProfiles: (pubkeys: string[]) => Promise; + requireChannelMembership?: boolean; }) { const requestedAgentPubkeys = new Set( pubkeys.map(normalizePubkey).filter((pubkey) => agentPubkeys.has(pubkey)), @@ -50,13 +59,19 @@ export async function revalidateAgentMentionPubkeys({ return [...pubkeys]; } - const [managedResult, relayResult, ownerProfiles] = await Promise.all([ - refetchManagedAgents(), - refetchRelayAgents(), - ownerOnly - ? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null) - : Promise.resolve(null), - ]); + const [managedResult, relayResult, ownerProfiles, channelMembersResult] = + await Promise.all([ + refetchManagedAgents(), + refetchRelayAgents(), + ownerOnly + ? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null) + : Promise.resolve(null), + eligibilityScope.type === "channel" && + requireChannelMembership && + refetchChannelMembers + ? refetchChannelMembers() + : Promise.resolve(null), + ]); if ( managedResult.error !== null || relayResult.error !== null || @@ -64,7 +79,12 @@ export async function revalidateAgentMentionPubkeys({ relayResult.data === undefined || ownerOnly === undefined || ownerPolicyError !== null || - (ownerOnly && ownerProfiles === null) + (ownerOnly && ownerProfiles === null) || + (eligibilityScope.type === "channel" && + requireChannelMembership && + (channelMembersResult === null || + channelMembersResult.error !== null || + channelMembersResult.data === undefined)) ) { return filterAdmittedMentionPubkeys(pubkeys, agentPubkeys, new Set()); } @@ -72,11 +92,34 @@ export async function revalidateAgentMentionPubkeys({ const managedPubkeys = new Set( managedResult.data.map((agent) => normalizePubkey(agent.pubkey)), ); + const freshMemberPubkeys = new Set( + (channelMembersResult?.data ?? []).map((member) => + normalizePubkey(member.pubkey), + ), + ); + const scopedManagedPubkeys = + eligibilityScope.type === "channel" && requireChannelMembership + ? new Set( + [...managedPubkeys].filter((pubkey) => + freshMemberPubkeys.has(pubkey), + ), + ) + : managedPubkeys; + const scopedRelayAgents = + eligibilityScope.type === "channel" && requireChannelMembership + ? relayResult.data.filter((agent) => + freshMemberPubkeys.has(normalizePubkey(agent.pubkey)), + ) + : relayResult.data; + const freshEligibilityScope = + eligibilityScope.type === "channel" && requireChannelMembership + ? { ...eligibilityScope, memberPubkeys: freshMemberPubkeys } + : eligibilityScope; const mentionablePubkeys = getMentionableAgentPubkeys({ currentPubkey, - eligibilityScope, - managedAgentPubkeys: managedPubkeys, - relayAgents: relayResult.data, + eligibilityScope: freshEligibilityScope, + managedAgentPubkeys: scopedManagedPubkeys, + relayAgents: scopedRelayAgents, sharedChannelIds, }); const admittedPubkeys = new Set( @@ -84,7 +127,7 @@ export async function revalidateAgentMentionPubkeys({ (pubkey) => getAgentMentionAdmission({ isAgent: true, - isManagedAgent: managedPubkeys.has(pubkey), + isManagedAgent: scopedManagedPubkeys.has(pubkey), pubkey, ownerPubkey: ownerProfiles?.profiles[pubkey]?.ownerPubkey, currentPubkey, @@ -107,6 +150,7 @@ export function useAgentMentionRevalidation({ ownerPolicyError, refetchManagedAgents, refetchRelayAgents, + refetchChannelMembers, }: { agentPubkeys: ReadonlySet; getSelectedAgentPubkeys: () => ReadonlySet; @@ -117,6 +161,7 @@ export function useAgentMentionRevalidation({ ownerPolicyError: Error | null; refetchManagedAgents: () => Promise>; refetchRelayAgents: () => Promise>; + refetchChannelMembers?: () => Promise>; }) { const queryClient = useQueryClient(); const refetchOwnerProfiles = React.useCallback( @@ -127,7 +172,10 @@ export function useAgentMentionRevalidation({ [queryClient], ); return React.useCallback( - (pubkeys: readonly string[]) => + ( + pubkeys: readonly string[], + options: AgentMentionRevalidationOptions = {}, + ) => revalidateAgentMentionPubkeys({ pubkeys, agentPubkeys: new Set([...agentPubkeys, ...getSelectedAgentPubkeys()]), @@ -138,7 +186,9 @@ export function useAgentMentionRevalidation({ ownerPolicyError, refetchManagedAgents, refetchRelayAgents, + refetchChannelMembers, refetchOwnerProfiles, + requireChannelMembership: options.requireChannelMembership, }), [ agentPubkeys, @@ -148,6 +198,7 @@ export function useAgentMentionRevalidation({ ownerOnly, ownerPolicyError, refetchManagedAgents, + refetchChannelMembers, refetchOwnerProfiles, refetchRelayAgents, sharedChannelIds, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index fbf59e4c958..a076b19678a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -183,12 +183,29 @@ export function useMentions( const mentionChannelId = isAgentMentionChannelType(options?.channelType) ? channelId : null; + const channelRosterReady = + membersQuery.data !== undefined && + membersQuery.error === null && + !membersQuery.isFetching; + const memberPubkeys = React.useMemo( + () => + new Set( + (channelRosterReady ? (membersQuery.data ?? []) : []).map((member) => + normalizePubkey(member.pubkey), + ), + ), + [channelRosterReady, membersQuery.data], + ); const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ currentPubkey, eligibilityScope: mentionChannelId - ? { type: "channel", channelId: mentionChannelId } + ? { + type: "channel", + channelId: mentionChannelId, + memberPubkeys, + } : { type: "managed-only" }, managedAgentPubkeys, relayAgents: relayAgentsQuery.data, @@ -197,6 +214,7 @@ export function useMentions( [ currentPubkey, managedAgentPubkeys, + memberPubkeys, mentionChannelId, relayAgentsQuery.data, sharedChannelIds, @@ -231,11 +249,6 @@ export function useMentions( () => new Set(activePersonas.map((persona) => persona.id)), [activePersonas], ); - const memberPubkeys = React.useMemo( - () => - new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), - [members], - ); const agentIdentityPubkeys = React.useMemo( () => getAgentIdentityPubkeys({ @@ -821,13 +834,18 @@ export function useMentions( getSelectedAgentPubkeys, currentPubkey, eligibilityScope: mentionChannelId - ? { type: "channel", channelId: mentionChannelId } + ? { + type: "channel", + channelId: mentionChannelId, + memberPubkeys, + } : { type: "managed-only" }, sharedChannelIds, ownerOnly: agentAccessOwnerOnlyQuery.data, ownerPolicyError: agentAccessOwnerOnlyQuery.error, refetchManagedAgents: managedAgentsQuery.refetch, refetchRelayAgents: relayAgentsQuery.refetch, + refetchChannelMembers: mentionChannelId ? membersQuery.refetch : undefined, }); const extractMentionPersonas = React.useCallback( diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index e322f91987d..a7dd473c23f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -402,7 +402,9 @@ export function useMentionSendFlow({ let uploadStarted = false; try { const admittedMentionPubkeys = uniqueNormalizedPubkeys( - await mentions.revalidateMentionPubkeys(mentionPubkeys), + await mentions.revalidateMentionPubkeys(mentionPubkeys, { + requireChannelMembership: false, + }), ); if (isSendCancelled()) return; if (!isMountedRef.current) return persistPreflightDraft(); @@ -885,13 +887,16 @@ export function useMentionSendFlow({ } setNonMemberPromptError(null); void (async () => { - const mentionPubkeys = uniqueNormalizedPubkeys( - await mentions.revalidateMentionPubkeys([ - ...pendingNonMemberSend.mentionPubkeys, - ...pendingNonMemberSend.nonMemberPubkeys, - ]), + const preInviteMentionPubkeys = uniqueNormalizedPubkeys( + await mentions.revalidateMentionPubkeys( + [ + ...pendingNonMemberSend.mentionPubkeys, + ...pendingNonMemberSend.nonMemberPubkeys, + ], + { requireChannelMembership: false }, + ), ); - const admittedMentionPubkeys = new Set(mentionPubkeys); + const admittedMentionPubkeys = new Set(preInviteMentionPubkeys); const originalNonMemberPubkeys = new Set( pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey), ); @@ -943,6 +948,9 @@ export function useMentionSendFlow({ return; } + const mentionPubkeys = uniqueNormalizedPubkeys( + await mentions.revalidateMentionPubkeys(preInviteMentionPubkeys), + ); await completeSend( { ...pendingNonMemberSend, diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 801000189f6..696a9220179 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -233,6 +233,14 @@ async function waitForTimelineSettled(page: import("@playwright/test").Page) { await expect(page.locator("[data-render-pending]")).toHaveCount(0); } +async function typeAfterSelectedMention( + input: import("@playwright/test").Locator, + text: string, +) { + const current = ((await input.textContent()) ?? "").trimEnd(); + await input.fill(`${current} ${text}`); +} + async function expectOwnedAgentProfileActions( profilePopover: import("@playwright/test").Locator, pubkey: string, @@ -320,6 +328,95 @@ test("@ trigger prioritizes channel members before runnable personas and other m expect(fizzIndex).toBeLessThan(charlieIndex); }); +test("roster-authorized agents with stale directory channels emit the canonical p tag", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: TEST_IDENTITIES.bob.pubkey, + name: "bob", + respondTo: "anyone", + channelIds: [], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Ask @bob"); + + const bobRow = autocomplete(page).locator("button", { hasText: "bob" }); + await expect(bobRow).toBeVisible(); + await bobRow.click(); + await typeAfterSelectedMention(input, "please reply"); + + const content = "Ask @bob please reply"; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toEqual([TEST_IDENTITIES.bob.pubkey]); +}); + +test("send-time roster refresh drops an agent removed after selection", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: TEST_IDENTITIES.bob.pubkey, + name: "bob", + respondTo: "anyone", + channelIds: [], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Ask @bob"); + const bobRow = autocomplete(page).locator("button", { hasText: "bob" }); + await expect(bobRow).toBeVisible(); + await bobRow.click(); + await typeAfterSelectedMention(input, "please reply"); + + await page.evaluate( + async ({ channelId, pubkey }) => { + const tauriWindow = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + const invoke = + tauriWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? + tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!invoke) throw new Error("Mock invoke bridge is unavailable."); + await invoke("remove_channel_member", { channelId, pubkey }); + }, + { channelId: GENERAL_CHANNEL_ID, pubkey: TEST_IDENTITIES.bob.pubkey }, + ); + + const content = "Ask @bob please reply"; + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toEqual([]); +}); + test("relay-only shared agents emit an outbound mention tag when selected", async ({ page, }) => { @@ -333,7 +430,7 @@ test("relay-only shared agents emit an outbound mention tag when selected", asyn const aliceRow = autocomplete(page).locator("button", { hasText: "alice" }); await expect(aliceRow).toBeVisible(); await aliceRow.click(); - await page.keyboard.type("please reply"); + await typeAfterSelectedMention(input, "please reply"); const content = "Ask @alice please reply"; await expect(input).toHaveText(content); @@ -1082,7 +1179,7 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ const input = page.getByTestId("message-input"); await input.fill("@quinn"); await page.getByTestId("mention-autocomplete").getByText("quinn").click(); - await page.keyboard.type("hello"); + await typeAfterSelectedMention(input, "hello"); await page.getByRole("button", { name: "Attach file" }).click(); const removeAttachment = page.getByRole("button", { name: "Remove attachment", @@ -1204,7 +1301,7 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); await expect(quinnRow).toBeVisible(); await quinnRow.click(); - await page.keyboard.type("hello"); + await typeAfterSelectedMention(input, "hello"); await expect(input).toHaveText("@quinn hello"); await page.getByTestId("send-message").click(); await page.getByRole("button", { name: "Invite", exact: true }).click(); @@ -1235,7 +1332,7 @@ test("selected relay agents revoked before send emit no p tag", async ({ const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); await expect(quinnRow).toBeVisible(); await quinnRow.click(); - await page.keyboard.type("hello"); + await typeAfterSelectedMention(input, "hello"); await page.evaluate(async () => { window.__BUZZ_E2E__.mock ??= {}; @@ -1300,7 +1397,7 @@ test("selected relay agents revoked after the invite prompt cause no side effect const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); await expect(quinnRow).toBeVisible(); await quinnRow.click(); - await page.keyboard.type("hello"); + await typeAfterSelectedMention(input, "hello"); await page.getByTestId("send-message").click(); const inviteButton = page.getByRole("button", { name: "Invite", @@ -1357,7 +1454,7 @@ test("selected relay agents revoked during send emit no p tag", async ({ const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); await expect(quinnRow).toBeVisible(); await quinnRow.click(); - await page.keyboard.type("hello"); + await typeAfterSelectedMention(input, "hello"); await page.evaluate(() => { window.__BUZZ_E2E__.mock ??= {}; From ffed2a25029d17b5eac017320e204354ed617bfb Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Fri, 21 Aug 2026 10:42:59 +0200 Subject: [PATCH 09/76] feat(desktop): restore read-only 'On this relay' agents section (cherry-pick 8ca8852b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the documented BUZZ-DESKTOP-002 contract (LOCAL_MODIFICATIONS.md: patches/buzz-desktop-pr5883-pr5881.patch) was missing from the sessions-panel branch — only #5881 (membership-first mentions) had been restored. This cherry-picks upstream PR #5883 so external kind:10100 relay agents are visible and inspectable again in the Agents view. Cherry-pick applied clean on feat/agent-sessions-panel. Signed-off-by: Robi Hermes --- desktop/playwright.config.ts | 1 + desktop/src/features/agents/ui/AgentsView.tsx | 8 ++ .../features/agents/ui/RelayAgentsSection.tsx | 114 +++++++++++++++++ .../tests/e2e/relay-agents-section.spec.ts | 116 ++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 desktop/src/features/agents/ui/RelayAgentsSection.tsx create mode 100644 desktop/tests/e2e/relay-agents-section.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7d06c4da91b..4fd20c1fd9f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -147,6 +147,7 @@ export default defineConfig({ "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", "**/needs-restart-screenshots.spec.ts", + "**/relay-agents-section.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35f..8c2c0050f5f 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -18,6 +18,7 @@ import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog"; import { TeamShareDialog } from "./TeamShareDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; +import { RelayAgentsSection } from "./RelayAgentsSection"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; @@ -291,6 +292,13 @@ export function AgentsView() { personas={personas.libraryPersonas} teams={teamActions.teams} /> + + { + openProfilePanel?.(pubkey); + }} + /> diff --git a/desktop/src/features/agents/ui/RelayAgentsSection.tsx b/desktop/src/features/agents/ui/RelayAgentsSection.tsx new file mode 100644 index 00000000000..51c8dae768a --- /dev/null +++ b/desktop/src/features/agents/ui/RelayAgentsSection.tsx @@ -0,0 +1,114 @@ +import * as React from "react"; +import { BadgeCheck } from "lucide-react"; + +import { useRelayAgentsQuery } from "@/features/agents/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { Badge } from "@/shared/ui/badge"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { AgentIdentityCard } from "./AgentIdentityCard"; +import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; + +type RelayAgentsSectionProps = { + /** Local managed agents; relay records for these pubkeys are shown above. */ + managedAgents: ManagedAgent[]; + onOpenAgentProfile: (pubkey: string) => void; +}; + +function audienceLabel(agent: RelayAgent): string { + switch (agent.respondTo) { + case "anyone": + return "anyone"; + case "allowlist": + return "allowlist"; + case "owner-only": + return "owner only"; + default: + return "no audience set"; + } +} + +function channelsLabel(agent: RelayAgent): string { + const count = agent.channelIds.length; + if (count === 0) return "No channels"; + return count === 1 ? "1 channel" : `${count} channels`; +} + +/** + * Read-only directory of agents hosted outside this app (kind:10100 records + * published on the relay). These agents run elsewhere — there is deliberately + * no start/edit/delete here: recreating them locally would mint duplicate + * identities. Records whose pubkey matches a local managed agent are shown in + * the managed section instead; archived identities are hidden entirely. + */ +export function RelayAgentsSection({ + managedAgents, + onOpenAgentProfile, +}: RelayAgentsSectionProps) { + const relayAgentsQuery = useRelayAgentsQuery(); + const isArchived = useIsArchivedPredicate(); + const identityQuery = useIdentityQuery(); + const selfPubkey = identityQuery.data?.pubkey?.toLowerCase() ?? null; + + const managedPubkeys = React.useMemo( + () => new Set(managedAgents.map((agent) => agent.pubkey.toLowerCase())), + [managedAgents], + ); + + const remoteAgents = React.useMemo( + () => + (relayAgentsQuery.data ?? []) + .filter((agent) => !managedPubkeys.has(agent.pubkey.toLowerCase())) + .filter((agent) => !isArchived(agent.pubkey)) + .sort((a, b) => a.name.localeCompare(b.name)), + [relayAgentsQuery.data, managedPubkeys, isArchived], + ); + + const profilesQuery = useUsersBatchQuery( + remoteAgents.map((agent) => agent.pubkey), + { enabled: remoteAgents.length > 0 }, + ); + + if (remoteAgents.length === 0) return null; + + return ( +
+ +
+ {remoteAgents.map((agent) => { + const summary = + profilesQuery.data?.profiles[agent.pubkey.toLowerCase()] ?? null; + const label = summary?.displayName?.trim() || agent.name; + const ownedByMe = + selfPubkey !== null && + summary?.ownerPubkey?.toLowerCase() === selfPubkey; + + return ( + onOpenAgentProfile(agent.pubkey)} + statusBadge={ + ownedByMe ? ( + + + Owned by you + + ) : null + } + /> + ); + })} +
+
+ ); +} diff --git a/desktop/tests/e2e/relay-agents-section.spec.ts b/desktop/tests/e2e/relay-agents-section.spec.ts new file mode 100644 index 00000000000..7eb0a08424f --- /dev/null +++ b/desktop/tests/e2e/relay-agents-section.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); +const OWNED_REMOTE_AGENT_PUBKEY = "a7".repeat(32); +const DM_ONLY_REMOTE_AGENT_PUBKEY = "b7".repeat(32); + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: OWNED_REMOTE_AGENT_PUBKEY, + name: "AM Window", + channelNames: ["general", "random"], + respondTo: "anyone", + }, + { + pubkey: DM_ONLY_REMOTE_AGENT_PUBKEY, + name: "Quinn", + respondTo: "owner-only", + }, + ], + searchProfiles: [ + { + pubkey: OWNED_REMOTE_AGENT_PUBKEY, + displayName: "AM Window", + ownerPubkey: MOCK_IDENTITY_PUBKEY, + isAgent: true, + }, + ], + }); +}); + +async function openAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-page-content")).toBeVisible(); +} + +test("relay-hosted agents render in a read-only directory section", async ({ + page, +}) => { + await openAgentsView(page); + + const section = page.getByTestId("agents-on-this-relay"); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + await expect(section.getByText("On this relay")).toBeVisible(); + + // Channel-scoped agent: membership summary, audience, and the owner badge + // sourced from its kind:0 NIP-OA ownership (profile batch). + const ownedCard = section.getByTestId( + `relay-agent-card-${OWNED_REMOTE_AGENT_PUBKEY}`, + ); + await expect(ownedCard).toBeVisible(); + await expect(ownedCard).toContainText("AM Window"); + await expect(ownedCard).toContainText("2 channels · anyone"); + await expect(ownedCard).toContainText("Owned by you"); + + // DM-only agent: no channels in its directory record, no ownership proof + // seeded, so no badge. + const dmOnlyCard = section.getByTestId( + `relay-agent-card-${DM_ONLY_REMOTE_AGENT_PUBKEY}`, + ); + await expect(dmOnlyCard).toBeVisible(); + await expect(dmOnlyCard).toContainText("Quinn"); + await expect(dmOnlyCard).toContainText("No channels · owner only"); + await expect(dmOnlyCard).not.toContainText("Owned by you"); + + // The cards are read-only: no start button and no actions menu anywhere in + // the section. + await expect(section.getByRole("button", { name: /start/i })).toHaveCount(0); + await expect(section.getByRole("button", { name: /actions/i })).toHaveCount( + 0, + ); + + await waitForAnimations(page); + await section.screenshot({ + path: "test-results/relay-agents-section/section.png", + }); +}); + +test("relay records mirroring local managed agents stay in the managed section", async ({ + page, +}) => { + await openAgentsView(page); + + const section = page.getByTestId("agents-on-this-relay"); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + + // The mock bridge mirrors every managed agent into the relay directory + // (syncMockRelayAgentsFromManagedAgents). None of those pubkeys may render + // here — the managed section above already shows them. + const managedPubkeys = await page.evaluate(async () => { + const internals = ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: (cmd: string) => Promise<{ pubkey: string }[]>; + }; + } + ).__TAURI_INTERNALS__; + const agents = await internals.invoke("list_managed_agents"); + return agents.map((agent) => agent.pubkey); + }); + for (const pubkey of managedPubkeys) { + await expect(section.getByTestId(`relay-agent-card-${pubkey}`)).toHaveCount( + 0, + ); + } +}); From 826ae0572573cef25ac8d26923c02d44f24436dc Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Fri, 21 Aug 2026 11:08:17 +0200 Subject: [PATCH 10/76] feat(desktop): Reset-session button in the Agent Sessions panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kills stuck/dead ACP sessions from the sessions panel: publishes the harness '!rotate' owner command (kind 9, content exactly '!rotate', agent p-tag mention) into the session's channel. buzz-acp consumes it owner-side — in-flight turns are cancelled and the channel session invalidated; idle channel sessions are dropped immediately, so the next mention starts a fresh session. Motivation: session corpses (e.g. a crashed turn leaving a 'stuck?' row in the tree) had no cleanup path from the UI; today only manual CLI '!rotate' messages or a service restart clear them. - RotateSessionButton in the session header, confirm dialog, toasts - isRotateSessionAuthorized lib gate: enabled only for identities in the agent's respond-to allowlist (harness stays owner-authoritative either way); unlisted agents stay ungated, harness rejects non-owner - 8 unit tests green, tsc clean Signed-off-by: Robi Hermes --- .../lib/rotateSessionAuth.test.mjs | 49 +++++++++++ .../agent-sessions/lib/rotateSessionAuth.ts | 37 +++++++++ .../agent-sessions/ui/AgentSessionsScreen.tsx | 83 +++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 desktop/src/features/agent-sessions/lib/rotateSessionAuth.test.mjs create mode 100644 desktop/src/features/agent-sessions/lib/rotateSessionAuth.ts diff --git a/desktop/src/features/agent-sessions/lib/rotateSessionAuth.test.mjs b/desktop/src/features/agent-sessions/lib/rotateSessionAuth.test.mjs new file mode 100644 index 00000000000..ec43a426a95 --- /dev/null +++ b/desktop/src/features/agent-sessions/lib/rotateSessionAuth.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isRotateSessionAuthorized } from "./rotateSessionAuth.ts"; + +const OWNER = "dd57c78422bccf568feb2a7ae5bcf4d7ebefc2c6c54bf56a26faeb9e0b08d36b"; +const SIBLING = "1af26bb78ad6313ca562eed7bec2c72f69ceacce968fb06b92de0aad26901ada"; +const STRANGER = "0000000000000000000000000000000000000000000000000000000000000001"; +const AGENT = "8df15208c09bccecce4d77cbf73874fbaf1441f4a7747925e5e62e422d9f0a1b"; + +const directory = [ + { pubkey: AGENT, respondToAllowlist: [OWNER, SIBLING] }, +]; + +test("owner in the allowlist is authorized", () => { + assert.equal(isRotateSessionAuthorized(directory, OWNER, AGENT), true); +}); + +test("allowlisted sibling is authorized", () => { + assert.equal(isRotateSessionAuthorized(directory, SIBLING, AGENT), true); +}); + +test("stranger outside the allowlist is denied", () => { + assert.equal(isRotateSessionAuthorized(directory, STRANGER, AGENT), false); +}); + +test("no current identity is denied", () => { + assert.equal(isRotateSessionAuthorized(directory, null, AGENT), false); +}); + +test("agent without a directory record stays ungated", () => { + assert.equal(isRotateSessionAuthorized(directory, STRANGER, STRANGER), true); +}); + +test("missing directory query stays ungated (local managed agents)", () => { + assert.equal(isRotateSessionAuthorized(undefined, STRANGER, AGENT), true); +}); + +test("pubkey comparison is case-insensitive", () => { + assert.equal( + isRotateSessionAuthorized(directory, OWNER.toUpperCase(), AGENT.toUpperCase()), + true, + ); +}); + +test("empty allowlist denies everyone (harness stays owner-authoritative)", () => { + const empty = [{ pubkey: AGENT, respondToAllowlist: [] }]; + assert.equal(isRotateSessionAuthorized(empty, OWNER, AGENT), false); +}); diff --git a/desktop/src/features/agent-sessions/lib/rotateSessionAuth.ts b/desktop/src/features/agent-sessions/lib/rotateSessionAuth.ts new file mode 100644 index 00000000000..13f47cc2afd --- /dev/null +++ b/desktop/src/features/agent-sessions/lib/rotateSessionAuth.ts @@ -0,0 +1,37 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type RelayAgentDirectoryRow = { + pubkey: string; + respondToAllowlist: string[]; +}; + +/** + * Rotation gate for the Agent Sessions panel Reset button. + * + * `!rotate` is enforced owner-side by buzz-acp: a rotate command from a + * non-owner falls through to normal prompt handling, it is never an + * authority bypass. The button therefore stays honest about who can + * actually rotate: it is enabled when the current identity appears in the + * agent's respond-to allowlist (owner + allowlisted siblings). + * + * Agents without a relay directory record (local managed agents) stay + * ungated here — the harness author check remains the authority and the + * button simply attempts the send, which fails closed at the harness. + * + * No current identity -> deny (nothing can be attributed). + */ +export function isRotateSessionAuthorized( + relayAgents: readonly RelayAgentDirectoryRow[] | undefined, + currentPubkey: string | null, + agentPubkey: string, +): boolean { + if (!currentPubkey) return false; + const agent = (relayAgents ?? []).find( + (candidate) => + normalizePubkey(candidate.pubkey) === normalizePubkey(agentPubkey), + ); + if (!agent) return true; + return agent.respondToAllowlist + .map((pubkey) => normalizePubkey(pubkey)) + .includes(normalizePubkey(currentPubkey)); +} diff --git a/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx b/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx index b8d012b1370..833ea9ca769 100644 --- a/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx +++ b/desktop/src/features/agent-sessions/ui/AgentSessionsScreen.tsx @@ -4,8 +4,10 @@ import { Circle, CircleAlert, Loader2, + RotateCcw, TerminalSquare, } from "lucide-react"; +import { toast } from "sonner"; import { deriveAgentSessions, @@ -14,6 +16,7 @@ import { import { buildTranscriptState } from "@/features/agents/ui/agentSessionTranscript"; import { useObserverEvents } from "@/features/agents/ui/useObserverEvents"; import { AgentSessionTranscriptList } from "@/features/agents/ui/AgentSessionTranscriptList"; +import { isRotateSessionAuthorized } from "@/features/agent-sessions/lib/rotateSessionAuth"; import { useContextUsageArchive, asTurnMetric, @@ -25,6 +28,7 @@ import { useManagedAgentsQuery, } from "@/features/agents/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { sendChannelMessage } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { cn } from "@/shared/lib/cn"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -371,6 +375,7 @@ function SelectedSessionTranscript({ no token metrics yet )} +
); } + +/** + * Kill/reset control for one session: publishes the harness `!rotate` + * owner command (kind 9, content exactly "!rotate", agent p-tag mention) + * into the session's channel. buzz-acp consumes it owner-side: an + * in-flight turn is cancelled and the channel session invalidated; idle + * channel sessions are dropped immediately — the next mention starts a + * fresh session. This is the documented cleanup path for stuck/dead + * sessions (see LOCAL_MODIFICATIONS BUZZ-ACP notes); rotation is + * per-channel, matching the harness contract. + */ +function RotateSessionButton({ + agentPubkey, + channelId, +}: { + agentPubkey: string; + channelId: string | null; +}) { + const identity = useIdentityQuery(); + const relayAgents = useRelayAgentsQuery(); + const [pending, setPending] = React.useState(false); + + const authorized = isRotateSessionAuthorized( + relayAgents.data, + identity.data?.pubkey ?? null, + agentPubkey, + ); + + const rotate = React.useCallback(async () => { + if (!channelId) { + toast.error("No channel known for this session — cannot reset it."); + return; + } + const confirmed = window.confirm( + "Reset this agent's channel session?\n\n" + + "This sends !rotate to the channel: the harness cancels any in-flight turn and starts the next prompt with a fresh session. " + + "Idle sessions are dropped immediately. Only the agent owner can do this.", + ); + if (!confirmed) return; + setPending(true); + try { + await sendChannelMessage(channelId, "!rotate", undefined, undefined, [ + agentPubkey, + ]); + toast.success("Reset sent — the session will rotate."); + } catch (error) { + toast.error( + `Reset failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + setPending(false); + } + }, [agentPubkey, channelId]); + + return ( + + ); +} From 594842150976dd81941800c690cbabbb91d49ab6 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Fri, 21 Aug 2026 13:50:10 +0200 Subject: [PATCH 11/76] feat(cli): expose NIP-34 issue status events + repos delete tombstone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issues get/list now include status-transition events (kind 1630-1633) with content, making `buzz issues status --content` readable — the commentary was accepted by the relay but had no CLI read path. repos delete removes one's own repo announcement via a head-based kind:5 tombstone, mirroring projects delete (submit + re-query verify). --- crates/buzz-cli/src/commands/issues.rs | 79 ++++++++++++++++++++++++-- crates/buzz-cli/src/commands/repos.rs | 48 ++++++++++++++++ crates/buzz-cli/src/lib.rs | 15 ++++- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/crates/buzz-cli/src/commands/issues.rs b/crates/buzz-cli/src/commands/issues.rs index 15284a0d7bd..8c4d6ad8b7f 100644 --- a/crates/buzz-cli/src/commands/issues.rs +++ b/crates/buzz-cli/src/commands/issues.rs @@ -1,5 +1,9 @@ use std::collections::{HashMap, HashSet}; +use buzz_core::kind::{ + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, +}; + use crate::client::BuzzClient; use crate::commands::with_git_provenance; use crate::error::CliError; @@ -11,6 +15,52 @@ use serde::Deserialize; const ISSUE_ASSIGNMENT_LABEL: &str = "assignment"; const ISSUE_UNASSIGNMENT_LABEL: &str = "unassignment"; +/// NIP-34 issue/patch status-transition kinds (open/resolved/closed/draft). +const ISSUE_STATUS_KINDS: [u32; 4] = [ + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]; + +/// Relay filter for the status-transition events (kind 1630–1633) of the +/// given issue event ids. Status events reference their issue via an `e` tag, +/// so `#e` is the complete read path regardless of whether the status setter +/// passed repo coordinates (the `a` tag is optional on status events). +fn issue_status_filter(issue_ids: &[&str]) -> serde_json::Value { + serde_json::json!({ + "kinds": ISSUE_STATUS_KINDS, + "#e": issue_ids, + "limit": 1000 + }) +} + +fn status_event_created_at(event: &serde_json::Value) -> i64 { + event.get("created_at").and_then(|c| c.as_i64()).unwrap_or(0) +} + +/// Reorder a flat relay response: issue events (kind 1621) first, then their +/// status events (kind 1630–1633) chronologically. This is the read path for +/// `buzz issues status --content` — without it, status-change commentary is +/// accepted by the relay but invisible to every CLI consumer. +fn order_issue_with_status_events(raw: &str) -> Result { + let events: Vec = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("failed to parse issue response: {e}")))?; + let mut issues: Vec = Vec::new(); + let mut statuses: Vec = Vec::new(); + for event in events { + match event.get("kind").and_then(|k| k.as_u64()) { + Some(1621) => issues.push(event), + Some(kind) if ISSUE_STATUS_KINDS.contains(&(kind as u32)) => statuses.push(event), + _ => {} + } + } + statuses.sort_by_key(status_event_created_at); + issues.append(&mut statuses); + serde_json::to_string(&issues) + .map_err(|e| CliError::Other(format!("failed to serialize issue response: {e}"))) +} + fn assignment_note_label(assignees: &[String], label: Option<&str>) -> Result { if let Some(label) = label { let label = label.trim(); @@ -445,12 +495,13 @@ async fn issue_assignment_context( pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> { validate_hex64(event)?; - let filter = serde_json::json!({ + let issue_filter = serde_json::json!({ "kinds": [1621], "ids": [event] }); - let resp = client.query(&filter).await?; - println!("{resp}"); + let status_filter = issue_status_filter(&[event]); + let resp = client.query_multi(&[issue_filter, status_filter]).await?; + println!("{}", order_issue_with_status_events(&resp)?); Ok(()) } @@ -483,7 +534,27 @@ pub async fn cmd_list_issues( } let resp = client.query(&filter).await?; - println!("{resp}"); + let issues: Vec = serde_json::from_str(&resp) + .map_err(|e| CliError::Other(format!("failed to parse issue list: {e}")))?; + let issue_ids: Vec<&str> = issues + .iter() + .filter_map(|issue| issue.get("id").and_then(|id| id.as_str())) + .collect(); + if issue_ids.is_empty() { + println!("{resp}"); + return Ok(()); + } + let status_resp = client.query(&issue_status_filter(&issue_ids)).await?; + let mut statuses: Vec = serde_json::from_str(&status_resp) + .map_err(|e| CliError::Other(format!("failed to parse status events: {e}")))?; + statuses.sort_by_key(status_event_created_at); + let mut merged = issues; + merged.append(&mut statuses); + println!( + "{}", + serde_json::to_string(&merged) + .map_err(|e| CliError::Other(format!("failed to serialize issue list: {e}")))? + ); Ok(()) } diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index e54b95ef20e..6464cbacba4 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -2,6 +2,7 @@ use buzz_core::{ git_perms::{parse_protection_tag, parse_protection_tags, RefPattern}, kind::KIND_GIT_REPO_ANNOUNCEMENT, }; +use buzz_sdk::build_delete_addressable; use nostr::{Event, EventBuilder, Tag, Timestamp}; use crate::client::BuzzClient; @@ -407,6 +408,52 @@ async fn cmd_bind_repo(client: &BuzzClient, repo_id: &str, channel: &str) -> Res submit_repo_update(client, builder).await } +/// `buzz repos delete` +/// +/// Head-based and verified, mirroring `buzz projects delete`: +/// 1. Fetch own live head — `NotFound` if absent. +/// 2. Build a kind:5 tombstone at `head.created_at + 1` targeting the +/// addressable coordinate `30617::`. +/// 3. Submit. +/// 4. Re-query the coordinate; if a newer head survived → `Conflict`. +/// +/// A tombstoned announcement stops resolving on the relay's git transport +/// (clone/fetch/push 404 for everyone); issues and patches already filed on +/// it remain stored. +pub async fn cmd_delete_repo(client: &BuzzClient, repo_id: &str) -> Result<(), CliError> { + validate_repo_id(repo_id)?; + + let head = fetch_own_repo_announcement(client, repo_id) + .await? + .ok_or_else(|| CliError::NotFound(format!("repository {repo_id:?} not found")))?; + let next_ts = head + .created_at + .as_secs() + .checked_add(1) + .map(Timestamp::from) + .ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?; + + let pubkey_hex = client.keys().public_key().to_hex(); + let tombstone = build_delete_addressable(KIND_GIT_REPO_ANNOUNCEMENT, &pubkey_hex, repo_id) + .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? + .custom_created_at(next_ts); + + let event = client.sign_event(tombstone)?; + let raw = client.submit_event(event).await?; + parse_write_response(&raw, "delete event was dominated; a newer head exists")?; + + // Post-submit verification: re-query to confirm the head is gone. + if let Some(survivor) = fetch_own_repo_announcement(client, repo_id).await? { + return Err(CliError::Conflict(format!( + "repository {repo_id:?} still exists (head at {}); a concurrent write raced the delete", + survivor.created_at.as_secs() + ))); + } + + println!("{}", serde_json::json!({ "deleted": repo_id, "status": "ok" })); + Ok(()) +} + pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), CliError> { use crate::{ReposCmd, ReposProtectCmd}; match cmd { @@ -434,6 +481,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await, ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await, ReposCmd::Bind { id, channel } => cmd_bind_repo(client, &id, &channel).await, + ReposCmd::Delete { id } => cmd_delete_repo(client, &id).await, ReposCmd::Protect(command) => match command { ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await, ReposProtectCmd::Set { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2b041da57b5..822f69dd005 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1199,6 +1199,17 @@ pub enum ReposCmd { #[arg(long)] channel: String, }, + /// Delete one of your repositories (head-based tombstone; verified after submit). + /// + /// Removes the announcement so the coordinate stops resolving for + /// clone/fetch/push and disappears from listings. Already-filed issues + /// and patches remain stored. Only the announcement author can delete + /// their own repository. + Delete { + /// Repository identifier (d-tag). + #[arg(long)] + id: String, + }, /// Manage branch and tag protection rules on one of your repositories. #[command(subcommand)] Protect(ReposProtectCmd), @@ -2292,7 +2303,7 @@ mod tests { ); assert_eq!( names(&cmd, "repos"), - vec!["bind", "create", "get", "list", "protect"] + vec!["bind", "create", "delete", "get", "list", "protect"] ); let repos = cmd .get_subcommands() @@ -2368,7 +2379,7 @@ mod tests { ("pr", 5), ("projects", 7), ("reactions", 3), - ("repos", 5), + ("repos", 6), ("social", 7), ("upload", 1), ("users", 5), From 7a61f7a5017afb2e5b1b5cd6e9966058d541537d Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Fri, 21 Aug 2026 14:03:02 +0200 Subject: [PATCH 12/76] fix(cli): tombstone timestamps must respect relay drift window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay rejects events whose created_at drifts more than ±15 minutes from server time. Both projects delete and the new repos delete built the tombstone at head.created_at+1, which fails for any head older than ~14 minutes. Use max(head+1, wall-clock) instead. --- crates/buzz-cli/src/commands/projects.rs | 17 ++++++++++++++--- crates/buzz-cli/src/commands/repos.rs | 20 ++++++++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs index 32056bc6991..628424c960a 100644 --- a/crates/buzz-cli/src/commands/projects.rs +++ b/crates/buzz-cli/src/commands/projects.rs @@ -138,11 +138,22 @@ async fn submit_project( /// Advance the `created_at` counter off an observed head. fn next_timestamp(head: &Event) -> Result { - head.created_at + // The relay rejects events whose timestamp drifts more than ±15 minutes + // from server time. A bare `head + 1` therefore fails for any head older + // than ~14 minutes. Advance to whichever is later: one second past the + // head (NIP-MP tombstones must be strictly after the live head) or the + // wall clock (keeps the tombstone inside the relay's drift window). + let wall = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| CliError::Other(format!("read system clock: {error}")))? + .as_secs(); + let next = head + .created_at .as_secs() .checked_add(1) - .map(Timestamp::from) - .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into()))? + .max(wall); + Ok(Timestamp::from(next)) } /// Strip `auth` from a tag list and pass the resulting envelope through diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 6464cbacba4..f99822ae379 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -426,12 +426,20 @@ pub async fn cmd_delete_repo(client: &BuzzClient, repo_id: &str) -> Result<(), C let head = fetch_own_repo_announcement(client, repo_id) .await? .ok_or_else(|| CliError::NotFound(format!("repository {repo_id:?} not found")))?; - let next_ts = head - .created_at - .as_secs() - .checked_add(1) - .map(Timestamp::from) - .ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?; + // The relay rejects events whose timestamp drifts more than ±15 minutes + // from server time; use whichever is later of head+1 (tombstone must be + // strictly after the live head) and the wall clock. + let wall = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| CliError::Other(format!("read system clock: {error}")))? + .as_secs(); + let next_ts = Timestamp::from( + head.created_at + .as_secs() + .checked_add(1) + .ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))? + .max(wall), + ); let pubkey_hex = client.keys().public_key().to_hex(); let tombstone = build_delete_addressable(KIND_GIT_REPO_ANNOUNCEMENT, &pubkey_hex, repo_id) From ad005bc492e6fd5bccd80401dbe3c45d7fea868e Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Sat, 22 Aug 2026 01:58:07 +0200 Subject: [PATCH 13/76] fix(desktop): make the media download cap symmetric with the upload cap The Windows client refused any download above 50 MiB while the relay accepts generic-file uploads up to 100 MiB (buzz-media default_max_file_bytes). A 51 MiB installer uploaded cleanly and was then undownloadable by the same client: "file too large (51 MiB, max 50 MiB)". Raise MAX_DOWNLOAD_BYTES to 104_857_600 so the two caps match, and split the clipboard decompression-bomb guard onto its own constant. That guard bounds the decoded RGBA buffer rather than the transferred bytes, so it must not grow just because the transfer cap did; it stays at 50 MiB. Buzz-Issue: 000717741d1590ca9112465b1fbcdce79fc8d4df6e5ec12a22c4557e442a32d8 Signed-off-by: Robi Hermes --- .../src-tauri/src/commands/media_download.rs | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d2..c807a15bac0 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -17,8 +17,20 @@ use crate::commands::{ }; use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message}; -/// Maximum download size: 50 MiB. Prevents OOM from oversized responses. -const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +/// Maximum download size: 100 MiB. Prevents OOM from oversized responses. +/// +/// Kept equal to the relay's generic-file upload cap +/// (`buzz-media` `default_max_file_bytes`, 104_857_600) so that anything this +/// client is allowed to upload can also be downloaded again. A lower value +/// here is not a safety margin, it is an asymmetry that strands files. +const MAX_DOWNLOAD_BYTES: u64 = 100 * 1024 * 1024; + +/// Maximum decoded RGBA size for an image copied to the clipboard: 50 MiB. +/// +/// Separate from `MAX_DOWNLOAD_BYTES` on purpose: this one bounds the decoded +/// pixel buffer, not the transferred bytes, so raising the transfer cap must +/// not silently raise the decompression-bomb ceiling with it. +const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 50 * 1024 * 1024; /// Download request timeout. const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -151,7 +163,7 @@ pub async fn download_file( /// /// Returns `tauri::ipc::Response` so the bytes cross IPC as a raw buffer /// instead of a JSON number array (which would be ~3x the size to -/// serialize and deserialize at the 50 MiB cap). +/// serialize and deserialize at the download cap). #[tauri::command] pub async fn fetch_media_bytes( url: String, @@ -190,9 +202,9 @@ pub async fn copy_image_to_clipboard( image::load_from_memory(&bytes).map_err(|e| format!("failed to decode image: {e}"))?; // Guard against decompression bombs: a small compressed file can decode to - // a huge RGBA buffer. Cap at 50 MiB (matching the download size cap). + // a huge RGBA buffer. Cap the decoded buffer at 50 MiB. let pixels = img.width() as u64 * img.height() as u64; - if pixels * 4 > MAX_DOWNLOAD_BYTES { + if pixels * 4 > MAX_CLIPBOARD_IMAGE_BYTES { return Err("image too large to copy to clipboard".to_string()); } @@ -527,6 +539,22 @@ pub async fn fetch_snapshot_bytes( mod tests { use super::*; + /// The download cap must match the relay's generic-file upload cap + /// (`buzz-media` `default_max_file_bytes`). Anything this client can + /// upload must be downloadable again — that asymmetry was the bug. + #[test] + fn download_cap_matches_relay_file_upload_cap() { + assert_eq!(MAX_DOWNLOAD_BYTES, 104_857_600); + } + + /// The decompression-bomb guard bounds the decoded pixel buffer and is + /// deliberately independent of (and below) the transfer cap. + #[test] + fn clipboard_image_cap_is_independent_of_download_cap() { + assert_eq!(MAX_CLIPBOARD_IMAGE_BYTES, 52_428_800); + const { assert!(MAX_CLIPBOARD_IMAGE_BYTES < MAX_DOWNLOAD_BYTES) }; + } + #[test] fn snapshot_kind_json_returns_json_kind_and_correct_cap() { let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); From 98cd88621d3e6ad9108c41ecf1e362b380235c1f Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Sat, 22 Aug 2026 01:58:25 +0200 Subject: [PATCH 14/76] feat(desktop): set issue status from the issue detail rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop could read NIP-34 issue status but never write it: pull requests have lifecycle buttons, issues had a static badge, and the only way to move an issue was `buzz issues status` on the CLI. The status badge in the issue meta rail becomes a picker that publishes a status event (1630 Backlog / 1633 Triage / 1631 Done / 1632 Closed). The event carries the repo coordinate in an `a` tag, which is load-bearing: fetchProjectIssues reads status events with an `#a` filter, so an event without it is invisible to the desktop and the issue silently falls back to Backlog. "In Progress" and "In Review" stay label heuristics with no status event behind them, so they can be the current state but are never offered as a choice. The picker follows the same trust rule the parser applies (allowedActorsForRoot): only the issue author and the repo owner. When the repo owner is a managed agent, sign_project_issue_status signs on its behalf — the mirror of sign_project_pull_request_status. Also records statusCreatedAt on the parsed issue so a second change inside the same whole-second Nostr timestamp still outranks the one it replaces. Buzz-Issue: ed272cd3c0c168edf4270f28382e29c12587c2a201013ba6ca3a8882e16292c5 Signed-off-by: Robi Hermes --- .../src/commands/project_git_workflow.rs | 191 +++++++++++++++++- desktop/src-tauri/src/lib.rs | 1 + .../features/projects/issueStatus.test.mjs | 85 ++++++++ desktop/src/features/projects/issueStatus.ts | 154 ++++++++++++++ .../src/features/projects/projectIssues.d.mts | 5 + .../src/features/projects/projectIssues.mjs | 8 + .../features/projects/projectIssues.test.mjs | 31 +++ .../projects/ui/ProjectIssuesPanel.tsx | 122 ++++++++++- desktop/src/shared/api/projectGit.ts | 11 + desktop/src/testing/e2eBridge.ts | 39 ++++ .../tests/e2e/project-issue-comments.spec.ts | 34 ++++ 11 files changed, 671 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/projects/issueStatus.test.mjs create mode 100644 desktop/src/features/projects/issueStatus.ts diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 2784068c7ca..734771a303e 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -71,6 +71,18 @@ pub struct ProjectPullRequestStatusInput { created_at: u64, } +/// Repository-scoped metadata for an agent-signed issue lifecycle status. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectIssueStatusInput { + target_owner: String, + repo_address: String, + issue_id: String, + issue_author: String, + status: String, + created_at: u64, +} + /// A previously signed merged-status event that needs publishing again. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -313,6 +325,55 @@ fn build_pull_request_status_event( .map_err(|error| format!("sign pull request status: {error}")) } +/// Build a NIP-34 issue status event (kinds 1630/1631/1632/1633). +/// +/// Same shape as `buzz issues status` and the desktop's own +/// `buildGitStatusTags`: root `e` tag, repo `a` tag, and `p` tags for the repo +/// owner plus the issue author. The `a` tag is load-bearing — the desktop +/// reads status events with an `#a` filter, so an event without it is +/// invisible to every client and the issue silently falls back to Backlog. +fn build_issue_status_event( + keys: &Keys, + repo_address: &str, + issue_id: &str, + issue_author: &str, + status: &str, + created_at: u64, +) -> Result { + let owner = keys.public_key().to_hex(); + validate_repo_address(repo_address, &owner)?; + let issue_id = + normalize_event_id(issue_id).ok_or_else(|| "Invalid issue event ID.".to_string())?; + let issue_author = + normalize_event_id(issue_author).ok_or_else(|| "Invalid issue author.".to_string())?; + let kind = match status { + "open" => Kind::Custom(1630), + "resolved" => Kind::Custom(1631), + "closed" => Kind::Custom(1632), + "draft" => Kind::Custom(1633), + _ => return Err("Invalid issue lifecycle status.".to_string()), + }; + let mut raw_tags = vec![ + vec!["e", issue_id.as_str(), "", "root"], + vec!["a", repo_address], + vec!["p", owner.as_str()], + ]; + if issue_author != owner { + raw_tags.push(vec!["p", issue_author.as_str()]); + } + let tags = raw_tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|error| format!("build issue status tags: {error}"))?; + EventBuilder::new(kind, "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at.max(Timestamp::now().as_secs()))) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign issue status: {error}")) +} + fn same_repository(left: &str, right: &str) -> bool { left.trim() .trim_end_matches('/') @@ -465,6 +526,36 @@ pub async fn sign_project_pull_request_status( Ok(()) } +/// Sign and submit an issue lifecycle status as the repository owner. +/// +/// Mirrors [`sign_project_pull_request_status`]: used when the repo owner is a +/// managed agent, so the signed-in human owner cannot sign the status with +/// their own key and still have it accepted by NIP-34 authorization. +#[tauri::command] +pub async fn sign_project_issue_status( + input: ProjectIssueStatusInput, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let target_owner = input.target_owner.trim().to_ascii_lowercase(); + if normalize_event_id(&target_owner).is_none() { + return Err("Invalid target repository owner.".to_string()); + } + let identity = project_owner_identity(&app, &state, &target_owner)?; + let event = Event::from_json(build_issue_status_event( + &identity.keys, + &input.repo_address, + &input.issue_id, + &input.issue_author, + &input.status, + input.created_at, + )?) + .map_err(|error| format!("parse signed issue status: {error}"))?; + submit_signed_event_with_keys(&event, &state, &identity.keys, identity.auth_tag.as_deref()) + .await?; + Ok(()) +} + #[tauri::command] pub async fn publish_project_pull_request_merged_status( input: ProjectPullRequestMergedStatusInput, @@ -671,9 +762,10 @@ pub async fn merge_project_pull_request( #[cfg(test)] mod tests { use super::{ - align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event, - normalize_commit, same_repository, validate_merge_status_metadata, - validate_project_owner_announcement, ProjectOwnerAnnouncementInput, + align_unborn_head_branch, build_issue_status_event, build_merged_status_event, + build_pull_request_status_event, normalize_commit, same_repository, + validate_merge_status_metadata, validate_project_owner_announcement, + ProjectOwnerAnnouncementInput, }; use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; use nostr::{Event, JsonUtil, Keys, Timestamp}; @@ -895,4 +987,97 @@ mod tests { ) .is_err()); } + + #[test] + fn issue_status_carries_repo_coordinate_and_recipients() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let repo_address = format!("30617:{owner}:buzz"); + let author = "b".repeat(64); + let event = Event::from_json( + build_issue_status_event( + &keys, + &repo_address, + &"d".repeat(64), + &author, + "resolved", + Timestamp::now().as_secs(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.kind.as_u16(), 1631); + // Without the `a` tag the desktop's `#a` status filter never sees the + // event and the issue falls back to Backlog. + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["a", repo_address.as_str()])); + // The issue author is notified. The owner's own `p` tag is dropped by + // `EventBuilder::build`, which strips self-tags unless + // `allow_self_tagging` is set — the signer needs no notification. + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", author.as_str()])); + assert!(!event + .tags + .iter() + .any(|tag| tag.as_slice() == ["p", owner.as_str()])); + assert!(event.verify().is_ok()); + } + + #[test] + fn issue_status_maps_each_lifecycle_state_to_its_kind() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + let repo_address = format!("30617:{owner}:buzz"); + for (status, kind) in [ + ("open", 1630u16), + ("resolved", 1631), + ("closed", 1632), + ("draft", 1633), + ] { + let event = Event::from_json( + build_issue_status_event( + &keys, + &repo_address, + &"d".repeat(64), + &"b".repeat(64), + status, + Timestamp::now().as_secs(), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(event.kind.as_u16(), kind, "status {status}"); + } + } + + #[test] + fn issue_status_rejects_unknown_state_and_foreign_repo() { + let keys = Keys::generate(); + let owner = keys.public_key().to_hex(); + + assert!(build_issue_status_event( + &keys, + &format!("30617:{owner}:buzz"), + &"d".repeat(64), + &"b".repeat(64), + "in-progress", + Timestamp::now().as_secs(), + ) + .is_err()); + assert!(build_issue_status_event( + &keys, + &format!("30617:{}:buzz", "a".repeat(64)), + &"d".repeat(64), + &"b".repeat(64), + "closed", + Timestamp::now().as_secs(), + ) + .is_err()); + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6f3f48f3a84..d3a3610c9f7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -659,6 +659,7 @@ pub fn run() { publish_project_owner_announcement, sign_project_pull_request_status, sign_project_pull_request_review_request, + sign_project_issue_status, sign_project_issue_assignment, sign_project_issue_unassignment, publish_project_pull_request_merged_status, diff --git a/desktop/src/features/projects/issueStatus.test.mjs b/desktop/src/features/projects/issueStatus.test.mjs new file mode 100644 index 00000000000..9637fedf2aa --- /dev/null +++ b/desktop/src/features/projects/issueStatus.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canChangeProjectIssueStatus, + ISSUE_LIFECYCLE_STATUS_LABEL, + ISSUE_LIFECYCLE_STATUSES, +} from "./issueStatus.ts"; + +const OWNER = "a".repeat(64); +const AUTHOR = "b".repeat(64); +const STRANGER = "c".repeat(64); + +test("every publishable lifecycle state maps to the label the read path derives", () => { + assert.deepEqual(ISSUE_LIFECYCLE_STATUSES, [ + "draft", + "open", + "resolved", + "closed", + ]); + assert.deepEqual( + ISSUE_LIFECYCLE_STATUSES.map( + (status) => ISSUE_LIFECYCLE_STATUS_LABEL[status], + ), + ["Triage", "Backlog", "Done", "Closed"], + ); +}); + +test("label-driven states are not offered as publishable statuses", () => { + const labels = ISSUE_LIFECYCLE_STATUSES.map( + (status) => ISSUE_LIFECYCLE_STATUS_LABEL[status], + ); + assert.equal(labels.includes("In Progress"), false); + assert.equal(labels.includes("In Review"), false); +}); + +test("issue author and repo owner may change status", () => { + for (const viewer of [AUTHOR, OWNER]) { + assert.equal( + canChangeProjectIssueStatus({ + isManagedAgentOwner: false, + issueAuthor: AUTHOR, + projectOwner: OWNER, + viewer, + }), + true, + ); + } +}); + +test("a third party may not change status", () => { + assert.equal( + canChangeProjectIssueStatus({ + isManagedAgentOwner: false, + issueAuthor: AUTHOR, + projectOwner: OWNER, + viewer: STRANGER, + }), + false, + ); +}); + +test("a signed-out viewer may not change status", () => { + assert.equal( + canChangeProjectIssueStatus({ + isManagedAgentOwner: true, + issueAuthor: AUTHOR, + projectOwner: OWNER, + viewer: null, + }), + false, + ); +}); + +test("the human owner of a managed-agent repo owner may change status", () => { + assert.equal( + canChangeProjectIssueStatus({ + isManagedAgentOwner: true, + issueAuthor: AUTHOR, + projectOwner: OWNER, + viewer: STRANGER, + }), + true, + ); +}); diff --git a/desktop/src/features/projects/issueStatus.ts b/desktop/src/features/projects/issueStatus.ts new file mode 100644 index 00000000000..54d6a72bc25 --- /dev/null +++ b/desktop/src/features/projects/issueStatus.ts @@ -0,0 +1,154 @@ +import { useMutation } from "@tanstack/react-query"; + +import { signProjectIssueStatus } from "@/shared/api/projectGit"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import { + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, +} from "@/shared/constants/kinds"; +import type { Repository as Project } from "./hooks"; +import { useProjectIssueWriteInvalidation } from "./issueAssignments"; +import { + nextProjectIssueStatusCreatedAt, + PROJECT_ISSUE_STATUS, + type ProjectIssue, + type ProjectIssueStatus, +} from "./projectIssues.mjs"; + +/** NIP-34 lifecycle states the desktop can publish for an issue. + * + * "In Progress" and "In Review" are deliberately absent: they are label + * heuristics in `statusFromEvent`, not protocol states, so there is no status + * event that could express them. */ +export type ProjectIssueLifecycleStatus = + | "open" + | "resolved" + | "closed" + | "draft"; + +const ISSUE_STATUS_KIND_BY_LIFECYCLE: Record< + ProjectIssueLifecycleStatus, + number +> = { + open: KIND_GIT_STATUS_OPEN, + resolved: KIND_GIT_STATUS_MERGED, + closed: KIND_GIT_STATUS_CLOSED, + draft: KIND_GIT_STATUS_DRAFT, +}; + +/** Display label for each publishable lifecycle state, matching the labels + * `statusFromEvent` derives when the event is read back. */ +export const ISSUE_LIFECYCLE_STATUS_LABEL: Record< + ProjectIssueLifecycleStatus, + ProjectIssueStatus +> = { + open: PROJECT_ISSUE_STATUS.BACKLOG, + draft: PROJECT_ISSUE_STATUS.TRIAGE, + resolved: PROJECT_ISSUE_STATUS.DONE, + closed: PROJECT_ISSUE_STATUS.CLOSED, +}; + +/** The publishable states, in the order the picker offers them. */ +export const ISSUE_LIFECYCLE_STATUSES: ProjectIssueLifecycleStatus[] = [ + "draft", + "open", + "resolved", + "closed", +]; + +/** Only the issue author and the repo owner are trusted for status changes — + * the same rule `allowedActorsForRoot` applies when parsing them back. A + * managed-agent owner counts as the owner because the desktop can sign on its + * behalf. */ +export function canChangeProjectIssueStatus({ + isManagedAgentOwner, + issueAuthor, + projectOwner, + viewer, +}: { + isManagedAgentOwner: boolean; + issueAuthor: string; + projectOwner: string; + viewer: string | null; +}): boolean { + if (!viewer) return false; + return ( + viewer === issueAuthor.toLowerCase() || + viewer === projectOwner.toLowerCase() || + isManagedAgentOwner + ); +} + +// Same shape as `buzz issues status` (buzz-sdk build_git_status) and +// `buildGitStatusTags`: root `e` tag, repo `a` tag, and `p` tags for the repo +// owner + issue author. The `a` tag is load-bearing — `fetchProjectIssues` +// reads status events with an `#a` filter, so an event without it is invisible +// to the desktop and the issue silently falls back to Backlog. +async function updateProjectIssueStatus({ + issue, + project, + signAsManagedOwner, + status, +}: { + issue: ProjectIssue; + project: Project; + signAsManagedOwner: boolean; + status: ProjectIssueLifecycleStatus; +}): Promise { + const createdAt = nextProjectIssueStatusCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + ); + if (signAsManagedOwner) { + await signProjectIssueStatus({ + targetOwner: project.owner, + repoAddress: project.repoAddress, + issueId: issue.id, + issueAuthor: issue.author, + status, + createdAt, + }); + return; + } + const recipients = new Set([ + project.owner.toLowerCase(), + issue.author.toLowerCase(), + ]); + const event = await signRelayEvent({ + kind: ISSUE_STATUS_KIND_BY_LIFECYCLE[status], + content: "", + createdAt, + tags: [ + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ...[...recipients].map((recipient) => ["p", recipient]), + ], + }); + + await relayClient.publishEvent( + event, + "Timed out updating issue status.", + "Failed to update issue status.", + ); +} + +export function useUpdateProjectIssueStatusMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectIssueWriteInvalidation(project); + + return useMutation({ + mutationFn: (input: { + issue: ProjectIssue; + signAsManagedOwner: boolean; + status: ProjectIssueLifecycleStatus; + }) => { + if (!project) throw new Error("No project selected."); + return updateProjectIssueStatus({ ...input, project }); + }, + onSuccess: invalidate, + }); +} diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index 8abf67b4fae..b17641abe87 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -32,6 +32,7 @@ export type ProjectIssue = { assigneeOperationHeads: Record; status: ProjectIssueStatus; statusEventId: string | null; + statusCreatedAt: number | null; updatedAt: number; comments: ProjectIssueComment[]; }; @@ -61,6 +62,10 @@ export function projectIssueEventsToIssues( statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], ): ProjectIssue[]; +export function nextProjectIssueStatusCreatedAt( + issue: ProjectIssue, + now: number, +): number; export function nextProjectIssueCommentCreatedAt( issue: ProjectIssue, now: number, diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 49363d6551f..0eedc970499 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -211,6 +211,7 @@ export function eventToProjectIssue( assigneeOperationHeads: assignmentState.heads, status: statusFromEvent(issue, latestStatus), statusEventId: latestStatus?.id ?? null, + statusCreatedAt: latestStatus?.created_at ?? null, updatedAt: [ ...comments, @@ -231,6 +232,13 @@ export function projectIssueEventsToIssues( .sort((left, right) => right.updatedAt - left.updatedAt); } +/** Keep consecutive status changes ordered across whole-second Nostr + * timestamps — `latestStatusForIssue` picks the highest `created_at`, so a + * second change inside the same second would otherwise be a coin flip. */ +export function nextProjectIssueStatusCreatedAt(issue, now) { + return Math.max(now, (issue.statusCreatedAt ?? 0) + 1); +} + /** Keep consecutive comments ordered across whole-second Nostr timestamps. */ export function nextProjectIssueCommentCreatedAt(issue, now, author) { const normalizedAuthor = author.toLowerCase(); diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 4c670213b5d..b73dd3df9de 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -9,6 +9,7 @@ import { ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, nextProjectIssueCommentCreatedAt, + nextProjectIssueStatusCreatedAt, PROJECT_ISSUE_STATUS, } from "./projectIssues.mjs"; @@ -107,6 +108,36 @@ test("honors status events from the issue author and repo owner", () => { ); }); +test("exposes the timestamp of the status event it honored", () => { + const ownerClosed = statusEvent({ + kind: 1632, + pubkey: OWNER, + createdAt: 300, + }); + const issue = eventToProjectIssue(issueEvent(), [ownerClosed]); + + assert.equal(issue.statusCreatedAt, 300); + assert.equal(eventToProjectIssue(issueEvent()).statusCreatedAt, null); +}); + +test("a follow-up status change outranks the one it replaces", () => { + const ownerClosed = statusEvent({ + kind: 1632, + pubkey: OWNER, + createdAt: 300, + }); + const issue = eventToProjectIssue(issueEvent(), [ownerClosed]); + + // Nostr timestamps are whole seconds: reopening within the same second must + // still sort above the close it replaces. + assert.equal(nextProjectIssueStatusCreatedAt(issue, 300), 301); + assert.equal(nextProjectIssueStatusCreatedAt(issue, 900), 900); + assert.equal( + nextProjectIssueStatusCreatedAt(eventToProjectIssue(issueEvent()), 900), + 900, + ); +}); + test("tag helpers drop malformed value-less tags", () => { const event = issueEvent({ tags: [ diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 7b7d196ec47..262df4931a4 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -1,4 +1,11 @@ -import { CircleCheck, CircleDot, CircleX, MessageSquare } from "lucide-react"; +import { + Check, + ChevronDown, + CircleCheck, + CircleDot, + CircleX, + MessageSquare, +} from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -14,6 +21,13 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { + canChangeProjectIssueStatus, + ISSUE_LIFECYCLE_STATUS_LABEL, + ISSUE_LIFECYCLE_STATUSES, + type ProjectIssueLifecycleStatus, + useUpdateProjectIssueStatusMutation, +} from "@/features/projects/issueStatus"; import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; import { issueShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; @@ -21,6 +35,12 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow"; import { ProjectFeedRow, @@ -275,6 +295,79 @@ export function ProjectIssueDetail({ ); } +/** Status control for the issue meta rail: publishes a NIP-34 status event + * (kind 1630/1631/1632/1633) instead of leaving the desktop read-only. + * + * Only the four protocol states are offered. "In Progress" and "In Review" + * are label heuristics with no status event behind them, so they can be shown + * as the current state but never selected here. */ +function IssueStatusPicker({ + issue, + project, + signAsManagedOwner, +}: { + issue: ProjectIssue; + project: Project; + signAsManagedOwner: boolean; +}) { + const { isPending, mutateAsync: updateIssueStatus } = + useUpdateProjectIssueStatusMutation(project); + const visual = issueStatusVisual(issue.status); + + const handleSelect = React.useCallback( + async (status: ProjectIssueLifecycleStatus) => { + if (ISSUE_LIFECYCLE_STATUS_LABEL[status] === issue.status) return; + try { + await updateIssueStatus({ issue, signAsManagedOwner, status }); + toast.success(`Status set to ${ISSUE_LIFECYCLE_STATUS_LABEL[status]}.`); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update issue status.", + ); + } + }, + [issue, signAsManagedOwner, updateIssueStatus], + ); + + return ( + + + + {issue.status} + + + + {ISSUE_LIFECYCLE_STATUSES.map((status) => { + const label = ISSUE_LIFECYCLE_STATUS_LABEL[status]; + const option = issueStatusVisual(label); + return ( + { + void handleSelect(status); + }} + > + + {label} + {label === issue.status ? ( + + ) : null} + + ); + })} + + + ); +} + /** Right-hand meta column for the issue detail view: status, assignees, * author, labels, and dates — keeps the conversation column focused. */ function IssueMetaRail({ @@ -302,6 +395,12 @@ function IssueMetaRail({ // everyone else who is signed in may still self-assign. const canAssignOthers = Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner); + const canChangeStatus = canChangeProjectIssueStatus({ + isManagedAgentOwner, + issueAuthor: issue.author, + projectOwner: project.owner, + viewer, + }); return (
); } /** One agent row with its session list. Owns its hook calls (one per - * component instance — hooks in a .map() would be invalid React). */ + * component instance — hooks in a .map() would be invalid React). + * + * Killed sessions are filtered out before render: the per-agent counter and + * the empty state both reflect the visible set, so killing an agent's last + * session drops it back to "No observer events yet.". */ function AgentTreeSection({ agent, - selectedSessionId, + killedSessionIds, + onKill, + onReset, onSelect, + selectedSessionId, }: { agent: AgentIdentity; - selectedSessionId: string | null; + killedSessionIds: Set; + onKill: (sessionId: string) => void; + onReset: (agentPubkey: string, session: AgentSessionSummary) => void; onSelect: (agentPubkey: string, sessionId: string) => void; + selectedSessionId: string | null; }) { const { sessions } = useAgentSessionTree(agent); + const identity = useIdentityQuery(); + const relayAgents = useRelayAgentsQuery(); + const authorized = isRotateSessionAuthorized( + relayAgents.data, + identity.data?.pubkey ?? null, + agent.pubkey, + ); + const visibleSessions = sessions.filter( + (session) => !killedSessionIds.has(session.sessionId), + ); return (
@@ -213,16 +285,21 @@ function AgentTreeSection({ {agent.name} - {sessions.length} + + {visibleSessions.length} +
- {sessions.length === 0 ? ( + {visibleSessions.length === 0 ? (

No observer events yet.

) : ( - sessions.map((session) => ( + visibleSessions.map((session) => ( onKill(session.sessionId)} + onReset={() => onReset(agent.pubkey, session)} onSelect={() => onSelect(agent.pubkey, session.sessionId)} selected={selectedSessionId === session.sessionId} session={session} @@ -235,10 +312,84 @@ function AgentTreeSection({ export function AgentSessionsScreen() { const agents = useAgentIdentities(); + const identity = useIdentityQuery(); + const identityPubkey = identity.data?.pubkey ?? null; const [selected, setSelected] = React.useState<{ agentPubkey: string; sessionId: string; } | null>(null); + const [killedSessionIds, setKilledSessionIds] = React.useState>( + new Set(), + ); + + // Seed the kill list once the identity resolves. localStorage is the + // source of truth across runs; the state copy only drives renders. + React.useEffect(() => { + if (!identityPubkey) return; + setKilledSessionIds(readKilledAgentSessions(identityPubkey)); + }, [identityPubkey]); + + const killSession = React.useCallback( + (sessionId: string) => { + const confirmed = window.confirm( + "Kill this session?\n\n" + + "It disappears from the session list on this device. " + + "Kill only removes the entry — it does not interrupt a running turn " + + "(use Reset for that).", + ); + if (!confirmed) return; + recordKilledAgentSession(identityPubkey, sessionId); + setKilledSessionIds((prev) => { + const next = new Set(prev); + next.add(sessionId); + return next; + }); + // A killed selected session must not keep its transcript on screen. + setSelected((current) => + current?.sessionId === sessionId ? null : current, + ); + toast.success("Session removed from the list."); + }, + [identityPubkey], + ); + + /** + * Reset (formerly the transcript-header button): publishes the harness + * `!rotate` owner command into the session's channel. buzz-acp consumes it + * owner-side: an in-flight turn is cancelled and the channel session + * invalidated; idle channel sessions are dropped immediately — the next + * mention starts a fresh session. Rotation is per-channel, matching the + * harness contract. + */ + const resetSession = React.useCallback( + async (agentPubkey: string, session: AgentSessionSummary) => { + if (!session.channelId) { + toast.error("No channel known for this session — cannot reset it."); + return; + } + const confirmed = window.confirm( + "Reset this agent's channel session?\n\n" + + "This sends !rotate to the channel: the harness cancels any in-flight turn and starts the next prompt with a fresh session. " + + "Idle sessions are dropped immediately. Only the agent owner can do this.", + ); + if (!confirmed) return; + try { + await sendChannelMessage( + session.channelId, + "!rotate", + undefined, + undefined, + [agentPubkey], + ); + toast.success("Reset sent — the session will rotate."); + } catch (error) { + toast.error( + `Reset failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + [], + ); // Observer ingestion is app-wide already (AppShell mounts // useAgentObserverIngestion), so per-agent tree reads are cheap store @@ -263,6 +414,9 @@ export function AgentSessionsScreen() { setSelected({ agentPubkey, sessionId }) } @@ -375,7 +529,6 @@ function SelectedSessionTranscript({ no token metrics yet )} -
); } - -/** - * Kill/reset control for one session: publishes the harness `!rotate` - * owner command (kind 9, content exactly "!rotate", agent p-tag mention) - * into the session's channel. buzz-acp consumes it owner-side: an - * in-flight turn is cancelled and the channel session invalidated; idle - * channel sessions are dropped immediately — the next mention starts a - * fresh session. This is the documented cleanup path for stuck/dead - * sessions (see LOCAL_MODIFICATIONS BUZZ-ACP notes); rotation is - * per-channel, matching the harness contract. - */ -function RotateSessionButton({ - agentPubkey, - channelId, -}: { - agentPubkey: string; - channelId: string | null; -}) { - const identity = useIdentityQuery(); - const relayAgents = useRelayAgentsQuery(); - const [pending, setPending] = React.useState(false); - - const authorized = isRotateSessionAuthorized( - relayAgents.data, - identity.data?.pubkey ?? null, - agentPubkey, - ); - - const rotate = React.useCallback(async () => { - if (!channelId) { - toast.error("No channel known for this session — cannot reset it."); - return; - } - const confirmed = window.confirm( - "Reset this agent's channel session?\n\n" + - "This sends !rotate to the channel: the harness cancels any in-flight turn and starts the next prompt with a fresh session. " + - "Idle sessions are dropped immediately. Only the agent owner can do this.", - ); - if (!confirmed) return; - setPending(true); - try { - await sendChannelMessage(channelId, "!rotate", undefined, undefined, [ - agentPubkey, - ]); - toast.success("Reset sent — the session will rotate."); - } catch (error) { - toast.error( - `Reset failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - setPending(false); - } - }, [agentPubkey, channelId]); - - return ( - - ); -} From 2705cab71b3120e2d2da221fa8e0a5ccadc8a4e3 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Sat, 22 Aug 2026 14:48:13 +0200 Subject: [PATCH 17/76] fix(desktop): accept JSON-degraded IPC bodies for raw media uploads Drag-drop/paste uploads failed with 'raw upload requires a byte body' for any file type (e.g. PDF). Tauri's webview IPC prefers a custom-protocol fetch that carries raw bytes, but after a single failure of that transport (e.g. AV/VPN interception of ipc.localhost) it permanently falls back to postMessage, which JSON-serializes the byte payload into a number array. The renderer sends the same call; only the transport changed. upload_media_bytes_raw now recovers the bytes from that JSON number-array shape instead of rejecting it, with strict validation (integers 0-255 only). The raw fast path is untouched while it works. Adds unit tests covering recovery, out-of-range numbers, negatives, and non-array bodies. Signed-off-by: Robi Hermes --- desktop/src-tauri/src/commands/media_raw.rs | 44 ++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs index 97081571623..9cbd5eeacd7 100644 --- a/desktop/src-tauri/src/commands/media_raw.rs +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -73,6 +73,30 @@ pub fn release_media_upload(progress_id: String) { finish_media_upload(Some(&progress_id)); } +/// Recover raw bytes from a JSON IPC body. +/// +/// The renderer sends the file as a byte body, but if the webview's IPC +/// transport degrades from the custom protocol (raw-capable) to the +/// postMessage fallback — a sticky downgrade that happens after a single +/// custom-protocol failure, e.g. AV/VPN interception of `ipc.localhost` — +/// the same payload arrives JSON-serialized as a number array. Accept that +/// shape so uploads keep working on the degraded transport; the raw fast +/// path is untouched while it works. +fn bytes_from_json_body(value: &serde_json::Value) -> Result, String> { + let serde_json::Value::Array(items) = value else { + return Err("raw upload requires a byte body".to_string()); + }; + let mut data = Vec::with_capacity(items.len()); + for item in items { + let byte = item + .as_u64() + .filter(|n| *n <= u64::from(u8::MAX)) + .ok_or("raw upload body is not a byte array")?; + data.push(byte as u8); + } + Ok(data) +} + /// Upload raw IPC bytes without expanding a large browser File into JSON. #[tauri::command] pub async fn upload_media_bytes_raw( @@ -82,7 +106,7 @@ pub async fn upload_media_bytes_raw( ) -> Result { let data = match request.body() { InvokeBody::Raw(data) => data.clone(), - InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()), + InvokeBody::Json(value) => bytes_from_json_body(value)?, }; let filename = optional_raw_upload_header(&request, "x-buzz-filename")?; let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; @@ -110,4 +134,22 @@ mod tests { let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4"); assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4"); } + + #[test] + fn test_bytes_from_json_body_recovers_byte_array() { + let value = serde_json::json!([7, 255, 0, 128]); + assert_eq!(bytes_from_json_body(&value).unwrap(), vec![7, 255, 0, 128]); + } + + #[test] + fn test_bytes_from_json_body_rejects_non_byte_numbers() { + assert!(bytes_from_json_body(&serde_json::json!([7, 256])).is_err()); + assert!(bytes_from_json_body(&serde_json::json!([7, -1])).is_err()); + } + + #[test] + fn test_bytes_from_json_body_rejects_non_array_bodies() { + assert!(bytes_from_json_body(&serde_json::json!({"data": [1]})).is_err()); + assert!(bytes_from_json_body(&serde_json::json!(null)).is_err()); + } } From fbd3c65abe9d6d0a378c2deccb36916fe3a739d2 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Tue, 25 Aug 2026 07:19:18 +0200 Subject: [PATCH 18/76] feat(desktop): make issues the scoped work record Signed-off-by: Robi Hermes --- .../ui/ChannelIssuesAuxiliaryPanel.tsx | 102 +++++++++ .../src/features/channels/ui/ChannelPane.tsx | 22 +- .../features/channels/ui/ChannelPane.types.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 27 ++- .../channels/ui/ChannelScreenHeader.tsx | 18 +- desktop/src/features/projects/hooks.ts | 6 + .../src/features/projects/projectIssues.d.mts | 6 + .../src/features/projects/projectIssues.mjs | 16 +- .../features/projects/projectIssues.test.mjs | 56 +++++ .../ui/ProjectIssueCommentTimeline.tsx | 63 ++++-- .../projects/ui/ProjectIssuesPanel.tsx | 153 ++++++++++++- .../projects/ui/ProjectsIssuesList.tsx | 209 ++++++++++++++---- .../tests/e2e/project-commit-detail.spec.ts | 2 +- .../tests/e2e/project-issue-comments.spec.ts | 79 +++++-- 14 files changed, 667 insertions(+), 94 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx diff --git a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx new file mode 100644 index 00000000000..a1880c67136 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx @@ -0,0 +1,102 @@ +import { ArrowLeft, X } from "lucide-react"; +import * as React from "react"; + +import { useProjectsQuery } from "@/features/projects/hooks"; +import { ProjectIssuesPanel } from "@/features/projects/ui/ProjectIssuesPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import type { Channel } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +type ChannelIssuesAuxiliaryPanelProps = { + activeChannel: Channel; + canResetWidth: boolean; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + profiles?: UserProfileLookup; + widthPx: number; +}; + +/** + * Channel-scoped issue surface. The channel binding on a repository is the + * authority for scope: no binding means no issue list, never a global fallback. + */ +export function ChannelIssuesAuxiliaryPanel({ + activeChannel, + canResetWidth, + onClose, + onResetWidth, + onResizeStart, + profiles, + widthPx, +}: ChannelIssuesAuxiliaryPanelProps) { + const projectsQuery = useProjectsQuery(); + const repository = projectsQuery.data + ?.flatMap((project) => project.repositories) + .find((candidate) => candidate.channelId === activeChannel.id); + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + + return ( + +
+
+
+ {selectedIssueId ? ( + + ) : null} +
+

Issues

+

+ {repository?.name ?? "No repository linked to this channel"} +

+
+
+ +
+
+ {projectsQuery.isLoading ? ( +

Loading issues…

+ ) : repository ? ( + + ) : ( +

+ Link a repository to this channel to keep its issue work here. +

+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index ce8ecfa66e0..738635e177e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -31,6 +31,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; +import { ChannelIssuesAuxiliaryPanel } from "@/features/channels/ui/ChannelIssuesAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle"; import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer"; @@ -79,6 +80,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onAutoSendComplete = null, botTypingEntries, channelManagementOpen = false, + issuesPanelOpen = false, currentPubkey, editTarget = null, fetchOlder, @@ -109,6 +111,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onBackFromAgentSession, onCloseAgentSession, onCloseChannelManagement, + onCloseIssuesPanel, onChannelManagementDeleted, onCloseProfilePanel, onAddAgent, @@ -405,8 +408,7 @@ export const ChannelPane = React.memo(function ChannelPane({ if (!isDmChannel || !channelContextUsage) return null; const { usageRatio, model, lastInputTokens, contextWindow } = channelContextUsage; - const pct = - usageRatio == null ? null : Math.round(usageRatio * 100); + const pct = usageRatio == null ? null : Math.round(usageRatio * 100); const label = [ model ?? "unknown model", pct == null ? "no usage data" : `${pct}% context used`, @@ -516,7 +518,8 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && - (channelManagementOpen || + (issuesPanelOpen || + channelManagementOpen || Boolean(threadHeadMessage) || shouldShowThreadSkeleton || Boolean(activeChannel && selectedAgent) || @@ -802,7 +805,18 @@ export const ChannelPane = React.memo(function ChannelPane({ * frozen snapshot because the panel is fully prop-driven. */} - {channelManagementOpen && activeChannel ? ( + {issuesPanelOpen && activeChannel ? ( + onCloseIssuesPanel?.()} + onResetWidth={onResetThreadPanelWidth} + onResizeStart={onThreadPanelResizeStart} + profiles={profiles} + widthPx={threadPanelWidthPx} + /> + ) : channelManagementOpen && activeChannel ? ( void) | null; botTypingEntries: TypingIndicatorEntry[]; channelManagementOpen?: boolean; + issuesPanelOpen?: boolean; currentPubkey?: string; editTarget?: { author: string; @@ -78,6 +79,7 @@ export type ChannelPaneProps = { onBackFromAgentSession?: () => void; onCloseAgentSession: () => void; onCloseChannelManagement?: () => void; + onCloseIssuesPanel?: () => void; onChannelManagementDeleted?: () => void; onCloseProfilePanel: () => void; onAddAgent?: (options?: { beforeSend?: () => void }) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c71..12475dc79de 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -144,6 +144,7 @@ export function ChannelScreen({ widthPx: threadPanelWidthPx, } = useThreadPanelWidth(channelContentWidthPx || undefined); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); + const [issuesPanelOpen, setIssuesPanelOpen] = React.useState(false); const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( () => new Set(), @@ -672,7 +673,8 @@ export function ChannelScreen({ effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey || - channelManagementOpen, + channelManagementOpen || + issuesPanelOpen, ); const displayedThreadHeadMessage = threadPanelData.threadHead; const displayedThreadAllMessages = threadPanelData.messages; @@ -716,6 +718,7 @@ export function ChannelScreen({ setThreadReplyTargetId(null); handleCloseAgentSession(); setProfilePanelPubkey(null); + setIssuesPanelOpen(false); setChannelManagementOpen(true); }, [ activeChannel?.channelType, @@ -726,6 +729,22 @@ export function ChannelScreen({ handleCloseAgentSession, setProfilePanelPubkey, ]); + const handleToggleIssues = React.useCallback(() => { + setIssuesPanelOpen((open) => !open); + setOpenThreadHeadId(null); + setProfilePanelPubkey(null); + setChannelManagementOpen(false); + handleCloseAgentSession(); + }, [ + handleCloseAgentSession, + setChannelManagementOpen, + setOpenThreadHeadId, + setProfilePanelPubkey, + ]); + const handleCloseIssuesPanel = React.useCallback( + () => setIssuesPanelOpen(false), + [], + ); const handleToggleMembers = React.useCallback( () => setIsMembersSidebarOpen((prev) => !prev), [], @@ -743,10 +762,12 @@ export function ChannelScreen({ chromeWrapperRef={channelHeaderChromeRef} currentPubkey={currentPubkey} isAddBotOpen={isAddBotOpen} + issuesPanelOpen={issuesPanelOpen} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} onJoinChannel={joinChannelMutation.mutateAsync} onManageChannel={handleManageChannel} + onToggleIssues={handleToggleIssues} onToggleMembers={handleToggleMembers} showHeaderContent={!isSinglePanelView && !isHuddleTranscript} transparentChrome={activeChannel?.channelType !== "forum"} @@ -763,9 +784,11 @@ export function ChannelScreen({ channelHeaderChromeRef, currentPubkey, isAddBotOpen, + issuesPanelOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, handleManageChannel, + handleToggleIssues, handleToggleMembers, isSinglePanelView, isHuddleTranscript, @@ -841,6 +864,7 @@ export function ChannelScreen({ onAutoSendComplete={clearAutoSend} botTypingEntries={botTypingEntries} channelManagementOpen={channelManagementOpen} + issuesPanelOpen={issuesPanelOpen} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} @@ -900,6 +924,7 @@ export function ChannelScreen({ : undefined } onCloseChannelManagement={handleCloseChannelManagement} + onCloseIssuesPanel={handleCloseIssuesPanel} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 358a0e637bb..56b158309f4 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -1,4 +1,4 @@ -import { LogIn, SquareTerminal } from "lucide-react"; +import { ListTodo, LogIn, SquareTerminal } from "lucide-react"; import type * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; @@ -39,12 +39,14 @@ type ChannelScreenHeaderProps = { chromeWrapperRef?: React.Ref; currentPubkey?: string; isAddBotOpen?: boolean; + issuesPanelOpen?: boolean; isJoining?: boolean; showHeaderContent?: boolean; transparentChrome?: boolean; onAddBotOpenChange?: (open: boolean) => void; onJoinChannel?: () => Promise; onManageChannel: () => void; + onToggleIssues: () => void; onToggleMembers: () => void; }; @@ -59,12 +61,14 @@ export function ChannelScreenHeader({ chromeWrapperRef, currentPubkey, isAddBotOpen, + issuesPanelOpen = false, isJoining = false, onAddBotOpenChange, showHeaderContent = true, transparentChrome = false, onJoinChannel, onManageChannel, + onToggleIssues, onToggleMembers, }: ChannelScreenHeaderProps) { const isGroupDm = @@ -119,6 +123,18 @@ export function ChannelScreenHeader({ const actions = activeChannel ? (
{terminalButton} + {channelActions}
) : null; diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 7f3f55ff027..102a3ba34ad 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -44,6 +44,8 @@ import { } from "./assignmentOperationFetch"; import type { ProjectIssue } from "./projectIssues.mjs"; import { + ISSUE_ACTION_REQUIRED_LABEL, + isHumanDirectedIssueComment, nextProjectIssueCommentCreatedAt, projectIssueEventsToIssues, } from "./projectIssues.mjs"; @@ -416,10 +418,14 @@ async function createProjectIssueComment({ ...issue.recipients.map((recipient) => recipient.toLowerCase()), ...mentionPubkeys.map((pubkey) => pubkey.toLowerCase()), ]); + // Human-directed verification/request comments are routed to recipients and + // visibly separated from agent evidence without mutating the NIP-34 root. + const requiresAction = isHumanDirectedIssueComment(body); const tags = [ ["e", issue.id, "", "root"], ["a", project.repoAddress], ...[...recipients].map((recipient) => ["p", recipient]), + ...(requiresAction ? [["t", ISSUE_ACTION_REQUIRED_LABEL]] : []), ...(mediaTags ?? []), ]; const identity = await getIdentity(); diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index b17641abe87..7ea1319ce66 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -4,6 +4,7 @@ export type ProjectIssueStatus = | "Triage" | "Backlog" | "In Progress" + | "Approved" | "In Review" | "Done" | "Closed"; @@ -14,6 +15,8 @@ export type ProjectIssueComment = { tags: string[][]; author: string; createdAt: number; + recipients: string[]; + actionRequired: boolean; }; export type ProjectIssue = { @@ -39,11 +42,14 @@ export type ProjectIssue = { export const ISSUE_ASSIGNMENT_LABEL: "assignment"; export const ISSUE_UNASSIGNMENT_LABEL: "unassignment"; +export const ISSUE_ACTION_REQUIRED_LABEL: "action-required"; +export function isHumanDirectedIssueComment(body: string): boolean; export const PROJECT_ISSUE_STATUS: { TRIAGE: "Triage"; BACKLOG: "Backlog"; IN_PROGRESS: "In Progress"; + APPROVED: "Approved"; IN_REVIEW: "In Review"; DONE: "Done"; CLOSED: "Closed"; diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index fe6d4418b8b..c37e37e40ec 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -7,11 +7,17 @@ import { sortEvents } from "../../shared/api/relayClientShared.ts"; // assignee's mention feed (inbox) for free. export const ISSUE_ASSIGNMENT_LABEL = "assignment"; export const ISSUE_UNASSIGNMENT_LABEL = "unassignment"; +export const ISSUE_ACTION_REQUIRED_LABEL = "action-required"; + +export function isHumanDirectedIssueComment(body) { + return /^(?:test|expected|reply)\s*:/i.test(body.trimStart()); +} export const PROJECT_ISSUE_STATUS = { TRIAGE: "Triage", BACKLOG: "Backlog", IN_PROGRESS: "In Progress", + APPROVED: "Approved", IN_REVIEW: "In Review", DONE: "Done", CLOSED: "Closed", @@ -77,7 +83,11 @@ function statusFromEvent(issue, statusEvent) { // label-based fallbacks below are client-side heuristics, not protocol. if (statusEvent?.kind === 1633) return PROJECT_ISSUE_STATUS.TRIAGE; - const labels = getAllTags(issue, "t").map((label) => label.toLowerCase()); + const labels = [ + ...getAllTags(issue, "t"), + ...(statusEvent ? getAllTags(statusEvent, "t") : []), + ].map((label) => label.toLowerCase()); + if (labels.includes("approved")) return PROJECT_ISSUE_STATUS.APPROVED; if (labels.includes("in-review") || labels.includes("review")) { return PROJECT_ISSUE_STATUS.IN_REVIEW; } @@ -179,6 +189,10 @@ function commentsForIssue(issueCommentEvents) { tags: getImetaTags(event), author: event.pubkey, createdAt: event.created_at, + recipients: getAllTags(event, "p"), + actionRequired: getAllTags(event, "t").includes( + ISSUE_ACTION_REQUIRED_LABEL, + ), })); } diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index e82f4cbf015..cfb82827424 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -6,6 +6,8 @@ import { eventToProjectIssue, getAllTags, getTag, + isHumanDirectedIssueComment, + ISSUE_ACTION_REQUIRED_LABEL, ISSUE_ASSIGNMENT_LABEL, ISSUE_UNASSIGNMENT_LABEL, nextProjectIssueCommentCreatedAt, @@ -213,6 +215,60 @@ test("preserves root and comment tags for rich content rendering", () => { assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); }); +test("recognizes approved status-event labels and action-required comment metadata", () => { + const root = issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Something is broken"], + ], + }); + const approved = { + id: "approved-status", + kind: 1630, + pubkey: OWNER, + created_at: 150, + content: "", + tags: [ + ["e", root.id, "", "root"], + ["a", REPO_ADDRESS], + ["t", "approved"], + ], + }; + const comment = { + id: "comment-action-required", + kind: 1, + pubkey: OWNER, + created_at: 200, + content: "Test: verify the Windows installer", + tags: [ + ["e", root.id, "", "root"], + ["p", AUTHOR], + ["t", ISSUE_ACTION_REQUIRED_LABEL], + ], + }; + + const issue = eventToProjectIssue(root, [approved], [comment]); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.APPROVED); + assert.equal(issue.comments[0].actionRequired, true); + assert.deepEqual(issue.comments[0].recipients, [AUTHOR]); +}); + +test("human-directed issue comments require a leading action prefix", () => { + assert.equal( + isHumanDirectedIssueComment("Test: verify the Windows installer"), + true, + ); + assert.equal( + isHumanDirectedIssueComment("Evidence:\nExpected: HTTP 200"), + false, + ); + assert.equal( + isHumanDirectedIssueComment("Build passed\nReply: not requested"), + false, + ); +}); + test("parses public and private-safe issue provenance", () => { const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const publicIssue = eventToProjectIssue( diff --git a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx index 75566530754..c43182e341d 100644 --- a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx +++ b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronUp, History } from "lucide-react"; +import { CircleAlert, ChevronDown, ChevronUp, History } from "lucide-react"; import * as React from "react"; import type { ProjectIssue } from "@/features/projects/hooks"; @@ -16,7 +16,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar"; import { ProfileAuthorName } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; -const COLLAPSED_COMMENT_COUNT = 3; +const COLLAPSED_TECHNICAL_EVIDENCE_COUNT = 3; export function ProjectIssueCommentTimeline({ comments, @@ -26,7 +26,8 @@ export function ProjectIssueCommentTimeline({ profiles?: UserProfileLookup; }) { const [isCollapsed, setIsCollapsed] = React.useState(false); - const [isExpanded, setIsExpanded] = React.useState(false); + const [technicalEvidenceExpanded, setTechnicalEvidenceExpanded] = + React.useState(false); const orderedComments = React.useMemo( () => [...comments].sort( @@ -35,15 +36,27 @@ export function ProjectIssueCommentTimeline({ ), [comments], ); - const earlierCommentCount = Math.max( + const actionComments = orderedComments.filter( + (comment) => comment.actionRequired, + ); + const technicalEvidence = orderedComments.filter( + (comment) => !comment.actionRequired, + ); + const earlierTechnicalEvidenceCount = Math.max( 0, - orderedComments.length - COLLAPSED_COMMENT_COUNT, + technicalEvidence.length - COLLAPSED_TECHNICAL_EVIDENCE_COUNT, + ); + const visibleTechnicalEvidence = technicalEvidenceExpanded + ? technicalEvidence + : technicalEvidence.slice(-COLLAPSED_TECHNICAL_EVIDENCE_COUNT); + const visibleCommentIds = new Set( + [...actionComments, ...visibleTechnicalEvidence].map( + (comment) => comment.id, + ), ); - const visibleComments = - isExpanded || earlierCommentCount === 0 - ? orderedComments - : orderedComments.slice(-COLLAPSED_COMMENT_COUNT); - const displayedComments = isCollapsed ? [] : visibleComments; + const displayedComments = isCollapsed + ? [] + : orderedComments.filter((comment) => visibleCommentIds.has(comment.id)); if (orderedComments.length === 0) { return null; @@ -78,21 +91,29 @@ export function ProjectIssueCommentTimeline({ )} - {!isCollapsed && earlierCommentCount > 0 && !isExpanded ? ( + {!isCollapsed && + (earlierTechnicalEvidenceCount > 0 || technicalEvidenceExpanded) ? ( ) : null} @@ -104,7 +125,11 @@ export function ProjectIssueCommentTimeline({ }); return (
@@ -131,6 +156,12 @@ export function ProjectIssueCommentTimeline({ {authorLabel} + {comment.actionRequired ? ( + + + Action required + + ) : null} @@ -245,7 +265,7 @@ export function ProjectIssueDetail({

{issue.title}{" "} - #{issue.id.slice(0, 8)} + ISS-{issue.id.slice(0, 8).toUpperCase()} issue.id === selectedIssueId) ?? null; @@ -523,16 +544,126 @@ export function ProjectIssuesPanel({ ); } + const changeRowsPerGroup = (value: number) => { + setRowsPerGroup(value); + globalThis.localStorage?.setItem( + ISSUE_ROWS_PER_GROUP_STORAGE_KEY, + String(value), + ); + }; + return ( -
- {issues.map((issue) => ( - onSelectedIssueIdChange(issue.id)} - profiles={profiles} - /> - ))} +
+
+ + +
+ {ISSUE_STATUS_SECTIONS.map(({ label, status, terminal }) => { + const sectionIssues = issues.filter((issue) => issue.status === status); + if (sectionIssues.length === 0) return null; + return ( + + ); + })}
); } + +function IssueStatusSection({ + issues, + label, + onOpen, + profiles, + rowsPerGroup, + terminal, +}: { + issues: ProjectIssue[]; + label: string; + onOpen: (id: string | null) => void; + profiles?: UserProfileLookup; + rowsPerGroup: number; + terminal: boolean; +}) { + const [expanded, setExpanded] = React.useState(!terminal); + const [showAll, setShowAll] = React.useState(false); + const visibleIssues = expanded + ? showAll + ? issues + : issues.slice(0, rowsPerGroup) + : []; + const remaining = issues.length - visibleIssues.length; + + return ( +
+
+ {terminal ? ( + + ) : ( +

+ {label} · {issues.length} +

+ )} + {expanded && remaining > 0 ? ( + + ) : expanded && showAll ? ( + + ) : null} +
+ {expanded ? ( +
+ {visibleIssues.map((issue) => ( + onOpen(issue.id)} + profiles={profiles} + /> + ))} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index 544c0b7e32a..057578d1e97 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { Eye, MessageSquare } from "lucide-react"; import type { @@ -24,7 +25,6 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; import { - PROJECT_LIST_CONTAINER_CLASS, PROJECT_LIST_ROW_CLASS, PROJECT_LIST_ROW_CONTENT_CLASS, PROJECT_LIST_ROW_DATE_CLASS, @@ -84,6 +84,9 @@ function IssueHeader({

{issue.title}

+ + ISS-{issue.id.slice(0, 8).toUpperCase()} +
{/* Flex (not inline flow) so the 20px author avatar cannot grow the line box — keeps row heights identical to the PR list. */} @@ -259,6 +262,120 @@ function IssueListRow({ ); } +const ISSUE_STATUS_SECTIONS = [ + { status: "Triage", label: "Triage", terminal: false }, + { status: "In Review", label: "Review", terminal: false }, + { status: "Approved", label: "Approved", terminal: false }, + { status: "In Progress", label: "In Progress", terminal: false }, + { status: "Backlog", label: "Backlog", terminal: false }, + { status: "Done", label: "Done", terminal: true }, + { status: "Closed", label: "Closed", terminal: true }, +] as const; + +function IssueStatusSection({ + embedded, + items, + label, + limit, + onOpen, + profiles, + status, + terminal, + viewMode, +}: { + embedded: boolean; + items: ProjectIssueListItem[]; + label: string; + limit: number; + onOpen: ProjectsIssuesListProps["onOpen"]; + profiles?: UserProfileLookup; + status: string; + terminal: boolean; + viewMode: ProjectsIssuesListProps["viewMode"]; +}) { + const [expanded, setExpanded] = React.useState(!terminal); + const [showAll, setShowAll] = React.useState(false); + const visibleItems = expanded + ? showAll + ? items + : items.slice(0, limit) + : []; + + if (items.length === 0) return null; + + return ( +
+
+

+ {label} {items.length} +

+
+ {terminal ? ( + + ) : null} + {expanded && items.length > limit ? ( + + ) : null} +
+
+
+ {visibleItems.map(({ project, issue, repository }) => + viewMode === "grid" ? ( + + onOpen(selectedProject, repository, selectedIssue) + } + profiles={profiles} + project={project} + repository={repository} + /> + ) : ( + + onOpen(selectedProject, repository, selectedIssue) + } + profiles={profiles} + project={project} + repository={repository} + /> + ), + )} +
+
+ ); +} + export function ProjectsIssuesList({ embedded, error, @@ -271,6 +388,28 @@ export function ProjectsIssuesList({ profiles, viewMode, }: ProjectsIssuesListProps) { + const [limit, setLimit] = React.useState(() => { + const stored = Number( + globalThis.localStorage?.getItem("buzz.projects.issueRows"), + ); + return stored === 10 || stored === 20 ? stored : 5; + }); + const issuesByStatus = React.useMemo( + () => + new Map( + ISSUE_STATUS_SECTIONS.map(({ status }) => [ + status, + issues.filter(({ issue }) => issue.status === status), + ]), + ), + [issues], + ); + + const changeLimit = (value: number) => { + setLimit(value); + globalThis.localStorage?.setItem("buzz.projects.issueRows", String(value)); + }; + if (isLoading) { return (
- {loadNotice} -
- {issues.map(({ project, issue, repository }) => ( - - onOpen(selectedProject, repository, selectedIssue) - } - profiles={profiles} - project={project} - repository={repository} - /> - ))} -
-
- ); - } - return ( -
+
{loadNotice} -
- {issues.map(({ project, issue, repository }) => ( - - onOpen(selectedProject, repository, selectedIssue) - } - profiles={profiles} - project={project} - repository={repository} - /> - ))} +
+ +
+ {ISSUE_STATUS_SECTIONS.map(({ label, status, terminal }) => ( + + ))}
); } diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 4de0e135714..9678bc659a0 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -647,7 +647,7 @@ test("pull request and issue feeds share the commit row structure", async ({ const issueRows = page.getByTestId("project-issue-row"); await expect(issueRows.first()).toBeVisible({ timeout: 10_000 }); await expect( - issueRows.first().getByRole("button", { name: /^#/ }), + issueRows.first().getByRole("button", { name: /^ISS-/ }), ).toBeVisible(); await waitForAnimations(page); await page.screenshot({ diff --git a/desktop/tests/e2e/project-issue-comments.spec.ts b/desktop/tests/e2e/project-issue-comments.spec.ts index 84228671d09..af44345d844 100644 --- a/desktop/tests/e2e/project-issue-comments.spec.ts +++ b/desktop/tests/e2e/project-issue-comments.spec.ts @@ -8,6 +8,7 @@ const ISSUE_COMMENTS = [ "Third issue comment", "Fourth issue comment", ]; +const ACTION_COMMENT = "Test: verify the Windows installer launches."; async function openBuzzProject(page: import("@playwright/test").Page) { await page.goto("/", { waitUntil: "domcontentloaded" }); @@ -22,14 +23,16 @@ async function openBuzzProject(page: import("@playwright/test").Page) { await projectEntry.click(); } -test("issue comments use the project activity timeline", async ({ page }) => { +test("issue comments keep technical evidence collapsed and surface human actions", async ({ + page, +}) => { await installMockBridge(page); await openBuzzProject(page); await page.getByRole("tab", { name: "Issues", exact: true }).click(); const issueRow = page.getByTestId("project-issue-row").first(); await expect(issueRow).toBeVisible({ timeout: 10_000 }); - await issueRow.getByRole("button", { name: /^#/ }).click(); + await issueRow.getByRole("button", { name: /^ISS-/ }).click(); const composer = page.getByTestId("project-issue-comment-composer"); await expect(composer).toBeVisible(); @@ -37,38 +40,76 @@ test("issue comments use the project activity timeline", async ({ page }) => { for (const comment of ISSUE_COMMENTS) { await composer.locator('[contenteditable="true"]').fill(comment); await composer.getByRole("button", { name: "Send message" }).click(); - await expect(page.getByText(comment, { exact: true })).toBeVisible({ - timeout: 10_000, - }); + await expect(composer.locator('[contenteditable="true"]')).toBeEmpty(); } + await composer.locator('[contenteditable="true"]').fill(ACTION_COMMENT); + await composer.getByRole("button", { name: "Send message" }).click(); + await expect(page.getByText(ACTION_COMMENT, { exact: true })).toBeVisible({ + timeout: 10_000, + }); const timelineRows = page.getByTestId("project-issue-comment-timeline-row"); - const earlierComments = page.getByTestId("project-issue-earlier-comments"); + const technicalEvidence = page.getByTestId( + "project-issue-technical-evidence-toggle", + ); const historyToggle = page.getByTestId( "project-issue-comment-history-toggle", ); - await expect(timelineRows).toHaveCount(3); - await expect(earlierComments).toContainText("Show 1 earlier comment"); + await expect(timelineRows).toHaveCount(4); + const actionRow = timelineRows.filter({ hasText: ACTION_COMMENT }); + await expect(actionRow).toHaveCount(1); + await expect(actionRow).toContainText("Action required"); + await expect(technicalEvidence).toContainText( + "Show 1 earlier technical evidence comment", + ); await expect( timelineRows.filter({ hasText: "First issue comment" }), ).toHaveCount(0); - await expect( - timelineRows.filter({ hasText: "Fourth issue comment" }), - ).toHaveCount(1); - await earlierComments.click(); - await expect(timelineRows).toHaveCount(4); + await technicalEvidence.click(); + await expect(timelineRows).toHaveCount(5); for (const comment of ISSUE_COMMENTS) { await expect(timelineRows.filter({ hasText: comment })).toHaveCount(1); } await historyToggle.click(); await expect(timelineRows).toHaveCount(0); - await expect(historyToggle).toContainText("Show 4 earlier comments"); + await expect(historyToggle).toContainText("Show 5 earlier comments"); await historyToggle.click(); - await expect(timelineRows).toHaveCount(4); + await expect(timelineRows).toHaveCount(5); + + const actionEvent = await page.evaluate(() => + window.__BUZZ_E2E_SIGNED_EVENTS__?.find( + (event) => + event.kind === 1 && + event.content === "Test: verify the Windows installer launches.", + ), + ); + expect(actionEvent?.tags).toContainEqual(["t", "action-required"]); + expect(actionEvent?.tags.some((tag) => tag[0] === "p")).toBe(true); +}); + +test("channel issue detail has a back path to the scoped issue list", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-issues-trigger").click(); + + const panel = page.getByTestId("channel-issues-auxiliary-pane"); + await expect(panel).toBeVisible(); + const issueRow = panel.getByTestId("project-issue-row").first(); + await expect(issueRow).toBeVisible({ timeout: 10_000 }); + await issueRow.getByRole("button", { name: /^ISS-/ }).click(); + await expect( + panel.getByRole("button", { name: "Back to issues" }), + ).toBeVisible(); + + await panel.getByRole("button", { name: "Back to issues" }).click(); + await expect(panel.getByTestId("project-issue-row").first()).toBeVisible(); }); test("issue assignees can be assigned and unassigned", async ({ page }) => { @@ -78,7 +119,7 @@ test("issue assignees can be assigned and unassigned", async ({ page }) => { await page.getByRole("tab", { name: "Issues", exact: true }).click(); const issueRow = page.getByTestId("project-issue-row").first(); await expect(issueRow).toBeVisible({ timeout: 10_000 }); - await issueRow.getByRole("button", { name: /^#/ }).click(); + await issueRow.getByRole("button", { name: /^ISS-/ }).click(); await page.getByTestId("project-issue-assign").click(); const candidate = page @@ -105,7 +146,7 @@ test("issue status can be changed from the detail rail", async ({ page }) => { await page.getByRole("tab", { name: "Issues", exact: true }).click(); const issueRow = page.getByTestId("project-issue-row").first(); await expect(issueRow).toBeVisible({ timeout: 10_000 }); - await issueRow.getByRole("button", { name: /^#/ }).click(); + await issueRow.getByRole("button", { name: /^ISS-/ }).click(); const trigger = page.getByTestId("project-issue-status-trigger"); await expect(trigger).toBeVisible({ timeout: 10_000 }); @@ -127,7 +168,11 @@ test("issue status can be changed from the detail rail", async ({ page }) => { // The rail reflects the new status without a reload. await expect(trigger).toContainText("Done", { timeout: 10_000 }); + await page.keyboard.press("Escape"); await trigger.click(); + await expect( + page.getByTestId("project-issue-status-option-closed"), + ).toBeVisible(); await page.getByTestId("project-issue-status-option-closed").click(); await expect(trigger).toContainText("Closed", { timeout: 10_000 }); }); From 26f6b11a728162d7c6aa48404c3caa2e6ece562a Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Tue, 25 Aug 2026 14:18:29 +0200 Subject: [PATCH 19/76] feat(buzz-acp): isolate sessions by conversation root Signed-off-by: Robi Hermes --- crates/buzz-acp/src/acp.rs | 64 +- crates/buzz-acp/src/conversation.rs | 268 +++ crates/buzz-acp/src/lib.rs | 1422 +++++++++++++--- crates/buzz-acp/src/pool.rs | 884 ++++++---- crates/buzz-acp/src/queue.rs | 2375 ++++++++++++++++++--------- 5 files changed, 3740 insertions(+), 1273 deletions(-) create mode 100644 crates/buzz-acp/src/conversation.rs diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f04b8eeec0d..4d69f2081ad 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -214,6 +214,9 @@ pub struct AcpClient { standard_usage: StandardUsageTracker, /// Known adapter identity for prompt-response usage mapping. standard_adapter: Option, + /// Fallback model ID for turn metrics when the adapter omits the model. + /// Adapters that report their model keep their authoritative value. + metric_model_fallback: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -563,6 +566,7 @@ impl AcpClient { goose_usage: UsageTracker::default(), standard_usage: StandardUsageTracker::default(), standard_adapter, + metric_model_fallback: None, }) } @@ -883,11 +887,21 @@ impl AcpClient { /// Consume per-turn usage for NIP-AM publishing. Goose/buzz-agent is an /// exclusive cumulative path; standard ACP prompt usage is used only when - /// goose emitted nothing for this turn. + /// goose emitted nothing for this turn. If the adapter omits its model, + /// the effective session model is used as a fallback. pub fn take_turn_usage(&mut self) -> Option { let goose_usage = self.goose_usage.take(); let standard_usage = self.standard_usage.take(); - goose_usage.or(standard_usage) + let mut usage = goose_usage.or(standard_usage)?; + if usage.model.is_none() { + usage.model = self.metric_model_fallback.clone(); + } + Some(usage) + } + + /// Set the effective model used only when usage notifications omit it. + pub fn set_metric_model_fallback(&mut self, model: Option) { + self.metric_model_fallback = model; } /// Notify the usage tracker that buzz-acp just spawned a new session. @@ -4607,6 +4621,52 @@ mod tests { ); } + // ── Metric model fallback ───────────────────────────────────────────── + + #[tokio::test] + async fn metric_model_fallback_stamps_usage_without_model() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("claude-session"); + client.set_metric_model_fallback(Some("claude-sonnet".to_string())); + client.standard_usage.begin_turn("claude-session"); + client + .parse_prompt_response( + "claude-session", + &prompt_response_usage(10, 2, 12, None, None), + ) + .unwrap(); + let usage = client.take_turn_usage().expect("claude usage"); + assert_eq!(usage.model.as_deref(), Some("claude-sonnet")); + } + + #[tokio::test] + async fn metric_model_fallback_none_leaves_model_unknown() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.notify_session_spawned("unknown-session"); + client.standard_usage.begin_turn("unknown-session"); + client + .parse_prompt_response( + "unknown-session", + &prompt_response_usage(10, 2, 12, None, None), + ) + .unwrap(); + assert!(client.take_turn_usage().expect("usage").model.is_none()); + } + + #[tokio::test] + async fn metric_model_fallback_never_overrides_reported_model() { + let mut client = spawn_inert_client().await; + client.set_metric_model_fallback(Some("wrong-model".to_string())); + client.goose_usage.begin_turn("goose-model-session"); + let mut message = goose_usage_update_msg("goose-model-session", 100, 20, None); + message["params"]["update"]["model"] = serde_json::json!("reported-model"); + client.handle_goose_usage_update(&message); + let usage = client.take_turn_usage().expect("goose usage"); + assert_eq!(usage.model.as_deref(), Some("reported-model")); + } + // ── Goose usage notification integration ────────────────────────────── /// Build a `_goose/unstable/session/update` JSON-RPC notification. diff --git a/crates/buzz-acp/src/conversation.rs b/crates/buzz-acp/src/conversation.rs new file mode 100644 index 00000000000..8e43109b2dc --- /dev/null +++ b/crates/buzz-acp/src/conversation.rs @@ -0,0 +1,268 @@ +//! Conversation scoping for buzz-acp. +//! +//! A **conversation** is the unit that owns an ACP session, a turn counter, a +//! delivery ledger, a queue, and the mid-turn steer/cancel path. It is *not* +//! the channel: +//! +//! - **DM channel** — exactly one conversation, replies included. A DM is +//! already a two-party thread; splitting it per NIP-10 root would fragment a +//! single human conversation across sessions. +//! - **Group channel, top-level event** — the event's own id is the stable +//! conversation root. +//! - **Group channel, reply** — the NIP-10 `root` event id is the stable +//! conversation root, so every reply under a root reuses that root's session. +//! +//! Two threads in the same group channel therefore never share an ACP session, +//! turn counter, delivery ledger, cancelled batch, retry state, or in-flight +//! control path. +//! +//! Membership removal and agent teardown stay channel-wide and iterate every +//! conversation scope belonging to the channel. Owner `!cancel` / `!rotate` +//! commands intentionally target only the conversation where they were posted. +//! Channel metadata, canvas, and core-memory caches remain channel-keyed — +//! those are properties of the channel, not of a thread. + +use std::fmt; + +use nostr::Event; +use uuid::Uuid; + +/// Stable identity of one agent conversation. +/// +/// Hashable and ordered so it can key every per-conversation map in +/// [`EventQueue`](crate::queue::EventQueue) and +/// [`SessionState`](crate::pool::SessionState). +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ConversationScope { + /// The Buzz channel this conversation lives in. Always meaningful — every + /// relay call, observer frame, CLI hint, and channel-scoped cache keys off + /// this. Read it with [`channel_id`](Self::channel_id). + channel_id: Uuid, + /// `None` for DM channels (one conversation per DM channel). + /// `Some(root_event_id)` for group channels, lowercase hex. + thread_root: Option, +} + +impl ConversationScope { + /// The single conversation of a DM channel. + pub fn dm(channel_id: Uuid) -> Self { + Self { + channel_id, + thread_root: None, + } + } + + /// The conversation rooted at `root_event_id` inside a group channel. + pub fn thread(channel_id: Uuid, root_event_id: impl AsRef) -> Self { + Self { + channel_id, + thread_root: Some(root_event_id.as_ref().trim().to_ascii_lowercase()), + } + } + + /// Derive the scope an inbound event belongs to. + /// + /// `is_dm` must come from definitive channel metadata. Unknown channel + /// types are rejected by the routing layer before this constructor is + /// called; authorization may independently treat unknown as DM fail-closed. + pub fn for_event(channel_id: Uuid, is_dm: bool, event: &Event) -> Self { + if is_dm { + return Self::dm(channel_id); + } + match crate::queue::parse_thread_tags(event).root_event_id { + Some(root) => Self::thread(channel_id, root), + None => Self::thread(channel_id, event.id.to_hex()), + } + } + + /// The Buzz channel this conversation lives in. + /// + /// Channel-wide concerns — channel metadata, canvas, core memory, observer + /// and metric frames, relay calls — key off this, never off the scope. + pub fn channel_id(&self) -> Uuid { + self.channel_id + } + + /// The thread root for group conversations; `None` for DM conversations. + pub fn thread_root(&self) -> Option<&str> { + self.thread_root.as_deref() + } +} + +impl fmt::Display for ConversationScope { + /// `` for a DM, `#` for a thread — + /// short enough to read in a log line, unique enough to correlate turns. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.thread_root { + Some(root) => { + let prefix: String = root.chars().take(12).collect(); + write!(f, "{}#{prefix}", self.channel_id) + } + None => write!(f, "{}", self.channel_id), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn top_level(content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + fn root_only(root_id: &str, content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([Tag::parse(["e", root_id, "", "root"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + fn direct_reply(parent_id: &str, content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([Tag::parse(["e", parent_id, "", "reply"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + fn deep_reply(root_id: &str, parent_id: &str, content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([ + Tag::parse(["e", root_id, "", "root"]).unwrap(), + Tag::parse(["e", parent_id, "", "reply"]).unwrap(), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn two_group_top_level_events_in_one_channel_are_separate_scopes() { + let ch = Uuid::new_v4(); + let a = top_level("thread A"); + let b = top_level("thread B"); + + let scope_a = ConversationScope::for_event(ch, false, &a); + let scope_b = ConversationScope::for_event(ch, false, &b); + + assert_ne!(scope_a, scope_b); + assert_eq!(scope_a.channel_id(), ch); + assert_eq!(scope_b.channel_id(), ch); + assert_eq!(scope_a.thread_root(), Some(a.id.to_hex().as_str())); + } + + #[test] + fn a_group_reply_reuses_its_roots_scope() { + let ch = Uuid::new_v4(); + let root = top_level("thread A"); + let root_scope = ConversationScope::for_event(ch, false, &root); + + let reply = direct_reply(&root.id.to_hex(), "reply one"); + let deep_reply = deep_reply(&root.id.to_hex(), &reply.id.to_hex(), "reply two"); + + assert_eq!(ConversationScope::for_event(ch, false, &reply), root_scope); + assert_eq!( + ConversationScope::for_event(ch, false, &deep_reply), + root_scope + ); + } + + #[test] + fn a_root_only_event_is_top_level_like_relay_ingestion() { + let ch = Uuid::new_v4(); + let unrelated = top_level("unrelated root"); + let event = root_only(&unrelated.id.to_hex(), "accepted as top-level"); + + assert_eq!( + ConversationScope::for_event(ch, false, &event), + ConversationScope::thread(ch, event.id.to_hex()) + ); + } + + #[test] + fn malformed_and_invalid_trailing_ids_match_relay_ingestion() { + let ch = Uuid::new_v4(); + let root = top_level("root"); + let parent = direct_reply(&root.id.to_hex(), "parent"); + let root_id = root.id.to_hex(); + let parent_id = parent.id.to_hex(); + let malformed = EventBuilder::new(Kind::Custom(9), "deep") + .tags([ + Tag::parse(["e", root_id.as_str(), "", "root"]).unwrap(), + Tag::parse(["e", "not-hex", "", "root"]).unwrap(), + Tag::parse(["e", parent_id.as_str(), "", "reply"]).unwrap(), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + + assert_eq!( + ConversationScope::for_event(ch, false, &malformed), + ConversationScope::thread(ch, root.id.to_hex()), + "relay ignores the invalid trailing root and retains the last valid root" + ); + + let invalid_reply_id = "f".repeat(63); + let invalid_reply = EventBuilder::new(Kind::Custom(9), "top-level") + .tags([Tag::parse(["e", invalid_reply_id.as_str(), "", "reply"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert_eq!( + ConversationScope::for_event(ch, false, &invalid_reply), + ConversationScope::thread(ch, invalid_reply.id.to_hex()) + ); + } + + #[test] + fn a_direct_reply_without_a_root_marker_uses_the_parent_as_root() { + // NIP-10: a direct reply to the root carries only a `reply` marker. + let ch = Uuid::new_v4(); + let root = top_level("thread A"); + let root_scope = ConversationScope::for_event(ch, false, &root); + + let reply = direct_reply(&root.id.to_hex(), "direct reply"); + assert_eq!(ConversationScope::for_event(ch, false, &reply), root_scope); + } + + #[test] + fn dm_top_level_and_replies_share_the_channel_scope() { + let ch = Uuid::new_v4(); + let first = top_level("hi"); + let reply = direct_reply(&first.id.to_hex(), "hi back"); + + let a = ConversationScope::for_event(ch, true, &first); + let b = ConversationScope::for_event(ch, true, &reply); + + assert_eq!(a, b, "a DM channel is one conversation, replies included"); + assert_eq!(a, ConversationScope::dm(ch)); + assert_eq!(a.thread_root(), None); + } + + #[test] + fn the_same_root_in_two_channels_is_two_scopes() { + let root = top_level("cross-posted"); + let a = ConversationScope::for_event(Uuid::new_v4(), false, &root); + let b = ConversationScope::for_event(Uuid::new_v4(), false, &root); + assert_ne!(a, b); + } + + #[test] + fn display_never_panics_on_a_public_non_ascii_thread_root() { + let scope = ConversationScope::thread(Uuid::new_v4(), "a€€€€thread-root"); + let rendered = scope.to_string(); + assert!(rendered.contains("#a")); + } + + #[test] + fn thread_roots_are_case_normalized() { + let ch = Uuid::new_v4(); + let root = top_level("thread A"); + let upper = root.id.to_hex().to_ascii_uppercase(); + assert_eq!( + ConversationScope::thread(ch, upper), + ConversationScope::for_event(ch, false, &root) + ); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7fd40b83db1..9698d7719a7 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2,6 +2,7 @@ mod acp; mod config; +mod conversation; mod engram_fetch; mod filter; mod observer; @@ -33,6 +34,7 @@ use config::{ AuthAgentArgs, AuthMethodsArgs, AuthenticateArgs, Config, DedupMode, ModelsArgs, MultipleEventHandling, RespondTo, SubscribeMode, }; +use conversation::ConversationScope; use filter::SubscriptionRule; use futures_util::FutureExt; use nostr::{PublicKey, ToBech32}; @@ -286,6 +288,30 @@ pub(crate) async fn is_dm_channel( } } +/// Resolve an inbound event's routable conversation without weakening the DM +/// author gate. Unknown channel metadata is not a DM routing decision: the +/// event is dropped for this delivery and a later event retries metadata +/// resolution because [`pool::ChannelInfoResolver`] never caches failures. +async fn resolve_event_scope( + channel_id: Uuid, + event: &nostr::Event, + channel_info: &pool::ChannelInfoResolver, +) -> Option { + let Some(info) = channel_info.resolve(channel_id).await else { + tracing::warn!( + channel_id = %channel_id, + event_id = %event.id.to_hex(), + "channel type unresolved — dropping event without assigning a conversation scope" + ); + return None; + }; + Some(ConversationScope::for_event( + channel_id, + info.channel_type == "dm", + event, + )) +} + /// Query an author's kind:0 profile and check if their NIP-OA auth tag /// proves the same owner as us. async fn check_sibling_via_profile( @@ -1288,8 +1314,16 @@ fn handle_cancel_turn_control( return; }; - let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel); - let status = if fired { "sent" } else { "no_active_turn" }; + let turn_id = payload.get("turnId").and_then(|value| value.as_str()); + let conversation_root = payload + .get("conversationRoot") + .and_then(|value| value.as_str()); + let result = signal_cancel_turn(pool, channel_id, turn_id, conversation_root); + let status = match result { + CancelTurnResult::Sent => "sent", + CancelTurnResult::NoActiveTurn => "no_active_turn", + CancelTurnResult::Ambiguous => "ambiguous_scope", + }; if let Some(observer) = observer { observer.emit( "control_result", @@ -1310,15 +1344,12 @@ fn handle_cancel_turn_control( /// Handle a `switch_model` control frame (Phase 3a, Option ii). /// -/// Busy path: deliver `SwitchModel` over the in-flight task's oneshot — the -/// task cancels the turn, sets `desired_model`, and requeues the batch so it -/// re-runs on a fresh session under the new model. A catalog miss surfaces -/// post-cancel via `create_session_and_apply_model` (the turn restarts on the -/// unchanged model + an `unsupported_model` result). +/// Busy path: fail closed while any channel turn is in flight because a +/// channel-level switch cannot atomically update checked-out and idle sibling +/// slots. The desktop may retry when the channel is idle. /// -/// Idle path: validate against the cached catalog *before* invalidating -/// (pre-cancel guard), then set `desired_model` + invalidate. The override -/// takes visible effect on the agent's next turn. +/// Idle path: validate every relevant slot's cached catalog before mutating +/// any slot, then update all desired models and invalidate all channel sessions. fn handle_switch_model_control( payload: &serde_json::Value, pool: &mut AgentPool, @@ -1340,29 +1371,23 @@ fn handle_switch_model_control( // A turn is in flight for this channel iff a task_map entry exists. The // agent is moved out of the pool during a turn, so the control oneshot is // the only reachable lever; an idle channel has no such entry. - let turn_in_flight = pool - .task_map() - .values() - .any(|m| m.channel_id == Some(channel_id)); + let turn_in_flight = pool.task_map().values().any(|m| { + m.scope + .as_ref() + .is_some_and(|scope| scope.channel_id() == channel_id) + }); let status = if turn_in_flight { - // Busy path: deliver over the oneshot. `false` means the oneshot was - // already consumed this turn (a prior cancel/interrupt) — the turn is - // already ending, so the switch cannot land on it. - if signal_in_flight_task( - pool, - channel_id, - ControlSignal::SwitchModel(model_id.to_string()), - ) { - "sent" - } else { - "turn_ending" - } + // A channel-level switch cannot atomically update checked-out and idle + // sibling slots. Fail closed rather than splitting the channel across + // models; retry once every relevant slot is idle. + "in_flight_ambiguous" } else { // Idle path: validate against the cached catalog before invalidating. match pool.switch_idle_agent_model(channel_id, model_id) { IdleSwitchResult::Switched => "switched", IdleSwitchResult::UnsupportedModel => "unsupported_model", + IdleSwitchResult::CatalogUnavailable => "catalog_unavailable", IdleSwitchResult::NoIdleAgent => "no_active_turn", } }; @@ -1536,7 +1561,10 @@ struct RespawnResult { /// `EventQueue::withheld_native_steer`: `channel_id` is the routing key, /// `event_id` is the hex id of the single event the steer carried. struct SteerAckEvent { - channel_id: Uuid, + /// Conversation the steered event belongs to. Carrying the scope (not a + /// bare channel id) keeps the withhold/release/fallback bookkeeping on the + /// same conversation the steer targeted. + scope: ConversationScope, event_id: String, /// `Ok` if the read loop sent any of the locked `SteerAck` variants. /// `Err` if the oneshot was dropped without a send — should not happen @@ -2233,7 +2261,10 @@ async fn tokio_main() -> Result<()> { } else { None }; - let mut typing_channels: HashMap = HashMap::new(); + // Keyed by conversation, not channel: two threads can be mid-turn in the + // same channel at once, and each needs its own typing anchor (and its own + // stop when *its* turn ends). + let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; // Independent of pool readiness: a never-mentioned lazy agent must still @@ -2445,10 +2476,10 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -2458,13 +2489,18 @@ async fn tokio_main() -> Result<()> { crash_history[rr.index].respawn_in_flight = false; match rr.result { Ok((acp, protocol_version, agent_name)) => { + let desired_model = pool + .effective_model() + .map(str::to_string) + .or_else(|| config.model.clone()); + let model_overridden = pool.model_overridden(); let agent = OwnedAgent { index: rr.index, acp, state: SessionState::default(), model_capabilities: None, - desired_model: config.model.clone(), - model_overridden: false, + desired_model, + model_overridden, agent_name, goose_system_prompt_supported: None, protocol_version, @@ -2494,10 +2530,10 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } @@ -2675,6 +2711,12 @@ async fn tokio_main() -> Result<()> { if let Err(e) = relay.unsubscribe_channel(ch).await { tracing::warn!("failed to unsubscribe from channel {ch}: {e}"); } + // Advance before any re-add can occur. Every in-flight + // task captured under an older generation remains + // invalidated on return even if membership is restored. + if pool_ready { + pool.advance_membership_generation(ch); + } // Drain queued events and invalidate sessions for the // removed channel. Events already in-flight will // complete normally (the relay may reject actions if @@ -2688,7 +2730,7 @@ async fn tokio_main() -> Result<()> { // Track removed channels so checked-out agents get // their sessions stripped when they return to the pool. removed_channels.insert(ch); - typing_channels.remove(&ch); + typing_channels.retain(|scope, _| scope.channel_id() != ch); // Best-effort: clean up 👀 on drained events. // Note: the relay revokes membership before // emitting the notification, so this DELETE may @@ -2721,7 +2763,10 @@ async fn tokio_main() -> Result<()> { continue; } - // Check: kind:9, content "!shutdown", from owner, mentions THIS agent. + // Scope-independent owner emergency control. This must + // stay before channel metadata resolution: an owner must + // still be able to stop the harness when a dynamic + // channel cannot be resolved. let is_shutdown = is_owner_control_command( &buzz_event.event, kind_u32, @@ -2729,8 +2774,7 @@ async fn tokio_main() -> Result<()> { &pubkey_hex, ); if is_shutdown { - let owner = owner_cache.get(); - if let Some(owner) = owner { + if let Some(owner) = owner_cache.get() { if buzz_event.event.pubkey.to_hex() == *owner { tracing::info!( channel_id = %buzz_event.channel_id, @@ -2742,10 +2786,29 @@ async fn tokio_main() -> Result<()> { } } // Not from owner — fall through to normal prompt handling. - // Don't drop it — it's a regular message that happens to - // contain "!shutdown" from a non-owner. + // Don't drop it: it is an ordinary message that happens + // to contain "!shutdown" from a non-owner. } + // Resolve routing before any conversation-scoped + // control path. Unknown metadata is dropped for this + // delivery and retried on a later event; it is never + // collapsed into the DM scope used by the author gate. + let Some(event_scope) = resolve_event_scope( + buzz_event.channel_id, + &buzz_event.event, + &ctx.channel_info, + ) + .await + else { + continue; + }; + // Every routable group event has a concrete thread + // root (its own id for top-level events); only DMs + // have no root. This boolean is solely for the + // authorization gate below. + let is_dm = event_scope.thread_root().is_none(); + // Mirrors !shutdown: kind:9, content "!cancel", from // owner, mentions THIS agent. Must be BEFORE // queue.push() — the event content is moved by push. @@ -2762,14 +2825,19 @@ async fn tokio_main() -> Result<()> { if is_cancel { if let Some(owner) = owner_cache.get() { if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( + // Scoped to the conversation the command + // was posted in: "stop what you are doing + // *here*". A turn running in a sibling + // thread is a different piece of work and + // is left alone. + let fired = signal_in_flight_conversation( &mut pool, - buzz_event.channel_id, + &event_scope, ControlSignal::Cancel, ); if !fired { tracing::warn!( - channel_id = %buzz_event.channel_id, + conversation = %event_scope, "!cancel received but no in-flight task — no-op" ); } @@ -2783,12 +2851,12 @@ async fn tokio_main() -> Result<()> { // "!rotate", from owner, mentions THIS agent. // // Rotation is explicit owner intent to start the - // next turn in this channel with a fresh ACP + // next turn in this conversation with a fresh ACP // session. It is consumed by the harness and never // forwarded to the agent. If a turn is in-flight, // cancel it, drop its triggering batch, and - // invalidate the channel session when the task - // returns. If idle, invalidate the cached channel + // invalidate the conversation session when the task + // returns. If idle, invalidate the cached conversation // session immediately. Queued future events remain // queued and will create a fresh session on dispatch. let is_rotate = is_owner_control_command( @@ -2800,22 +2868,26 @@ async fn tokio_main() -> Result<()> { if is_rotate { if let Some(owner) = owner_cache.get() { if buzz_event.event.pubkey.to_hex() == *owner { - let fired = signal_in_flight_task( + // Conversation-scoped, same reasoning as + // !cancel: rotate the session backing the + // thread the owner is speaking in. + let fired = signal_in_flight_conversation( &mut pool, - buzz_event.channel_id, + &event_scope, ControlSignal::Rotate, ); if fired { tracing::info!( - channel_id = %buzz_event.channel_id, + conversation = %event_scope, "!rotate received — cancelling in-flight turn and rotating session" ); } else { - let invalidated = pool.invalidate_channel_sessions(buzz_event.channel_id); + let invalidated = + pool.invalidate_conversation_sessions(&event_scope); tracing::info!( - channel_id = %buzz_event.channel_id, + conversation = %event_scope, invalidated, - "!rotate received — invalidated idle channel session(s)" + "!rotate received — invalidated idle conversation session(s)" ); } continue; // consume event — do NOT push to queue @@ -2837,11 +2909,9 @@ async fn tokio_main() -> Result<()> { // it never revokes same-owner team bots. { let author = buzz_event.event.pubkey.to_hex(); - // DM hardening: resolve channel type (fail-closed - // to DM) so allowlist/anyone modes cannot be + // DM hardening: `is_dm` was resolved above (fail- + // closed to DM) so allowlist/anyone modes cannot be // exercised by non-owner authors inside DMs. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, @@ -2888,7 +2958,7 @@ async fn tokio_main() -> Result<()> { let event_for_steer = buzz_event.event.clone(); let prompt_tag_for_steer = prompt_tag.clone(); let accepted = queue.push(QueuedEvent { - channel_id: buzz_event.channel_id, + scope: event_scope.clone(), event: buzz_event.event, received_at: std::time::Instant::now(), prompt_tag, @@ -2908,7 +2978,7 @@ async fn tokio_main() -> Result<()> { // Event is already queued. If mode requires it AND // the channel has an in-flight task, fire cancel — // OR take the non-cancelling (ACP steer) fork for Steer signals. - if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { + if accepted && queue.is_conversation_in_flight(&event_scope) { // Author eligibility (owner ∪ allowlist ∪ siblings) // is already enforced by the inbound author gate // above, so the mid-turn signal fires for every @@ -2935,25 +3005,25 @@ async fn tokio_main() -> Result<()> { && try_native_steer( &mut pool, &mut queue, - buzz_event.channel_id, + &event_scope, event_for_steer, prompt_tag_for_steer, &steer_ack_tx, ); if !native_attempted { - signal_in_flight_task( + signal_in_flight_conversation( &mut pool, - buzz_event.channel_id, + &event_scope, signal, ); } } } if pool_ready { - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } } @@ -3050,10 +3120,10 @@ async fn tokio_main() -> Result<()> { tracing::debug!("heartbeat_skipped_pool_not_ready"); } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } else if pool.any_idle() { dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight); @@ -3092,14 +3162,14 @@ async fn tokio_main() -> Result<()> { // Use try_publish (non-blocking) for typing indicators — // they're ephemeral and must not block the main loop during // relay reconnection (#35). - for (&ch, thread_tags) in &typing_channels { + for (scope, thread_tags) in &typing_channels { if let Ok(event) = relay.build_typing_event( - ch, + scope.channel_id(), thread_tags.root_event_id.as_deref(), thread_tags.parent_event_id.as_deref(), ) { if let Err(e) = relay.try_publish_event(event) { - tracing::debug!("typing indicator dropped for {ch}: {e}"); + tracing::debug!("typing indicator dropped for {scope}: {e}"); } } } @@ -3115,8 +3185,8 @@ async fn tokio_main() -> Result<()> { match pool_event { Some(PoolEvent::Result(result)) => { // Stop typing indicator for the completed channel. - if let PromptSource::Channel(ch) = &result.source { - typing_channels.remove(ch); + if let PromptSource::Channel(scope) = &result.source { + typing_channels.remove(scope); } if handle_prompt_result( &mut pool, @@ -3149,10 +3219,10 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Panic(join_error)) => { @@ -3174,14 +3244,14 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::SteerAck(SteerAckEvent { - channel_id, + scope, event_id, ack, })) => { @@ -3286,7 +3356,7 @@ async fn tokio_main() -> Result<()> { Err(_recv_err) => (true, false, false), }; tracing::info!( - channel = %channel_id, + conversation = %scope, event_id = %event_id, ?ack, release_withheld, @@ -3295,32 +3365,28 @@ async fn tokio_main() -> Result<()> { "non-cancelling steer ack received" ); if let Ok(pool::SteerAck::Success { session_id }) = &ack { - queue.extend_in_flight_deadline(channel_id, config.max_turn_duration_secs); - if !pool.record_successful_steer( - channel_id, - event_id.clone(), - session_id.clone(), - ) { + queue.extend_in_flight_deadline(&scope, config.max_turn_duration_secs); + if !pool.record_successful_steer(&scope, event_id.clone(), session_id.clone()) { tracing::warn!( - channel = %channel_id, + conversation = %scope, event_id = %event_id, "successful steer lost its in-flight delivery ledger" ); } } if drop_withheld { - queue.remove_event(channel_id, &event_id); + queue.remove_event(&scope, &event_id); } if release_withheld { - queue.release_native_steer(channel_id, &event_id); + queue.release_native_steer(&scope, &event_id); } if signal_fallback { // Universal cancel+merge fallback. Note: the // queued event has already been released to the - // front of `queues[channel_id]`, so the cancel + // front of `queues[scope]`, so the cancel // will pick it up as part of the merged batch and // re-prompt the agent. - signal_in_flight_task(&mut pool, channel_id, ControlSignal::Steer); + signal_in_flight_conversation(&mut pool, &scope, ControlSignal::Steer); } // After releasing a withheld event, give dispatch a chance // to re-flush. If the prompt is still in flight, the @@ -3329,10 +3395,10 @@ async fn tokio_main() -> Result<()> { // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Some(PoolEvent::Wake(attempt, result)) => { @@ -3357,10 +3423,10 @@ async fn tokio_main() -> Result<()> { "ready", None, ); - for (channel_id, thread_tags) in + for (scope, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { - typing_channels.insert(channel_id, thread_tags); + typing_channels.insert(scope, thread_tags); } } Err(error) => { @@ -3556,21 +3622,26 @@ fn mode_gate_signal( } } -/// Send a control signal to the in-flight task for `channel_id`. +/// Send a control signal to the in-flight task for one conversation. +/// +/// Deliberately exact: a turn running for a *sibling* thread in the same +/// channel is never signalled here. Mid-turn steer / interrupt / cancel must +/// not cross conversation boundaries. +/// /// Returns `true` if a signal was sent, `false` if no in-flight task was found. -fn signal_in_flight_task( +fn signal_in_flight_conversation( pool: &mut AgentPool, - channel_id: uuid::Uuid, + scope: &ConversationScope, mode: ControlSignal, ) -> bool { let entry = pool .task_map_mut() .values_mut() - .find(|m| m.channel_id == Some(channel_id)); + .find(|m| m.scope.as_ref() == Some(scope)); if let Some(meta) = entry { if let Some(tx) = meta.control_tx.take() { - tracing::info!(channel = %channel_id, ?mode, "control signal sent to in-flight task"); + tracing::info!(conversation = %scope, ?mode, "control signal sent to in-flight task"); let _ = tx.send(mode); return true; } @@ -3578,10 +3649,72 @@ fn signal_in_flight_task( false } +#[derive(Debug, PartialEq, Eq)] +enum CancelTurnResult { + Sent, + NoActiveTurn, + Ambiguous, +} + +/// Cancel one Desktop-selected turn without ever broadcasting across sibling +/// conversations. Modern frames may identify a turn or conversation root. +/// Legacy channel-only frames are accepted only when exactly one turn is active +/// in the channel; otherwise they fail closed as ambiguous. +fn signal_cancel_turn( + pool: &mut AgentPool, + channel_id: Uuid, + turn_id: Option<&str>, + conversation_root: Option<&str>, +) -> CancelTurnResult { + let matches: Vec = pool + .task_map() + .iter() + .filter(|(_, meta)| { + let Some(scope) = meta.scope.as_ref() else { + return false; + }; + scope.channel_id() == channel_id + && turn_id.is_none_or(|requested| meta.turn_id == requested) + && conversation_root.is_none_or(|requested| { + requested.len() == 64 + && requested.chars().all(|c| c.is_ascii_hexdigit()) + && scope + .thread_root() + .is_some_and(|root| root.eq_ignore_ascii_case(requested)) + }) + }) + .map(|(id, _)| *id) + .collect(); + + if matches.is_empty() { + return CancelTurnResult::NoActiveTurn; + } + if matches.len() > 1 { + tracing::warn!( + channel = %channel_id, + active_scopes = matches.len(), + "channel-only cancel is ambiguous — no sibling turn was signalled" + ); + return CancelTurnResult::Ambiguous; + } + + let Some(meta) = pool.task_map_mut().get_mut(&matches[0]) else { + return CancelTurnResult::NoActiveTurn; + }; + let Some(tx) = meta.control_tx.take() else { + return CancelTurnResult::NoActiveTurn; + }; + if tx.send(ControlSignal::Cancel).is_ok() { + CancelTurnResult::Sent + } else { + CancelTurnResult::NoActiveTurn + } +} + /// Attempt the non-cancelling (ACP) steer for a freshly-queued event. /// /// Caller invariants: -/// - `event` has already been pushed into `EventQueue::queues[channel_id]` +/// - `event` has already been pushed into `EventQueue::queues[scope]` /// via [`EventQueue::push`] — its `event.id` must still be locatable /// there so [`EventQueue::mark_native_steer_pending`] can move it to the /// side table. @@ -3594,10 +3727,10 @@ fn signal_in_flight_task( /// universal cancel+merge `ControlSignal::Steer` fallback — the watcher /// will issue it from the ack arm if the native attempt fails. /// -/// Returns `false` if `pool.send_steer` failed (no in-flight task, -/// `steer_tx` already full from a prior in-flight steer, or read loop -/// torn down). The caller MUST fall through to -/// `signal_in_flight_task(channel_id, ControlSignal::Steer)` so the +/// Returns `false` if `pool.send_steer` failed (no in-flight task for this +/// conversation, `steer_tx` already full from a prior in-flight steer, or read +/// loop torn down). The caller MUST fall through to +/// `signal_in_flight_conversation(scope, ControlSignal::Steer)` so the /// event still reaches the agent via the universal path. /// /// The withheld event is NOT released here on `false` because no withhold @@ -3605,7 +3738,7 @@ fn signal_in_flight_task( fn try_native_steer( pool: &mut AgentPool, queue: &mut EventQueue, - channel_id: uuid::Uuid, + scope: &ConversationScope, event: nostr::Event, prompt_tag: String, steer_ack_tx: &mpsc::UnboundedSender, @@ -3630,7 +3763,7 @@ fn try_native_steer( prompt_tag: prompt_tag.clone(), received_at: std::time::Instant::now(), }; - let event_block = queue::format_event_block(channel_id, None, &be, None); + let event_block = queue::format_event_block(scope.channel_id(), None, &be, None); let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); @@ -3639,14 +3772,14 @@ fn try_native_steer( ack_tx, }; - match pool.send_steer(channel_id, request) { + match pool.send_steer(scope, request) { Ok(()) => { // Withhold the queued event synchronously BEFORE spawning // the watcher: this closes the race where `mark_complete` - // clears `in_flight_channels` and a stray `flush_next` could + // clears `in_flight_conversations` and a stray `flush_next` could // re-deliver the event via normal dispatch. See - // `EventQueue::mark_native_steer_pending` docs at queue.rs:606. - let withheld = queue.mark_native_steer_pending(channel_id, &event_id_hex); + // `EventQueue::mark_native_steer_pending` docs. + let withheld = queue.mark_native_steer_pending(scope, &event_id_hex); if !withheld { // Race: the event was already drained out of the queue // before we got here (e.g. a concurrent flush picked it @@ -3656,7 +3789,7 @@ fn try_native_steer( // the same message twice). Log so this is visible if it // ever happens in production. tracing::warn!( - channel = %channel_id, + conversation = %scope, event_id = %event_id_hex, "native steer accepted by read loop but event was not in queue to withhold \ — possible duplicate delivery if steer succeeds" @@ -3664,10 +3797,11 @@ fn try_native_steer( } let ack_tx_clone = steer_ack_tx.clone(); let event_id_for_watcher = event_id_hex.clone(); + let scope_for_watcher = scope.clone(); tokio::spawn(async move { let ack = ack_rx.await; let _ = ack_tx_clone.send(SteerAckEvent { - channel_id, + scope: scope_for_watcher, event_id: event_id_for_watcher, ack, }); @@ -3676,7 +3810,7 @@ fn try_native_steer( } Err(e) => { tracing::info!( - channel = %channel_id, + conversation = %scope, error = ?e, "non-cancelling steer not accepted — falling back to cancel+merge" ); @@ -3688,36 +3822,40 @@ fn try_native_steer( // ── dispatch_pending ────────────────────────────────────────────────────────── /// Flush queued work to available agents. +/// +/// Returns one entry per dispatched conversation, so typing indicators are +/// tracked per conversation rather than per channel — two threads running at +/// once must not clobber each other's typing anchor. fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, last_activity: &mut tokio::time::Instant, -) -> Vec<(Uuid, ThreadTags)> { - let mut dispatched_channels = Vec::new(); +) -> Vec<(ConversationScope, ThreadTags)> { + let mut dispatched = Vec::new(); loop { let batch = match queue.flush_next() { Some(b) => b, None => break, }; - let channel_id = batch.channel_id; - let typing_scope = batch + let scope = batch.scope.clone(); + let typing_tags = batch .events .last() .map(|event| queue::parse_thread_tags(&event.event)) .unwrap_or_default(); - let affinity_hit = pool.has_session_for(channel_id); - let mut agent = match pool.try_claim(Some(channel_id)) { + let affinity_hit = pool.has_session_for(&scope); + let mut agent = match pool.try_claim(Some(&scope)) { Some(a) => a, None => { - let pending = queue.pending_channels(); - tracing::debug!(pending_channels = pending, "pool_exhausted"); + let pending = queue.pending_conversations(); + tracing::debug!(pending_conversations = pending, "pool_exhausted"); queue.requeue_preserve_timestamps(batch); - queue.mark_complete(channel_id); + queue.mark_complete(&scope); break; } }; - tracing::debug!(agent = agent.index, channel = %channel_id, affinity_hit, "agent_claimed"); + tracing::debug!(agent = agent.index, conversation = %scope, affinity_hit, "agent_claimed"); let recoverable_batch = match ctx.dedup_mode { DedupMode::Queue => Some(batch.clone()), @@ -3760,27 +3898,30 @@ fn dispatch_pending( .await; }); + let membership_generation = pool.membership_generation(scope.channel_id()); pool.task_map_mut().insert( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: Some(channel_id), + scope: Some(scope.clone()), turn_id, recoverable_batch, control_tx: Some(control_tx), steer_tx, successful_steer_deliveries: HashSet::new(), + membership_generation, + membership_invalidated: false, }, ); - dispatched_channels.push((channel_id, typing_scope)); + dispatched.push((scope, typing_tags)); *last_activity = tokio::time::Instant::now(); } tracing::debug!( - dispatched = dispatched_channels.len(), - queue_depth = queue.pending_channels(), + dispatched = dispatched.len(), + queue_depth = queue.pending_conversations(), "dispatch_pending" ); - dispatched_channels + dispatched } /// Returns `true` when `error` is a non-retryable authentication failure. @@ -3828,7 +3969,7 @@ fn spawn_failure_notice( .map(|be| queue::parse_thread_tags(&be.event)) .unwrap_or_default(); let rest = rest.clone(); - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); tokio::spawn(async move { pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await; }); @@ -3851,20 +3992,25 @@ fn handle_prompt_result( ) -> LoopAction { let before = pool.task_map().len(); let agent_index = result.agent.index; - let successful_steer_deliveries = pool + let (successful_steer_deliveries, membership_invalidated) = pool .task_map() .values() .find(|meta| meta.agent_index == agent_index) - .map(|meta| meta.successful_steer_deliveries.clone()) + .map(|meta| { + ( + meta.successful_steer_deliveries.clone(), + meta.membership_invalidated, + ) + }) .unwrap_or_default(); pool.task_map_mut() .retain(|_, meta| meta.agent_index != agent_index); debug_assert_eq!(before, pool.task_map().len() + 1); - if let PromptSource::Channel(channel_id) = &result.source { + if let PromptSource::Channel(scope) = &result.source { // The task may have invalidated this session before returning. Never // resurrect delivery state for a dead session; its replacement must // receive fresh standing context and history. - if let Some(live_session_id) = result.agent.state.sessions.get(channel_id).cloned() { + if let Some(live_session_id) = result.agent.state.sessions.get(scope).cloned() { let event_ids = successful_steer_deliveries .into_iter() .filter(|delivery| delivery.session_id == live_session_id) @@ -3872,7 +4018,7 @@ fn handle_prompt_result( result .agent .state - .mark_channel_delivery_success(*channel_id, false, event_ids); + .mark_channel_delivery_success(scope, false, event_ids); } } @@ -3893,8 +4039,13 @@ fn handle_prompt_result( if let Some(batch) = result.batch.take() { // Don't requeue batches for channels the agent was removed from — // those events are stale and should be silently dropped. - if !removed_channels.contains(&batch.channel_id) { - if matches!( + if !membership_invalidated && !removed_channels.contains(&batch.channel_id()) { + if matches!(result.outcome, PromptOutcome::ProcessRecycle) { + // The session-cap branch stopped the worker before ACP saw this + // batch. Restore it immediately, preserving timestamps and + // without charging retry/dead-letter budget. + queue.requeue_preserve_timestamps(batch); + } else if matches!( result.outcome, PromptOutcome::Cancelled | PromptOutcome::CancelDrainTimeout(_) ) { @@ -3921,7 +4072,7 @@ fn handle_prompt_result( }) ) { tracing::error!( - channel_id = %batch.channel_id, + channel_id = %batch.channel_id(), events = batch.events.len(), "dead-lettering batch after hard-cap timeout (no recent activity) — discarding {} events", batch.events.len(), @@ -3939,7 +4090,7 @@ fn handle_prompt_result( }) ) { tracing::warn!( - channel_id = %batch.channel_id, + channel_id = %batch.channel_id(), events = batch.events.len(), "hard-cap timeout with recent activity — requeueing for retry" ); @@ -3959,7 +4110,7 @@ fn handle_prompt_result( // delays the visible failure. Dead-letter immediately and tell // the user to re-authenticate the CLI. tracing::warn!( - channel_id = %batch.channel_id, + channel_id = %batch.channel_id(), events = batch.events.len(), "dead-lettering batch immediately — non-retryable auth error" ); @@ -3985,24 +4136,31 @@ fn handle_prompt_result( } } else { tracing::debug!( - channel_id = %batch.channel_id, + channel_id = %batch.channel_id(), events = batch.events.len(), - "dropping failed batch for removed channel" + membership_invalidated, + "dropping stale batch after membership invalidation" ); hard_timeout_fate_suffix = Some(" — batch dropped (channel removed)"); } } match &result.source { - PromptSource::Channel(ch) => queue.mark_complete(*ch), + PromptSource::Channel(scope) => queue.mark_complete(scope), PromptSource::Heartbeat => *heartbeat_in_flight = false, } - // Strip sessions for channels the agent was removed from while this - // agent was checked out. This covers the gap where invalidate_channel_sessions - // only touches idle agents. + // Strip sessions for channels the agent was removed from while this agent + // was checked out. This covers the gap where invalidate_channel_sessions + // only touches idle agents. Channel-wide on purpose: removal invalidates + // every conversation in the channel. for ch in removed_channels { - result.agent.state.invalidate_channel(ch); + result.agent.state.invalidate_channel(*ch); + } + if membership_invalidated { + if let PromptSource::Channel(scope) = &result.source { + result.agent.state.invalidate_channel(scope.channel_id()); + } } let outcome_label = match &result.outcome { @@ -4011,6 +4169,7 @@ fn handle_prompt_result( PromptOutcome::Timeout(TimeoutKind::Idle) => "idle_timeout", PromptOutcome::Timeout(TimeoutKind::Hard { .. }) => "hard_timeout", PromptOutcome::AgentExited => "exited", + PromptOutcome::ProcessRecycle => "process_recycle", PromptOutcome::Cancelled => "cancelled", PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout", }; @@ -4029,10 +4188,8 @@ fn handle_prompt_result( .to_string(); let harness_pid = std::process::id(); - let channel_id = match &result.source { - PromptSource::Channel(ch) => Some(*ch), - PromptSource::Heartbeat => None, - }; + // Observer frames stay channel-keyed — the desktop groups turns by channel. + let channel_id = result.source.channel_id(); let turn_id = result.turn_id.clone(); let emit_turn_error = |error_msg: &str, error_code: Option| { if let Some(ref observer) = observer { @@ -4053,6 +4210,25 @@ fn handle_prompt_result( }; match result.outcome { + // Intentional bounded-session recycle. The old child was shut down + // before it returned and the unprompted batch was restored above. + // Replace immediately without emitting a crash error or consuming + // circuit-breaker/backoff budget. + PromptOutcome::ProcessRecycle => { + tracing::info!( + agent = agent_index, + outcome = outcome_label, + "agent_returned — intentional process recycle" + ); + spawn_recycle_task( + result.agent, + config, + &mut crash_history[agent_index], + respawn_tx, + respawn_tasks, + observer, + ); + } // Successful prompt — return agent to pool. PromptOutcome::Ok(_) => { tracing::debug!( @@ -4232,7 +4408,7 @@ fn recover_panicked_agent( join_error: tokio::task::JoinError, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4247,25 +4423,27 @@ fn recover_panicked_agent( // Requeue BEFORE mark_complete (same rationale as handle_prompt_result). if let Some(batch) = meta.recoverable_batch { - if let Some(ch) = meta.channel_id { - if !removed_channels.contains(&ch) { + if let Some(scope) = meta.scope.as_ref() { + if !meta.membership_invalidated && !removed_channels.contains(&scope.channel_id()) { // Dead-letter on exhaustion is logged inside requeue(); a // panic path has no outcome to report, so no notice here. let _ = queue.requeue(batch); tracing::warn!("requeued batch for panicked agent {i}"); } else { tracing::debug!( - channel_id = %ch, - "dropping panicked batch for removed channel" + conversation = %scope, + membership_invalidated = meta.membership_invalidated, + "dropping panicked batch after membership invalidation or channel removal" ); } } } - if let Some(ch) = meta.channel_id { - queue.mark_complete(ch); - typing_channels.remove(&ch); - tracing::warn!("cleared wedged in-flight channel {ch} from panicked agent {i}"); + let panicked_channel_id = meta.scope.as_ref().map(ConversationScope::channel_id); + if let Some(scope) = meta.scope.as_ref() { + queue.mark_complete(scope); + typing_channels.remove(scope); + tracing::warn!("cleared wedged in-flight conversation {scope} from panicked agent {i}"); } else { *heartbeat_in_flight = false; tracing::warn!("cleared wedged heartbeat_in_flight from panicked agent {i}"); @@ -4275,7 +4453,7 @@ fn recover_panicked_agent( observer.emit( "agent_panic", Some(i), - &observer::context_for(meta.channel_id, None, Some(meta.turn_id)), + &observer::context_for(panicked_channel_id, None, Some(meta.turn_id)), serde_json::json!({ "outcome": "panic", "error": format!("Agent task panicked: {join_error}"), @@ -4330,7 +4508,7 @@ fn drain_ready_join_results( config: &Config, heartbeat_in_flight: &mut bool, removed_channels: &HashSet, - typing_channels: &mut HashMap, + typing_channels: &mut HashMap, crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, @@ -4400,12 +4578,14 @@ fn dispatch_heartbeat( abort_handle.id(), pool::TaskMeta { agent_index, - channel_id: None, + scope: None, turn_id, recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); *heartbeat_in_flight = true; @@ -4484,6 +4664,34 @@ fn default_heartbeat_prompt() -> String { ) } +/// Spawn a clean replacement after an intentional session-cap recycle. +/// +/// Unlike crash recovery, this path has no backoff and never records a crash; +/// the triggering batch was not prompted and is restored separately. +fn spawn_recycle_task( + old_agent: OwnedAgent, + config: &Config, + slot: &mut SlotCircuit, + respawn_tx: &mpsc::Sender, + respawn_tasks: &mut tokio::task::JoinSet<()>, + observer: Option, +) { + let index = old_agent.index; + slot.respawn_in_flight = true; + let cmd = config.agent_command.clone(); + let args = config.agent_args.clone(); + let env = config.persona_env_vars.clone(); + let has_codex = config.has_generated_codex_config; + let guard = RespawnGuard::new(index, respawn_tx.clone()); + respawn_tasks.spawn(async move { + let mut agent = old_agent; + agent.acp.shutdown().await; + drop(agent); + let result = spawn_and_init(&cmd, &args, &env, has_codex, index, observer).await; + guard.send(result); + }); +} + /// Spawn a background respawn task for a crashed agent slot. /// /// Does the circuit breaker check synchronously (non-blocking), then spawns @@ -5142,6 +5350,24 @@ mod owner_control_command_tests { )); } + #[test] + fn owner_shutdown_is_checked_before_conversation_scope_resolution() { + // Structural regression sentinel only: this pins the critical ordering + // in the inline event loop but does not replace an end-to-end metadata + // failure test. A future refactor should extract the early-control gate. + let source = include_str!("lib.rs"); + let shutdown = source + .find("let is_shutdown = is_owner_control_command") + .expect("shutdown gate exists"); + let routing = source + .find("let Some(event_scope) = resolve_event_scope") + .expect("conversation routing exists"); + assert!( + shutdown < routing, + "owner !shutdown must remain routable when channel metadata resolution fails" + ); + } + #[test] fn mode_gate_signal_maps_handling_to_control_signal() { let owner = "a".repeat(64); @@ -5183,10 +5409,9 @@ mod owner_control_command_tests { } #[tokio::test] - async fn signal_in_flight_task_sends_rotate_once() { + async fn legacy_channel_only_cancel_targets_the_only_active_scope() { let mut pool = AgentPool::from_slots(vec![]); let channel_id = Uuid::new_v4(); - let other_channel_id = Uuid::new_v4(); let (control_tx, control_rx) = tokio::sync::oneshot::channel(); let abort_handle = pool.join_set.spawn(async {}); @@ -5194,31 +5419,94 @@ mod owner_control_command_tests { abort_handle.id(), pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::thread(channel_id, "a".repeat(64))), turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: Some(control_tx), steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); - assert!(!signal_in_flight_task( - &mut pool, - other_channel_id, - ControlSignal::Rotate - )); - assert!(signal_in_flight_task( - &mut pool, - channel_id, - ControlSignal::Rotate - )); - assert_eq!(control_rx.await.unwrap(), ControlSignal::Rotate); - assert!(!signal_in_flight_task( - &mut pool, - channel_id, - ControlSignal::Rotate - )); + assert_eq!( + signal_cancel_turn(&mut pool, channel_id, None, None), + CancelTurnResult::Sent + ); + assert_eq!(control_rx.await.unwrap(), ControlSignal::Cancel); + } + + #[tokio::test] + async fn desktop_cancel_reports_no_active_turn_when_control_receiver_is_closed() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + drop(control_rx); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: 0, + scope: Some(ConversationScope::thread(channel_id, "a".repeat(64))), + turn_id: "completed-race-turn".into(), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, + }, + ); + + assert_eq!( + signal_cancel_turn(&mut pool, channel_id, Some("completed-race-turn"), None), + CancelTurnResult::NoActiveTurn + ); + } + + #[tokio::test] + async fn legacy_channel_only_cancel_fails_closed_with_two_active_scopes() { + let mut pool = AgentPool::from_slots(vec![]); + let channel_id = Uuid::new_v4(); + let mut receivers = Vec::new(); + for (index, root) in [(0, "a".repeat(64)), (1, "b".repeat(64))] { + let (control_tx, control_rx) = tokio::sync::oneshot::channel(); + receivers.push(control_rx); + let abort_handle = pool.join_set.spawn(async {}); + pool.task_map_mut().insert( + abort_handle.id(), + pool::TaskMeta { + agent_index: index, + scope: Some(ConversationScope::thread(channel_id, root)), + turn_id: format!("turn-{index}"), + recoverable_batch: None, + control_tx: Some(control_tx), + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, + }, + ); + } + + assert_eq!( + signal_cancel_turn(&mut pool, channel_id, None, None), + CancelTurnResult::Ambiguous + ); + assert!( + receivers.iter_mut().all(|rx| rx.try_recv().is_err()), + "ambiguous legacy cancellation must not signal either sibling scope" + ); + + assert_eq!( + signal_cancel_turn(&mut pool, channel_id, Some("turn-1"), None), + CancelTurnResult::Sent, + "a turn id disambiguates the requested sibling" + ); + let selected = receivers.remove(1); + assert_eq!(selected.await.unwrap(), ControlSignal::Cancel); + assert!(receivers[0].try_recv().is_err()); } #[test] @@ -5671,6 +5959,90 @@ mod author_gate_tests { "an unresolvable channel type must be treated as a DM" ); } + + #[tokio::test] + async fn test_unknown_channel_type_is_not_routed_as_dm_scope() { + let channel_id = Uuid::new_v4(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "root") + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + let channel_info = resolver(HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "unknown".into(), + channel_type: "unknown".into(), + description: None, + }, + )])); + + assert!( + resolve_event_scope(channel_id, &event, &channel_info) + .await + .is_none(), + "unknown metadata must be held/dropped for retry, never collapsed into a DM scope" + ); + } + + #[tokio::test] + async fn test_transient_unknown_then_group_resolution_preserves_thread_scope() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let channel_id = Uuid::new_v4(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = std::sync::Arc::new(AtomicUsize::new(0)); + let request_count = requests.clone(); + let response_id = channel_id; + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + let attempt = request_count.fetch_add(1, Ordering::SeqCst); + let body = if attempt < 2 { + serde_json::json!([]) + } else { + serde_json::json!([{ + "tags": [["d", response_id.to_string()], ["name", "Stream"], ["t", "stream"]] + }]) + } + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::new(), + relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }, + ); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "root") + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert!(resolve_event_scope(channel_id, &event, &channel_info) + .await + .is_none()); + let resolved = resolve_event_scope(channel_id, &event, &channel_info) + .await + .expect("later metadata resolution should route the group event"); + assert_eq!( + resolved, + ConversationScope::thread(channel_id, event.id.to_hex()) + ); + assert_eq!(requests.load(Ordering::SeqCst), 3); + server.abort(); + } } #[cfg(test)] @@ -6935,7 +7307,8 @@ mod error_outcome_emission_tests { use crate::acp::{AcpClient, AcpError}; use crate::observer::ObserverHandle; use crate::pool::{ - AgentPool, OwnedAgent, PromptOutcome, PromptResult, PromptSource, TimeoutKind, + AgentModelCapabilities, AgentPool, OwnedAgent, PromptOutcome, PromptResult, PromptSource, + TimeoutKind, }; use crate::queue::{BatchEvent, FlushBatch}; use nostr::{EventBuilder, Keys, Kind}; @@ -7028,6 +7401,499 @@ mod error_outcome_emission_tests { } } + fn allow_model(agent: &mut OwnedAgent, model: &str) { + agent.model_capabilities = Some(AgentModelCapabilities { + config_options_raw: vec![serde_json::json!({ + "configId": "model", + "category": "model", + "options": [{ "value": model }] + })], + available_models_raw: None, + }); + } + + #[tokio::test] + async fn idle_channel_model_switch_updates_all_sibling_slots() { + let channel_id = Uuid::new_v4(); + let scope_a = ConversationScope::thread(channel_id, "a".repeat(64)); + let scope_b = ConversationScope::thread(channel_id, "b".repeat(64)); + let unrelated_scope = ConversationScope::thread(Uuid::new_v4(), "c".repeat(64)); + let mut agent_a = dummy_agent(0).await; + let mut agent_b = dummy_agent(1).await; + let mut agent_c = dummy_agent(2).await; + allow_model(&mut agent_a, "model-next"); + allow_model(&mut agent_b, "model-next"); + allow_model(&mut agent_c, "model-next"); + agent_a + .state + .sessions + .insert(scope_a.clone(), "session-a".into()); + agent_b + .state + .sessions + .insert(scope_b.clone(), "session-b".into()); + agent_c + .state + .sessions + .insert(unrelated_scope.clone(), "session-c".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent_a), Some(agent_b), Some(agent_c)]); + + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-next"), + IdleSwitchResult::Switched + ); + assert_eq!(pool.effective_model(), Some("model-next")); + assert!(pool.model_overridden()); + + let returned_a = pool + .try_claim(Some(&scope_a)) + .expect("slot A remains available"); + assert_eq!(returned_a.desired_model.as_deref(), Some("model-next")); + assert!(!returned_a.state.sessions.contains_key(&scope_a)); + let returned_b = pool + .try_claim(Some(&scope_b)) + .expect("slot B remains available"); + assert_eq!(returned_b.desired_model.as_deref(), Some("model-next")); + assert!(!returned_b.state.sessions.contains_key(&scope_b)); + let returned_c = pool + .try_claim(Some(&unrelated_scope)) + .expect("unrelated idle slot remains available"); + assert_eq!( + returned_c.desired_model.as_deref(), + Some("model-next"), + "a slot with no target-channel session must still receive the process-wide override" + ); + assert!(returned_c.state.sessions.contains_key(&unrelated_scope)); + } + + #[tokio::test] + async fn channel_model_switch_fails_atomically_when_any_catalog_is_unknown() { + let channel_id = Uuid::new_v4(); + let mut known = dummy_agent(0).await; + let unknown = dummy_agent(1).await; + allow_model(&mut known, "model-next"); + let mut pool = AgentPool::from_slots(vec![Some(known), Some(unknown)]); + + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-next"), + IdleSwitchResult::CatalogUnavailable + ); + for agent in pool.agents_mut().iter().flatten() { + assert!(agent.desired_model.is_none()); + assert!(!agent.model_overridden); + } + assert_eq!(pool.effective_model(), None); + assert!(!pool.model_overridden()); + } + + #[tokio::test] + async fn channel_model_switch_reports_unsupported_for_known_missing_model() { + let channel_id = Uuid::new_v4(); + let mut first = dummy_agent(0).await; + let mut second = dummy_agent(1).await; + allow_model(&mut first, "model-known"); + allow_model(&mut second, "model-known"); + let mut pool = AgentPool::from_slots(vec![Some(first), Some(second)]); + + assert_eq!( + pool.switch_idle_agent_model(channel_id, "model-missing"), + IdleSwitchResult::UnsupportedModel + ); + for agent in pool.agents_mut().iter().flatten() { + assert!(agent.desired_model.is_none()); + assert!(!agent.model_overridden); + } + } + + #[tokio::test] + async fn channel_model_switch_fails_atomically_when_unrelated_slot_is_checked_out() { + let channel_id = Uuid::new_v4(); + let scope_a = ConversationScope::thread(channel_id, "a".repeat(64)); + let scope_b = ConversationScope::thread(channel_id, "b".repeat(64)); + let unrelated_scope = ConversationScope::thread(Uuid::new_v4(), "c".repeat(64)); + let mut agent_a = dummy_agent(0).await; + let mut agent_b = dummy_agent(1).await; + let mut agent_c = dummy_agent(2).await; + agent_a + .state + .sessions + .insert(scope_a.clone(), "session-a".into()); + agent_b + .state + .sessions + .insert(scope_b.clone(), "session-b".into()); + agent_c + .state + .sessions + .insert(unrelated_scope.clone(), "session-c".into()); + let mut pool = AgentPool::from_slots(vec![Some(agent_a), Some(agent_b), Some(agent_c)]); + let checked_out = pool + .try_claim(Some(&unrelated_scope)) + .expect("unrelated slot checks out"); + + assert_ne!( + pool.switch_idle_agent_model(channel_id, "model-next"), + IdleSwitchResult::Switched, + "any checked-out slot must make the complete-pool update fail closed" + ); + + let returned_a = pool.try_claim(Some(&scope_a)).expect("slot A unchanged"); + assert!(returned_a.desired_model.is_none()); + assert!(returned_a.state.sessions.contains_key(&scope_a)); + let returned_b = pool.try_claim(Some(&scope_b)).expect("slot B unchanged"); + assert!(returned_b.desired_model.is_none()); + assert!(returned_b.state.sessions.contains_key(&scope_b)); + assert!(checked_out.desired_model.is_none()); + assert!(checked_out.state.sessions.contains_key(&unrelated_scope)); + } + + #[tokio::test] + async fn remove_then_readd_still_invalidates_in_flight_generation_on_return() { + let channel_id = Uuid::new_v4(); + let scope = ConversationScope::thread(channel_id, "a".repeat(64)); + let mut agent = dummy_agent(0).await; + agent + .state + .sessions + .insert(scope.clone(), "stale-session".into()); + agent + .state + .deliveries + .insert(scope.clone(), Default::default()); + + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "stale work") + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + let batch = FlushBatch { + scope: scope.clone(), + events: vec![queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + let membership_generation = pool.membership_generation(channel_id); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + scope: Some(scope.clone()), + turn_id: "pre-removal-turn".into(), + recoverable_batch: Some(batch.clone()), + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + membership_generation, + membership_invalidated: false, + }, + ); + pool.advance_membership_generation(channel_id); + // Re-add deliberately does not rewind the generation. The active + // removed-channel set is empty when the old task returns. + let removed_channels = HashSet::new(); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let mut heartbeat_in_flight = false; + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let action = handle_prompt_result( + &mut pool, + &mut queue, + &test_config(), + PromptResult { + agent, + source: PromptSource::Channel(scope.clone()), + turn_id: "pre-removal-turn".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: Some(batch), + }, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + assert!(matches!(action, LoopAction::Continue)); + assert_eq!( + queue.pending_conversations(), + 0, + "stale work is not requeued" + ); + let returned = pool + .try_claim(Some(&scope)) + .expect("agent returned to pool"); + assert!(!returned.state.sessions.contains_key(&scope)); + } + + #[tokio::test] + async fn remove_then_readd_panic_recovery_does_not_requeue_stale_batch() { + let channel_id = Uuid::new_v4(); + let scope = ConversationScope::thread(channel_id, "a".repeat(64)); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "stale panic work") + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + let batch = FlushBatch { + scope: scope.clone(), + events: vec![queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool + .join_set + .spawn(async { panic!("real prompt task panic") }) + .id(); + let membership_generation = pool.membership_generation(channel_id); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + scope: Some(scope.clone()), + turn_id: "pre-removal-panicked-turn".into(), + recoverable_batch: Some(batch), + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + membership_generation, + membership_invalidated: false, + }, + ); + pool.advance_membership_generation(channel_id); + let join_error = pool + .join_set + .join_next() + .await + .expect("panicked task joins") + .expect_err("task must really panic"); + + let mut queue = EventQueue::new(config::DedupMode::Queue); + let mut heartbeat_in_flight = false; + let removed_channels = HashSet::new(); + let mut typing_channels = HashMap::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + + recover_panicked_agent( + &mut pool, + &mut queue, + &test_config(), + join_error, + &mut heartbeat_in_flight, + &removed_channels, + &mut typing_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + ); + respawn_tasks.abort_all(); + + assert_eq!( + queue.pending_conversations(), + 0, + "pre-removal work must stay dropped after re-add even if the old task panics" + ); + assert!(!queue.is_conversation_in_flight(&scope)); + } + + #[tokio::test] + async fn intentional_session_recycle_respawns_and_redispatches_without_budget() { + let channel_id = Uuid::new_v4(); + let scope = ConversationScope::dm(channel_id); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "replacement must process") + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + let event_id = event.id.to_hex(); + let mut queue = EventQueue::new(config::DedupMode::Drop); + assert!(queue.push(QueuedEvent { + scope: scope.clone(), + event, + received_at: std::time::Instant::now(), + prompt_tag: "test".into(), + })); + let batch = queue.flush_next().expect("triggering batch is in flight"); + + let old_agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + scope: Some(scope.clone()), + turn_id: "intentional-recycle".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, + }, + ); + + let script = r#" +while IFS= read -r line; do + if [[ "$line" =~ \"id\":([0-9]+) ]]; then id="${BASH_REMATCH[1]}"; else id=0; fi + if [[ "$line" == *'"method":"initialize"'* ]]; then + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"protocolVersion\":1,\"agentInfo\":{\"name\":\"replacement-test-agent\"}}}" + elif [[ "$line" == *'"method":"session/new"'* ]]; then + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"sessionId\":\"replacement-session\"}}" + elif [[ "$line" == *'"method":"session/prompt"'* ]]; then + printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"stopReason\":\"end_turn\"}}" + fi +done +"#; + let mut config = test_config(); + config.agent_command = "bash".into(); + config.agent_args = vec!["-c".into(), script.into()]; + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, mut respawn_rx) = mpsc::channel(1); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let mut heartbeat_in_flight = false; + + let action = handle_prompt_result( + &mut pool, + &mut queue, + &config, + PromptResult { + agent: old_agent, + source: PromptSource::Channel(scope.clone()), + turn_id: "intentional-recycle".into(), + outcome: PromptOutcome::ProcessRecycle, + batch: Some(batch), + }, + &mut heartbeat_in_flight, + &HashSet::new(), + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!( + crash_history[0].crash_times.is_empty(), + "intentional recycle must not consume circuit-breaker budget" + ); + assert!( + queue.has_flushable_work(), + "unprompted batch must be immediately redispatchable without retry backoff" + ); + + let replacement = tokio::time::timeout(Duration::from_secs(5), respawn_rx.recv()) + .await + .expect("replacement spawn is bounded") + .expect("replacement result arrives"); + crash_history[replacement.index].respawn_in_flight = false; + let (acp, protocol_version, agent_name) = replacement + .result + .expect("intentional replacement initializes"); + pool.return_agent(OwnedAgent { + index: replacement.index, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: config.model.clone(), + model_overridden: false, + agent_name, + goose_system_prompt_supported: None, + protocol_version, + }); + + let replacement_batch = queue.flush_next().expect("preserved batch redispatches"); + assert_eq!(replacement_batch.events[0].event.id.to_hex(), event_id); + let replacement_agent = pool + .try_claim(Some(&scope)) + .expect("replacement slot is usable"); + let keys = nostr::Keys::generate(); + let rest_client = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".into(), + keys: keys.clone(), + auth_tag_json: None, + }; + let prompt_ctx = PromptContext { + mcp_servers: vec![], + initial_message: None, + idle_timeout: Duration::from_secs(5), + max_turn_duration: Duration::from_secs(10), + turn_liveness_interval: Duration::ZERO, + dedup_mode: config::DedupMode::Drop, + system_prompt: None, + session_title: None, + team_instructions: None, + heartbeat_prompt: None, + base_prompt: None, + cwd: ".".into(), + rest_client: rest_client.clone(), + channel_info: pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "replacement-dm".into(), + channel_type: "dm".into(), + description: None, + }, + )]), + rest_client, + ), + context_message_limit: 0, + max_turns_per_session: 0, + permission_mode: config::PermissionMode::Default, + agent_keys: keys, + agent_owner_pubkey: None, + memory_enabled: false, + harness_name: "replacement-test-agent".into(), + relay_url: "ws://127.0.0.1:3000".into(), + }; + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + pool::run_prompt_task( + replacement_agent, + Some(replacement_batch), + None, + Arc::new(prompt_ctx), + result_tx, + None, + "replacement-turn".into(), + ) + .await; + let mut replacement_result = result_rx.recv().await.expect("replacement prompt result"); + assert!(matches!( + replacement_result.outcome, + PromptOutcome::Ok(crate::acp::StopReason::EndTurn) + )); + replacement_result.agent.acp.shutdown().await; + respawn_tasks.shutdown().await; + } + #[tokio::test] async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { let channel_id = Uuid::new_v4(); @@ -7036,11 +7902,11 @@ mod error_outcome_emission_tests { agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(ConversationScope::dm(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, Default::default()); + .insert(ConversationScope::dm(channel_id), Default::default()); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7048,7 +7914,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7059,6 +7925,8 @@ mod error_outcome_emission_tests { session_id: "live-session".into(), }, ]), + membership_generation: 0, + membership_invalidated: false, }, ); @@ -7075,7 +7943,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7096,23 +7964,25 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&ConversationScope::dm(channel_id)] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn in_flight_stale_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); + agent.state.sessions.insert( + ConversationScope::dm(channel_id), + "replacement-session".into(), + ); agent .state .deliveries - .insert(channel_id, Default::default()); + .insert(ConversationScope::dm(channel_id), Default::default()); let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7120,7 +7990,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7131,6 +8001,8 @@ mod error_outcome_emission_tests { session_id: "old-session".into(), }, ]), + membership_generation: 0, + membership_invalidated: false, }, ); @@ -7147,7 +8019,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7168,9 +8040,11 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&ConversationScope::dm(channel_id)] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7181,47 +8055,51 @@ mod error_outcome_emission_tests { agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(ConversationScope::dm(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, Default::default()); + .insert(ConversationScope::dm(channel_id), Default::default()); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &ConversationScope::dm(channel_id), steer_event_id.into(), "live-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("idle returned agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .contains(steer_event_id)); + assert!( + returned.state.deliveries[&ConversationScope::dm(channel_id)] + .delivered_event_ids + .contains(steer_event_id) + ); } #[tokio::test] async fn late_native_steer_ack_cannot_update_replacement_session() { let channel_id = Uuid::new_v4(); let mut agent = dummy_agent(0).await; - agent - .state - .sessions - .insert(channel_id, "replacement-session".into()); + agent.state.sessions.insert( + ConversationScope::dm(channel_id), + "replacement-session".into(), + ); agent .state .deliveries - .insert(channel_id, Default::default()); + .insert(ConversationScope::dm(channel_id), Default::default()); let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(!pool.record_successful_steer( - channel_id, + &ConversationScope::dm(channel_id), "stale-event".into(), "old-session".into(), )); let returned = pool.agents_mut()[0].as_ref().expect("replacement agent"); - assert!(returned.state.deliveries[&channel_id] - .delivered_event_ids - .is_empty()); + assert!( + returned.state.deliveries[&ConversationScope::dm(channel_id)] + .delivered_event_ids + .is_empty() + ); } #[tokio::test] @@ -7235,7 +8113,7 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".into(), recoverable_batch: None, control_tx: None, @@ -7246,6 +8124,8 @@ mod error_outcome_emission_tests { session_id: "invalidated-session".into(), }, ]), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7261,7 +8141,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".into(), outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), batch: None, @@ -7282,7 +8162,10 @@ mod error_outcome_emission_tests { ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); - assert!(!returned.state.deliveries.contains_key(&channel_id)); + assert!(!returned + .state + .deliveries + .contains_key(&ConversationScope::dm(channel_id))); } /// Drive one error outcome through `handle_prompt_result` and return how @@ -7300,12 +8183,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); @@ -7324,7 +8209,7 @@ mod error_outcome_emission_tests { let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(ConversationScope::dm(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7377,12 +8262,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: Some(channel_id), + scope: Some(ConversationScope::dm(channel_id)), turn_id: "panic-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); started_rx.await.unwrap(); @@ -7470,12 +8357,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7492,7 +8381,7 @@ mod error_outcome_emission_tests { let observer = ObserverHandle::in_process(); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(ConversationScope::dm(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome, batch: None, @@ -7541,7 +8430,7 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id: Uuid::new_v4(), + scope: ConversationScope::dm(Uuid::new_v4()), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7554,7 +8443,7 @@ mod error_outcome_emission_tests { // Returns (pending_channels, queued_event_count_for_channel). let run = |outcome: PromptOutcome, batch: FlushBatch| async move { - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7562,12 +8451,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7583,7 +8474,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -7602,8 +8493,8 @@ mod error_outcome_emission_tests { None, ); ( - queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.pending_conversations(), + queue.queued_event_count(&ConversationScope::dm(channel_id)), ) }; @@ -7648,7 +8539,7 @@ mod error_outcome_emission_tests { .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id, + scope: ConversationScope::dm(channel_id), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -7660,7 +8551,7 @@ mod error_outcome_emission_tests { }; let run = |outcome: PromptOutcome, batch: FlushBatch| async move { - let channel_id = batch.channel_id; + let channel_id = batch.channel_id(); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); let task_id = pool.join_set.spawn(async {}).id(); @@ -7668,12 +8559,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7689,7 +8582,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome, batch: Some(batch), @@ -7708,8 +8601,8 @@ mod error_outcome_emission_tests { None, ); ( - queue.pending_channels(), - queue.queued_event_count(&channel_id), + queue.pending_conversations(), + queue.queued_event_count(&ConversationScope::dm(channel_id)), ) }; @@ -7745,12 +8638,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7766,7 +8661,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::dm(channel_id), events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "test") .sign_with_keys(&Keys::generate()) @@ -7779,7 +8674,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -7813,7 +8708,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 1, "batch must be requeued, not dead-lettered, while within the retry budget" ); @@ -7831,7 +8726,10 @@ mod error_outcome_emission_tests { // Simulate MAX_RETRIES prior failed attempts on this channel so the // upcoming requeue() call in handle_prompt_result crosses the // dead-letter threshold. - queue.set_retry_count_for_test(channel_id, crate::queue::MAX_RETRIES); + queue.set_retry_count_for_test( + &ConversationScope::dm(channel_id), + crate::queue::MAX_RETRIES, + ); let agent = dummy_agent(0).await; let mut pool = AgentPool::from_slots(vec![None]); @@ -7840,12 +8738,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let config = test_config(); @@ -7860,7 +8760,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let observer = ObserverHandle::in_process(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::dm(channel_id), events: vec![BatchEvent { event: EventBuilder::new(Kind::Custom(9), "final-attempt") .sign_with_keys(&Keys::generate()) @@ -7873,7 +8773,7 @@ mod error_outcome_emission_tests { }; let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Timeout(TimeoutKind::Hard { recently_active: true, @@ -7907,7 +8807,7 @@ mod error_outcome_emission_tests { ), ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&ConversationScope::dm(channel_id)), 0, "batch with an exhausted retry budget must be dead-lettered, not requeued" ); @@ -7940,7 +8840,7 @@ mod error_outcome_emission_tests { ); let channel_id = Uuid::new_v4(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::dm(channel_id), events: vec![BatchEvent { event: original_event.clone(), prompt_tag: "test".into(), @@ -7957,12 +8857,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -7971,7 +8873,7 @@ mod error_outcome_emission_tests { // out on drain — so it is already queued by the time // handle_prompt_result runs. queue.push(QueuedEvent { - channel_id, + scope: ConversationScope::dm(channel_id), event: new_event.clone(), received_at: std::time::Instant::now(), prompt_tag: "test".into(), @@ -7990,7 +8892,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), batch: Some(batch), @@ -8097,12 +8999,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -8120,7 +9024,7 @@ mod error_outcome_emission_tests { let grace = std::time::Duration::from_secs(5); let result = PromptResult { agent, - source: PromptSource::Channel(Uuid::new_v4()), + source: PromptSource::Channel(ConversationScope::dm(Uuid::new_v4())), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::CancelDrainTimeout(grace), // Explicit Stop already dropped the batch upstream in @@ -8145,7 +9049,7 @@ mod error_outcome_emission_tests { // No batch to merge — the queue has nothing pending for any channel. assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 0, "a dropped Stop batch must not leave anything queued" ); @@ -8263,7 +9167,7 @@ mod error_outcome_emission_tests { .unwrap(); let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::dm(channel_id), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8286,12 +9190,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -8307,7 +9213,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(auth_error), batch: Some(batch), @@ -8328,12 +9234,12 @@ mod error_outcome_emission_tests { // The batch must not be requeued: pending_channels returns 0. assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 0, "auth error must dead-letter immediately — batch must not be requeued" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&ConversationScope::dm(channel_id)), 0, "auth error must dead-letter immediately — no events should be pending" ); @@ -8349,7 +9255,7 @@ mod error_outcome_emission_tests { .unwrap(); let channel_id = uuid::Uuid::new_v4(); let batch = FlushBatch { - channel_id, + scope: ConversationScope::dm(channel_id), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -8372,12 +9278,14 @@ mod error_outcome_emission_tests { task_id, crate::pool::TaskMeta { agent_index: 0, - channel_id: None, + scope: None, turn_id: "test-turn-id".to_string(), recoverable_batch: None, control_tx: None, steer_tx: None, successful_steer_deliveries: HashSet::new(), + membership_generation: 0, + membership_invalidated: false, }, ); let mut queue = EventQueue::new(config::DedupMode::Queue); @@ -8393,7 +9301,7 @@ mod error_outcome_emission_tests { let mut respawn_tasks = tokio::task::JoinSet::new(); let result = PromptResult { agent, - source: PromptSource::Channel(channel_id), + source: PromptSource::Channel(ConversationScope::dm(channel_id)), turn_id: "test-turn-id".to_string(), outcome: PromptOutcome::Error(usage_error), batch: Some(batch), @@ -8414,12 +9322,12 @@ mod error_outcome_emission_tests { // Non-auth application error: batch IS requeued (first attempt, retry budget > 0). assert_eq!( - queue.pending_channels(), + queue.pending_conversations(), 1, "non-auth application error must requeue the batch for retry" ); assert_eq!( - queue.queued_event_count(&channel_id), + queue.queued_event_count(&ConversationScope::dm(channel_id)), 1, "non-auth application error must preserve the event for retry" ); diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2efacce2b19..22441ca2f94 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -35,6 +35,7 @@ use crate::acp::{ StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; +use crate::conversation::ConversationScope; use crate::observer; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, @@ -45,6 +46,10 @@ use crate::relay::{ChannelInfo, RestClient}; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); +/// Bound child-agent sessions and all per-conversation maps retained by one +/// worker process. Eviction occurs only while that worker is idle between its +/// own turns, so its current in-flight conversation is never a candidate. +const MAX_CONVERSATION_SESSIONS_PER_AGENT: usize = 256; // FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store // a recoverable copy in TaskMeta for panic recovery in Queue mode. @@ -58,7 +63,10 @@ pub struct SuccessfulSteerDelivery { pub struct TaskMeta { pub agent_index: usize, - pub channel_id: Option, + /// Conversation this task is prompting for; `None` for heartbeat tasks. + /// Mid-turn control (cancel / interrupt / steer) matches on this, so a + /// sibling thread in the same channel can never interrupt this turn. + pub scope: Option, /// Identifies terminal events when the task panics before returning a result. pub turn_id: String, /// Clone of batch for Queue mode panic recovery. @@ -77,6 +85,11 @@ pub struct TaskMeta { /// live session. The session ID prevents a late ack from contaminating a /// replacement session after task return. pub successful_steer_deliveries: HashSet, + /// Membership generation captured when this turn was dispatched. + pub membership_generation: u64, + /// Set when a later removal advances the channel generation. Never cleared + /// by re-add, so return-time cleanup remains causally correct. + pub membership_invalidated: bool, } /// Agent-level model capabilities. Populated on first session creation. @@ -90,7 +103,7 @@ pub struct AgentModelCapabilities { pub available_models_raw: Option, } -/// Successful deliveries associated with one live channel session. +/// Successful deliveries associated with one live conversation session. #[derive(Default)] pub struct ChannelDeliveryState { /// Whether a legacy user message has successfully carried standing context. @@ -100,18 +113,30 @@ pub struct ChannelDeliveryState { pub delivered_event_ids: HashSet, } -/// Per-channel session IDs, turn counters, and delivery state. +/// Per-conversation session IDs, turn counters, and delivery state. +/// +/// Session-bearing state is keyed by [`ConversationScope`] — a DM channel is one +/// conversation, and each thread root in a group channel is its own. Two threads +/// in one channel therefore never share a session, a turn counter, or a delivery +/// ledger. +/// +/// Channel-keyed caches (`core_sections`, `canvas_sections`) stay keyed by +/// `Uuid`: they describe the *channel*, not the thread, and re-fetching them per +/// thread would multiply relay traffic for identical content. /// /// Separated from `OwnedAgent` so the state machine is testable without /// spawning a real agent subprocess. #[derive(Default)] pub struct SessionState { - /// channel_id → session_id - pub sessions: HashMap, + /// conversation → session_id + pub sessions: HashMap, + /// Monotonic LRU stamps for bounded inactive-session eviction. + session_last_used: HashMap, + session_usage_clock: u64, pub heartbeat_session: Option, - /// Per-channel turn counters for proactive session rotation. + /// Per-conversation turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. - pub turn_counts: HashMap, + pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, /// Whether the live heartbeat session has successfully received `[Base]`. @@ -126,17 +151,63 @@ pub struct SessionState { /// fetch fails — all fail open. Cleared on session invalidation alongside /// `core_sections` so the next session picks up any canvas change. pub canvas_sections: HashMap, - /// Per-channel successful-delivery state. Created with the ACP session and - /// cleared atomically with every invalidation path. - pub deliveries: HashMap, + /// Per-conversation successful-delivery state. Created with the ACP session + /// and cleared atomically with every invalidation path. + pub deliveries: HashMap, } impl SessionState { + fn requires_process_recycle(&self, scope: &ConversationScope) -> bool { + !self.sessions.contains_key(scope) + && self.sessions.len() >= MAX_CONVERSATION_SESSIONS_PER_AGENT + } + + fn next_session_usage(&mut self) -> u64 { + self.session_usage_clock = self.session_usage_clock.saturating_add(1); + self.session_usage_clock + } + + fn session_id(&mut self, scope: &ConversationScope) -> Option { + let session = self.sessions.get(scope).cloned()?; + let usage = self.next_session_usage(); + self.session_last_used.insert(scope.clone(), usage); + Some(session) + } + + fn insert_session(&mut self, scope: ConversationScope, session_id: String) { + if !self.sessions.contains_key(&scope) + && self.sessions.len() >= MAX_CONVERSATION_SESSIONS_PER_AGENT + { + let oldest = self + .sessions + .keys() + .filter(|candidate| *candidate != &scope) + .min_by_key(|candidate| { + self.session_last_used + .get(*candidate) + .copied() + .unwrap_or_default() + }) + .cloned(); + if let Some(oldest) = oldest { + tracing::info!( + conversation = %oldest, + limit = MAX_CONVERSATION_SESSIONS_PER_AGENT, + "evicting least-recently-used inactive ACP session" + ); + self.invalidate_conversation(&oldest); + } + } + let usage = self.next_session_usage(); + self.sessions.insert(scope.clone(), session_id); + self.session_last_used.insert(scope, usage); + } + /// Invalidate the session (and turn counter) for a specific prompt source. pub fn invalidate(&mut self, source: &PromptSource) { match source { - PromptSource::Channel(cid) => { - self.invalidate_channel(cid); + PromptSource::Channel(scope) => { + self.invalidate_conversation(scope); } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -146,19 +217,72 @@ impl SessionState { } } - /// Invalidate a single channel's session and turn counter. - /// Returns `true` if the channel had an active session. - pub fn invalidate_channel(&mut self, channel_id: &Uuid) -> bool { - self.turn_counts.remove(channel_id); - self.core_sections.remove(channel_id); - self.canvas_sections.remove(channel_id); - self.deliveries.remove(channel_id); - self.sessions.remove(channel_id).is_some() + /// Invalidate a single conversation's session, turn counter, and delivery + /// ledger. Sibling conversations in the same channel are untouched. + /// + /// The channel-keyed `core_sections` / `canvas_sections` caches are dropped + /// only once the channel has no live conversation left — they exist to be + /// re-read at session creation, and evicting them while a sibling thread is + /// still running would refetch identical content for no benefit. + /// + /// Returns `true` if the conversation had an active session. + pub fn invalidate_conversation(&mut self, scope: &ConversationScope) -> bool { + self.turn_counts.remove(scope); + self.deliveries.remove(scope); + self.session_last_used.remove(scope); + let had_session = self.sessions.remove(scope).is_some(); + if !self.has_live_conversation_in(scope.channel_id()) { + self.core_sections.remove(&scope.channel_id()); + self.canvas_sections.remove(&scope.channel_id()); + } + had_session + } + + /// Invalidate **every** conversation in `channel_id`, plus its + /// channel-keyed caches. + /// + /// Channel removal is deliberately channel-wide: losing membership + /// invalidates every thread in that channel, not just the one that last + /// prompted. Returns the number of conversations that had an active session. + pub fn invalidate_channel(&mut self, channel_id: Uuid) -> usize { + let scopes: Vec = + self.scopes_for_channel(channel_id).into_iter().collect(); + let mut invalidated = 0; + for scope in scopes { + self.turn_counts.remove(&scope); + self.deliveries.remove(&scope); + self.session_last_used.remove(&scope); + if self.sessions.remove(&scope).is_some() { + invalidated += 1; + } + } + self.core_sections.remove(&channel_id); + self.canvas_sections.remove(&channel_id); + invalidated + } + + /// Every conversation scope in `channel_id` this state holds an entry for. + fn scopes_for_channel(&self, channel_id: Uuid) -> HashSet { + self.sessions + .keys() + .chain(self.turn_counts.keys()) + .chain(self.deliveries.keys()) + .filter(|scope| scope.channel_id() == channel_id) + .cloned() + .collect() + } + + /// Whether any conversation in `channel_id` still has a live ACP session. + fn has_live_conversation_in(&self, channel_id: Uuid) -> bool { + self.sessions + .keys() + .any(|scope| scope.channel_id() == channel_id) } /// Invalidate all sessions and turn counters (e.g. after agent exit). pub fn invalidate_all(&mut self) { self.sessions.clear(); + self.session_last_used.clear(); self.turn_counts.clear(); self.heartbeat_session = None; self.heartbeat_turn_count = 0; @@ -170,22 +294,20 @@ impl SessionState { pub(crate) fn mark_channel_delivery_success( &mut self, - channel_id: Uuid, + scope: &ConversationScope, standing_context_sent: bool, event_ids: impl IntoIterator, ) { - let delivery = self.deliveries.entry(channel_id).or_default(); + let delivery = self.deliveries.entry(scope.clone()).or_default(); delivery.standing_context_sent |= standing_context_sent; delivery.delivered_event_ids.extend(event_ids); } #[cfg(test)] - fn has_channel_state(&self, channel_id: &Uuid) -> bool { - self.sessions.contains_key(channel_id) - || self.turn_counts.contains_key(channel_id) - || self.core_sections.contains_key(channel_id) - || self.canvas_sections.contains_key(channel_id) - || self.deliveries.contains_key(channel_id) + fn has_conversation_state(&self, scope: &ConversationScope) -> bool { + self.sessions.contains_key(scope) + || self.turn_counts.contains_key(scope) + || self.deliveries.contains_key(scope) } } @@ -198,7 +320,7 @@ pub struct OwnedAgent { pub model_capabilities: Option, /// Desired model ID (from `Config.model`). Applied after every `session_new_full()`. pub desired_model: Option, - /// Whether `desired_model` was set by a live `SwitchModel` control signal + /// Whether `desired_model` was set by a live channel model-switch control /// (as opposed to being derived from config/persona at spawn). Used by the /// desktop reader to distinguish a genuine runtime override from a stale /// session whose persona model was edited. Reset on spawn/restart. @@ -270,6 +392,11 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + membership_generations: HashMap, + /// Effective process-wide model selection retained even while every worker + /// slot is checked out or being recycled. + effective_model: Option, + model_overridden: bool, } /// Result returned by a completed prompt task. @@ -286,10 +413,20 @@ pub struct PromptResult { /// Whether the prompt came from a channel event or a heartbeat. #[derive(Debug)] pub enum PromptSource { - Channel(Uuid), + Channel(ConversationScope), Heartbeat, } +impl PromptSource { + /// The Buzz channel behind this source; `None` for heartbeats. + pub fn channel_id(&self) -> Option { + match self { + PromptSource::Channel(scope) => Some(scope.channel_id()), + PromptSource::Heartbeat => None, + } + } +} + /// Apply state effects for Race 1, where a control signal arrives just after the /// prompt completed naturally. The prompt result has already been consumed by /// `select!`, so the harness must synthesize a successful result while still @@ -299,21 +436,14 @@ fn apply_completed_before_control_signal( source: &PromptSource, control_signal: &ControlSignal, ) { - // Rotate and SwitchModel both invalidate so the next turn creates a fresh - // session. For SwitchModel the caller has already set `desired_model`, so - // the fresh session applies the new model on its next creation. - if matches!( - control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) - ) { + // Rotate invalidates so the next turn creates a fresh session. + if matches!(control_signal, ControlSignal::Rotate) { state.invalidate(source); } } -/// Control signal for an in-flight channel turn. +/// Control signal for an in-flight conversation turn. /// -/// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when -/// a value is needed after a move, or match by reference. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. @@ -330,12 +460,6 @@ pub enum ControlSignal { /// Stop the current turn and drop its triggering batch. The session is /// invalidated just like cancel; the next turn creates a fresh session. Rotate, - /// Switch the agent's model, then requeue the triggering batch so it - /// re-runs on a fresh session under the new model. The model lands by - /// setting `OwnedAgent::desired_model` before invalidation; the requeued - /// turn re-creates the session and re-applies `desired_model`. Runtime-only - /// — never persisted, gone on restart/respawn. - SwitchModel(String), } /// Goose-native non-cancelling steer request, sent from the main loop to an @@ -480,6 +604,10 @@ pub enum PromptOutcome { Ok(StopReason), Error(AcpError), AgentExited, + /// The worker intentionally stopped at its bounded conversation-session + /// cap before the triggering batch was sent. The main loop must preserve + /// that unprompted batch and replace the child without crash/retry budget. + ProcessRecycle, Timeout(TimeoutKind), /// Intentional cancel via `!cancel` command or interrupt mode. /// Agent is healthy — no respawn, no retry penalty. @@ -617,27 +745,44 @@ impl AgentPool { /// the index invariant. pub fn from_slots(slots: Vec>) -> Self { let (result_tx, result_rx) = mpsc::unbounded_channel(); + let overridden = slots.iter().flatten().find(|agent| agent.model_overridden); + let effective_model = overridden + .and_then(|agent| agent.desired_model.clone()) + .or_else(|| { + slots + .iter() + .flatten() + .find_map(|agent| agent.desired_model.clone()) + }); + let model_overridden = overridden.is_some(); Self { agents: slots, result_tx, result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + membership_generations: HashMap::new(), + effective_model, + model_overridden, } } - /// Try to claim an idle agent for the given channel (or heartbeat if `None`). + /// Try to claim an idle agent for the given conversation (or heartbeat if + /// `None`). /// - /// Pass 1: prefer an agent that already has a session for `channel_id`. + /// Pass 1: prefer an agent that already has a session for `scope`. Affinity + /// is per-conversation, not per-channel: reusing an agent because it happens + /// to hold a *different* thread's session buys nothing and hides the fact + /// that a fresh session is about to be created. /// Pass 2: any idle agent. /// /// Returns `None` if all agents are checked out. - pub fn try_claim(&mut self, channel_id: Option) -> Option { - // Pass 1: prefer agent with existing session for this channel. - if let Some(cid) = channel_id { + pub fn try_claim(&mut self, scope: Option<&ConversationScope>) -> Option { + // Pass 1: prefer agent with existing session for this conversation. + if let Some(scope) = scope { let idx = self.agents.iter().position(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&cid)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }); if let Some(i) = idx { @@ -671,12 +816,12 @@ impl AgentPool { self.agents.iter().any(|slot| slot.is_some()) } - /// Whether any idle agent already has a session for `channel_id`. + /// Whether any idle agent already has a session for `scope`. /// Used to compute `affinity_hit` before calling `try_claim`. - pub fn has_session_for(&self, channel_id: Uuid) -> bool { + pub fn has_session_for(&self, scope: &ConversationScope) -> bool { self.agents.iter().any(|slot| { slot.as_ref() - .map(|a| a.state.sessions.contains_key(&channel_id)) + .map(|a| a.state.sessions.contains_key(scope)) .unwrap_or(false) }) } @@ -698,8 +843,33 @@ impl AgentPool { &mut self.task_map } + pub fn membership_generation(&self, channel_id: Uuid) -> u64 { + self.membership_generations + .get(&channel_id) + .copied() + .unwrap_or_default() + } + + /// Advance membership generation and invalidate every older in-flight turn + /// for this channel. Re-add does not rewind or clear this causal marker. + pub fn advance_membership_generation(&mut self, channel_id: Uuid) -> u64 { + let generation = self.membership_generation(channel_id).saturating_add(1); + self.membership_generations.insert(channel_id, generation); + for meta in self.task_map.values_mut() { + if meta + .scope + .as_ref() + .is_some_and(|scope| scope.channel_id() == channel_id) + && meta.membership_generation < generation + { + meta.membership_invalidated = true; + } + } + generation + } + /// Try to send a goose-native steer request to the in-flight task for - /// `channel_id`. + /// `scope`. /// /// Returns `Ok(())` if the request was accepted by the read loop's /// receiver (capacity-1 mpsc; one slot is the single in-flight steer @@ -716,19 +886,21 @@ impl AgentPool { /// watcher, to close the result-vs-ack race. /// /// Returns `Err(SteerError::PromptCompleted)` if no task is in flight - /// for `channel_id` (the prompt completed between the mode-gate check - /// and this call, or the channel was never in flight). This is - /// semantically a soft no-op — the caller should release any withheld - /// event and let normal dispatch handle delivery. + /// for `scope` (the prompt completed between the mode-gate check and this + /// call, or the conversation was never in flight). A turn running for a + /// *sibling* thread in the same channel is deliberately not a match — it + /// must never be steered by another thread's message. This is semantically + /// a soft no-op — the caller should release any withheld event and let + /// normal dispatch handle delivery. pub fn send_steer( &mut self, - channel_id: Uuid, + scope: &ConversationScope, request: SteerRequest, ) -> Result<(), SteerError> { let meta = self .task_map .values_mut() - .find(|m| m.channel_id == Some(channel_id)) + .find(|m| m.scope.as_ref() == Some(scope)) .ok_or(SteerError::PromptCompleted)?; let tx = meta .steer_tx @@ -744,14 +916,14 @@ impl AgentPool { /// we write directly to the idle agent's matching live-session ledger. pub fn record_successful_steer( &mut self, - channel_id: Uuid, + scope: &ConversationScope, event_id: String, session_id: String, ) -> bool { if let Some(meta) = self .task_map .values_mut() - .find(|meta| meta.channel_id == Some(channel_id)) + .find(|meta| meta.scope.as_ref() == Some(scope)) { meta.successful_steer_deliveries .insert(SuccessfulSteerDelivery { @@ -762,13 +934,13 @@ impl AgentPool { } let Some(agent) = self.agents.iter_mut().flatten().find(|agent| { - agent.state.sessions.get(&channel_id).map(String::as_str) == Some(session_id.as_str()) + agent.state.sessions.get(scope).map(String::as_str) == Some(session_id.as_str()) }) else { return false; }; agent .state - .mark_channel_delivery_success(channel_id, false, [event_id]); + .mark_channel_delivery_success(scope, false, [event_id]); true } @@ -807,80 +979,108 @@ impl AgentPool { &mut self.agents } - /// Remove the session for `channel_id` from all idle agents. + /// Remove **every conversation's** session for `channel_id` from all idle + /// agents. /// - /// Called when the agent is removed from a channel — stale sessions - /// should not be reused. Checked-out agents (in-flight) are not - /// modified; their sessions will fail naturally on the next prompt - /// if the relay rejects the request. + /// Called when the agent is removed from a channel, or on `!rotate` — stale + /// sessions should not be reused. Losing channel access invalidates every + /// thread in that channel, so this is deliberately channel-wide. + /// Checked-out agents (in-flight) are not modified; their sessions will fail + /// naturally on the next prompt if the relay rejects the request. /// - /// Returns the number of sessions invalidated. + /// Returns the number of conversation sessions invalidated. pub fn invalidate_channel_sessions(&mut self, channel_id: Uuid) -> usize { + self.agents + .iter_mut() + .flatten() + .map(|agent| agent.state.invalidate_channel(channel_id)) + .sum() + } + + /// Remove the session for a single conversation from all idle agents. + /// + /// Used by `!rotate`, which is posted inside one thread and rotates only + /// that thread's session — sibling conversations keep theirs. + /// + /// Returns the number of sessions invalidated. + pub fn invalidate_conversation_sessions(&mut self, scope: &ConversationScope) -> usize { let mut count = 0; - for slot in &mut self.agents { - if let Some(agent) = slot.as_mut() { - if agent.state.invalidate_channel(&channel_id) { - count += 1; - } + for agent in self.agents.iter_mut().flatten() { + if agent.state.invalidate_conversation(scope) { + count += 1; } } count } - /// Idle-path model switch: set `desired_model` on the idle agent for - /// `channel_id` and invalidate its session so the next turn re-creates the - /// session under the new model. - /// - /// Pre-cancel guard: the desired model is validated against the agent's - /// cached catalog *before* the session is invalidated, so an unsupported - /// pick is rejected without disturbing the existing session. + /// Complete-pool model switch: require every worker slot to be idle, + /// prevalidate every worker's cached catalog, then atomically update every + /// slot and invalidate target-channel sessions. /// - /// Returns [`IdleSwitchResult`] describing what happened. The model does not - /// take effect — and the panel does not reflect it — until the agent next - /// runs a turn (no live session exists to re-emit `session_config_captured` - /// from an idle agent). This lag is intentional: faking the emit would - /// surface an override the session has not actually applied. + /// The override lives on the worker process, not the conversation. Updating + /// only workers that currently hold a session for `channel_id` would leave + /// another idle worker able to claim the channel later under the old model. + /// Any checked-out/empty slot therefore fails closed before mutation. pub fn switch_idle_agent_model( &mut self, channel_id: Uuid, model_id: &str, ) -> IdleSwitchResult { - let Some(agent) = self - .agents - .iter_mut() - .flatten() - .find(|a| a.state.sessions.contains_key(&channel_id)) - else { + if self.agents.is_empty() || self.agents.iter().any(Option::is_none) { return IdleSwitchResult::NoIdleAgent; - }; - - // Pre-cancel guard against the cached catalog. None = catalog not yet - // populated (no session ever created); defer validation to apply time. - if let Some(caps) = agent.model_capabilities.as_ref() { + } + let all_slots: Vec = (0..self.agents.len()).collect(); + + // Validate every process before mutating any of them. A catalog + // disagreement fails closed rather than leaving slots split. + for &index in &all_slots { + let Some(capabilities) = self.agents[index] + .as_ref() + .and_then(|agent| agent.model_capabilities.as_ref()) + else { + return IdleSwitchResult::CatalogUnavailable; + }; if !model_in_catalog( - &caps.config_options_raw, - caps.available_models_raw.as_ref(), + &capabilities.config_options_raw, + capabilities.available_models_raw.as_ref(), model_id, ) { return IdleSwitchResult::UnsupportedModel; } } - agent.desired_model = Some(model_id.to_string()); - agent.model_overridden = true; - agent.state.invalidate_channel(&channel_id); + for index in all_slots { + if let Some(agent) = self.agents[index].as_mut() { + agent.desired_model = Some(model_id.to_string()); + agent.model_overridden = true; + agent.state.invalidate_channel(channel_id); + } + } + self.effective_model = Some(model_id.to_string()); + self.model_overridden = true; IdleSwitchResult::Switched } + + pub fn effective_model(&self) -> Option<&str> { + self.effective_model.as_deref() + } + + pub fn model_overridden(&self) -> bool { + self.model_overridden + } } /// Outcome of [`AgentPool::switch_idle_agent_model`]. #[derive(Debug, PartialEq, Eq)] pub enum IdleSwitchResult { - /// `desired_model` set and the channel session invalidated. + /// `desired_model` set and the channel's conversation sessions invalidated. Switched, /// Desired model is not in the agent's cached catalog — pick rejected, /// session untouched. UnsupportedModel, + /// At least one idle worker has not created a session yet, so its model + /// catalog is not authoritative. Retry after that worker initializes. + CatalogUnavailable, /// No idle agent available (all checked out / none spawned). NoIdleAgent, } @@ -1080,6 +1280,20 @@ async fn create_session_and_apply_model( false }; + // Adapters such as claude-agent-acp omit the model from usage updates. + // Stamp metrics with the effective model: applied switch first, otherwise + // session/new's current model. Unknown stays unknown rather than guessing. + let metric_model = if switch_succeeded { + agent.desired_model.clone() + } else { + resp.raw + .get("models") + .and_then(|models| models.get("currentModelId")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }; + agent.acp.set_metric_model_fallback(metric_model); + // Emit session config for desktop consumption (config bridge tier 1b). // Emitted AFTER desired_model resolution so the desktop caches the // post-switch state. modelOverridden reflects whether the switch actually @@ -1465,8 +1679,8 @@ fn send_prompt_result( /// Core async function spawned for each prompt. /// /// Lifecycle: -/// 1. Resolve or create a session (channel or heartbeat). -/// 2. Send `initial_message` on new channel sessions (if configured). +/// 1. Resolve or create a session (conversation or heartbeat). +/// 2. Send `initial_message` on new conversation sessions (if configured). /// 3. Fetch conversation context if needed (thread reply or DM). /// 4. Build the prompt text from batch + context. /// 5. Send the actual prompt with turn timeout. @@ -1485,13 +1699,13 @@ pub async fn run_prompt_task( ) { // Is this a channel prompt or a heartbeat? let source = match &batch { - Some(b) => PromptSource::Channel(b.channel_id), + Some(b) => PromptSource::Channel(b.scope.clone()), None => PromptSource::Heartbeat, }; - let observer_channel_id = match &source { - PromptSource::Channel(channel_id) => Some(*channel_id), - PromptSource::Heartbeat => None, - }; + // Observer and metric frames stay channel-keyed: the desktop groups turns by + // channel, and a thread root is not a channel. Conversation identity is + // carried separately where it is additive. + let observer_channel_id = source.channel_id(); let turn_started_at = chrono::Utc::now().to_rfc3339(); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, @@ -1511,6 +1725,14 @@ pub async fn run_prompt_task( PromptSource::Heartbeat => "heartbeat", }, "triggeringEventIds": triggering_event_ids, + // Additive: which conversation inside the channel this turn belongs + // to. `null` for DMs (one conversation per DM channel) and + // heartbeats. Existing consumers key off `context.channelId` and are + // unaffected. + "conversationRoot": match &source { + PromptSource::Channel(scope) => scope.thread_root(), + PromptSource::Heartbeat => None, + }, }), ); @@ -1562,6 +1784,32 @@ pub async fn run_prompt_task( .unwrap_or_default(); let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone()); + // ACP has no portable session-close method. Once this worker has created + // the bounded number of conversation sessions, recycle the whole child + // before admitting another unique root. The current batch was not prompted, + // so it is always returned (including Drop mode) and the main loop replaces + // the child without charging crash or queue-retry budget. + if let PromptSource::Channel(scope) = &source { + if agent.state.requires_process_recycle(scope) { + tracing::info!( + agent = agent.index, + limit = MAX_CONVERSATION_SESSIONS_PER_AGENT, + "conversation-session cap reached — recycling ACP child process" + ); + agent.acp.shutdown().await; + agent.state.invalidate_all(); + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::ProcessRecycle, + batch, + ); + return; + } + } + // // Core memory is delivered inside the system prompt the harness already // builds (system role for protocol >= 2, the `[System]` user-message @@ -1584,15 +1832,18 @@ pub async fn run_prompt_task( // // Per Tyler's locked spec: NO mid-session refreshes. Re-fetch only // happens when a session is invalidated and recreated (see - // `SessionState::invalidate_channel`). + // `SessionState::invalidate_conversation`). // // Operator opt-out: `--no-memory` / `BUZZ_ACP_NO_MEMORY` skips the fetch. if ctx.memory_enabled { - if let (PromptSource::Channel(cid), Some(owner_pk)) = + if let (PromptSource::Channel(scope), Some(owner_pk)) = (&source, ctx.agent_owner_pubkey.as_ref()) { - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { + // Core memory is channel-scoped: the cache key is the channel, but + // "is this a new session" is a per-conversation question. + let channel_id = scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + if is_new_channel_session && !agent.state.core_sections.contains_key(&channel_id) { // Bounded — we'd rather start the session with no core hint // than block session creation on a stalled relay. const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); @@ -1606,7 +1857,8 @@ pub async fn run_prompt_task( Err(_) => { tracing::warn!( target: "engram::core", - channel = %cid, + channel = %channel_id, + conversation = %scope, timeout_ms = CORE_FETCH_TIMEOUT.as_millis() as u64, "core fetch timed out — emitting no section" ); @@ -1616,11 +1868,12 @@ pub async fn run_prompt_task( if let Some(rendered) = section { tracing::info!( target: "engram::core", - channel = %cid, + channel = %channel_id, + conversation = %scope, section_len = rendered.len(), "injected NIP-AE core section into system prompt" ); - agent.state.core_sections.insert(*cid, rendered); + agent.state.core_sections.insert(channel_id, rendered); } } } @@ -1644,51 +1897,54 @@ pub async fn run_prompt_task( // canvas DM check uses — see `resolve_new_session_channel_context`. let mut title_channel: Option = None; let mut origin_channel_type: Option = None; - if let PromptSource::Channel(cid) = &source { - let is_new_channel_session = !agent.state.sessions.contains_key(cid); - let needs_canvas = is_new_channel_session && !agent.state.canvas_sections.contains_key(cid); + if let PromptSource::Channel(scope) = &source { + let channel_id = scope.channel_id(); + let is_new_channel_session = !agent.state.sessions.contains_key(scope); + let needs_canvas = + is_new_channel_session && !agent.state.canvas_sections.contains_key(&channel_id); if is_new_channel_session { let (is_dm, resolved_channel, resolved_channel_type) = - resolve_new_session_channel_context(&ctx.channel_info, *cid).await; + resolve_new_session_channel_context(&ctx.channel_info, channel_id).await; title_channel = resolved_channel; origin_channel_type = resolved_channel_type; if let Some(owner) = ctx.agent_owner_pubkey.as_ref() { huddle_instructions = - fetch_huddle_instructions(*cid, owner, &ctx.rest_client).await; + fetch_huddle_instructions(channel_id, owner, &ctx.rest_client).await; } // A confirmed DM never receives a canvas section; an undeterminable // channel type fails closed as a DM for the same reason. if needs_canvas && !is_dm { - if let Some(section) = fetch_canvas_section(*cid, &ctx.rest_client).await { - pending_canvas = Some((*cid, section)); + if let Some(section) = fetch_canvas_section(channel_id, &ctx.rest_client).await { + pending_canvas = Some((channel_id, section)); } } } } // The core section to fold into the system prompt for this turn's session. - // Channel-scoped; heartbeats carry no owner core. + // Channel-scoped (every thread in a channel gets the same owner core); + // heartbeats carry no owner core. let agent_core: Option = match &source { - PromptSource::Channel(cid) => agent.state.core_sections.get(cid).cloned(), + PromptSource::Channel(scope) => agent.state.core_sections.get(&scope.channel_id()).cloned(), PromptSource::Heartbeat => None, }; // The canvas metadata section — channel-scoped, absent for heartbeats/DMs. // Prefer the committed cache; fall back to pending (for new sessions being created now). let agent_canvas: Option = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .canvas_sections - .get(cid) + .get(&scope.channel_id()) .cloned() .or_else(|| pending_canvas.as_ref().map(|(_, s)| s.clone())), PromptSource::Heartbeat => None, }; let (session_id, is_new_session) = match &source { - PromptSource::Channel(cid) => { - if let Some(sid) = agent.state.sessions.get(cid) { - (sid.clone(), false) + PromptSource::Channel(scope) => { + if let Some(sid) = agent.state.session_id(scope) { + (sid, false) } else { // The title is channel-qualified (`Agent · #channel`) so one // agent in several channels doesn't produce identical session @@ -1702,7 +1958,7 @@ pub async fn run_prompt_task( huddle_instructions: huddle_instructions.as_deref(), canvas: agent_canvas.as_deref(), name: title_channel.as_deref(), - id: Some(*cid), + id: Some(scope.channel_id()), channel_type: origin_channel_type.as_deref(), }, ) @@ -1711,13 +1967,13 @@ pub async fn run_prompt_task( Ok(sid) => { tracing::info!( target: "pool::session", - "created session {sid} for channel {cid}" + "created session {sid} for conversation {scope}" ); - agent.state.sessions.insert(*cid, sid.clone()); + agent.state.insert_session(scope.clone(), sid.clone()); agent .state .deliveries - .insert(*cid, ChannelDeliveryState::default()); + .insert(scope.clone(), ChannelDeliveryState::default()); // Seed a zero usage baseline: buzz-acp spawned this session // so prior usage is zero by definition — first turn is reliable. agent.acp.notify_session_spawned(&sid); @@ -1847,20 +2103,21 @@ pub async fn run_prompt_task( // sessions created before this field existed fail safe by behaving as // undelivered once, rather than silently omitting standing context. let mut standing_context_sent = match &source { - PromptSource::Channel(cid) => agent + PromptSource::Channel(scope) => agent .state .deliveries - .get(cid) + .get(scope) .is_some_and(|delivery| delivery.standing_context_sent), PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, }; if is_new_session { - if let (PromptSource::Channel(cid), Some(ref initial_msg)) = (&source, &ctx.initial_message) + if let (PromptSource::Channel(scope), Some(ref initial_msg)) = + (&source, &ctx.initial_message) { tracing::info!( target: "pool::session", - "sending initial_message to session {session_id} for channel {cid}" + "sending initial_message to session {session_id} for conversation {scope}" ); let init_msg = prepend_standing_for_legacy( if agent.has_system_prompt_support() { @@ -1885,19 +2142,19 @@ pub async fn run_prompt_task( Ok(stop_reason) => { tracing::info!( target: "pool::session", - "initial_message complete for channel {cid}: {stop_reason:?}" + "initial_message complete for conversation {scope}: {stop_reason:?}" ); // The legacy agent has its standing context now; the turn // prompt below must not repeat it. Every other arm returns. standing_context_sent = true; if !agent.has_system_prompt_support() { - agent.state.mark_channel_delivery_success(*cid, true, []); + agent.state.mark_channel_delivery_success(scope, true, []); } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( &ctx, usage, - Some(*cid), + Some(scope.channel_id()), &session_id, &format!("{turn_id}:initial"), Some(acp_stop_to_core(&stop_reason)), @@ -1919,7 +2176,7 @@ pub async fn run_prompt_task( Err(AcpError::IdleTimeout(_)) => { tracing::warn!( target: "pool::session", - "initial_message idle timeout ({}s) for channel {cid} — cancelling", + "initial_message idle timeout ({}s) for conversation {scope} — cancelling", ctx.idle_timeout.as_secs() ); match agent @@ -1932,7 +2189,7 @@ pub async fn run_prompt_task( publish_agent_turn_metric( &ctx, usage, - Some(*cid), + Some(scope.channel_id()), &session_id, &format!("{turn_id}:initial"), Some(acp_stop_to_core(&stop_reason)), @@ -1974,7 +2231,7 @@ pub async fn run_prompt_task( let recently_active = silence < RECENT_ACTIVITY_WINDOW; tracing::error!( target: "pool::session", - "hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) during initial_message for channel {cid} — agent process is unrecoverable", + "hard timeout ({}s cap, silence {silence:?}, recently_active={recently_active}) during initial_message for conversation {scope} — agent process is unrecoverable", ctx.max_turn_duration.as_secs() ); agent.state.invalidate_all(); @@ -1991,7 +2248,7 @@ pub async fn run_prompt_task( Err(e) => { tracing::error!( target: "pool::session", - "initial_message failed for channel {cid}: {e} — invalidating session" + "initial_message failed for conversation {scope}: {e} — invalidating session" ); agent.state.invalidate(&source); send_prompt_result( @@ -2043,7 +2300,7 @@ pub async fn run_prompt_task( } else if let Some(ref b) = batch { // Build prompt from batch with context enrichment. // Try startup cache first; lazy-fetch via REST for dynamic channels. - let channel_info = ctx.channel_info.resolve(b.channel_id).await; + let channel_info = ctx.channel_info.resolve(b.channel_id()).await; let conversation_context = if ctx.context_message_limit > 0 { fetch_conversation_context(b, &channel_info, &ctx).await @@ -2059,7 +2316,7 @@ pub async fn run_prompt_task( let delivered_ids = agent .state .deliveries - .get(&b.channel_id) + .get(&b.scope) .map(|delivery| &delivery.delivered_event_ids) .cloned() .unwrap_or_default(); @@ -2089,7 +2346,7 @@ pub async fn run_prompt_task( if let Some(ref cmd) = slash_command { tracing::info!( target: "pool::prompt", - channel = %b.channel_id, + channel = %b.channel_id(), command = %cmd, "slash-command pass-through" ); @@ -2212,14 +2469,7 @@ pub async fn run_prompt_task( ) => result, mode = rx => { let control_signal = mode.unwrap_or(ControlSignal::Cancel); - // Land the model switch before any cancel/requeue work: setting - // `desired_model` here means the fresh session created by the - // requeued turn (busy) or the next turn (already-completed) - // applies the new model. Runtime-only — never persisted. - if let ControlSignal::SwitchModel(ref model_id) = control_signal { - agent.desired_model = Some(model_id.clone()); - agent.model_overridden = true; - } + // Control signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. if agent.acp.has_in_flight_prompt() { @@ -2307,10 +2557,7 @@ pub async fn run_prompt_task( // and last_prompt_id was cleared by the success path. // // MUST send a PromptResult or the main loop deadlocks. - if matches!( - control_signal, - ControlSignal::Rotate | ControlSignal::SwitchModel(_) - ) { + if matches!(control_signal, ControlSignal::Rotate) { tracing::debug!( target: "pool::prompt", "rotate/switch signal arrived but turn already completed — invalidating session" @@ -2321,10 +2568,10 @@ pub async fn run_prompt_task( "control signal arrived but turn already completed — treating as success" ); } - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); agent.state.mark_channel_delivery_success( - *cid, + scope, standing_sent, pending_delivered_event_ids.iter().cloned(), ); @@ -2363,10 +2610,10 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); - if let PromptSource::Channel(cid) = &source { + if let PromptSource::Channel(scope) = &source { let standing_sent = !agent.has_system_prompt_support(); agent.state.mark_channel_delivery_success( - *cid, + scope, standing_sent, pending_delivered_event_ids.iter().cloned(), ); @@ -2383,8 +2630,8 @@ pub async fn run_prompt_task( let limit = ctx.max_turns_per_session; if limit > 0 { match &source { - PromptSource::Channel(cid) => { - let count = agent.state.turn_counts.entry(*cid).or_insert(0); + PromptSource::Channel(scope) => { + let count = agent.state.turn_counts.entry(scope.clone()).or_insert(0); *count += 1; *count >= limit } @@ -3025,7 +3272,7 @@ async fn fetch_conversation_context( let tags = crate::queue::parse_thread_tags(&last_event.event); if let Some(root_id) = tags.root_event_id { return fetch_thread_context( - batch.channel_id, + batch.channel_id(), &root_id, limit, ctx.agent_keys.public_key(), @@ -3036,7 +3283,7 @@ async fn fetch_conversation_context( // DM non-reply: fetch recent conversation history. if is_dm { - return fetch_dm_context(batch.channel_id, limit, &ctx.rest_client).await; + return fetch_dm_context(batch.channel_id(), limit, &ctx.rest_client).await; } None @@ -3691,7 +3938,7 @@ fn requeue_cancelled_batch( ) -> Option { let reason = match signal { ControlSignal::Steer => CancelReason::Steer, - ControlSignal::Interrupt | ControlSignal::SwitchModel(_) => CancelReason::Interrupt, + ControlSignal::Interrupt => CancelReason::Interrupt, // Cancel/Rotate discard the batch — no merged re-prompt. ControlSignal::Cancel | ControlSignal::Rotate => return None, }; @@ -3760,7 +4007,7 @@ fn classify_control_cancel_failure( /// Shared by the turn-start and turn-stop lines so a log can be read as pairs. fn prompt_label(source: &PromptSource) -> String { match source { - PromptSource::Channel(cid) => format!("channel {cid}"), + PromptSource::Channel(scope) => format!("conversation {scope}"), PromptSource::Heartbeat => "heartbeat".to_string(), } } @@ -4402,6 +4649,10 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + fn dm_scope(channel_id: Uuid) -> ConversationScope { + ConversationScope::dm(channel_id) + } + fn test_mcp_server() -> McpServer { McpServer { name: "dev".into(), @@ -5528,7 +5779,7 @@ mod tests { .unwrap(); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { - channel_id: Uuid::new_v4(), + scope: dm_scope(Uuid::new_v4()), events: vec![crate::queue::BatchEvent { event, prompt_tag: "@mention".into(), @@ -5752,11 +6003,11 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(dm_scope(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(dm_scope(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); ctx.base_prompt = Some("standing-once"); @@ -5769,7 +6020,7 @@ done"# .unwrap(); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id, + scope: dm_scope(channel_id), events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -5796,7 +6047,7 @@ done"# PromptOutcome::Ok(StopReason::EndTurn) )), } - let delivery = &result.agent.state.deliveries[&channel_id]; + let delivery = &result.agent.state.deliveries[&dm_scope(channel_id)]; assert_eq!( delivery.standing_context_sent, turn >= 2, @@ -5851,7 +6102,7 @@ done"# .sign_with_keys(&keys) .unwrap(); let merged_batch = FlushBatch { - channel_id, + scope: dm_scope(channel_id), events: vec![crate::queue::BatchEvent { event: new_event.clone(), prompt_tag: "test".into(), @@ -5865,7 +6116,7 @@ done"# cancel_reason: Some(crate::queue::CancelReason::Steer), }; let next_batch = FlushBatch { - channel_id, + scope: dm_scope(channel_id), events: vec![crate::queue::BatchEvent { event: next_event, prompt_tag: "test".into(), @@ -5924,11 +6175,11 @@ done"# agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(dm_scope(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(dm_scope(channel_id), ChannelDeliveryState::default()); let mut ctx = make_prompt_context_no_owner(); ctx.context_message_limit = 10; @@ -5970,7 +6221,7 @@ done"# )); agent = result.agent; } - let delivery = &agent.state.deliveries[&channel_id]; + let delivery = &agent.state.deliveries[&dm_scope(channel_id)]; assert!(delivery.delivered_event_ids.contains(&carry_over_id)); assert!(delivery.delivered_event_ids.contains(&new_event_id)); agent.acp.shutdown().await; @@ -6017,7 +6268,7 @@ done"# .sign_with_keys(&keys) .unwrap(); let batch = FlushBatch { - channel_id, + scope: dm_scope(channel_id), events: vec![crate::queue::BatchEvent { event: trigger, prompt_tag: "test".into(), @@ -6074,22 +6325,22 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent .state .sessions - .insert(channel_id, "live-session".into()); + .insert(dm_scope(channel_id), "live-session".into()); agent .state .deliveries - .insert(channel_id, ChannelDeliveryState::default()); + .insert(dm_scope(channel_id), ChannelDeliveryState::default()); // Model the adversarial ordering: the task result has already retired // its TaskMeta and returned the agent before the successful ack arrives. let mut pool = AgentPool::from_slots(vec![Some(agent)]); assert!(pool.record_successful_steer( - channel_id, + &dm_scope(channel_id), steered_event_id.clone(), "live-session".into(), )); let agent = pool - .try_claim(Some(channel_id)) + .try_claim(Some(&dm_scope(channel_id))) .expect("claim returned agent"); let mut ctx = make_prompt_context_no_owner(); @@ -6148,11 +6399,11 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn delivery_state_commits_only_when_explicitly_marked_successful() { - let channel = Uuid::new_v4(); + let channel = dm_scope(Uuid::new_v4()); let mut state = SessionState::default(); state .deliveries - .insert(channel, ChannelDeliveryState::default()); + .insert(channel.clone(), ChannelDeliveryState::default()); // Building or attempting a prompt does not mutate delivery state. let delivery = state.deliveries.get(&channel).unwrap(); @@ -6160,7 +6411,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(delivery.delivered_event_ids.is_empty()); state.mark_channel_delivery_success( - channel, + &channel, true, ["trigger".to_string(), "context".to_string()], ); @@ -6171,18 +6422,18 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn delivery_state_is_cleared_on_rotation_and_restarts_empty() { - let channel = Uuid::new_v4(); + let channel = dm_scope(Uuid::new_v4()); let mut state = SessionState::default(); - state.sessions.insert(channel, "old-session".into()); - state.mark_channel_delivery_success(channel, true, ["old-event".to_string()]); + state.sessions.insert(channel.clone(), "old-session".into()); + state.mark_channel_delivery_success(&channel, true, ["old-event".to_string()]); - assert!(state.invalidate_channel(&channel)); + assert_eq!(state.invalidate_channel(channel.channel_id()), 1); assert!(!state.deliveries.contains_key(&channel)); - state.sessions.insert(channel, "new-session".into()); + state.sessions.insert(channel.clone(), "new-session".into()); state .deliveries - .insert(channel, ChannelDeliveryState::default()); + .insert(channel.clone(), ChannelDeliveryState::default()); let delivery = state.deliveries.get(&channel).unwrap(); assert!(!delivery.standing_context_sent); assert!(delivery.delivered_event_ids.is_empty()); @@ -6286,25 +6537,131 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert_eq!(pct_encode(" "), "%20"); } - fn make_state() -> (SessionState, Uuid, Uuid) { - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + #[tokio::test] + async fn session_cap_recycle_preserves_unprompted_batch_in_queue_and_drop_modes() { + for dedup_mode in [DedupMode::Queue, DedupMode::Drop] { + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("spawn capped test child"); + let mut agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "capped-test-agent".into(), + goose_system_prompt_supported: None, + protocol_version: 1, + }; + let channel_id = Uuid::new_v4(); + for index in 0..MAX_CONVERSATION_SESSIONS_PER_AGENT { + let scope = ConversationScope::thread(channel_id, format!("{index:064x}")); + agent + .state + .insert_session(scope, format!("session-{index}")); + } + let new_scope = ConversationScope::thread(channel_id, "f".repeat(64)); + let event = EventBuilder::new(Kind::Custom(9), "must survive recycle") + .sign_with_keys(&Keys::generate()) + .unwrap(); + let event_id = event.id.to_hex(); + let batch = FlushBatch { + scope: new_scope.clone(), + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let mut ctx = make_prompt_context_no_owner(); + ctx.dedup_mode = dedup_mode; + let (result_tx, mut result_rx) = mpsc::unbounded_channel(); + + run_prompt_task( + agent, + Some(batch), + None, + Arc::new(ctx), + result_tx, + None, + format!("recycle-{dedup_mode:?}"), + ) + .await; + let result = result_rx.recv().await.expect("recycle result"); + + assert!( + !matches!(result.outcome, PromptOutcome::AgentExited), + "session-cap recycle is intentional, not a crash in {dedup_mode:?} mode" + ); + let preserved = result + .batch + .expect("unprompted triggering batch survives intentional recycle"); + assert_eq!(preserved.scope, new_scope); + assert_eq!(preserved.events.len(), 1); + assert_eq!(preserved.events[0].event.id.to_hex(), event_id); + } + } + + #[test] + fn inactive_session_lru_is_bounded_without_evicting_current_scope() { + let mut state = SessionState::default(); + let channel_id = Uuid::new_v4(); + let scopes: Vec<_> = (0..MAX_CONVERSATION_SESSIONS_PER_AGENT) + .map(|index| ConversationScope::thread(channel_id, format!("{index:064x}"))) + .collect(); + for (index, scope) in scopes.iter().enumerate() { + state.insert_session(scope.clone(), format!("session-{index}")); + } + assert_eq!(state.sessions.len(), MAX_CONVERSATION_SESSIONS_PER_AGENT); + assert!(!state.requires_process_recycle(&scopes[0])); + let newest = ConversationScope::thread(channel_id, "f".repeat(64)); + assert!(state.requires_process_recycle(&newest)); + + assert_eq!(state.session_id(&scopes[0]).as_deref(), Some("session-0")); + state.insert_session(newest.clone(), "newest".into()); + + assert_eq!(state.sessions.len(), MAX_CONVERSATION_SESSIONS_PER_AGENT); + assert!( + state.sessions.contains_key(&scopes[0]), + "recently used session survives" + ); + assert!( + !state.sessions.contains_key(&scopes[1]), + "oldest inactive session is evicted" + ); + assert_eq!( + state.sessions.get(&newest).map(String::as_str), + Some("newest") + ); + } + + fn make_state() -> (SessionState, ConversationScope, ConversationScope) { + let ch_a = dm_scope(Uuid::new_v4()); + let ch_b = dm_scope(Uuid::new_v4()); let mut s = SessionState::default(); - s.sessions.insert(ch_a, "sess-a".into()); - s.sessions.insert(ch_b, "sess-b".into()); - s.turn_counts.insert(ch_a, 5); - s.turn_counts.insert(ch_b, 3); - s.core_sections.insert(ch_a, "core-a".into()); - s.core_sections.insert(ch_b, "core-b".into()); + s.sessions.insert(ch_a.clone(), "sess-a".into()); + s.sessions.insert(ch_b.clone(), "sess-b".into()); + s.turn_counts.insert(ch_a.clone(), 5); + s.turn_counts.insert(ch_b.clone(), 3); + s.core_sections.insert(ch_a.channel_id(), "core-a".into()); + s.core_sections.insert(ch_b.channel_id(), "core-b".into()); s.deliveries.insert( - ch_a, + ch_a.clone(), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-a".into()]), }, ); s.deliveries.insert( - ch_b, + ch_b.clone(), ChannelDeliveryState { standing_context_sent: true, delivered_event_ids: HashSet::from(["event-b".into()]), @@ -6322,17 +6679,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(ch_a.clone()), &ControlSignal::Rotate, ); assert!(!s.sessions.contains_key(&ch_a)); assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); - assert!(!s.has_channel_state(&ch_a)); + assert!(!s.core_sections.contains_key(&ch_a.channel_id())); + assert!(!s.has_conversation_state(&ch_a)); assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.core_sections.get(&ch_b.channel_id()).unwrap(), "core-b"); assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); } @@ -6343,29 +6700,29 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" apply_completed_before_control_signal( &mut s, - &PromptSource::Channel(ch_a), + &PromptSource::Channel(ch_a.clone()), &ControlSignal::Cancel, ); assert_eq!(s.sessions.get(&ch_a).unwrap(), "sess-a"); assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&ch_a.channel_id()).unwrap(), "core-a"); assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); } #[test] fn test_invalidate_channel_clears_session_and_turn_count() { let (mut s, ch_a, ch_b) = make_state(); - s.invalidate(&PromptSource::Channel(ch_a)); + s.invalidate(&PromptSource::Channel(ch_a.clone())); assert!(!s.sessions.contains_key(&ch_a)); assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); - assert!(!s.has_channel_state(&ch_a)); + assert!(!s.core_sections.contains_key(&ch_a.channel_id())); + assert!(!s.has_conversation_state(&ch_a)); // ch_b untouched assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.core_sections.get(&ch_b.channel_id()).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -6383,8 +6740,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert_eq!(s.sessions.len(), 2); assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.core_sections.get(&ch_a.channel_id()).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&ch_b.channel_id()).unwrap(), "core-b"); } #[test] @@ -6403,7 +6760,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_invalidate_nonexistent_channel_is_noop() { let (mut s, ch_a, ch_b) = make_state(); - let ghost = Uuid::new_v4(); + let ghost = dm_scope(Uuid::new_v4()); s.invalidate(&PromptSource::Channel(ghost)); // Everything still intact. @@ -6411,8 +6768,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert_eq!(s.turn_counts.len(), 2); assert_eq!(*s.turn_counts.get(&ch_a).unwrap(), 5); assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_a).unwrap(), "core-a"); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.core_sections.get(&ch_a.channel_id()).unwrap(), "core-a"); + assert_eq!(s.core_sections.get(&ch_b.channel_id()).unwrap(), "core-b"); } #[test] @@ -6427,15 +6784,15 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_invalidate_channel_returns_true_when_session_existed() { let (mut s, ch_a, ch_b) = make_state(); - assert!(s.invalidate_channel(&ch_a)); + assert_eq!(s.invalidate_channel(ch_a.channel_id()), 1); assert!(!s.sessions.contains_key(&ch_a)); assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); - assert!(!s.has_channel_state(&ch_a)); + assert!(!s.core_sections.contains_key(&ch_a.channel_id())); + assert!(!s.has_conversation_state(&ch_a)); // ch_b untouched assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); + assert_eq!(s.core_sections.get(&ch_b.channel_id()).unwrap(), "core-b"); // heartbeat untouched assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); @@ -6445,7 +6802,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn test_invalidate_channel_returns_false_when_no_session() { let (mut s, _ch_a, _ch_b) = make_state(); let ghost = Uuid::new_v4(); - assert!(!s.invalidate_channel(&ghost)); + assert_eq!(s.invalidate_channel(ghost), 0); // Nothing changed. assert_eq!(s.sessions.len(), 2); assert_eq!(s.turn_counts.len(), 2); @@ -6456,37 +6813,17 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" // Simulates handle_prompt_result: channels removed while agent // was checked out should have both sessions and turn_counts stripped. let (mut s, ch_a, ch_b) = make_state(); - let removed = vec![ch_a]; + let removed = vec![ch_a.channel_id()]; for ch in &removed { - s.invalidate_channel(ch); + s.invalidate_channel(*ch); } assert!(!s.sessions.contains_key(&ch_a)); assert!(!s.turn_counts.contains_key(&ch_a)); - assert!(!s.core_sections.contains_key(&ch_a)); - assert!(!s.has_channel_state(&ch_a)); - assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); - assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); - assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); - } - - // ── ControlSignal::SwitchModel (Phase 3a, Option ii) ───────────────────── - - #[test] - fn test_switch_model_after_natural_completion_invalidates_channel_state() { - let (mut s, ch_a, ch_b) = make_state(); - - // SwitchModel must invalidate just like Rotate so the requeued turn - // re-creates a fresh session that re-applies the new desired_model. - apply_completed_before_control_signal( - &mut s, - &PromptSource::Channel(ch_a), - &ControlSignal::SwitchModel("gpt-5".into()), - ); - - assert!(!s.has_channel_state(&ch_a)); - // ch_b untouched — the switch is channel-scoped. + assert!(!s.core_sections.contains_key(&ch_a.channel_id())); + assert!(!s.has_conversation_state(&ch_a)); assert_eq!(s.sessions.get(&ch_b).unwrap(), "sess-b"); assert_eq!(*s.turn_counts.get(&ch_b).unwrap(), 3); + assert_eq!(s.core_sections.get(&ch_b.channel_id()).unwrap(), "core-b"); } // ── requeue_cancelled_batch ──────────────────────────────────────────── @@ -6503,7 +6840,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" .sign_with_keys(&keys) .unwrap(); FlushBatch { - channel_id, + scope: dm_scope(channel_id), events: vec![crate::queue::BatchEvent { event, prompt_tag: "test".into(), @@ -6519,10 +6856,6 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let cases = [ (ControlSignal::Steer, Some(CancelReason::Steer)), (ControlSignal::Interrupt, Some(CancelReason::Interrupt)), - ( - ControlSignal::SwitchModel("gpt-5".into()), - Some(CancelReason::Interrupt), - ), (ControlSignal::Cancel, None), (ControlSignal::Rotate, None), ]; @@ -6565,6 +6898,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn assert_outcome_matches(outcome: &PromptOutcome, expected: &str) { let label = match outcome { PromptOutcome::AgentExited => "AgentExited", + PromptOutcome::ProcessRecycle => "ProcessRecycle", PromptOutcome::Timeout(TimeoutKind::Idle) => "Timeout(Idle)", PromptOutcome::Timeout(TimeoutKind::Hard { .. }) => "Timeout(Hard)", PromptOutcome::CancelDrainTimeout(_) => "CancelDrainTimeout", @@ -6633,15 +6967,6 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" expected_reason: None, invalidate_all: false, }, - Case { - name: "CancelDrainTimeout + SwitchModel preserves batch with Interrupt reason", - error: || AcpError::CancelDrainTimeout(CONTROL_CANCEL_GRACE), - signal: ControlSignal::SwitchModel("gpt-5".to_string()), - expected_outcome: "CancelDrainTimeout", - batch_preserved: true, - expected_reason: Some(CancelReason::Interrupt), - invalidate_all: false, - }, Case { name: "unexpected HardTimeout cannot become Timeout(Hard)", error: || AcpError::HardTimeout { @@ -7704,15 +8029,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_invalidate_channel_clears_canvas_section() { let ch = Uuid::new_v4(); + let scope_a = ConversationScope::thread(ch, "root-a"); + let scope_b = ConversationScope::thread(ch, "root-b"); let mut s = SessionState::default(); - s.sessions.insert(ch, "sess".into()); + s.sessions.insert(scope_a.clone(), "sess-a".into()); + s.sessions.insert(scope_b.clone(), "sess-b".into()); s.canvas_sections .insert(ch, "[Channel Canvas]\nrev abc".into()); - s.invalidate_channel(&ch); + assert_eq!(s.invalidate_channel(ch), 2); assert!(!s.canvas_sections.contains_key(&ch)); - assert!(!s.sessions.contains_key(&ch)); + assert!(!s.sessions.contains_key(&scope_a)); + assert!(!s.sessions.contains_key(&scope_b)); } #[test] @@ -7722,7 +8051,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let mut s = SessionState::default(); s.canvas_sections.insert(ch_a, "canvas-a".into()); s.canvas_sections.insert(ch_b, "canvas-b".into()); - s.sessions.insert(ch_a, "sess-a".into()); + s.sessions.insert(dm_scope(ch_a), "sess-a".into()); s.invalidate_all(); @@ -7735,23 +8064,24 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let ch_a = Uuid::new_v4(); let ch_b = Uuid::new_v4(); let mut s = SessionState::default(); - s.sessions.insert(ch_a, "sess-a".into()); - s.sessions.insert(ch_b, "sess-b".into()); + s.sessions.insert(dm_scope(ch_a), "sess-a".into()); + s.sessions.insert(dm_scope(ch_b), "sess-b".into()); s.canvas_sections.insert(ch_a, "canvas-a".into()); s.canvas_sections.insert(ch_b, "canvas-b".into()); - s.invalidate_channel(&ch_a); + s.invalidate_channel(ch_a); assert!(!s.canvas_sections.contains_key(&ch_a)); assert_eq!(s.canvas_sections.get(&ch_b).unwrap(), "canvas-b"); } #[test] - fn test_has_channel_state_true_when_only_canvas_section_present() { + fn test_canvas_section_can_exist_without_conversation_state() { let ch = Uuid::new_v4(); let mut s = SessionState::default(); s.canvas_sections.insert(ch, "canvas".into()); - assert!(s.has_channel_state(&ch)); + assert!(s.canvas_sections.contains_key(&ch)); + assert!(!s.has_conversation_state(&dm_scope(ch))); } // ── canvas_section_from_query_response ─────────────────────────────────── diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b0f0fa248e3..0851dcce8f9 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1,16 +1,19 @@ //! Event queue state machine for buzz-acp. //! -//! Manages per-channel event queues with per-channel in-flight tracking. -//! When the harness is ready to prompt the agent, it flushes the channel with -//! the oldest pending event, draining ALL events for that channel into a single -//! batch. Multiple channels can be in-flight simultaneously; each channel is -//! independent. +//! Manages per-[conversation](crate::conversation::ConversationScope) event +//! queues with per-conversation in-flight tracking. When the harness is ready to +//! prompt the agent, it flushes the conversation with the oldest pending event, +//! draining ALL events for that conversation into a single batch. Multiple +//! conversations can be in-flight simultaneously; each is independent — two +//! threads in the same group channel never share a batch, an in-flight slot, a +//! retry backoff, or a cancelled-batch carryover. //! //! ## Dedup modes //! -//! - **Drop** (default) — while a prompt is in-flight for channel C, new events -//! for channel C are silently dropped (debug-logged). Events for other channels -//! still queue normally. +//! - **Drop** (default) — while a prompt is in-flight for conversation C, new +//! events for C are silently dropped (debug-logged). Events for other +//! conversations (including sibling threads in the same channel) still queue +//! normally. //! - **Queue** — all events accumulate; batched on the next flush cycle. use nostr::{Event, ToBech32}; @@ -19,9 +22,17 @@ use std::time::{Duration, Instant}; use uuid::Uuid; use crate::config::DedupMode; +use crate::conversation::ConversationScope; -/// Maximum events queued per channel before oldest events are dropped. +/// Maximum events queued per conversation before oldest events are dropped. +const MAX_PENDING_PER_CONVERSATION: usize = 500; +/// Aggregate pending-event cap retained from the former channel-keyed queue. const MAX_PENDING_PER_CHANNEL: usize = 500; +/// Global buffered-event backstop across all channels and conversation roots. +const MAX_GLOBAL_BUFFERED_EVENTS: usize = 5_000; +/// Global cap on conversation scopes with queue state. New roots fail closed at +/// the boundary; existing queued or in-flight scopes are never evicted. +const MAX_GLOBAL_CONVERSATION_SCOPES: usize = 1_024; /// Maximum events drained into a single batch. const MAX_BATCH_EVENTS: usize = 50; @@ -44,7 +55,8 @@ const DEFAULT_IN_FLIGHT_DEADLINE_SECS: u64 = 7300; /// An event waiting in the queue. #[derive(Debug, Clone)] pub struct QueuedEvent { - pub channel_id: Uuid, + /// Conversation this event belongs to — the queue's unit of isolation. + pub scope: ConversationScope, pub event: Event, pub received_at: Instant, /// Tag identifying which rule (or mode) matched this event. @@ -75,7 +87,8 @@ pub enum CancelReason { /// A batch of events to prompt the agent with. #[derive(Debug, Clone)] pub struct FlushBatch { - pub channel_id: Uuid, + /// Conversation this batch was drained from. + pub scope: ConversationScope, pub events: Vec, /// Events from a cancelled batch that triggered this re-prompt. /// Empty for normal (non-cancel) batches. When non-empty, `format_prompt()` @@ -89,84 +102,100 @@ pub struct FlushBatch { pub cancel_reason: Option, } -/// Per-channel event queue with per-channel in-flight enforcement. +impl FlushBatch { + /// The Buzz channel this batch's conversation lives in. + pub fn channel_id(&self) -> Uuid { + self.scope.channel_id() + } +} + +/// Per-conversation event queue with per-conversation in-flight enforcement. /// /// # State Machine /// +/// Every table is keyed by [`ConversationScope`], not by channel: two threads in +/// the same group channel are fully independent (separate batches, separate +/// in-flight slot, separate retry/cancel state). Channel-wide teardown +/// ([`drain_channel`](Self::drain_channel)) is the one operation that +/// deliberately iterates every scope belonging to a channel. +/// /// ```text /// State: -/// queues: Map> (capped at MAX_PENDING_PER_CHANNEL) -/// in_flight_channels: HashSet -/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) -/// retry_after: Map -/// retry_counts: Map (dead-letter after MAX_RETRIES) -/// dedup_mode: DedupMode +/// queues: Map> (capped at MAX_PENDING_PER_CONVERSATION) +/// in_flight_conversations: HashSet +/// in_flight_deadlines: Map (auto-expire after in_flight_deadline) +/// retry_after: Map +/// retry_counts: Map (dead-letter after MAX_RETRIES) +/// dedup_mode: DedupMode /// /// Transitions: /// push(event): -/// if dedup_mode == Drop AND in_flight_channels.contains(event.channel_id): +/// if dedup_mode == Drop AND in_flight_conversations.contains(event.scope): /// debug log + discard -/// else if queues[channel].len() >= MAX_PENDING_PER_CHANNEL: +/// else if queues[scope].len() >= MAX_PENDING_PER_CONVERSATION: /// drop oldest (pop_front), warn, push_back new event /// else: -/// queues[event.channel_id].push_back(event) +/// queues[event.scope].push_back(event) /// -/// flush_next() → Option: +/// flush_next() -> Option: /// expire any stuck in-flight entries past their deadline -/// candidates = channels where queue non-empty -/// AND NOT in in_flight_channels -/// AND (no retry_after OR retry_after[c] <= now) +/// candidates = scopes where queue non-empty +/// AND NOT in in_flight_conversations +/// AND (no retry_after OR retry_after[s] <= now) /// if candidates empty: return None -/// channel = pick candidate with oldest head event (min received_at) -/// events = drain up to MAX_BATCH_EVENTS from queues[channel] -/// in_flight_channels.insert(channel) -/// in_flight_deadlines.insert(channel, now + in_flight_deadline) -/// return Some(FlushBatch { channel, events }) +/// scope = pick candidate with oldest head event (min received_at) +/// events = drain up to MAX_BATCH_EVENTS from queues[scope] +/// in_flight_conversations.insert(scope) +/// in_flight_deadlines.insert(scope, now + in_flight_deadline) +/// return Some(FlushBatch { scope, events }) /// -/// mark_complete(channel_id): -/// in_flight_channels.remove(channel_id) -/// in_flight_deadlines.remove(channel_id) -/// retry_counts.remove(channel_id) +/// mark_complete(scope): +/// in_flight_conversations.remove(scope) +/// in_flight_deadlines.remove(scope) +/// retry_counts.remove(scope) /// clean up expired retry_after entry if present /// /// requeue(batch): -/// increment retry_counts[channel] -/// if retry_counts[channel] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) +/// increment retry_counts[scope] +/// if retry_counts[scope] > MAX_RETRIES: dead-letter (log ERROR, return batch to caller) /// else: push_front with original received_at, set exponential backoff retry_after with jitter /// ``` pub struct EventQueue { - queues: HashMap>, - in_flight_channels: HashSet, - /// Per-channel deadline for auto-expiring stuck in-flight entries. - in_flight_deadlines: HashMap, + queues: HashMap>, + in_flight_conversations: HashSet, + /// Per-conversation deadline for auto-expiring stuck in-flight entries. + in_flight_deadlines: HashMap, /// Number of events in each in-flight batch (for expiry logging). - in_flight_batch_sizes: HashMap, - retry_after: HashMap, - /// Per-channel retry attempt counter for exponential backoff / dead-lettering. - retry_counts: HashMap, + in_flight_batch_sizes: HashMap, + retry_after: HashMap, + /// Per-conversation retry attempt counter for exponential backoff / dead-lettering. + retry_counts: HashMap, dedup_mode: DedupMode, - /// Events from cancelled batches, keyed by channel. Merged into the next - /// `FlushBatch` for that channel as `cancelled_events` so `format_prompt()` - /// can produce annotated "[Previous request — interrupted]" sections. - cancelled_batches: HashMap>, - /// Why each channel's cancelled batch was cancelled (steer vs interrupt). - /// Set by `requeue_as_cancelled`, consumed by `flush_next` to set - /// `FlushBatch::cancel_reason`. Keyed by channel, cleared on flush. - cancel_reasons: HashMap, + /// Events from cancelled batches, keyed by conversation. Merged into the + /// next `FlushBatch` for that conversation as `cancelled_events` so + /// `format_prompt()` can produce annotated "[Previous request — + /// interrupted]" sections. Keyed by scope so a cancelled turn in one + /// thread can never be merged into a sibling thread's prompt. + cancelled_batches: HashMap>, + /// Why each conversation's cancelled batch was cancelled (steer vs + /// interrupt). Set by `requeue_as_cancelled`, consumed by `flush_next` to + /// set `FlushBatch::cancel_reason`. Cleared on flush. + cancel_reasons: HashMap, /// Events withheld from `queues` while a goose-native steer is in flight /// for that event. Invisible to `flush_next` / `has_flushable_work` / /// `drain` (the events have been moved out of `queues`), so the queue's /// no-double-deliver invariant holds without any change to the hot drain /// path. Populated by [`mark_native_steer_pending`]; drained back to the /// queue front by [`release_native_steer`] (preserving original - /// `received_at` fairness, same discipline as `requeue_preserve_timestamps` - /// at line 453). Bulk recovery on in-flight deadline expiry is performed - /// by `flush_next` / `has_flushable_work` (recover, not log-and-drop — - /// the events were never delivered to the agent). - withheld_native_steer: HashMap>, - /// Duration after which an in-flight channel is auto-expired as orphaned. - /// Must be strictly greater than `max_turn_duration` so a turn running to - /// the hard cap returns via `mark_complete` before the backstop fires. + /// `received_at` fairness, same discipline as `requeue_preserve_timestamps`). + /// Bulk recovery on in-flight deadline expiry is performed by `flush_next` / + /// `has_flushable_work` (recover, not log-and-drop — the events were never + /// delivered to the agent). + withheld_native_steer: HashMap>, + /// Duration after which an in-flight conversation is auto-expired as + /// orphaned. Must be strictly greater than `max_turn_duration` so a turn + /// running to the hard cap returns via `mark_complete` before the backstop + /// fires. in_flight_deadline: Duration, } @@ -179,7 +208,7 @@ impl EventQueue { pub fn new(dedup_mode: DedupMode) -> Self { Self { queues: HashMap::new(), - in_flight_channels: HashSet::new(), + in_flight_conversations: HashSet::new(), in_flight_deadlines: HashMap::new(), in_flight_batch_sizes: HashMap::new(), retry_after: HashMap::new(), @@ -200,20 +229,20 @@ impl EventQueue { self } - /// Monotonically extend an existing in-flight deadline for `channel_id`. + /// Monotonically extend an existing in-flight deadline for `scope`. /// /// Called when a successful steer grants a fresh turn budget. The new /// deadline is `max(current, now + max_turn_secs + buffer)` — it never - /// moves backward. If the channel is not in-flight (already completed + /// moves backward. If the conversation is not in-flight (already completed /// via `mark_complete`), this is a no-op: a late ack never resurrects /// a deadline. - pub fn extend_in_flight_deadline(&mut self, channel_id: Uuid, max_turn_secs: u64) { - if let Some(current) = self.in_flight_deadlines.get_mut(&channel_id) { + pub fn extend_in_flight_deadline(&mut self, scope: &ConversationScope, max_turn_secs: u64) { + if let Some(current) = self.in_flight_deadlines.get_mut(scope) { let extended = Instant::now() + Duration::from_secs(max_turn_secs + IN_FLIGHT_DEADLINE_BUFFER_SECS); if extended > *current { tracing::info!( - %channel_id, + conversation = %scope, "extending in-flight deadline by {max_turn_secs}s + {IN_FLIGHT_DEADLINE_BUFFER_SECS}s buffer" ); *current = extended; @@ -221,29 +250,58 @@ impl EventQueue { } } - /// Push an event into the queue for its channel. + /// Push an event into the queue for its conversation. /// - /// In [`DedupMode::Drop`], events for any currently in-flight channel are - /// silently discarded (debug-logged). + /// In [`DedupMode::Drop`], events for a currently in-flight conversation + /// are silently discarded (debug-logged). A sibling thread in the same + /// channel is a different conversation and is unaffected. /// /// Returns `true` if the event was accepted, `false` if dropped. pub fn push(&mut self, event: QueuedEvent) -> bool { if matches!(self.dedup_mode, DedupMode::Drop) - && self.in_flight_channels.contains(&event.channel_id) + && self.in_flight_conversations.contains(&event.scope) { tracing::debug!( - channel_id = %event.channel_id, - "dropping event for in-flight channel (drop mode)" + conversation = %event.scope, + "dropping event for in-flight conversation (drop mode)" + ); + return false; + } + + let is_new_scope = !self.known_scopes().contains(&event.scope); + if is_new_scope && self.known_scope_count() >= MAX_GLOBAL_CONVERSATION_SCOPES { + tracing::warn!( + conversation = %event.scope, + limit = MAX_GLOBAL_CONVERSATION_SCOPES, + "global conversation-scope cap reached — rejecting new root" + ); + return false; + } + if self.buffered_event_count() >= MAX_GLOBAL_BUFFERED_EVENTS { + tracing::warn!( + conversation = %event.scope, + limit = MAX_GLOBAL_BUFFERED_EVENTS, + "global buffered-event cap reached — rejecting event" ); return false; } - let queue = self.queues.entry(event.channel_id).or_default(); - // Enforce per-channel depth cap: drop oldest to make room. - if queue.len() >= MAX_PENDING_PER_CHANNEL { + if self.buffered_event_count_for_channel(event.scope.channel_id()) + >= MAX_PENDING_PER_CHANNEL + && !self.drop_oldest_queued_for_channel(event.scope.channel_id()) + { + // Every buffered event for the channel is currently withheld or in + // a cancelled batch. Never evict that protected work to admit a new + // event; reject the newcomer instead. + return false; + } + + let queue = self.queues.entry(event.scope.clone()).or_default(); + // Enforce per-conversation depth cap: drop oldest to make room. + if queue.len() >= MAX_PENDING_PER_CONVERSATION { queue.pop_front(); tracing::warn!( - channel_id = %event.channel_id, - limit = MAX_PENDING_PER_CHANNEL, + conversation = %event.scope, + limit = MAX_PENDING_PER_CONVERSATION, "queue depth cap reached — dropped oldest event" ); } @@ -254,74 +312,54 @@ impl EventQueue { /// Try to flush the next batch. /// /// Returns `None` if all non-in-flight, non-throttled queues are empty. - /// Otherwise picks the channel with the oldest pending event (FIFO fairness - /// across channels), drains ALL events for that channel into a single batch, - /// inserts into `in_flight_channels`, and returns the batch. + /// Otherwise picks the *conversation* with the oldest pending event (FIFO + /// fairness across conversations), drains ALL events for that conversation + /// into a single batch, inserts into `in_flight_conversations`, and returns + /// the batch. Two threads in one channel are two candidates and never share + /// a batch. pub fn flush_next(&mut self) -> Option { let now = Instant::now(); // Auto-expire any stuck in-flight entries that missed mark_complete. - let expired: Vec = self - .in_flight_deadlines - .iter() - .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) - .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); - tracing::error!( - channel_id = %id, - lost_events, - deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ - auto-releasing; {lost_events} dispatched event(s) orphaned" - ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); - // Recover any withheld goose-native steer events for the expired - // channel back to the queue front so normal dispatch delivers - // them. Unlike the in-flight batch above (already delivered to a - // now-hung prompt — nothing to recover), these events were never - // delivered to the agent. - self.recover_withheld_for_expired_channel(id); - } + self.expire_stuck_in_flight(now); - // Find the channel whose head event has the oldest received_at, - // excluding in-flight channels and throttled channels. - let channel_id = self + // Find the conversation whose head event has the oldest received_at, + // excluding in-flight and throttled conversations. + let scope = self .queues .iter() - .filter(|(id, q)| { + .filter(|(scope, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_conversations.contains(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) .min_by_key(|(_, q)| q.front().unwrap().received_at) - .map(|(id, _)| *id); + .map(|(scope, _)| scope.clone()); - // Fallback: if no queued events are ready but a channel has cancelled - // events waiting (e.g., explicit !cancel with no new @mention), flush - // those as a regular batch (re-dispatch unchanged). - let channel_id = match channel_id { - Some(id) => id, + // Fallback: if no queued events are ready but a conversation has + // cancelled events waiting (e.g., explicit !cancel with no new + // @mention), flush those as a regular batch (re-dispatch unchanged). + let scope = match scope { + Some(scope) => scope, None => { - let cancelled_id = self + let cancelled_scope = self .cancelled_batches .keys() - .find(|id| !self.in_flight_channels.contains(id)) - .copied(); - match cancelled_id { - Some(id) => { + .find(|scope| !self.in_flight_conversations.contains(scope)) + .cloned(); + match cancelled_scope { + Some(scope) => { // Move cancelled events into the regular events slot. // No new events to merge — re-dispatch the original batch. - let cancelled = self.cancelled_batches.remove(&id).unwrap_or_default(); - let cancel_reason = self.cancel_reasons.remove(&id); - self.in_flight_channels.insert(id); + let cancelled = self.cancelled_batches.remove(&scope).unwrap_or_default(); + let cancel_reason = self.cancel_reasons.remove(&scope); + self.in_flight_conversations.insert(scope.clone()); self.in_flight_deadlines - .insert(id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(id, cancelled.len()); + .insert(scope.clone(), now + self.in_flight_deadline); + self.in_flight_batch_sizes + .insert(scope.clone(), cancelled.len()); return Some(FlushBatch { - channel_id: id, + scope, events: cancelled, cancelled_events: vec![], cancel_reason, @@ -333,7 +371,7 @@ impl EventQueue { }; // Drain up to MAX_BATCH_EVENTS; leave any remainder in the queue. - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); let drain_count = MAX_BATCH_EVENTS.min(queue.len()); let mut events: Vec = queue .drain(..drain_count) @@ -350,103 +388,144 @@ impl EventQueue { events.sort_by_key(|be| be.event.created_at); // Remove the queue entry if now empty. - if self.queues.get(&channel_id).is_some_and(|q| q.is_empty()) { - self.queues.remove(&channel_id); + if self.queues.get(&scope).is_some_and(|q| q.is_empty()) { + self.queues.remove(&scope); } - self.in_flight_channels.insert(channel_id); + self.in_flight_conversations.insert(scope.clone()); self.in_flight_deadlines - .insert(channel_id, now + self.in_flight_deadline); - self.in_flight_batch_sizes.insert(channel_id, events.len()); - - // Merge any cancelled events stored by requeue_as_cancelled(). - let cancelled_events = self - .cancelled_batches - .remove(&channel_id) - .unwrap_or_default(); + .insert(scope.clone(), now + self.in_flight_deadline); + // Merge any cancelled events stored by requeue_as_cancelled() — for + // THIS conversation only; a sibling thread's cancelled batch stays put. + let cancelled_events = self.cancelled_batches.remove(&scope).unwrap_or_default(); let cancel_reason = if cancelled_events.is_empty() { - self.cancel_reasons.remove(&channel_id); + self.cancel_reasons.remove(&scope); None } else { - self.cancel_reasons.remove(&channel_id) + self.cancel_reasons.remove(&scope) }; + self.in_flight_batch_sizes.insert( + scope.clone(), + events.len().saturating_add(cancelled_events.len()), + ); Some(FlushBatch { - channel_id, + scope, events, cancelled_events, cancel_reason, }) } - /// Mark the prompt for `channel_id` as complete. + /// Auto-expire in-flight conversations that missed `mark_complete`. /// - /// Removes the channel from `in_flight_channels` and `in_flight_deadlines`. + /// Shared by `flush_next` and `has_flushable_work` so the two cannot drift. + fn expire_stuck_in_flight(&mut self, now: Instant) { + let expired: Vec = self + .in_flight_deadlines + .iter() + .filter(|(_, deadline)| now >= **deadline) + .map(|(scope, _)| scope.clone()) + .collect(); + for scope in expired { + let lost_events = self.in_flight_batch_sizes.remove(&scope).unwrap_or(0); + tracing::error!( + conversation = %scope, + lost_events, + deadline_secs = self.in_flight_deadline.as_secs(), + "BUG: in-flight conversation expired without mark_complete — \ + auto-releasing; {lost_events} dispatched event(s) orphaned" + ); + self.in_flight_conversations.remove(&scope); + self.in_flight_deadlines.remove(&scope); + // Recover any withheld goose-native steer events for the expired + // conversation back to the queue front so normal dispatch delivers + // them. Unlike the in-flight batch above (already delivered to a + // now-hung prompt — nothing to recover), these events were never + // delivered to the agent. + self.recover_withheld_for_expired_conversation(&scope); + } + } + + /// Mark the prompt for `scope` as complete. /// - /// If the channel was NOT requeued (no active `retry_after` throttle), the - /// retry counter is reset — the channel is healthy and the next failure - /// starts fresh. If the channel WAS requeued, `retry_counts` is left intact + /// Removes the conversation from `in_flight_conversations` and + /// `in_flight_deadlines`. + /// + /// If the conversation was NOT requeued (no active `retry_after` throttle), + /// the retry counter is reset — the conversation is healthy and the next + /// failure starts fresh. If it WAS requeued, `retry_counts` is left intact /// so the backoff sequence continues on the next attempt. /// /// Also cleans up any already-expired `retry_after` entry. - pub fn mark_complete(&mut self, channel_id: Uuid) { - self.in_flight_channels.remove(&channel_id); - self.in_flight_deadlines.remove(&channel_id); - self.in_flight_batch_sizes.remove(&channel_id); + pub fn mark_complete(&mut self, scope: &ConversationScope) { + self.in_flight_conversations.remove(scope); + self.in_flight_deadlines.remove(scope); + self.in_flight_batch_sizes.remove(scope); let now = Instant::now(); - match self.retry_after.get(&channel_id) { - // Active throttle → channel was requeued; keep retry_counts intact. + match self.retry_after.get(scope) { + // Active throttle → conversation was requeued; keep retry_counts. Some(&deadline) if deadline > now => {} // Expired or absent throttle → successful completion; reset counter // and clean up the stale retry_after entry. Some(_) => { - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); + self.retry_after.remove(scope); + self.retry_counts.remove(scope); } None => { - self.retry_counts.remove(&channel_id); + self.retry_counts.remove(scope); } } } /// Re-queue a batch of events that failed to process. /// - /// Events are pushed back to the **front** of the channel's queue so they - /// are processed first on the next flush cycle. This prevents event loss - /// when session creation or `session/prompt` fails transiently. + /// Events are pushed back to the **front** of the conversation's queue so + /// they are processed first on the next flush cycle. This prevents event + /// loss when session creation or `session/prompt` fails transiently. /// - /// Original `received_at` timestamps are preserved so the channel retains - /// its fairness position. The retry delay comes from exponential backoff, - /// not from resetting received_at. + /// Original `received_at` timestamps are preserved so the conversation + /// retains its fairness position. The retry delay comes from exponential + /// backoff, not from resetting received_at. The backoff throttles only this + /// conversation — a sibling thread in the same channel keeps flushing. /// /// After [`MAX_RETRIES`] attempts the batch is dead-lettered: logged at /// ERROR and returned to the caller (rather than requeued) so a visible /// failure notice can be posted to the channel. Returns `None` when the /// batch was requeued for another attempt. /// - /// Note: does NOT remove from `in_flight_channels` — caller must call + /// Note: does NOT remove from `in_flight_conversations` — caller must call /// `mark_complete` separately. pub fn requeue(&mut self, batch: FlushBatch) -> Option { - let channel_id = batch.channel_id; + let scope = batch.scope.clone(); + let returned_events = batch.events.len() + batch.cancelled_events.len(); + if !self.can_return_events(&scope, returned_events) { + tracing::error!( + conversation = %scope, + events = returned_events, + "requeue overflow without matching in-flight reservation — returning batch unchanged" + ); + return Some(batch); + } let attempt = { - let count = self.retry_counts.entry(channel_id).or_insert(0); + let count = self.retry_counts.entry(scope.clone()).or_insert(0); *count += 1; *count }; if attempt > MAX_RETRIES { tracing::error!( - channel_id = %channel_id, + conversation = %scope, attempt, - events = batch.events.len(), + events = returned_events, "dead-lettering batch after {} retries — discarding {} events", MAX_RETRIES, - batch.events.len(), + returned_events, ); - self.retry_counts.remove(&channel_id); - // Also clear retry_after so fresh traffic on this channel isn't + self.retry_counts.remove(&scope); + // Also clear retry_after so fresh traffic in this conversation isn't // throttled by stale backoff from the discarded poison batch. - self.retry_after.remove(&channel_id); + self.retry_after.remove(&scope); return Some(batch); } @@ -464,73 +543,123 @@ impl EventQueue { let delay = Duration::from_secs_f64(capped_secs as f64 * jitter); tracing::warn!( - channel_id = %channel_id, + conversation = %scope, attempt, max = MAX_RETRIES, delay_secs = delay.as_secs_f64(), - events = batch.events.len(), + events = returned_events, "requeueing failed batch with backoff" ); - let queue = self.queues.entry(channel_id).or_default(); + let FlushBatch { + scope: _, + events, + cancelled_events, + cancel_reason, + } = batch; + self.in_flight_batch_sizes.remove(&scope); + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. - for be in batch.events.into_iter().rev() { + for be in events.into_iter().rev() { queue.push_front(QueuedEvent { - channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, // preserve original timestamp (#46) }); } - // Enforce per-channel cap: trim oldest (back) events if requeue pushed - // the queue over the limit. Without this, repeated requeue+push cycles - // can grow the queue unboundedly. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Restore protected prior work separately so the next flush preserves + // merged-prompt framing across a failed replacement turn. + if !cancelled_events.is_empty() { + self.cancelled_batches + .entry(scope.clone()) + .or_default() + .extend(cancelled_events); + if let Some(reason) = cancel_reason { + self.cancel_reasons.insert(scope.clone(), reason); + } + } + + // Enforce per-conversation cap: trim newest (back) events if requeue + // pushed the queue over the limit. Without this, repeated requeue+push + // cycles can grow the queue unboundedly. + while queue.len() > MAX_PENDING_PER_CONVERSATION { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, - "requeue overflow — dropped oldest event to enforce cap" + conversation = %scope, + limit = MAX_PENDING_PER_CONVERSATION, + "requeue overflow — dropped newest event to enforce cap" ); } - self.retry_after.insert(channel_id, Instant::now() + delay); + self.enforce_aggregate_caps(scope.channel_id()); + self.retry_after.insert(scope, Instant::now() + delay); None } /// Re-queue a batch preserving original `received_at` timestamps. /// /// Used when a batch was flushed but no agent was available — we want to - /// retry without penalizing the channel's position in the fairness queue - /// and without imposing a retry throttle. + /// retry without penalizing the conversation's position in the fairness + /// queue and without imposing a retry throttle. /// - /// Does NOT set `retry_after`. Does NOT remove from `in_flight_channels` — + /// Does NOT set `retry_after`. Does NOT remove from `in_flight_conversations` — /// caller must call `mark_complete` separately. pub fn requeue_preserve_timestamps(&mut self, batch: FlushBatch) { - let channel_id = batch.channel_id; - let queue = self.queues.entry(channel_id).or_default(); + let scope = batch.scope.clone(); + let returned_events = batch + .events + .len() + .saturating_add(batch.cancelled_events.len()); + if !self.can_return_events(&scope, returned_events) { + tracing::error!( + conversation = %scope, + events = returned_events, + "timestamp-preserving requeue overflow without matching in-flight reservation — batch rejected" + ); + return; + } + self.in_flight_batch_sizes.remove(&scope); + let FlushBatch { + events, + cancelled_events, + cancel_reason, + .. + } = batch; + let queue = self.queues.entry(scope.clone()).or_default(); // Push to front in reverse order so original order is preserved. - for be in batch.events.into_iter().rev() { + for be in events.into_iter().rev() { queue.push_front(QueuedEvent { - channel_id, + scope: scope.clone(), event: be.event, prompt_tag: be.prompt_tag, received_at: be.received_at, }); } - // Enforce per-channel cap: trim newest (back) events if over limit. - while queue.len() > MAX_PENDING_PER_CHANNEL { + // Enforce per-conversation cap: trim newest (back) events if over limit. + while queue.len() > MAX_PENDING_PER_CONVERSATION { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + conversation = %scope, + limit = MAX_PENDING_PER_CONVERSATION, "requeue_preserve overflow — dropped newest event to enforce cap" ); } + if !cancelled_events.is_empty() { + self.cancelled_batches + .entry(scope.clone()) + .or_default() + .extend(cancelled_events); + if let Some(reason) = cancel_reason { + self.cancel_reasons.insert(scope.clone(), reason); + } + } + self.enforce_aggregate_caps(scope.channel_id()); } /// Requeue a cancelled batch so its events appear as `cancelled_events` - /// in the next `FlushBatch` for this channel (enabling the annotated - /// merged-prompt format in `format_prompt()`). + /// in the next `FlushBatch` for this **conversation** (enabling the + /// annotated merged-prompt format in `format_prompt()`). A sibling thread + /// in the same channel never inherits this carryover. /// /// `reason` records why the turn was cancelled (steer vs interrupt) so the /// merged prompt is framed correctly. On a double-cancel, the most recent @@ -540,15 +669,33 @@ impl EventQueue { /// the generic queue — they are stored separately and merged by /// `flush_next()`. No retry throttle, no backoff. pub fn requeue_as_cancelled(&mut self, batch: FlushBatch, reason: CancelReason) { - let entry = self.cancelled_batches.entry(batch.channel_id).or_default(); + let channel_id = batch.scope.channel_id(); + let returned_events = batch + .events + .len() + .saturating_add(batch.cancelled_events.len()); + if !self.can_return_events(&batch.scope, returned_events) { + tracing::error!( + conversation = %batch.scope, + events = returned_events, + "protected requeue overflow without matching in-flight reservation — batch rejected" + ); + return; + } + self.in_flight_batch_sizes.remove(&batch.scope); + let entry = self + .cancelled_batches + .entry(batch.scope.clone()) + .or_default(); // Preserve any already-cancelled events from a prior cancel (double-cancel). entry.extend(batch.cancelled_events); entry.extend(batch.events); - self.cancel_reasons.insert(batch.channel_id, reason); + self.cancel_reasons.insert(batch.scope, reason); + self.enforce_aggregate_caps(channel_id); } - /// Returns `true` if any channel has pending events that are not in-flight - /// and not throttled by `retry_after`. + /// Returns `true` if any conversation has pending events that are not + /// in-flight and not throttled by `retry_after`. /// /// Also auto-expires any stuck in-flight entries whose deadline has passed. /// This is a `&mut self` method so expiry can happen without requiring a @@ -557,45 +704,24 @@ impl EventQueue { let now = Instant::now(); // Auto-expire stuck in-flight entries (same logic as flush_next). - let expired: Vec = self - .in_flight_deadlines - .iter() - .filter(|(_, deadline)| now >= **deadline) - .map(|(id, _)| *id) - .collect(); - for id in expired { - let lost_events = self.in_flight_batch_sizes.remove(&id).unwrap_or(0); - tracing::error!( - channel_id = %id, - lost_events, - deadline_secs = self.in_flight_deadline.as_secs(), - "BUG: in-flight channel expired without mark_complete — \ - auto-releasing; {lost_events} dispatched event(s) orphaned" - ); - self.in_flight_channels.remove(&id); - self.in_flight_deadlines.remove(&id); - // Symmetric with the flush_next expiry block: recover withheld - // goose-native steer events for the expired channel so they are - // not permanently orphaned in the side table. - self.recover_withheld_for_expired_channel(id); - } + self.expire_stuck_in_flight(now); - self.queues.iter().any(|(id, q)| { + self.queues.iter().any(|(scope, q)| { !q.is_empty() - && !self.in_flight_channels.contains(id) - && self.retry_after.get(id).is_none_or(|&t| t <= now) + && !self.in_flight_conversations.contains(scope) + && self.retry_after.get(scope).is_none_or(|&t| t <= now) }) || self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)) + .any(|scope| !self.in_flight_conversations.contains(scope)) } - /// Returns `true` if any undispatched work remains for a channel that is - /// NOT currently in-flight — *including* work held back only by a + /// Returns `true` if any undispatched work remains for a conversation that + /// is NOT currently in-flight — *including* work held back only by a /// `retry_after` backoff throttle. /// /// This is deliberately broader than [`has_flushable_work`](Self::has_flushable_work): - /// that method excludes `retry_after`-throttled channels because they are + /// that method excludes `retry_after`-throttled conversations because they are /// not flushable *right now*, but the events are still queued and MUST be /// delivered once the backoff deadline passes. Idle-pool-sleep teardown /// must gate on this, not on flushability — a failed turn requeued with a @@ -611,77 +737,242 @@ impl EventQueue { let has_queued = self .queues .iter() - .any(|(id, q)| !q.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, q)| !q.is_empty() && !self.in_flight_conversations.contains(scope)); let has_cancelled = self .cancelled_batches .keys() - .any(|id| !self.in_flight_channels.contains(id)); + .any(|scope| !self.in_flight_conversations.contains(scope)); let has_withheld = self .withheld_native_steer .iter() - .any(|(id, v)| !v.is_empty() && !self.in_flight_channels.contains(id)); + .any(|(scope, v)| !v.is_empty() && !self.in_flight_conversations.contains(scope)); has_queued || has_cancelled || has_withheld } - /// Number of channels with pending events. - pub fn pending_channels(&self) -> usize { + /// Number of conversations with pending events. + pub fn pending_conversations(&self) -> usize { self.queues.len() } - /// Number of queued events for a specific channel. Test-only. + fn buffered_event_count(&self) -> usize { + self.queues.values().map(VecDeque::len).sum::() + + self.cancelled_batches.values().map(Vec::len).sum::() + + self + .withheld_native_steer + .values() + .map(Vec::len) + .sum::() + + self.in_flight_batch_sizes.values().sum::() + } + + fn buffered_event_count_for_channel(&self, channel_id: Uuid) -> usize { + self.queues + .iter() + .filter(|(scope, _)| scope.channel_id() == channel_id) + .map(|(_, events)| events.len()) + .sum::() + + self + .cancelled_batches + .iter() + .filter(|(scope, _)| scope.channel_id() == channel_id) + .map(|(_, events)| events.len()) + .sum::() + + self + .withheld_native_steer + .iter() + .filter(|(scope, _)| scope.channel_id() == channel_id) + .map(|(_, events)| events.len()) + .sum::() + + self + .in_flight_batch_sizes + .iter() + .filter(|(scope, _)| scope.channel_id() == channel_id) + .map(|(_, size)| *size) + .sum::() + } + + /// Check whether replacing this scope's in-flight reservation with + /// `returned_events` keeps both aggregate event limits hard. No mutation is + /// performed; callers consume the reservation only after this succeeds. + fn can_return_events(&self, scope: &ConversationScope, returned_events: usize) -> bool { + let reserved = self + .in_flight_batch_sizes + .get(scope) + .copied() + .unwrap_or_default(); + self.buffered_event_count_for_channel(scope.channel_id()) + .saturating_sub(reserved) + .saturating_add(returned_events) + <= MAX_PENDING_PER_CHANNEL + && self + .buffered_event_count() + .saturating_sub(reserved) + .saturating_add(returned_events) + <= MAX_GLOBAL_BUFFERED_EVENTS + } + + fn known_scopes(&self) -> HashSet { + self.queues + .keys() + .chain(self.in_flight_conversations.iter()) + .chain(self.in_flight_deadlines.keys()) + .chain(self.in_flight_batch_sizes.keys()) + .chain(self.retry_after.keys()) + .chain(self.retry_counts.keys()) + .chain(self.cancelled_batches.keys()) + .chain(self.cancel_reasons.keys()) + .chain(self.withheld_native_steer.keys()) + .cloned() + .collect() + } + + fn known_scope_count(&self) -> usize { + self.known_scopes().len() + } + + fn drop_oldest_queued_for_channel(&mut self, channel_id: Uuid) -> bool { + let oldest_scope = self + .queues + .iter() + .filter(|(scope, events)| scope.channel_id() == channel_id && !events.is_empty()) + .min_by_key(|(_, events)| events.front().map(|event| event.received_at)) + .map(|(scope, _)| scope.clone()); + let Some(scope) = oldest_scope else { + return false; + }; + if let Some(events) = self.queues.get_mut(&scope) { + events.pop_front(); + if events.is_empty() { + self.queues.remove(&scope); + } + } + tracing::warn!( + channel = %channel_id, + conversation = %scope, + limit = MAX_PENDING_PER_CHANNEL, + "aggregate channel queue cap reached — dropped oldest queued event" + ); + true + } + + fn drop_newest_queued(&mut self, channel_id: Option) -> bool { + let newest_scope = self + .queues + .iter() + .filter(|(scope, events)| { + channel_id.is_none_or(|id| scope.channel_id() == id) && !events.is_empty() + }) + .max_by_key(|(_, events)| events.back().map(|event| event.received_at)) + .map(|(scope, _)| scope.clone()); + let Some(scope) = newest_scope else { + return false; + }; + if let Some(events) = self.queues.get_mut(&scope) { + events.pop_back(); + if events.is_empty() { + self.queues.remove(&scope); + } + } + true + } + + /// Requeue paths can return an older in-flight batch while newer sibling + /// events are already buffered. Preserve the old work and trim only newest + /// ordinary queued events to keep the aggregate channel/global bounds. + fn enforce_aggregate_caps(&mut self, channel_id: Uuid) { + while self.buffered_event_count_for_channel(channel_id) > MAX_PENDING_PER_CHANNEL { + if !self.drop_newest_queued(Some(channel_id)) { + break; + } + } + while self.buffered_event_count() > MAX_GLOBAL_BUFFERED_EVENTS { + if !self.drop_newest_queued(None) { + break; + } + } + } + + /// Number of queued events for a specific conversation. Test-only. #[cfg(test)] - pub fn queued_event_count(&self, channel_id: &Uuid) -> usize { - self.queues.get(channel_id).map_or(0, |q| q.len()) + pub fn queued_event_count(&self, scope: &ConversationScope) -> usize { + self.queues.get(scope).map_or(0, |q| q.len()) } - /// Force a channel's retry-attempt counter to `count`, simulating `count` - /// prior failed attempts without needing to drive fake flush/requeue - /// cycles through the queue (which would leave artifact events behind). - /// Test-only — lets integration tests outside this module exercise + /// Force a conversation's retry-attempt counter to `count`, simulating + /// `count` prior failed attempts without needing to drive fake + /// flush/requeue cycles through the queue (which would leave artifact + /// events behind). Test-only — lets tests outside this module exercise /// `requeue()`'s dead-letter threshold directly. #[cfg(test)] - pub fn set_retry_count_for_test(&mut self, channel_id: Uuid, count: u32) { - self.retry_counts.insert(channel_id, count); + pub fn set_retry_count_for_test(&mut self, scope: &ConversationScope, count: u32) { + self.retry_counts.insert(scope.clone(), count); } - /// Drop all queued (non-in-flight) events for a channel. + /// Drop all queued (non-in-flight) events for **every conversation** in a + /// channel. /// - /// Used when the agent is removed from a channel — any pending events - /// for that channel are stale and should not be prompted. Does NOT - /// affect in-flight prompts (those will complete normally; the agent - /// may fail to act if it lost access, but that's handled by the relay). + /// Used when the agent is removed from a channel — any pending events for + /// that channel are stale and should not be prompted, no matter which + /// thread they belong to. Channel removal is deliberately channel-wide: + /// this is the one place that iterates all conversation scopes sharing a + /// `channel_id`. Does NOT affect in-flight prompts (those will complete + /// normally; the agent may fail to act if it lost access, but that's + /// handled by the relay). /// - /// Also clears any `retry_after` throttle for the channel. + /// Also clears any `retry_after` throttle for those conversations. /// /// Returns the event IDs of dropped events so the caller can clean up /// any reactions (👀) that were added at queue-push time. pub fn drain_channel(&mut self, channel_id: Uuid) -> Vec { - let ids = self - .queues - .remove(&channel_id) - .map(|q| q.into_iter().map(|e| e.event.id.to_hex()).collect()) - .unwrap_or_default(); - self.retry_after.remove(&channel_id); - self.retry_counts.remove(&channel_id); - self.cancelled_batches.remove(&channel_id); - self.cancel_reasons.remove(&channel_id); - self.withheld_native_steer.remove(&channel_id); - // Preserve in_flight_channels AND in_flight_deadlines: the in-flight + let scopes: Vec = + self.scopes_for_channel(channel_id).into_iter().collect(); + let mut ids = Vec::new(); + for scope in scopes { + if let Some(q) = self.queues.remove(&scope) { + ids.extend(q.into_iter().map(|e| e.event.id.to_hex())); + } + self.retry_after.remove(&scope); + self.retry_counts.remove(&scope); + self.cancelled_batches.remove(&scope); + self.cancel_reasons.remove(&scope); + self.withheld_native_steer.remove(&scope); + } + // Preserve in_flight_conversations AND in_flight_deadlines: the in-flight // task will eventually complete (calling mark_complete) or the deadline - // will expire (auto-cleaning the channel). Removing deadlines without - // removing in_flight_channels would disable auto-expiry and leave a - // wedged task permanently blocking the channel. + // will expire (auto-cleaning the conversation). Removing deadlines without + // removing in_flight_conversations would disable auto-expiry and leave a + // wedged task permanently blocking the conversation. ids } - /// Whether a prompt is currently in-flight for the given channel. - pub fn is_channel_in_flight(&self, channel_id: Uuid) -> bool { - self.in_flight_channels.contains(&channel_id) + /// Every conversation scope in `channel_id` that this queue holds state for. + /// + /// Deduplicated across all per-conversation tables so channel-wide teardown + /// cannot miss a scope that exists in only one of them. + fn scopes_for_channel(&self, channel_id: Uuid) -> HashSet { + self.queues + .keys() + .chain(self.retry_after.keys()) + .chain(self.retry_counts.keys()) + .chain(self.cancelled_batches.keys()) + .chain(self.cancel_reasons.keys()) + .chain(self.withheld_native_steer.keys()) + .filter(|scope| scope.channel_id() == channel_id) + .cloned() + .collect() } - /// Whether any channel currently has a turn in flight. + /// Whether a prompt is currently in-flight for the given conversation. + /// + /// Deliberately per-conversation: the mid-turn steer/cancel gate must not + /// fire because a *different* thread in the same channel is busy. + pub fn is_conversation_in_flight(&self, scope: &ConversationScope) -> bool { + self.in_flight_conversations.contains(scope) + } + + /// Whether any conversation currently has a turn in flight. pub fn has_in_flight(&self) -> bool { - !self.in_flight_channels.is_empty() + !self.in_flight_conversations.is_empty() } // ── Goose-native steer withhold (side table) ────────────────────────── @@ -689,27 +980,27 @@ impl EventQueue { // While a goose-native `_goose/unstable/session/steer` write is in flight // for a specific queued event, that event is moved out of `queues` into // `withheld_native_steer` so `flush_next` / `has_flushable_work` / the - // contiguous drain at line 285 cannot see it — closing the race window - // between `mark_complete` (which clears `in_flight_channels`) and the + // contiguous drain cannot see it — closing the race window + // between `mark_complete` (which clears `in_flight_conversations`) and the // ack arriving on the main loop. On `Success` the event is consumed // (`remove_event`); on `Err` / `PromptCompletedNeutral` it is released // back to the queue front (`release_native_steer`), preserving its // original `received_at` for FIFO fairness. - /// Move a queued event out of `queues[channel_id]` into the side table + /// Move a queued event out of `queues[scope]` into the side table /// to withhold it from `flush_next` while a goose-native steer is in /// flight. /// /// Returns `true` if the event was found and withheld, `false` if the - /// event id was not present in `queues[channel_id]` (race-safe no-op: + /// event id was not present in `queues[scope]` (race-safe no-op: /// the event may have already been drained, removed, or never queued). /// /// Must be called synchronously from the mode-gate fork immediately /// after `pool.send_steer` returns `Ok(())` and before any watcher task /// is spawned, so the withhold is established before `mark_complete` / /// any subsequent `flush_next` tick can run. - pub fn mark_native_steer_pending(&mut self, channel_id: Uuid, event_id: &str) -> bool { - let Some(q) = self.queues.get_mut(&channel_id) else { + pub fn mark_native_steer_pending(&mut self, scope: &ConversationScope, event_id: &str) -> bool { + let Some(q) = self.queues.get_mut(scope) else { return false; }; let Some(pos) = q.iter().position(|qe| qe.event.id.to_hex() == event_id) else { @@ -719,27 +1010,27 @@ impl EventQueue { .remove(pos) .expect("position came from iter so remove must succeed"); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(scope); } self.withheld_native_steer - .entry(channel_id) + .entry(scope.clone()) .or_default() .push(qe); true } /// Release a single withheld event back to the front of - /// `queues[channel_id]`, preserving its original `received_at`. + /// `queues[scope]`, preserving its original `received_at`. /// /// Called on `SteerAck::Err(_)` and `SteerAck::PromptCompletedNeutral` /// (delivery unknown after prompt completion; restoring queued event /// for normal dispatch). Idempotent: a no-op if the event was already /// removed or never withheld. /// - /// Push-to-front matches the discipline of `requeue_preserve_timestamps` - /// at line 453, preserving fairness across channels. - pub fn release_native_steer(&mut self, channel_id: Uuid, event_id: &str) { - let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) else { + /// Push-to-front matches the discipline of `requeue_preserve_timestamps`, + /// preserving fairness across conversations. + pub fn release_native_steer(&mut self, scope: &ConversationScope, event_id: &str) { + let Some(entries) = self.withheld_native_steer.get_mut(scope) else { return; }; let Some(pos) = entries @@ -750,18 +1041,18 @@ impl EventQueue { }; let qe = entries.remove(pos); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(scope); } // Push to FRONT so original `received_at` keeps the event at the head - // of the channel's queue. Per-channel cap is enforced below in case - // a flood of events arrived during the ack window. - let queue = self.queues.entry(channel_id).or_default(); + // of the conversation's queue. The per-conversation cap is enforced + // below in case a flood of events arrived during the ack window. + let queue = self.queues.entry(scope.clone()).or_default(); queue.push_front(qe); - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_CONVERSATION { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + conversation = %scope, + limit = MAX_PENDING_PER_CONVERSATION, "release_native_steer overflow — dropped newest event to enforce cap" ); } @@ -773,22 +1064,22 @@ impl EventQueue { /// Called on `SteerAck::Success` — the agent received the steer, so the /// event has been "delivered" via the non-cancelling path and must not /// be redelivered via normal dispatch. Idempotent across both stores. - pub fn remove_event(&mut self, channel_id: Uuid, event_id: &str) { - if let Some(entries) = self.withheld_native_steer.get_mut(&channel_id) { + pub fn remove_event(&mut self, scope: &ConversationScope, event_id: &str) { + if let Some(entries) = self.withheld_native_steer.get_mut(scope) { entries.retain(|qe| qe.event.id.to_hex() != event_id); if entries.is_empty() { - self.withheld_native_steer.remove(&channel_id); + self.withheld_native_steer.remove(scope); } } - if let Some(q) = self.queues.get_mut(&channel_id) { + if let Some(q) = self.queues.get_mut(scope) { q.retain(|qe| qe.event.id.to_hex() != event_id); if q.is_empty() { - self.queues.remove(&channel_id); + self.queues.remove(scope); } } } - /// Bulk-release every withheld event for `channel_id` back to the queue + /// Bulk-release every withheld event for `scope` back to the queue /// front, preserving relative FIFO order. /// /// Called from the `in_flight_deadline` expiry blocks in @@ -800,26 +1091,26 @@ impl EventQueue { /// /// Iterates the stored entries in reverse so per-entry `push_front` /// composes to original-FIFO order at the queue front (same discipline - /// as `requeue_preserve_timestamps` at line 453). - fn recover_withheld_for_expired_channel(&mut self, channel_id: Uuid) { - let Some(entries) = self.withheld_native_steer.remove(&channel_id) else { + /// as `requeue_preserve_timestamps`). + fn recover_withheld_for_expired_conversation(&mut self, scope: &ConversationScope) { + let Some(entries) = self.withheld_native_steer.remove(scope) else { return; }; let n = entries.len(); - let queue = self.queues.entry(channel_id).or_default(); + let queue = self.queues.entry(scope.clone()).or_default(); for qe in entries.into_iter().rev() { queue.push_front(qe); } - while queue.len() > MAX_PENDING_PER_CHANNEL { + while queue.len() > MAX_PENDING_PER_CONVERSATION { queue.pop_back(); tracing::warn!( - channel_id = %channel_id, - limit = MAX_PENDING_PER_CHANNEL, + conversation = %scope, + limit = MAX_PENDING_PER_CONVERSATION, "withheld-steer recovery overflow — dropped newest event to enforce cap" ); } tracing::warn!( - channel_id = %channel_id, + conversation = %scope, recovered = n, "in-flight expiry recovered withheld steer event(s) — \ steer ack never arrived; normal dispatch will deliver" @@ -829,13 +1120,13 @@ impl EventQueue { /// Compact expired metadata entries to prevent unbounded map growth. /// /// Removes `retry_after` entries whose deadline has already passed, and - /// cleans up orphaned `retry_counts` entries for channels that have no + /// cleans up orphaned `retry_counts` entries for conversations that have no /// queued events, no active throttle, and no in-flight prompt. Without - /// this, channels that completed their retry cycle but never received + /// this, conversations that completed their retry cycle but never received /// fresh traffic would leak a `u32` entry in `retry_counts` indefinitely. /// - /// The in-flight guard is critical: a channel whose throttle expired and - /// whose queue is empty because it was flushed may still have a retry + /// The in-flight guard is critical: a conversation whose throttle expired + /// and whose queue is empty because it was flushed may still have a retry /// attempt in flight. Removing its `retry_counts` would reset the /// backoff sequence if that attempt fails and requeues. /// @@ -845,13 +1136,13 @@ impl EventQueue { pub fn compact_expired_state(&mut self) { let now = Instant::now(); self.retry_after.retain(|_, deadline| *deadline > now); - // Remove retry_counts for channels with no active throttle, no + // Remove retry_counts for conversations with no active throttle, no // queued events, AND no in-flight prompt — they completed their // retry cycle and are truly idle. - self.retry_counts.retain(|ch, _| { - self.retry_after.contains_key(ch) - || self.queues.get(ch).is_some_and(|q| !q.is_empty()) - || self.in_flight_channels.contains(ch) + self.retry_counts.retain(|scope, _| { + self.retry_after.contains_key(scope) + || self.queues.get(scope).is_some_and(|q| !q.is_empty()) + || self.in_flight_conversations.contains(scope) }); } } @@ -895,6 +1186,11 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags { Some("e") if parts.len() >= 4 => { let id = &parts[1]; let marker = &parts[3]; + // Match relay ingestion exactly: malformed IDs are ignored and + // therefore cannot overwrite an earlier valid marker. + if id.len() != 64 || !id.chars().all(|c| c.is_ascii_hexdigit()) { + continue; + } match marker.as_str() { "root" => root = Some(id.clone()), "reply" => reply = Some(id.clone()), @@ -908,13 +1204,14 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags { } } - // For direct replies to root: single "reply" tag, no "root" tag. - // In that case, root == parent. + // Match relay ingestion's marker semantics: + // - root + reply: deep reply + // - reply only: direct reply to the root (root == parent) + // - root only: accepted as top-level, with no thread metadata let (root_event_id, parent_event_id) = match (root, reply) { (Some(r), Some(p)) => (Some(r), Some(p)), - (Some(r), None) => (Some(r.clone()), Some(r)), (None, Some(p)) => (Some(p.clone()), Some(p)), - (None, None) => (None, None), + (Some(_), None) | (None, None) => (None, None), }; ThreadTags { @@ -1626,7 +1923,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec) -> Vec) -> Vec QueuedEvent { + /// A fresh, unique group-thread conversation. + /// + /// Tests that previously used a bare channel UUID as the queue key use one + /// of these: the queue's unit of isolation is the conversation, and a lone + /// channel UUID no longer identifies one. + pub(super) fn conv() -> ConversationScope { + ConversationScope::thread(Uuid::new_v4(), Uuid::new_v4().simple().to_string()) + } + + /// A second, distinct conversation inside the SAME channel as `scope`. + pub(super) fn sibling_of(scope: &ConversationScope) -> ConversationScope { + ConversationScope::thread(scope.channel_id(), Uuid::new_v4().simple().to_string()) + } + + /// Build a QueuedEvent for the given conversation. + pub(super) fn make_queued(scope: &ConversationScope, content: &str) -> QueuedEvent { QueuedEvent { - channel_id, + scope: scope.clone(), event: make_event(content), received_at: Instant::now(), prompt_tag: "test".into(), @@ -1799,9 +2130,13 @@ mod tests { } /// Build a QueuedEvent with a specific `received_at` offset from now. - fn make_queued_at(channel_id: Uuid, content: &str, age: Duration) -> QueuedEvent { + pub(super) fn make_queued_at( + scope: &ConversationScope, + content: &str, + age: Duration, + ) -> QueuedEvent { QueuedEvent { - channel_id, + scope: scope.clone(), event: make_event(content), received_at: Instant::now() - age, prompt_tag: "test".into(), @@ -1809,8 +2144,8 @@ mod tests { } /// Build a QueuedEvent with an explicit Nostr `created_at` timestamp. - fn make_queued_created_at( - channel_id: Uuid, + pub(super) fn make_queued_created_at( + scope: &ConversationScope, content: &str, created_at_secs: u64, ) -> QueuedEvent { @@ -1821,7 +2156,7 @@ mod tests { .sign_with_keys(&keys) .unwrap(); QueuedEvent { - channel_id, + scope: scope.clone(), event, received_at: Instant::now(), prompt_tag: "test".into(), @@ -1833,7 +2168,7 @@ mod tests { } fn any_in_flight(q: &EventQueue) -> bool { - !q.in_flight_channels.is_empty() + !q.in_flight_conversations.is_empty() } #[test] @@ -1849,12 +2184,12 @@ mod tests { #[test] fn test_push_flush_basic() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "hello")); + q.push(make_queued(&ch, "hello")); let batch = q.flush_next().expect("should return a batch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.scope, ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.content, "hello"); @@ -1866,14 +2201,14 @@ mod tests { #[test] fn test_in_flight_blocks_same_channel() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "first")); + q.push(make_queued(&ch, "first")); let _batch = q.flush_next().expect("first flush should succeed"); assert!(any_in_flight(&q)); // Push another event while in-flight. - q.push(make_queued(ch, "second")); + q.push(make_queued(&ch, "second")); // flush_next for the same channel must return None (it's in-flight). // No other channels exist, so result is None. @@ -1883,22 +2218,22 @@ mod tests { #[test] fn test_mark_complete_enables_flush() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "first")); + q.push(make_queued(&ch, "first")); let _batch = q.flush_next().expect("first flush should succeed"); // Push while in-flight; flush blocked (same channel in-flight). - q.push(make_queued(ch, "second")); + q.push(make_queued(&ch, "second")); assert!(q.flush_next().is_none()); // Complete the in-flight prompt. - q.mark_complete(ch); + q.mark_complete(&ch); assert!(!any_in_flight(&q)); // Now flush should succeed. let batch = q.flush_next().expect("should flush after mark_complete"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.scope, ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.content, "second"); } @@ -1906,16 +2241,16 @@ mod tests { #[test] fn test_batch_drain_all_events() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "msg1")); - q.push(make_queued(ch, "msg2")); - q.push(make_queued(ch, "msg3")); + q.push(make_queued(&ch, "msg1")); + q.push(make_queued(&ch, "msg2")); + q.push(make_queued(&ch, "msg3")); assert_eq!(pending_count(&q), 3); let batch = q.flush_next().expect("should return batch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.scope, ch); assert_eq!(batch.events.len(), 3); assert_eq!(batch.events[0].event.content, "msg1"); assert_eq!(batch.events[1].event.content, "msg2"); @@ -1934,10 +2269,10 @@ mod tests { // that it is the newest. The drained batch must therefore be // chronological regardless of delivery order. let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued_created_at(ch, "newest", 2_000)); - q.push(make_queued_created_at(ch, "oldest", 1_000)); + q.push(make_queued_created_at(&ch, "newest", 2_000)); + q.push(make_queued_created_at(&ch, "oldest", 1_000)); let batch = q.flush_next().expect("should return batch"); assert_eq!(batch.events.len(), 2); @@ -1957,43 +2292,43 @@ mod tests { #[test] fn test_fifo_fairness_picks_oldest_channel() { let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); // Channel A has an older event (2 seconds ago), B has a newer one (1 second ago). - q.push(make_queued_at(ch_a, "from A", Duration::from_secs(2))); - q.push(make_queued_at(ch_b, "from B", Duration::from_secs(1))); + q.push(make_queued_at(&ch_a, "from A", Duration::from_secs(2))); + q.push(make_queued_at(&ch_b, "from B", Duration::from_secs(1))); let batch = q.flush_next().expect("should return batch"); // A is older, so it should be picked first. - assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.scope, ch_a); assert_eq!(batch.events[0].event.content, "from A"); } #[test] fn test_multi_channel_interleave() { let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); // A is older. - q.push(make_queued_at(ch_a, "A-event", Duration::from_secs(2))); - q.push(make_queued_at(ch_b, "B-event", Duration::from_secs(1))); + q.push(make_queued_at(&ch_a, "A-event", Duration::from_secs(2))); + q.push(make_queued_at(&ch_b, "B-event", Duration::from_secs(1))); // First flush picks A. let batch_a = q.flush_next().expect("first flush"); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.scope, ch_a); assert!(any_in_flight(&q)); // B still pending. assert_eq!(pending_count(&q), 1); assert_eq!(q.queues.len(), 1); - q.mark_complete(ch_a); + q.mark_complete(&ch_a); // Second flush picks B. let batch_b = q.flush_next().expect("second flush"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.scope, ch_b); assert_eq!(batch_b.events[0].event.content, "B-event"); assert_eq!(pending_count(&q), 0); @@ -2007,7 +2342,7 @@ mod tests { #[test] fn test_format_prompt_single() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("Hello @agent"); let npub = event .pubkey @@ -2015,7 +2350,7 @@ mod tests { .unwrap_or_else(|_| event.pubkey.to_hex()); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -2031,7 +2366,7 @@ mod tests { assert!(prompt.contains("[Context]")); assert!(prompt.contains("Scope: channel")); assert!(prompt.contains("[Buzz event: @mention]\n")); - assert!(prompt.contains(&format!("Channel: {}", ch))); + assert!(prompt.contains(&format!("Channel: {}", ch.channel_id()))); assert!(prompt.contains(&format!("From: {}", npub))); assert!(prompt.contains("Content: Hello @agent")); // Event ID should be present. @@ -2043,9 +2378,9 @@ mod tests { /// Helper: build a merged (cancel + re-prompt) batch with one cancelled /// event and one new event, framed by `reason`. fn make_merged_batch(reason: Option) -> FlushBatch { - let ch = Uuid::new_v4(); + let ch = conv(); FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: make_event("the new message"), prompt_tag: "@mention".into(), @@ -2127,20 +2462,20 @@ mod tests { #[test] fn test_steer_end_to_end_queue_to_rendered_prompt() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Original turn is in flight: push the work, flush it into a batch. - q.push(make_queued(ch, "draft the migration plan")); + q.push(make_queued(&ch, "draft the migration plan")); let batch = q.flush_next().unwrap(); assert!(any_in_flight(&q)); // A steering-eligible mention arrives mid-turn. - q.push(make_queued(ch, "actually scope it to v2 only")); + q.push(make_queued(&ch, "actually scope it to v2 only")); // The mode gate fires Steer → cancel → requeue as cancelled, carrying // the steer reason (exactly the lib.rs requeue path). q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(&ch); // The re-prompt the agent actually receives. let merged = q.flush_next().unwrap(); @@ -2174,9 +2509,9 @@ mod tests { #[test] fn test_format_prompt_steer_framing_multi_event() { // Multi-event header path must also branch on reason. - let ch = Uuid::new_v4(); + let ch = conv(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![ BatchEvent { event: make_event("new one"), @@ -2208,7 +2543,7 @@ mod tests { /// your in-progress work." This is intended behavior, not a mismatch. #[test] fn test_steer_cross_thread_reply_targets_steering_message() { - let ch = Uuid::new_v4(); + let ch = conv(); let thread_a = "a".repeat(64); let thread_b = "b".repeat(64); @@ -2233,7 +2568,7 @@ mod tests { let _steering_id = steering.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: steering, prompt_tag: "@mention".into(), @@ -2272,9 +2607,9 @@ mod tests { #[test] fn test_requeue_preserves_events() { let mut queue = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - queue.push(make_queued(ch, "msg1")); - queue.push(make_queued(ch, "msg2")); + let ch = conv(); + queue.push(make_queued(&ch, "msg1")); + queue.push(make_queued(&ch, "msg2")); let batch = queue.flush_next().unwrap(); assert_eq!(batch.events.len(), 2); @@ -2282,7 +2617,7 @@ mod tests { // Simulate failure — requeue the batch. queue.requeue(batch); - queue.mark_complete(ch); + queue.mark_complete(&ch); // retry_after is set, so manually clear it for this test. queue.retry_after.remove(&ch); @@ -2307,9 +2642,9 @@ mod tests { #[test] fn test_retry_throttled_batch_is_undispatched_but_not_flushable() { let mut queue = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - queue.push(make_queued(ch, "msg1")); - queue.push(make_queued(ch, "msg2")); + let ch = conv(); + queue.push(make_queued(&ch, "msg1")); + queue.push(make_queued(&ch, "msg2")); // Drive a real failure → requeue-with-backoff → mark_complete cycle. let batch = queue.flush_next().unwrap(); @@ -2318,7 +2653,7 @@ mod tests { queue.requeue(batch).is_none(), "batch requeued, not dead-lettered" ); - queue.mark_complete(ch); + queue.mark_complete(&ch); // The batch is back in the queue, no longer in-flight, and throttled by // a future `retry_after`. BASE_RETRY_DELAY guarantees the deadline is in @@ -2347,7 +2682,7 @@ mod tests { #[test] fn test_has_undispatched_work_false_when_truly_empty_or_in_flight() { let mut queue = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Empty queue: no undispatched work. assert!(!queue.has_undispatched_work()); @@ -2355,7 +2690,7 @@ mod tests { // Dispatched batch (in-flight): the events left the queue, and an // in-flight turn is gated separately (has_in_flight), so this must be // false — otherwise the pool could never sleep after any turn. - queue.push(make_queued(ch, "msg1")); + queue.push(make_queued(&ch, "msg1")); assert!( queue.has_undispatched_work(), "queued-but-not-flushed is undispatched" @@ -2368,7 +2703,7 @@ mod tests { ); // Completed cleanly (no requeue): fully drained, nothing left. - queue.mark_complete(batch.channel_id); + queue.mark_complete(&batch.scope); assert!(!queue.has_undispatched_work()); assert!(!queue.has_in_flight()); } @@ -2376,35 +2711,35 @@ mod tests { #[test] fn test_requeue_interleaves_with_other_channels() { let mut queue = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); // ch_a has an older event. - queue.push(make_queued_at(ch_a, "A-old", Duration::from_secs(5))); - queue.push(make_queued_at(ch_b, "B-new", Duration::from_secs(1))); + queue.push(make_queued_at(&ch_a, "A-old", Duration::from_secs(5))); + queue.push(make_queued_at(&ch_b, "B-new", Duration::from_secs(1))); // Flush ch_a first (older). let batch_a = queue.flush_next().unwrap(); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.scope, ch_a); // Requeue ch_a (simulating failure) and complete. queue.requeue(batch_a); - queue.mark_complete(ch_a); + queue.mark_complete(&ch_a); // After requeue, ch_a has retry_after set (5s), so ch_b goes first. let next_batch = queue.flush_next().unwrap(); - assert_eq!(next_batch.channel_id, ch_b); + assert_eq!(next_batch.scope, ch_b); } #[test] fn test_format_prompt_batch() { - let ch = Uuid::new_v4(); + let ch = conv(); let e1 = make_event("first message"); let e2 = make_event("second message"); let e3 = make_event("third message"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![ BatchEvent { event: e1, @@ -2440,11 +2775,11 @@ mod tests { #[test] fn test_format_prompt_no_system_prompt_in_user_message() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2464,10 +2799,10 @@ mod tests { #[test] fn test_format_prompt_with_agent_core() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2496,10 +2831,10 @@ mod tests { // Modern agents (protocol_version >= 2) receive core via the system // role in session/new, so format_prompt must NOT also emit it in the // user message — otherwise core would double-render. - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2526,10 +2861,10 @@ mod tests { #[test] fn test_format_prompt_without_system_prompts_core_first() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hi"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2552,11 +2887,11 @@ mod tests { #[test] fn test_format_prompt_no_base_or_system_sections() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2576,11 +2911,11 @@ mod tests { #[test] fn test_format_prompt_legacy_agent_emits_base_and_system() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2636,9 +2971,9 @@ mod tests { // legacy session, so the largest and least informative part of the // prompt was also the most recent — crowding out the conversation and // evicting real channel history sooner. - let ch = Uuid::new_v4(); + let ch = conv(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: make_event("hello"), prompt_tag: "test".into(), @@ -2687,11 +3022,11 @@ mod tests { #[test] fn test_format_prompt_modern_agent_suppresses_base_and_system() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2726,10 +3061,10 @@ mod tests { #[test] fn test_format_prompt_ordering_with_full_context() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -2786,17 +3121,17 @@ mod tests { #[test] fn test_drop_mode_discards_in_flight_events() { let mut q = EventQueue::new(DedupMode::Drop); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "first")); + q.push(make_queued(&ch, "first")); let _batch = q.flush_next().expect("first flush"); assert!(any_in_flight(&q)); // In drop mode, pushing to the in-flight channel should be discarded. - q.push(make_queued(ch, "dropped")); + q.push(make_queued(&ch, "dropped")); assert_eq!(pending_count(&q), 0, "event should be dropped"); - q.mark_complete(ch); + q.mark_complete(&ch); // Nothing to flush. assert!(q.flush_next().is_none()); } @@ -2804,69 +3139,69 @@ mod tests { #[test] fn test_drop_mode_queues_other_channels() { let mut q = EventQueue::new(DedupMode::Drop); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); - q.push(make_queued(ch_a, "A-first")); + q.push(make_queued(&ch_a, "A-first")); let _batch = q.flush_next().expect("flush A"); assert!(any_in_flight(&q)); // Events for ch_b should still queue. - q.push(make_queued(ch_b, "B-event")); + q.push(make_queued(&ch_b, "B-event")); assert_eq!(pending_count(&q), 1); - q.mark_complete(ch_a); + q.mark_complete(&ch_a); let batch_b = q.flush_next().expect("flush B"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.scope, ch_b); } #[test] fn test_multiple_channels_in_flight_simultaneously() { let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); - q.push(make_queued_at(ch_a, "A-event", Duration::from_secs(2))); - q.push(make_queued_at(ch_b, "B-event", Duration::from_secs(1))); + q.push(make_queued_at(&ch_a, "A-event", Duration::from_secs(2))); + q.push(make_queued_at(&ch_b, "B-event", Duration::from_secs(1))); // Flush A — now A is in-flight. let batch_a = q.flush_next().expect("flush A"); - assert_eq!(batch_a.channel_id, ch_a); + assert_eq!(batch_a.scope, ch_a); assert!(any_in_flight(&q)); // Flush B — B should also be flushable (different channel). let batch_b = q.flush_next().expect("flush B while A in-flight"); - assert_eq!(batch_b.channel_id, ch_b); + assert_eq!(batch_b.scope, ch_b); // Both in-flight. - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_conversations.len(), 2); // Complete A only. - q.mark_complete(ch_a); + q.mark_complete(&ch_a); assert!(any_in_flight(&q)); // B still in-flight. // Complete B. - q.mark_complete(ch_b); + q.mark_complete(&ch_b); assert!(!any_in_flight(&q)); } #[test] fn test_same_channel_not_flushed_twice() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - let ch2 = Uuid::new_v4(); + let ch = conv(); + let ch2 = conv(); - q.push(make_queued(ch, "first")); + q.push(make_queued(&ch, "first")); let _batch = q.flush_next().expect("first flush"); // Push more events for same channel while in-flight. - q.push(make_queued(ch, "second")); + q.push(make_queued(&ch, "second")); // Also push for another channel. - q.push(make_queued(ch2, "other")); + q.push(make_queued(&ch2, "other")); // flush_next should pick ch2, not ch (ch is in-flight). let batch2 = q.flush_next().expect("should flush ch2"); - assert_eq!(batch2.channel_id, ch2); + assert_eq!(batch2.scope, ch2); // ch still in-flight — no more candidates. assert!(q.flush_next().is_none()); @@ -2875,92 +3210,92 @@ mod tests { #[test] fn test_drop_mode_drops_for_any_in_flight_channel() { let mut q = EventQueue::new(DedupMode::Drop); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); - q.push(make_queued_at(ch_a, "A-event", Duration::from_secs(2))); - q.push(make_queued_at(ch_b, "B-event", Duration::from_secs(1))); + q.push(make_queued_at(&ch_a, "A-event", Duration::from_secs(2))); + q.push(make_queued_at(&ch_b, "B-event", Duration::from_secs(1))); // Flush both — both in-flight. let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); // Drop mode: pushing to either in-flight channel is dropped. - q.push(make_queued(ch_a, "A-dropped")); - q.push(make_queued(ch_b, "B-dropped")); + q.push(make_queued(&ch_a, "A-dropped")); + q.push(make_queued(&ch_b, "B-dropped")); assert_eq!(pending_count(&q), 0); - q.mark_complete(ch_a); - q.mark_complete(ch_b); + q.mark_complete(&ch_a); + q.mark_complete(&ch_b); } #[test] fn test_flush_next_picks_oldest_non_throttled() { let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); - let ch_c = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); + let ch_c = conv(); // A is oldest, B is middle, C is newest. - q.push(make_queued_at(ch_a, "A", Duration::from_secs(10))); - q.push(make_queued_at(ch_b, "B", Duration::from_secs(5))); - q.push(make_queued_at(ch_c, "C", Duration::from_secs(1))); + q.push(make_queued_at(&ch_a, "A", Duration::from_secs(10))); + q.push(make_queued_at(&ch_b, "B", Duration::from_secs(5))); + q.push(make_queued_at(&ch_c, "C", Duration::from_secs(1))); // Flush A (oldest). let batch = q.flush_next().expect("flush A"); - assert_eq!(batch.channel_id, ch_a); + assert_eq!(batch.scope, ch_a); // A is in-flight; next oldest non-in-flight is B. let batch2 = q.flush_next().expect("flush B"); - assert_eq!(batch2.channel_id, ch_b); + assert_eq!(batch2.scope, ch_b); // A and B in-flight; only C left. let batch3 = q.flush_next().expect("flush C"); - assert_eq!(batch3.channel_id, ch_c); + assert_eq!(batch3.scope, ch_c); // All in-flight. assert!(q.flush_next().is_none()); - q.mark_complete(ch_a); - q.mark_complete(ch_b); - q.mark_complete(ch_c); + q.mark_complete(&ch_a); + q.mark_complete(&ch_b); + q.mark_complete(&ch_c); } #[test] fn test_mark_complete_clears_only_specified_channel() { let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); - q.push(make_queued_at(ch_a, "A", Duration::from_secs(2))); - q.push(make_queued_at(ch_b, "B", Duration::from_secs(1))); + q.push(make_queued_at(&ch_a, "A", Duration::from_secs(2))); + q.push(make_queued_at(&ch_b, "B", Duration::from_secs(1))); let _batch_a = q.flush_next().expect("flush A"); let _batch_b = q.flush_next().expect("flush B"); - assert_eq!(q.in_flight_channels.len(), 2); + assert_eq!(q.in_flight_conversations.len(), 2); // Complete only A. - q.mark_complete(ch_a); - assert_eq!(q.in_flight_channels.len(), 1); - assert!(q.in_flight_channels.contains(&ch_b)); - assert!(!q.in_flight_channels.contains(&ch_a)); + q.mark_complete(&ch_a); + assert_eq!(q.in_flight_conversations.len(), 1); + assert!(q.in_flight_conversations.contains(&ch_b)); + assert!(!q.in_flight_conversations.contains(&ch_a)); // B still in-flight. assert!(any_in_flight(&q)); - q.mark_complete(ch_b); + q.mark_complete(&ch_b); assert!(!any_in_flight(&q)); } #[test] fn test_requeue_preserve_timestamps() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); let old_time = Instant::now() - Duration::from_secs(10); q.push(QueuedEvent { - channel_id: ch, + scope: ch.clone(), event: make_event("old-msg"), received_at: old_time, prompt_tag: "test".into(), @@ -2971,7 +3306,7 @@ mod tests { // requeue_preserve_timestamps should keep the original timestamp. q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(&ch); // No retry_after set — should be immediately flushable. let batch2 = q.flush_next().expect("flush after requeue_preserve"); @@ -2981,13 +3316,13 @@ mod tests { #[test] fn test_requeue_preserve_timestamps_no_retry_after() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "msg")); + q.push(make_queued(&ch, "msg")); let batch = q.flush_next().expect("flush"); q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(&ch); // No retry_after — channel should be immediately flushable. assert!(!q.retry_after.contains_key(&ch)); @@ -2997,47 +3332,47 @@ mod tests { #[test] fn test_requeue_preserve_timestamps_enforces_cap() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - // Fill the channel to MAX_PENDING_PER_CHANNEL. - for i in 0..MAX_PENDING_PER_CHANNEL { - q.push(make_queued(ch, &format!("fill-{i}"))); + // Fill the channel to MAX_PENDING_PER_CONVERSATION. + for i in 0..MAX_PENDING_PER_CONVERSATION { + q.push(make_queued(&ch, &format!("fill-{i}"))); } - assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL); + assert_eq!(pending_count(&q), MAX_PENDING_PER_CONVERSATION); // Flush a batch (removes some events from the queue). let batch = q.flush_next().expect("should flush"); let batch_size = batch.events.len(); - let remaining = MAX_PENDING_PER_CHANNEL - batch_size; + let remaining = MAX_PENDING_PER_CONVERSATION - batch_size; assert_eq!(pending_count(&q), remaining); - // Push more events while the batch is "in-flight" — fill back to cap. + // Push more events while the batch is in flight. Its capacity stays + // reserved, so channel admission replaces ordinary queued events rather + // than growing queued + in-flight work beyond the hard cap. for i in 0..batch_size { - q.push(make_queued(ch, &format!("new-{i}"))); + q.push(make_queued(&ch, &format!("new-{i}"))); } - assert_eq!(pending_count(&q), MAX_PENDING_PER_CHANNEL); + assert_eq!(pending_count(&q), remaining); + assert_eq!( + q.buffered_event_count_for_channel(ch.channel_id()), + MAX_PENDING_PER_CONVERSATION + ); - // Requeue the original batch — without cap enforcement this would - // push the queue to MAX_PENDING_PER_CHANNEL + batch_size. + // Returning the original batch consumes its reservation and restores + // the queue to the exact cap without evicting the returned work. q.requeue_preserve_timestamps(batch); - // Cap must be enforced: queue should not exceed MAX_PENDING_PER_CHANNEL. - assert!( - pending_count(&q) <= MAX_PENDING_PER_CHANNEL, - "queue exceeded cap: {} > {}", - pending_count(&q), - MAX_PENDING_PER_CHANNEL, - ); + assert_eq!(pending_count(&q), MAX_PENDING_PER_CONVERSATION); } #[test] fn test_requeue_preserve_timestamps_overflow_keeps_requeued_events() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - // Push exactly MAX_PENDING_PER_CHANNEL events with identifiable content. - for i in 0..MAX_PENDING_PER_CHANNEL { - q.push(make_queued(ch, &format!("original-{i}"))); + // Push exactly MAX_PENDING_PER_CONVERSATION events with identifiable content. + for i in 0..MAX_PENDING_PER_CONVERSATION { + q.push(make_queued(&ch, &format!("original-{i}"))); } // Flush a batch — these are the "requeued" events we want to survive. @@ -3046,7 +3381,7 @@ mod tests { // Push new events to fill back to cap. for i in 0..batch_size { - q.push(make_queued(ch, &format!("new-{i}"))); + q.push(make_queued(&ch, &format!("new-{i}"))); } // Capture the content of the first requeued event for verification. @@ -3054,7 +3389,7 @@ mod tests { // Requeue — older events go to front, overflow trims from back (newest). q.requeue_preserve_timestamps(batch); - q.mark_complete(ch); + q.mark_complete(&ch); // The requeued events should be at the front of the queue. let batch2 = q.flush_next().expect("should flush after requeue"); @@ -3068,12 +3403,12 @@ mod tests { #[test] fn test_has_flushable_work() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Empty queue — no flushable work. assert!(!q.has_flushable_work()); - q.push(make_queued(ch, "msg")); + q.push(make_queued(&ch, "msg")); assert!(q.has_flushable_work()); // Flush — now in-flight, no flushable work. @@ -3081,14 +3416,14 @@ mod tests { assert!(!q.has_flushable_work()); // Complete — no pending events, no flushable work. - q.mark_complete(ch); + q.mark_complete(&ch); assert!(!q.has_flushable_work()); // Requeue with retry_after — throttled, no flushable work. - q.push(make_queued(ch, "msg2")); + q.push(make_queued(&ch, "msg2")); let batch2 = q.flush_next().expect("flush2"); q.requeue(batch2); - q.mark_complete(ch); + q.mark_complete(&ch); assert!( !q.has_flushable_work(), "throttled channel should not be flushable" @@ -3096,7 +3431,7 @@ mod tests { // Manually expire the retry_after to simulate time passing. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); assert!( q.has_flushable_work(), "expired throttle should be flushable" @@ -3106,28 +3441,28 @@ mod tests { #[test] fn test_requeue_dead_letters_after_max_retries() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "poison")); + q.push(make_queued(&ch, "poison")); for attempt in 1..=MAX_RETRIES { q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); assert!( q.requeue(batch).is_none(), "attempt {attempt} should requeue, not dead-letter" ); - q.mark_complete(ch); + q.mark_complete(&ch); } // The MAX_RETRIES+1'th failure dead-letters: batch is returned. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); let batch = q.flush_next().expect("flush"); let dead = q.requeue(batch).expect("should dead-letter"); - assert_eq!(dead.channel_id, ch); + assert_eq!(dead.scope, ch); assert_eq!(dead.events.len(), 1); - q.mark_complete(ch); + q.mark_complete(&ch); // Retry state is cleared so fresh traffic isn't throttled. assert!(!q.retry_counts.contains_key(&ch)); assert!(!q.retry_after.contains_key(&ch)); @@ -3136,32 +3471,32 @@ mod tests { #[test] fn test_retry_throttle_blocks_requeue_channel() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - let ch2 = Uuid::new_v4(); + let ch = conv(); + let ch2 = conv(); - q.push(make_queued(ch, "msg")); + q.push(make_queued(&ch, "msg")); let batch = q.flush_next().expect("flush"); // Requeue sets retry_after. q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(&ch); // Channel is throttled — flush_next should return None (no other channels). assert!(q.flush_next().is_none()); // Add a different channel — it should be flushable. - q.push(make_queued(ch2, "other")); + q.push(make_queued(&ch2, "other")); let batch2 = q.flush_next().expect("ch2 should be flushable"); - assert_eq!(batch2.channel_id, ch2); + assert_eq!(batch2.scope, ch2); // After retry_after expires, ch should be flushable again. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.mark_complete(ch2); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); + q.mark_complete(&ch2); let batch3 = q .flush_next() .expect("ch should be flushable after throttle expires"); - assert_eq!(batch3.channel_id, ch); + assert_eq!(batch3.scope, ch); } /// Build an event with specific tags for thread testing. @@ -3194,11 +3529,22 @@ mod tests { // Direct reply to root: single "reply" tag. let event = make_event_with_tags( "reply to root", - vec![vec!["e".into(), "abc123".into(), "".into(), "reply".into()]], + vec![vec![ + "e".into(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + "".into(), + "reply".into(), + ]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("abc123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("abc123")); + assert_eq!( + tags.root_event_id.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert_eq!( + tags.parent_event_id.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); } #[test] @@ -3207,13 +3553,29 @@ mod tests { let event = make_event_with_tags( "nested reply", vec![ - vec!["e".into(), "root123".into(), "".into(), "root".into()], - vec!["e".into(), "parent456".into(), "".into(), "reply".into()], + vec![ + "e".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), + "".into(), + "root".into(), + ], + vec![ + "e".into(), + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".into(), + "".into(), + "reply".into(), + ], ], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("parent456")); + assert_eq!( + tags.root_event_id.as_deref(), + Some("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + ); + assert_eq!( + tags.parent_event_id.as_deref(), + Some("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd") + ); } #[test] @@ -3232,22 +3594,27 @@ mod tests { #[test] fn test_parse_thread_tags_root_only() { - // Only root marker, no reply marker — root == parent. + // Relay ingestion treats a lone root marker as top-level. let event = make_event_with_tags( - "reply", - vec![vec!["e".into(), "root123".into(), "".into(), "root".into()]], + "top-level with root marker", + vec![vec![ + "e".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), + "".into(), + "root".into(), + ]], ); let tags = parse_thread_tags(&event); - assert_eq!(tags.root_event_id.as_deref(), Some("root123")); - assert_eq!(tags.parent_event_id.as_deref(), Some("root123")); + assert!(tags.root_event_id.is_none()); + assert!(tags.parent_event_id.is_none()); } #[test] fn test_format_prompt_with_channel_info() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3276,10 +3643,10 @@ mod tests { #[test] fn test_format_prompt_dm_scope() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hey"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3307,18 +3674,18 @@ mod tests { #[test] fn test_format_prompt_thread_scope() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event_with_tags( "yes go ahead", vec![vec![ "e".into(), - "root123".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), "".into(), "reply".into(), ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3330,23 +3697,25 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(prompt.contains("Scope: thread")); - assert!(prompt.contains("Thread root: root123")); + assert!(prompt.contains( + "Thread root: cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + )); } #[test] fn test_format_prompt_with_thread_context() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event_with_tags( "yes go ahead", vec![vec![ "e".into(), - "root123".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), "".into(), "reply".into(), ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3389,10 +3758,10 @@ mod tests { #[test] fn test_format_prompt_with_dm_context() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("ok do that"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3433,7 +3802,7 @@ mod tests { #[test] fn test_format_prompt_with_profiles_prefers_display_names() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event_with_tags( "hello there", vec![vec![ @@ -3443,7 +3812,7 @@ mod tests { ); let author_hex = event.pubkey.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -3639,19 +4008,19 @@ mod tests { #[test] fn test_format_prompt_dm_reply_hints_get_thread() { - let ch = Uuid::new_v4(); + let ch = conv(); // DM reply event — has thread e-tags. let event = make_event_with_tags( "sounds good, do it", vec![vec![ "e".into(), - "root123".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), "".into(), "reply".into(), ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3698,7 +4067,9 @@ mod tests { ); // Thread structural info should be present. assert!( - prompt.contains("Thread root: root123"), + prompt.contains( + "Thread root: cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ), "DM reply should include thread root" ); // Thread context should be included. @@ -3707,18 +4078,18 @@ mod tests { #[test] fn test_format_prompt_empty_thread_delta_distinguishes_trigger_only_from_delivered() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event_with_tags( "follow up", vec![vec![ "e".into(), - "root123".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), "".into(), "reply".into(), ]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3749,9 +4120,9 @@ mod tests { #[test] fn test_format_prompt_empty_dm_delta_distinguishes_trigger_only_from_delivered() { - let ch = Uuid::new_v4(); + let ch = conv(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: make_event("follow up"), prompt_tag: "dm".into(), @@ -3797,10 +4168,10 @@ mod tests { #[test] fn test_format_prompt_dm_non_reply_hints_get_messages() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hey there"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "dm".into(), @@ -3837,11 +4208,11 @@ mod tests { #[test] fn test_format_event_block_includes_event_id() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("test"); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3860,12 +4231,12 @@ mod tests { #[test] fn test_format_event_block_includes_hex_and_npub() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("test"); let hex = event.pubkey.to_hex(); let npub = event.pubkey.to_bech32().unwrap(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3884,11 +4255,11 @@ mod tests { #[test] fn test_format_event_block_always_includes_tags() { - let ch = Uuid::new_v4(); + let ch = conv(); // Kind 9 (stream message) — tags were previously stripped. let event = make_event_with_tags("hello", vec![vec!["h".into(), ch.to_string()]]); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -3908,13 +4279,13 @@ mod tests { #[test] fn test_drain_channel_removes_pending_events() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "msg1")); - q.push(make_queued(ch, "msg2")); + q.push(make_queued(&ch, "msg1")); + q.push(make_queued(&ch, "msg2")); assert_eq!(pending_count(&q), 2); - let drained = q.drain_channel(ch); + let drained = q.drain_channel(ch.channel_id()); assert_eq!(drained.len(), 2); assert_eq!(pending_count(&q), 0); } @@ -3922,13 +4293,13 @@ mod tests { #[test] fn test_drain_channel_does_not_affect_other_channels() { let mut q = EventQueue::new(DedupMode::Queue); - let ch_a = Uuid::new_v4(); - let ch_b = Uuid::new_v4(); + let ch_a = conv(); + let ch_b = conv(); - q.push(make_queued(ch_a, "A")); - q.push(make_queued(ch_b, "B")); + q.push(make_queued(&ch_a, "A")); + q.push(make_queued(&ch_b, "B")); - let drained = q.drain_channel(ch_a); + let drained = q.drain_channel(ch_a.channel_id()); assert_eq!(drained.len(), 1); assert_eq!(pending_count(&q), 1); // ch_b still has 1 } @@ -3936,16 +4307,16 @@ mod tests { #[test] fn test_drain_channel_clears_retry_after() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "msg")); + q.push(make_queued(&ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue(batch); // sets retry_after - q.mark_complete(ch); + q.mark_complete(&ch); // Channel is throttled — verify drain clears it. assert!(!q.has_flushable_work()); - let drained = q.drain_channel(ch); + let drained = q.drain_channel(ch.channel_id()); assert_eq!(drained.len(), 1); assert_eq!(pending_count(&q), 0); } @@ -3953,24 +4324,24 @@ mod tests { #[test] fn test_drain_channel_empty_returns_empty() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - assert!(q.drain_channel(ch).is_empty()); + let ch = conv(); + assert!(q.drain_channel(ch.channel_id()).is_empty()); } #[test] fn test_drain_channel_does_not_affect_in_flight() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.push(make_queued(ch, "msg1")); + q.push(make_queued(&ch, "msg1")); let _batch = q.flush_next().unwrap(); // now in-flight assert!(any_in_flight(&q)); // Push another event while in-flight. - q.push(make_queued(ch, "msg2")); + q.push(make_queued(&ch, "msg2")); // drain_channel should only remove the queued event, not the in-flight one. - let drained = q.drain_channel(ch); + let drained = q.drain_channel(ch.channel_id()); assert_eq!(drained.len(), 1); assert!(any_in_flight(&q)); // in-flight unaffected } @@ -3978,14 +4349,14 @@ mod tests { #[test] fn test_compact_cleans_orphaned_retry_counts() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Simulate: push, flush, requeue (sets retry_after + retry_counts), // then mark_complete (preserves retry_counts because throttle is active). - q.push(make_queued(ch, "msg1")); + q.push(make_queued(&ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(&ch); assert!(q.retry_after.contains_key(&ch)); assert!(q.retry_counts.contains_key(&ch)); @@ -3993,15 +4364,15 @@ mod tests { // queue is empty (simulating a successful retry dispatch). // We need to wait for retry_after to expire first. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Now mark_complete with no active throttle — clears retry_counts. - q.mark_complete(ch); + q.mark_complete(&ch); assert!(!q.retry_counts.contains_key(&ch)); // Re-create the orphan scenario: manually insert stale retry_counts // with no queue, no throttle, and no in-flight. - q.retry_counts.insert(ch, 3); + q.retry_counts.insert(ch.clone(), 3); q.compact_expired_state(); assert!( !q.retry_counts.contains_key(&ch), @@ -4012,20 +4383,20 @@ mod tests { #[test] fn test_compact_preserves_retry_counts_when_in_flight() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Push, flush, requeue, mark_complete — sets up retry state. - q.push(make_queued(ch, "msg1")); + q.push(make_queued(&ch, "msg1")); let batch = q.flush_next().unwrap(); q.requeue(batch); - q.mark_complete(ch); + q.mark_complete(&ch); // Expire the throttle so the requeued event can be flushed. q.retry_after - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); let _batch2 = q.flush_next().unwrap(); // Channel is now in-flight with empty queue and expired throttle. - assert!(q.in_flight_channels.contains(&ch)); + assert!(q.in_flight_conversations.contains(&ch)); assert!(q.queues.get(&ch).is_none_or(|q| q.is_empty())); // compact must NOT remove retry_counts — the in-flight attempt @@ -4040,11 +4411,11 @@ mod tests { #[test] fn test_compact_preserves_retry_counts_with_queued_events() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Manually set up: retry_counts exists, queue is non-empty, no throttle. - q.push(make_queued(ch, "msg1")); - q.retry_counts.insert(ch, 2); + q.push(make_queued(&ch, "msg1")); + q.retry_counts.insert(ch.clone(), 2); q.compact_expired_state(); assert!( @@ -4056,20 +4427,20 @@ mod tests { #[test] fn test_requeue_as_cancelled_merges_in_flush_next() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Push 2 events, flush into a batch. - q.push(make_queued(ch, "old-1")); - q.push(make_queued(ch, "old-2")); + q.push(make_queued(&ch, "old-1")); + q.push(make_queued(&ch, "old-2")); let batch = q.flush_next().unwrap(); assert_eq!(batch.events.len(), 2); // Push 1 new event while channel is in-flight. - q.push(make_queued(ch, "new-1")); + q.push(make_queued(&ch, "new-1")); // Cancel the original batch and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); // flush_next should merge: events=[new-1], cancelled_events=[old-1, old-2]. let next = q.flush_next().unwrap(); @@ -4084,37 +4455,37 @@ mod tests { #[test] fn test_requeue_as_cancelled_propagates_reason() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Merge path (new event present): reason rides on FlushBatch. - q.push(make_queued(ch, "old")); + q.push(make_queued(&ch, "old")); let batch = q.flush_next().unwrap(); - q.push(make_queued(ch, "new")); + q.push(make_queued(&ch, "new")); q.requeue_as_cancelled(batch, CancelReason::Steer); - q.mark_complete(ch); + q.mark_complete(&ch); let merged = q.flush_next().unwrap(); assert_eq!( merged.cancel_reason, Some(CancelReason::Steer), "steer reason should reach the merged batch" ); - q.mark_complete(ch); + q.mark_complete(&ch); // Fallback path (no new event): reason still rides through. - q.push(make_queued(ch, "only")); + q.push(make_queued(&ch, "only")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); let fallback = q.flush_next().unwrap(); assert_eq!( fallback.cancel_reason, Some(CancelReason::Interrupt), "interrupt reason should reach the re-dispatched batch" ); - q.mark_complete(ch); + q.mark_complete(&ch); // A normal (non-cancel) flush carries no reason. - q.push(make_queued(ch, "plain")); + q.push(make_queued(&ch, "plain")); let plain = q.flush_next().unwrap(); assert_eq!(plain.cancel_reason, None); } @@ -4122,17 +4493,17 @@ mod tests { #[test] fn test_double_cancel_latest_reason_wins() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - q.push(make_queued(ch, "orig")); + let ch = conv(); + q.push(make_queued(&ch, "orig")); let batch1 = q.flush_next().unwrap(); - q.push(make_queued(ch, "new-1")); + q.push(make_queued(&ch, "new-1")); q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); let batch2 = q.flush_next().unwrap(); // Second cancel with a different reason — the latest reason wins. q.requeue_as_cancelled(batch2, CancelReason::Steer); - q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.push(make_queued(&ch, "new-2")); + q.mark_complete(&ch); let batch3 = q.flush_next().unwrap(); assert_eq!(batch3.cancel_reason, Some(CancelReason::Steer)); } @@ -4142,15 +4513,15 @@ mod tests { #[test] fn test_requeue_as_cancelled_no_new_events_fallback() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Push 1 event, flush into a batch. - q.push(make_queued(ch, "only-event")); + q.push(make_queued(&ch, "only-event")); let batch = q.flush_next().unwrap(); // Cancel the batch (no new events pushed) and release the channel. q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); // Fallback path: cancelled events become regular events, cancelled_events is empty. let next = q.flush_next().unwrap(); @@ -4168,13 +4539,13 @@ mod tests { #[test] fn test_has_flushable_work_with_cancelled_only() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Push, flush, cancel — no new events queued. - q.push(make_queued(ch, "msg")); + q.push(make_queued(&ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); // Channel has only cancelled events — should still be considered flushable. assert!( @@ -4186,16 +4557,16 @@ mod tests { #[test] fn test_drain_channel_clears_cancelled_batches() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Push, flush, cancel. - q.push(make_queued(ch, "msg")); + q.push(make_queued(&ch, "msg")); let batch = q.flush_next().unwrap(); q.requeue_as_cancelled(batch, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); // drain_channel should clear cancelled_batches for the channel. - q.drain_channel(ch); + q.drain_channel(ch.channel_id()); assert!(!q.has_flushable_work(), "nothing left after drain"); assert!( @@ -4207,20 +4578,20 @@ mod tests { #[test] fn test_double_cancel_preserves_all_events() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // First flush: 2 events. - q.push(make_queued(ch, "orig-1")); - q.push(make_queued(ch, "orig-2")); + q.push(make_queued(&ch, "orig-1")); + q.push(make_queued(&ch, "orig-2")); let batch1 = q.flush_next().unwrap(); assert_eq!(batch1.events.len(), 2); // Push 1 new event while in-flight. - q.push(make_queued(ch, "new-1")); + q.push(make_queued(&ch, "new-1")); // First cancel: store 2 cancelled events. q.requeue_as_cancelled(batch1, CancelReason::Interrupt); - q.mark_complete(ch); + q.mark_complete(&ch); // Second flush: events=[new-1], cancelled_events=[orig-1, orig-2]. let batch2 = q.flush_next().unwrap(); @@ -4232,8 +4603,8 @@ mod tests { q.requeue_as_cancelled(batch2, CancelReason::Interrupt); // Push 1 more new event and release channel. - q.push(make_queued(ch, "new-2")); - q.mark_complete(ch); + q.push(make_queued(&ch, "new-2")); + q.mark_complete(&ch); // Third flush: events=[new-2], cancelled_events=[orig-1, orig-2, new-1]. let batch3 = q.flush_next().unwrap(); @@ -4247,14 +4618,14 @@ mod tests { #[test] fn test_reply_instruction_present_for_channel_thread_reply() { - let ch = Uuid::new_v4(); + let ch = conv(); let root_id = "a".repeat(64); let event = make_event_with_tags( "@bot help", vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4288,7 +4659,7 @@ mod tests { #[test] fn test_reply_instruction_present_for_dm_thread_reply() { - let ch = Uuid::new_v4(); + let ch = conv(); let root_id = "b".repeat(64); let event = make_event_with_tags( "thanks", @@ -4296,7 +4667,7 @@ mod tests { ); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4327,11 +4698,11 @@ mod tests { #[test] fn test_reply_instruction_present_for_top_level_human_message() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hello world"); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4357,10 +4728,10 @@ mod tests { #[test] fn test_reply_instruction_absent_for_dm_non_reply() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event("hey there"); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -4391,7 +4762,7 @@ mod tests { #[test] fn test_human_thread_reply_anchors_to_root_not_triggering_or_parent() { - let ch = Uuid::new_v4(); + let ch = conv(); let root_id = "a".repeat(64); let parent_id = "b".repeat(64); let event = make_event_with_tags( @@ -4403,7 +4774,7 @@ mod tests { ); let event_id = event.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4432,14 +4803,14 @@ mod tests { #[test] fn test_reply_instruction_allows_explicit_root_post_requests() { - let ch = Uuid::new_v4(); + let ch = conv(); let root_id = "e".repeat(64); let event = make_event_with_tags( "@bot post your summary in the channel root", vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "@mention".into(), @@ -4466,7 +4837,7 @@ mod tests { #[test] fn test_reply_instruction_batched_last_event_is_threaded() { - let ch = Uuid::new_v4(); + let ch = conv(); let plain = make_event("unrelated"); let root_id = "c".repeat(64); let threaded = make_event_with_tags( @@ -4474,7 +4845,7 @@ mod tests { vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]], ); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![ BatchEvent { event: plain, @@ -4502,7 +4873,7 @@ mod tests { #[test] fn test_reply_instruction_batched_last_event_is_top_level() { - let ch = Uuid::new_v4(); + let ch = conv(); let root_id = "d".repeat(64); let threaded = make_event_with_tags( "earlier thread msg", @@ -4511,7 +4882,7 @@ mod tests { let plain = make_event("latest top-level"); let plain_id = plain.id.to_hex(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![ BatchEvent { event: threaded, @@ -4544,7 +4915,7 @@ mod tests { /// Build a single-event FlushBatch with the given content. fn make_single_batch(content: &str) -> FlushBatch { FlushBatch { - channel_id: Uuid::new_v4(), + scope: conv(), events: vec![BatchEvent { event: make_event(content), prompt_tag: "test".into(), @@ -4662,13 +5033,13 @@ mod tests { #[test] fn test_native_steer_withhold_only_channel_not_flushable() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - let qe = make_queued(ch, "hello"); + let qe = make_queued(&ch, "hello"); let event_id = qe.event.id.to_hex(); q.push(qe); - assert!(q.mark_native_steer_pending(ch, &event_id)); + assert!(q.mark_native_steer_pending(&ch, &event_id)); assert!( q.flush_next().is_none(), @@ -4690,13 +5061,13 @@ mod tests { #[test] fn test_native_steer_earlier_events_flush_during_ack_window() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Three events arrive in order: e1, e2 (already queued), then e3 // (the latest mid-turn mention being steered). - let e1 = make_queued_at(ch, "e1", Duration::from_millis(30)); - let e2 = make_queued_at(ch, "e2", Duration::from_millis(20)); - let e3 = make_queued_at(ch, "e3", Duration::from_millis(10)); + let e1 = make_queued_at(&ch, "e1", Duration::from_millis(30)); + let e2 = make_queued_at(&ch, "e2", Duration::from_millis(20)); + let e3 = make_queued_at(&ch, "e3", Duration::from_millis(10)); let e1_id = e1.event.id.to_hex(); let e2_id = e2.event.id.to_hex(); let e3_id = e3.event.id.to_hex(); @@ -4705,25 +5076,25 @@ mod tests { q.push(e3); // Steer in flight for e3 — withhold it from normal dispatch. - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(&ch, &e3_id)); // Earlier events flush as a normal batch; e3 is invisible. let batch = q .flush_next() .expect("e1+e2 should flush during ack window"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.scope, ch); assert_eq!(batch.events.len(), 2); assert_eq!(batch.events[0].event.id.to_hex(), e1_id); assert_eq!(batch.events[1].event.id.to_hex(), e2_id); // Earlier batch completes; channel is no longer in flight. - q.mark_complete(ch); + q.mark_complete(&ch); // Ack arrives as Err or PromptCompletedNeutral → release e3. - q.release_native_steer(ch, &e3_id); + q.release_native_steer(&ch, &e3_id); let next = q.flush_next().expect("released e3 should now flush"); - assert_eq!(next.channel_id, ch); + assert_eq!(next.scope, ch); assert_eq!(next.events.len(), 1); assert_eq!(next.events[0].event.id.to_hex(), e3_id); @@ -4739,25 +5110,25 @@ mod tests { #[test] fn test_native_steer_expiry_recovers_withheld() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - let qe = make_queued(ch, "withheld event"); + let qe = make_queued(&ch, "withheld event"); let event_id = qe.event.id.to_hex(); q.push(qe); // Simulate a prompt in flight for `ch`, then withhold the queued // event for an in-flight goose-native steer. - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); - assert!(q.mark_native_steer_pending(ch, &event_id)); + q.in_flight_conversations.insert(ch.clone()); + q.in_flight_deadlines.insert(ch.clone(), Instant::now()); + q.in_flight_batch_sizes.insert(ch.clone(), 1); + assert!(q.mark_native_steer_pending(&ch, &event_id)); // Force the in-flight deadline to be in the past, simulating the // steer ack never arriving and the read loop hanging long enough // for `in_flight_deadline` to elapse. Same expiry-simulation // trick used by `test_retry_throttle_blocks_requeue_channel`. q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); // `has_flushable_work` runs the expiry block first; it must recover // the withheld event so the channel registers as flushable. @@ -4774,7 +5145,7 @@ mod tests { let batch = q .flush_next() .expect("recovered event should flush via normal dispatch"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.scope, ch); assert_eq!(batch.events.len(), 1); assert_eq!(batch.events[0].event.id.to_hex(), event_id); } @@ -4786,12 +5157,12 @@ mod tests { #[test] fn test_native_steer_bulk_release_preserves_fifo() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Three events with staggered ages — e1 oldest, e3 newest. - let e1 = make_queued_at(ch, "e1", Duration::from_millis(30)); - let e2 = make_queued_at(ch, "e2", Duration::from_millis(20)); - let e3 = make_queued_at(ch, "e3", Duration::from_millis(10)); + let e1 = make_queued_at(&ch, "e1", Duration::from_millis(30)); + let e2 = make_queued_at(&ch, "e2", Duration::from_millis(20)); + let e3 = make_queued_at(&ch, "e3", Duration::from_millis(10)); let e1_id = e1.event.id.to_hex(); let e2_id = e2.event.id.to_hex(); let e3_id = e3.event.id.to_hex(); @@ -4805,17 +5176,17 @@ mod tests { // tests. What matters here is that the bulk-recovery path // (reverse iter + push_front) composes to original FIFO at the // queue front. - assert!(q.mark_native_steer_pending(ch, &e1_id)); - assert!(q.mark_native_steer_pending(ch, &e2_id)); - assert!(q.mark_native_steer_pending(ch, &e3_id)); + assert!(q.mark_native_steer_pending(&ch, &e1_id)); + assert!(q.mark_native_steer_pending(&ch, &e2_id)); + assert!(q.mark_native_steer_pending(&ch, &e3_id)); assert_eq!(pending_count(&q), 0); assert_eq!(q.withheld_native_steer.get(&ch).map(|v| v.len()), Some(3)); // Trigger expiry → bulk-release path. - q.in_flight_channels.insert(ch); + q.in_flight_conversations.insert(ch.clone()); q.in_flight_deadlines - .insert(ch, Instant::now() - Duration::from_secs(1)); - q.in_flight_batch_sizes.insert(ch, 3); + .insert(ch.clone(), Instant::now() - Duration::from_secs(1)); + q.in_flight_batch_sizes.insert(ch.clone(), 3); assert!(q.has_flushable_work()); // After recovery, the queue front-to-back order must match the @@ -4836,9 +5207,9 @@ mod tests { #[test] fn test_format_prompt_canvas_injected_for_legacy_agent() { let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; - let ch = Uuid::new_v4(); + let ch = conv(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4865,9 +5236,9 @@ mod tests { #[test] fn test_format_prompt_canvas_omitted_for_modern_agent() { let canvas = "[Channel Canvas]\nCanvas revision (event ID): abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234\nLast modified: 2024-01-15T10:30:00+00:00\nFetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae"; - let ch = Uuid::new_v4(); + let ch = conv(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4893,9 +5264,9 @@ mod tests { #[test] fn test_format_prompt_no_canvas_produces_no_canvas_section() { - let ch = Uuid::new_v4(); + let ch = conv(); let batch = FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event: make_event("hi"), prompt_tag: "test".into(), @@ -4942,12 +5313,12 @@ mod tests { #[test] fn extend_in_flight_deadline_advances_existing_entry() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); let old_deadline = Instant::now() + Duration::from_secs(100); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, old_deadline); + q.in_flight_conversations.insert(ch.clone()); + q.in_flight_deadlines.insert(ch.clone(), old_deadline); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(&ch, 7200); let new = *q.in_flight_deadlines.get(&ch).unwrap(); assert!( new > old_deadline, @@ -4958,12 +5329,12 @@ mod tests { #[test] fn extend_in_flight_deadline_is_monotonic() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); let far_future = Instant::now() + Duration::from_secs(999_999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, far_future); + q.in_flight_conversations.insert(ch.clone()); + q.in_flight_deadlines.insert(ch.clone(), far_future); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(&ch, 7200); let after = *q.in_flight_deadlines.get(&ch).unwrap(); assert_eq!(after, far_future, "deadline must never move backward"); } @@ -4971,16 +5342,16 @@ mod tests { #[test] fn extend_in_flight_deadline_noop_after_mark_complete() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); - q.in_flight_channels.insert(ch); + let ch = conv(); + q.in_flight_conversations.insert(ch.clone()); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(ch.clone(), Instant::now() + Duration::from_secs(100)); + q.in_flight_batch_sizes.insert(ch.clone(), 1); - q.mark_complete(ch); + q.mark_complete(&ch); assert!(!q.in_flight_deadlines.contains_key(&ch)); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(&ch, 7200); assert!( !q.in_flight_deadlines.contains_key(&ch), "extend after mark_complete must not resurrect a deadline" @@ -4990,10 +5361,10 @@ mod tests { #[test] fn compact_expired_state_preserves_extended_in_flight_deadline() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); let extended = Instant::now() + Duration::from_secs(9999); - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, extended); + q.in_flight_conversations.insert(ch.clone()); + q.in_flight_deadlines.insert(ch.clone(), extended); q.compact_expired_state(); @@ -5017,23 +5388,23 @@ mod tests { #[test] fn expired_in_flight_deadline_is_auto_released_by_flush_next() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Insert the channel as in-flight with a deadline already in the past // (Instant::now() — by the time flush_next runs, now >= deadline). - q.in_flight_channels.insert(ch); - q.in_flight_deadlines.insert(ch, Instant::now()); - q.in_flight_batch_sizes.insert(ch, 1); + q.in_flight_conversations.insert(ch.clone()); + q.in_flight_deadlines.insert(ch.clone(), Instant::now()); + q.in_flight_batch_sizes.insert(ch.clone(), 1); // Also push an event so flush_next has something to do after expiry. - q.push(make_queued(ch, "after-expiry")); + q.push(make_queued(&ch, "after-expiry")); // flush_next auto-expires the stuck entry and then dispatches the // pending event for the now-freed channel. let batch = q .flush_next() .expect("channel should be dispatchable after auto-expiry"); - assert_eq!(batch.channel_id, ch); + assert_eq!(batch.scope, ch); assert_eq!(batch.events[0].event.content, "after-expiry"); } @@ -5046,27 +5417,27 @@ mod tests { #[test] fn extended_deadline_prevents_flush_next_expiry() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // Put the channel in-flight with an extended deadline far in the future. - q.in_flight_channels.insert(ch); + q.in_flight_conversations.insert(ch.clone()); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(ch.clone(), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(ch.clone(), 1); // Push an event for another channel so flush_next has work to do. - let ch2 = Uuid::new_v4(); - q.push(make_queued(ch2, "other-channel")); + let ch2 = conv(); + q.push(make_queued(&ch2, "other-channel")); let batch = q.flush_next().expect("other channel should flush"); assert_eq!( - batch.channel_id, ch2, + batch.scope, ch2, "only ch2 should be flushed; ch is still in-flight with extended deadline" ); // ch must still be in-flight — the extended deadline did not expire. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_conversations.contains(&ch), "ch must remain in-flight after flush_next with an extended deadline" ); assert!( @@ -5084,13 +5455,13 @@ mod tests { #[test] fn extended_deadline_prevents_has_flushable_work_expiry() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); // In-flight channel with extended (far-future) deadline. - q.in_flight_channels.insert(ch); + q.in_flight_conversations.insert(ch.clone()); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(9999)); - q.in_flight_batch_sizes.insert(ch, 1); + .insert(ch.clone(), Instant::now() + Duration::from_secs(9999)); + q.in_flight_batch_sizes.insert(ch.clone(), 1); // No other channels — nothing flushable. assert!( @@ -5098,20 +5469,20 @@ mod tests { "has_flushable_work must return false when the only channel is in-flight with extended deadline" ); assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_conversations.contains(&ch), "ch must remain in-flight after has_flushable_work with extended deadline" ); // Add a pending event for a different channel. - let ch2 = Uuid::new_v4(); - q.push(make_queued(ch2, "pending")); + let ch2 = conv(); + q.push(make_queued(&ch2, "pending")); assert!( q.has_flushable_work(), "has_flushable_work must return true for the pending ch2 event" ); // ch still in-flight and not expired. assert!( - q.in_flight_channels.contains(&ch), + q.in_flight_conversations.contains(&ch), "ch must still be in-flight after has_flushable_work finds ch2 work" ); } @@ -5124,16 +5495,16 @@ mod tests { #[test] fn repeated_extend_in_flight_deadline_is_monotonic() { let mut q = EventQueue::new(DedupMode::Queue); - let ch = Uuid::new_v4(); + let ch = conv(); - q.in_flight_channels.insert(ch); + q.in_flight_conversations.insert(ch.clone()); q.in_flight_deadlines - .insert(ch, Instant::now() + Duration::from_secs(100)); + .insert(ch.clone(), Instant::now() + Duration::from_secs(100)); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(&ch, 7200); let after_first = *q.in_flight_deadlines.get(&ch).unwrap(); - q.extend_in_flight_deadline(ch, 7200); + q.extend_in_flight_deadline(&ch, 7200); let after_second = *q.in_flight_deadlines.get(&ch).unwrap(); assert!( @@ -5262,9 +5633,9 @@ mod tests { ); } - fn description_batch(ch: Uuid, event: Event) -> FlushBatch { + fn description_batch(ch: ConversationScope, event: Event) -> FlushBatch { FlushBatch { - channel_id: ch, + scope: ch.clone(), events: vec![BatchEvent { event, prompt_tag: "test".into(), @@ -5277,7 +5648,7 @@ mod tests { #[test] fn test_format_prompt_includes_description_in_context_for_channel_turn() { - let ch = Uuid::new_v4(); + let ch = conv(); let batch = description_batch(ch, make_event("what should we build?")); let ci = PromptChannelInfo { name: "engineering".into(), @@ -5305,12 +5676,12 @@ mod tests { #[test] fn test_format_prompt_includes_description_in_context_for_thread_turn() { - let ch = Uuid::new_v4(); + let ch = conv(); let event = make_event_with_tags( "reply in thread", vec![vec![ "e".into(), - "root123".into(), + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".into(), "".into(), "reply".into(), ]], @@ -5342,7 +5713,7 @@ mod tests { #[test] fn test_format_prompt_excludes_description_for_dm_turn() { - let ch = Uuid::new_v4(); + let ch = conv(); let batch = description_batch(ch, make_event("hey")); let ci = PromptChannelInfo { name: "DM".into(), @@ -5370,7 +5741,7 @@ mod tests { #[test] fn test_format_prompt_no_description_when_channel_metadata_unresolved() { - let ch = Uuid::new_v4(); + let ch = conv(); let batch = description_batch(ch, make_event("what should we build?")); // channel_info None models unresolved metadata: no name, no description. let prompt = format_prompt( @@ -5392,3 +5763,533 @@ mod tests { ); } } + +/// Conversation isolation: the contract that two threads in one channel are two +/// independent units of work, while a DM channel stays a single unit. +/// +/// These tests exercise the queue only — scope *derivation* is covered in +/// `crate::conversation`, and session/turn-count isolation in `crate::pool`. +#[cfg(test)] +mod conversation_isolation_tests { + use super::tests::{conv, make_queued, sibling_of}; + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn top_level(content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + fn reply_to(root_id: &str, content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([Tag::parse(["e", root_id, "", "reply"]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + /// Push an event under the scope it derives to for a group channel. + fn push_group(q: &mut EventQueue, channel_id: Uuid, event: Event) -> bool { + q.push(QueuedEvent { + scope: ConversationScope::for_event(channel_id, false, &event), + event, + received_at: Instant::now(), + prompt_tag: "test".into(), + }) + } + + #[test] + fn retrying_a_merged_batch_preserves_prior_cancelled_work_and_framing() { + let mut q = EventQueue::new(DedupMode::Queue); + let scope = conv(); + + q.push(make_queued(&scope, "original request")); + let original = q.flush_next().expect("original request flushes"); + let original_id = original.events[0].event.id.to_hex(); + q.requeue_as_cancelled(original, CancelReason::Steer); + q.mark_complete(&scope); + + q.push(make_queued(&scope, "new request")); + let merged = q.flush_next().expect("merged replacement flushes"); + let new_id = merged.events[0].event.id.to_hex(); + assert_eq!(merged.cancelled_events.len(), 1); + assert_eq!(merged.cancel_reason, Some(CancelReason::Steer)); + + assert!(q.requeue(merged).is_none(), "first retry must be retained"); + q.mark_complete(&scope); + q.retry_after + .insert(scope.clone(), Instant::now() - Duration::from_secs(1)); + + let retried = q.flush_next().expect("merged batch retries"); + assert!(retried + .events + .iter() + .any(|event| event.event.id.to_hex() == new_id)); + assert!(retried + .cancelled_events + .iter() + .any(|event| event.event.id.to_hex() == original_id)); + assert_eq!(retried.cancel_reason, Some(CancelReason::Steer)); + } + + #[test] + fn protected_only_channel_cap_rejects_overflow_without_evicting_protected_work() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + for index in 0..MAX_PENDING_PER_CHANNEL { + let scope = ConversationScope::thread(channel_id, format!("{index:064x}")); + let queued = make_queued(&scope, &format!("protected-{index}")); + q.requeue_as_cancelled( + FlushBatch { + scope, + events: vec![BatchEvent { + event: queued.event, + prompt_tag: queued.prompt_tag, + received_at: queued.received_at, + }], + cancelled_events: vec![], + cancel_reason: Some(CancelReason::Steer), + }, + CancelReason::Steer, + ); + } + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL + ); + let overflow_scope = ConversationScope::thread(channel_id, "f".repeat(64)); + let overflow = make_queued(&overflow_scope, "protected insert must be rejected"); + q.requeue_as_cancelled( + FlushBatch { + scope: overflow_scope.clone(), + events: vec![BatchEvent { + event: overflow.event, + prompt_tag: overflow.prompt_tag, + received_at: overflow.received_at, + }], + cancelled_events: vec![], + cancel_reason: Some(CancelReason::Steer), + }, + CancelReason::Steer, + ); + assert!(!q.cancelled_batches.contains_key(&overflow_scope)); + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL, + "protected-only insertion overflow must fail before mutation" + ); + assert!(!q.push(make_queued(&overflow_scope, "must be rejected"))); + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL, + "protected work must not be evicted to admit overflow" + ); + assert_eq!( + q.cancelled_batches.values().map(Vec::len).sum::(), + MAX_PENDING_PER_CHANNEL + ); + } + + #[test] + fn cancelled_in_flight_return_is_reserved_at_channel_limit() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let in_flight_scope = ConversationScope::thread(channel_id, "a".repeat(64)); + let sibling_scope = ConversationScope::thread(channel_id, "b".repeat(64)); + q.push(make_queued(&in_flight_scope, "protected return")); + let batch = q.flush_next().expect("batch is in flight"); + let returned_id = batch.events[0].event.id.to_hex(); + for index in 0..(MAX_PENDING_PER_CHANNEL - 1) { + assert!(q.push(make_queued(&sibling_scope, &format!("sibling-{index}")))); + } + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL, + "the dispatched batch must keep aggregate channel capacity reserved" + ); + + q.requeue_as_cancelled(batch, CancelReason::Steer); + q.mark_complete(&in_flight_scope); + + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL + ); + assert!(q.cancelled_batches[&in_flight_scope] + .iter() + .any(|event| event.event.id.to_hex() == returned_id)); + assert_eq!( + q.queued_event_count(&sibling_scope), + MAX_PENDING_PER_CHANNEL - 1, + "returning protected work must not evict ordinary sibling work when capacity was reserved" + ); + } + + #[test] + fn global_5000_event_boundary_reserves_and_restores_in_flight_work() { + let mut q = EventQueue::new(DedupMode::Queue); + let first_channel = Uuid::new_v4(); + let in_flight_scope = ConversationScope::thread(first_channel, "a".repeat(64)); + let sibling_scope = ConversationScope::thread(first_channel, "b".repeat(64)); + q.push(make_queued(&in_flight_scope, "global protected return")); + let batch = q.flush_next().expect("batch is in flight"); + let returned_id = batch.events[0].event.id.to_hex(); + for index in 0..(MAX_PENDING_PER_CHANNEL - 1) { + assert!(q.push(make_queued(&sibling_scope, &format!("first-{index}")))); + } + for channel_index in 0..9 { + let scope = ConversationScope::dm(Uuid::new_v4()); + for event_index in 0..MAX_PENDING_PER_CHANNEL { + assert!(q.push(make_queued( + &scope, + &format!("global-{channel_index}-{event_index}") + ))); + } + } + + assert_eq!( + q.buffered_event_count(), + MAX_GLOBAL_BUFFERED_EVENTS, + "boundary must count the real in-flight reservation" + ); + let overflow_scope = ConversationScope::dm(Uuid::new_v4()); + assert!(!q.push(make_queued(&overflow_scope, "event 5001"))); + assert_eq!(q.buffered_event_count(), MAX_GLOBAL_BUFFERED_EVENTS); + + q.requeue_preserve_timestamps(batch); + q.mark_complete(&in_flight_scope); + + assert_eq!(q.buffered_event_count(), MAX_GLOBAL_BUFFERED_EVENTS); + assert!(q.queues[&in_flight_scope] + .iter() + .any(|event| event.event.id.to_hex() == returned_id)); + } + + #[test] + fn native_steer_release_keeps_reserved_channel_cap_hard() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let active_scope = ConversationScope::thread(channel_id, "a".repeat(64)); + let sibling_scope = ConversationScope::thread(channel_id, "b".repeat(64)); + q.push(make_queued(&active_scope, "active batch")); + q.flush_next().expect("active batch flushes"); + let steered = make_queued(&active_scope, "withheld steer"); + let steered_id = steered.event.id.to_hex(); + assert!(q.push(steered)); + assert!(q.mark_native_steer_pending(&active_scope, &steered_id)); + for index in 0..(MAX_PENDING_PER_CHANNEL - 2) { + assert!(q.push(make_queued(&sibling_scope, &format!("fill-{index}")))); + } + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL + ); + + q.release_native_steer(&active_scope, &steered_id); + + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL + ); + assert!(q.queues[&active_scope] + .iter() + .any(|event| event.event.id.to_hex() == steered_id)); + } + + #[test] + fn aggregate_channel_cap_is_preserved_when_an_in_flight_batch_requeues() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let a = ConversationScope::thread(channel_id, "a".repeat(64)); + let b = ConversationScope::thread(channel_id, "b".repeat(64)); + q.push(make_queued(&a, "old in-flight")); + let batch = q.flush_next().expect("A flushes"); + for index in 0..MAX_PENDING_PER_CHANNEL { + q.push(make_queued(&b, &format!("b-{index}"))); + } + + assert!(q.requeue(batch).is_none()); + assert_eq!( + q.buffered_event_count_for_channel(channel_id), + MAX_PENDING_PER_CHANNEL + ); + assert_eq!( + q.queued_event_count(&a), + 1, + "older requeued work is retained" + ); + assert_eq!(q.queued_event_count(&b), MAX_PENDING_PER_CHANNEL - 1); + } + + #[test] + fn aggregate_channel_queue_cap_applies_across_sibling_roots() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let a = ConversationScope::thread(channel_id, "a".repeat(64)); + let b = ConversationScope::thread(channel_id, "b".repeat(64)); + for index in 0..300 { + assert!(q.push(make_queued(&a, &format!("a-{index}")))); + } + for index in 0..300 { + assert!(q.push(make_queued(&b, &format!("b-{index}")))); + } + + assert_eq!(q.buffered_event_count_for_channel(channel_id), 500); + assert_eq!(q.queued_event_count(&a), 200); + assert_eq!(q.queued_event_count(&b), 300); + } + + #[test] + fn global_scope_cap_rejects_new_roots_without_evicting_queued_or_in_flight_work() { + let mut q = EventQueue::new(DedupMode::Queue); + for index in 0..MAX_GLOBAL_CONVERSATION_SCOPES { + let scope = ConversationScope::thread(Uuid::new_v4(), format!("{index:064x}")); + assert!(q.push(make_queued(&scope, "kept"))); + } + let overflow = ConversationScope::thread(Uuid::new_v4(), "f".repeat(64)); + assert!(!q.push(make_queued(&overflow, "rejected"))); + assert_eq!(q.pending_conversations(), MAX_GLOBAL_CONVERSATION_SCOPES); + assert_eq!(q.buffered_event_count(), MAX_GLOBAL_CONVERSATION_SCOPES); + } + + #[test] + fn two_group_roots_in_one_channel_flush_as_separate_batches() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + push_group(&mut q, channel_id, top_level("thread A")); + push_group(&mut q, channel_id, top_level("thread B")); + + let first = q.flush_next().expect("first conversation must flush"); + assert_eq!( + first.events.len(), + 1, + "two distinct thread roots must not share one batch" + ); + let second = q + .flush_next() + .expect("sibling conversation must flush independently"); + assert_eq!(second.events.len(), 1); + assert_ne!(first.scope, second.scope); + assert_eq!(first.channel_id(), channel_id); + assert_eq!(second.channel_id(), channel_id); + } + + #[test] + fn replies_batch_with_their_root_not_with_a_sibling_thread() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let root_a = top_level("thread A"); + let root_a_id = root_a.id.to_hex(); + push_group(&mut q, channel_id, root_a); + push_group(&mut q, channel_id, reply_to(&root_a_id, "reply in A")); + push_group(&mut q, channel_id, top_level("thread B")); + + let first = q.flush_next().expect("thread A flushes"); + assert_eq!( + first.events.len(), + 2, + "root + its reply are one conversation and batch together" + ); + assert_eq!( + first.scope, + ConversationScope::thread(channel_id, &root_a_id) + ); + + let second = q.flush_next().expect("thread B flushes"); + assert_eq!(second.events.len(), 1); + assert_eq!(second.events[0].event.content, "thread B"); + } + + #[test] + fn an_in_flight_thread_does_not_drop_events_for_a_sibling_thread() { + let mut q = EventQueue::new(DedupMode::Drop); + let channel_id = Uuid::new_v4(); + let root_a = top_level("thread A"); + let root_a_id = root_a.id.to_hex(); + push_group(&mut q, channel_id, root_a); + q.flush_next().expect("thread A flushes"); + + // Same conversation while A is in flight → dropped by Drop mode. + assert!( + !push_group(&mut q, channel_id, reply_to(&root_a_id, "reply in A")), + "a reply under the in-flight root belongs to that conversation" + ); + // Different conversation in the same channel → must be unaffected. + assert!( + push_group(&mut q, channel_id, top_level("thread B")), + "a sibling thread must not be dropped by thread A being in flight" + ); + assert!( + q.flush_next().is_some(), + "sibling thread must be flushable while thread A is in flight" + ); + } + + #[test] + fn a_dm_channel_is_one_conversation_including_replies() { + let mut q = EventQueue::new(DedupMode::Queue); + let channel_id = Uuid::new_v4(); + let first = top_level("hi"); + let reply = reply_to(&first.id.to_hex(), "hi back"); + for event in [first, reply] { + q.push(QueuedEvent { + scope: ConversationScope::for_event(channel_id, true, &event), + event, + received_at: Instant::now(), + prompt_tag: "test".into(), + }); + } + + let batch = q.flush_next().expect("the DM conversation flushes"); + assert_eq!( + batch.events.len(), + 2, + "a DM channel is one conversation — a reply does not split it" + ); + assert_eq!(batch.scope, ConversationScope::dm(channel_id)); + assert!( + q.flush_next().is_none(), + "a DM channel has exactly one conversation" + ); + } + + #[test] + fn in_flight_state_is_per_conversation() { + let mut q = EventQueue::new(DedupMode::Queue); + let a = conv(); + let b = sibling_of(&a); + q.push(make_queued(&a, "a")); + q.flush_next().expect("a flushes"); + + assert!(q.is_conversation_in_flight(&a)); + assert!( + !q.is_conversation_in_flight(&b), + "a sibling thread must not be reported in flight" + ); + } + + #[test] + fn retry_backoff_for_one_root_does_not_throttle_its_sibling() { + let mut q = EventQueue::new(DedupMode::Queue); + let a = conv(); + let b = sibling_of(&a); + q.push(make_queued(&a, "thread A")); + let batch_a = q.flush_next().expect("thread A flushes"); + assert!( + q.requeue(batch_a).is_none(), + "first retry is not a dead-letter" + ); + q.mark_complete(&a); + + q.push(make_queued(&b, "thread B")); + let batch_b = q + .flush_next() + .expect("thread B must flush despite thread A's retry backoff"); + assert_eq!(batch_b.events.len(), 1); + assert_eq!(batch_b.events[0].event.content, "thread B"); + assert_eq!(batch_b.scope, b); + } + + #[test] + fn dead_lettering_one_root_leaves_its_siblings_retry_budget_intact() { + let mut q = EventQueue::new(DedupMode::Queue); + let a = conv(); + let b = sibling_of(&a); + q.set_retry_count_for_test(&a, MAX_RETRIES); + q.push(make_queued(&a, "poison")); + let batch_a = q.flush_next().expect("thread A flushes"); + assert!( + q.requeue(batch_a).is_some(), + "thread A exhausted its retry budget and dead-letters" + ); + q.mark_complete(&a); + + q.push(make_queued(&b, "healthy")); + let batch_b = q.flush_next().expect("thread B flushes"); + assert!( + q.requeue(batch_b).is_none(), + "thread B starts at attempt 1 — it did not inherit A's exhausted budget" + ); + } + + #[test] + fn a_cancelled_batch_stays_with_its_own_conversation() { + let mut q = EventQueue::new(DedupMode::Queue); + let a = conv(); + let b = sibling_of(&a); + q.push(make_queued(&a, "thread A")); + let batch_a = q.flush_next().expect("thread A flushes"); + q.requeue_as_cancelled(batch_a, CancelReason::Steer); + q.mark_complete(&a); + + q.push(make_queued(&b, "thread B")); + let batch_b = q.flush_next().expect("thread B flushes"); + assert_eq!(batch_b.scope, b); + assert!( + batch_b.cancelled_events.is_empty(), + "thread B must not inherit thread A's cancelled batch" + ); + assert!(batch_b.cancel_reason.is_none()); + + // A's carryover is still there, waiting for A. + q.mark_complete(&b); + let batch_a2 = q.flush_next().expect("thread A's carryover re-flushes"); + assert_eq!(batch_a2.scope, a); + assert_eq!(batch_a2.events.len(), 1); + assert_eq!(batch_a2.events[0].event.content, "thread A"); + } + + #[test] + fn withheld_steer_events_are_per_conversation() { + let mut q = EventQueue::new(DedupMode::Queue); + let a = conv(); + let b = sibling_of(&a); + let qe_a = make_queued(&a, "steered"); + let event_id = qe_a.event.id.to_hex(); + q.push(qe_a); + q.push(make_queued(&b, "sibling")); + + assert!( + !q.mark_native_steer_pending(&b, &event_id), + "a sibling conversation cannot withhold another thread's event" + ); + assert!(q.mark_native_steer_pending(&a, &event_id)); + assert_eq!(q.queued_event_count(&a), 0); + assert_eq!(q.queued_event_count(&b), 1); + } + + #[test] + fn channel_removal_drains_every_conversation_in_the_channel() { + let mut q = EventQueue::new(DedupMode::Queue); + let a = conv(); + let b = sibling_of(&a); + let elsewhere = conv(); + q.push(make_queued(&a, "a1")); + q.push(make_queued(&a, "a2")); + q.push(make_queued(&b, "b1")); + q.push(make_queued(&elsewhere, "other")); + + // Give A a cancelled carryover and a retry throttle so the drain has to + // clear more than just `queues`. + let batch_a = q.flush_next().expect("a flushes"); + q.requeue_as_cancelled(batch_a, CancelReason::Interrupt); + q.mark_complete(&a); + + let drained = q.drain_channel(a.channel_id()); + assert_eq!(drained.len(), 1, "b1 was still queued and must be drained"); + assert_eq!(q.queued_event_count(&a), 0); + assert_eq!(q.queued_event_count(&b), 0); + assert_eq!( + q.queued_event_count(&elsewhere), + 1, + "another channel is untouched" + ); + + // The cancelled carryover for A is gone too — nothing from the removed + // channel may re-dispatch. + let next = q.flush_next().expect("only the other channel remains"); + assert_eq!(next.scope, elsewhere); + assert!(q.flush_next().is_none()); + } +} From dd15d799cd31fe08542fcfd232f046bb6c26f2ab Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Wed, 26 Aug 2026 08:37:51 +0200 Subject: [PATCH 20/76] feat(desktop): improve project issue workflow - filter aggregate issues by repository and show full active groups - prioritize review-ready workflow evidence over historic approval labels - reset channel issue detail navigation and expose a visible back action - place Channel Issues before Buzz Terminal in the channel header - cover repository filtering, status precedence, navigation, and action order Signed-off-by: Robi Hermes --- .../ui/ChannelIssuesAuxiliaryPanel.test.mjs | 63 +++++++++++ .../ui/ChannelIssuesAuxiliaryPanel.tsx | 103 ++++++++++++------ .../channels/ui/ChannelScreenHeader.tsx | 55 +++++++--- .../projects/issueRepositoryScope.test.mjs | 49 +++++++++ .../features/projects/issueRepositoryScope.ts | 46 ++++++++ .../src/features/projects/projectIssues.mjs | 7 ++ .../features/projects/projectIssues.test.mjs | 19 ++++ .../projects/ui/ProjectsIssuesList.tsx | 45 +------- .../projects/ui/ProjectsListHeaderBar.tsx | 30 ++++- .../src/features/projects/ui/ProjectsView.tsx | 61 +++++++++-- 10 files changed, 369 insertions(+), 109 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs create mode 100644 desktop/src/features/projects/issueRepositoryScope.test.mjs create mode 100644 desktop/src/features/projects/issueRepositoryScope.ts diff --git a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs new file mode 100644 index 00000000000..94d79ded9a3 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +const issuesPanelModule = await import("./ChannelIssuesAuxiliaryPanel.tsx"); +const channelHeaderModule = await import("./ChannelScreenHeader.tsx"); + +test("issue panel instance identity follows the active channel", () => { + assert.equal( + issuesPanelModule.channelIssuesPanelInstanceKey?.("channel-one"), + "channel-issues-panel:channel-one", + ); + assert.notEqual( + issuesPanelModule.channelIssuesPanelInstanceKey?.("channel-one"), + issuesPanelModule.channelIssuesPanelInstanceKey?.("channel-two"), + ); +}); + +test("selected issue header exposes a visible back action", () => { + const Header = issuesPanelModule.ChannelIssuesPanelHeader; + assert.equal(typeof Header, "function"); + + const html = renderToStaticMarkup( + React.createElement(Header, { + onBack() {}, + onClose() {}, + repositoryName: "Control Room", + selectedIssueId: "issue-one", + }), + ); + + assert.match(html, /Back to issues/); + assert.match(html, /aria-label="Back to issues"/); +}); + +test("channel issues action appears before Buzz Terminal", () => { + const Actions = channelHeaderModule.ChannelHeaderPrimaryActions; + assert.equal(typeof Actions, "function"); + + const html = renderToStaticMarkup( + React.createElement(Actions, { + channelActions: React.createElement( + "button", + { type: "button" }, + "Members", + ), + issuesPanelOpen: false, + onToggleIssues() {}, + terminalButton: React.createElement( + "button", + { "aria-label": "Open Buzz Term", type: "button" }, + "Terminal", + ), + }), + ); + + assert.ok( + html.indexOf("Open channel issues") < html.indexOf("Open Buzz Term"), + "issues action must render before Buzz Terminal", + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx index a1880c67136..bbccd452700 100644 --- a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx +++ b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx @@ -22,7 +22,70 @@ type ChannelIssuesAuxiliaryPanelProps = { * Channel-scoped issue surface. The channel binding on a repository is the * authority for scope: no binding means no issue list, never a global fallback. */ -export function ChannelIssuesAuxiliaryPanel({ +export function channelIssuesPanelInstanceKey(channelId: string) { + return `channel-issues-panel:${channelId}`; +} + +export function ChannelIssuesPanelHeader({ + onBack, + onClose, + repositoryName, + selectedIssueId, +}: { + onBack: () => void; + onClose: () => void; + repositoryName: string | null; + selectedIssueId: string | null; +}) { + return ( +
+
+ {selectedIssueId ? ( + + ) : null} +
+

Issues

+

+ {repositoryName ?? "No repository linked to this channel"} +

+
+
+ +
+ ); +} + +export function ChannelIssuesAuxiliaryPanel( + props: ChannelIssuesAuxiliaryPanelProps, +) { + return ( + + ); +} + +function ChannelIssuesAuxiliaryPanelForChannel({ activeChannel, canResetWidth, onClose, @@ -48,38 +111,12 @@ export function ChannelIssuesAuxiliaryPanel({ widthPx={widthPx} >
-
-
- {selectedIssueId ? ( - - ) : null} -
-

Issues

-

- {repository?.name ?? "No repository linked to this channel"} -

-
-
- -
+ setSelectedIssueId(null)} + onClose={onClose} + repositoryName={repository?.name ?? null} + selectedIssueId={selectedIssueId} + />
{projectsQuery.isLoading ? (

Loading issues…

diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 56b158309f4..658ec4b5506 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -50,6 +50,39 @@ type ChannelScreenHeaderProps = { onToggleMembers: () => void; }; +type ChannelHeaderPrimaryActionsProps = { + channelActions: React.ReactNode; + issuesPanelOpen: boolean; + onToggleIssues: () => void; + terminalButton: React.ReactNode; +}; + +export function ChannelHeaderPrimaryActions({ + channelActions, + issuesPanelOpen, + onToggleIssues, + terminalButton, +}: ChannelHeaderPrimaryActionsProps) { + return ( +
+ + {terminalButton} + {channelActions} +
+ ); +} + export function ChannelScreenHeader({ activeChannel, activeChannelEphemeralDisplay, @@ -121,22 +154,12 @@ export function ChannelScreenHeader({ ) ) : null; const actions = activeChannel ? ( -
- {terminalButton} - - {channelActions} -
+ ) : null; if (!showHeaderContent) { diff --git a/desktop/src/features/projects/issueRepositoryScope.test.mjs b/desktop/src/features/projects/issueRepositoryScope.test.mjs new file mode 100644 index 00000000000..bafdf8212f9 --- /dev/null +++ b/desktop/src/features/projects/issueRepositoryScope.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + filterIssueRowsByRepository, + issueRepositoryScopeOptions, + normalizeIssueRepositoryScope, +} from "./issueRepositoryScope.ts"; + +const CONTROL_ROOM = "30617:aaaa:control-room"; +const PATTERNDY = "30617:bbbb:patterndy"; + +const rows = [ + { + project: { name: "Buzz Workflow" }, + repository: { name: "Control Room", repoAddress: CONTROL_ROOM }, + issue: { id: "control" }, + }, + { + project: { name: "PatternDY" }, + repository: { name: "PatternDY", repoAddress: PATTERNDY }, + issue: { id: "pattern" }, + }, +]; + +test("repository filter keeps all issue rows for the all-repositories scope", () => { + assert.deepEqual(filterIssueRowsByRepository(rows, "all"), rows); +}); + +test("repository filter keeps only the selected repository's issue rows", () => { + assert.deepEqual(filterIssueRowsByRepository(rows, PATTERNDY), [rows[1]]); +}); + +test("repository selector offers all repositories plus disambiguated project labels", () => { + assert.deepEqual(issueRepositoryScopeOptions(rows), [ + { label: "All repositories", value: "all" }, + { label: "Buzz Workflow / Control Room", value: CONTROL_ROOM }, + { label: "PatternDY / PatternDY", value: PATTERNDY }, + ]); +}); + +test("repository scope resets to all when its repository is no longer available", () => { + const options = issueRepositoryScopeOptions([rows[0]]); + assert.equal(normalizeIssueRepositoryScope(PATTERNDY, options), "all"); + assert.equal( + normalizeIssueRepositoryScope(CONTROL_ROOM, options), + CONTROL_ROOM, + ); +}); diff --git a/desktop/src/features/projects/issueRepositoryScope.ts b/desktop/src/features/projects/issueRepositoryScope.ts new file mode 100644 index 00000000000..196e8a48d25 --- /dev/null +++ b/desktop/src/features/projects/issueRepositoryScope.ts @@ -0,0 +1,46 @@ +export type IssueRepositoryScope = "all" | string; + +type IssueRepositoryRow = { + project: { name: string }; + repository: { name: string; repoAddress: string }; +}; + +export type IssueRepositoryScopeOption = { + label: string; + value: IssueRepositoryScope; +}; + +/** Filters the already loaded aggregate issue rows without another relay query. */ +export function filterIssueRowsByRepository( + rows: readonly TRow[], + scope: IssueRepositoryScope, +): TRow[] { + if (scope === "all") return [...rows]; + return rows.filter((row) => row.repository.repoAddress === scope); +} + +export function normalizeIssueRepositoryScope( + scope: IssueRepositoryScope, + options: readonly IssueRepositoryScopeOption[], +): IssueRepositoryScope { + return options.some((option) => option.value === scope) ? scope : "all"; +} + +/** Lists issue-bearing repositories once, preserving the aggregate row order. */ +export function issueRepositoryScopeOptions( + rows: readonly TRow[], +): IssueRepositoryScopeOption[] { + const options: IssueRepositoryScopeOption[] = [ + { label: "All repositories", value: "all" }, + ]; + const seen = new Set(); + for (const { project, repository } of rows) { + if (seen.has(repository.repoAddress)) continue; + seen.add(repository.repoAddress); + options.push({ + label: `${project.name} / ${repository.name}`, + value: repository.repoAddress, + }); + } + return options; +} diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index c37e37e40ec..c719375b1db 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -83,6 +83,13 @@ function statusFromEvent(issue, statusEvent) { // label-based fallbacks below are client-side heuristics, not protocol. if (statusEvent?.kind === 1633) return PROJECT_ISSUE_STATUS.TRIAGE; + // Review-ready is verified workflow evidence on the mutable NIP-34 status + // event. It must override the immutable root's historic `approved` decision + // label: approval admitted work to the queue, it did not complete review. + if (/\breview[- ]ready\b/i.test(statusEvent?.content ?? "")) { + return PROJECT_ISSUE_STATUS.IN_REVIEW; + } + const labels = [ ...getAllTags(issue, "t"), ...(statusEvent ? getAllTags(statusEvent, "t") : []), diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index cfb82827424..7d2b8983184 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -215,6 +215,25 @@ test("preserves root and comment tags for rich content rendering", () => { assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); }); +test("renders a review-ready status event ahead of an immutable approved decision label", () => { + const root = issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Something is broken"], + ["t", "approved"], + ], + }); + const reviewReady = { + ...statusEvent({ kind: 1630, pubkey: OWNER, createdAt: 150 }), + content: "## LIVE / Review-ready\n\nKanban KBN-t_example stands on Review.", + }; + + assert.equal( + eventToProjectIssue(root, [reviewReady]).status, + PROJECT_ISSUE_STATUS.IN_REVIEW, + ); +}); + test("recognizes approved status-event labels and action-required comment metadata", () => { const root = issueEvent({ tags: [ diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index 057578d1e97..36924bc71b4 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -276,7 +276,6 @@ function IssueStatusSection({ embedded, items, label, - limit, onOpen, profiles, status, @@ -286,7 +285,6 @@ function IssueStatusSection({ embedded: boolean; items: ProjectIssueListItem[]; label: string; - limit: number; onOpen: ProjectsIssuesListProps["onOpen"]; profiles?: UserProfileLookup; status: string; @@ -294,12 +292,7 @@ function IssueStatusSection({ viewMode: ProjectsIssuesListProps["viewMode"]; }) { const [expanded, setExpanded] = React.useState(!terminal); - const [showAll, setShowAll] = React.useState(false); - const visibleItems = expanded - ? showAll - ? items - : items.slice(0, limit) - : []; + const visibleItems = expanded ? items : []; if (items.length === 0) return null; @@ -324,17 +317,6 @@ function IssueStatusSection({ {expanded ? "Collapse" : "Expand"} ) : null} - {expanded && items.length > limit ? ( - - ) : null}
{ - const stored = Number( - globalThis.localStorage?.getItem("buzz.projects.issueRows"), - ); - return stored === 10 || stored === 20 ? stored : 5; - }); const issuesByStatus = React.useMemo( () => new Map( @@ -405,11 +381,6 @@ export function ProjectsIssuesList({ [issues], ); - const changeLimit = (value: number) => { - setLimit(value); - globalThis.localStorage?.setItem("buzz.projects.issueRows", String(value)); - }; - if (isLoading) { return (
{loadNotice} -
- - -
{ISSUE_STATUS_SECTIONS.map(({ label, status, terminal }) => ( void; onIssueScopeChange: (scope: ProjectsWorkItemScope) => void; onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void; onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void; @@ -71,7 +78,10 @@ type ProjectsListHeaderBarProps = { */ export function ProjectsListHeaderBar({ filter, + issueRepositoryOptions, + issueRepositoryScope, issueScope, + onIssueRepositoryScopeChange, onIssueScopeChange, onPullRequestScopeChange, onRepositoryScopeChange, @@ -92,12 +102,20 @@ export function ProjectsListHeaderBar({ value={pullRequestScope} /> ) : filter === "issues" ? ( - +
+ + +
) : filter === "projects" ? ( ( () => readStoredIssueScope(), ); + const [issueRepositoryScope, setIssueRepositoryScope] = + React.useState("all"); const projectsWorkItemsQuery = useProjectsWorkItemsQuery( filter === "all" || filter === "prs" || filter === "issues" ? projects : [], ); @@ -286,6 +294,13 @@ export function ProjectsView() { [], ); + const handleIssueRepositoryScopeChange = React.useCallback( + (scope: IssueRepositoryScope) => { + setIssueRepositoryScope(scope); + }, + [], + ); + const handleSortChange = React.useCallback((nextSort: ProjectsSort) => { setSort(nextSort); writeStoredSort(nextSort); @@ -479,16 +494,39 @@ export function ProjectsView() { ), ) : issues; - return [...scopedIssues].sort((left, right) => { - if (sort === "name") { - return left.issue.title.localeCompare(right.issue.title); - } - if (sort === "created") { - return right.issue.createdAt - left.issue.createdAt; - } - return right.issue.updatedAt - left.issue.updatedAt; - }); - }, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]); + return filterIssueRowsByRepository(scopedIssues, issueRepositoryScope).sort( + (left, right) => { + if (sort === "name") { + return left.issue.title.localeCompare(right.issue.title); + } + if (sort === "created") { + return right.issue.createdAt - left.issue.createdAt; + } + return right.issue.updatedAt - left.issue.updatedAt; + }, + ); + }, [ + currentPubkey, + issueRepositoryScope, + issueScope, + projectsWorkItemsQuery.data, + sort, + ]); + + const issueRepositoryOptions = React.useMemo( + () => + issueRepositoryScopeOptions( + projectsWorkItemsQuery.data?.issues.items ?? [], + ), + [projectsWorkItemsQuery.data?.issues.items], + ); + + React.useEffect(() => { + if (!projectsWorkItemsQuery.data) return; + setIssueRepositoryScope((scope) => + normalizeIssueRepositoryScope(scope, issueRepositoryOptions), + ); + }, [issueRepositoryOptions, projectsWorkItemsQuery.data]); // Route by the canonical `owner:dtag` project ID — a bare dtag is // ambiguous across owners (forks can share the same dtag). @@ -705,7 +743,10 @@ export function ProjectsView() { Date: Wed, 26 Aug 2026 10:01:51 +0200 Subject: [PATCH 21/76] fix(desktop): keep channel issue header visible Render the channel issue panel header above the shared channel chrome so Back to issues and Close remain visible. Add a regression assertion for the z-index contract. Signed-off-by: Robi Hermes --- .../channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs | 5 +++++ .../src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs index 94d79ded9a3..ead77c7edf2 100644 --- a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs +++ b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs @@ -33,6 +33,11 @@ test("selected issue header exposes a visible back action", () => { assert.match(html, /Back to issues/); assert.match(html, /aria-label="Back to issues"/); + assert.match( + html, + /class="[^"]*\brelative\b[^"]*\bz-40\b/, + "panel header must stay above the shared z-30 channel backdrop", + ); }); test("channel issues action appears before Buzz Terminal", () => { diff --git a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx index bbccd452700..8898c453e28 100644 --- a/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx +++ b/desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx @@ -38,7 +38,7 @@ export function ChannelIssuesPanelHeader({ selectedIssueId: string | null; }) { return ( -
+
{selectedIssueId ? ( +
+ ) : null} + {!isLoading ? (
@@ -140,7 +157,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { isPending={isPersonasPending} persona={group.persona} linkedAgent={profileAgent} - onDeactivate={onDeactivatePersona} + onHideBuiltin={onHideBuiltinPersona} onDelete={onDeletePersona} onDuplicate={onDuplicatePersona} onEdit={onEditPersona} diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index b4ef5afb6c8..d0c106adbf4 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -9,9 +9,12 @@ import { useCreatePersonaMutation, useDeletePersonaMutation, useExportAgentSnapshotMutation, + useHiddenBuiltinPersonasQuery, usePersonasQuery, usePreviewAgentSnapshotImportMutation, useConfirmAgentSnapshotImportMutation, + useRestoreBuiltinPersonasMutation, + useSetBuiltinPersonaHiddenMutation, useSetPersonaActiveMutation, useUpdatePersonaMutation, type AgentSnapshotImportPreview, @@ -72,6 +75,7 @@ export function usePersonaActions() { const identityQuery = useIdentityQuery(); const communityId = activeCommunity?.id ?? null; const personasQuery = usePersonasQuery(); + const hiddenBuiltinPersonasQuery = useHiddenBuiltinPersonasQuery(); const catalogQuery = usePersonaCatalogQuery(communityId); usePersonaCatalogLiveUpdates(communityId); const setCatalogSharedMutation = @@ -88,6 +92,8 @@ export function usePersonaActions() { useUpdatePersonaAndPublishMutation(communityId); const deletePersonaMutation = useDeletePersonaMutation(); const setPersonaActiveMutation = useSetPersonaActiveMutation(); + const setBuiltinPersonaHiddenMutation = useSetBuiltinPersonaHiddenMutation(); + const restoreBuiltinPersonasMutation = useRestoreBuiltinPersonasMutation(); const exportAgentSnapshotMutation = useExportAgentSnapshotMutation(); const previewSnapshotImportMutation = usePreviewAgentSnapshotImportMutation(); const confirmSnapshotImportMutation = useConfirmAgentSnapshotImportMutation(); @@ -159,6 +165,10 @@ export function usePersonaActions() { () => getLibraryPersonas(personas), [personas], ); + const hiddenBuiltinPersonaIds = React.useMemo( + () => new Set(hiddenBuiltinPersonasQuery.data ?? []), + [hiddenBuiltinPersonasQuery.data], + ); const personaLabelsById = React.useMemo( () => getPersonaLabelsById(personas), [personas], @@ -364,6 +374,37 @@ export function usePersonaActions() { } } + async function handleHideBuiltin(persona: AgentPersona) { + clearFeedback("library"); + try { + await setBuiltinPersonaHiddenMutation.mutateAsync({ + id: persona.id, + hidden: true, + }); + setPersonaNoticeMessage(`Hidden starter agent ${persona.displayName}.`); + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to hide starter agent.", + ); + } + } + + async function handleRestoreBuiltins() { + clearFeedback("library"); + try { + await restoreBuiltinPersonasMutation.mutateAsync(); + setPersonaNoticeMessage("Restored hidden starter agents."); + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to restore starter agents.", + ); + } + } + async function handleImportSnapshotFile( fileBytes: number[], fileName: string, @@ -562,6 +603,8 @@ export function usePersonaActions() { updatePersonaAndPublishMutation.isPending || deletePersonaMutation.isPending || setPersonaActiveMutation.isPending || + setBuiltinPersonaHiddenMutation.isPending || + restoreBuiltinPersonasMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || confirmSnapshotImportMutation.isPending || @@ -569,6 +612,7 @@ export function usePersonaActions() { return { personasQuery, + hiddenBuiltinPersonasQuery, catalogQuery, acpRuntimesQuery, createPersonaMutation, @@ -577,6 +621,7 @@ export function usePersonaActions() { setPersonaActiveMutation, catalogPersonas, libraryPersonas, + hiddenBuiltinPersonaIds, personaLabelsById, isPending, personaDialogState, @@ -594,6 +639,8 @@ export function usePersonaActions() { handleSubmit, handleDelete, handleSetActive, + handleHideBuiltin, + handleRestoreBuiltins, prepareCreate, openEdit, openDuplicate, diff --git a/desktop/src/features/channels/useChannelPaneHandlers.test.mjs b/desktop/src/features/channels/useChannelPaneHandlers.test.mjs new file mode 100644 index 00000000000..148227d4a1d --- /dev/null +++ b/desktop/src/features/channels/useChannelPaneHandlers.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as React from "react"; +import { JSDOM } from "jsdom"; + +import { useChannelPaneHandlers } from "./useChannelPaneHandlers.ts"; + +function useThreadHarness(initialOpenThreadHeadId) { + const [openThreadHeadId, commitOpenThreadHeadId] = React.useState( + initialOpenThreadHeadId, + ); + const openThreadCommitsRef = React.useRef([]); + const setOpenThreadHeadId = React.useCallback((value) => { + openThreadCommitsRef.current.push(value); + commitOpenThreadHeadId(value); + }, []); + const [optimisticOpenThreadHeadId, setOptimisticOpenThreadHeadId] = + React.useState(undefined); + const [threadReplyTargetId, setThreadReplyTargetId] = React.useState(null); + const [, setThreadScrollTargetId] = React.useState(null); + const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( + new Set(), + ); + const [editTargetId, setEditTargetId] = React.useState(null); + + const handlers = useChannelPaneHandlers({ + deleteMessageMutation: { mutateAsync: async () => {} }, + editMessageMutation: { mutateAsync: async () => {} }, + editTargetId, + expandedThreadReplyIds, + getFirstReplyIdForMessage: () => null, + getReplyDescendantIdsForMessage: () => [], + markRevealedRepliesRead: () => {}, + profiles: undefined, + recordThreadInteraction: () => {}, + onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, + onRequestEmptyEditDelete: () => {}, + openThreadHeadId, + sendMessageMutation: { mutateAsync: async () => {} }, + setExpandedThreadReplyIds, + setEditTargetId, + setOpenThreadHeadId, + setThreadReplyTargetId, + setThreadScrollTargetId, + threadReplyTargetId, + toggleReactionMutation: { mutateAsync: async () => {} }, + }); + + return { + handlers, + openThreadHeadId, + openThreadCommits: openThreadCommitsRef.current, + optimisticOpenThreadHeadId, + }; +} + +async function withRenderedHarness(initialOpenThreadHeadId, assertion) { + const dom = new JSDOM("", { + url: "http://localhost", + }); + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + const { act, cleanup, renderHook } = await import("@testing-library/react"); + + try { + const hook = renderHook(() => useThreadHarness(initialOpenThreadHeadId)); + await assertion({ act, hook }); + } finally { + cleanup(); + dom.window.close(); + } +} + +test("clicking the already open thread keeps it open", async () => { + await withRenderedHarness("thread-a", async ({ act, hook }) => { + await act(async () => { + hook.result.current.handlers.handleOpenThread({ id: "thread-a" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(hook.result.current.openThreadHeadId, "thread-a"); + assert.equal(hook.result.current.optimisticOpenThreadHeadId, "thread-a"); + }); +}); + +test("clicking a different thread switches the open sidepanel", async () => { + await withRenderedHarness("thread-a", async ({ act, hook }) => { + await act(async () => { + hook.result.current.handlers.handleOpenThread({ id: "thread-b" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.equal(hook.result.current.openThreadHeadId, "thread-b"); + assert.equal(hook.result.current.optimisticOpenThreadHeadId, "thread-b"); + }); +}); + +test("rapid thread clicks commit only the latest sidepanel", async () => { + await withRenderedHarness("thread-a", async ({ act, hook }) => { + await act(async () => { + hook.result.current.handlers.handleOpenThread({ id: "thread-b" }); + hook.result.current.handlers.handleOpenThread({ id: "thread-c" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.deepEqual(hook.result.current.openThreadCommits, ["thread-c"]); + assert.equal(hook.result.current.openThreadHeadId, "thread-c"); + }); +}); diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index f9c57f6656a..2e8fa8316c5 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -107,12 +107,26 @@ export function useChannelPaneHandlers({ const markRevealedRepliesReadRef = React.useRef(markRevealedRepliesRead); markRevealedRepliesReadRef.current = markRevealedRepliesRead; + const panelStateTimerRef = React.useRef(null); const deferPanelState = React.useCallback((update: () => void) => { - window.setTimeout(() => { + if (panelStateTimerRef.current !== null) { + window.clearTimeout(panelStateTimerRef.current); + } + panelStateTimerRef.current = window.setTimeout(() => { + panelStateTimerRef.current = null; React.startTransition(update); }, 0); }, []); + React.useEffect( + () => () => { + if (panelStateTimerRef.current !== null) { + window.clearTimeout(panelStateTimerRef.current); + } + }, + [], + ); + const handleCancelThreadReply = React.useCallback(() => { setThreadReplyTargetId(openThreadHeadIdRef.current); }, [setThreadReplyTargetId]); @@ -198,20 +212,11 @@ export function useChannelPaneHandlers({ const handleOpenThread = React.useCallback( (message: { id: string }) => { - if (openThreadHeadIdRef.current === message.id) { - deferPanelState(() => { - onOptimisticOpenThreadHeadIdChange(null); - setOpenThreadHeadId(null); - setThreadReplyTargetId(null); - setThreadScrollTargetId(null); - setExpandedThreadReplyIds(new Set()); - }); - setEditTargetId(null); - return; - } - deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(message.id); + if (openThreadHeadIdRef.current === message.id) { + return; + } setOpenThreadHeadId(message.id); setThreadReplyTargetId(message.id); setThreadScrollTargetId(null); diff --git a/desktop/src/features/identity-archive/hooks.test.mjs b/desktop/src/features/identity-archive/hooks.test.mjs new file mode 100644 index 00000000000..7128000e306 --- /dev/null +++ b/desktop/src/features/identity-archive/hooks.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { oaOwnerQueryKey } from "./hooks.ts"; + +const TARGET = "a".repeat(64); +const FIRST_VIEWER = "b".repeat(64); +const SECOND_VIEWER = "c".repeat(64); + +test("OA owner cache key changes with the viewer identity", () => { + const first = oaOwnerQueryKey( + TARGET.toUpperCase(), + FIRST_VIEWER.toUpperCase(), + ); + const second = oaOwnerQueryKey(TARGET, SECOND_VIEWER); + + assert.deepEqual(first, ["oaOwner", TARGET, FIRST_VIEWER]); + assert.deepEqual(second, ["oaOwner", TARGET, SECOND_VIEWER]); + assert.notDeepEqual(first, second); +}); diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 8149eff7b74..c7a2d032e1f 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -64,10 +64,16 @@ export function useIsArchivedPredicate(): (pubkey: string) => boolean { } /** Gates the owner-path archive button via the target's live `kind:0`. */ +export function oaOwnerQueryKey(pubkey: string, viewerPubkey: string) { + return ["oaOwner", pubkey.toLowerCase(), viewerPubkey.toLowerCase()] as const; +} + export function useOaOwnerQuery(pubkey: string, enabled = true) { + const identityQuery = useIdentityQuery(); + const viewerPubkey = identityQuery.data?.pubkey; return useQuery({ - enabled, - queryKey: ["oaOwner", pubkey.toLowerCase()] as const, + enabled: enabled && Boolean(viewerPubkey), + queryKey: oaOwnerQueryKey(pubkey, viewerPubkey ?? ""), queryFn: () => resolveOaOwner(pubkey), staleTime: 60_000, }); diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 102a3ba34ad..830d7571ad6 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -6,6 +6,7 @@ import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; import { signRelayEvent } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; +import { resolveOaOwner } from "@/shared/api/tauriIdentityArchive"; import { getProjectLocalRepoDiff, getProjectRepoDiff, @@ -235,7 +236,7 @@ async function fetchProjectIssues( "#a": [project.repoAddress], limit: 200, }); - const [issueEvents, statusEvents, commentEvents, assignmentEvents] = + const [issueEvents, statusEvents, commentEvents, assignmentEvents, oaOwner] = await Promise.all([ issuePromise, relayClient.fetchEvents({ @@ -260,12 +261,17 @@ async function fetchProjectIssues( issuePromise.then((events) => fetchAssignmentOperationEvents(events.map((event) => event.id)), ), + // NIP-OA ownership is cryptographically verified by the existing Tauri + // command. A failed lookup must keep the extra status gate closed without + // making the issue list itself unavailable. + resolveOaOwner(project.owner).catch(() => null), ]); return projectIssueEventsToIssues( issueEvents, statusEvents, mergeEventsById(commentEvents, assignmentEvents), + oaOwner ? [oaOwner.owner] : [], ); } diff --git a/desktop/src/features/projects/issueStatus.test.mjs b/desktop/src/features/projects/issueStatus.test.mjs index 3348de991a4..8e6267ad1fd 100644 --- a/desktop/src/features/projects/issueStatus.test.mjs +++ b/desktop/src/features/projects/issueStatus.test.mjs @@ -10,6 +10,7 @@ import { const OWNER = "a".repeat(64); const AUTHOR = "b".repeat(64); const ASSIGNEE = "d".repeat(64); +const OA_OWNER = "e".repeat(64); const STRANGER = "c".repeat(64); test("every publishable lifecycle state maps to the label the read path derives", () => { @@ -40,6 +41,7 @@ test("issue author and repo owner may change status", () => { assert.equal( canChangeProjectIssueStatus({ isManagedAgentOwner: false, + isOaOwner: false, issueAssignees: [], issueAuthor: AUTHOR, projectOwner: OWNER, @@ -54,6 +56,7 @@ test("an assignee may change status", () => { assert.equal( canChangeProjectIssueStatus({ isManagedAgentOwner: false, + isOaOwner: false, issueAssignees: [ASSIGNEE], issueAuthor: AUTHOR, projectOwner: OWNER, @@ -67,6 +70,7 @@ test("a third party may not change status", () => { assert.equal( canChangeProjectIssueStatus({ isManagedAgentOwner: false, + isOaOwner: false, issueAssignees: [ASSIGNEE], issueAuthor: AUTHOR, projectOwner: OWNER, @@ -80,6 +84,7 @@ test("a signed-out viewer may not change status", () => { assert.equal( canChangeProjectIssueStatus({ isManagedAgentOwner: true, + isOaOwner: false, issueAssignees: [], issueAuthor: AUTHOR, projectOwner: OWNER, @@ -93,6 +98,7 @@ test("the human owner of a managed-agent repo owner may change status", () => { assert.equal( canChangeProjectIssueStatus({ isManagedAgentOwner: true, + isOaOwner: false, issueAssignees: [], issueAuthor: AUTHOR, projectOwner: OWNER, @@ -101,3 +107,17 @@ test("the human owner of a managed-agent repo owner may change status", () => { true, ); }); + +test("the verified NIP-OA owner of an external repo-owner agent may change status", () => { + assert.equal( + canChangeProjectIssueStatus({ + isManagedAgentOwner: false, + isOaOwner: true, + issueAssignees: [], + issueAuthor: AUTHOR, + projectOwner: OWNER, + viewer: OA_OWNER, + }), + true, + ); +}); diff --git a/desktop/src/features/projects/issueStatus.ts b/desktop/src/features/projects/issueStatus.ts index f3c91eaf0e8..366e1f12641 100644 --- a/desktop/src/features/projects/issueStatus.ts +++ b/desktop/src/features/projects/issueStatus.ts @@ -68,12 +68,14 @@ export const ISSUE_LIFECYCLE_STATUSES: ProjectIssueLifecycleStatus[] = [ * honored instead of silently discarded. */ export function canChangeProjectIssueStatus({ isManagedAgentOwner, + isOaOwner, issueAssignees, issueAuthor, projectOwner, viewer, }: { isManagedAgentOwner: boolean; + isOaOwner: boolean; issueAssignees: readonly string[]; issueAuthor: string; projectOwner: string; @@ -84,6 +86,7 @@ export function canChangeProjectIssueStatus({ viewer === issueAuthor.toLowerCase() || viewer === projectOwner.toLowerCase() || isManagedAgentOwner || + isOaOwner || issueAssignees.some((assignee) => viewer === assignee.toLowerCase()) ); } diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index 7ea1319ce66..8c60acb5b69 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -62,11 +62,13 @@ export function eventToProjectIssue( issue: RelayEvent, statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], + additionalStatusActors?: string[], ): ProjectIssue; export function projectIssueEventsToIssues( issueEvents: RelayEvent[], statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], + additionalStatusActors?: string[], ): ProjectIssue[]; export function nextProjectIssueStatusCreatedAt( issue: ProjectIssue, diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index c719375b1db..a2e9cb000d9 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -60,12 +60,18 @@ export function allowedActorsForRoot(rootEvent) { return allowed; } -function latestStatusForIssue(issue, statusEvents, assignees) { +function latestStatusForIssue( + issue, + statusEvents, + assignees, + additionalStatusActors = [], +) { const allowedActors = allowedActorsForRoot(issue); - // Assignees are the issue's handlers and may move it just like the author - // and repo owner (mirror of canChangeProjectIssueStatus in issueStatus.ts). - for (const assignee of assignees) { - allowedActors.add(assignee.toLowerCase()); + // Assignees and a verified NIP-OA owner are status-specific actors. Do not + // add them to allowedActorsForRoot: that helper also gates assignments and + // pull-request lifecycle events. + for (const actor of [...assignees, ...additionalStatusActors]) { + allowedActors.add(actor.toLowerCase()); } return statusEvents .filter( @@ -207,6 +213,7 @@ export function eventToProjectIssue( issue, statusEvents = [], commentEvents = [], + additionalStatusActors = [], ) { const issueCommentEvents = commentEvents.filter((event) => event.tags.some( @@ -221,6 +228,7 @@ export function eventToProjectIssue( issue, statusEvents, assignmentState.assignees, + additionalStatusActors, ); const comments = commentsForIssue(issueCommentEvents); const title = @@ -259,9 +267,17 @@ export function projectIssueEventsToIssues( issueEvents, statusEvents = [], commentEvents = [], + additionalStatusActors = [], ) { return [...issueEvents] - .map((issue) => eventToProjectIssue(issue, statusEvents, commentEvents)) + .map((issue) => + eventToProjectIssue( + issue, + statusEvents, + commentEvents, + additionalStatusActors, + ), + ) .sort((left, right) => right.updatedAt - left.updatedAt); } diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index 7d2b8983184..e11b6cfd976 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -18,6 +18,7 @@ import { const OWNER = "a".repeat(64); const AUTHOR = "b".repeat(64); const ATTACKER = "c".repeat(64); +const OA_OWNER = "d".repeat(64); const REPO_ADDRESS = `30617:${OWNER}:demo`; function issueEvent(overrides = {}) { @@ -110,6 +111,23 @@ test("honors status events from the issue author and repo owner", () => { ); }); +test("honors a status event from the verified NIP-OA owner only when explicitly supplied", () => { + const oaOwnerDone = statusEvent({ + kind: 1631, + pubkey: OA_OWNER, + createdAt: 300, + }); + + assert.equal( + eventToProjectIssue(issueEvent(), [oaOwnerDone]).status, + PROJECT_ISSUE_STATUS.BACKLOG, + ); + assert.equal( + eventToProjectIssue(issueEvent(), [oaOwnerDone], [], [OA_OWNER]).status, + PROJECT_ISSUE_STATUS.DONE, + ); +}); + test("honors status events from an assignee", () => { const assignee = "d".repeat(64); const assignment = assignmentComment(AUTHOR, [assignee], "assign-1"); diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 326a6bea19c..05f15045655 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -11,6 +11,7 @@ import * as React from "react"; import { toast } from "sonner"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { useOaOwnerQuery } from "@/features/identity-archive/hooks"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { type ProjectIssue, @@ -410,6 +411,11 @@ function IssueMetaRail({ const isAuthor = viewer === normalizePubkey(issue.author); const isOwner = viewer === normalizePubkey(project.owner); const isManagedAgentOwner = useIsManagedAgent(project.owner) === true; + const oaOwnerQuery = useOaOwnerQuery( + project.owner, + Boolean(viewer) && !isOwner && !isManagedAgentOwner, + ); + const isOaOwner = oaOwnerQuery.data?.isMe === true; // Same trust rule as parsing (assigneesForIssue): the issue author or // repo owner (directly or via a managed agent) can assign anyone; // everyone else who is signed in may still self-assign. @@ -417,6 +423,7 @@ function IssueMetaRail({ Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner); const canChangeStatus = canChangeProjectIssueStatus({ isManagedAgentOwner, + isOaOwner, issueAssignees: issue.assignees, issueAuthor: issue.author, projectOwner: project.owner, diff --git a/desktop/src/shared/api/tauriPersonas.test.mjs b/desktop/src/shared/api/tauriPersonas.test.mjs index 13fe66e4382..3b61b004aba 100644 --- a/desktop/src/shared/api/tauriPersonas.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { fromRawPersona } from "./tauriPersonas.ts"; +import { + fromRawPersona, + listHiddenBuiltinPersonas, + restoreBuiltinPersonas, + setBuiltinPersonaHidden, +} from "./tauriPersonas.ts"; function rawPersona(overrides = {}) { return { @@ -28,3 +33,44 @@ test("fromRawPersona maps source_team to sourceTeam", () => { assert.equal(persona.sourceTeam, "team-research"); }); + +test("built-in visibility APIs use dedicated non-destructive commands", async () => { + const previousWindow = globalThis.window; + const calls = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command, args) { + calls.push({ command, args }); + if (command === "list_hidden_builtin_personas") { + return Promise.resolve(["builtin:fizz"]); + } + if (command === "set_builtin_persona_hidden") { + return Promise.resolve(["builtin:fizz", "builtin:honey"]); + } + if (command === "restore_builtin_personas") { + return Promise.resolve([]); + } + throw new Error(`Unexpected command: ${command}`); + }, + }, + }; + + try { + assert.deepEqual(await listHiddenBuiltinPersonas(), ["builtin:fizz"]); + assert.deepEqual(await setBuiltinPersonaHidden("builtin:honey", true), [ + "builtin:fizz", + "builtin:honey", + ]); + assert.deepEqual(await restoreBuiltinPersonas(), []); + assert.deepEqual(calls, [ + { command: "list_hidden_builtin_personas", args: {} }, + { + command: "set_builtin_persona_hidden", + args: { id: "builtin:honey", hidden: true }, + }, + { command: "restore_builtin_personas", args: {} }, + ]); + } finally { + globalThis.window = previousWindow; + } +}); diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 3cd9734ae26..0bf7dbccd75 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -162,6 +162,21 @@ export async function deletePersona(id: string): Promise { await invokeTauri("delete_persona", { id }); } +export async function listHiddenBuiltinPersonas(): Promise { + return invokeTauri("list_hidden_builtin_personas"); +} + +export async function setBuiltinPersonaHidden( + id: string, + hidden: boolean, +): Promise { + return invokeTauri("set_builtin_persona_hidden", { id, hidden }); +} + +export async function restoreBuiltinPersonas(): Promise { + return invokeTauri("restore_builtin_personas"); +} + export async function setPersonaActive( id: string, active: boolean, diff --git a/scripts/check-windows-client-contract.sh b/scripts/check-windows-client-contract.sh new file mode 100755 index 00000000000..cb53dc43a3f --- /dev/null +++ b/scripts/check-windows-client-contract.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +candidate=$1 + +require_path() { + local path=$1 + if ! git cat-file -e "${candidate}:${path}" 2>/dev/null; then + printf 'windows client contract missing path: %s\n' "$path" >&2 + exit 1 + fi +} + +require_text() { + local path=$1 + local marker=$2 + require_path "$path" + if ! git show "${candidate}:${path}" | grep -F "$marker" >/dev/null; then + printf 'windows client contract missing marker %q in %s\n' \ + "$marker" "$path" >&2 + exit 1 + fi +} + +# Cumulative surfaces already delivered to the Windows owner client. +require_path 'desktop/src/features/agent-sessions' +require_text \ + 'desktop/src/features/channels/ui/ChannelScreenHeader.tsx' \ + 'data-testid="channel-issues-trigger"' +require_text \ + 'desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.tsx' \ + 'testId="channel-issues-auxiliary-pane"' +require_text \ + 'desktop/src/features/channels/ui/ChannelIssuesAuxiliaryPanel.test.mjs' \ + 'channel issues action appears before Buzz Terminal' +require_path 'desktop/src/features/projects/issueRepositoryScope.ts' +require_text \ + 'desktop/src/features/projects/ui/ProjectIssuesPanel.tsx' \ + 'aria-label="Change issue status"' +require_text \ + 'desktop/src/features/agents/ui/PersonaActionsMenu.tsx' \ + 'Hide starter agent' +require_text \ + 'desktop/src/features/channels/useChannelPaneHandlers.test.mjs' \ + 'clicking the already open thread keeps it open' +require_text \ + 'crates/buzz-cli/src/commands/issues.rs' \ + 'writer_consistent' +require_text \ + 'crates/buzz-cli/src/commands/issues.rs' \ + 'assignment_context_queries_are_writer_consistent' +require_text \ + 'crates/buzz-relay/src/handlers/req.rs' \ + 'issue_comment_reads_that_feed_assignment_causality_require_writer' + +printf 'windows client cumulative contract: OK (%s)\n' "$candidate" diff --git a/scripts/test-windows-client-build-policy.sh b/scripts/test-windows-client-build-policy.sh new file mode 100755 index 00000000000..06bcd74e3da --- /dev/null +++ b/scripts/test-windows-client-build-policy.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" + +build16=1a3084e4d3a7a93492ac9a82ebdfffb40c2def39 +build17=984bec51866fa750ae0dbea0a8ccd6f76c9d6d15 +merged_tree=$(git merge-tree --write-tree "$build16" "$build17") +candidate_tree=$(git write-tree) + +expect_failure() { + local label=$1 + shift + if "$@" >/dev/null 2>&1; then + printf 'FAIL: %s unexpectedly passed\n' "$label" >&2 + exit 1 + fi + printf 'PASS: %s rejected\n' "$label" +} + +expect_success() { + local label=$1 + shift + if ! "$@"; then + printf 'FAIL: %s unexpectedly failed\n' "$label" >&2 + exit 1 + fi + printf 'PASS: %s accepted\n' "$label" +} + +expect_failure \ + 'Build 16 alone lacks the new cumulative client contract' \ + scripts/check-windows-client-contract.sh "$build16" +expect_failure \ + 'Build 17 alone dropped the previous client contract' \ + scripts/check-windows-client-contract.sh "$build17" +stress_candidate_contract() { + local iteration + for iteration in {1..100}; do + scripts/check-windows-client-contract.sh "$candidate_tree" >/dev/null + done +} + +expect_failure \ + 'the raw Build 16 + Build 17 tree lacks the reviewed writer-consistency fix' \ + scripts/check-windows-client-contract.sh "$merged_tree" +expect_success \ + 'the corrected cumulative candidate retains every contract repeatedly' \ + stress_candidate_contract + +expect_failure \ + 'a sibling candidate is not cumulative' \ + scripts/verify-windows-client-lineage.sh \ + "$build16" "$build17" windows-integration +expect_failure \ + 'a build from a non-canonical branch is forbidden' \ + scripts/verify-windows-client-lineage.sh \ + "$build16" "$build16" fix/windows-client-batch-20260826 +expect_success \ + 'a descendant on the canonical branch is cumulative' \ + scripts/verify-windows-client-lineage.sh \ + "$build16" "$build16" windows-integration + +workflow=.github/workflows/windows-fork-integration.yml +grep -Fq 'CANONICAL_WINDOWS_BRANCH: windows-integration' "$workflow" +grep -Fq \ + 'WINDOWS_INTEGRATION_BOOTSTRAP_SHA: 1a3084e4d3a7a93492ac9a82ebdfffb40c2def39' \ + "$workflow" +grep -Fq 'branch=${CANONICAL_WINDOWS_BRANCH}' "$workflow" +grep -Fq 'scripts/verify-windows-client-lineage.sh' "$workflow" +grep -Fq 'scripts/check-windows-client-contract.sh' "$workflow" +printf 'PASS: GitHub workflow invokes both fail-closed gates\n' diff --git a/scripts/verify-windows-client-lineage.sh b/scripts/verify-windows-client-lineage.sh new file mode 100755 index 00000000000..8879e7fe907 --- /dev/null +++ b/scripts/verify-windows-client-lineage.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 3 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 2 +fi + +previous_sha=$1 +candidate_sha=$2 +branch=$3 +canonical_branch=windows-integration + +if [[ "$branch" != "$canonical_branch" ]]; then + printf 'Windows builds are allowed only from %s; got %s\n' \ + "$canonical_branch" "$branch" >&2 + exit 1 +fi + +for commit in "$previous_sha" "$candidate_sha"; do + if ! git cat-file -e "${commit}^{commit}" 2>/dev/null; then + printf 'Windows lineage commit is unavailable: %s\n' "$commit" >&2 + exit 1 + fi +done + +if ! git merge-base --is-ancestor "$previous_sha" "$candidate_sha"; then + printf 'Windows candidate %s does not contain previous build %s\n' \ + "$candidate_sha" "$previous_sha" >&2 + exit 1 +fi + +printf 'windows client lineage: OK (%s -> %s on %s)\n' \ + "$previous_sha" "$candidate_sha" "$branch" From 4d8002f112892c9d40cea7b6327b6ce6db6faa92 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Thu, 27 Aug 2026 23:15:32 +0200 Subject: [PATCH 23/76] fix(desktop): close hidden-agent and thread-panel regressions Signed-off-by: Robi Hermes --- .../agents/lib/builtinVisibility.test.mjs | 24 +++- .../features/agents/lib/builtinVisibility.ts | 41 ++++-- desktop/src/features/agents/ui/AgentsView.tsx | 20 +-- desktop/src/features/agents/ui/TeamDialog.tsx | 40 ++++-- .../agents/ui/UnifiedAgentsSection.tsx | 10 +- .../agents/ui/teamDialogSelection.test.mjs | 54 ++++++++ .../features/agents/ui/teamDialogSelection.ts | 29 ++++- .../agents/ui/unifiedAgentGroups.test.mjs | 33 +++++ .../features/agents/ui/unifiedAgentGroups.ts | 4 + .../features/agents/ui/usePersonaActions.ts | 23 +++- .../useApplyTemplate.test.mjs | 98 +++++++++++++++ .../channel-templates/useApplyTemplate.ts | 119 ++++++++++++------ .../channels/ui/AddChannelBotDialog.test.mjs | 108 ++++++++++++++++ .../channels/ui/AddChannelBotDialog.tsx | 63 ++++++++-- .../features/channels/ui/ChannelScreen.tsx | 9 +- .../channels/useChannelPaneHandlers.test.mjs | 37 +++++- .../channels/useChannelPaneHandlers.ts | 4 + .../ui/ChannelTemplatesSettingsCard.test.mjs | 78 ++++++++++++ .../ui/ChannelTemplatesSettingsCard.tsx | 55 +++++++- 19 files changed, 735 insertions(+), 114 deletions(-) create mode 100644 desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs create mode 100644 desktop/src/features/channel-templates/useApplyTemplate.test.mjs create mode 100644 desktop/src/features/channels/ui/AddChannelBotDialog.test.mjs create mode 100644 desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.test.mjs diff --git a/desktop/src/features/agents/lib/builtinVisibility.test.mjs b/desktop/src/features/agents/lib/builtinVisibility.test.mjs index b301c9428ac..233f8d6ee2e 100644 --- a/desktop/src/features/agents/lib/builtinVisibility.test.mjs +++ b/desktop/src/features/agents/lib/builtinVisibility.test.mjs @@ -1,28 +1,40 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { visibleLibraryPersonas } from "./builtinVisibility.ts"; +const visibilityModule = await import("./builtinVisibility.ts"); function persona(id, isBuiltIn) { return { id, isBuiltIn }; } -test("hidden starter agents disappear while custom and managed agents stay visible", () => { +test("hidden starter agents disappear while custom agents stay visible", () => { const personas = [ persona("builtin:fizz", true), persona("builtin:honey", true), persona("custom:writer", false), ]; - const agents = [{ personaId: "builtin:honey" }]; - const visible = visibleLibraryPersonas( + const visible = visibilityModule.visibleLibraryPersonas( personas, - agents, new Set(["builtin:fizz", "builtin:honey", "custom:writer"]), ); assert.deepEqual( visible.map((entry) => entry.id), - ["builtin:honey", "custom:writer"], + ["custom:writer"], ); }); + +test("persona visibility is authoritative only after both queries succeed", () => { + assert.equal( + typeof visibilityModule.isPersonaVisibilityAuthoritative, + "function", + ); + + const authoritative = visibilityModule.isPersonaVisibilityAuthoritative; + assert.equal(authoritative([], [], null, null), true); + assert.equal(authoritative(undefined, [], null, null), false); + assert.equal(authoritative([], undefined, null, null), false); + assert.equal(authoritative([], [], new Error("personas"), null), false); + assert.equal(authoritative([], [], null, new Error("visibility")), false); +}); diff --git a/desktop/src/features/agents/lib/builtinVisibility.ts b/desktop/src/features/agents/lib/builtinVisibility.ts index a23233d4c5a..eacf167d79a 100644 --- a/desktop/src/features/agents/lib/builtinVisibility.ts +++ b/desktop/src/features/agents/lib/builtinVisibility.ts @@ -1,18 +1,41 @@ -import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import { getActivePersonas } from "@/features/agents/lib/catalog"; +import { getUsableTeams } from "@/features/agents/lib/teamPersonas"; +import type { AgentPersona, AgentTeam } from "@/shared/api/types"; + +export function isPersonaVisibilityAuthoritative( + personas: readonly AgentPersona[] | undefined, + hiddenBuiltinIds: readonly string[] | undefined, + personasError: unknown, + hiddenBuiltinIdsError: unknown, +): boolean { + return ( + personas !== undefined && + hiddenBuiltinIds !== undefined && + personasError == null && + hiddenBuiltinIdsError == null + ); +} export function visibleLibraryPersonas( personas: readonly AgentPersona[], - agents: readonly Pick[], hiddenBuiltinIds: ReadonlySet, ): AgentPersona[] { - const managedPersonaIds = new Set( - agents.flatMap((agent) => (agent.personaId ? [agent.personaId] : [])), + return personas.filter( + (persona) => !persona.isBuiltIn || !hiddenBuiltinIds.has(persona.id), ); +} - return personas.filter( - (persona) => - !persona.isBuiltIn || - !hiddenBuiltinIds.has(persona.id) || - managedPersonaIds.has(persona.id), +export function getSelectableAgentOptions( + personas: readonly AgentPersona[], + teams: readonly AgentTeam[], + hiddenBuiltinPersonaIds: ReadonlySet, +) { + const visiblePersonas = visibleLibraryPersonas( + getActivePersonas(personas), + hiddenBuiltinPersonaIds, ); + return { + personas: visiblePersonas, + teams: getUsableTeams(teams, visiblePersonas), + }; } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 66e94c9f556..ef264fbdb4d 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -27,7 +27,6 @@ import { useTeamActions } from "./useTeamActions"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { visibleLibraryPersonas } from "@/features/agents/lib/builtinVisibility"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { Button } from "@/shared/ui/button"; import { @@ -92,19 +91,6 @@ export function AgentsView() { const runningAgentCount = agents.managedAgents.filter((agent) => isManagedAgentActive(agent), ).length; - const visiblePersonas = React.useMemo( - () => - visibleLibraryPersonas( - personas.libraryPersonas, - agents.managedAgents, - personas.hiddenBuiltinPersonaIds, - ), - [ - agents.managedAgents, - personas.hiddenBuiltinPersonaIds, - personas.libraryPersonas, - ], - ); const hasSavedAgentDefaults = Boolean( globalConfig.preferred_runtime?.trim() || globalConfig.provider?.trim() || @@ -254,7 +240,7 @@ export function AgentsView() { void agents.handleStartPersona(persona); }} // Persona props - personas={visiblePersonas} + personas={personas.libraryPersonas} personasError={ personas.personasQuery.error instanceof Error ? personas.personasQuery.error @@ -277,7 +263,7 @@ export function AgentsView() { personas.hiddenBuiltinPersonasQuery.isLoading } isPersonasPending={personas.isPending} - hiddenBuiltinCount={personas.hiddenBuiltinPersonaIds.size} + hiddenBuiltinPersonaIds={personas.hiddenBuiltinPersonaIds} onOpenCatalog={openUnifiedCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} @@ -574,6 +560,8 @@ export function AgentsView() { onSubmit={teamActions.handleTeamSubmit} open={teamActions.teamDialogState !== null} personas={personas.libraryPersonas} + preservedUnavailablePersonaIds={personas.hiddenBuiltinPersonaIds} + isPersonaCatalogAuthoritative={personas.isVisibilityAuthoritative} submitLabel={teamActions.teamDialogState.submitLabel} title={teamActions.teamDialogState.title} /> diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429dc..91daa297ef6 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -23,8 +23,8 @@ import { RemoveMembersConfirmDialog } from "./RemoveMembersConfirmDialog"; import { copySelectedPersonaIds, countMissingPersonaIds, - filterAvailablePersonaIds, orderPersonasByInitiallySelected, + resolveTeamSubmitPersonaIds, } from "./teamDialogSelection"; type TeamDialogProps = { @@ -34,6 +34,8 @@ type TeamDialogProps = { submitLabel: string; initialValues: CreateTeamInput | UpdateTeamInput | null; personas: AgentPersona[]; + preservedUnavailablePersonaIds: ReadonlySet; + isPersonaCatalogAuthoritative: boolean; error: Error | null; isPending: boolean; onOpenChange: (open: boolean) => void; @@ -48,6 +50,8 @@ export function TeamDialog({ submitLabel, initialValues, personas, + preservedUnavailablePersonaIds, + isPersonaCatalogAuthoritative, error, isPending, onOpenChange, @@ -71,8 +75,12 @@ export function TeamDialog({ return 0; } - return countMissingPersonaIds(initialValues.personaIds, personas); - }, [initialValues, personas]); + return countMissingPersonaIds( + initialValues.personaIds, + personas, + preservedUnavailablePersonaIds, + ); + }, [initialValues, personas, preservedUnavailablePersonaIds]); React.useEffect(() => { if (!open || !initialValues) { @@ -125,12 +133,19 @@ export function TeamDialog({ [removedPersonaIds, personas], ); - function buildSubmitInput(): CreateTeamInput | UpdateTeamInput { + function buildSubmitInput(): CreateTeamInput | UpdateTeamInput | null { + const personaIds = resolveTeamSubmitPersonaIds( + selectedPersonaIds, + personas, + preservedUnavailablePersonaIds, + isPersonaCatalogAuthoritative, + ); + if (personaIds === null) return null; const baseInput = { name, description: teamDescription.trim() || undefined, instructions: instructions.trim() || undefined, - personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas), + personaIds, }; if (initialValues && "id" in initialValues) { @@ -141,23 +156,29 @@ export function TeamDialog({ async function handleSubmit() { if (!initialValues) return; + const input = buildSubmitInput(); + if (input === null) return; if (removedPersonaIds.length > 0 && isEditMode && onDeleteRemovedPersonas) { setConfirmRemovalOpen(true); return; } - await onSubmit(buildSubmitInput()); + await onSubmit(input); } async function handleSubmitKeepAgents() { + const input = buildSubmitInput(); + if (input === null) return; setConfirmRemovalOpen(false); - await onSubmit(buildSubmitInput()); + await onSubmit(input); } async function handleSubmitDeleteAgents() { + const input = buildSubmitInput(); + if (input === null) return; setConfirmRemovalOpen(false); - await onSubmit(buildSubmitInput()); + await onSubmit(input); if (onDeleteRemovedPersonas && removedPersonaIds.length > 0) { await onDeleteRemovedPersonas(removedPersonaIds); } @@ -328,7 +349,8 @@ export function TeamDialog({ disabled={ name.trim().length === 0 || selectedPersonaIds.length === 0 || - isPending + isPending || + !isPersonaCatalogAuthoritative } onClick={() => void handleSubmit()} size="sm" diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index e4190e1e7d7..92d00be76cd 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -46,7 +46,7 @@ type UnifiedAgentsSectionProps = { personaFeedbackNoticeMessage: string | null; isPersonasLoading: boolean; isPersonasPending: boolean; - hiddenBuiltinCount: number; + hiddenBuiltinPersonaIds: ReadonlySet; onOpenCatalog: () => void; onDuplicatePersona: (persona: AgentPersona) => void; onEditPersona: (persona: AgentPersona) => void; @@ -88,7 +88,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { personaFeedbackNoticeMessage, isPersonasLoading, isPersonasPending, - hiddenBuiltinCount, + hiddenBuiltinPersonaIds, onOpenCatalog, onDuplicatePersona, onEditPersona, @@ -99,8 +99,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { } = props; const { groups, ungrouped, unknown } = React.useMemo( - () => buildUnifiedGroups(personas, agents), - [personas, agents], + () => buildUnifiedGroups(personas, agents, hiddenBuiltinPersonaIds), + [personas, agents, hiddenBuiltinPersonaIds], ); const [collapsed, setCollapsed] = React.useState>(new Set()); function toggle(key: string) { @@ -123,7 +123,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { > {isLoading ? : null} - {!isLoading && hiddenBuiltinCount > 0 ? ( + {!isLoading && hiddenBuiltinPersonaIds.size > 0 ? (
+ + event.preventDefault()} + > + {loading ? ( +
+ + Loading models... +
+ ) : error ? ( +
+

Failed to contact this agent.

+ +
+ ) : catalog?.message ? ( +
+ {catalog.message} +
+ ) : catalog && catalog.models.length > 0 ? ( + <> + void handleModelChange(value)} + value={catalog.currentModelId ?? ""} + > + {catalog.models.map((modelId) => ( + + {modelId} + + ))} + + {catalog.truncated ? ( +
+ Catalog truncated by the agent. +
+ ) : null} + + ) : ( +
+ Open to request available models. +
+ )} +
+ + ); +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index b4a139eb0ee..0b7653c4488 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -63,6 +63,20 @@ test("buildTranscript drops a session/prompt turn whose frame was stubbed by the assert.deepEqual(buildTranscript([stubbed]), []); }); +test("buildTranscript propagates conversation identity to transcript items", () => { + const [item] = buildTranscript([ + { + ...baseEvent, + kind: "turn_started", + conversationRoot: "thread-root-1", + payload: null, + }, + ]); + + assert.equal(item.channelId, baseEvent.channelId); + assert.equal(item.conversationRoot, "thread-root-1"); +}); + // --- positive control: a well-formed multi-block prompt DOES render --- test("buildTranscript renders Prompt context + user message for a multi-block session/prompt frame", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e371bf5fc30..d2cd8480a3c 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -279,6 +279,7 @@ function rawPayloadTitle(payload: unknown) { type TranscriptItemContext = { channelId: string | null; + conversationRoot: string | null; turnId: string | null; sessionId: string | null; }; @@ -304,6 +305,7 @@ function upsertMessage( ...existing, text: existing.text + text, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, authorPubkey: authorPubkey ?? existing.authorPubkey, @@ -325,9 +327,7 @@ function upsertMessage( text, timestamp, messageId, - channelId: ctx.channelId, - turnId: ctx.turnId, - sessionId: ctx.sessionId, + ...ctx, authorPubkey, acpSource, }); @@ -354,6 +354,7 @@ function upsertTextItem( ? joinLifecycleText(existing.text, text) : existing.text + text, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -370,6 +371,7 @@ function upsertTextItem( text, timestamp, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -418,6 +420,7 @@ function upsertLifecycleItem( text: joinLifecycleText(existing.text, text), descriptor: descriptor ?? existing.descriptor, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -434,9 +437,7 @@ function upsertLifecycleItem( text, timestamp, descriptor, - channelId: ctx.channelId, - turnId: ctx.turnId, - sessionId: ctx.sessionId, + ...ctx, acpSource, }); } @@ -465,6 +466,7 @@ function replaceLifecycleItem( title, text, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -480,9 +482,7 @@ function replaceLifecycleItem( title, text, timestamp, - channelId: ctx.channelId, - turnId: ctx.turnId, - sessionId: ctx.sessionId, + ...ctx, acpSource, }); } @@ -504,6 +504,7 @@ function upsertPlan( ...existing, text, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -519,6 +520,7 @@ function upsertPlan( isUpdate: true, targetId: id, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot, turnId: ctx.turnId, sessionId: ctx.sessionId, acpSource, @@ -534,9 +536,7 @@ function upsertPlan( title, text, timestamp, - channelId: ctx.channelId, - turnId: ctx.turnId, - sessionId: ctx.sessionId, + ...ctx, acpSource, }); } @@ -571,6 +571,7 @@ function upsertMetadata( ...existing, sections, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -585,9 +586,7 @@ function upsertMetadata( title, sections, timestamp, - channelId: ctx.channelId, - turnId: ctx.turnId, - sessionId: ctx.sessionId, + ...ctx, acpSource, }); } @@ -659,6 +658,7 @@ function upsertTool( ? timestamp : existing.completedAt, channelId: ctx.channelId, + conversationRoot: ctx.conversationRoot ?? existing.conversationRoot, turnId: ctx.turnId ?? existing.turnId, sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, @@ -690,9 +690,7 @@ function upsertTool( timestamp, startedAt: timestamp, completedAt: isTerminalToolStatus(status) ? timestamp : null, - channelId: ctx.channelId, - turnId: ctx.turnId, - sessionId: ctx.sessionId, + ...ctx, acpSource, }); } @@ -711,6 +709,7 @@ export function processTranscriptEvent( const ch = channelId ?? "global"; const ctx: TranscriptItemContext = { channelId, + conversationRoot: event.conversationRoot ?? null, turnId: event.turnId, sessionId: event.sessionId ?? d.latestSessionId, }; diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 578f98076cd..4ca026640cb 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -6,6 +6,8 @@ export type ObserverEvent = { kind: string; agentIndex: number | null; channelId: string | null; + /** Thread/conversation root inside channelId; absent for DMs and legacy frames. */ + conversationRoot?: string | null; sessionId: string | null; turnId: string | null; startedAt?: string | null; @@ -66,6 +68,8 @@ export type TranscriptItemIdentity = { turnId?: string | null; sessionId?: string | null; channelId?: string | null; + /** Thread identity; null/absent denotes legacy channel-scoped activity. */ + conversationRoot?: string | null; }; export type TranscriptItem = diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a961030..65982969aab 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -13,12 +13,15 @@ import { cn } from "@/shared/lib/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { RelayAgentModelPicker } from "@/features/agents/ui/RelayAgentModelPicker"; +import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; export type BotActivityAgent = Pick; type BotActivityBarProps = { agents: BotActivityAgent[]; channelId?: string | null; + conversationRoot?: string | null; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; @@ -30,9 +33,40 @@ const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; +export function relayAgentModelPickerKey( + agentPubkey: string, + channelId: string, + conversationRoot: string | null, +): string { + return JSON.stringify([ + agentPubkey.toLowerCase(), + channelId, + conversationRoot ?? null, + ]); +} + +export function filterBotActivityTranscript< + T extends Pick, +>( + transcript: readonly T[], + channelId: string | null, + conversationRoot: string | null, +): T[] { + return transcript.filter( + (item) => + (channelId === null || item.channelId === channelId) && + // Root-less observer items are legacy channel-scoped activity and remain + // visible as the documented fallback in a modern thread. + (conversationRoot === null || + item.conversationRoot == null || + item.conversationRoot === conversationRoot), + ); +} + export function BotActivityComposerAction({ agents, channelId = null, + conversationRoot = null, onOpenAgentSession, openAgentSessionPubkey, profiles, @@ -64,9 +98,11 @@ export function BotActivityComposerAction({ const seen = new Set(); const headlines: string[] = []; - const scopedTranscript = channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript; + const scopedTranscript = filterBotActivityTranscript( + transcript, + channelId, + conversationRoot, + ); // Two-tier scan: spine items first (reads recede when real work is present). // If no spine headlines are found (session start / idle), fall back to all @@ -92,7 +128,7 @@ export function BotActivityComposerAction({ } return headlines; - }, [channelId, singleWorkingAgent, transcript]); + }, [channelId, conversationRoot, singleWorkingAgent, transcript]); const [headlineIndex, setHeadlineIndex] = React.useState(0); const clearHoverTimer = React.useCallback(() => { @@ -157,116 +193,130 @@ export function BotActivityComposerAction({ : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( - - - - - event.preventDefault()} - side="top" - sideOffset={8} - > -
- Agents working -
-
- {workingAgents.map((agent) => { - const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); - - return ( - - ); - })} -
-
-
+ ))} + + {workingAgents.length > 2 ? ( + + +{workingAgents.length - 2} + + ) : null} + + {isInline ? ( + + {visibleStatusLabel} + + ) : ( + "working" + )} + + {isInline ? null : ( + + )} + + + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ Agents working +
+
+ {workingAgents.map((agent) => { + const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); + + return ( + + ); + })} +
+
+ + {singleWorkingAgent && channelId ? ( + + ) : null} + ); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 738635e177e..fefec078d53 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -6,10 +6,6 @@ import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; import { ComposerUploadProgressOverlay } from "@/features/messages/ui/ComposerUploadProgressOverlay"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; -import { - ContextUsageRing, - formatTokenCount, -} from "@/features/messages/ui/ContextUsageRing"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; import { isModerationDm } from "@/features/moderation/lib/moderationDm"; @@ -41,7 +37,6 @@ import { useThreadViewMode } from "@/features/channels/lib/threadViewModePrefere import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch"; import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; -import { useChannelContextUsage } from "@/features/agents/lib/channelContextUsage"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; import { mergeThreadComposerActivityPubkeys } from "@/features/channels/ui/channelComposerActivityMerge"; @@ -58,6 +53,7 @@ import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types" import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages"; +import { useChannelContextRings } from "@/features/channels/ui/useChannelContextRings"; import { Button } from "@/shared/ui/button"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; @@ -368,6 +364,10 @@ export const ChannelPane = React.memo(function ChannelPane({ const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( activeChannel?.id ?? null, ); + const threadObserverWorkingPubkeys = useChannelWorkingAgentPubkeys( + activeChannel?.id ?? null, + openThreadHeadId, + ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = @@ -377,13 +377,14 @@ export const ChannelPane = React.memo(function ChannelPane({ const threadTyping = botTypingEntries .filter((entry) => entry.threadHeadId === openThreadHeadId) .map((entry) => entry.pubkey); - // BUZZ-DESKTOP-003: merge the channel-level working set so threads - // render the activity bar, not only "is typing". + // Thread activity uses observer frames scoped by conversationRoot plus the + // thread's own typing entries. Legacy observer frames remain available via + // the store's channel-level fallback. return mergeThreadComposerActivityPubkeys( threadTyping, - composerWorkingBotPubkeys, + threadObserverWorkingPubkeys, ); - }, [botTypingEntries, composerWorkingBotPubkeys, openThreadHeadId]); + }, [botTypingEntries, openThreadHeadId, threadObserverWorkingPubkeys]); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; const directMessageIntro = React.useMemo( @@ -396,34 +397,13 @@ export const ChannelPane = React.memo(function ChannelPane({ [activeChannel, currentPubkey, profiles], ); - // BUZZ-DESKTOP-003: DM context-fill ring. Only mounted for DM channels — - // channel-scoped metrics let us attribute usage to the DM's agent without - // agent-pubkey plumbing. const isDmChannel = activeChannel?.channelType === "dm"; - const channelContextUsage = useChannelContextUsage( - isDmChannel ? (activeChannel?.id ?? null) : null, + const { dmContextRing, threadContextRing } = useChannelContextRings({ + channelId: activeChannel?.id ?? null, currentPubkey, - ); - const dmContextRing = React.useMemo(() => { - if (!isDmChannel || !channelContextUsage) return null; - const { usageRatio, model, lastInputTokens, contextWindow } = - channelContextUsage; - const pct = usageRatio == null ? null : Math.round(usageRatio * 100); - const label = [ - model ?? "unknown model", - pct == null ? "no usage data" : `${pct}% context used`, - lastInputTokens != null && contextWindow > 0 - ? `${formatTokenCount(lastInputTokens)}/${formatTokenCount(contextWindow)} tokens` - : null, - ] - .filter(Boolean) - .join(" · "); - return ( - - - - ); - }, [channelContextUsage, isDmChannel]); + isDmChannel, + threadRoot: openThreadHeadId, + }); const handleWelcomeAddAgent = React.useCallback(() => { onAddAgent?.({ @@ -887,11 +867,13 @@ export const ChannelPane = React.memo(function ChannelPane({ threadReplyUnreadCounts={threadReplyUnreadCounts} threadTypingPubkeys={threadTypingPubkeys} activityAccessoryVisible={hasThreadComposerBotActivity} + composerToolbarExtraActions={threadContextRing} activityAccessoryContent={ hasThreadComposerBotActivity ? ( setIssuesPanelOpen(false), - [], - ); + const issuesPanel = issuesPanelState.useChannelIssuesPanelState(); const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState( () => new Set(), @@ -490,7 +487,7 @@ export function ChannelScreen({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, - onBeforeOpenThread: handleCloseIssuesPanel, + onBeforeOpenThread: issuesPanel.close, profiles: messageProfiles, recordThreadInteraction, openThreadHeadId: effectiveOpenThreadHeadId, @@ -674,13 +671,13 @@ export function ChannelScreen({ threadReplyTargetId, threadReplyTargetMessage, }); - const hasAuxiliaryPanel = Boolean( - effectiveOpenThreadHeadId || - openAgentSessionPubkey || - profilePanelPubkey || - channelManagementOpen || - issuesPanelOpen, - ); + const hasAuxiliaryPanel = [ + effectiveOpenThreadHeadId, + openAgentSessionPubkey, + profilePanelPubkey, + channelManagementOpen, + issuesPanel.open, + ].some(Boolean); const displayedThreadHeadMessage = threadPanelData.threadHead; const displayedThreadAllMessages = threadPanelData.messages; const displayedThreadMessages = threadPanelData.visibleReplies; @@ -723,7 +720,7 @@ export function ChannelScreen({ setThreadReplyTargetId(null); handleCloseAgentSession(); setProfilePanelPubkey(null); - setIssuesPanelOpen(false); + issuesPanel.close(); setChannelManagementOpen(true); }, [ activeChannel?.channelType, @@ -732,20 +729,16 @@ export function ChannelScreen({ setChannelManagementOpen, setOpenThreadHeadId, handleCloseAgentSession, + issuesPanel.close, setProfilePanelPubkey, ]); - const handleToggleIssues = React.useCallback(() => { - setIssuesPanelOpen((open) => !open); - setOpenThreadHeadId(null); - setProfilePanelPubkey(null); - setChannelManagementOpen(false); - handleCloseAgentSession(); - }, [ - handleCloseAgentSession, + const handleToggleIssues = issuesPanelState.useChannelIssuesPanelToggle({ + closeAgentSession: handleCloseAgentSession, setChannelManagementOpen, setOpenThreadHeadId, setProfilePanelPubkey, - ]); + toggleIssues: issuesPanel.toggle, + }); const handleToggleMembers = React.useCallback( () => setIsMembersSidebarOpen((prev) => !prev), [], @@ -763,7 +756,7 @@ export function ChannelScreen({ chromeWrapperRef={channelHeaderChromeRef} currentPubkey={currentPubkey} isAddBotOpen={isAddBotOpen} - issuesPanelOpen={issuesPanelOpen} + issuesPanelOpen={issuesPanel.open} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} onJoinChannel={joinChannelMutation.mutateAsync} @@ -785,7 +778,7 @@ export function ChannelScreen({ channelHeaderChromeRef, currentPubkey, isAddBotOpen, - issuesPanelOpen, + issuesPanel.open, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, handleManageChannel, @@ -865,7 +858,7 @@ export function ChannelScreen({ onAutoSendComplete={clearAutoSend} botTypingEntries={botTypingEntries} channelManagementOpen={channelManagementOpen} - issuesPanelOpen={issuesPanelOpen} + issuesPanelOpen={issuesPanel.open} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} @@ -925,7 +918,7 @@ export function ChannelScreen({ : undefined } onCloseChannelManagement={handleCloseChannelManagement} - onCloseIssuesPanel={handleCloseIssuesPanel} + onCloseIssuesPanel={issuesPanel.close} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete diff --git a/desktop/src/features/channels/ui/useChannelContextRings.tsx b/desktop/src/features/channels/ui/useChannelContextRings.tsx new file mode 100644 index 00000000000..752a42fd9df --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelContextRings.tsx @@ -0,0 +1,62 @@ +import type * as React from "react"; + +import { useChannelContextUsage } from "@/features/agents/lib/channelContextUsage"; +import { + ContextUsageRing, + formatTokenCount, +} from "@/features/messages/ui/ContextUsageRing"; + +function renderContextRing( + usage: NonNullable>, +) { + const { usageRatio, model, lastInputTokens, contextWindow } = usage; + const pct = usageRatio == null ? null : Math.round(usageRatio * 100); + const label = [ + model ?? "unknown model", + pct == null ? "no usage data" : `${pct}% context used`, + lastInputTokens != null && contextWindow > 0 + ? `${formatTokenCount(lastInputTokens)}/${formatTokenCount(contextWindow)} tokens` + : null, + ] + .filter(Boolean) + .join(" · "); + + return ( + + + + ); +} + +/** + * Fold owner-wide metric history into independent composer accessories. DMs are + * channel-scoped; group threads are scoped by their root while retaining the + * root-less legacy fallback in `useChannelContextUsage`. + */ +export function useChannelContextRings({ + channelId, + currentPubkey, + isDmChannel, + threadRoot, +}: { + channelId: string | null; + currentPubkey: string | null | undefined; + isDmChannel: boolean; + threadRoot: string | null; +}) { + const dmUsage = useChannelContextUsage( + isDmChannel ? channelId : null, + currentPubkey, + ); + const threadUsage = useChannelContextUsage( + threadRoot ? channelId : null, + currentPubkey, + threadRoot, + ); + + return { + dmContextRing: isDmChannel && dmUsage ? renderContextRing(dmUsage) : null, + threadContextRing: + threadRoot && threadUsage ? renderContextRing(threadUsage) : null, + } satisfies Record<"dmContextRing" | "threadContextRing", React.ReactNode>; +} diff --git a/desktop/src/features/channels/ui/useChannelIssuesPanelState.ts b/desktop/src/features/channels/ui/useChannelIssuesPanelState.ts new file mode 100644 index 00000000000..0b0f3ad3c77 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelIssuesPanelState.ts @@ -0,0 +1,37 @@ +import * as React from "react"; + +export function useChannelIssuesPanelState() { + const [open, setOpen] = React.useState(false); + const close = React.useCallback(() => setOpen(false), []); + const toggle = React.useCallback(() => setOpen((current) => !current), []); + + return { close, open, setOpen, toggle }; +} + +export function useChannelIssuesPanelToggle({ + closeAgentSession, + setChannelManagementOpen, + setOpenThreadHeadId, + setProfilePanelPubkey, + toggleIssues, +}: { + closeAgentSession: () => void; + setChannelManagementOpen: (open: boolean) => void; + setOpenThreadHeadId: (eventId: string | null) => void; + setProfilePanelPubkey: (pubkey: string | null) => void; + toggleIssues: () => void; +}) { + return React.useCallback(() => { + toggleIssues(); + setOpenThreadHeadId(null); + setProfilePanelPubkey(null); + setChannelManagementOpen(false); + closeAgentSession(); + }, [ + closeAgentSession, + setChannelManagementOpen, + setOpenThreadHeadId, + setProfilePanelPubkey, + toggleIssues, + ]); +} diff --git a/desktop/src/features/messages/lib/useMentionAgentEligibility.ts b/desktop/src/features/messages/lib/useMentionAgentEligibility.ts new file mode 100644 index 00000000000..a210b29f286 --- /dev/null +++ b/desktop/src/features/messages/lib/useMentionAgentEligibility.ts @@ -0,0 +1,69 @@ +import * as React from "react"; + +import { + getMentionableAgentPubkeys, + isAgentMentionChannelType, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import type { + ChannelMember, + ChannelType, + RelayAgent, +} from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function useMentionAgentEligibility({ + channelId, + channelType, + currentPubkey, + managedAgentPubkeys, + members, + membersError, + membersFetching, + relayAgents, + sharedChannelIds, +}: { + channelId: string | null; + channelType?: ChannelType | null; + currentPubkey: string | null; + managedAgentPubkeys: ReadonlySet; + members: ChannelMember[] | undefined; + membersError: unknown; + membersFetching: boolean; + relayAgents: readonly RelayAgent[] | undefined; + sharedChannelIds: ReadonlySet; +}) { + const mentionChannelId = isAgentMentionChannelType(channelType) + ? channelId + : null; + const memberPubkeys = React.useMemo( + () => + new Set( + members !== undefined && membersError === null && !membersFetching + ? members.map((member) => normalizePubkey(member.pubkey)) + : [], + ), + [members, membersError, membersFetching], + ); + const mentionableAgentPubkeys = React.useMemo( + () => + getMentionableAgentPubkeys({ + currentPubkey, + eligibilityScope: mentionChannelId + ? { type: "channel", channelId: mentionChannelId, memberPubkeys } + : { type: "managed-only" }, + managedAgentPubkeys, + relayAgents, + sharedChannelIds, + }), + [ + currentPubkey, + managedAgentPubkeys, + memberPubkeys, + mentionChannelId, + relayAgents, + sharedChannelIds, + ], + ); + + return { memberPubkeys, mentionableAgentPubkeys, mentionChannelId }; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index a076b19678a..e232e172cb7 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -19,9 +19,7 @@ import { filterCachedAgentSuggestions, getAdmittedAgentPubkeys, getAgentIdentityPubkeys, - getMentionableAgentPubkeys, getSharedChannelIds, - isAgentMentionChannelType, rememberSelectedAgentPubkeys, shouldHideAgentFromMentions, uniqueAutocompleteLabels, @@ -46,6 +44,7 @@ import { useAgentMentionRevalidation } from "./agentMentionRevalidation"; import { hasMention } from "./hasMention"; import { extractMentionPubkeys } from "./extractMentionPubkeys"; import { useDraftMentionRouting } from "./useDraftMentionRouting"; +import { useMentionAgentEligibility } from "./useMentionAgentEligibility"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; import { @@ -180,46 +179,18 @@ export function useMentions( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], ); - const mentionChannelId = isAgentMentionChannelType(options?.channelType) - ? channelId - : null; - const channelRosterReady = - membersQuery.data !== undefined && - membersQuery.error === null && - !membersQuery.isFetching; - const memberPubkeys = React.useMemo( - () => - new Set( - (channelRosterReady ? (membersQuery.data ?? []) : []).map((member) => - normalizePubkey(member.pubkey), - ), - ), - [channelRosterReady, membersQuery.data], - ); - const mentionableAgentPubkeys = React.useMemo( - () => - getMentionableAgentPubkeys({ - currentPubkey, - eligibilityScope: mentionChannelId - ? { - type: "channel", - channelId: mentionChannelId, - memberPubkeys, - } - : { type: "managed-only" }, - managedAgentPubkeys, - relayAgents: relayAgentsQuery.data, - sharedChannelIds, - }), - [ + const { memberPubkeys, mentionableAgentPubkeys, mentionChannelId } = + useMentionAgentEligibility({ + channelId, + channelType: options?.channelType, currentPubkey, managedAgentPubkeys, - memberPubkeys, - mentionChannelId, - relayAgentsQuery.data, + members: membersQuery.data, + membersError: membersQuery.error, + membersFetching: membersQuery.isFetching, + relayAgents: relayAgentsQuery.data, sharedChannelIds, - ], - ); + }); const personaNameByPubkey = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personas = personasQuery.data ?? []; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index c98d0bc0118..ab8f70f6d6d 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -117,6 +117,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { videoReviewPresentation?: VideoReviewPresentation; activityAccessoryContent?: React.ReactNode; activityAccessoryVisible: boolean; + composerToolbarExtraActions?: React.ReactNode; widthPx: number; isFollowingThread?: boolean; isMessageUnreadById?: (messageId: string) => boolean; @@ -237,6 +238,7 @@ export function MessageThreadPanel({ threadTypingPubkeys, activityAccessoryContent, activityAccessoryVisible, + composerToolbarExtraActions, widthPx, transparentChrome = false, autoSendDraftKey = null, @@ -918,6 +920,7 @@ export function MessageThreadPanel({ } profiles={profiles} replyTarget={composerReplyTarget} + toolbarExtraActions={composerToolbarExtraActions} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} /> diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index a7dd473c23f..e81bc9ea79f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -20,21 +20,14 @@ import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/eff import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -48,50 +41,8 @@ import { resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick< - UseRichTextEditorResult, - "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" - >; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; - onSuccessfulExplicitAgentAudience?: (audience: { - channelId: string; - expectedGeneration: number; - expectedRevision: number | null; - explicitAgentPubkeys: string[]; - }) => void; - resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; + export function useMentionSendFlow({ channelId, channelLinks, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 00000000000..ab29ce3c924 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,56 @@ +import type * as React from "react"; + +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { ChannelType } from "@/shared/api/types"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + ) => Promise + >; + richText: Pick< + UseRichTextEditorResult, + "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" + >; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; + onSuccessfulExplicitAgentAudience?: (audience: { + channelId: string; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + }) => void; + resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; +}; diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 7b45efd38c8..e66f9967182 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -48,6 +48,7 @@ import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; import { HuddleProfileControl } from "@/features/huddle"; import type { + AppSidebarView, CollapsibleSidebarGroup, CreateChannelKind, } from "@/features/sidebar/ui/AppSidebar.types"; @@ -96,15 +97,7 @@ type AppSidebarProps = { selfPresenceStatus: PresenceStatus; errorMessage?: string; selectedChannelId: string | null; - selectedView: - | "home" - | "channel" - | "messages" - | "agents" - | "agent-sessions" - | "workflows" - | "pulse" - | "projects"; + selectedView: AppSidebarView; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 48a19711a69..7cff22f990b 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -5,3 +5,13 @@ export type CollapsibleSidebarGroup = | "directMessages"; export type CreateChannelKind = "stream" | "forum"; + +export type AppSidebarView = + | "home" + | "channel" + | "messages" + | "agents" + | "agent-sessions" + | "workflows" + | "pulse" + | "projects"; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 7f7a0573700..570d4c5259e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,11 @@ -import { Activity, Bot, FolderGit2, Inbox, TerminalSquare, Zap } from "lucide-react"; +import { + Activity, + Bot, + FolderGit2, + Inbox, + TerminalSquare, + Zap, +} from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { FeatureGate } from "@/shared/features"; diff --git a/desktop/src/shared/api/agentControl.test.mjs b/desktop/src/shared/api/agentControl.test.mjs new file mode 100644 index 00000000000..85ba1278924 --- /dev/null +++ b/desktop/src/shared/api/agentControl.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildListModelsControl, + buildSwitchModelControl, +} from "./agentControl.ts"; + +test("buildListModelsControl carries the request correlation id", () => { + assert.deepEqual(buildListModelsControl("catalog-1"), { + type: "list_models", + requestId: "catalog-1", + }); +}); + +test("buildSwitchModelControl scopes the switch to one conversation", () => { + assert.deepEqual( + buildSwitchModelControl( + [{ channelId: "channel-1", conversationRoot: "thread-root-1" }], + "anthropic/claude-sonnet-4-5", + "switch-1", + ), + { + type: "switch_model", + modelId: "anthropic/claude-sonnet-4-5", + requestId: "switch-1", + targets: [ + { + channelId: "channel-1", + conversationRoot: "thread-root-1", + }, + ], + }, + ); +}); + +test("multi-turn model switch builds and sends one atomic control frame", async () => { + const targets = [ + { channelId: "channel-1", conversationRoot: "thread-root-1" }, + { channelId: "channel-2", conversationRoot: null }, + ]; + const expected = { + type: "switch_model", + modelId: "anthropic/claude-sonnet-4-5", + requestId: "switch-aggregate-1", + targets: [ + { channelId: "channel-1", conversationRoot: "thread-root-1" }, + { channelId: "channel-2" }, + ], + }; + + assert.deepEqual( + buildSwitchModelControl( + targets, + "anthropic/claude-sonnet-4-5", + "switch-aggregate-1", + ), + expected, + ); + + const api = await import("./agentControl.ts"); + assert.equal( + typeof api.sendSwitchModelControl, + "function", + "the caller must have one send seam for the aggregate frame", + ); + const sends = []; + await api.sendSwitchModelControl( + async (pubkey, payload) => sends.push({ pubkey, payload }), + "agent-pubkey", + targets, + "anthropic/claude-sonnet-4-5", + "switch-aggregate-1", + ); + assert.deepEqual(sends, [{ pubkey: "agent-pubkey", payload: expected }]); +}); diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad49..7638b4bca86 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -12,20 +12,76 @@ export async function cancelManagedAgentTurn( return { status: "sent" }; } +export function buildListModelsControl(requestId: string) { + return { + type: "list_models" as const, + requestId, + }; +} + +export type SwitchModelTarget = { + channelId: string; + conversationRoot?: string | null; +}; + +type ObserverControlSender = ( + pubkey: string, + payload: unknown, +) => Promise; + +export function buildSwitchModelControl( + targets: readonly SwitchModelTarget[], + modelId: string, + requestId: string, +) { + return { + type: "switch_model" as const, + modelId, + requestId, + targets: targets.map(({ channelId, conversationRoot }) => ({ + channelId, + ...(conversationRoot ? { conversationRoot } : {}), + })), + }; +} + +/** Send exactly one atomic model-switch control frame. */ +export async function sendSwitchModelControl( + sendControl: ObserverControlSender, + pubkey: string, + targets: readonly SwitchModelTarget[], + modelId: string, + requestId: string, +): Promise { + await sendControl( + pubkey, + buildSwitchModelControl(targets, modelId, requestId), + ); +} + +export async function listManagedAgentModels( + pubkey: string, + requestId: string, +): Promise { + await sendAgentObserverControl(pubkey, buildListModelsControl(requestId)); +} + /** - * Send a live model-switch control frame to a running agent. The switch rides - * the harness's cancel-switch-requeue path (busy turn) or invalidate-and-reapply - * (idle); the outcome arrives asynchronously as a `control_result` observer - * frame, not as the return value here. This is fire-and-forget on the send side. + * Send one live model-switch control frame for all named conversations. The + * outcome arrives asynchronously as one aggregate `control_result` observer + * frame, not as the return value here. */ export async function switchManagedAgentModel( pubkey: string, - channelId: string, + targets: readonly SwitchModelTarget[], modelId: string, + requestId: string, ): Promise { - await sendAgentObserverControl(pubkey, { - type: "switch_model", - channelId, + await sendSwitchModelControl( + sendAgentObserverControl, + pubkey, + targets, modelId, - }); + requestId, + ); } diff --git a/desktop/src/shared/api/observerRelay.ts b/desktop/src/shared/api/observerRelay.ts index 3b41f4de411..684adcb2ae3 100644 --- a/desktop/src/shared/api/observerRelay.ts +++ b/desktop/src/shared/api/observerRelay.ts @@ -11,6 +11,20 @@ import { relayClient } from "./relayClient"; // local Tauri archive, so there is no double-processing risk. const OBSERVER_LIVE_LOOKBACK_SECS = 300; +export type ControlResultFrame = { + type: "cancel_turn" | "switch_model" | "list_models"; + status: string; + requestId?: string; + modelId?: string; + conversationRoot?: string | null; + targets?: Array<{ channelId: string; conversationRoot?: string | null }>; + models?: string[]; + currentModelId?: string | null; + desiredModelId?: string | null; + totalCount?: number; + truncated?: boolean; +}; + export function subscribeToAgentObserverFrames( ownerPubkey: string, onEvent: (event: RelayEvent) => void, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 24ef6257832..d703296d149 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -474,12 +474,6 @@ export type SwitchManagedAgentModelStatus = | "unsupported_model" | "no_active_turn"; -export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; - status: string; - modelId?: string; -}; - export type GitBashPrerequisite = { available: boolean; path: string | null; From c29cae36f51f1da5245ce09c01212ed2093fcf33 Mon Sep 17 00:00:00 2001 From: Robi Hermes Date: Sat, 29 Aug 2026 15:42:03 +0200 Subject: [PATCH 27/76] feat(desktop): add issue human verdict flow Parse project-scoped review authority, keep raw verdicts pending until an exact coordinator confirmation, and expose accepted or rejected outcomes in the Issue panel. Signed-off-by: Robi Hermes --- desktop/src/features/projects/hooks.ts | 1 + .../features/projects/issueStatus.test.mjs | 144 ++++++ desktop/src/features/projects/issueStatus.ts | 128 +++++ .../src/features/projects/projectIssues.d.mts | 34 ++ .../src/features/projects/projectIssues.mjs | 403 +++++++++++++++- .../features/projects/projectIssues.test.mjs | 448 +++++++++++++++++- .../features/projects/projectModels.test.mjs | 76 +++ .../src/features/projects/projectModels.ts | 39 +- .../projects/projectWorkItems.test.mjs | 61 +++ .../src/features/projects/projectWorkItems.ts | 4 + .../projects/ui/ProjectIssuesPanel.test.tsx | 101 ++++ .../projects/ui/ProjectIssuesPanel.tsx | 160 ++++++- 12 files changed, 1572 insertions(+), 27 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectIssuesPanel.test.tsx diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 830d7571ad6..425f6994c89 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -272,6 +272,7 @@ async function fetchProjectIssues( statusEvents, mergeEventsById(commentEvents, assignmentEvents), oaOwner ? [oaOwner.owner] : [], + project.reviewAuthority, ); } diff --git a/desktop/src/features/projects/issueStatus.test.mjs b/desktop/src/features/projects/issueStatus.test.mjs index 8e6267ad1fd..950ccdcecbe 100644 --- a/desktop/src/features/projects/issueStatus.test.mjs +++ b/desktop/src/features/projects/issueStatus.test.mjs @@ -2,9 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + availableProjectIssueLifecycleStatuses, + buildProjectIssueVerdict, + canSubmitProjectIssueVerdict, canChangeProjectIssueStatus, ISSUE_LIFECYCLE_STATUS_LABEL, ISSUE_LIFECYCLE_STATUSES, + MAX_REJECTION_REASON_LENGTH, } from "./issueStatus.ts"; const OWNER = "a".repeat(64); @@ -28,6 +32,20 @@ test("every publishable lifecycle state maps to the label the read path derives" ); }); +test("current review removes generic resolved from the lifecycle picker", () => { + assert.deepEqual(availableProjectIssueLifecycleStatuses(true), [ + "draft", + "open", + "closed", + ]); + assert.deepEqual(availableProjectIssueLifecycleStatuses(false), [ + "draft", + "open", + "resolved", + "closed", + ]); +}); + test("label-driven states are not offered as publishable statuses", () => { const labels = ISSUE_LIFECYCLE_STATUSES.map( (status) => ISSUE_LIFECYCLE_STATUS_LABEL[status], @@ -36,6 +54,132 @@ test("label-driven states are not offered as publishable statuses", () => { assert.equal(labels.includes("In Review"), false); }); +test("builds exact personal-signed human verdict contracts", () => { + const issue = { + author: AUTHOR, + currentReview: { id: "review-42", rootId: "review-root-42" }, + id: "f".repeat(64), + }; + const project = { owner: OWNER, repoAddress: `30617:${OWNER}:demo` }; + + assert.deepEqual( + buildProjectIssueVerdict({ issue, project, verdict: "accepted" }), + { + content: "", + kind: 1631, + tags: [ + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ["p", OWNER], + ["p", AUTHOR], + ["t", "human-verdict"], + ["verdict", "accepted"], + ["review", "review-42"], + ["review-root", "review-root-42"], + ], + }, + ); + assert.deepEqual( + buildProjectIssueVerdict({ + issue, + project, + reason: "The target does not load.", + verdict: "rejected", + }), + { + content: "The target does not load.", + kind: 1630, + tags: [ + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ["p", OWNER], + ["p", AUTHOR], + ["t", "human-verdict"], + ["verdict", "rejected"], + ["review", "review-42"], + ["review-root", "review-root-42"], + ], + }, + ); + assert.throws( + () => + buildProjectIssueVerdict({ + issue, + project, + reason: "bad\nreason", + verdict: "rejected", + }), + /reason/i, + ); +}); + +test("human verdict recipients are the deduped exact owner and author set", () => { + const issue = { + author: OWNER, + currentReview: { id: "review-42", rootId: "1".repeat(64) }, + id: "f".repeat(64), + }; + const project = { owner: OWNER, repoAddress: `30617:${OWNER}:demo` }; + + const verdict = buildProjectIssueVerdict({ + issue, + project, + verdict: "accepted", + }); + assert.deepEqual( + verdict.tags.filter((tag) => tag[0] === "p"), + [["p", OWNER]], + ); + assert.equal(verdict.content, ""); +}); + +test("rejection reasons enforce the exact 500-character contract", () => { + assert.equal(MAX_REJECTION_REASON_LENGTH, 500); + const issue = { + author: AUTHOR, + currentReview: { id: "review-42", rootId: "1".repeat(64) }, + id: "f".repeat(64), + }; + const project = { owner: OWNER, repoAddress: `30617:${OWNER}:demo` }; + + assert.equal( + buildProjectIssueVerdict({ + issue, + project, + reason: "x".repeat(500), + verdict: "rejected", + }).content.length, + 500, + ); + assert.throws( + () => + buildProjectIssueVerdict({ + issue, + project, + reason: "x".repeat(501), + verdict: "rejected", + }), + /500/, + ); +}); + +test("only configured human verdict actors may submit a current review", () => { + const issue = { + currentReview: { authorizedHumanPubkeys: [OWNER, OA_OWNER] }, + }; + assert.equal(canSubmitProjectIssueVerdict(issue, OWNER), true); + assert.equal( + canSubmitProjectIssueVerdict(issue, OA_OWNER.toUpperCase()), + true, + ); + assert.equal(canSubmitProjectIssueVerdict(issue, ASSIGNEE), false); + assert.equal(canSubmitProjectIssueVerdict(issue, null), false); + assert.equal( + canSubmitProjectIssueVerdict({ currentReview: null }, OWNER), + false, + ); +}); + test("issue author and repo owner may change status", () => { for (const viewer of [AUTHOR, OWNER]) { assert.equal( diff --git a/desktop/src/features/projects/issueStatus.ts b/desktop/src/features/projects/issueStatus.ts index 366e1f12641..47bff25cc28 100644 --- a/desktop/src/features/projects/issueStatus.ts +++ b/desktop/src/features/projects/issueStatus.ts @@ -59,6 +59,86 @@ export const ISSUE_LIFECYCLE_STATUSES: ProjectIssueLifecycleStatus[] = [ "closed", ]; +export function availableProjectIssueLifecycleStatuses( + hasCurrentReview: boolean, +): ProjectIssueLifecycleStatus[] { + return hasCurrentReview + ? ISSUE_LIFECYCLE_STATUSES.filter((status) => status !== "resolved") + : ISSUE_LIFECYCLE_STATUSES; +} + +export type ProjectIssueHumanVerdict = "accepted" | "rejected"; + +export const MAX_REJECTION_REASON_LENGTH = 500; + +function hasControlCharacters(value: string): boolean { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); +} + +export function buildProjectIssueVerdict({ + issue, + project, + reason, + verdict, +}: { + issue: Pick; + project: Pick; + reason?: string; + verdict: ProjectIssueHumanVerdict; +}): { content: string; kind: number; tags: string[][] } { + const reviewId = issue.currentReview?.id; + const reviewRootId = issue.currentReview?.rootId; + if (!reviewId || !reviewRootId) { + throw new Error("A current review with its bound root is required."); + } + if (issue.currentReview?.verdict) { + throw new Error("A verdict was already sent for the current review."); + } + const content = reason?.trim() ?? ""; + if ( + verdict === "rejected" && + (!content || + content.length > MAX_REJECTION_REASON_LENGTH || + hasControlCharacters(content)) + ) { + throw new Error( + `A concrete, control-character-free rejection reason of at most ${MAX_REJECTION_REASON_LENGTH} characters is required.`, + ); + } + return { + kind: + verdict === "accepted" ? KIND_GIT_STATUS_MERGED : KIND_GIT_STATUS_OPEN, + content: verdict === "rejected" ? content : "", + tags: [ + ["e", issue.id, "", "root"], + ["a", project.repoAddress], + ...[ + ...new Set([project.owner.toLowerCase(), issue.author.toLowerCase()]), + ].map((recipient) => ["p", recipient]), + ["t", "human-verdict"], + ["verdict", verdict], + ["review", reviewId], + ["review-root", reviewRootId], + ], + }; +} + +export function canSubmitProjectIssueVerdict( + issue: Pick, + viewer: string | null, +): boolean { + return Boolean( + viewer && + !issue.currentReview?.verdict && + issue.currentReview?.authorizedHumanPubkeys.some( + (pubkey) => pubkey === viewer.toLowerCase(), + ), + ); +} + /** The issue author, the repo owner, and anyone assigned to the issue are * trusted for status changes. A managed-agent owner counts as the owner * because the desktop can sign on its behalf. Assignees are the ticket's @@ -144,6 +224,54 @@ async function updateProjectIssueStatus({ ); } +async function submitProjectIssueVerdict({ + issue, + project, + reason, + verdict, +}: { + issue: ProjectIssue; + project: Project; + reason?: string; + verdict: ProjectIssueHumanVerdict; +}): Promise { + const verdictEvent = buildProjectIssueVerdict({ + issue, + project, + reason, + verdict, + }); + const event = await signRelayEvent({ + ...verdictEvent, + createdAt: nextProjectIssueStatusCreatedAt( + issue, + Math.floor(Date.now() / 1_000), + ), + }); + await relayClient.publishEvent( + event, + "Timed out sending human verdict.", + "Failed to send human verdict.", + ); +} + +export function useSubmitProjectIssueVerdictMutation( + project: Project | null | undefined, +) { + const invalidate = useProjectIssueWriteInvalidation(project); + return useMutation({ + mutationFn: (input: { + issue: ProjectIssue; + reason?: string; + verdict: ProjectIssueHumanVerdict; + }) => { + if (!project) throw new Error("No project selected."); + return submitProjectIssueVerdict({ ...input, project }); + }, + onSuccess: invalidate, + }); +} + export function useUpdateProjectIssueStatusMutation( project: Project | null | undefined, ) { diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index 8c60acb5b69..d80a11eca24 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -19,6 +19,37 @@ export type ProjectIssueComment = { actionRequired: boolean; }; +export type ProjectIssueReviewConfirmation = { + eventId: string; + createdAt: number; + kanbanStatus: "done" | "ready" | "todo"; +}; + +export type ProjectIssueReviewVerdict = { + eventId: string; + kind: "accepted" | "rejected"; + actorPubkey: string; + createdAt: number; + reason: string | null; + confirmation: ProjectIssueReviewConfirmation | null; +}; + +export type ProjectIssueReview = { + id: string; + rootId: string; + target: string; + evidence: string; + test: string; + limitations: string; + authorizedHumanPubkeys: string[]; + verdict: ProjectIssueReviewVerdict | null; +}; + +export type ProjectIssueReviewAuthority = { + coordinatorPubkeys: string[]; + humanPubkeys: string[]; +}; + export type ProjectIssue = { id: string; title: string; @@ -37,6 +68,7 @@ export type ProjectIssue = { statusEventId: string | null; statusCreatedAt: number | null; updatedAt: number; + currentReview: ProjectIssueReview | null; comments: ProjectIssueComment[]; }; @@ -63,12 +95,14 @@ export function eventToProjectIssue( statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], additionalStatusActors?: string[], + reviewAuthority?: ProjectIssueReviewAuthority, ): ProjectIssue; export function projectIssueEventsToIssues( issueEvents: RelayEvent[], statusEvents?: RelayEvent[], commentEvents?: RelayEvent[], additionalStatusActors?: string[], + reviewAuthority?: ProjectIssueReviewAuthority, ): ProjectIssue[]; export function nextProjectIssueStatusCreatedAt( issue: ProjectIssue, diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index a2e9cb000d9..93d49e4d750 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -77,6 +77,7 @@ function latestStatusForIssue( .filter( (event) => allowedActors.has(event.pubkey.toLowerCase()) && + !getAllTags(event, "t").includes("human-verdict") && event.tags.some((tag) => tag[0] === "e" && tag[1] === issue.id), ) .sort((left, right) => right.created_at - left.created_at)[0]; @@ -89,13 +90,6 @@ function statusFromEvent(issue, statusEvent) { // label-based fallbacks below are client-side heuristics, not protocol. if (statusEvent?.kind === 1633) return PROJECT_ISSUE_STATUS.TRIAGE; - // Review-ready is verified workflow evidence on the mutable NIP-34 status - // event. It must override the immutable root's historic `approved` decision - // label: approval admitted work to the queue, it did not complete review. - if (/\breview[- ]ready\b/i.test(statusEvent?.content ?? "")) { - return PROJECT_ISSUE_STATUS.IN_REVIEW; - } - const labels = [ ...getAllTags(issue, "t"), ...(statusEvent ? getAllTags(statusEvent, "t") : []), @@ -196,17 +190,323 @@ function assignmentStateForIssue(issue, issueCommentEvents) { } function commentsForIssue(issueCommentEvents) { - return sortEvents(issueCommentEvents).map((event) => ({ - id: event.id, - content: event.content, - tags: getImetaTags(event), - author: event.pubkey, - createdAt: event.created_at, - recipients: getAllTags(event, "p"), - actionRequired: getAllTags(event, "t").includes( - ISSUE_ACTION_REQUIRED_LABEL, + return sortEvents(issueCommentEvents) + .filter((event) => { + const labels = getAllTags(event, "t"); + return ( + !labels.includes("review-ready") && + !labels.includes("issue-verdict-confirmed") + ); + }) + .map((event) => ({ + id: event.id, + content: event.content, + tags: getImetaTags(event), + author: event.pubkey, + createdAt: event.created_at, + recipients: getAllTags(event, "p"), + actionRequired: getAllTags(event, "t").includes( + ISSUE_ACTION_REQUIRED_LABEL, + ), + })); +} + +function exactTag(event, name, value) { + return event.tags.some( + (tag) => + tag.length === value.length + 1 && + tag[0] === name && + value.every((part, index) => tag[index + 1] === part), + ); +} + +function exactlyOneTag(event, name, value) { + return ( + event.tags.filter((tag) => tag[0] === name).length === 1 && + exactTag(event, name, value) + ); +} + +function singleTagValue(event, name) { + const tags = event.tags.filter((tag) => tag[0] === name); + return tags.length === 1 && + tags[0].length === 2 && + isNonEmptyString(tags[0][1]) + ? tags[0][1] + : null; +} + +function normalizedConfiguredPubkeys(pubkeys) { + if (!Array.isArray(pubkeys) || pubkeys.length === 0) return []; + if (pubkeys.some((pubkey) => !/^[0-9a-f]{64}$/.test(pubkey))) return []; + return new Set(pubkeys).size === pubkeys.length ? [...pubkeys] : []; +} + +function exactContentFields(content, header, expectedNames) { + const lines = content.split("\n"); + if (lines[0] !== header || lines.length !== expectedNames.length + 1) { + return null; + } + const fields = new Map(); + for (const line of lines.slice(1)) { + const separator = line.indexOf(": "); + if (separator <= 0) return null; + const name = line.slice(0, separator); + const value = line.slice(separator + 2); + if (!expectedNames.includes(name) || !value || fields.has(name)) + return null; + fields.set(name, value); + } + return fields.size === expectedNames.length ? fields : null; +} + +function reviewContent(event, reviewId) { + const fields = exactContentFields(event.content, "[REVIEW-READY]", [ + "Review-ID", + "Target", + "Evidence", + "Test", + "Known limitations", + ]); + if (!fields || fields.get("Review-ID") !== reviewId) return null; + return { + target: fields.get("Target"), + evidence: fields.get("Evidence"), + test: fields.get("Test"), + limitations: fields.get("Known limitations"), + }; +} + +function exactRecipientSet(event, expectedRecipients) { + const recipientTags = event.tags.filter((tag) => tag[0] === "p"); + if (recipientTags.length !== expectedRecipients.size) return false; + const recipients = recipientTags.map((tag) => + tag.length === 2 && /^[0-9a-f]{64}$/.test(tag[1] ?? "") ? tag[1] : null, + ); + return ( + recipients.every(Boolean) && + new Set(recipients).size === recipients.length && + recipients.every((recipient) => expectedRecipients.has(recipient)) + ); +} + +function descendingEventOrder(left, right) { + return right.created_at - left.created_at || right.id.localeCompare(left.id); +} + +function currentReviewForIssue(issue, issueCommentEvents, reviewAuthority) { + const repoAddress = getTag(issue, "a"); + const coordinators = normalizedConfiguredPubkeys( + reviewAuthority?.coordinatorPubkeys, + ); + const humans = normalizedConfiguredPubkeys(reviewAuthority?.humanPubkeys); + if ( + !repoAddress || + !repoOwnerFromAddress(repoAddress) || + coordinators.length === 0 || + humans.length !== 2 + ) { + return null; + } + const expectedHumans = new Set(humans); + const candidates = issueCommentEvents + .filter( + (event) => + event.kind === 1 && + coordinators.includes(event.pubkey) && + event.tags.some((tag) => tag[0] === "t" && tag[1] === "review-ready"), + ) + .sort(descendingEventOrder); + const marker = candidates[0]; + if ( + !marker || + !/^[0-9a-f]{64}$/.test(marker.id) || + marker.created_at < issue.created_at + ) { + return null; + } + if ( + !exactlyOneTag(marker, "e", [issue.id, "", "root"]) || + !exactlyOneTag(marker, "a", [repoAddress]) || + !exactlyOneTag(marker, "t", ["review-ready"]) || + !exactRecipientSet(marker, expectedHumans) + ) { + return null; + } + const id = singleTagValue(marker, "review"); + const rootId = singleTagValue(marker, "review-root"); + const content = id ? reviewContent(marker, id) : null; + if (!id || !rootId || !/^[0-9a-f]{64}$/.test(rootId) || !content) { + return null; + } + if ( + candidates.some( + (candidate, index) => + index > 0 && singleTagValue(candidate, "review") === id, + ) + ) { + return null; + } + return { + marker, + coordinators, + review: { + id, + rootId, + ...content, + authorizedHumanPubkeys: humans, + verdict: null, + }, + }; +} + +function hasControlCharacters(value) { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + }); +} + +function humanVerdictForCurrentReview( + issue, + statusEvents, + currentReviewBinding, +) { + if (!currentReviewBinding) return null; + const { marker, review } = currentReviewBinding; + const repoAddress = getTag(issue, "a"); + const authorizedHumans = new Set(review.authorizedHumanPubkeys); + const expectedRecipients = new Set( + [repoOwnerFromAddress(repoAddress), issue.pubkey.toLowerCase()].filter( + Boolean, ), - })); + ); + return ( + statusEvents + .filter((event) => { + const verdict = singleTagValue(event, "verdict"); + if ( + !/^[0-9a-f]{64}$/.test(event.id) || + !authorizedHumans.has(event.pubkey) || + event.created_at < marker.created_at || + !exactlyOneTag(event, "e", [issue.id, "", "root"]) || + !exactlyOneTag(event, "a", [repoAddress]) || + !exactlyOneTag(event, "t", ["human-verdict"]) || + !exactlyOneTag(event, "review", [review.id]) || + !exactlyOneTag(event, "review-root", [review.rootId]) || + !exactRecipientSet(event, expectedRecipients) + ) { + return false; + } + if (event.kind === 1631 && verdict === "accepted") { + return event.content === ""; + } + return ( + event.kind === 1630 && + verdict === "rejected" && + event.content.length > 0 && + event.content.length <= 500 && + event.content.trim() === event.content && + !hasControlCharacters(event.content) + ); + }) + .sort( + (left, right) => + left.created_at - right.created_at || left.id.localeCompare(right.id), + )[0] ?? null + ); +} + +function confirmationContent(event, issue, review, rawVerdict, kanbanStatus) { + const verdict = singleTagValue(rawVerdict, "verdict"); + const expectedNames = [ + "Issue", + "Repository", + "Board", + "Task", + "Review", + "Review-Root", + "Verdict-Event", + "Actor", + "Verdict", + "Kanban-Status", + ...(verdict === "rejected" ? ["Reason"] : []), + ]; + const fields = exactContentFields( + event.content, + "[ISSUE-VERDICT-CONFIRMED]", + expectedNames, + ); + if (!fields) return false; + return ( + fields.get("Issue") === issue.id && + fields.get("Repository") === getTag(issue, "a") && + fields.get("Review") === review.id && + fields.get("Review-Root") === review.rootId && + fields.get("Verdict-Event") === rawVerdict.id && + fields.get("Actor") === rawVerdict.pubkey && + fields.get("Verdict") === verdict && + fields.get("Kanban-Status") === kanbanStatus && + Boolean(fields.get("Board")) && + Boolean(fields.get("Task")) && + (verdict === "accepted" + ? !fields.has("Reason") + : fields.get("Reason") === rawVerdict.content) + ); +} + +function confirmationForCurrentVerdict( + issue, + issueCommentEvents, + currentReviewBinding, + rawVerdict, +) { + if (!currentReviewBinding || !rawVerdict) return null; + const { coordinators, marker, review } = currentReviewBinding; + const verdict = singleTagValue(rawVerdict, "verdict"); + const allowedKanbanStatuses = + verdict === "accepted" ? new Set(["done"]) : new Set(["ready", "todo"]); + const candidates = issueCommentEvents.filter( + (event) => + event.kind === 1 && + coordinators.includes(event.pubkey) && + event.created_at >= marker.created_at && + event.tags.some( + (tag) => tag[0] === "t" && tag[1] === "issue-verdict-confirmed", + ), + ); + if (candidates.length !== 1) return null; + const confirmations = candidates.filter((event) => { + if ( + !/^[0-9a-f]{64}$/.test(event.id) || + event.created_at < rawVerdict.created_at || + !exactlyOneTag(event, "e", [issue.id, "", "root"]) || + !exactlyOneTag(event, "a", [getTag(issue, "a")]) || + !exactlyOneTag(event, "t", ["issue-verdict-confirmed"]) || + !exactlyOneTag(event, "review", [review.id]) || + !exactlyOneTag(event, "review-root", [review.rootId]) || + !exactlyOneTag(event, "verdict", [verdict]) || + !exactlyOneTag(event, "verdict-event", [rawVerdict.id]) || + !exactRecipientSet(event, new Set([rawVerdict.pubkey])) + ) { + return false; + } + const kanbanStatus = singleTagValue(event, "kanban-status"); + return ( + allowedKanbanStatuses.has(kanbanStatus) && + confirmationContent(event, issue, review, rawVerdict, kanbanStatus) + ); + }); + if (confirmations.length !== 1) return null; + const event = confirmations[0]; + return { + event, + model: { + eventId: event.id, + createdAt: event.created_at, + kanbanStatus: singleTagValue(event, "kanban-status"), + }, + }; } export function eventToProjectIssue( @@ -214,6 +514,7 @@ export function eventToProjectIssue( statusEvents = [], commentEvents = [], additionalStatusActors = [], + reviewAuthority, ) { const issueCommentEvents = commentEvents.filter((event) => event.tags.some( @@ -230,6 +531,63 @@ export function eventToProjectIssue( assignmentState.assignees, additionalStatusActors, ); + const currentReviewBinding = currentReviewForIssue( + issue, + issueCommentEvents, + reviewAuthority, + ); + const rawVerdict = humanVerdictForCurrentReview( + issue, + statusEvents, + currentReviewBinding, + ); + const confirmation = confirmationForCurrentVerdict( + issue, + issueCommentEvents, + currentReviewBinding, + rawVerdict, + ); + const currentReview = currentReviewBinding + ? { + ...currentReviewBinding.review, + verdict: rawVerdict + ? { + eventId: rawVerdict.id, + kind: singleTagValue(rawVerdict, "verdict"), + actorPubkey: rawVerdict.pubkey, + createdAt: rawVerdict.created_at, + reason: rawVerdict.kind === 1630 ? rawVerdict.content : null, + confirmation: confirmation?.model ?? null, + } + : null, + } + : null; + const confirmedVerdict = currentReview?.verdict?.confirmation + ? currentReview.verdict.kind + : null; + const latestLifecycleAfterMarker = + latestStatus && + currentReviewBinding && + latestStatus.created_at > currentReviewBinding.marker.created_at + ? latestStatus + : null; + const status = + confirmedVerdict === "accepted" + ? PROJECT_ISSUE_STATUS.DONE + : confirmedVerdict === "rejected" + ? PROJECT_ISSUE_STATUS.BACKLOG + : currentReview + ? latestLifecycleAfterMarker?.kind === 1631 || + !latestLifecycleAfterMarker + ? PROJECT_ISSUE_STATUS.IN_REVIEW + : statusFromEvent(issue, latestLifecycleAfterMarker) + : statusFromEvent(issue, latestStatus); + const effectiveStatus = + confirmation?.event ?? + rawVerdict ?? + (currentReviewBinding + ? (latestStatus ?? currentReviewBinding.marker) + : latestStatus); const comments = commentsForIssue(issueCommentEvents); const title = getTag(issue, "subject") || @@ -250,15 +608,16 @@ export function eventToProjectIssue( recipients: getAllTags(issue, "p"), assignees: assignmentState.assignees, assigneeOperationHeads: assignmentState.heads, - status: statusFromEvent(issue, latestStatus), - statusEventId: latestStatus?.id ?? null, - statusCreatedAt: latestStatus?.created_at ?? null, + status, + statusEventId: effectiveStatus?.id ?? null, + statusCreatedAt: effectiveStatus?.created_at ?? null, updatedAt: [ ...comments, - ...(latestStatus ? [{ createdAt: latestStatus.created_at }] : []), + ...(effectiveStatus ? [{ createdAt: effectiveStatus.created_at }] : []), ].sort((left, right) => right.createdAt - left.createdAt)[0]?.createdAt ?? issue.created_at, + currentReview, comments, }; } @@ -268,6 +627,7 @@ export function projectIssueEventsToIssues( statusEvents = [], commentEvents = [], additionalStatusActors = [], + reviewAuthority, ) { return [...issueEvents] .map((issue) => @@ -276,6 +636,7 @@ export function projectIssueEventsToIssues( statusEvents, commentEvents, additionalStatusActors, + reviewAuthority, ), ) .sort((left, right) => right.updatedAt - left.updatedAt); diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index e11b6cfd976..02719e8a3e5 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -19,6 +19,10 @@ const OWNER = "a".repeat(64); const AUTHOR = "b".repeat(64); const ATTACKER = "c".repeat(64); const OA_OWNER = "d".repeat(64); +const REVIEW_TESTER = "f".repeat(64); +const REVIEW_COORDINATOR = "1".repeat(64); +const REVIEW_ID = "buzz-workflow:t_1:revision"; +const REVIEW_ROOT_ID = "2".repeat(64); const REPO_ADDRESS = `30617:${OWNER}:demo`; function issueEvent(overrides = {}) { @@ -77,6 +81,98 @@ function assignmentComment( }; } +function currentReviewMarker() { + return { + id: "3".repeat(64), + kind: 1, + pubkey: REVIEW_COORDINATOR, + created_at: 250, + content: `[REVIEW-READY]\nReview-ID: ${REVIEW_ID}\nTarget: immutable-target\nEvidence: focused tests\nTest: open the issue\nKnown limitations: none`, + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ["t", "review-ready"], + ["review", REVIEW_ID], + ["review-root", REVIEW_ROOT_ID], + ["p", OWNER], + ["p", REVIEW_TESTER], + ], + }; +} + +function acceptedReviewVerdict(overrides = {}) { + return { + id: "4".repeat(64), + kind: 1631, + pubkey: REVIEW_TESTER, + created_at: 300, + content: "", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ["p", OWNER], + ["p", AUTHOR], + ["t", "human-verdict"], + ["verdict", "accepted"], + ["review", REVIEW_ID], + ["review-root", REVIEW_ROOT_ID], + ], + ...overrides, + }; +} + +function rejectedReviewVerdict(overrides = {}) { + return { + id: "5".repeat(64), + kind: 1630, + pubkey: OWNER, + created_at: 300, + content: "The target does not load.", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ["p", OWNER], + ["p", AUTHOR], + ["t", "human-verdict"], + ["verdict", "rejected"], + ["review", REVIEW_ID], + ["review-root", REVIEW_ROOT_ID], + ], + ...overrides, + }; +} + +function reviewConfirmation(rawVerdict, overrides = {}) { + const verdict = rawVerdict.tags.find((tag) => tag[0] === "verdict")[1]; + const kanbanStatus = verdict === "accepted" ? "done" : "ready"; + const reason = + verdict === "rejected" ? `\nReason: ${rawVerdict.content}` : ""; + return { + id: "6".repeat(64), + kind: 1, + pubkey: REVIEW_COORDINATOR, + created_at: 350, + content: `[ISSUE-VERDICT-CONFIRMED]\nIssue: ${"e".repeat(64)}\nRepository: ${REPO_ADDRESS}\nBoard: buzz-workflow\nTask: t_1\nReview: ${REVIEW_ID}\nReview-Root: ${REVIEW_ROOT_ID}\nVerdict-Event: ${rawVerdict.id}\nActor: ${rawVerdict.pubkey}\nVerdict: ${verdict}\nKanban-Status: ${kanbanStatus}${reason}`, + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ["t", "issue-verdict-confirmed"], + ["review", REVIEW_ID], + ["review-root", REVIEW_ROOT_ID], + ["verdict", verdict], + ["verdict-event", rawVerdict.id], + ["kanban-status", kanbanStatus], + ["p", rawVerdict.pubkey], + ], + ...overrides, + }; +} + +const REVIEW_AUTHORITY = { + coordinatorPubkeys: [REVIEW_COORDINATOR], + humanPubkeys: [OWNER, REVIEW_TESTER], +}; + test("ignores status events from a different pubkey", () => { const attackerClosed = statusEvent({ kind: 1632, @@ -233,7 +329,7 @@ test("preserves root and comment tags for rich content rendering", () => { assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); }); -test("renders a review-ready status event ahead of an immutable approved decision label", () => { +test("does not infer a review from status-event prose", () => { const root = issueEvent({ tags: [ ["a", REPO_ADDRESS], @@ -248,7 +344,355 @@ test("renders a review-ready status event ahead of an immutable approved decisio assert.equal( eventToProjectIssue(root, [reviewReady]).status, - PROJECT_ISSUE_STATUS.IN_REVIEW, + PROJECT_ISSUE_STATUS.APPROVED, + ); +}); + +test("parses a trusted review-ready marker into the current review model", () => { + const TESTER = "f".repeat(64); + const COORDINATOR = "1".repeat(64); + const marker = { + id: "3".repeat(64), + kind: 1, + pubkey: COORDINATOR, + created_at: 250, + content: + "[REVIEW-READY]\nReview-ID: buzz-workflow:t_1:revision\nTarget: immutable-target\nEvidence: focused tests\nTest: open the issue\nKnown limitations: none", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ["t", "review-ready"], + ["review", "buzz-workflow:t_1:revision"], + ["review-root", "2".repeat(64)], + ["p", OWNER], + ["p", TESTER], + ], + }; + + const issue = eventToProjectIssue(issueEvent(), [], [marker], [], { + coordinatorPubkeys: [COORDINATOR], + humanPubkeys: [OWNER, TESTER], + }); + + assert.deepEqual(issue.currentReview, { + id: "buzz-workflow:t_1:revision", + rootId: "2".repeat(64), + target: "immutable-target", + evidence: "focused tests", + test: "open the issue", + limitations: "none", + authorizedHumanPubkeys: [OWNER, TESTER], + verdict: null, + }); +}); + +test("a valid raw accepted verdict remains pending until coordinator confirmation", () => { + const accepted = acceptedReviewVerdict(); + const issue = eventToProjectIssue( + issueEvent(), + [accepted], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); + assert.equal(issue.currentReview.verdict.kind, "accepted"); + assert.equal(issue.currentReview.verdict.confirmation, null); + assert.equal(issue.statusEventId, accepted.id); +}); + +test("an exact trusted accepted confirmation applies Done", () => { + const accepted = acceptedReviewVerdict(); + const confirmation = reviewConfirmation(accepted); + const issue = eventToProjectIssue( + issueEvent(), + [accepted], + [currentReviewMarker(), confirmation], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.DONE); + assert.equal(issue.statusEventId, confirmation.id); + assert.equal(issue.statusCreatedAt, confirmation.created_at); + assert.equal(issue.currentReview.verdict.confirmation.kanbanStatus, "done"); +}); + +test("an exact trusted rejected confirmation returns the issue to Backlog", () => { + const rejected = rejectedReviewVerdict(); + const pending = eventToProjectIssue( + issueEvent(), + [rejected], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + assert.equal(pending.status, PROJECT_ISSUE_STATUS.IN_REVIEW); + assert.equal(pending.currentReview.verdict.kind, "rejected"); + assert.equal(pending.currentReview.verdict.confirmation, null); + + for (const kanbanStatus of ["ready", "todo"]) { + const base = reviewConfirmation(rejected); + const confirmation = { + ...base, + content: base.content.replace( + "Kanban-Status: ready", + `Kanban-Status: ${kanbanStatus}`, + ), + tags: base.tags.map((tag) => + tag[0] === "kanban-status" ? ["kanban-status", kanbanStatus] : tag, + ), + }; + const issue = eventToProjectIssue( + issueEvent(), + [rejected], + [currentReviewMarker(), confirmation], + [], + REVIEW_AUTHORITY, + ); + assert.equal(issue.status, PROJECT_ISSUE_STATUS.BACKLOG); + assert.equal( + issue.currentReview.verdict.confirmation.kanbanStatus, + kanbanStatus, + ); + } +}); + +test("a pending current review suppresses a generic resolved status", () => { + const genericDone = statusEvent({ + kind: 1631, + pubkey: OWNER, + createdAt: 300, + }); + const issue = eventToProjectIssue( + issueEvent(), + [genericDone], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); +}); + +test("ordinary lifecycle authority remains effective after the current marker", () => { + const closed = statusEvent({ + kind: 1632, + pubkey: OWNER, + createdAt: 300, + }); + const issue = eventToProjectIssue( + issueEvent(), + [closed], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.CLOSED); +}); + +test("raw verdicts require exact recipients and accepted content", () => { + const accepted = acceptedReviewVerdict(); + const malformedVerdicts = [ + { ...accepted, content: "accepted" }, + { + ...accepted, + tags: accepted.tags.filter((tag) => tag[0] !== "p"), + }, + { + ...accepted, + tags: [...accepted.tags, ["p", OWNER]], + }, + ]; + + for (const verdict of malformedVerdicts) { + const issue = eventToProjectIssue( + issueEvent(), + [verdict], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); + assert.equal(issue.currentReview.verdict, null); + } +}); + +test("malformed, stale, untrusted, or duplicate confirmations fail closed", () => { + const accepted = acceptedReviewVerdict(); + const valid = reviewConfirmation(accepted); + const cases = [ + [ + { + ...valid, + pubkey: ATTACKER, + }, + ], + [ + { + ...valid, + created_at: accepted.created_at - 1, + }, + ], + [ + { + ...valid, + tags: [...valid.tags, ["review", REVIEW_ID]], + }, + ], + [ + { + ...valid, + content: valid.content.replace("Actor: ", "Actor: wrong-"), + }, + ], + [valid, { ...valid, id: "7".repeat(64) }], + [ + valid, + { + ...valid, + id: "8".repeat(64), + tags: [...valid.tags, ["review", REVIEW_ID]], + }, + ], + ]; + + for (const confirmations of cases) { + const issue = eventToProjectIssue( + issueEvent(), + [accepted], + [currentReviewMarker(), ...confirmations], + [], + REVIEW_AUTHORITY, + ); + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); + assert.equal(issue.currentReview.verdict.confirmation, null); + } +}); + +test("a review marker for a malformed repository coordinate fails closed", () => { + const malformedAddress = "not-a-repository-coordinate"; + const root = issueEvent({ + tags: [ + ["a", malformedAddress], + ["subject", "Something is broken"], + ], + }); + const marker = { + ...currentReviewMarker(), + tags: currentReviewMarker().tags.map((tag) => + tag[0] === "a" ? ["a", malformedAddress] : tag, + ), + }; + + assert.equal( + eventToProjectIssue(root, [], [marker], [], REVIEW_AUTHORITY).currentReview, + null, + ); +}); + +test("a review marker predating the issue fails closed", () => { + const marker = { ...currentReviewMarker(), created_at: 99 }; + const issue = eventToProjectIssue( + issueEvent(), + [], + [marker], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.currentReview, null); + assert.equal(issue.status, PROJECT_ISSUE_STATUS.BACKLOG); +}); + +test("a stale accepted verdict does not resolve the current review", () => { + const stale = acceptedReviewVerdict({ + tags: acceptedReviewVerdict().tags.map((tag) => + tag[0] === "review" ? ["review", "stale-review"] : tag, + ), + }); + const issue = eventToProjectIssue( + issueEvent(), + [stale], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); +}); + +test("an untrusted accepted verdict does not resolve the current review", () => { + const issue = eventToProjectIssue( + issueEvent(), + [acceptedReviewVerdict({ pubkey: ATTACKER })], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); +}); + +test("an accepted verdict predating the review marker fails closed", () => { + const issue = eventToProjectIssue( + issueEvent(), + [acceptedReviewVerdict({ created_at: 249 })], + [currentReviewMarker()], + [], + REVIEW_AUTHORITY, + ); + + assert.equal(issue.status, PROJECT_ISSUE_STATUS.IN_REVIEW); +}); + +test("rejects review-ready markers with duplicate review bindings", () => { + const TESTER = "f".repeat(64); + const COORDINATOR = "1".repeat(64); + const marker = { + id: "8".repeat(64), + kind: 1, + pubkey: COORDINATOR, + created_at: 250, + content: + "[REVIEW-READY]\nReview-ID: buzz-workflow:t_1:revision\nTarget: immutable-target\nEvidence: focused tests\nTest: open the issue\nKnown limitations: none", + tags: [ + ["e", "e".repeat(64), "", "root"], + ["a", REPO_ADDRESS], + ["t", "review-ready"], + ["review", "buzz-workflow:t_1:revision"], + ["review", "stale-review", "unexpected"], + ["review-root", "2".repeat(64)], + ["p", OWNER], + ["p", TESTER], + ], + }; + + assert.equal( + eventToProjectIssue(issueEvent(), [], [marker], [], { + coordinatorPubkeys: [COORDINATOR], + humanPubkeys: [OWNER, TESTER], + }).currentReview, + null, + ); + + const conflictingRoot = { + ...marker, + tags: [ + ...marker.tags.filter( + (tag) => tag[0] !== "review" || tag[1] === "buzz-workflow:t_1:revision", + ), + ["e", "f".repeat(64), "", "root"], + ], + }; + assert.equal( + eventToProjectIssue(issueEvent(), [], [conflictingRoot], [], { + coordinatorPubkeys: [COORDINATOR], + humanPubkeys: [OWNER, TESTER], + }).currentReview, + null, ); }); diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 7be105584a9..61963e14526 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -14,6 +14,9 @@ import { projectMatchesRouteId } from "./projectRoutes.ts"; const PROJECT_OWNER = "a".repeat(64); const FRONTEND_OWNER = "b".repeat(64); const BACKEND_OWNER = "c".repeat(64); +const REVIEW_COORDINATOR = "d".repeat(64); +const REVIEW_OWNER = "e".repeat(64); +const REVIEW_TESTER = "f".repeat(64); const RELAY_ORIGIN = "https://relay.example"; function repositoryEvent(owner, id, createdAt = 100) { @@ -61,6 +64,79 @@ test("eventToRepository preserves repository-scoped identity and clone data", () ]); }); +test("eventToExplicitProject populates valid signed review authority on the project and its repositories", () => { + const repository = eventToRepository( + repositoryEvent(PROJECT_OWNER, "frontend"), + RELAY_ORIGIN, + ); + const address = repository.repoAddress; + const event = projectEvent([["a", address]], { + tags: [ + ["d", "sprout"], + ["a", address], + ["review-coordinator", REVIEW_COORDINATOR], + ["review-human", REVIEW_OWNER], + ["review-human", REVIEW_TESTER], + ], + }); + + const project = eventToExplicitProject( + event, + new Map([[address, repository]]), + new Map([[address, repository]]), + ); + + assert.deepEqual(project.reviewAuthority, { + coordinatorPubkeys: [REVIEW_COORDINATOR], + humanPubkeys: [REVIEW_OWNER, REVIEW_TESTER], + }); + assert.deepEqual( + project.repositories[0].reviewAuthority, + project.reviewAuthority, + ); + assert.equal(repository.reviewAuthority, undefined); +}); + +test("malformed, duplicate, or incomplete signed review authority is absent", () => { + const cases = [ + [["review-human", REVIEW_OWNER]], + [ + ["review-coordinator", REVIEW_COORDINATOR.toUpperCase()], + ["review-human", REVIEW_OWNER], + ["review-human", REVIEW_TESTER], + ], + [ + ["review-coordinator", REVIEW_COORDINATOR], + ["review-human", REVIEW_OWNER], + ["review-human", REVIEW_OWNER], + ], + [ + ["review-coordinator", REVIEW_COORDINATOR, "unexpected"], + ["review-human", REVIEW_OWNER], + ["review-human", REVIEW_TESTER], + ], + ]; + + for (const authorityTags of cases) { + const project = eventToExplicitProject( + projectEvent([], { + tags: [["d", "sprout"], ...authorityTags], + }), + new Map(), + new Map(), + ); + assert.ok(project); + assert.equal(project.reviewAuthority, undefined); + } + + const [legacy] = buildProjectReadModels({ + projectEvents: [], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "legacy")], + }); + assert.equal(legacy.reviewAuthority, undefined); + assert.equal(legacy.repositories[0].reviewAuthority, undefined); +}); + test("buildProjectReadModels resolves repositories with a deterministic selection fallback", () => { const frontendAddress = `30617:${PROJECT_OWNER}:frontend`; const backendAddress = `30617:${PROJECT_OWNER}:backend`; diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 6d539b54d9e..1f0b8a66a98 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -1,3 +1,4 @@ +import type { ProjectIssueReviewAuthority } from "./projectIssues.mjs"; import type { RelayEvent } from "@/shared/api/types"; import { KIND_PROJECT_ANNOUNCEMENT, @@ -22,6 +23,8 @@ export type Repository = { channelId?: string | null; eventContent?: string; eventTags?: string[][]; + /** Explicit project-scoped authority injected by the project contract. */ + reviewAuthority?: ProjectIssueReviewAuthority; }; export type Project = { @@ -38,6 +41,8 @@ export type Project = { repositoryAddresses: string[]; repositoryRelayHints?: Record; repositories: Repository[]; + /** Explicit project-scoped authority injected by the project contract. */ + reviewAuthority?: ProjectIssueReviewAuthority; unavailableRepositoryAddresses?: string[]; visibility?: "listed" | "unlisted"; legacy: boolean; @@ -100,6 +105,30 @@ function isValidProjectMemberOwner(value: string): boolean { return /^[0-9a-f]{64}$/.test(value); } +export function parseProjectReviewAuthority( + tags: string[][], +): ProjectIssueReviewAuthority | undefined { + const coordinatorTags = tags.filter((tag) => tag[0] === "review-coordinator"); + const humanTags = tags.filter((tag) => tag[0] === "review-human"); + if (coordinatorTags.length === 0 || humanTags.length !== 2) return undefined; + if ( + [...coordinatorTags, ...humanTags].some( + (tag) => tag.length !== 2 || !isValidProjectMemberOwner(tag[1] ?? ""), + ) + ) { + return undefined; + } + const coordinatorPubkeys = coordinatorTags.map((tag) => tag[1]); + const humanPubkeys = humanTags.map((tag) => tag[1]); + if ( + new Set(coordinatorPubkeys).size !== coordinatorPubkeys.length || + new Set(humanPubkeys).size !== humanPubkeys.length + ) { + return undefined; + } + return { coordinatorPubkeys, humanPubkeys }; +} + /** NIP-MP rule `member-cap`: a project may carry at most 64 member `a` tags. */ export const MAX_PROJECT_MEMBERS = 64; @@ -339,6 +368,7 @@ export function eventToExplicitProject( const visibility = rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); const channel = getTag(event, "buzz-channel"); + const reviewAuthority = parseProjectReviewAuthority(event.tags); return { id: projectAddress, dtag, @@ -355,8 +385,11 @@ export function eventToExplicitProject( repositoryRelayHints, repositories: repositoryAddresses.flatMap((address) => { const repository = visibleRepositoriesByAddress.get(address); - return repository ? [repository] : []; + return repository + ? [reviewAuthority ? { ...repository, reviewAuthority } : repository] + : []; }), + ...(reviewAuthority ? { reviewAuthority } : {}), unavailableRepositoryAddresses: repositoryAddresses.filter( (address) => !repositoriesByAddress.has(address), ), @@ -526,7 +559,9 @@ export function addRepositoryToProject( ...project.repositories.filter( (candidate) => candidate.repoAddress !== repository.repoAddress, ), - repository, + project.reviewAuthority + ? { ...repository, reviewAuthority: project.reviewAuthority } + : repository, ].sort((left, right) => left.repoAddress.localeCompare(right.repoAddress)); return { diff --git a/desktop/src/features/projects/projectWorkItems.test.mjs b/desktop/src/features/projects/projectWorkItems.test.mjs index 21ee577b54e..52c2c207da4 100644 --- a/desktop/src/features/projects/projectWorkItems.test.mjs +++ b/desktop/src/features/projects/projectWorkItems.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { buildProjectReadModels } from "./projectModels.ts"; import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; // ── Work-item deduplication ───────────────────────────────────────────────── @@ -12,6 +13,8 @@ import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; // end-to-end, not just the filter algorithm in isolation. const REPO_OWNER = "a".repeat(64); +const REVIEW_TESTER = "b".repeat(64); +const REVIEW_COORDINATOR = "c".repeat(64); const REPO_DTAG = "relay"; const REPO_ADDRESS = `30617:${REPO_OWNER}:${REPO_DTAG}`; @@ -88,6 +91,64 @@ test("fetchProjectsWorkItems deduplicates issues from a shared repository", asyn assert.equal(result.issues.items[0].issue.id, ISSUE_ID); }); +test("fetchProjectsWorkItems threads project review authority into issue reduction", async () => { + const issue = makeIssue(ISSUE_ID); + const reviewId = "buzz-workflow:t_1:revision"; + const marker = { + id: "3".repeat(64), + kind: 1, + pubkey: REVIEW_COORDINATOR, + created_at: 250, + content: `[REVIEW-READY]\nReview-ID: ${reviewId}\nTarget: immutable-target\nEvidence: focused tests\nTest: open the issue\nKnown limitations: none`, + tags: [ + ["e", ISSUE_ID, "", "root"], + ["a", REPO_ADDRESS], + ["t", "review-ready"], + ["review", reviewId], + ["review-root", "2".repeat(64)], + ["p", REPO_OWNER], + ["p", REVIEW_TESTER], + ], + }; + const repositoryEvent = { + id: "1".repeat(64), + kind: 30617, + pubkey: REPO_OWNER, + created_at: 200, + content: "", + tags: [["d", REPO_DTAG]], + }; + const projectEvent = { + id: "2".repeat(64), + kind: 30621, + pubkey: REPO_OWNER, + created_at: 220, + content: "", + tags: [ + ["d", "workflow"], + ["a", REPO_ADDRESS], + ["review-coordinator", REVIEW_COORDINATOR], + ["review-human", REPO_OWNER], + ["review-human", REVIEW_TESTER], + ], + }; + const [project] = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: [repositoryEvent], + }); + assert.ok(project); + const fetchEvents = async (filter) => { + if (filter.kinds?.includes(1621)) return [issue]; + if (filter.kinds?.includes(1)) return [marker]; + return []; + }; + + const result = await fetchProjectsWorkItems([project], fetchEvents); + + assert.equal(result.issues.items[0].issue.currentReview?.id, reviewId); + assert.equal(result.issues.items[0].issue.status, "In Review"); +}); + test("fetchProjectsWorkItems deduplicates pull requests from a shared repository", async () => { const pr1 = makePR(PR_ID, 100); const pr2 = makePR(PR_ID_2, 90); diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index 75ba6789084..1bdfe87c633 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -17,6 +17,7 @@ import { import { getTag, type ProjectIssue, + type ProjectIssueReviewAuthority, projectIssueEventsToIssues, } from "./projectIssues.mjs"; import { @@ -30,6 +31,7 @@ type RepositoryReference = { type ProjectReference = { repositories: RepositoryReference[]; + reviewAuthority?: ProjectIssueReviewAuthority; }; type ProjectRepository = @@ -195,6 +197,8 @@ export async function fetchProjectsWorkItems( ), statusesByRepo.get(repository.repoAddress) ?? [], commentsByRepo.get(repository.repoAddress) ?? [], + [], + project.reviewAuthority, ).map((issue) => ({ issue, project, repository })), ), ) diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.test.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.test.tsx new file mode 100644 index 00000000000..7e0b08e89e8 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.test.tsx @@ -0,0 +1,101 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import test from "node:test"; + +import { reviewSectionState } from "./ProjectIssuesPanel.tsx"; + +const OWNER = "a".repeat(64); +const TESTER = "b".repeat(64); +const OTHER = "c".repeat(64); + +function issueWithVerdict(verdict = null) { + return { + currentReview: { + authorizedHumanPubkeys: [OWNER, TESTER], + evidence: "focused tests", + id: "review-42", + limitations: "none", + rootId: "1".repeat(64), + target: "immutable target", + test: "open the issue", + verdict, + }, + }; +} + +test("review section is read-only except for configured human verdict actors", () => { + const issue = issueWithVerdict(); + + assert.equal(reviewSectionState(issue, OWNER).canSubmit, true); + assert.equal(reviewSectionState(issue, TESTER).canSubmit, true); + assert.equal(reviewSectionState(issue, OTHER).canSubmit, false); + assert.equal(reviewSectionState({ currentReview: null }, OWNER), null); +}); + +test("pending raw verdict shows awaiting confirmation and disables repeat actions", () => { + const pending = reviewSectionState( + issueWithVerdict({ + actorPubkey: OWNER, + confirmation: null, + createdAt: 300, + eventId: "2".repeat(64), + kind: "accepted", + reason: null, + }), + OWNER, + ); + + assert.equal(pending.canSubmit, false); + assert.equal( + pending.message, + "Urteil gesendet – Workflow-Bestätigung ausstehend", + ); +}); + +test("trusted confirmations expose accepted and rejected success semantics", () => { + const accepted = reviewSectionState( + issueWithVerdict({ + actorPubkey: OWNER, + confirmation: { + createdAt: 350, + eventId: "3".repeat(64), + kanbanStatus: "done", + }, + createdAt: 300, + eventId: "2".repeat(64), + kind: "accepted", + reason: null, + }), + OWNER, + ); + const rejected = reviewSectionState( + issueWithVerdict({ + actorPubkey: OWNER, + confirmation: { + createdAt: 350, + eventId: "4".repeat(64), + kanbanStatus: "ready", + }, + createdAt: 300, + eventId: "2".repeat(64), + kind: "rejected", + reason: "Needs changes", + }), + OWNER, + ); + + assert.equal(accepted.canSubmit, false); + assert.equal(accepted.message, "Abnahme übernommen"); + assert.equal(rejected.canSubmit, false); + assert.equal(rejected.message, "Zur Nacharbeit zurückgegeben"); +}); + +test("send failure reports failure but re-enables actions for retry", () => { + const failed = reviewSectionState(issueWithVerdict(), OWNER, "failed"); + + assert.equal(failed.canSubmit, true); + assert.equal( + failed.message, + "Urteil konnte nicht gesendet werden. Bitte erneut versuchen.", + ); +}); diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 05f15045655..6155380002f 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -24,10 +24,13 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { + availableProjectIssueLifecycleStatuses, canChangeProjectIssueStatus, + canSubmitProjectIssueVerdict, ISSUE_LIFECYCLE_STATUS_LABEL, - ISSUE_LIFECYCLE_STATUSES, + MAX_REJECTION_REASON_LENGTH, type ProjectIssueLifecycleStatus, + useSubmitProjectIssueVerdictMutation, useUpdateProjectIssueStatusMutation, } from "@/features/projects/issueStatus"; import { entityDiscussionQuery } from "@/features/projects/lib/discussionChannels"; @@ -63,6 +66,33 @@ export function issueStatusClassName(status: ProjectIssue["status"]) { return "text-green-500"; } +export function reviewSectionState( + issue: Pick, + viewer: string | null, + submissionState: "idle" | "sent" | "failed" = "idle", +) { + if (!issue.currentReview) return null; + const verdict = issue.currentReview.verdict; + const confirmation = verdict?.confirmation; + const message = confirmation + ? verdict.kind === "accepted" + ? "Abnahme übernommen" + : "Zur Nacharbeit zurückgegeben" + : verdict || submissionState === "sent" + ? "Urteil gesendet – Workflow-Bestätigung ausstehend" + : submissionState === "failed" + ? "Urteil konnte nicht gesendet werden. Bitte erneut versuchen." + : null; + return { + ...issue.currentReview, + canSubmit: + canSubmitProjectIssueVerdict(issue, viewer) && + verdict === null && + submissionState !== "sent", + message, + }; +} + const ISSUE_STATUS_SECTIONS = [ { status: "Triage", label: "Triage", terminal: false }, { status: "In Review", label: "Review", terminal: false }, @@ -365,7 +395,9 @@ function IssueStatusPicker({ - {ISSUE_LIFECYCLE_STATUSES.map((status) => { + {availableProjectIssueLifecycleStatuses( + issue.currentReview !== null, + ).map((status) => { const label = ISSUE_LIFECYCLE_STATUS_LABEL[status]; const option = issueStatusVisual(label); return ( @@ -389,6 +421,124 @@ function IssueStatusPicker({ ); } +function IssueReviewSection({ + issue, + project, + viewer, +}: { + issue: ProjectIssue; + project: Project; + viewer: string | null; +}) { + const [reason, setReason] = React.useState(""); + const [submissionState, setSubmissionState] = React.useState< + "idle" | "sent" | "failed" + >("idle"); + const { isPending, mutateAsync: submitVerdict } = + useSubmitProjectIssueVerdictMutation(project); + const review = reviewSectionState(issue, viewer, submissionState); + + if (!review) return null; + + const submit = async (verdict: "accepted" | "rejected") => { + if (!review.canSubmit) return; + if ( + verdict === "accepted" && + !globalThis.confirm( + `Done for review ${review.id}?\n\nImmutable target: ${review.target}`, + ) + ) { + return; + } + try { + await submitVerdict({ + issue, + ...(verdict === "rejected" ? { reason } : {}), + verdict, + }); + setSubmissionState("sent"); + toast.success("Urteil gesendet – Workflow-Bestätigung ausstehend"); + } catch (error) { + setSubmissionState("failed"); + toast.error( + error instanceof Error + ? error.message + : "Failed to send human verdict.", + ); + } + }; + + return ( + +
+
+
Review-ID
+
{review.id}
+
+
+
Target
+
{review.target}
+
+
+
Evidence
+
{review.evidence}
+
+
+
Test
+
{review.test}
+
+
+
Known limitations
+
{review.limitations}
+
+
+ {review.message ? ( +

+ {review.message} +

+ ) : null} + {review.canSubmit ? ( +
+ + +