feat(agents): add active turn indicators to Agents Menu (#1005)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@squareup.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-12 13:54:07 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw
parent 39d9aa8260
commit 7983bf6751
14 changed files with 1133 additions and 16 deletions
+10
View File
@@ -253,6 +253,16 @@ impl AcpClient {
self.observer_context = context;
}
/// Return a clone of the observer handle, if attached.
pub(crate) fn observer_handle(&self) -> Option<ObserverHandle> {
self.observer.clone()
}
/// Return the pool slot index for this agent process.
pub(crate) fn observer_agent_index(&self) -> Option<usize> {
self.observer_agent_index
}
/// Emit a semantic event to the local observer feed, if enabled.
pub fn observe(&self, kind: impl Into<String>, payload: serde_json::Value) {
if let Some(observer) = &self.observer {
+53
View File
@@ -677,6 +677,16 @@ pub async fn run_prompt_task(
.unwrap_or_default();
let _reaction_guard = ReactionGuard::new(ctx.rest_client.clone(), reaction_ids.clone());
// ── Turn completion guard ─────────────────────────────────────────────
// Emits `turn_completed` on any exit path. Captures observer handle and
// metadata now, before the agent is moved into PromptResult.
let _turn_guard = TurnCompletionGuard::new(
agent.acp.observer_handle(),
agent.acp.observer_agent_index(),
observer_channel_id,
turn_id.clone(),
);
let (session_id, is_new_session) = match &source {
PromptSource::Channel(cid) => {
if let Some(sid) = agent.state.sessions.get(cid) {
@@ -1965,6 +1975,49 @@ impl Drop for ReactionGuard {
}
}
// ── Turn completion scope guard ──────────────────────────────────────────────
// Emits a `turn_completed` observer event on drop, covering ALL exit paths
// (success, error, timeout, cancel, panic) from `run_prompt_task`. Captures
// observer handle and metadata at creation time so it remains valid even after
// the agent is moved into `PromptResult`.
struct TurnCompletionGuard {
observer: Option<observer::ObserverHandle>,
agent_index: Option<usize>,
channel_id: Option<uuid::Uuid>,
turn_id: String,
}
impl TurnCompletionGuard {
fn new(
observer: Option<observer::ObserverHandle>,
agent_index: Option<usize>,
channel_id: Option<uuid::Uuid>,
turn_id: String,
) -> Self {
Self {
observer,
agent_index,
channel_id,
turn_id,
}
}
}
impl Drop for TurnCompletionGuard {
fn drop(&mut self) {
if let Some(observer) = self.observer.take() {
let context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone()));
observer.emit(
"turn_completed",
self.agent_index,
&context,
serde_json::json!({}),
);
}
}
}
const REACTION_SEEN: &str = "👀";
const REACTION_WORKING: &str = "💬";
+1
View File
@@ -31,6 +31,7 @@ export default defineConfig({
"**/channel-star-screenshots.spec.ts",
"**/channel-controls-screenshots.spec.ts",
"**/team-management-screenshots.spec.ts",
"**/active-turn-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/video-attachment.spec.ts",
"**/mentions.spec.ts",
@@ -0,0 +1,447 @@
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import {
syncAgentTurnsFromEvents,
getActiveChannelsForAgent,
resetActiveAgentTurnsStore,
subscribeActiveAgentTurns,
} from "./activeAgentTurnsStore.ts";
const AGENT =
"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
function makeEvent(overrides) {
return {
seq: 1,
timestamp: "2024-01-01T00:00:00Z",
kind: "turn_started",
agentIndex: 0,
channelId: "chan-1",
sessionId: "sess-1",
turnId: "turn-1",
payload: null,
...overrides,
};
}
describe("activeAgentTurnsStore", () => {
beforeEach(() => {
resetActiveAgentTurnsStore();
});
describe("seq filtering", () => {
it("processes events with increasing seq", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
const channels = getActiveChannelsForAgent(AGENT);
assert.equal(channels.size, 1);
assert.ok(channels.has("c1"));
});
it("skips events at or below the watermark", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 5, turnId: "t1", channelId: "c1" }),
]);
// Try to process an older event — should be ignored
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 3, turnId: "t2", channelId: "c2" }),
]);
const channels = getActiveChannelsForAgent(AGENT);
assert.equal(channels.size, 1);
assert.ok(channels.has("c1"));
assert.ok(!channels.has("c2"));
});
it("skips duplicate seq", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t2", channelId: "c2" }),
]);
const channels = getActiveChannelsForAgent(AGENT);
assert.equal(channels.size, 1);
assert.ok(channels.has("c1"));
});
});
describe("seq restart detection", () => {
it("processes post-restart events whose timestamp climbs past the watermark", () => {
// Process events up to seq 50.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 50,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
]);
assert.equal(getActiveChannelsForAgent(AGENT).size, 1);
// Agent restarts — seq resets to 1, but wall-clock timestamp keeps
// climbing. The composite watermark accepts it on timestamp alone.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "t2",
channelId: "c2",
timestamp: "2024-01-01T00:01:00Z",
}),
]);
const channels = getActiveChannelsForAgent(AGENT);
assert.ok(channels.has("c2"), "post-restart event should be processed");
});
it("processes subsequent events after restart", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 100,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
]);
// Restart: seq goes 1, 2, 3 with climbing timestamps.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "t2",
channelId: "c2",
timestamp: "2024-01-01T00:01:00Z",
}),
makeEvent({
seq: 2,
turnId: "t3",
channelId: "c3",
timestamp: "2024-01-01T00:01:01Z",
}),
makeEvent({
seq: 3,
kind: "turn_completed",
turnId: "t2",
channelId: "c2",
timestamp: "2024-01-01T00:01:02Z",
}),
]);
const channels = getActiveChannelsForAgent(AGENT);
// t1 still active (not ended), t2 ended, t3 still active.
assert.ok(channels.has("c1"));
assert.ok(!channels.has("c2"));
assert.ok(channels.has("c3"));
});
});
describe("eviction at MAX_TURNS_PER_AGENT", () => {
it("evicts oldest turn when exceeding 4 concurrent turns", () => {
const events = [];
for (let i = 1; i <= 5; i++) {
events.push(
makeEvent({
seq: i,
turnId: `t${i}`,
channelId: `c${i}`,
timestamp: `2024-01-01T00:0${i}:00Z`,
}),
);
}
syncAgentTurnsFromEvents(AGENT, events);
const channels = getActiveChannelsForAgent(AGENT);
// Should have evicted c1 (oldest) to make room for c5
assert.equal(channels.size, 4);
assert.ok(!channels.has("c1"), "oldest turn should be evicted");
assert.ok(channels.has("c2"));
assert.ok(channels.has("c5"));
});
});
describe("endTurn turnId-vs-channelId fallback", () => {
it("ends turn by turnId when provided", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({
seq: 2,
kind: "turn_completed",
turnId: "t1",
channelId: null,
}),
]);
assert.equal(getActiveChannelsForAgent(AGENT).size, 0);
});
it("falls back to channelId when turnId is null", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({
seq: 2,
kind: "turn_completed",
turnId: null,
channelId: "c1",
}),
]);
assert.equal(getActiveChannelsForAgent(AGENT).size, 0);
});
it("does nothing when both turnId and channelId are null", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({
seq: 2,
kind: "turn_completed",
turnId: null,
channelId: null,
}),
]);
// Turn should still be active — no way to identify which to end
assert.equal(getActiveChannelsForAgent(AGENT).size, 1);
});
it("channelId fallback removes only one matching turn", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
makeEvent({ seq: 2, turnId: "t2", channelId: "c1" }),
makeEvent({
seq: 3,
kind: "turn_completed",
turnId: null,
channelId: "c1",
}),
]);
// Only one of the two turns in c1 should be removed
const channels = getActiveChannelsForAgent(AGENT);
assert.equal(channels.size, 1);
assert.ok(channels.has("c1"));
});
});
describe("listener notifications", () => {
it("notifies on turn_started", () => {
let called = 0;
const unsub = subscribeActiveAgentTurns(() => {
called++;
});
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
assert.ok(called > 0);
unsub();
});
it("notifies on turn_completed", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
let called = 0;
const unsub = subscribeActiveAgentTurns(() => {
called++;
});
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 2, kind: "turn_completed", turnId: "t1" }),
]);
assert.ok(called > 0);
unsub();
});
});
describe("replay idempotency", () => {
it("replaying the same buffer produces no additional state change or notifications", () => {
const buffer = [
makeEvent({
seq: 1,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
turnId: "t2",
channelId: "c2",
timestamp: "2024-01-01T00:00:01Z",
}),
];
// Initial pass.
syncAgentTurnsFromEvents(AGENT, buffer);
const afterFirst = getActiveChannelsForAgent(AGENT);
assert.equal(afterFirst.size, 2);
// Subscribe, then replay the identical buffer.
let notified = 0;
const unsub = subscribeActiveAgentTurns(() => {
notified++;
});
syncAgentTurnsFromEvents(AGENT, buffer);
unsub();
assert.equal(notified, 0, "replay must not notify listeners");
const afterReplay = getActiveChannelsForAgent(AGENT);
assert.equal(
afterReplay,
afterFirst,
"replay must not change turn state (stable reference)",
);
});
it("post-restart replay does not reprocess seen events or resurrect evicted turns", () => {
// Start a turn, then complete it (turn evicted).
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
kind: "turn_completed",
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:01Z",
}),
]);
assert.equal(getActiveChannelsForAgent(AGENT).size, 0);
// Agent restarts. The harness replays its buffer with seq reset to 1,
// but the original event timestamps (older than the watermark) are
// unchanged. The start event must NOT resurrect the evicted turn.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
kind: "turn_completed",
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:01Z",
}),
]);
assert.equal(
getActiveChannelsForAgent(AGENT).size,
0,
"stale replayed start must not resurrect an evicted turn",
);
});
});
describe("replayed eviction safety", () => {
it("replayed stale turn_error with null turnId does not kill the live turn", () => {
// A turn errors out (harness emits turn_error with a null turnId), then a
// fresh turn starts in the same channel.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
kind: "turn_error",
turnId: null,
channelId: "c1",
timestamp: "2024-01-01T00:00:01Z",
}),
makeEvent({
seq: 3,
turnId: "t2",
channelId: "c1",
timestamp: "2024-01-01T00:00:02Z",
}),
]);
assert.equal(getActiveChannelsForAgent(AGENT).size, 1);
// The full buffer is replayed on the next observer event. The stale
// turn_error (below the watermark) must NOT re-run its channel-match
// fallback and delete the live turn t2.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
kind: "turn_error",
turnId: null,
channelId: "c1",
timestamp: "2024-01-01T00:00:01Z",
}),
makeEvent({
seq: 3,
turnId: "t2",
channelId: "c1",
timestamp: "2024-01-01T00:00:02Z",
}),
]);
const channels = getActiveChannelsForAgent(AGENT);
assert.equal(
channels.size,
1,
"replayed stale turn_error must not delete the live turn",
);
assert.ok(channels.has("c1"));
});
it("replaying evictions fires no spurious listener notifications", () => {
const buffer = [
makeEvent({
seq: 1,
turnId: "t1",
channelId: "c1",
timestamp: "2024-01-01T00:00:00Z",
}),
makeEvent({
seq: 2,
kind: "turn_error",
turnId: null,
channelId: "c1",
timestamp: "2024-01-01T00:00:01Z",
}),
makeEvent({
seq: 3,
kind: "agent_panic",
turnId: null,
channelId: "c2",
timestamp: "2024-01-01T00:00:02Z",
}),
];
// Initial pass processes the buffer.
syncAgentTurnsFromEvents(AGENT, buffer);
// Subscribe, then replay the identical buffer. Every event is below the
// watermark, so the replay must be a complete no-op.
let notified = 0;
const unsub = subscribeActiveAgentTurns(() => {
notified++;
});
syncAgentTurnsFromEvents(AGENT, buffer);
unsub();
assert.equal(notified, 0, "replayed evictions must not notify listeners");
});
});
describe("getActiveChannelsForAgent", () => {
it("returns EMPTY_SET for null/undefined pubkey", () => {
assert.equal(getActiveChannelsForAgent(null).size, 0);
assert.equal(getActiveChannelsForAgent(undefined).size, 0);
});
it("returns stable reference when unchanged", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
const ref1 = getActiveChannelsForAgent(AGENT);
const ref2 = getActiveChannelsForAgent(AGENT);
assert.equal(ref1, ref2, "should return cached reference");
});
});
});
@@ -0,0 +1,291 @@
import * as React from "react";
import {
subscribeAgentObserverStore,
getAgentObserverSnapshot,
compareObserverEvents,
} from "@/features/agents/observerRelayStore";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { ObserverEvent } from "./ui/agentSessionTypes";
/** Remove a turn entirely after 90s of no activity. */
const REMOVE_AFTER_MS = 90_000;
/** Maximum concurrent active turns tracked per agent (matches pool size). */
const MAX_TURNS_PER_AGENT = 4;
/** Interval for pruning stale/expired turns. */
const PRUNE_INTERVAL_MS = 5_000;
type ActiveTurn = {
turnId: string;
channelId: string;
startedAt: number;
lastActivityAt: number;
};
// Module-level state: agentPubkey → turnId → ActiveTurn
const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>();
const listeners = new Set<() => void>();
// Cached snapshots for useSyncExternalStore reference stability.
// Only regenerated when the underlying turn map for an agent actually changes.
const cachedChannelSets = new Map<string, Set<string>>();
// Composite watermark per agent: the newest observer event processed, by
// (timestamp, seq) ordering. An event is processed only if it is strictly
// newer than this — making full-buffer replays idempotent and post-restart
// streams (seq resets to 1, timestamp keeps climbing) handled for free.
const lastProcessed = new Map<string, ObserverEvent>();
let pruneInterval: ReturnType<typeof setInterval> | null = null;
function invalidateCache(agentKey: string) {
cachedChannelSets.delete(agentKey);
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function startTurn(
agentPubkey: string,
channelId: string,
turnId: string,
timestamp: string,
) {
const key = normalizePubkey(agentPubkey);
let agentTurns = activeTurnsByAgent.get(key);
if (!agentTurns) {
agentTurns = new Map();
activeTurnsByAgent.set(key, agentTurns);
}
// Cap at MAX_TURNS_PER_AGENT — evict oldest if exceeded
if (agentTurns.size >= MAX_TURNS_PER_AGENT && !agentTurns.has(turnId)) {
let oldestKey: string | null = null;
let oldestTime = Number.POSITIVE_INFINITY;
for (const [tid, turn] of agentTurns) {
if (turn.startedAt < oldestTime) {
oldestTime = turn.startedAt;
oldestKey = tid;
}
}
if (oldestKey) {
agentTurns.delete(oldestKey);
}
}
const now = Date.parse(timestamp) || Date.now();
agentTurns.set(turnId, {
turnId,
channelId,
startedAt: now,
lastActivityAt: now,
});
invalidateCache(key);
}
function recordActivity(agentPubkey: string, turnId: string | null) {
if (!turnId) return;
const key = normalizePubkey(agentPubkey);
const agentTurns = activeTurnsByAgent.get(key);
if (!agentTurns) return;
const turn = agentTurns.get(turnId);
if (turn) {
turn.lastActivityAt = Date.now();
}
}
function endTurn(
agentPubkey: string,
turnId: string | null,
channelId: string | null,
) {
const key = normalizePubkey(agentPubkey);
const agentTurns = activeTurnsByAgent.get(key);
if (!agentTurns) return;
if (turnId) {
agentTurns.delete(turnId);
} else if (channelId) {
// Fallback: remove by channelId if turnId not available
for (const [tid, turn] of agentTurns) {
if (turn.channelId === channelId) {
agentTurns.delete(tid);
break;
}
}
}
if (agentTurns.size === 0) {
activeTurnsByAgent.delete(key);
}
invalidateCache(key);
}
function pruneExpired() {
const now = Date.now();
let changed = false;
for (const [agentKey, agentTurns] of activeTurnsByAgent) {
for (const [turnId, turn] of agentTurns) {
if (now - turn.lastActivityAt > REMOVE_AFTER_MS) {
agentTurns.delete(turnId);
invalidateCache(agentKey);
changed = true;
}
}
if (agentTurns.size === 0) {
activeTurnsByAgent.delete(agentKey);
}
}
if (changed) {
notifyListeners();
}
}
// INVARIANT: events must be sorted by (timestamp, seq) ascending.
// syncAgentTurnsFromEvents receives sorted arrays from observerRelayStore.
// Calling with unsorted events will cause silent data loss.
function processEvent(agentPubkey: string, event: ObserverEvent) {
const key = normalizePubkey(agentPubkey);
// Gate every event kind on the watermark uniformly: process only events
// strictly newer than the last one seen for this agent. With sorted buffers
// (the documented invariant), this makes full-buffer replays a complete
// no-op. Evictions must be gated too — replaying a stale turn_error/
// agent_panic (emitted with a null turnId) would otherwise fall back to
// deleting the first turn in the channel, killing the live turn. Resurrection
// is not a concern: it would require reprocessing a stale start, which the
// watermark already blocks.
const last = lastProcessed.get(key);
if (last && compareObserverEvents(event, last) <= 0) {
return;
}
lastProcessed.set(key, event);
switch (event.kind) {
case "turn_started":
if (event.channelId) {
startTurn(
agentPubkey,
event.channelId,
event.turnId ?? `seq-${event.seq}`,
event.timestamp,
);
notifyListeners();
}
break;
case "turn_completed":
case "turn_error":
case "agent_panic":
endTurn(agentPubkey, event.turnId ?? null, event.channelId ?? null);
notifyListeners();
break;
case "acp_read":
case "acp_write":
recordActivity(agentPubkey, event.turnId ?? null);
break;
}
}
function ensurePruneInterval() {
if (pruneInterval) return;
pruneInterval = setInterval(pruneExpired, PRUNE_INTERVAL_MS);
}
function stopPruneInterval() {
if (pruneInterval) {
clearInterval(pruneInterval);
pruneInterval = null;
}
}
// ─── Public API ──────────────────────────────────────────────────────────────
export function subscribeActiveAgentTurns(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) {
ensurePruneInterval();
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
stopPruneInterval();
}
};
}
/** Returns the set of channel IDs where the given agent has active turns. */
export function getActiveChannelsForAgent(
agentPubkey: string | null | undefined,
): Set<string> {
if (!agentPubkey) return EMPTY_SET;
const key = normalizePubkey(agentPubkey);
const agentTurns = activeTurnsByAgent.get(key);
if (!agentTurns || agentTurns.size === 0) return EMPTY_SET;
const cached = cachedChannelSets.get(key);
if (cached) return cached;
const result = new Set([...agentTurns.values()].map((t) => t.channelId));
cachedChannelSets.set(key, result);
return result;
}
const EMPTY_SET: Set<string> = new Set();
/**
* Synchronize the active-turns store with the latest observer events for a
* given agent.
*/
export function syncAgentTurnsFromEvents(
agentPubkey: string,
events: ObserverEvent[],
) {
for (const event of events) {
processEvent(agentPubkey, event);
}
}
/**
* Hook: returns the set of channel IDs where the given agent is currently working.
* Re-renders when the set changes.
*/
export function useActiveAgentTurns(
agentPubkey: string | null | undefined,
): Set<string> {
const getSnapshot = React.useCallback(
() => getActiveChannelsForAgent(agentPubkey),
[agentPubkey],
);
return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot);
}
/**
* Bridge hook: processes observer events into the active-turns store.
* Should be called by a parent component that has access to the observer events.
*/
export function useActiveAgentTurnsBridge(
agents: readonly { pubkey: string; status: string }[],
) {
React.useEffect(() => {
function syncAll() {
for (const agent of agents) {
if (agent.status !== "running" && agent.status !== "deployed") continue;
const snapshot = getAgentObserverSnapshot(agent.pubkey, true);
syncAgentTurnsFromEvents(agent.pubkey, snapshot.events);
}
}
syncAll();
return subscribeAgentObserverStore(syncAll);
}, [agents]);
}
export function resetActiveAgentTurnsStore() {
activeTurnsByAgent.clear();
lastProcessed.clear();
cachedChannelSets.clear();
notifyListeners();
}
@@ -115,7 +115,10 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) {
notifyListeners();
}
function compareObserverEvents(left: ObserverEvent, right: ObserverEvent) {
export function compareObserverEvents(
left: ObserverEvent,
right: ObserverEvent,
) {
const leftTime = Date.parse(left.timestamp);
const rightTime = Date.parse(right.timestamp);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) {
@@ -4,7 +4,8 @@ import { ManagedAgentRow } from "./ManagedAgentRow";
export type AgentGroupRowsProps = {
agents: ManagedAgent[];
channelsByPubkey: Record<string, string[]>;
channelIdToName: Record<string, string>;
channelsByPubkey: Record<string, { id: string; name: string }[]>;
isActionPending: boolean;
logContent: string | null;
logError: Error | null;
@@ -23,6 +24,7 @@ export type AgentGroupRowsProps = {
export function AgentGroupRows({
agents,
channelIdToName,
channelsByPubkey,
isActionPending,
logContent,
@@ -44,6 +46,7 @@ export function AgentGroupRows({
{agents.map((agent) => (
<ManagedAgentRow
agent={agent}
channelIdToName={channelIdToName}
channelNames={channelsByPubkey[normalizePubkey(agent.pubkey)] ?? []}
isActionPending={isActionPending}
isLogSelected={selectedLogAgentPubkey === agent.pubkey}
@@ -7,10 +7,12 @@ import type { ManagedAgent, PresenceStatus } from "@/shared/api/types";
const PRESENCE_GRACE_MS = 15_000;
export function AgentStatusBadge({
isWorking,
presenceLoaded,
presenceStatus,
status,
}: {
isWorking?: boolean;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
status: ManagedAgent["status"];
@@ -29,11 +31,26 @@ export function AgentStatusBadge({
status === "running" &&
(!presenceStatus || presenceStatus === "offline");
const variant = isStarting ? "warning" : isActive ? "default" : "secondary";
const variant: "default" | "warning" | "secondary" = isWorking
? "default"
: isStarting
? "warning"
: isActive
? "default"
: "secondary";
const label = isWorking
? "Working"
: isStarting
? "Starting\u2026"
: status.replace(/_/g, " ");
return (
<Badge variant={variant}>
{isStarting ? "Starting\u2026" : status.replace(/_/g, " ")}
<Badge
className={isWorking ? "motion-safe:animate-pulse" : undefined}
variant={variant}
>
{label}
</Badge>
);
}
@@ -49,6 +49,7 @@ export function AgentsView() {
actionErrorMessage={agents.actionErrorMessage}
actionNoticeMessage={agents.actionNoticeMessage}
agents={agents.managedAgents}
channelIdToName={agents.channelIdToName}
channelsByPubkey={agents.channelsByPubkey}
agentsError={
agents.managedAgentsQuery.error instanceof Error
@@ -15,9 +15,11 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { Badge } from "@/shared/ui/badge";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import type {
ManagedAgent,
PresenceLookup,
@@ -39,6 +41,7 @@ import { truncatePubkey } from "./agentUi";
export function ManagedAgentRow({
agent,
channelIdToName,
channelNames,
isActionPending,
isLogSelected,
@@ -56,7 +59,8 @@ export function ManagedAgentRow({
onToggleStartOnAppLaunch,
}: {
agent: ManagedAgent;
channelNames: string[];
channelIdToName: Record<string, string>;
channelNames: { id: string; name: string }[];
isActionPending: boolean;
isLogSelected: boolean;
logContent: string | null;
@@ -80,6 +84,15 @@ export function ManagedAgentRow({
? (personaLabelsById[agent.personaId] ?? null)
: null;
const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()];
const activeChannelIds = useActiveAgentTurns(agent.pubkey);
const activeWorkingChannels = React.useMemo(
() =>
[...activeChannelIds]
.map((id) => ({ id, name: channelIdToName[id] ?? id }))
.slice(0, 3),
[activeChannelIds, channelIdToName],
);
const isWorking = activeWorkingChannels.length > 0;
const processDetail =
agent.pid !== null
? `PID ${agent.pid}`
@@ -116,6 +129,7 @@ export function ManagedAgentRow({
>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
<AgentSummary
activeWorkingChannels={activeWorkingChannels}
agent={agent}
channelNames={channelNames}
isExpandable
@@ -125,6 +139,7 @@ export function ManagedAgentRow({
/>
<StatusBlock
friendlyError={friendlyError}
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
@@ -137,6 +152,7 @@ export function ManagedAgentRow({
<div className="min-w-0 flex-1">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.8fr)_minmax(120px,0.8fr)_minmax(0,1.1fr)] lg:gap-4">
<AgentSummary
activeWorkingChannels={activeWorkingChannels}
agent={agent}
channelNames={channelNames}
isExpandable={false}
@@ -146,6 +162,7 @@ export function ManagedAgentRow({
/>
<StatusBlock
friendlyError={friendlyError}
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
@@ -191,6 +208,7 @@ export function ManagedAgentRow({
}
function AgentSummary({
activeWorkingChannels,
agent,
channelNames,
isExpandable,
@@ -198,13 +216,16 @@ function AgentSummary({
personaLabel,
presenceStatus,
}: {
activeWorkingChannels: { id: string; name: string }[];
agent: ManagedAgent;
channelNames: string[];
channelNames: { id: string; name: string }[];
isExpandable: boolean;
isLogSelected: boolean;
personaLabel: string | null;
presenceStatus: PresenceStatus | undefined;
}) {
const { goChannel } = useAppNavigation();
return (
<div className="min-w-0">
<div className="flex items-start gap-3">
@@ -242,13 +263,34 @@ function AgentSummary({
</div>
{channelNames.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{channelNames.map((name) => (
{channelNames.map((channel) => (
<Badge
className="normal-case tracking-normal"
key={name}
className="cursor-pointer normal-case tracking-normal hover:opacity-80"
key={channel.id}
variant="outline"
onClick={(e) => {
e.stopPropagation();
void goChannel(channel.id);
}}
>
# {name}
# {channel.name}
</Badge>
))}
</div>
) : null}
{activeWorkingChannels.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{activeWorkingChannels.map((channel) => (
<Badge
className="cursor-pointer motion-safe:animate-pulse normal-case tracking-normal hover:opacity-80"
key={`working-${channel.id}`}
variant="default"
onClick={(e) => {
e.stopPropagation();
void goChannel(channel.id);
}}
>
Working in #{channel.name}
</Badge>
))}
</div>
@@ -261,12 +303,14 @@ function AgentSummary({
function StatusBlock({
friendlyError,
isWorking,
presenceLoaded,
presenceStatus,
processDetail,
status,
}: {
friendlyError: ReturnType<typeof friendlyAgentLastError>;
isWorking: boolean;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
processDetail: string;
@@ -278,6 +322,7 @@ function StatusBlock({
Status
</p>
<AgentStatusBadge
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={status}
@@ -37,7 +37,8 @@ type UnifiedAgentsSectionProps = {
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
agents: ManagedAgent[];
channelsByPubkey: Record<string, string[]>;
channelIdToName: Record<string, string>;
channelsByPubkey: Record<string, { id: string; name: string }[]>;
agentsError: Error | null;
isActionPending: boolean;
isAgentsLoading: boolean;
@@ -109,6 +110,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
actionErrorMessage,
actionNoticeMessage,
agents,
channelIdToName,
channelsByPubkey,
agentsError,
isActionPending,
@@ -177,6 +179,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
const isLoading = isAgentsLoading || isPersonasLoading;
const rowProps = {
channelIdToName,
channelsByPubkey,
isActionPending,
logContent,
@@ -13,6 +13,7 @@ import {
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import type {
Channel,
CreateManagedAgentResponse,
@@ -64,6 +65,7 @@ export function useManagedAgentActions() {
[managedAgentsQuery.data],
);
useManagedAgentObserverBridge(managedAgents);
useActiveAgentTurnsBridge(managedAgents);
const managedPubkeys = React.useMemo(
() => new Set(managedAgents.map((agent) => agent.pubkey)),
@@ -78,11 +80,18 @@ export function useManagedAgentActions() {
const managedPresenceQuery = usePresenceQuery(managedPubkeyList);
const channelsByPubkey = React.useMemo(() => {
const map: Record<string, string[]> = {};
const map: Record<string, { id: string; name: string }[]> = {};
// Seed from relay agent profiles (kind:10100 events).
for (const ra of relayAgentsQuery.data ?? []) {
if (ra.channels.length > 0) {
map[normalizePubkey(ra.pubkey)] = ra.channels;
// Skip entries missing a channel id rather than falling back to the
// name as id — a misaligned channels/channelIds pairing would otherwise
// produce a pill that silently navigates to a channel name as if it
// were an id.
map[normalizePubkey(ra.pubkey)] = ra.channels.flatMap((name, i) => {
const id = ra.channelIds[i];
return id ? [{ id, name }] : [];
});
}
}
// Fill in from channel member lists (kind:39002) for any managed agents
@@ -95,14 +104,22 @@ export function useManagedAgentActions() {
const key = normalizePubkey(pk);
if (!normalizedManaged.has(key)) continue;
if (!map[key]) map[key] = [];
if (!map[key].includes(ch.name)) {
map[key].push(ch.name);
if (!map[key].some((entry) => entry.id === ch.id)) {
map[key].push({ id: ch.id, name: ch.name });
}
}
}
return map;
}, [relayAgentsQuery.data, channelsQuery.data, managedAgents]);
const channelIdToName = React.useMemo(() => {
const map: Record<string, string> = {};
for (const ch of channelsQuery.data ?? []) {
map[ch.id] = ch.name;
}
return map;
}, [channelsQuery.data]);
// Clear log selection if the agent was removed
React.useEffect(() => {
if (
@@ -323,6 +340,7 @@ export function useManagedAgentActions() {
// Derived state
managedAgents,
managedPubkeys,
channelIdToName,
channelsByPubkey,
isPending,
// UI state
+26
View File
@@ -5,6 +5,7 @@ import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { parse as yamlParse } from "yaml";
import type { RelayEvent } from "@/shared/api/types";
import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore";
import {
CUSTOM_EMOJI_SET_D_TAG,
KIND_EMOJI_SET,
@@ -595,6 +596,11 @@ declare global {
models?: Array<{ id: string; name: string | null }>;
denyReason?: string;
}) => void;
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
__BUZZ_E2E_EMIT_MOCK_READ_STATE__?: (input: {
clientId: string;
contexts: Record<string, number>;
@@ -5868,6 +5874,26 @@ export function maybeInstallE2eTauriMocks() {
if (mesh.denyReason !== undefined)
mockMeshState.denyReason = mesh.denyReason;
};
let seedTurnSeq = Date.now();
window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ = ({
agentPubkey,
channelId,
turnId,
}) => {
seedTurnSeq += 1;
syncAgentTurnsFromEvents(agentPubkey, [
{
seq: seedTurnSeq,
timestamp: new Date().toISOString(),
kind: "turn_started",
agentIndex: 0,
channelId,
sessionId: null,
turnId,
payload: null,
},
]);
};
const meshNodeStatus = (
state: "off" | "running",
mode: "serve" | "client" | null,
@@ -0,0 +1,199 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/active-turns";
// Mock agent pubkeys (distinct from the relay agents seeded by default)
const AGENT_PAUL = "aa".repeat(32);
const AGENT_DUNCAN = "bb".repeat(32);
const AGENT_THUFIR = "cc".repeat(32);
// Mock channel IDs from the e2e bridge
const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const CHANNEL_ENGINEERING = "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9";
async function waitForBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() =>
typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown })
.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
null,
{ timeout: 10_000 },
);
}
async function openAgentsView(page: import("@playwright/test").Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForBridge(page);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("unified-agents-groups")).toBeVisible({
timeout: 10_000,
});
}
test.describe("active turn indicator screenshots", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("01 — baseline: agents running but idle", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PAUL,
name: "Paul",
status: "running",
channelNames: ["general", "engineering"],
},
{
pubkey: AGENT_DUNCAN,
name: "Duncan",
status: "running",
channelNames: ["general", "design"],
},
{
pubkey: AGENT_THUFIR,
name: "Thufir",
status: "stopped",
channelNames: [],
},
],
});
await openAgentsView(page);
const agentsSection = page.getByTestId("unified-agents-groups");
await expect(agentsSection).toContainText("Paul");
await expect(agentsSection).toContainText("Duncan");
await expect(agentsSection).toContainText("Thufir");
await agentsSection.screenshot({
path: `${SHOTS}/01-baseline-idle.png`,
});
});
test("02 — single agent working in one channel", async ({ page }) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PAUL,
name: "Paul",
status: "running",
channelNames: ["general", "engineering"],
},
{
pubkey: AGENT_DUNCAN,
name: "Duncan",
status: "running",
channelNames: ["general", "design"],
},
{
pubkey: AGENT_THUFIR,
name: "Thufir",
status: "stopped",
channelNames: [],
},
],
});
await openAgentsView(page);
await waitForBridge(page);
// Seed Paul as actively working in #general
await page.evaluate(
({ pubkey, channelId }) => {
const win = window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
};
win.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey: pubkey,
channelId,
turnId: "turn-001",
});
},
{ pubkey: AGENT_PAUL, channelId: CHANNEL_GENERAL },
);
// Wait for the "Working" badge to appear
await expect(page.getByTestId(`managed-agent-${AGENT_PAUL}`)).toContainText(
"Working",
{ timeout: 5_000 },
);
const agentsSection = page.getByTestId("unified-agents-groups");
await agentsSection.screenshot({
path: `${SHOTS}/02-single-agent-working.png`,
});
});
test("03 — mixed states: one working in 2 channels, one idle, one stopped", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
pubkey: AGENT_PAUL,
name: "Paul",
status: "running",
channelNames: ["general", "engineering"],
},
{
pubkey: AGENT_DUNCAN,
name: "Duncan",
status: "running",
channelNames: ["general", "design"],
},
{
pubkey: AGENT_THUFIR,
name: "Thufir",
status: "stopped",
channelNames: [],
},
],
});
await openAgentsView(page);
await waitForBridge(page);
// Seed Paul as working in both #general and #engineering
await page.evaluate(
({ pubkey, channels }) => {
const win = window as Window & {
__BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: {
agentPubkey: string;
channelId: string;
turnId: string;
}) => void;
};
for (const { channelId, turnId } of channels) {
win.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey: pubkey,
channelId,
turnId,
});
}
},
{
pubkey: AGENT_PAUL,
channels: [
{ channelId: CHANNEL_GENERAL, turnId: "turn-002" },
{ channelId: CHANNEL_ENGINEERING, turnId: "turn-003" },
],
},
);
// Wait for the "Working" indicators to appear
await expect(page.getByTestId(`managed-agent-${AGENT_PAUL}`)).toContainText(
"Working",
{ timeout: 5_000 },
);
const agentsSection = page.getByTestId("unified-agents-groups");
await agentsSection.screenshot({
path: `${SHOTS}/03-mixed-states.png`,
});
});
});