fix(desktop): sync derived active-turn liveness in channel activity path

Rebased from tho/active-turn-bridge-fix (#1492) onto post-#1380 main,
squashing the temporary debug-harness add/remove churn that netted to
zero. Behavior preserved:

- ChannelScreen mounts useActiveAgentTurnsBridge next to the existing
  observer bridge so raw observer frames (e.g. turn_completed) and the
  derived liveness store stay in sync in the channel activity path
- Extract syncActiveAgentTurnsFromObserver() from the bridge hook so the
  regression test drives the exact observer→derived-liveness path
- resetActiveAgentTurnsStore() wired into workspace-switch reset per the
  workspace-switching singleton contract
- Regression coverage: observer→active-turns bridge sync keeps the raw
  feed's completion event while derived liveness clears; non-running
  agents don't populate the store

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-03 12:34:04 -07:00
co-authored by Taylor Ho
parent 4a09510429
commit b2b0857c0c
4 changed files with 95 additions and 5 deletions
@@ -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");
@@ -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();
@@ -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(
@@ -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();