Remove archivePagingReset.test.mjs — the HarnessHook lifecycle test
was a copy of the production effect testing itself, not the real
useLoadArchivedObserverEvents hook. The pure applyChannelReset /
createArchivePagingState unit tests in ingestArchivedObserverEvents
.test.mjs are sufficient and call the real extracted functions.
Fix lazy-init MINOR: useRef(createArchivePagingState()) was building
a throwaway ArchivePagingState (including a pending Promise) on every
render after the first. Use a nullable ref with an if (!ref.current)
guard instead.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The two archive-paging-reset tests in ingestArchivedObserverEvents.test.mjs
were tautological: they reassigned local let variables and asserted on those
same reassignments, not on any production behavior. The tests passed even
if the useEffect([channelId]) reset in useLoadArchivedObserverEvents was
deleted.
Fix: extract the paging state machine into archivePagingState.ts with two
pure functions (createArchivePagingState, applyChannelReset). The hook
imports and calls these; tests import the same functions directly.
Replace the tautological tests with:
1. Three unit tests in ingestArchivedObserverEvents.test.mjs that call
createArchivePagingState() and applyChannelReset() — the real production
functions, not local copies. These catch behavioral regressions in the
state machine itself.
2. Two hook-lifecycle tests in archivePagingReset.test.mjs that mount a
React component (via the same DOM shim used in
MessageComposerDraftImagePersist.test.mjs), drive channel A to
exhaustion, re-render with channel B, and assert B starts fresh via the
hook's own reactive state. These tests fail when the useEffect([channelId])
body is deleted — verified before commit: removing applyChannelReset(ps)
from the effect causes cursor/hasOlderArchived to remain stale after the
channel switch (AssertionError: cursor must reset to null after channel
switch (A->B)).
The hook's useEffect([channelId]) body is unchanged in behavior: it calls
applyChannelReset(ps) which sets cursor=null, isFetching=false,
hasOlderArchived=true — identical to the prior inline mutations of
cursorRef.current/isFetchingRef.current. Backfill state is not touched
by applyChannelReset, preserving the identity-level semantics Paul and
Thufir confirmed.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F7 — idle agent archived history not renderable (CRITICAL):
getAgentObserverSnapshot and getAgentTranscript were early-returning empty
when enabled=false, discarding ingested archived events. The enabled flag
conflated two concerns: subscribing to the live relay, and reading stored
data. Only the relay subscription should gate on isLive/hasObserver.
Fix: drop the !enabled early-return in both store readers; keep !agentPubkey
as the only guard. The enabled parameter is retained for call-site
compatibility (renamed to _enabled) but no longer controls store reads.
The relay subscription useEffect in useObserverEvents still gates on enabled
so idle agents don't trigger unnecessary relay connections.
Also fix the EmptyObserverState gate in ManagedAgentSessionPanel: previously
shown whenever !hasObserver regardless of content, now only shown when the
agent is idle AND there is nothing to display (transcript.length === 0 &&
events.length === 0). This allows archived channel history to render for
idle agents instead of showing the empty state.
F8 — archive paging state not scoped to channel (IMPORTANT):
useLoadArchivedObserverEvents held hasOlderArchived, cursorRef, and
isFetchingRef in a single hook instance while channelId changed across
channel switches in the same AgentSessionThreadPanel lifecycle. Channel A's
exhausted state and cursor bled into channel B — B would not page, or B's
first read would skip rows newer than A's cursor.
Fix: add a useEffect([channelId]) that resets cursorRef to null,
isFetchingRef to false, and hasOlderArchived to true on channel change.
Backfill state (backfillStatusRef/backfillPromiseRef/backfillResolveRef)
is identity-level and deliberately NOT reset — backfill covers all channels
and only needs to run once per identity mount.
Tests: two new regression cases in ingestArchivedObserverEvents.test.mjs:
- test_idle_agent_archived_events_readable_when_enabled_false: idle agent
with archived rows for two channels reads both (enabled=false no longer
blocks), and scopeByChannel returns only channel-A frames for channel A
with zero channel-B contamination.
- test_channel_switch_resets_cursor_and_exhaustion: verifies the paging
state-machine semantics — channel A exhausted then channel switch resets
cursor/hasOlderArchived/isFetching. Plus a spec test confirming backfill
state fields are identity-scoped and must not reset on channel switch.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Both openAgentSession and selectAgentSession were calling
setOpenAgentSessionChannelId(channelId ?? null). When the activity-list
opener supplies no explicit channelId, the null propagated into
sessionChannelId, which caused scopeByChannel to skip filtering entirely
(its null fast-path returns all items). Combined with the shared per-pubkey
store, this let every channel's live frames appear in any channel's feed.
Fix: fall back to activeChannelId in both callbacks so opening from within
a channel always resolves a non-null scope key. activeChannelId is already
in the useChannelAgentSessions parameter list; add it to both callbacks'
dependency arrays.
Also fix the archive-load enabled gate: useLoadArchivedObserverEvents was
gated on isLive, so an idle agent's channel showed no archived observer
history. Change the enable condition to Boolean(sessionChannelId) so
archive history loads whenever a channel scope is resolved, regardless of
whether the agent is currently running.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The first-turn wire sequence emitted by the harness is:
turn_started(sessionId=null) → session/new(sessionId=null) → session_resolved(sessionId=X) → session/prompt(sessionId=X)
The previous splitIntoSessionRuns opened a synthetic 'unknown' run for the
leading null-session items, then opened a second run when session_resolved
arrived. This caused two bugs:
1. Leaked system prompt: the session/new metadata had no following user prompt
in its 'unknown' run so pendingSystemPrompt was never consumed and emitted
as a standalone 'System prompt' row. Caught by Desktop Smoke E2E (3),
observer-feed-screenshots.spec.ts:726 (expected count 0, got 1).
2. Spurious session boundary: two runs from one session triggered
buildTranscriptDisplayBlocks to inject a session-boundary block on a feed
that has exactly one session — the opposite of the signal this PR adds.
Fix: buffer pre-resolution null-session items and prepend them to the first
run that has a non-null sessionId. Only if the entire stream has no resolved
session do they form a fallback 'unknown' run. Mid-stream null-session items
(after resolution) continue to attribute to the current run as before.
Added two regression tests:
- firstTurnSequence_noStandaloneSystemPrompt: asserts zero standalone
session/new blocks, zero session-boundary blocks, system prompt present
inside the turn block (prompt bundle).
- genuineSecondSession_boundaryPreserved: asserts the deferral fix does not
collapse two genuinely distinct sessions — boundary is still injected.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two observer-feed defects fixed in a single PR:
Slice 1 — channel-scoped archive (index at ingest + idempotent backfill)
The archive read path queried all owner_p kind 24200 rows for an identity,
then relied on a display-time scopeByChannel filter. When the filter was
missing or skipped, frames from other channels leaked into the current
channel's observer feed.
Fix: add an observer_channel_index table that maps (identity, relay, event_id)
to channel_id. Three new Tauri commands drive this:
- read_archived_observer_events_for_channel: channel-scoped paginated read via
the index (replaces the raw owner_p scan in useLoadArchivedObserverEvents)
- index_observer_channel_id: TS calls this after decrypting events to write
index entries
- read_unindexed_observer_rows: returns all owner_p kind 24200 rows not yet
indexed, for the one-shot idempotent backfill
Backfill strategy (choice i, backfill-before-read): a React.useEffect in
useLoadArchivedObserverEvents runs once on mount, reads all unindexed rows,
decrypts each, and batch-writes index entries. After backfill the channel
index is authoritative — the scoped read pages it directly with no zero-match
raw-page guard needed.
Null/decrypt-failed channelId rows stay unscoped and are hidden from every
scoped channel view (Will's ruling: option (a) — display decision only, rows
stay on disk).
Slice 2 — session-boundary rendering
Items rendered flat with no session grouping; archived and live frames
interleaved without any visual marker of where one agent session ended and
another began, causing users to think archived historical context is still
live.
Fix: split TranscriptItem[] into contiguous session runs before calling
buildTranscriptDisplayBlocks. This also fixes the pendingSystemPrompt
single-slot clobber (each run gets its own lifecycle). A new session-boundary
display block kind is interleaved between runs; SessionBoundaryDivider renders
a horizontal rule with session start time. No boundary emitted for
single-session transcripts.
Liveness — "current session" gating
Track latest-live-session-id per (normalized agent pubkey, channelId) in
observerRelayStore, set on the handleRelayObserverEvent path, cleared in
resetAgentObserverStore. Label a boundary "current session" only when it is
both the newest-visible session AND equals that latest-live id; otherwise label
it "most recent observed session".
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>