feat(x): sessions IPC + renderer data layer with testable stores (stage 5a)

The wire and state layer for the UI cutover; App.tsx integration follows
separately.

- shared/ipc.ts: eleven sessions:* invoke channels + the sessions:events
  push feed (SessionBusEvent via z.custom, like runs:events). The generic
  preload bridge needs no changes.
- main: sessions:* handlers as thin pass-throughs to the DI'd sessions
  service; startSessionsWatcher forwards the session bus to all windows;
  startup awaits the session-index scan before the renderer can list.
- renderer architecture per review guidance — all logic in framework-
  agnostic, dependency-injected modules; hooks are thin
  useSyncExternalStore subscriptions; components will consume pre-digested
  view models:
  - client.ts: narrow SessionsClient over window.ipc (fakeable).
  - feed.ts: one shared sessions:events consumer with fan-out (factory
    for tests).
  - turn-view.ts: pure derivations — live overlay (deltas accumulate,
    canonical events clear), TurnState -> ConversationItem[], and the
    session chat state (permission/ask-human maps re-manufactured in the
    runs-era shapes so existing components render unchanged;
    isProcessing/isThinking contract preserved).
  - store.ts: SessionChatStore (seed via getSession/getTurn, shared
    reduceTurn over live events, prior-turn freezing, unknown-turn
    reconciliation, stale-load guard, action routing) + SessionListStore.
  - hooks/useSessionChat + useSessions: thin wrappers, deps injectable.
- renderer test infra added from scratch (vitest + jsdom +
  testing-library); 29 tests across stores, pure views, feed, and hooks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 11:10:17 +05:30
parent 1d5782fa75
commit df607ea510
17 changed files with 1840 additions and 5 deletions

View file

@ -13,7 +13,9 @@ import {
BackgroundTaskSummarySchema,
TriggersSchema,
} from './background-task.js';
import { UserMessageContent } from './message.js';
import { UserMessage, UserMessageContent } from './message.js';
import { RequestedAgent, type TurnEvent } from './turns.js';
import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js';
@ -375,6 +377,82 @@ const ipcSchemas = {
req: z.null(),
res: z.null(),
},
// ── New runtime: sessions + turns (session-design.md) ────────────────────
// Turn-mutating calls return quickly; the renderer follows progress through
// the sessions:events feed and the shared reduceTurn reducer.
'sessions:create': {
req: z.object({ title: z.string().optional() }),
res: z.object({ sessionId: z.string() }),
},
'sessions:list': {
req: z.object({}),
res: z.object({ sessions: z.array(z.custom<SessionIndexEntry>()) }),
},
'sessions:get': {
req: z.object({ sessionId: z.string() }),
res: z.custom<SessionState>(),
},
'sessions:getTurn': {
// Events are strictly validated at the repository read; typed via
// z.custom to avoid re-validating potentially large logs per IPC hop.
req: z.object({ turnId: z.string() }),
res: z.custom<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>(),
},
'sessions:sendMessage': {
req: z.object({
sessionId: z.string(),
input: UserMessage,
config: z.object({
agent: RequestedAgent,
autoPermission: z.boolean().optional(),
maxModelCalls: z.number().int().positive().optional(),
}),
}),
res: z.object({ turnId: z.string() }),
},
'sessions:respondToPermission': {
req: z.object({
turnId: z.string(),
toolCallId: z.string(),
decision: z.enum(['allow', 'deny']),
metadata: z.json().optional(),
}),
res: z.object({ success: z.literal(true) }),
},
'sessions:respondToAskHuman': {
req: z.object({
turnId: z.string(),
toolCallId: z.string(),
answer: z.string(),
}),
res: z.object({ success: z.literal(true) }),
},
'sessions:stopTurn': {
req: z.object({
turnId: z.string(),
reason: z.string().optional(),
}),
res: z.object({ success: z.literal(true) }),
},
'sessions:resumeTurn': {
req: z.object({ sessionId: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'sessions:setTitle': {
req: z.object({ sessionId: z.string(), title: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'sessions:delete': {
req: z.object({ sessionId: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'sessions:events': {
// Typed via z.custom so the renderer's `on` handler is typed without
// runtime validation (the broadcast path bypasses preload validation,
// like runs:events).
req: z.custom<SessionBusEvent>(),
res: z.null(),
},
'services:events': {
req: ServiceEvent,
res: z.null(),