# Cache Staleness Fix — resolveSessionHistory Validation

> **For Agent:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task.

**Goal:** Fix two bugs where conduit serves incomplete session history and shows stuck "processing" status after daemon restarts or SSE gaps.

**Architecture:** Add a lightweight validation step to `resolveSessionHistory()` in `session-switch.ts` that compares the cache's message count against OpenCode's actual message count. When the cache is stale, fall back to the REST API. Inject a synthetic `done` event into the cache when serving an idle session whose cache lacks one.

**Tech Stack:** TypeScript, Vitest, existing `SessionSwitchDeps` interface

---

## Background

### Root cause (investigated and confirmed against a live session)

1. **Missing messages:** The JSONL cache is append-only and only contains events the relay observed in real-time via SSE or the message poller. When events are missed (SSE gap, daemon restart, poller idle timeout), the cache is permanently incomplete. `classifyHistorySource()` sees `user_message`/`delta` in the cache and returns `"cached-events"`, bypassing the REST fallback that would have the full history.

2. **Stuck "processing":** The `done` event is synthetically generated by the monitoring reducer on busy→idle transition. After a daemon restart, the status poller's first poll establishes idle as the baseline — no transition detected, no `done` generated. The cache (loaded from JSONL) lacks `done`, so the frontend shows a stale processing state. The separate `status: idle` message IS sent, but the frontend's chat renderer also depends on the `done` event in the event stream to close tool cards and streaming indicators (`handleDone` in `chat.svelte.ts:546-577` calls `registry.finalizeAll(getMessages())`, and the replay loop in `ws-dispatch.ts:637` uses `done` to set `llmActive = false`).

### Why the fix belongs here

The fork-resilience branch has already centralized all session switch logic into `resolveSessionHistory()` + `switchClientToSession()` in `session-switch.ts`. There is exactly ONE place to add validation, and an existing regression test (`regression-server-cache-pipeline.test.ts:297-343`) documents the bug. The fix converts that documentation test into a passing test.

### Key constraint

The validation must be cheap. `getSession()` is a single REST call returning ~200 bytes of metadata including `time.updated` — but it adds latency. Alternatively, comparing the cache's last `result` event count against `getMessages().length` is exact but requires fetching all messages. The plan uses a **message count comparison** via the `getMessages()` call that the poller seed already makes (line 210-211 of `session-switch.ts`), so we hoist that fetch and reuse it for both validation and seeding.

### `messageId` field on relay events

The `messageId` field is **optional** on all relay event types that carry it (`delta`, `tool_start`, `tool_executing`, `tool_result`, `thinking_*` — see `shared-types.ts:254-277`). The event translator includes it only when `properties.messageID` is non-null (`event-translator.ts:117-119`). **Poller-synthesized** events always have `messageId` (from `msg.id`), but SSE-path events may lack it. The `countUniqueMessages` function is therefore a **conservative heuristic** — it may undercount SSE-only sessions, triggering an extra REST fallback. This is safe (correctness preserved, latency cost only on session view).

The `result` event type has `sessionId` but **no** `messageId` field (`shared-types.ts:300-305`). The `user_message` type has only `text` — no `messageId` either (`shared-types.ts:402`).

---

### Task 1: Add `getMessageCount` to SessionSwitchDeps and resolveSessionHistory signature

**Files:**
- Modify: `src/lib/session/session-switch.ts:42-73` (SessionSwitchDeps interface)
- Modify: `src/lib/session/session-switch.ts:144-164` (resolveSessionHistory)
- Modify: `src/lib/handlers/session.ts:135-146` (toSessionSwitchDeps)
- Modify: `src/lib/bridges/client-init.ts:101-110` (inline deps construction)
- Test: `test/unit/session/session-switch.test.ts`

**Step 1: Update `createMinimalDeps` to include `client`**

In `test/unit/session/session-switch.test.ts`, update `createMinimalDeps` (line 161-177) to return `client` in its return type and accept it in overrides:

```typescript
function createMinimalDeps(
	overrides?: Partial<
		Pick<SessionSwitchDeps, "messageCache" | "sessionMgr" | "log" | "client">
	>,
): Pick<SessionSwitchDeps, "messageCache" | "sessionMgr" | "log" | "client"> {
	return {
		messageCache: { getEvents: vi.fn().mockReturnValue(null) },
		sessionMgr: {
			loadPreRenderedHistory: vi.fn().mockResolvedValue({
				messages: [],
				hasMore: false,
			}),
		},
		client: { getMessages: vi.fn().mockResolvedValue([]) },
		log: { info: vi.fn(), warn: vi.fn() },
		...overrides,
	};
}
```

**Step 2: Write the failing tests**

Add to the `resolveSessionHistory` describe block in `test/unit/session/session-switch.test.ts`:

```typescript
it("falls back to REST when cache has fewer messages than OpenCode", async () => {
	// Cache has events from 3 user turns, but OpenCode has 10 messages total
	const cachedEvents: RelayMessage[] = [
		{ type: "user_message", text: "Turn 1" },
		{ type: "delta", text: "Response 1", messageId: "msg_asst1" },
		{ type: "user_message", text: "Turn 2" },
		{ type: "delta", text: "Response 2", messageId: "msg_asst2" },
		{ type: "user_message", text: "Turn 3" },
		{ type: "delta", text: "Response 3", messageId: "msg_asst3" },
	];
	const restHistory = {
		messages: Array.from({ length: 10 }, (_, i) => ({
			id: `m${i}`,
			role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
		})),
		hasMore: false,
		total: 10,
	};
	const deps = createMinimalDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(cachedEvents) },
		sessionMgr: {
			loadPreRenderedHistory: vi.fn().mockResolvedValue(restHistory),
		},
		client: {
			getMessages: vi.fn().mockResolvedValue([]),
			getMessageCount: vi.fn().mockResolvedValue(10),
		},
	});

	const result = await resolveSessionHistory("ses_stale", deps);

	expect(result.kind).toBe("rest-history");
	if (result.kind === "rest-history") {
		expect(result.history.total).toBe(10);
	}
});

it("uses cache when message count matches OpenCode", async () => {
	// Cache has 1 user_message + 1 delta with messageId = 2 unique messages
	// OpenCode also has 2 messages (1 user + 1 assistant)
	const cachedEvents: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "hi there", messageId: "msg_asst1" },
	];
	const deps = createMinimalDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(cachedEvents) },
		client: {
			getMessages: vi.fn().mockResolvedValue([]),
			getMessageCount: vi.fn().mockResolvedValue(2),
		},
	});

	const result = await resolveSessionHistory("ses_fresh", deps);

	expect(result.kind).toBe("cached-events");
	expect(deps.sessionMgr.loadPreRenderedHistory).not.toHaveBeenCalled();
});

it("uses cache when getMessageCount is unavailable (graceful degradation)", async () => {
	const cachedEvents: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "hi" },
	];
	const deps = createMinimalDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(cachedEvents) },
		// client has no getMessageCount — graceful degradation
	});

	const result = await resolveSessionHistory("ses_no_count", deps);

	expect(result.kind).toBe("cached-events");
});

it("uses cache when getMessageCount rejects (network error)", async () => {
	const cachedEvents: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "hi" },
	];
	const deps = createMinimalDeps({
		client: {
			getMessages: vi.fn().mockResolvedValue([]),
			getMessageCount: vi.fn().mockRejectedValue(new Error("timeout")),
		},
		messageCache: { getEvents: vi.fn().mockReturnValue(cachedEvents) },
	});

	const result = await resolveSessionHistory("ses_err", deps);

	// Graceful degradation: serve stale cache rather than nothing
	expect(result.kind).toBe("cached-events");
});

it("uses cache for user-message-only session (no assistant response yet)", async () => {
	// User just sent a message, assistant hasn't responded
	const cachedEvents: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
	];
	const deps = createMinimalDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(cachedEvents) },
		client: {
			getMessages: vi.fn().mockResolvedValue([]),
			getMessageCount: vi.fn().mockResolvedValue(1),
		},
	});

	const result = await resolveSessionHistory("ses_pending", deps);

	expect(result.kind).toBe("cached-events");
});
```

**NOTE:** Existing tests that call `resolveSessionHistory` with the old `createMinimalDeps` (without `client`) will now receive a `client` with no `getMessageCount` — they exercise the graceful degradation path, which is correct and desirable.

**Step 3: Run tests to verify they fail**

Run: `pnpm vitest run test/unit/session/session-switch.test.ts`
Expected: New tests fail — `resolveSessionHistory` doesn't accept or use `client` in its deps slice.

**Step 4: Implement the change**

In `session-switch.ts`, update the `SessionSwitchDeps` interface to add an optional `getMessageCount` method to `client`:

```typescript
// In SessionSwitchDeps, update the client field:
readonly client: {
	getMessages(sessionId: string): Promise<unknown[]>;
	/** Fast message count for cache validation. Implementations may use getMessages().length. */
	getMessageCount?(sessionId: string): Promise<number>;
};
```

Add the pure helper function above `resolveSessionHistory`:

```typescript
/**
 * Count unique OpenCode messages referenced in cached events.
 * Uses optional `messageId` fields from streaming/tool events (each unique
 * messageId = one assistant message), plus counts `user_message` events.
 *
 * This is a conservative heuristic — SSE-path events may lack `messageId`,
 * causing undercounting and unnecessary REST fallbacks. This is safe:
 * correctness is preserved at the cost of one extra REST call on session view.
 */
export function countUniqueMessages(events: readonly RelayMessage[]): number {
	const messageIds = new Set<string>();
	let userMessageCount = 0;
	for (const e of events) {
		if (e.type === "user_message") {
			userMessageCount++;
		} else if ("messageId" in e && typeof e.messageId === "string" && e.messageId) {
			messageIds.add(e.messageId);
		}
	}
	return messageIds.size + userMessageCount;
}
```

Update `resolveSessionHistory` signature and implementation — widen the deps Pick to include `client`:

```typescript
export async function resolveSessionHistory(
	sessionId: string,
	deps: Pick<SessionSwitchDeps, "messageCache" | "sessionMgr" | "log" | "client">,
): Promise<SessionHistorySource> {
	const events = deps.messageCache.getEvents(sessionId);
	const classification = classifyHistorySource(events);

	if (classification === "cached-events" && events) {
		// Validate cache completeness if getMessageCount is available
		if (deps.client?.getMessageCount) {
			try {
				const actualCount = await deps.client.getMessageCount(sessionId);
				const cachedCount = countUniqueMessages(events);
				if (cachedCount < actualCount) {
					deps.log.info(
						`Cache stale for ${sessionId.slice(0, 12)}: cache covers ${cachedCount}/${actualCount} messages — falling back to REST`,
					);
					// Fall through to REST below
				} else {
					return { kind: "cached-events", events };
				}
			} catch {
				// Validation failed — serve cache rather than nothing
				return { kind: "cached-events", events };
			}
		} else {
			return { kind: "cached-events", events };
		}
	}

	try {
		const history = await deps.sessionMgr.loadPreRenderedHistory(sessionId);
		return { kind: "rest-history", history };
	} catch (err) {
		deps.log.warn(
			`Failed to load history for ${sessionId}: ${err instanceof Error ? err.message : err}`,
		);
		return { kind: "empty" };
	}
}
```

**Step 5: Run tests to verify they pass**

Run: `pnpm vitest run test/unit/session/session-switch.test.ts`
Expected: All tests pass.

**Step 6: Wire `getMessageCount` in deps construction sites**

There are **two** sites where `SessionSwitchDeps` are constructed:

**6a.** In `src/lib/handlers/session.ts` — `toSessionSwitchDeps()` at line 135. The `deps.client` already has `getMessages`. Add `getMessageCount`:

```typescript
function toSessionSwitchDeps(deps: HandlerDeps): SessionSwitchDeps {
	return {
		messageCache: deps.messageCache,
		sessionMgr: deps.sessionMgr,
		wsHandler: deps.wsHandler,
		statusPoller: deps.statusPoller,
		pollerManager: deps.pollerManager,
		client: {
			getMessages: (sid: string) => deps.client.getMessages(sid),
			getMessageCount: (sid: string) => deps.client.getMessages(sid).then((m) => m.length),
		},
		log: deps.log,
		getInputDraft: getSessionInputDraft,
	};
}
```

If `deps.client` is already passed directly (`client: deps.client` at line 142), wrap it to add the `getMessageCount` method.

**6b.** In `src/lib/bridges/client-init.ts` at line 101-110. This site uses `skipPollerSeed: true` and passes `client` with a type assertion. Since `getMessageCount` is optional on `SessionSwitchDeps.client`, no change is strictly required here — it will use the graceful degradation path (serve cache without validation). However, adding it here too would validate on initial client connection:

```typescript
client: {
	getMessages: (sid: string) => client.getMessages(sid),
	getMessageCount: (sid: string) => client.getMessages(sid).then((m) => m.length),
},
```

The decision of whether to add it in `client-init.ts` depends on whether validation latency on initial connection is acceptable. It's optional — the fix still works for `view_session`/`switch_session` via the session handler path.

**Step 7: Verify full test suite**

Run: `pnpm check && pnpm lint && pnpm test:unit`
Expected: All pass.

**Step 8: Commit**

```
fix: validate cache completeness against OpenCode message count

resolveSessionHistory now compares the number of unique messages in the
cache against OpenCode's actual message count before serving cached events.
When the cache is stale (fewer messages than upstream), falls back to the
REST API for complete history. Gracefully degrades on network errors.

Wired getMessageCount in toSessionSwitchDeps (session.ts) for the
view_session / switch_session path.

Fixes: incomplete session history after daemon restarts or SSE gaps.
```

---

### Task 2: Inject synthetic `done` into stale cache on idle session view

**Files:**
- Modify: `src/lib/session/session-switch.ts:175-219` (switchClientToSession)
- Test: `test/unit/session/session-switch.test.ts`

The `done` event tells the frontend "this assistant turn is complete." The frontend replay path genuinely depends on `done` for both tool card finalization (`registry.finalizeAll` in `chat.svelte.ts:546-577`) and phase reconciliation (`phaseEndReplay` in `ws-dispatch.ts:637`). When the cache lacks a `done` but the status poller reports idle, inject a synthetic `done` into the response.

**Step 1: Write the failing tests**

Add to the `switchClientToSession` describe block:

```typescript
it("appends synthetic done to cached-events when session is idle and cache lacks done", async () => {
	const events: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "hi" },
	];
	const deps = createFullDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(events) },
		statusPoller: { isProcessing: vi.fn().mockReturnValue(false) },
	});

	await switchClientToSession(deps, "c1", "ses_1");

	const calls = vi.mocked(deps.wsHandler.sendTo).mock.calls;
	const switchMsg = calls.find(
		([, msg]) => (msg as { type: string }).type === "session_switched",
	);
	expect(switchMsg).toBeDefined();
	const payload = switchMsg?.[1] as { events?: RelayMessage[] };
	// Last event should be synthetic done
	const lastEvent = payload.events?.[payload.events.length - 1];
	expect(lastEvent).toEqual({ type: "done", code: 0 });
});

it("does NOT append done when session is processing", async () => {
	const events: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "thinking..." },
	];
	const deps = createFullDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(events) },
		statusPoller: { isProcessing: vi.fn().mockReturnValue(true) },
	});

	await switchClientToSession(deps, "c1", "ses_1");

	const calls = vi.mocked(deps.wsHandler.sendTo).mock.calls;
	const switchMsg = calls.find(
		([, msg]) => (msg as { type: string }).type === "session_switched",
	);
	const payload = switchMsg?.[1] as { events?: RelayMessage[] };
	const doneEvents = payload.events?.filter((e) => e.type === "done") ?? [];
	expect(doneEvents).toHaveLength(0);
});

it("does NOT append done when cache already has one", async () => {
	const events: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "hi" },
		{ type: "done", code: 0 },
	];
	const deps = createFullDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(events) },
		statusPoller: { isProcessing: vi.fn().mockReturnValue(false) },
	});

	await switchClientToSession(deps, "c1", "ses_1");

	const calls = vi.mocked(deps.wsHandler.sendTo).mock.calls;
	const switchMsg = calls.find(
		([, msg]) => (msg as { type: string }).type === "session_switched",
	);
	const payload = switchMsg?.[1] as { events?: RelayMessage[] };
	const doneEvents = payload.events?.filter((e) => e.type === "done") ?? [];
	expect(doneEvents).toHaveLength(1); // Original only, no duplicate
});

it("appends synthetic done when statusPoller is undefined (assumes idle)", async () => {
	const events: RelayMessage[] = [
		{ type: "user_message", text: "hello" },
		{ type: "delta", text: "hi" },
	];
	// eslint-disable-next-line @typescript-eslint/no-unused-vars
	const { statusPoller, ...rest } = createFullDeps({
		messageCache: { getEvents: vi.fn().mockReturnValue(events) },
	});
	const deps = rest as SessionSwitchDeps;

	await switchClientToSession(deps, "c1", "ses_1");

	const calls = vi.mocked(deps.wsHandler.sendTo).mock.calls;
	const switchMsg = calls.find(
		([, msg]) => (msg as { type: string }).type === "session_switched",
	);
	const payload = switchMsg?.[1] as { events?: RelayMessage[] };
	const lastEvent = payload.events?.[payload.events.length - 1];
	expect(lastEvent).toEqual({ type: "done", code: 0 });
});
```

**Step 2: Run tests to verify they fail**

Run: `pnpm vitest run test/unit/session/session-switch.test.ts`
Expected: First and last tests fail — no synthetic done injected.

**Step 3: Implement the change**

Add the pure helper to `session-switch.ts`:

```typescript
/**
 * If session is idle and cached events lack a `done` event, append a synthetic one.
 * Matches the shape used by monitoring-wiring.ts:129 and message-poller.ts:583.
 * Pure function — returns a new source or the original unchanged.
 */
export function patchMissingDone(
	source: SessionHistorySource,
	statusPoller: SessionSwitchDeps["statusPoller"],
	sessionId: string,
): SessionHistorySource {
	if (source.kind !== "cached-events") return source;
	if (statusPoller?.isProcessing(sessionId)) return source;

	const hasDone = source.events.some((e) => e.type === "done");
	if (hasDone) return source;

	return {
		kind: "cached-events",
		events: [...source.events, { type: "done", code: 0 }],
	};
}
```

In `switchClientToSession`, after resolving the source and before building the message, patch the source:

```typescript
// After the line: const source = ... (resolveSessionHistory or empty)
// Before: const message = buildSessionSwitchedMessage(...)
const patchedSource = patchMissingDone(source, deps.statusPoller, sessionId);

// Use patchedSource instead of source:
const message = buildSessionSwitchedMessage(sessionId, patchedSource, {
	...(draft ? { draft } : {}),
	...(options?.requestId != null ? { requestId: options.requestId } : {}),
});
```

**Step 4: Run tests to verify they pass**

Run: `pnpm vitest run test/unit/session/session-switch.test.ts`
Expected: All pass.

**Step 5: Verify full test suite**

Run: `pnpm check && pnpm lint && pnpm test:unit`
Expected: All pass.

**Step 6: Commit**

```
fix: inject synthetic done event for idle sessions with incomplete cache

When serving cached events for a session the status poller reports as idle,
append a synthetic done event if the cache lacks one. This prevents the
frontend from showing tool cards and streaming indicators as active after
daemon restarts or SSE gaps where the done event was never recorded.
```

---

### Task 3: Update existing regression test to verify the fix end-to-end

**Files:**
- Modify: `test/unit/relay/regression-server-cache-pipeline.test.ts:15` (add `vi` to import)
- Modify: `test/unit/relay/regression-server-cache-pipeline.test.ts:297-343` (replace test)

The existing test at line 297 documents the bug with a comment "the relay can't distinguish between a complete cache and a partial one." Replace it with an end-to-end test that uses the real `MessageCache` (already instantiated in the test file's `beforeEach`), populates it via the pipeline, then feeds it to `resolveSessionHistory` to verify the fix. Only mock what requires network access (`sessionMgr.loadPreRenderedHistory` and `client.getMessageCount`).

**Step 1: Add imports**

At line 15, update the vitest import to include `vi` (it is NOT currently imported):

```typescript
import { beforeEach, describe, expect, it, vi } from "vitest";
```

Add a static import for `resolveSessionHistory` and `countUniqueMessages`:

```typescript
import { countUniqueMessages, resolveSessionHistory } from "../../../src/lib/session/session-switch.js";
```

**Step 2: Replace the regression test**

Replace the test at line 297 with:

```typescript
it("REGRESSION: stale cache falls back to REST when message count diverges", async () => {
	// Previously: cache with partial history preempted REST API full history.
	// Now: resolveSessionHistory validates cache against upstream message count.
	const activeSession = "session-a";

	// Simulate: relay only captured turn 6 (the bug scenario from the original test)
	cache.recordEvent("session-a", { type: "user_message", text: "Turn 6" });
	processEvent(
		makePartDelta("session-a", "p1", "Response to turn 6"),
		translator,
		cache,
		activeSession,
		extractSessionId,
	);

	// classifyHistorySource still says "cached-events" (has chat content)
	const events = cache.getEvents("session-a");
	const hasChatContent =
		events?.some((e) => e.type === "user_message" || e.type === "delta") ??
		false;
	expect(hasChatContent).toBe(true);

	// Independently verify countUniqueMessages — cache has 1 user_message +
	// 1 delta with messageId "msg1" (from makePartDelta) = 2 unique messages
	expect(countUniqueMessages(events!)).toBe(2);

	// But OpenCode has 12 messages (turns 1-6, each with user + assistant)
	const fullHistory = {
		messages: Array.from({ length: 12 }, (_, i) => ({
			id: `m${i}`,
			role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
		})),
		hasMore: false,
		total: 12,
	};

	// Use the real cache, only mock network-bound calls
	const result = await resolveSessionHistory("session-a", {
		messageCache: cache,
		sessionMgr: {
			loadPreRenderedHistory: vi.fn().mockResolvedValue(fullHistory),
		},
		client: {
			getMessages: vi.fn().mockResolvedValue([]),
			getMessageCount: vi.fn().mockResolvedValue(12),
		},
		log: { info: vi.fn(), warn: vi.fn() },
	});

	// Fix: falls back to REST — all 12 messages visible
	expect(result.kind).toBe("rest-history");
	if (result.kind === "rest-history") {
		expect(result.history.total).toBe(12);
	}
});
```

**Step 3: Run test to verify it passes**

Run: `pnpm vitest run test/unit/relay/regression-server-cache-pipeline.test.ts`
Expected: All pass (new test passes because Task 1 implemented the fix).

**Step 4: Verify full test suite**

Run: `pnpm check && pnpm lint && pnpm test:unit`
Expected: All pass.

**Step 5: Commit**

```
test: convert cache staleness regression from bug-doc to passing end-to-end test

Updates the REGRESSION test in regression-server-cache-pipeline.test.ts
to exercise the full pipeline: populate cache via processEvent, then
verify resolveSessionHistory detects the stale cache and falls back to
REST. Uses real MessageCache, only mocks network-bound calls.
```

---

## Notes for the implementing agent

- **`countUniqueMessages` uses `"messageId" in e`**: This leverages TypeScript's `in` narrowing to safely access the optional `messageId` field without casting to `Record<string, unknown>`. The field exists on `delta`, `thinking_*`, `tool_start`, `tool_executing`, and `tool_result` relay event types — all with type `messageId?: string`.

- **`messageId` is optional on SSE-path events**: The translator only includes it when `properties.messageID` is non-null (`event-translator.ts:117-119`). Poller-synthesized events always have it. The counting function is conservative — undercounting triggers a harmless REST fallback.

- **The `getMessageCount` implementation**: In `toSessionSwitchDeps` (`session.ts:135`), implement as `client.getMessages(sid).then(m => m.length)`. This piggybacks on an existing REST call. It's not zero-cost, but `resolveSessionHistory` is only called on session view/switch — not on every event.

- **Don't clear/invalidate the JSONL cache file**: The fix is read-path only. The cache file remains append-only. When the REST fallback is used, the stale cache persists on disk but won't be served because validation will keep failing until new events fill the gap.

- **`client-init.ts` graceful degradation**: The client init path (`bridges/client-init.ts`) does not wire `getMessageCount`. Since it's optional on `SessionSwitchDeps.client`, the graceful degradation path serves the cache without validation on initial connection. This is acceptable — validation happens on subsequent `view_session`/`switch_session` calls via the session handler.
