Skip to content

test(session-transitions): determinism + GC coverage from PR#50 review#52

Open
JeanBaptisteRenard wants to merge 39 commits into
doctly:mainfrom
JeanBaptisteRenard:test/pr50-determinism-and-coverage
Open

test(session-transitions): determinism + GC coverage from PR#50 review#52
JeanBaptisteRenard wants to merge 39 commits into
doctly:mainfrom
JeanBaptisteRenard:test/pr50-determinism-and-coverage

Conversation

@JeanBaptisteRenard

Copy link
Copy Markdown

Summary

Non-blocking test improvements from the PR#50 review. No production behavior changes beyond a single optional parameter.

Test plan

  • npm test — 39 pass, 0 fail (was 38)
  • npm run lint — 0 errors
  • One commit per improvement

JeanBaptisteRenard and others added 30 commits May 21, 2026 23:02
Subagent transcripts written by Claude CLI live at
<folder>/<parentSessionId>/subagents/agent-<agentId>.jsonl alongside a
.meta.json sidecar holding { agentType, description }. Surface them as
first-class rows in session_cache, keyed by sessionId 'sub:<parent>:<agentId>'.

- adds parentSessionId, agentId, subagentType, description columns
- migration v4 clears the cache so a re-index repopulates everything
- adds idx_session_cache_parent for hierarchy lookups
- new query getCachedByParent + widened cacheGetByFolder
Adds helpers for the on-disk subagent layout:
- enumerateSessionFiles(folderPath) walks top-level + <uuid>/subagents/*.jsonl
  + legacy <uuid>/*.jsonl fallback
- subagentSessionId(parent, agentId) gives the synthetic 'sub:<p>:<a>' id
- resolveJsonlPath(projectsDir, row) reconstructs the absolute path
- readSubagentMeta reads the .meta.json sidecar

readSessionFile now accepts opts.parentSessionId. When set, it requires
isSidechain on at least one entry (defensive guard), reads the sidecar for
agentType/description, falls back to filename for agentId if absent in entries.

Adds 10 unit tests covering both branches, the sidecar guard, the legacy
fallback path, and the synthetic-id format.
Both scanners (synchronous refreshFolder + worker scan-projects) now use
enumerateSessionFiles instead of a flat top-level readdir, so subagent
transcripts get cached as first-class rows with parentSessionId set.

FTS indexing inherits this for free: subagent summaries land in search_fts
the same way top-level sessions do, with type='session' and folder=<parent>.
Full-text search now finds anything an Agent call discussed.
Clicking an Agent tool_use block in the JSONL viewer now loads the matching
subagent transcript and renders it nested below the block. Re-click collapses
without refetching.

- main.js: new IPC read-subagent-jsonl(parentSessionId, agentId) and
  list-subagents(parentSessionId)
- preload.js: window.api.readSubagentJsonl / listSubagents
- jsonl-viewer.js: clickable Agent block, caret indicator, recursive nested
  render. Identical (description, subagentType) pairs are disambiguated by
  occurrence ordinal so parallel fanout calls each open their own transcript.
- style.css: subtle hover + left-border indent for nested transcripts
Runs `npm ci && npm test` on ubuntu-latest against Node 20 and 22.
Triggers on pull requests targeting main and on direct pushes to main.
Migrations that clear session_cache (v2, v3, v4) leave the cache empty on
first launch. The previous get-projects handler returned [] immediately and
fire-and-forgot the worker scan, relying on a later 'projects-changed' IPC
to trigger a reload — but app.js only reloads on that event when the user
happens to be on the Sessions tab. If they were on another tab (or
hadn't navigated to Sessions yet), the list stayed empty until they
manually switched tabs.

Make populateCacheViaWorker return a Promise that resolves when the scan
finishes. Concurrent callers share the same Promise. get-projects awaits
that Promise when the cache is empty, so the response carries the freshly
populated cache and the renderer paints immediately.
buildProjectsFromCache was projecting only the pre-PR#1 columns onto the
session object sent to the renderer. The renderer's hierarchical sidebar
(introduced in PR#2) reads parentSessionId/agentId/subagentType/description
from each session and silently flattens to top-level when they're missing —
which is what happened: all subagents appeared as siblings of their parent
instead of nested under it.

Add the four subagent columns to the projection. No DB or wire change.
Subagent support: index, search, and inline-expand transcripts (fork CI)
readSessionFile JSON.parse'd every line of a session file in a single
outer try/catch. If a Claude CLI session was actively writing the file
while the worker scan read it, one mid-write line could throw and
invalidate the ENTIRE file's session row. With many parallel live sessions
this manifested as 'most projects show no sessions after a fresh index'.

- Per-line try/catch inside readSessionFile: skip malformed lines, keep
  parsing the rest.
- Per-file try/catch in workers/scan-projects.js: defensive belt and
  braces so one unparseable file can't abort an entire folder scan.
resolveWorktreePath was defined and exported in the file but never
actually called from deriveProjectPath. As a result every worktree under
<project>/.claude/worktrees/<name>/ appeared as a separate project group
in the sidebar instead of being grouped under its parent project.

Wire the call in both branches of deriveProjectPath (direct .jsonl path
and subdirectory path) and export resolveWorktreePath for other callers.
session-transitions.js now tracks per-active-session knownSubagents and emits
two new IPC events:
- subagent-spawned (parentSessionId, agentId, subagentType, description) when
  a new agent-*.jsonl file appears under <sid>/subagents/
- subagent-completed (parentSessionId, agentId) when an existing file's mtime
  has been stable for >30s

Adds two IPCs for read-only live tailing:
- start-subagent-watch (parent, agentId) -> watchId
- stop-subagent-watch (watchId)

The watcher streams new JSONL entries via subagent-watch-event so the renderer
can append them to an open inline-expanded subagent transcript without polling.
- sidebar.js: subagents are no longer flat siblings of their parent. Each
  top-level row now shows a 'N subagents' affordance with a disclosure caret;
  expanded children render indented with a subagentType pill, description as
  label, and persist expansion state per parent in localStorage. Orphan
  subagents (parent absent from cache) hoist to a 'Orphan subagents' group.
- grid-view.js: each active session card now shows a stack of small pills
  for currently-running subagents, color-coded by subagentType, capped at
  5 with a '+N more' overflow. Listens on onSubagentSpawned/onSubagentCompleted.
- jsonl-viewer.js: when an inline-expanded subagent block represents a still-
  running agent, start an fs.watch via the new IPC and append streamed
  entries; show a small '● live' indicator until completion. Stops the
  watch on collapse or subagent-completed.
- style.css: minimal styles for new sidebar/grid/live elements, matching
  existing palette and font weights.
When Switchboard attaches to a session that already has many subagent
files on disk (e.g. a long-running session with 100+ Agent calls), the
first walk of detectSubagentTransitions treated every existing file as a
'first sighting' and emitted subagent-spawned for each. 30s later it
emitted subagent-completed for each. Hundreds of IPC events back to back
froze the renderer UI.

Distinguish bootstrap (first walk for this session) from steady-state.
On bootstrap, record every existing file in knownSubagents silently:
- files modified in the last 60s stay in the active lifecycle (could be
  mid-run, will eventually fire subagent-completed)
- older files are marked completed immediately with no IPC

Only files that appear AFTER the bootstrap walk fire subagent-spawned.
…lity

Subagent observability: hierarchy, transitions, badges, live tail (fork CI)
…om disk

The existing 'Hide worktree' affordance only added the path to hiddenProjects;
on-disk worktrees accumulated. This adds a real delete action.

- main.js: new IPC delete-worktree(path). Validates the path matches the
  worktree-layout regex (.claude/worktrees, .claude-worktrees, .worktrees),
  runs 'git worktree remove -f' via execFile (no shell), retries with '-f -f'
  on locked worktrees, then purges any matching session_cache rows and FTS
  entries. Returns { ok, removed } / { ok: false, error }.
- preload.js: window.api.deleteWorktree binding.
- sidebar.js: 'Delete worktree' button on worktree sub-groups, with confirm
  dialog. Keeps 'Hide worktree' for the cosmetic case.

Hide is unchanged; Delete is opt-in and confirmed before running.
Makes it possible to tell the dev build apart from a released installed
binary in the OS task switcher, dock, About menu, and window title.
No-op in packaged builds.
Adds 14 unit tests for two load-bearing behaviours shipped via PR#1/#2
that previously had no coverage:

- derive-project-path.js — resolveWorktreePath collapses worktree paths
  (.worktrees, .claude-worktrees, .claude/worktrees) onto the parent
  project; deriveProjectPath end-to-end via stubbed jsonl
- session-transitions.js — bootstrap silent-init (no spawn/complete
  events on first walk), post-bootstrap spawn detection, completion
  timing
…ons-worktree

test: coverage for resolveWorktreePath + subagent cold-start
refreshFolder used a linear scan of cachedMap for every on-disk file
(O(N²) on a folder with N cached sessions). For projects with thousands
of subagent transcripts this froze the main process on every fs.watch
flush — fs.watch fires often while live Claude sessions append JSONL,
so the freeze recurred every 500ms (the debounce interval).

Build an inverted filePath → dbId index once and look up O(1) per file.

Confirmed: 24/24 tests still pass.
Even with the O(1) lookup, refreshFolder still ran enumerateSessionFiles
(many readdirSyncs) and fs.statSync on every file in the folder on
every flush. For projects with thousands of cached subagents and many
concurrent active agents writing JSONL, the main process kept blocking
on syscalls every 500ms.

The watcher already knows which file changed. Plumb that information
through to refreshFolder via opts.files; in targeted mode, skip
enumerateSessionFiles entirely and only stat the dirty paths.

- main.js: pendingFolders (Set) → pendingChanges (Map<folder, Set<relPath>|true>)
- session-cache.js: refreshFolder(folder, { files }) — when files is a
  non-empty Set, scan only those entries; otherwise full walk
- Targeted-mode deletion: rely on per-file ENOENT in statSync, skip the
  whole-folder GC sweep (no longer accurate when we only saw a subset)
- Falls back to full walk for folder-level events / bootstrap

24/24 tests still pass.
Live Claude session JSONLs grow without bound — observed a 218 MB
host-session file in a real workload. refreshFolder ran
fs.readFileSync + JSON.parse on the full file every time the
watcher fired (every ~500 ms while the session was being written),
making the main process freeze for 1-2 seconds at a time.

When a file is already cached and now exceeds HUGE_FILE_BYTES
(5 MB), skip the re-read entirely: just bump the modified timestamp
in the DB so the sidebar shows activity. Summary/slug/title were
captured when the file was smaller and rarely change after the first
turn; the next cold start or a shrink below threshold refreshes them.

Adds db.touchCachedModified(sessionId, modified) — a one-row UPDATE
that avoids the 14-column upsert when only mtime matters.

24/24 tests still pass.
…w ones

User feedback: "for display we shouldn't need to go that deep into the
file — full read should only happen for search". Implements that split.

readSessionDisplayHeader: stream-reads the first ~256 KB / 500 lines and
extracts only what the sidebar needs (summary, slug, customTitle,
aiTitle, agentId, isSidechain marker, subagent sidecar). No textContent,
no messageCount. ~1 ms even for a 200 MB host-session JSONL.

refreshFolder flow:
- NEW file (no cache row) → full readSessionFile, seeds FTS body
- EXISTING file → header-only refresh, merges fresh display fields with
  cached body. NO FTS write — search index for live sessions lags until
  cold-start
- Header fails → mtime-only touch as last resort

cacheGetByFolder widened to SELECT * so refresh can merge unchanged
fields (created, messageCount, textContent) without re-reading.

Drops the HUGE_FILE_BYTES hack from the previous commit — the header
approach handles size uniformly so no special-casing.

24/24 tests still pass.
The header-only refresh leaves search_fts stale for active sessions —
content typed after the last cold-start isn't indexed. Adds a user
trigger that runs the full worker re-scan (which rewrites FTS from
the live JSONL tails) and then re-fires the current query.

UI
  - New refresh button inside the search bar (circular-arrow icon)
  - Enter in the search input triggers the same path
  - Spinner on the button while reindexing
  - Search debounce bumped from 200ms to 350ms (gentler under heavy
    concurrent workloads)

Wiring
  - main.js: ipcMain.handle('rebuild-cache') → populateCacheViaWorker
  - preload.js: window.api.rebuildCache()
  - app.js: runSearchQuery() extracted; triggerRebuildAndSearch()
    serialises rebuild + refire and guards against double-clicks

24/24 tests still pass.
Renderer threw ReferenceError on every project iteration because the
result destructure at sidebar.js:438 was missing 'subagentIndex' even
though processProjectSessions returns it and buildSessionsList expects
it at line 477. The error aborted the project loop, leaving the
sidebar blank while the backend correctly returned 13 projects / 1500+
sessions. Pre-existing bug from PR#2's hierarchical sidebar — became
visible now because every project in this workload has subagents.

Also nudges #search-refresh-btn from right:42px to right:60px so it no
longer overlaps with #search-clear (the × button at right:40px).
…efault

On long-lived projects the orphan-subagent list can grow huge (>1000 in
this session's host project) and pushes the rest of the project out of
view. Default the section to collapsed and let the user toggle it with
a click on the label. Adds a right-pointing caret that rotates 90°
when expanded and a per-project state in localStorage so the choice
sticks across reloads.

- localStorage key: 'orphanExpanded:<projectPath>' = '0' | '1'
- Default: collapsed (no key set)
- Label format: '▸ Orphan subagents <count>'
The previous commit referenced 'project.projectPath' inside
buildSessionsList, which never had 'project' in scope — runtime
ReferenceError on every render, sidebar blank again. Pass projectPath
as an argument from both call sites (regular projects and worktrees).

Also leaves the renderer console→main bridge in place under
mainWindow.webContents 'console-message' — paid for itself twice now.
Live JSONL writes fire the watcher every ~500ms, triggering
notifyRendererProjectsChanged on each flush. Even with the header-only
refresh, the renderer was re-fetching projects and running morphdom
diff over 100+ session items at that cadence, producing visible
sidebar flicker. User flagged it as 'UI glitch on left side during
refresh'.

- session-cache.js: leading-edge throttle on notifyRendererProjectsChanged,
  1.5s cooldown with trailing flush so the first change is instant but
  bursts coalesce.
- public/app.js: bump renderer debounce 300ms → 900ms. Combined with the
  main-side throttle the sidebar redraws at most ~1×/sec under heavy load.
…ookup

perf(refresh): O(1) cached lookup in refreshFolder
Wraps npm scripts under task(1) with named tasks (install, dev, build,
test, lint, check, ci, clean, db:reset, install:lint). Updates README
with a Tooling section documenting the preferred workflow.
JeanBaptisteRenard and others added 9 commits May 22, 2026 14:09
Layer 1 — ESLint 9 flat config (eslint.config.js) with no-undef enforced
on renderer (public/*.js) and main-process files. Verified statically
catches the two recent sidebar.js regressions:
  - undeclared subagentIndex in renderProjects destructure
  - out-of-scope projectPath in buildSessionsList orphan branch
Wired via "lint" + "pretest" scripts; npm test now lints first.

Layer 2 — jsdom-backed renderer tests (test/dom-setup.js, test/dom-sidebar.test.js):
  - bootstraps a JSDOM window, evaluates utils.js / icons.js / sidebar.js
    in its VM context, stubs cross-file globals (window.api, sessionMap,
    activePtyIds, etc.)
  - sample fixture: 2 top-level sessions, 3 subagents (1 orphan),
    1 starred, 1 archived
  - 7 tests covering: structural completeness, starred/archived classes,
    subagent carets, orphan group, projectPath localStorage key, empty
    project, idempotent re-render

Both bug-class regressions are now caught at lint time AND runtime.
Existing 24+ tests still green (npm test → 32 pass).

Notes:
  - Pre-existing no-unused-vars / no-redeclare warnings (204) left as
    warnings, not errors; not in scope.
  - One stray no-undef (_shellProfiles in main.js) annotated with
    eslint-disable + TODO; appears to be dead code from a refactor.
Adds husky pre-commit hook that runs `task check` when task is on PATH,
falling back to `npm run lint && npm test` otherwise. Stops bad code
from reaching origin — the two ReferenceError regressions we just fixed
(subagentIndex undefined, project.projectPath out of scope) would have
been caught locally by `task lint` before commit.

Set SKIP_TESTS=1 to skip the heavier test suite in a pinch.
Two electron instances on the same ~/.switchboard/switchboard.db race on
session_cache and crash silently. This blocked any agent that wanted to
launch dev electron while the user's installed AppImage was running.

- db.js: honour SWITCHBOARD_DATA_DIR env var, fall back to ~/.switchboard.
  Skip the legacy ~/.claude/browser/ migration when a custom dir is set so a
  fresh dev DB doesn't steal the AppImage's old data.
- main.js: !app.isPackaged → default the env var to ~/.switchboard-dev before
  db.js is required. Explicit env var overrides (test sandbox / agent run).
- Taskfile.yaml: 'task dev' sets the env var for belt-and-suspenders; new
  'task db:reset:dev' wipes just the dev DB.

Result: dev electron and installed AppImage coexist safely.
chore: Taskfile + ESLint + jsdom tests + DB isolation env var
…json (#7)

Claude's /stats command only counts parent JSONL files and rotates them
off disk, making the heatmap blind to subagent sessions and older months.
Switch to an in-DB aggregate (getDailyActivity) that covers everything
Switchboard has indexed — parents and subagents alike.

- db.js: add getDailyActivity() — GROUP BY date over session_cache
- main.js: add get-stats-from-db IPC handler; rewrite refresh-stats to
  drop the /stats PTY spawn (now redundant); remove dead runClaude helper
- preload.js: expose getStatsFromDb to renderer
- public/stats-view.js: consume DB stats; update data-source notice text
- test/db-daily-activity.test.js: 6 tests covering aggregation logic and
  stats-object shape (better-sqlite3 ABI incompatibility with plain node
  means the SQL path is covered via a pure-JS mirror of the query)
`detectSubagentTransitions` now accepts an optional 4th `now` parameter
(defaulting to `Date.now()`). All internal references use this value, so
tests can freeze the clock and remove the wall-clock margins that could
flip the bootstrap boundary on slow CI runners (>5 s GC pauses).

Call sites in `session-transitions.js` itself (the only production caller)
pass nothing, preserving existing behavior. The 6 existing tests now
thread an explicit `now` through `seedAgents()` and the function call.
The GC sweep in `detectSubagentTransitions` (purges completed entries
after 5 minutes) had zero coverage. Add a test that pre-seeds both an
out-of-TTL ghost entry and an in-TTL keeper, then verifies the ghost is
deleted while the keeper survives.
…tion

The post-bootstrap spawn test only verified parentSessionId/agentId; the
sidecar .meta.json payload (subagentType, description) was never asserted
because no meta file was written. Add a real .meta.json with
{agentType, description} next to the agent jsonl and assert both fields
propagate through the IPC payload.
`init()` requires the full context object signature, but
`detectSubagentTransitions` only reads `getMainWindow` and `log`. The
other three (PROJECTS_DIR, activeSessions, rekeyMcpServer) are consumed
exclusively by `detectSessionTransitions`, which these tests don't
exercise. Add a single comment block so future readers don't waste time
hunting for stubs they need to enrich.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant