diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index c1359f6c3..75d216e2e 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -3,11 +3,17 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import { syncAgentTurnsFromEvents, + syncActiveAgentTurnsFromObserver, getActiveTurnsForAgent, getActiveTurnsByChannel, resetActiveAgentTurnsStore, subscribeActiveAgentTurns, } from "./activeAgentTurnsStore.ts"; +import { + injectObserverEventsForE2E, + getAgentObserverSnapshot, + resetAgentObserverStore, +} from "./observerRelayStore.ts"; import { formatElapsed } from "./ui/agentSessionUtils.ts"; const AGENT = @@ -1230,6 +1236,71 @@ describe("activeAgentTurnsStore", () => { }); }); +/** + * Regression: raw observer ingestion and derived active-turn liveness must + * stay in sync. The channel activity path previously mounted only the observer + * bridge, so a raw `turn_completed` could be visible in the activity feed + * while the derived liveness indicator kept spinning. This drives events + * through the real observer store (appendAgentEvent path) and the same sync + * the bridge hook runs, asserting derived liveness clears with the raw feed. + */ +describe("observer → active-turns bridge sync", () => { + const bridgeAgents = [{ pubkey: AGENT, status: "deployed" }]; + + beforeEach(() => { + resetActiveAgentTurnsStore(); + resetAgentObserverStore(); + }); + + afterEach(() => { + resetAgentObserverStore(); + }); + + it("clears derived liveness when raw turn_completed arrives", () => { + injectObserverEventsForE2E(AGENT, [ + makeEvent({ seq: 1, kind: "turn_started" }), + ]); + syncActiveAgentTurnsFromObserver(bridgeAgents); + assert.ok( + channelIdsOf(getActiveTurnsForAgent(AGENT)).has("chan-1"), + "turn_started must surface an active turn", + ); + + injectObserverEventsForE2E(AGENT, [ + makeEvent({ + seq: 2, + kind: "turn_completed", + timestamp: "2024-01-01T00:00:05Z", + }), + ]); + syncActiveAgentTurnsFromObserver(bridgeAgents); + + const rawEvents = getAgentObserverSnapshot(AGENT, true).events; + assert.equal( + rawEvents.at(-1)?.kind, + "turn_completed", + "raw feed must contain the completion event", + ); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "derived liveness must clear when the raw feed shows turn_completed", + ); + }); + + it("skips agents that are neither running nor deployed", () => { + injectObserverEventsForE2E(AGENT, [ + makeEvent({ seq: 1, kind: "turn_started" }), + ]); + syncActiveAgentTurnsFromObserver([{ pubkey: AGENT, status: "stopped" }]); + assert.equal( + getActiveTurnsForAgent(AGENT).length, + 0, + "inactive agents must not populate the active-turns store", + ); + }); +}); + describe("formatElapsed", () => { it("renders sub-10s as whole seconds", () => { assert.equal(formatElapsed(0), "0s"); diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 9344c3e8b..9ffd34db6 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -527,6 +527,21 @@ export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] { ); } +/** + * Sync every running/deployed agent's observer events into the active-turns + * store. Extracted from the bridge hook so a regression can drive the exact + * observer→derived-liveness path without a React renderer. + */ +export function syncActiveAgentTurnsFromObserver( + agents: readonly { pubkey: string; status: string }[], +) { + for (const agent of agents) { + if (agent.status !== "running" && agent.status !== "deployed") continue; + const snapshot = getAgentObserverSnapshot(agent.pubkey, true); + syncAgentTurnsFromEvents(agent.pubkey, snapshot.events); + } +} + /** * Bridge hook: processes observer events into the active-turns store. * Should be called by a parent component that has access to the observer events. @@ -536,11 +551,7 @@ export function useActiveAgentTurnsBridge( ) { 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); - } + syncActiveAgentTurnsFromObserver(agents); } syncAll(); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 662d6a74e..153807e80 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -24,6 +24,7 @@ import { usePersonasQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { mergeMessages, @@ -386,6 +387,11 @@ export function ChannelScreen({ ]; }, [managedAgents, openAgentSessionPubkey, profilePanelPubkey]); useManagedAgentObserverBridge(observerBridgeAgents); + // Derive active-turn/liveness state from the same observer events. Without + // this, raw observer frames (e.g. turn_completed) reach the activity panel + // while the derived active-turns store stays stale, leaving the liveness + // indicator spinning after the turn already finished. + useActiveAgentTurnsBridge(observerBridgeAgents); const messageProfiles = React.useMemo(() => { const base = mergeCurrentProfileIntoLookup( diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index fe01c30ce..c7afd9a18 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; +import { resetActiveAgentTurnsStore } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -27,6 +28,7 @@ import type { Workspace } from "./types"; function resetWorkspaceState(): void { relayClient.disconnect(); resetAgentObserverStore(); + resetActiveAgentTurnsStore(); resetSidebarRelayConnectionCardState(); resetMediaCaches(); resetVideoPlayerState();