Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
565 changes: 519 additions & 46 deletions desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs

Large diffs are not rendered by default.

117 changes: 116 additions & 1 deletion desktop/src/features/agents/observerRelayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,23 @@ const IDLE_SNAPSHOT: ObserverSnapshot = {
events: [],
};

const EMPTY_EVENTS: ObserverEvent[] = [];
const EMPTY_TRANSCRIPT: TranscriptItem[] = [];

const listeners = new Set<() => void>();
const eventsByAgent = new Map<string, ObserverEvent[]>();
const transcriptByAgent = new Map<string, TranscriptState>();
const snapshotByAgent = new Map<string, ObserverSnapshot>();

// Channel-scoped archive event journal — holds paged history loaded from the local
// SQLite archive without the MAX_OBSERVER_EVENTS live-relay cap. Keyed by
// `${normalizedAgentPubkey}:${channelId}`. The live relay path writes to
// `eventsByAgent` (per-agent, capped) and this map is NEVER written by live
// events — separation is strict so loading deep history can never evict live frames
// or vice versa. UI consumers merge the raw events from both sources, then derive
// TranscriptState once over the combined window.
const archiveEventsByChannel = new Map<string, ObserverEvent[]>();

// Per-agent, per-channel latest-live-session-id.
// Key: `${normalizePubkey(agentPubkey)}:${channelId}`.
// Set when a live relay observer event with a sessionId arrives.
Expand Down Expand Up @@ -204,6 +214,75 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) {
notifyListeners();
}

/**
* Compose the map key for the channel-scoped archive transcript.
* Separates agent identity from channel with `:` — the same delimiter used by
* liveSessionKey so all composite keys in this module are consistently shaped.
*/
function archiveChannelKey(agentPubkey: string, channelId: string): string {
return `${normalizePubkey(agentPubkey)}:${channelId}`;
}

/**
* Append a decoded archived observer event to the channel-scoped archive
* event journal. Unlike `appendAgentEvent`, this path does NOT cap or trim —
* the channel archive window grows only by explicit paged loads from SQLite,
* so unbounded growth from live relay events is impossible.
*
* Deduplicates on `(seq, timestamp)` — identical to `appendAgentEvent` — so
* events that arrive on the live relay before the archive page is loaded are
* silently skipped. The archive window and the live transcript are kept
* strictly separate: live events never write here.
*
* Returns `true` if the event was added (state changed), `false` if it was a
* duplicate and was skipped. The caller batches notifications.
*/
function appendArchivedChannelEvent(
agentPubkey: string,
channelId: string,
event: ObserverEvent,
): boolean {
const key = archiveChannelKey(agentPubkey, channelId);
const current = archiveEventsByChannel.get(key) ?? [];

// Dedup: skip if (seq, timestamp) already present in the archive window.
if (
current.some(
(existing) =>
existing.seq === event.seq && existing.timestamp === event.timestamp,
)
) {
return false;
}

// Archive pages arrive newest-first from SQLite, so each new event sorts
// BEFORE the existing entries. Sort the combined array to maintain ascending
// order for consumers that call buildTranscriptState over the window.
const sorted = [...current, event].sort(compareObserverEvents);
archiveEventsByChannel.set(key, sorted);
return true;
}

/**
* Read the channel-scoped archive raw events for a given (agent, channel)
* pair. Returns an empty array when no archive has been loaded yet.
*
* Called by `useArchivedChannelEvents` so UI components can reactively
* subscribe to archive loads and derive transcript state from the combined
* live + archive raw event window without touching the live-capped per-agent
* store.
*/
export function getArchivedChannelEvents(
agentPubkey: string | null | undefined,
channelId: string | null | undefined,
): ObserverEvent[] {
if (!agentPubkey || !channelId) return EMPTY_EVENTS;
return (
archiveEventsByChannel.get(archiveChannelKey(agentPubkey, channelId)) ??
EMPTY_EVENTS
);
}

export function compareObserverEvents(
left: ObserverEvent,
right: ObserverEvent,
Expand Down Expand Up @@ -535,6 +614,7 @@ export async function ingestArchivedObserverEvents(
rawEvents: RelayEvent[],
_decryptFn: (event: RelayEvent) => Promise<unknown> = decryptObserverEvent,
): Promise<void> {
let archiveChanged = false;
for (const event of rawEvents) {
const agentPubkey = observerTag(event, "agent");
const frame = observerTag(event, "frame");
Expand All @@ -549,11 +629,31 @@ export async function ingestArchivedObserverEvents(
}
try {
const parsed = (await _decryptFn(event)) as ObserverEvent;
appendAgentEvent(agentPubkey, parsed);
// Route archived events to the channel-scoped archive window (no cap)
// rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap).
// Events without a channelId fall through to the live store so they
// remain visible in the agent's general transcript.
if (parsed.channelId) {
const added = appendArchivedChannelEvent(
agentPubkey,
parsed.channelId,
parsed,
);
if (added) archiveChanged = true;
} else {
// Live path already calls notifyListeners() inside appendAgentEvent.
appendAgentEvent(agentPubkey, parsed);
}
} catch {
// Silently drop decrypt failures — same as live path error handling.
}
}
// Batch-notify once for the whole page of archive events. appendAgentEvent
// already notifies individually for live/no-channelId events above, so we
// only need one extra notify here for the archive path.
if (archiveChanged) {
notifyListeners();
}
}

/**
Expand Down Expand Up @@ -597,6 +697,7 @@ export function resetAgentObserverStore() {
eventsByAgent.clear();
transcriptByAgent.clear();
snapshotByAgent.clear();
archiveEventsByChannel.clear();
knownAgentPubkeys.clear();
knownAgentsBySubscription.clear();
latestLiveSessionByAgentChannel.clear();
Expand All @@ -618,3 +719,17 @@ export function _testRegisterKnownAgents(
): void {
registerKnownAgents(subscriptionId, pubkeys);
}

/**
* Test-only: read the raw archived observer events for a (agent, channel) pair.
* Production callers should use `getArchivedChannelEvents`.
* Only call from tests — never from production code.
*/
export function _testGetArchivedChannelEvents(
agentPubkey: string,
channelId: string,
): ObserverEvent[] {
return (
archiveEventsByChannel.get(archiveChannelKey(agentPubkey, channelId)) ?? []
);
}
21 changes: 11 additions & 10 deletions desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ function getDisplayBlockKey(block: TranscriptDisplayBlock) {
return block.item.id;
}
if (block.kind === "session-boundary") {
return `session-boundary:${block.sessionId}:${block.runIndex}`;
// Use firstItemId (stable across prepend) rather than runIndex (shifts when
// older sessions are prepended, causing unnecessary boundary remounts).
return `session-boundary:${block.sessionId}:${block.firstItemId}`;
}
return `turn:${block.turnId}`;
}
Expand Down Expand Up @@ -933,12 +935,11 @@ function TurnSetupStatus({
/**
* Horizontal rule rendered between session runs in the observer transcript.
*
* Three label states:
* - `"current"` — live session, agent is actively running in this context.
* - `"most-recent"` — newest visible session but no live match (archived-only
* or session ended). Shown as "Most recent observed session"
* so users know it is history, not active context.
* - `"earlier"` — an older session. Always labeled with the archive signal.
* Three label states (based on live-frame observation, not harness affinity):
* - `"current"` — most recent session observed via the live relay subscription.
* - `"most-recent"` — newest visible session with no matching live frames
* (loaded from archive or session ended before observation).
* - `"earlier"` — an older session preceding the most-recent one.
*/
function SessionBoundaryDivider({
labelState,
Expand All @@ -949,10 +950,10 @@ function SessionBoundaryDivider({
}) {
const label =
labelState === "current"
? "Current session"
? "Latest live-observed session"
: labelState === "most-recent"
? "Most recent observed session — not in current context"
: "Earlier session — not in current context";
? "Most recent observed session"
: "Earlier observed session";
const formattedDate = new Date(sessionStartTimestamp).toLocaleString();
return (
<div
Expand Down
45 changes: 35 additions & 10 deletions desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ import type {
import type { AgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
import {
deriveLatestSessionId,
mergeObserverEventWindows,
resolveDisplayEvents,
resolveRawRailLayout,
scopeByChannel,
} from "./agentSessionPanelLayout";
import { shorten } from "./agentSessionUtils";
import { useObserverEvents, useAgentTranscript } from "./useObserverEvents";
import {
useObserverEvents,
useArchivedChannelEvents,
} from "./useObserverEvents";
import { buildTranscriptState } from "./agentSessionTranscript";

type ManagedAgentSessionPanelProps = {
agent: Pick<ManagedAgent, "pubkey" | "name" | "status"> & {
Expand Down Expand Up @@ -80,22 +85,42 @@ export function ManagedAgentSessionPanel({
hasObserver,
agent.pubkey,
);
const transcript = useAgentTranscript(hasObserver, agent.pubkey);

const scopedTranscript = React.useMemo(
() => scopeByChannel(transcript, channelId),
[channelId, transcript],
// Channel-scoped live events (capped at MAX_OBSERVER_EVENTS) and uncapped
// archived events from SQLite paging. Both are raw ObserverEvent[] — we merge
// them at the raw-event level and derive a single TranscriptState, so stateful
// aggregates (tool start/update, plan replacement, permission request/response)
// are never split across two independent state machines.
const archivedChannelEvents = useArchivedChannelEvents(
agent.pubkey,
channelId,
);

const displayTranscript = transcriptOverride ?? scopedTranscript;

const scopedEvents = React.useMemo(
const scopedLiveEvents = React.useMemo(
() => scopeByChannel(events, channelId),
[channelId, events],
);

// Combined raw window: live (scoped) + archive merged by (seq, timestamp),
// sorted ascending. Used as the single source for both the transcript and the
// raw event rail / header count.
const combinedEvents = React.useMemo(
() => mergeObserverEventWindows(scopedLiveEvents, archivedChannelEvents),
[scopedLiveEvents, archivedChannelEvents],
);

// Derive transcript once from the combined raw window. When transcriptOverride
// is set (e.g. E2E snapshot specs), bypass both — the caller supplies the full
// transcript directly.
const derivedTranscript = React.useMemo(
() => buildTranscriptState(combinedEvents).items,
[combinedEvents],
);
const displayTranscript = transcriptOverride ?? derivedTranscript;

const displayEvents = React.useMemo(
() => resolveDisplayEvents(scopedEvents, rawEventsOverride),
[rawEventsOverride, scopedEvents],
() => resolveDisplayEvents(combinedEvents, rawEventsOverride),
[rawEventsOverride, combinedEvents],
);

const latestSessionId = React.useMemo(
Expand Down
41 changes: 41 additions & 0 deletions desktop/src/features/agents/ui/agentSessionPanelLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@ export function scopeByChannel<T extends { channelId?: string | null }>(
return items.filter((item) => item.channelId === channelId);
}

/**
* Merge live and archived raw `ObserverEvent[]` arrays into a single
* deduplicated, chronologically-sorted array.
*
* The live event window is capped at MAX_OBSERVER_EVENTS (3000) and holds the
* most recent events for the agent/channel. The archive window is channel-scoped
* paged history loaded from SQLite — it extends the visible range beyond the cap.
*
* Deduplication: events present in both (e.g. a frame that arrived live and was
* also loaded from the archive) are collapsed to one entry by `(seq, timestamp)`.
* The live copy is preferred when a duplicate exists, since the live path may
* have applied incremental transcript mutations via `processTranscriptEvent`.
*
* Sorting: ascending `compareObserverEvents` order (timestamp then seq).
* Callers should pass the result directly to `buildTranscriptState()`.
*/
export function mergeObserverEventWindows(
liveEvents: readonly ObserverEvent[],
archivedEvents: readonly ObserverEvent[],
): ObserverEvent[] {
if (archivedEvents.length === 0) return liveEvents as ObserverEvent[];
if (liveEvents.length === 0) return archivedEvents as ObserverEvent[];

// Dedup key: same as appendAgentEvent / appendArchivedChannelEvent.
const liveKeySet = new Set(liveEvents.map((e) => `${e.seq}:${e.timestamp}`));
const uniqueArchived = archivedEvents.filter(
(e) => !liveKeySet.has(`${e.seq}:${e.timestamp}`),
);
if (uniqueArchived.length === 0) return liveEvents as ObserverEvent[];

const merged = [...liveEvents, ...uniqueArchived];
// compareObserverEvents: timestamp diff then seq diff (ascending).
merged.sort((a, b) => {
const ta = Date.parse(a.timestamp);
const tb = Date.parse(b.timestamp);
if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) return ta - tb;
return a.seq - b.seq;
});
return merged;
}

/**
* Derive the most recent session id from a list of observer events by
* scanning from the end. Returns null when no event carries a sessionId.
Expand Down
Loading
Loading