mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
4a09510429
commit
b2b0857c0c
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user