diff --git a/AGENTS.md b/AGENTS.md index 7ff0eb4d47..5948eebcdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -495,6 +495,7 @@ reconnects preserve pending avatar verification work): - `resetAgentObserverStore()` — agent observer relay store - `resetActiveAgentTurnsStore()` — active agent turn timers - `resetAgentWorkingSignal()` — agent working indicator signal +- `resetFleetTurnMetricsStore()` — fleet turn-metric usage store (kind 44200) - `resetAvatarProfileSync()` — pending verified-avatar profile writes - `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts - `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40dd5dd1b1..b2edcbb423 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ "**/local-archive-screenshots.spec.ts", "**/agent-readiness-screenshots.spec.ts", "**/agent-error-state-screenshots.spec.ts", + "**/fleet-screenshots.spec.ts", "**/edit-agent.spec.ts", "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce894931..13fe799ec3 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" + | "fleet" | "workflows" | "pulse" | "projects"; @@ -132,6 +133,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/fleet") { + return { + selectedChannelId: null, + selectedView: "fleet", + }; + } + if (pathname === "/workflows" || pathname.startsWith("/workflows/")) { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 877cd948ad..49e00f9d1d 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -125,6 +125,7 @@ export function AppShell() { const { goAgents, goChannel, + goFleet, goHome, goNewMessage, goProjects, @@ -875,6 +876,7 @@ export function AppShell() { onSelectChannel={(channelId) => void goChannel(channelId) } + onSelectFleet={() => void goFleet()} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} searchFocusRequest={searchFocusRequest} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..d5a82f9319 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -68,6 +68,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goFleet = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/fleet", + }, + behavior, + ), + [commitNavigation], + ); + const goPulse = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -297,6 +308,7 @@ export function useAppNavigation() { closeWorkflowDetail, goAgents, goChannel, + goFleet, goForumPost, goHome, goNewMessage, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6..bfa430da71 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; import { Route as projectsRouteImport } from "./routes/projects"; +import { Route as fleetRouteImport } from "./routes/fleet"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; @@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({ path: "/projects", getParentRoute: () => rootRouteImport, } as any); +const fleetRoute = fleetRouteImport.update({ + id: "/fleet", + path: "/fleet", + getParentRoute: () => rootRouteImport, +} as any); const agentsRoute = agentsRouteImport.update({ id: "/agents", path: "/agents", @@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute = export interface FileRoutesByFullPath { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/fleet": typeof fleetRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -97,6 +104,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/fleet": typeof fleetRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -112,6 +120,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport; "/": typeof indexRoute; "/agents": typeof agentsRoute; + "/fleet": typeof fleetRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; "/reminders": typeof remindersRoute; @@ -128,6 +137,7 @@ export interface FileRouteTypes { fullPaths: | "/" | "/agents" + | "/fleet" | "/projects" | "/pulse" | "/reminders" @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | "/" | "/agents" + | "/fleet" | "/projects" | "/pulse" | "/reminders" @@ -156,6 +167,7 @@ export interface FileRouteTypes { | "__root__" | "/" | "/agents" + | "/fleet" | "/projects" | "/pulse" | "/reminders" @@ -171,6 +183,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { indexRoute: typeof indexRoute; agentsRoute: typeof agentsRoute; + fleetRoute: typeof fleetRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; remindersRoute: typeof remindersRoute; @@ -220,6 +233,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsRouteImport; parentRoute: typeof rootRouteImport; }; + "/fleet": { + id: "/fleet"; + path: "/fleet"; + fullPath: "/fleet"; + preLoaderRoute: typeof fleetRouteImport; + parentRoute: typeof rootRouteImport; + }; "/agents": { id: "/agents"; path: "/agents"; @@ -275,6 +295,7 @@ declare module "@tanstack/react-router" { const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, agentsRoute: agentsRoute, + fleetRoute: fleetRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, remindersRoute: remindersRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11..63571cf2ca 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("/fleet", "fleet.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), diff --git a/desktop/src/app/routes/fleet.tsx b/desktop/src/app/routes/fleet.tsx new file mode 100644 index 0000000000..3934cc245e --- /dev/null +++ b/desktop/src/app/routes/fleet.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const FleetScreen = React.lazy(async () => { + const module = await import("@/features/fleet/ui/FleetScreen"); + return { default: module.FleetScreen }; +}); + +export const Route = createFileRoute("/fleet")({ + component: FleetRouteComponent, +}); + +function FleetRouteComponent() { + return ( + }> + + + ); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index abb49485d2..f537750e90 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -22,6 +22,7 @@ import { restoreActiveAgentTurnsForCommunity, } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; +import { resetFleetTurnMetricsStore } from "@/features/fleet/turnMetricsStore"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; @@ -53,6 +54,7 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + resetFleetTurnMetricsStore(); if (resetAvatarState) { resetAvatarProfileSync(); resetAvatarPresentations(); diff --git a/desktop/src/features/fleet/fleetAgents.test.mjs b/desktop/src/features/fleet/fleetAgents.test.mjs new file mode 100644 index 0000000000..37d27d4a32 --- /dev/null +++ b/desktop/src/features/fleet/fleetAgents.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { combineFleetAgents } from "./fleetAgents.ts"; + +const ME = "a1".repeat(32); +const OTHER_OWNER = "b2".repeat(32); +const MANAGED_PUBKEY = "c3".repeat(32); +const REMOTE_PUBKEY = "d4".repeat(32); +const UNOWNED_PUBKEY = "e5".repeat(32); + +function makeManagedAgent(overrides = {}) { + return { + pubkey: MANAGED_PUBKEY, + name: "Local Agent", + personaId: null, + runtime: null, + relayUrl: "ws://localhost:3000", + acpCommand: "buzz-acp", + agentCommand: "goose", + agentCommandOverride: null, + agentArgs: [], + mcpCommand: "buzz-dev-mcp", + turnTimeoutSeconds: 600, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: 1, + systemPrompt: null, + avatarUrl: "https://example.com/a.png", + model: "gpt-x", + modelSource: null, + provider: null, + personaOutOfDate: false, + personaOrphaned: false, + needsRestart: false, + envVars: {}, + status: "running", + pid: 123, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + lastStartedAt: "2026-07-29T08:00:00Z", + lastStoppedAt: null, + lastExitCode: null, + lastError: null, + lastErrorCode: null, + logPath: "/tmp/agent.log", + startOnAppLaunch: false, + autoRestartOnConfigChange: false, + backend: { type: "local" }, + backendAgentId: null, + respondTo: "owner-only", + respondToAllowlist: [], + ...overrides, + }; +} + +function makeRelayAgent(overrides = {}) { + return { + pubkey: REMOTE_PUBKEY, + name: "Remote Agent", + agentType: "goose", + channels: [], + channelIds: [], + capabilities: [], + status: "online", + respondTo: null, + respondToAllowlist: [], + ...overrides, + }; +} + +describe("combineFleetAgents", () => { + it("includes managed agents with their full metadata", () => { + const fleet = combineFleetAgents( + [makeManagedAgent()], + [], + new Map(), + new Map(), + ME, + ); + assert.equal(fleet.length, 1); + assert.equal(fleet[0].isManaged, true); + assert.equal(fleet[0].status, "running"); + assert.equal(fleet[0].model, "gpt-x"); + }); + + it("adds declared-owned relay agents and skips agents owned by others", () => { + const fleet = combineFleetAgents( + [], + [ + makeRelayAgent(), + makeRelayAgent({ pubkey: UNOWNED_PUBKEY, name: "Someone else's" }), + ], + new Map([ + [REMOTE_PUBKEY, ME], + [UNOWNED_PUBKEY, OTHER_OWNER], + ]), + new Map([[REMOTE_PUBKEY, "https://example.com/r.png"]]), + ME, + ); + assert.equal(fleet.length, 1); + assert.equal(fleet[0].pubkey, REMOTE_PUBKEY); + assert.equal(fleet[0].isManaged, false); + assert.equal(fleet[0].status, "deployed"); + assert.equal(fleet[0].avatarUrl, "https://example.com/r.png"); + }); + + it("maps offline remote presence to not_deployed", () => { + const fleet = combineFleetAgents( + [], + [makeRelayAgent({ status: "offline" })], + new Map([[REMOTE_PUBKEY, ME]]), + new Map(), + ME, + ); + assert.equal(fleet[0].status, "not_deployed"); + }); + + it("managed agents win pubkey collisions with the relay roster", () => { + const fleet = combineFleetAgents( + [makeManagedAgent()], + [makeRelayAgent({ pubkey: MANAGED_PUBKEY, name: "Duplicate" })], + new Map([[MANAGED_PUBKEY, ME]]), + new Map(), + ME, + ); + assert.equal(fleet.length, 1); + assert.equal(fleet[0].isManaged, true); + assert.equal(fleet[0].name, "Local Agent"); + }); + + it("ignores relay agents entirely before identity resolves", () => { + const fleet = combineFleetAgents( + [makeManagedAgent()], + [makeRelayAgent()], + new Map([[REMOTE_PUBKEY, ME]]), + new Map(), + undefined, + ); + assert.equal(fleet.length, 1); + assert.equal(fleet[0].isManaged, true); + }); + + it("sorts by name then pubkey for a stable grid", () => { + const fleet = combineFleetAgents( + [ + makeManagedAgent({ name: "Zeta" }), + makeManagedAgent({ pubkey: REMOTE_PUBKEY, name: "Alpha" }), + ], + [], + new Map(), + new Map(), + ME, + ); + assert.deepEqual( + fleet.map((agent) => agent.name), + ["Alpha", "Zeta"], + ); + }); +}); diff --git a/desktop/src/features/fleet/fleetAgents.ts b/desktop/src/features/fleet/fleetAgents.ts new file mode 100644 index 0000000000..33e59833dd --- /dev/null +++ b/desktop/src/features/fleet/fleetAgents.ts @@ -0,0 +1,102 @@ +/** + * Fleet roster composition. Pure module — the hook that feeds it lives in + * `useFleetAgents.ts` so these rules stay unit-testable without React. + */ + +import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * One row on the fleet board. Managed agents carry full lifecycle metadata; + * declared-owned relay agents (NIP-OA `ownerPubkey == me`, running on another + * machine) surface with the reduced fields the relay roster provides — the + * same ownership rule `useAgentObserverIngestion` uses to decide whose + * observer frames to decrypt, so every agent shown here also has live data. + */ +export type FleetAgent = { + pubkey: string; + name: string; + avatarUrl: string | null; + /** Managed-agent process status; owned relay agents map presence → status. */ + status: ManagedAgent["status"]; + isManaged: boolean; + model: string | null; + lastError: string | null; + lastErrorCode: number | null; + lastStartedAt: string | null; + lastStoppedAt: string | null; +}; + +function fromManagedAgent(agent: ManagedAgent): FleetAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + avatarUrl: agent.avatarUrl, + status: agent.status, + isManaged: true, + model: agent.model, + lastError: agent.lastError, + lastErrorCode: agent.lastErrorCode, + lastStartedAt: agent.lastStartedAt, + lastStoppedAt: agent.lastStoppedAt, + }; +} + +function fromOwnedRelayAgent( + agent: Pick, + avatarUrl: string | null, +): FleetAgent { + return { + pubkey: agent.pubkey, + name: agent.name, + avatarUrl, + // A relay-roster agent that is not locally managed runs elsewhere; its + // presence is the only liveness fact available. Offline presence maps to + // `not_deployed` rather than `stopped` — remotely we cannot distinguish + // a deliberate stop from an expired presence TTL. + status: agent.status === "offline" ? "not_deployed" : "deployed", + isManaged: false, + model: null, + lastError: null, + lastErrorCode: null, + lastStartedAt: null, + lastStoppedAt: null, + }; +} + +/** + * Combine locally managed agents with relay agents the current identity + * declared-owns into one fleet list. Managed agents win pubkey collisions + * (they carry richer metadata); the result is name-sorted for a stable grid. + * Mirrors `combineObserverIngestionAgents` — the fleet shows exactly the set + * of agents whose observer frames this desktop decrypts. + */ +export function combineFleetAgents( + managedAgents: readonly ManagedAgent[], + relayAgents: readonly RelayAgent[], + ownerByPubkey: ReadonlyMap, + avatarByPubkey: ReadonlyMap, + currentPubkey: string | null | undefined, +): FleetAgent[] { + const fleet = managedAgents.map(fromManagedAgent); + const managedSet = new Set( + managedAgents.map((agent) => normalizePubkey(agent.pubkey)), + ); + + if (currentPubkey) { + const me = normalizePubkey(currentPubkey); + for (const agent of relayAgents) { + const key = normalizePubkey(agent.pubkey); + if (managedSet.has(key)) continue; + const owner = ownerByPubkey.get(key); + if (!owner || normalizePubkey(owner) !== me) continue; + fleet.push(fromOwnedRelayAgent(agent, avatarByPubkey.get(key) ?? null)); + } + } + + return fleet.sort( + (left, right) => + left.name.localeCompare(right.name) || + left.pubkey.localeCompare(right.pubkey), + ); +} diff --git a/desktop/src/features/fleet/fleetStatus.test.mjs b/desktop/src/features/fleet/fleetStatus.test.mjs new file mode 100644 index 0000000000..1a4c89682b --- /dev/null +++ b/desktop/src/features/fleet/fleetStatus.test.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + deriveActivityHeadline, + deriveFleetLiveStatus, + deriveLastSeenMs, + formatLastSeen, + formatTokenCount, + formatUsdCost, +} from "./fleetStatus.ts"; + +describe("deriveFleetLiveStatus", () => { + it("working wins over everything else", () => { + assert.equal( + deriveFleetLiveStatus({ + working: true, + status: "stopped", + lastError: "boom", + }), + "working", + ); + }); + + it("running/deployed without work is idle", () => { + assert.equal( + deriveFleetLiveStatus({ + working: false, + status: "running", + lastError: null, + }), + "idle", + ); + assert.equal( + deriveFleetLiveStatus({ + working: false, + status: "deployed", + // A stale error from a previous run must not shadow a live process. + lastError: "old error", + }), + "idle", + ); + }); + + it("down with a recorded error needs attention", () => { + assert.equal( + deriveFleetLiveStatus({ + working: false, + status: "stopped", + lastError: "exit 1", + }), + "error", + ); + }); + + it("down without an error is offline", () => { + assert.equal( + deriveFleetLiveStatus({ + working: false, + status: "stopped", + lastError: null, + }), + "offline", + ); + assert.equal( + deriveFleetLiveStatus({ + working: false, + status: "not_deployed", + lastError: null, + }), + "offline", + ); + }); +}); + +describe("deriveLastSeenMs", () => { + const event = (timestamp) => ({ + seq: 1, + timestamp, + kind: "turn_liveness", + agentIndex: 0, + channelId: null, + sessionId: null, + turnId: null, + payload: null, + }); + + it("uses the newest source among observer frames and lifecycle stamps", () => { + const result = deriveLastSeenMs({ + events: [event("2026-07-29T10:00:00Z"), event("2026-07-29T12:00:00Z")], + lastStartedAt: "2026-07-29T09:00:00Z", + lastStoppedAt: "2026-07-29T11:00:00Z", + }); + assert.equal(result, Date.parse("2026-07-29T12:00:00Z")); + }); + + it("falls back to lifecycle stamps without observer frames", () => { + const result = deriveLastSeenMs({ + events: [], + lastStartedAt: null, + lastStoppedAt: "2026-07-29T11:00:00Z", + }); + assert.equal(result, Date.parse("2026-07-29T11:00:00Z")); + }); + + it("returns null with no parseable source", () => { + assert.equal( + deriveLastSeenMs({ events: [event("not-a-date")], lastStartedAt: null }), + null, + ); + assert.equal(deriveLastSeenMs({ events: [] }), null); + }); +}); + +describe("deriveActivityHeadline", () => { + const tool = (title, renderClass = "shell") => ({ + id: `tool-${title}`, + type: "tool", + renderClass, + descriptor: { renderClass, label: title, preview: null }, + title, + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: "2026-07-29T12:00:00Z", + startedAt: "2026-07-29T12:00:00Z", + completedAt: null, + }); + + it("returns the newest meaningful item's title", () => { + assert.equal( + deriveActivityHeadline([tool("Read src/a.ts"), tool("Ran cargo test")]), + "Ran cargo test", + ); + }); + + it("uses message text rather than the speaker-role title", () => { + const message = { + id: "m1", + type: "message", + renderClass: "message", + role: "assistant", + title: "Assistant", + text: "Deployed the fix to staging.", + timestamp: "2026-07-29T12:00:00Z", + }; + assert.equal( + deriveActivityHeadline([tool("Older"), message]), + "Deployed the fix to staging.", + ); + }); + + it("skips suppressed and raw-rail items", () => { + const suppressed = { ...tool("hidden"), renderClass: "suppressed" }; + assert.equal( + deriveActivityHeadline([tool("Visible"), suppressed]), + "Visible", + ); + }); + + it("returns null for an empty transcript", () => { + assert.equal(deriveActivityHeadline([]), null); + }); +}); + +describe("formatting", () => { + it("formats token counts compactly", () => { + assert.equal(formatTokenCount(950), "950"); + assert.equal(formatTokenCount(12_340), "12.3k"); + assert.equal(formatTokenCount(2_000), "2k"); + assert.equal(formatTokenCount(4_200_000), "4.2M"); + }); + + it("formats costs with a sub-cent floor", () => { + assert.equal(formatUsdCost(0), "$0.00"); + assert.equal(formatUsdCost(0.004), "<$0.01"); + assert.equal(formatUsdCost(1.5), "$1.50"); + }); + + it("formats last-seen labels across tiers", () => { + const now = Date.parse("2026-07-29T12:00:00Z"); + assert.equal(formatLastSeen(now - 20_000, now), "just now"); + assert.equal(formatLastSeen(now - 5 * 60_000, now), "5m ago"); + assert.equal(formatLastSeen(now - 3 * 3_600_000, now), "3h ago"); + assert.equal(formatLastSeen(now - 2 * 86_400_000, now), "2d ago"); + // A skewed future timestamp clamps instead of going negative. + assert.equal(formatLastSeen(now + 60_000, now), "just now"); + }); +}); diff --git a/desktop/src/features/fleet/fleetStatus.ts b/desktop/src/features/fleet/fleetStatus.ts new file mode 100644 index 0000000000..16da0321c4 --- /dev/null +++ b/desktop/src/features/fleet/fleetStatus.ts @@ -0,0 +1,117 @@ +/** + * Pure derivations for the fleet board: per-agent live status, last-seen + * timestamps, activity headlines, and compact number formatting. Free of + * React/Tauri imports so the rules are unit-testable. + */ + +import type { ManagedAgent } from "@/shared/api/types"; +import type { + ObserverEvent, + TranscriptItem, +} from "@/features/agents/ui/agentSessionTypes"; + +export type FleetLiveStatus = "working" | "idle" | "error" | "offline"; + +/** + * Collapse the working signal, managed-agent process status, and last error + * into one glanceable state: + * + * - `working` — observer-derived active turn (or typing fallback) right now. + * - `idle` — process is up (`running`/`deployed`) but no active turn. + * - `error` — process is down and the harness recorded an error on the way + * out; surfaces crashed/auth-failed agents that need attention. + * - `offline` — stopped/not deployed with no recorded error. + */ +export function deriveFleetLiveStatus(input: { + working: boolean; + status: ManagedAgent["status"]; + lastError: string | null; +}): FleetLiveStatus { + if (input.working) return "working"; + if (input.status === "running" || input.status === "deployed") return "idle"; + return input.lastError ? "error" : "offline"; +} + +/** + * Newest activity timestamp for an agent, in desktop-clock ms: + * the latest observer frame (events arrive sorted ascending, so the last one + * wins) or the managed-agent lifecycle timestamps, whichever is newest. + * Returns null when nothing parseable exists. + */ +export function deriveLastSeenMs(input: { + events: readonly ObserverEvent[]; + lastStartedAt?: string | null; + lastStoppedAt?: string | null; +}): number | null { + let latest: number | null = null; + const candidates: Array = [ + input.events.at(-1)?.timestamp, + input.lastStartedAt, + input.lastStoppedAt, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const parsed = Date.parse(candidate); + if (Number.isFinite(parsed) && (latest === null || parsed > latest)) { + latest = parsed; + } + } + return latest; +} + +/** Transcript render classes that never make a meaningful headline. */ +const HEADLINE_SKIP_CLASSES: ReadonlySet = new Set([ + "suppressed", + "raw-rail", +]); + +/** + * One-line "what is this agent doing / what did it last do" headline: the + * newest transcript item that renders as real activity. Message items headline + * with their text (the title is just the speaker role); everything else uses + * its title ("Editing src/foo.ts", "Ran cargo test", ...). + */ +export function deriveActivityHeadline( + transcript: readonly TranscriptItem[], +): string | null { + for (let index = transcript.length - 1; index >= 0; index -= 1) { + const item = transcript[index]; + if (HEADLINE_SKIP_CLASSES.has(item.renderClass)) continue; + const text = + item.type === "message" ? item.text.trim() || item.title : item.title; + const trimmed = text.trim(); + if (trimmed.length > 0) return trimmed; + } + return null; +} + +/** Compact token count: 950 → "950", 12_340 → "12.3k", 4_200_000 → "4.2M". */ +export function formatTokenCount(count: number): string { + if (count < 1_000) return String(count); + if (count < 1_000_000) { + return `${trimTrailingZero((count / 1_000).toFixed(1))}k`; + } + return `${trimTrailingZero((count / 1_000_000).toFixed(1))}M`; +} + +function trimTrailingZero(value: string): string { + return value.endsWith(".0") ? value.slice(0, -2) : value; +} + +/** Compact USD cost: 0 → "$0.00", 0.004 → "<$0.01", 1.5 → "$1.50". */ +export function formatUsdCost(costUsd: number): string { + if (costUsd > 0 && costUsd < 0.01) return "<$0.01"; + return `$${costUsd.toFixed(2)}`; +} + +/** Relative last-seen label: "just now", "5m ago", "3h ago", "2d ago". */ +export function formatLastSeen(lastSeenMs: number, nowMs: number): string { + const elapsed = Math.max(0, nowMs - lastSeenMs); + const minutes = Math.floor(elapsed / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/desktop/src/features/fleet/turnMetrics.test.mjs b/desktop/src/features/fleet/turnMetrics.test.mjs new file mode 100644 index 0000000000..bcf81c1fcb --- /dev/null +++ b/desktop/src/features/fleet/turnMetrics.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + aggregateAgentTurnMetrics, + displayTokenTotal, + EMPTY_USAGE_TOTALS, + isTurnMetricPayload, +} from "./turnMetrics.ts"; + +function makePayload(overrides = {}) { + return { + harness: "goose", + timestamp: "2026-07-29T12:00:00Z", + sessionId: "sess-1", + turnSeq: 1, + ...overrides, + }; +} + +describe("isTurnMetricPayload", () => { + it("accepts a minimal payload (harness + timestamp only)", () => { + assert.equal( + isTurnMetricPayload({ + harness: "goose", + timestamp: "2026-01-01T00:00:00Z", + }), + true, + ); + }); + + it("rejects payloads missing required fields", () => { + assert.equal(isTurnMetricPayload(null), false); + assert.equal(isTurnMetricPayload({}), false); + assert.equal(isTurnMetricPayload({ harness: "", timestamp: "x" }), false); + assert.equal(isTurnMetricPayload({ harness: "goose" }), false); + }); + + it("rejects negative or non-finite costUsd (NIP-AM numeric validity)", () => { + assert.equal( + isTurnMetricPayload(makePayload({ turn: { costUsd: -0.5 } })), + false, + ); + assert.equal( + isTurnMetricPayload( + makePayload({ cumulative: { costUsd: Number.POSITIVE_INFINITY } }), + ), + false, + ); + assert.equal( + isTurnMetricPayload(makePayload({ turn: { costUsd: 0.5 } })), + true, + ); + }); + + it("ignores unknown fields (forward compatibility)", () => { + assert.equal( + isTurnMetricPayload(makePayload({ futureField: { nested: true } })), + true, + ); + }); +}); + +describe("aggregateAgentTurnMetrics", () => { + it("returns the empty totals for no payloads", () => { + assert.deepEqual(aggregateAgentTurnMetrics([]), EMPTY_USAGE_TOTALS); + }); + + it("sums per-turn counts when no cumulative is reported", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ + turnSeq: 1, + turn: { inputTokens: 100, outputTokens: 10, costUsd: 0.01 }, + }), + makePayload({ + turnSeq: 2, + turn: { inputTokens: 200, outputTokens: 20, costUsd: 0.02 }, + }), + ]); + assert.equal(totals.turnCount, 2); + assert.equal(totals.inputTokens, 300); + assert.equal(totals.outputTokens, 30); + assert.equal(totals.totalTokens, null); + assert.ok(Math.abs(totals.costUsd - 0.03) < 1e-9); + }); + + it("prefers the latest cumulative over summed deltas within a session", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ + turnSeq: 1, + turn: { totalTokens: 500 }, + cumulative: { totalTokens: 500, costUsd: 0.05 }, + }), + makePayload({ + turnSeq: 2, + turn: { totalTokens: 700 }, + cumulative: { totalTokens: 1200, costUsd: 0.12 }, + }), + ]); + // Cumulative already covers both turns — summing deltas on top would + // double-count (500 + 700 + 1200). + assert.equal(totals.totalTokens, 1200); + assert.ok(Math.abs(totals.costUsd - 0.12) < 1e-9); + assert.equal(totals.turnCount, 2); + }); + + it("keeps the higher-turnSeq cumulative when events arrive out of order", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ turnSeq: 3, cumulative: { totalTokens: 900 } }), + makePayload({ turnSeq: 2, cumulative: { totalTokens: 600 } }), + ]); + assert.equal(totals.totalTokens, 900); + }); + + it("sums across independent sessions", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ sessionId: "a", cumulative: { totalTokens: 100 } }), + makePayload({ sessionId: "b", cumulative: { totalTokens: 200 } }), + makePayload({ sessionId: null, turn: { totalTokens: 50 } }), + ]); + assert.equal(totals.totalTokens, 350); + }); + + it("treats sessionless payloads as independent groups", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ sessionId: null, turn: { inputTokens: 10 } }), + makePayload({ sessionId: null, turn: { inputTokens: 20 } }), + ]); + assert.equal(totals.inputTokens, 30); + }); + + it("skips unreliable deltas but keeps reliable ones", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ turnSeq: 1, turn: { inputTokens: 100 } }), + makePayload({ + turnSeq: 2, + deltaReliable: false, + turn: { inputTokens: 9999 }, + }), + ]); + assert.equal(totals.inputTokens, 100); + assert.equal(totals.turnCount, 2); + }); + + it("keeps unreported fields null instead of coercing to 0", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ turn: { inputTokens: 100 } }), + ]); + assert.equal(totals.inputTokens, 100); + assert.equal(totals.outputTokens, null); + assert.equal(totals.totalTokens, null); + assert.equal(totals.costUsd, null); + }); + + it("tracks the newest turn timestamp", () => { + const totals = aggregateAgentTurnMetrics([ + makePayload({ timestamp: "2026-07-29T12:00:00Z" }), + makePayload({ timestamp: "2026-07-29T14:30:00Z" }), + makePayload({ timestamp: "2026-07-29T09:00:00Z" }), + ]); + assert.equal(totals.lastTurnAt, Date.parse("2026-07-29T14:30:00Z")); + }); +}); + +describe("displayTokenTotal", () => { + it("prefers the provider-reported total", () => { + assert.equal( + displayTokenTotal({ + ...EMPTY_USAGE_TOTALS, + totalTokens: 1200, + inputTokens: 100, + outputTokens: 20, + }), + 1200, + ); + }); + + it("falls back to input + output when no total was reported", () => { + assert.equal( + displayTokenTotal({ + ...EMPTY_USAGE_TOTALS, + inputTokens: 100, + outputTokens: 20, + }), + 120, + ); + assert.equal( + displayTokenTotal({ ...EMPTY_USAGE_TOTALS, inputTokens: 100 }), + 100, + ); + }); + + it("returns null when nothing was reported", () => { + assert.equal(displayTokenTotal(EMPTY_USAGE_TOTALS), null); + }); +}); diff --git a/desktop/src/features/fleet/turnMetrics.ts b/desktop/src/features/fleet/turnMetrics.ts new file mode 100644 index 0000000000..f1d716c287 --- /dev/null +++ b/desktop/src/features/fleet/turnMetrics.ts @@ -0,0 +1,209 @@ +/** + * Pure aggregation for NIP-AM agent turn metrics (kind 44200). + * + * One decrypted payload per completed agent turn (see + * `crates/buzz-core/src/agent_turn_metric.rs` and `docs/nips/NIP-AM.md`). + * This module folds a bag of payloads into per-agent usage totals for the + * fleet board. It is deliberately free of React/Tauri imports so the + * aggregation semantics are unit-testable in isolation. + * + * Aggregation semantics (documented so reviewers can check them once): + * + * - Payloads are grouped by `sessionId`. Sessionless payloads each form their + * own group (they cannot participate in cumulative reconciliation). + * - Within a session that reported `cumulative` counts, the cumulative from + * the highest `turnSeq` wins — it already includes every prior turn of that + * session, so summing per-turn deltas on top would double-count. + * - Sessions without any cumulative sum their per-turn counts. Deltas flagged + * `deltaReliable: false` are skipped (NIP-AM: the delta is untrustworthy + * after a harness restart mid-session). + * - Every token field is nullable on the wire — `null`/absent means "not + * reported", never zero. Totals stay `null` until at least one payload + * reports the field, so the UI can render "—" instead of a misleading 0. + */ + +export type TurnMetricTokenCounts = { + inputTokens?: number | null; + outputTokens?: number | null; + totalTokens?: number | null; + costUsd?: number | null; +}; + +/** Decrypted kind 44200 payload (camelCase wire shape, unknown fields ignored). */ +export type TurnMetricPayload = { + harness: string; + timestamp: string; + model?: string | null; + channelId?: string | null; + sessionId?: string | null; + turnId?: string | null; + turnSeq?: number | null; + turn?: TurnMetricTokenCounts | null; + cumulative?: TurnMetricTokenCounts | null; + deltaReliable?: boolean; + stopReason?: string | null; +}; + +export type AgentUsageTotals = { + /** Number of metric events folded in (≈ completed turns). */ + turnCount: number; + /** Sums of reported fields; `null` when no payload ever reported the field. */ + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + costUsd: number | null; + /** Desktop-clock ms of the newest turn timestamp, or null if unparseable. */ + lastTurnAt: number | null; +}; + +export const EMPTY_USAGE_TOTALS: AgentUsageTotals = { + turnCount: 0, + inputTokens: null, + outputTokens: null, + totalTokens: null, + costUsd: null, + lastTurnAt: null, +}; + +function isTokenCounts(value: unknown): value is TurnMetricTokenCounts { + if (typeof value !== "object" || value === null) return false; + const counts = value as Record; + return ["inputTokens", "outputTokens", "totalTokens", "costUsd"].every( + (field) => { + const v = counts[field]; + return v === undefined || v === null || typeof v === "number"; + }, + ); +} + +/** + * Runtime guard for decrypted payloads. Mirrors NIP-AM's requirements: + * `harness` and `timestamp` are required, everything else optional. Unknown + * fields are ignored (forward compatibility). Negative or non-finite costs + * are rejected the way `AgentTurnMetricPayload::validate` rejects them. + */ +export function isTurnMetricPayload( + value: unknown, +): value is TurnMetricPayload { + if (typeof value !== "object" || value === null) return false; + const payload = value as Record; + if (typeof payload.harness !== "string" || payload.harness.length === 0) { + return false; + } + if (typeof payload.timestamp !== "string") return false; + for (const field of ["turn", "cumulative"]) { + const counts = payload[field]; + if (counts === undefined || counts === null) continue; + if (!isTokenCounts(counts)) return false; + const cost = (counts as TurnMetricTokenCounts).costUsd; + if (typeof cost === "number" && (!Number.isFinite(cost) || cost < 0)) { + return false; + } + } + return true; +} + +type NullableSums = { + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + costUsd: number | null; +}; + +const NULL_SUMS: NullableSums = { + inputTokens: null, + outputTokens: null, + totalTokens: null, + costUsd: null, +}; + +function addCounts( + sums: NullableSums, + counts: TurnMetricTokenCounts | null | undefined, +): NullableSums { + if (!counts) return sums; + const add = (base: number | null, value: number | null | undefined) => { + if (typeof value !== "number" || !Number.isFinite(value)) return base; + return (base ?? 0) + value; + }; + return { + inputTokens: add(sums.inputTokens, counts.inputTokens), + outputTokens: add(sums.outputTokens, counts.outputTokens), + totalTokens: add(sums.totalTokens, counts.totalTokens), + costUsd: add(sums.costUsd, counts.costUsd), + }; +} + +type SessionAccumulator = { + turnSums: NullableSums; + cumulative: TurnMetricTokenCounts | null; + cumulativeSeq: number; +}; + +/** Fold a bag of decrypted payloads for ONE agent into usage totals. */ +export function aggregateAgentTurnMetrics( + payloads: readonly TurnMetricPayload[], +): AgentUsageTotals { + if (payloads.length === 0) return EMPTY_USAGE_TOTALS; + + const sessions = new Map(); + let sessionlessKey = 0; + let lastTurnAt: number | null = null; + + for (const payload of payloads) { + const at = Date.parse(payload.timestamp); + if (Number.isFinite(at) && (lastTurnAt === null || at > lastTurnAt)) { + lastTurnAt = at; + } + + // Sessionless payloads cannot be reconciled against a cumulative series; + // give each its own group so its per-turn counts sum independently. + const key = payload.sessionId ?? `sessionless:${sessionlessKey++}`; + let session = sessions.get(key); + if (!session) { + session = { turnSums: NULL_SUMS, cumulative: null, cumulativeSeq: -1 }; + sessions.set(key, session); + } + + if (payload.deltaReliable !== false) { + session.turnSums = addCounts(session.turnSums, payload.turn); + } + if (payload.cumulative) { + const seq = typeof payload.turnSeq === "number" ? payload.turnSeq : 0; + if (seq >= session.cumulativeSeq) { + session.cumulative = payload.cumulative; + session.cumulativeSeq = seq; + } + } + } + + let totals = NULL_SUMS; + for (const session of sessions.values()) { + // Cumulative-at-latest-turn already covers every turn of the session; + // per-turn sums are the fallback when the session never reported one. + totals = session.cumulative + ? addCounts(totals, session.cumulative) + : addCounts(totals, session.turnSums); + } + + return { + turnCount: payloads.length, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + totalTokens: totals.totalTokens, + costUsd: totals.costUsd, + lastTurnAt, + }; +} + +/** + * Token total to display for an agent: provider-reported totals win; when a + * provider never reported one, fall back to input + output when both exist. + */ +export function displayTokenTotal(totals: AgentUsageTotals): number | null { + if (totals.totalTokens !== null) return totals.totalTokens; + if (totals.inputTokens !== null || totals.outputTokens !== null) { + return (totals.inputTokens ?? 0) + (totals.outputTokens ?? 0); + } + return null; +} diff --git a/desktop/src/features/fleet/turnMetricsStore.ts b/desktop/src/features/fleet/turnMetricsStore.ts new file mode 100644 index 0000000000..5747515d57 --- /dev/null +++ b/desktop/src/features/fleet/turnMetricsStore.ts @@ -0,0 +1,318 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { decryptObserverEvent } from "@/shared/api/tauriObserver"; +import { + listSaveSubscriptions, + readArchivedEvents, +} from "@/shared/api/tauriArchive"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + aggregateAgentTurnMetrics, + EMPTY_USAGE_TOTALS, + isTurnMetricPayload, + type AgentUsageTotals, + type TurnMetricPayload, +} from "./turnMetrics"; + +/** + * Module store for per-agent turn-metric usage (kind 44200, NIP-AM). + * + * Sources, both existing desktop data paths — no new endpoints: + * - The local SQLite archive (`owner_p` save subscription including 44200, + * seeded by `useAgentMetricArchiveSeed` or toggled in Local Archive + * settings), read via `read_archived_events`. + * - The relay itself: kind 44200 is relay-persisted and `#p`-addressed to the + * owner (result-gated to the addressed recipient), fetched once on load and + * then followed live so the board updates as turns complete. + * + * Every raw event is decrypted through the same Tauri `decrypt_observer_event` + * command the observer store uses (44200 shares the NIP-44 agent→owner + * encryption scheme with kind 24200). Decrypt failures are silently dropped, + * mirroring `ingestArchivedObserverEvents`. + * + * Community switching: this is a module-level singleton, so + * `resetFleetTurnMetricsStore()` is wired into `resetCommunityState()` + * (see `features/communities/useCommunityInit.ts`). + */ + +/** Archive pages read on load (newest-first); bounds decrypt IPC on mount. */ +const ARCHIVE_PAGE_SIZE = 200; +const ARCHIVE_PAGE_BUDGET = 3; +/** One-shot relay backfill window for owners without a local archive. */ +const RELAY_FETCH_LIMIT = 500; + +// agentKey → eventId → decrypted payload. Dedup across archive/relay/live by +// event id, so the same metric never counts twice. +const payloadsByAgent = new Map>(); +const seenEventIds = new Set(); + +const listeners = new Set<() => void>(); + +// Reference-stable snapshots for useSyncExternalStore. +const totalsCache = new Map(); + +let loadPromise: Promise | null = null; +let loaded = false; +let unsubscribeLive: (() => Promise) | null = null; +let generation = 0; +// Monotonic change counter — a cheap useSyncExternalStore snapshot for +// consumers that read many agents' totals in one pass (the fleet summary). +let storeVersion = 0; + +function notifyListeners() { + storeVersion += 1; + for (const listener of listeners) { + listener(); + } +} + +export function subscribeFleetTurnMetrics(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function ingestPayload( + agentPubkey: string, + eventId: string, + payload: TurnMetricPayload, +): boolean { + if (seenEventIds.has(eventId)) return false; + seenEventIds.add(eventId); + const key = normalizePubkey(agentPubkey); + let agentPayloads = payloadsByAgent.get(key); + if (!agentPayloads) { + agentPayloads = new Map(); + payloadsByAgent.set(key, agentPayloads); + } + agentPayloads.set(eventId, payload); + totalsCache.delete(key); + return true; +} + +/** + * Decrypt and ingest one raw kind 44200 event. The event author is the agent + * (the `#p` tag is the owner), so totals key on `event.pubkey`. Non-44200 + * kinds and undecryptable/malformed payloads are dropped silently — the same + * fail-quiet contract as the archived-observer ingest path. + */ +async function ingestRawMetricEvent(event: RelayEvent): Promise { + if (event.kind !== KIND_AGENT_TURN_METRIC) return false; + if (seenEventIds.has(event.id)) return false; + try { + const decoded = await decryptObserverEvent(event); + if (!isTurnMetricPayload(decoded)) return false; + return ingestPayload(event.pubkey, event.id, decoded); + } catch { + return false; + } +} + +async function loadArchivedMetrics(ownerPubkey: string): Promise { + let subs: Awaited>; + try { + subs = await listSaveSubscriptions(); + } catch { + return false; + } + const hasMetricSub = subs.some( + (sub) => + sub.scopeType === "owner_p" && + sub.scopeValue === ownerPubkey && + sub.kinds.includes(KIND_AGENT_TURN_METRIC), + ); + if (!hasMetricSub) return false; + + let changed = false; + let before: { createdAt: number; id: string } | null = null; + for (let page = 0; page < ARCHIVE_PAGE_BUDGET; page += 1) { + let events: RelayEvent[]; + try { + events = await readArchivedEvents("owner_p", ownerPubkey, { + kinds: [KIND_AGENT_TURN_METRIC], + before, + limit: ARCHIVE_PAGE_SIZE, + }); + } catch { + break; + } + for (const event of events) { + if (await ingestRawMetricEvent(event)) changed = true; + } + if (events.length < ARCHIVE_PAGE_SIZE) break; + const oldest = events[events.length - 1]; + before = { createdAt: oldest.created_at, id: oldest.id }; + } + return changed; +} + +async function loadRelayMetrics(ownerPubkey: string): Promise { + let events: RelayEvent[]; + try { + events = await relayClient.fetchEvents({ + kinds: [KIND_AGENT_TURN_METRIC], + "#p": [ownerPubkey], + limit: RELAY_FETCH_LIMIT, + }); + } catch { + return false; + } + let changed = false; + for (const event of events) { + if (await ingestRawMetricEvent(event)) changed = true; + } + return changed; +} + +/** + * One-shot load of archived + relay-persisted turn metrics for the current + * identity, then a live follow subscription so new turn completions update + * totals without a refetch. Concurrent callers share one promise; a failed + * load clears it so the next mount retries. + */ +export function ensureFleetTurnMetricsLoaded(): Promise { + if (loaded) return Promise.resolve(); + if (loadPromise) return loadPromise; + + const activeGeneration = generation; + loadPromise = (async () => { + const identity = await getIdentity(); + if (activeGeneration !== generation) return; + const ownerPubkey = identity.pubkey; + + const [archiveChanged, relayChanged] = await Promise.all([ + loadArchivedMetrics(ownerPubkey), + loadRelayMetrics(ownerPubkey), + ]); + if (activeGeneration !== generation) return; + if (archiveChanged || relayChanged) { + notifyListeners(); + } + + // Live follow: limit 0 = no replay, new events only (backfill above + // already covered history; dedup by event id makes overlap harmless). + const unsubscribe = await relayClient.subscribeLive( + { + kinds: [KIND_AGENT_TURN_METRIC], + "#p": [ownerPubkey], + limit: 0, + }, + (event) => { + void ingestRawMetricEvent(event).then((changed) => { + if (changed && activeGeneration === generation) { + notifyListeners(); + } + }); + }, + ); + if (activeGeneration !== generation) { + await unsubscribe(); + return; + } + unsubscribeLive = unsubscribe; + loaded = true; + })() + .catch((error) => { + if (activeGeneration === generation) { + console.warn("[fleet] turn metric load failed:", error); + } + }) + .finally(() => { + if (activeGeneration === generation) { + loadPromise = null; + } + }); + + return loadPromise; +} + +const EMPTY_TOTALS = EMPTY_USAGE_TOTALS; + +/** Usage totals for one agent. Reference-stable until its payloads change. */ +export function getAgentUsageTotals( + agentPubkey: string | null | undefined, +): AgentUsageTotals { + if (!agentPubkey) return EMPTY_TOTALS; + const key = normalizePubkey(agentPubkey); + const cached = totalsCache.get(key); + if (cached) return cached; + const payloads = payloadsByAgent.get(key); + if (!payloads || payloads.size === 0) return EMPTY_TOTALS; + const totals = aggregateAgentTurnMetrics([...payloads.values()]); + totalsCache.set(key, totals); + return totals; +} + +/** Hook: usage totals for one agent; loads the store on first use. */ +export function useAgentUsageTotals( + agentPubkey: string | null | undefined, +): AgentUsageTotals { + const getSnapshot = React.useCallback( + () => getAgentUsageTotals(agentPubkey), + [agentPubkey], + ); + React.useEffect(() => { + void ensureFleetTurnMetricsLoaded(); + }, []); + return React.useSyncExternalStore(subscribeFleetTurnMetrics, getSnapshot); +} + +function getFleetMetricsVersion(): number { + return storeVersion; +} + +/** + * Hook: monotonic version that bumps whenever new turn metrics are ingested. + * Lets a component memo a multi-agent aggregation over `getAgentUsageTotals` + * without subscribing once per agent. Loads the store on first use. + */ +export function useFleetMetricsVersion(): number { + React.useEffect(() => { + void ensureFleetTurnMetricsLoaded(); + }, []); + return React.useSyncExternalStore( + subscribeFleetTurnMetrics, + getFleetMetricsVersion, + ); +} + +/** + * E2E-only: inject decoded turn-metric payloads directly, bypassing the + * archive/relay/decrypt pipeline — mirrors `injectObserverEventsForE2E`. + * Never call from production code. + */ +export function injectFleetTurnMetricsForE2E( + agentPubkey: string, + payloads: TurnMetricPayload[], +) { + let injected = 0; + for (const payload of payloads) { + if (!isTurnMetricPayload(payload)) continue; + injected += 1; + ingestPayload( + agentPubkey, + `e2e-${normalizePubkey(agentPubkey)}-${seenEventIds.size}-${injected}`, + payload, + ); + } + if (injected > 0) notifyListeners(); +} + +/** Community-switch reset (see resetCommunityState in useCommunityInit). */ +export function resetFleetTurnMetricsStore() { + generation += 1; + const unsubscribe = unsubscribeLive; + unsubscribeLive = null; + loadPromise = null; + loaded = false; + payloadsByAgent.clear(); + seenEventIds.clear(); + totalsCache.clear(); + notifyListeners(); + void unsubscribe?.(); +} diff --git a/desktop/src/features/fleet/ui/FleetAgentCard.tsx b/desktop/src/features/fleet/ui/FleetAgentCard.tsx new file mode 100644 index 0000000000..03772806bd --- /dev/null +++ b/desktop/src/features/fleet/ui/FleetAgentCard.tsx @@ -0,0 +1,211 @@ +import { AlertTriangle, Coins, Hash } from "lucide-react"; + +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; +import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; +import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { IdentityInitialsAvatar } from "@/features/agents/ui/IdentityInitialsAvatar"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { + useAgentTranscript, + useObserverEvents, +} from "@/features/agents/ui/useObserverEvents"; +import type { PresenceStatus } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; +import type { FleetAgent } from "../fleetAgents"; +import { + deriveActivityHeadline, + deriveFleetLiveStatus, + deriveLastSeenMs, + formatLastSeen, + formatTokenCount, + formatUsdCost, +} from "../fleetStatus"; +import { useAgentUsageTotals } from "../turnMetricsStore"; +import { displayTokenTotal } from "../turnMetrics"; + +const LAST_SEEN_TICK_MS = 30_000; +const ELAPSED_TICK_MS = 1_000; + +export function FleetAgentCard({ + agent, + channelIdToName, + onOpen, + presenceLoaded, + presenceStatus, +}: { + agent: FleetAgent; + channelIdToName: Record; + onOpen: (pubkey: string) => void; + presenceLoaded: boolean; + presenceStatus: PresenceStatus | undefined; +}) { + // Store reads only — the app-shell observer ingestion + // (`useAgentObserverIngestion`) already owns the relay subscription, so the + // `enabled` flag stays false here to avoid a redundant ensure. + const { events } = useObserverEvents(false, agent.pubkey); + const transcript = useAgentTranscript(false, agent.pubkey); + const workingState = useAgentWorking(agent.pubkey); + const usage = useAgentUsageTotals(agent.pubkey); + + const liveStatus = deriveFleetLiveStatus({ + working: workingState.working, + status: agent.status, + lastError: agent.lastError, + }); + const headline = deriveActivityHeadline(transcript); + const lastSeenMs = deriveLastSeenMs({ + events, + lastStartedAt: agent.lastStartedAt, + lastStoppedAt: agent.lastStoppedAt, + }); + const friendlyError = + liveStatus === "error" + ? (friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy ?? + null) + : null; + const tokenTotal = displayTokenTotal(usage); + + return ( + + ); +} + +function WorkingChannels({ + channelIdToName, + channels, +}: { + channelIdToName: Record; + channels: ReturnType["channels"]; +}) { + // A live elapsed counter is displayed, so this component (and only this + // component) mounts a ticking clock. + const now = useNow(ELAPSED_TICK_MS); + const visible = channels.slice(0, 2); + const overflow = channels.length - visible.length; + + return ( +
+ {visible.map((channel) => ( + + + #{channelIdToName[channel.channelId] ?? channel.channelId} + + + {formatElapsed(now - channel.anchorAt)} + + + ))} + {overflow > 0 ? ( + +{overflow} more + ) : null} +
+ ); +} + +function LastSeen({ lastSeenMs }: { lastSeenMs: number | null }) { + const now = useNow(LAST_SEEN_TICK_MS); + if (lastSeenMs === null) return null; + return ( + + {formatLastSeen(lastSeenMs, now)} + + ); +} diff --git a/desktop/src/features/fleet/ui/FleetScreen.tsx b/desktop/src/features/fleet/ui/FleetScreen.tsx new file mode 100644 index 0000000000..f25ca83e8d --- /dev/null +++ b/desktop/src/features/fleet/ui/FleetScreen.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const FleetView = React.lazy(async () => { + const module = await import("@/features/fleet/ui/FleetView"); + return { default: module.FleetView }; +}); + +export function FleetScreen() { + return ( +
+ }> + + +
+ ); +} diff --git a/desktop/src/features/fleet/ui/FleetView.tsx b/desktop/src/features/fleet/ui/FleetView.tsx new file mode 100644 index 0000000000..d439bbec30 --- /dev/null +++ b/desktop/src/features/fleet/ui/FleetView.tsx @@ -0,0 +1,233 @@ +import * as React from "react"; +import { Bot } from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useWorkingChannels } from "@/features/agents/agentWorkingSignal"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { usePresenceQuery } from "@/features/presence/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { PageHeader } from "@/shared/ui/PageHeader"; +import { Skeleton } from "@/shared/ui/skeleton"; +import type { FleetAgent } from "../fleetAgents"; +import { useFleetAgents } from "../useFleetAgents"; +import { + deriveFleetLiveStatus, + formatTokenCount, + formatUsdCost, + type FleetLiveStatus, +} from "../fleetStatus"; +import { + getAgentUsageTotals, + useFleetMetricsVersion, +} from "../turnMetricsStore"; +import { displayTokenTotal } from "../turnMetrics"; +import { FleetAgentCard } from "./FleetAgentCard"; + +export function FleetView() { + const { agents, error, isLoading } = useFleetAgents(); + const { openAgentActivity } = useOpenAgentActivity(); + const { goAgents } = useAppNavigation(); + + const agentPubkeys = React.useMemo( + () => agents.map((agent) => agent.pubkey), + [agents], + ); + const presenceQuery = usePresenceQuery(agentPubkeys); + const presenceLookup = presenceQuery.data ?? {}; + + const channelsQuery = useChannelsQuery(); + const channelIdToName = React.useMemo(() => { + const map: Record = {}; + for (const channel of channelsQuery.data ?? []) { + map[channel.id] = channel.name; + } + return map; + }, [channelsQuery.data]); + + const handleOpen = React.useCallback( + (pubkey: string) => { + openAgentActivity(pubkey); + }, + [openAgentActivity], + ); + + return ( +
+
+ + {isLoading ? ( + + ) : error ? ( +

+ Could not load agents: {error.message} +

+ ) : agents.length === 0 ? ( + void goAgents()} /> + ) : ( + <> + +
+ {agents.map((agent) => ( + + ))} +
+ + )} +
+
+ ); +} + +const STATUS_SUMMARY_ORDER: Array<{ + status: FleetLiveStatus; + label: string; + dotClassName: string; +}> = [ + { status: "working", label: "working", dotClassName: "bg-primary" }, + { status: "idle", label: "idle", dotClassName: "bg-muted-foreground/50" }, + { status: "error", label: "need attention", dotClassName: "bg-destructive" }, + { status: "offline", label: "offline", dotClassName: "bg-border" }, +]; + +function FleetSummary({ agents }: { agents: FleetAgent[] }) { + // One subscription for the whole strip: the working signal drives status + // counts, the metrics version bumps whenever new turn metrics arrive. + const workingChannels = useWorkingChannels(); + const metricsVersion = useFleetMetricsVersion(); + + const workingSet = React.useMemo(() => { + const set = new Set(); + for (const channel of workingChannels) { + for (const pubkey of channel.agentPubkeys) { + set.add(normalizePubkey(pubkey)); + } + } + return set; + }, [workingChannels]); + + const statusCounts = React.useMemo(() => { + const counts: Record = { + working: 0, + idle: 0, + error: 0, + offline: 0, + }; + for (const agent of agents) { + counts[ + deriveFleetLiveStatus({ + working: workingSet.has(normalizePubkey(agent.pubkey)), + status: agent.status, + lastError: agent.lastError, + }) + ] += 1; + } + return counts; + }, [agents, workingSet]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: metricsVersion is the store-change signal that invalidates getAgentUsageTotals reads + const usage = React.useMemo(() => { + let tokens: number | null = null; + let cost: number | null = null; + for (const agent of agents) { + const totals = getAgentUsageTotals(agent.pubkey); + const agentTokens = displayTokenTotal(totals); + if (agentTokens !== null) tokens = (tokens ?? 0) + agentTokens; + if (totals.costUsd !== null) cost = (cost ?? 0) + totals.costUsd; + } + return { tokens, cost }; + }, [agents, metricsVersion]); + + return ( +
+
+ {STATUS_SUMMARY_ORDER.map(({ status, label, dotClassName }) => + statusCounts[status] > 0 ? ( + + + {statusCounts[status]} {label} + + ) : null, + )} +
+ {usage.tokens !== null || usage.cost !== null ? ( + + {usage.tokens !== null + ? `${formatTokenCount(usage.tokens)} tokens` + : null} + {usage.tokens !== null && usage.cost !== null ? " · " : null} + {usage.cost !== null ? formatUsdCost(usage.cost) : null} + + ) : null} +
+ ); +} + +function FleetEmptyState({ onOpenAgents }: { onOpenAgents: () => void }) { + return ( +
+ +
+

No agents yet

+

+ Create an agent and it will show up here with live status and usage. +

+
+ +
+ ); +} + +function FleetLoadingSkeleton() { + return ( +
+ {[0, 1, 2].map((index) => ( +
+
+ +
+ + +
+
+ + +
+ ))} +
+ ); +} diff --git a/desktop/src/features/fleet/useFleetAgents.ts b/desktop/src/features/fleet/useFleetAgents.ts new file mode 100644 index 0000000000..cf66dd76fd --- /dev/null +++ b/desktop/src/features/fleet/useFleetAgents.ts @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { combineFleetAgents, type FleetAgent } from "./fleetAgents"; + +/** All of the owner's agents (managed + declared-owned relay agents). */ +export function useFleetAgents(): { + agents: FleetAgent[]; + isLoading: boolean; + error: Error | null; +} { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgents = relayAgentsQuery.data; + + const relayAgentPubkeys = React.useMemo( + () => (relayAgents ?? []).map((agent) => agent.pubkey), + [relayAgents], + ); + const profilesQuery = useUsersBatchQuery(relayAgentPubkeys, { + enabled: Boolean(currentPubkey) && relayAgentPubkeys.length > 0, + }); + const profiles = profilesQuery.data?.profiles; + + const managedAgents = managedAgentsQuery.data; + const agents = React.useMemo(() => { + const ownerByPubkey = new Map(); + const avatarByPubkey = new Map(); + for (const [pubkey, summary] of Object.entries(profiles ?? {})) { + const key = normalizePubkey(pubkey); + if (summary.ownerPubkey) { + ownerByPubkey.set(key, normalizePubkey(summary.ownerPubkey)); + } + avatarByPubkey.set(key, summary.avatarUrl ?? null); + } + return combineFleetAgents( + managedAgents ?? [], + relayAgents ?? [], + ownerByPubkey, + avatarByPubkey, + currentPubkey, + ); + }, [currentPubkey, managedAgents, profiles, relayAgents]); + + return { + agents, + isLoading: managedAgentsQuery.isLoading || relayAgentsQuery.isLoading, + error: + managedAgentsQuery.error instanceof Error + ? managedAgentsQuery.error + : null, + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 55f467f215..0c0a5a09df 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -103,6 +103,7 @@ type AppSidebarProps = { | "channel" | "messages" | "agents" + | "fleet" | "workflows" | "pulse" | "projects"; @@ -143,6 +144,7 @@ type AppSidebarProps = { onRemoveCommunity: (id: string) => void; onCreateAgent: () => void; onSelectAgents: () => void; + onSelectFleet: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -212,6 +214,7 @@ export function AppSidebar({ onRemoveCommunity, onCreateAgent, onSelectAgents, + onSelectFleet, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -609,6 +612,7 @@ export function AppSidebar({ void; + onSelectFleet: () => void; onSelectHome: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -83,6 +85,7 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, + onSelectFleet, onSelectHome, onSelectProjects, onSelectPulse, @@ -155,6 +158,20 @@ export function AppSidebarPrimaryMenu({ Agents + + + + + Fleet + + + ; }) => void; + /** Seed decoded NIP-AM turn-metric payloads (kind 44200) into the fleet + * usage store, bypassing the archive/relay/decrypt pipeline — mirrors + * `__BUZZ_E2E_SEED_OBSERVER_EVENTS__`. */ + __BUZZ_E2E_SEED_TURN_METRICS__?: (input: { + agentPubkey: string; + payloads: Array<{ + harness: string; + timestamp: string; + sessionId?: string | null; + turnSeq?: number | null; + turn?: { + inputTokens?: number | null; + outputTokens?: number | null; + totalTokens?: number | null; + costUsd?: number | null; + } | null; + cumulative?: { + inputTokens?: number | null; + outputTokens?: number | null; + totalTokens?: number | null; + costUsd?: number | null; + } | null; + }>; + }) => void; __BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: { clientId: string; contexts: Record; @@ -9396,6 +9421,9 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => { injectObserverEventsForE2E(agentPubkey, events); }; + window.__BUZZ_E2E_SEED_TURN_METRICS__ = ({ agentPubkey, payloads }) => { + injectFleetTurnMetricsForE2E(agentPubkey, payloads); + }; const meshNodeStatus = ( state: "off" | "running", mode: "serve" | "client" | null, diff --git a/desktop/tests/e2e/fleet-screenshots.spec.ts b/desktop/tests/e2e/fleet-screenshots.spec.ts new file mode 100644 index 0000000000..a7f569d77e --- /dev/null +++ b/desktop/tests/e2e/fleet-screenshots.spec.ts @@ -0,0 +1,306 @@ +/** + * Screenshot + behavior spec for the Agent Fleet view (/fleet). + * + * Seeds a mixed fleet — a working agent (live turn in #agents), an idle agent + * with a recent transcript headline, a crashed agent with a recorded error, + * and a stopped one — plus NIP-AM turn metrics, then captures the board and + * per-card close-ups. The default mock bridge also contributes `nadia`, the + * declared-owned relay agent fixture, exercising the cross-machine roster + * path (owned but not locally managed). + */ + +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/fleet-screenshots"; + +// #agents mock channel — the working chip resolves this id to its name. +const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; + +const WORKING_AGENT = { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Scout", + status: "running" as const, + channelNames: ["agents"], +}; + +const IDLE_AGENT = { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "Archivist", + status: "running" as const, + channelNames: ["agents"], +}; + +const ERROR_AGENT = { + pubkey: TEST_IDENTITIES.bob.pubkey, + name: "Databricks Agent", + status: "stopped" as const, + lastError: + "Agent reported error (code -32002): llm model not found: (goose-databricks-llama-3-3-70b) 404 Not Found: model not found", + lastErrorCode: -32002, +}; + +const OFFLINE_AGENT = { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "Weekend Bot", + status: "stopped" as const, +}; + +const MANAGED_AGENTS = [WORKING_AGENT, IDLE_AGENT, ERROR_AGENT, OFFLINE_AGENT]; + +// A declared-owned relay agent running on another machine: present in the +// relay roster, owned by the mock viewer via its profile's NIP-OA +// owner_pubkey, but NOT locally managed — the cross-machine fleet case. +const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); +const REMOTE_AGENT = { + pubkey: TEST_IDENTITIES.outsider.pubkey, + name: "Laptop Runner", +}; + +async function openFleetView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + // The sidebar entry is behind the "fleet" preview gate, which + // installMockBridge seeds as enabled by default. + const fleetButton = page.getByTestId("open-fleet-view"); + await expect(fleetButton).toBeVisible({ timeout: 10_000 }); + await fleetButton.click(); + await expect(page.getByTestId("fleet-view")).toBeVisible({ + timeout: 10_000, + }); +} + +async function waitForSeedHooks(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => + typeof window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function" && + typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function" && + typeof window.__BUZZ_E2E_SEED_TURN_METRICS__ === "function", + null, + { timeout: 10_000 }, + ); +} + +async function seedFleetActivity(page: import("@playwright/test").Page) { + await page.evaluate( + ({ workingPubkey, idlePubkey, channelId }) => { + // Live turn: Scout is working in #agents, anchored ~90s ago. + window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey: workingPubkey, + channelId, + turnId: "turn-fleet-1", + }); + + // Recent transcript for the idle agent → activity headline. + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey: idlePubkey, + events: [ + { + seq: 1, + timestamp: new Date(Date.now() - 5 * 60_000).toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "session-fleet-1", + // No turnId: a turn-scoped acp frame would register live-turn + // activity in activeAgentTurnsStore and flip this agent to + // "working" — this seed is history for the headline only. + turnId: null, + payload: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + messageId: "msg-fleet-1", + content: [ + { + type: "text", + text: "Indexed 42 documents and refreshed the search cache.", + }, + ], + }, + }, + }, + }, + ], + }); + + // NIP-AM turn metrics: per-turn deltas for Scout, a session-cumulative + // series for Archivist (the aggregator must not double-count it). + window.__BUZZ_E2E_SEED_TURN_METRICS__?.({ + agentPubkey: workingPubkey, + payloads: [ + { + harness: "goose", + timestamp: new Date(Date.now() - 40 * 60_000).toISOString(), + sessionId: "sess-a", + turnSeq: 1, + turn: { inputTokens: 48_200, outputTokens: 3_400, costUsd: 0.31 }, + }, + { + harness: "goose", + timestamp: new Date(Date.now() - 12 * 60_000).toISOString(), + sessionId: "sess-a", + turnSeq: 2, + turn: { inputTokens: 61_800, outputTokens: 5_100, costUsd: 0.42 }, + }, + ], + }); + window.__BUZZ_E2E_SEED_TURN_METRICS__?.({ + agentPubkey: idlePubkey, + payloads: [ + { + harness: "goose", + timestamp: new Date(Date.now() - 2 * 3_600_000).toISOString(), + sessionId: "sess-b", + turnSeq: 1, + cumulative: { totalTokens: 220_000, costUsd: 0.9 }, + }, + { + harness: "goose", + timestamp: new Date(Date.now() - 5 * 60_000).toISOString(), + sessionId: "sess-b", + turnSeq: 2, + cumulative: { totalTokens: 402_000, costUsd: 1.65 }, + }, + ], + }); + }, + { + workingPubkey: WORKING_AGENT.pubkey, + idlePubkey: IDLE_AGENT.pubkey, + channelId: AGENTS_CHANNEL_ID, + }, + ); +} + +test.describe("fleet view screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + }); + + test("fleet board with mixed statuses and usage", async ({ page }) => { + await installMockBridge(page, { + managedAgents: MANAGED_AGENTS, + relayAgents: [ + { + pubkey: REMOTE_AGENT.pubkey, + name: REMOTE_AGENT.name, + status: "online", + channelNames: ["agents"], + }, + ], + searchProfiles: [ + { + pubkey: REMOTE_AGENT.pubkey, + displayName: REMOTE_AGENT.name, + ownerPubkey: MOCK_VIEWER_PUBKEY, + isAgent: true, + }, + ], + }); + // Open the view first, then seed — the board updates reactively, and a + // later goto would reload the page and wipe the seeded module stores. + await openFleetView(page); + await waitForSeedHooks(page); + await seedFleetActivity(page); + + // Summary strip: 1 working, 1 error; idle covers Archivist plus the + // default owned-relay fixture (nadia). + const summary = page.getByTestId("fleet-summary"); + await expect(summary).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("fleet-summary-working")).toHaveText( + /1 working/, + ); + await expect(page.getByTestId("fleet-summary-error")).toHaveText( + /1 need attention/, + ); + + // Working card: channel chip with a live elapsed counter. + const workingCard = page.getByTestId( + `fleet-agent-card-${WORKING_AGENT.pubkey}`, + ); + await expect(workingCard).toHaveAttribute("data-status", "working"); + await expect( + workingCard.getByTestId("fleet-card-working-channel"), + ).toContainText("#agents"); + + // Idle card: headline from the seeded transcript, cumulative usage. + const idleCard = page.getByTestId(`fleet-agent-card-${IDLE_AGENT.pubkey}`); + await expect(idleCard.getByTestId("fleet-card-headline")).toContainText( + "Indexed 42 documents", + ); + await expect(idleCard).toContainText("402k"); + await expect(idleCard).toContainText("$1.65"); + + // Error card: friendly structured copy, not the raw JSON error string. + const errorCard = page.getByTestId( + `fleet-agent-card-${ERROR_AGENT.pubkey}`, + ); + await expect(errorCard).toHaveAttribute("data-status", "error"); + await expect(errorCard.getByTestId("fleet-card-headline")).toContainText( + "model is not available", + ); + + // Remote owned agent: on the board via profile ownership, labeled as + // running elsewhere. + const remoteCard = page.getByTestId( + `fleet-agent-card-${REMOTE_AGENT.pubkey}`, + ); + await expect(remoteCard).toContainText("Laptop Runner"); + await expect(remoteCard).toContainText("Runs on another device"); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/01-fleet-board.png` }); + await workingCard.screenshot({ + path: `${SHOTS}/02-fleet-card-working.png`, + }); + await errorCard.screenshot({ path: `${SHOTS}/03-fleet-card-error.png` }); + }); + + test("clicking a card routes to the agent's session panel", async ({ + page, + }) => { + await installMockBridge(page, { + managedAgents: MANAGED_AGENTS, + relayAgents: [ + { + pubkey: REMOTE_AGENT.pubkey, + name: REMOTE_AGENT.name, + status: "online", + channelNames: ["agents"], + }, + ], + searchProfiles: [ + { + pubkey: REMOTE_AGENT.pubkey, + displayName: REMOTE_AGENT.name, + ownerPubkey: MOCK_VIEWER_PUBKEY, + isAgent: true, + }, + ], + }); + await openFleetView(page); + await waitForSeedHooks(page); + await seedFleetActivity(page); + + // The working agent card deep-links into its working channel with the + // existing agent session pane open (useOpenAgentActivity ingress). + await page.getByTestId(`fleet-agent-card-${WORKING_AGENT.pubkey}`).click(); + await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible({ + timeout: 10_000, + }); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/04-fleet-open-session.png` }); + }); +}); diff --git a/preview-features.json b/preview-features.json index 388f1c39b0..6f7b8e00cc 100644 --- a/preview-features.json +++ b/preview-features.json @@ -25,6 +25,12 @@ "description": "Forum-style threaded channels for long-form discussions", "platforms": ["desktop"] }, + { + "id": "fleet", + "name": "Agent Fleet", + "description": "Live status board for all of your agents: current activity, last seen, and turn-metric usage", + "platforms": ["desktop"] + }, { "id": "agentManagedProfiles", "name": "Agent-managed profiles",