Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts

90 lines
3.5 KiB
TypeScript

// Live SSE consumer for the swarm-relayed agent turn-state header
// (`GET /api/agents/{name}/state/stream`, swarm-controller's
// `agent_state_stream.rs`). Same transport shape as `useSwarmTermStream`
// — one core-NATS subject relayed as bare SSE `data`, no envelope, no
// replay — but the payload here is a full header that *replaces* the
// last one rather than an appendable row, so there is nothing to
// coalesce: each event is just the latest value.
//
// **No seed on attach.** `agent_state_stream.rs`'s own module doc names
// this limitation: the subject is published on transition and re-sent
// once a minute, so an agent that has sat idle since before this hook
// mounted announces nothing for up to a minute. `header` stays `null`
// until the first event lands — callers should render no badges in that
// gap rather than a stale or guessed one (there is no swarm-level
// endpoint today that can seed turn_state/model/ctx/cost synchronously —
// `GET /api/agents/status`'s snapshot carries only
// `status_text`/`running`).
import { useEffect, useState } from "preact/hooks";
export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed";
export interface TokenUsage {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
}
// `swarm_wire::agent_inference::Source`.
export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default";
// Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field
// — see that struct's own doc comment for why `turn_state_since` (an
// ISO 8601 string here, unlike the per-agent page's unix-seconds
// `/api/state`) and `agent_state` (the swarm `AgentState` enum, not a
// bare `paused: bool`) diverge from the per-agent page's own shape.
export interface AgentStateHeader {
turn_state: "idle" | "thinking" | "compacting";
turn_state_since: string;
agent_state: "up" | "offline" | "paused" | "destroyed";
model: string;
effort: string;
model_source: InferenceSource;
effort_source: InferenceSource;
resolved_model: string | null;
context_window_tokens: number;
ctx_usage: TokenUsage | null;
cost_usage: TokenUsage | null;
// Absent from an agent that predates the field.
needs_login?: boolean;
}
export interface UseSwarmAgentStateStreamResult {
header: AgentStateHeader | null;
connection: ConnectionState;
}
export function useSwarmAgentStateStream(
streamUrl: string | null,
): UseSwarmAgentStateStreamResult {
const [header, setHeader] = useState<AgentStateHeader | null>(null);
const [connection, setConnection] = useState<ConnectionState>("connecting");
useEffect(() => {
setHeader(null);
setConnection("connecting");
// `null`: a terminal with no header to follow (a subagent's).
if (streamUrl === null) return;
const es = new EventSource(streamUrl);
es.onopen = () => setConnection("open");
es.onmessage = (e) => {
try {
setHeader(JSON.parse(e.data) as AgentStateHeader);
} catch {
// Malformed payload: drop it silently. Unlike the terminal
// preview there is no row list to append a "[parse err]" line
// to here, and one bad header isn't worth degrading the whole
// badge cluster over — the next valid header replaces it.
}
};
es.onerror = () => {
setConnection(
es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed",
);
};
return () => es.close();
}, [streamUrl]);
return { header, connection };
}