refactor(x): one delivery path for turn events — chat, channels, deltas

Completes the turn-spine unification. Chat's SessionChatStore now
consumes turns:events like every other surface, joining live turns by
file offset (drop covered, append contiguous, refetch on gap) instead
of blind-appending session-bus events. Text/reasoning deltas cross IPC
only for turns a window subscribed to (turns:subscribe/unsubscribe, a
per-webContents registry that also survives window teardown), so
headless pipeline chatter never reaches windows that aren't watching.
The channels bridge settles turns off the turn event bus instead of
filtering the session broadcast. With no turn-event consumers left,
SessionsImpl stops forwarding entirely (it drains its execution streams
and keeps outcome/index handling) and sessions:events shrinks to
index-changed entries — one channel for turn events, one for session
metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-09 15:47:21 +05:30
parent f916cb1d70
commit 876bc35e9e
15 changed files with 284 additions and 115 deletions

View file

@ -697,9 +697,32 @@ export function startSessionsWatcher(): void {
// runtime executes (session chat, headless background/knowledge runners,
// spawned sub-agents), tagged with sessionId and the event's file offset so
// consumers can join a live turn against a sessions:getTurn snapshot without
// gaps or duplicates. Deltas are deliberately not broadcast here in v1 —
// session chat already streams them via sessions:events; scoping deltas to
// subscribed turns is a follow-up.
// gaps or duplicates. Durable events are broadcast to every window;
// text/reasoning deltas are high-volume and ephemeral, so they are sent only
// to windows that subscribed to that turn via turns:subscribe.
const turnDeltaSubs = new Map<Electron.WebContents, Set<string>>();
export function subscribeTurnDeltas(sender: Electron.WebContents, turnId: string): void {
let turnIds = turnDeltaSubs.get(sender);
if (!turnIds) {
turnIds = new Set();
turnDeltaSubs.set(sender, turnIds);
sender.once('destroyed', () => turnDeltaSubs.delete(sender));
}
turnIds.add(turnId);
}
export function unsubscribeTurnDeltas(sender: Electron.WebContents, turnId: string): void {
const turnIds = turnDeltaSubs.get(sender);
if (!turnIds) {
return;
}
turnIds.delete(turnId);
if (turnIds.size === 0) {
turnDeltaSubs.delete(sender);
}
}
let turnEventsWatcher: (() => void) | null = null;
export function startTurnEventsWatcher(): void {
if (turnEventsWatcher) {
@ -707,10 +730,15 @@ export function startTurnEventsWatcher(): void {
}
const hub = container.resolve<TurnEventHub>('turnEventBus');
turnEventsWatcher = hub.subscribeAll((event) => {
if (!isDurableTurnEvent(event.event)) {
if (isDurableTurnEvent(event.event)) {
broadcastToWindows('turns:events', event);
return;
}
broadcastToWindows('turns:events', event);
for (const [sender, turnIds] of turnDeltaSubs) {
if (turnIds.has(event.turnId) && !sender.isDestroyed()) {
sender.send('turns:events', event);
}
}
});
}
@ -1003,6 +1031,14 @@ export function setupIpcHandlers() {
await container.resolve<ISessions>('sessions').deleteSession(args.sessionId);
return { success: true };
},
'turns:subscribe': async (event, args) => {
subscribeTurnDeltas(event.sender, args.turnId);
return { success: true };
},
'turns:unsubscribe': async (event, args) => {
unsubscribeTurnDeltas(event.sender, args.turnId);
return { success: true };
},
'sessions:downloadLog': async (event, args) => {
// Concatenate the session's turn logs into one JSONL for debugging.
const sessions = container.resolve<ISessions>('sessions');

View file

@ -1,9 +1,8 @@
import { StrictMode } from 'react'
import { act, renderHook, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
import type { TurnBusEvent } from '@x/shared/src/turns.js'
import type { SessionsClient } from '@/lib/session-chat/client'
import type { SessionFeedListener } from '@/lib/session-chat/feed'
import {
assistantText,
completed,
@ -21,8 +20,9 @@ const S1 = 'sess-1'
function makeDeps() {
const calls: Array<{ method: string; args: unknown[] }> = []
let emit: SessionFeedListener = () => undefined
let emit: (event: TurnBusEvent) => void = () => undefined
let unsubscribed = 0
const deltaSubs: string[] = []
const sessions = new Map([[S1, sessionState(S1, ['turn-1'])]])
const turns = new Map([['turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1')]])
const client: SessionsClient = {
@ -54,22 +54,46 @@ function makeDeps() {
return {
deps: {
client,
subscribeFeed: (listener: SessionFeedListener) => {
subscribeTurnFeed: (listener: (event: TurnBusEvent) => void) => {
emit = listener
return () => {
unsubscribed += 1
}
},
subscribeDeltas: (turnId: string) => {
deltaSubs.push(turnId)
return () => {
const i = deltaSubs.indexOf(turnId)
if (i >= 0) deltaSubs.splice(i, 1)
}
},
},
calls,
emit: (event: SessionBusEvent) => emit(event),
emit: (event: TurnBusEvent) => emit(event),
deltaSubs,
getUnsubscribed: () => unsubscribed,
}
}
function durable(
turnId: string,
event: TurnBusEvent['event'],
offset: number,
): TurnBusEvent {
return { turnId, sessionId: S1, event, offset }
}
function delta(turnId: string, text: string): TurnBusEvent {
return {
turnId,
sessionId: S1,
event: { type: 'text_delta', turnId, modelCallIndex: 0, delta: text },
}
}
describe('useSessionChat', () => {
it('seeds from the session, follows live events, and routes actions', async () => {
const { deps, calls, emit } = makeDeps()
const { deps, calls, emit, deltaSubs } = makeDeps()
const { result } = renderHook(() => useSessionChat(S1, deps), { wrapper: StrictMode })
await waitFor(() => {
@ -78,25 +102,23 @@ describe('useSessionChat', () => {
expect(
result.current.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['q1', 'a1'])
// The window subscribed to the latest turn's deltas.
expect(deltaSubs).toEqual(['turn-1'])
// A new turn streams in over the feed.
// A new turn streams in over the turns:events spine.
act(() => {
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
emit({
kind: 'turn-event',
sessionId: S1,
turnId: 'turn-2',
event: { type: 'text_delta', turnId: 'turn-2', modelCallIndex: 0, delta: 'a2…' },
})
emit(durable('turn-2', created('turn-2', S1, user('q2')), 1))
emit(durable('turn-2', requested('turn-2', 0), 2))
emit(delta('turn-2', 'a2…'))
})
expect(result.current.latestTurnId).toBe('turn-2')
expect(result.current.chatState?.currentAssistantMessage).toBe('a2…')
expect(result.current.chatState?.isProcessing).toBe(true)
expect(deltaSubs).toEqual(['turn-2'])
act(() => {
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: completed('turn-2', 0, assistantText('a2')) })
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: turnCompleted('turn-2', 'a2') })
emit(durable('turn-2', completed('turn-2', 0, assistantText('a2')), 3))
emit(durable('turn-2', turnCompleted('turn-2', 'a2'), 4))
})
expect(result.current.chatState?.isProcessing).toBe(false)
@ -111,13 +133,14 @@ describe('useSessionChat', () => {
})
it('unsubscribes from the feed on unmount (StrictMode double-mounts included)', async () => {
const { deps, getUnsubscribed } = makeDeps()
const { deps, getUnsubscribed, deltaSubs } = makeDeps()
const { unmount } = renderHook(() => useSessionChat(S1, deps), {
wrapper: StrictMode,
})
unmount()
// StrictMode's simulated cleanup plus the real unmount: every subscribe
// was matched by an unsubscribe.
// was matched by an unsubscribe, and no delta subscription leaks.
expect(getUnsubscribed()).toBe(2)
expect(deltaSubs).toEqual([])
})
})

View file

@ -1,11 +1,22 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { ipcSessionsClient } from '@/lib/session-chat/client'
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
import { subscribeTurnFeed } from '@/lib/turn-feed'
import { SessionChatStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
// Declare "this window is watching turn X" so main forwards its deltas.
// Fire-and-forget on both edges: a lost subscribe only degrades streaming
// granularity (durable events still arrive), never correctness.
function subscribeDeltas(turnId: string): () => void {
void window.ipc.invoke('turns:subscribe', { turnId }).catch(() => undefined)
return () => {
void window.ipc.invoke('turns:unsubscribe', { turnId }).catch(() => undefined)
}
}
const defaultDeps: SessionChatStoreDeps = {
client: ipcSessionsClient,
subscribeFeed: subscribeSessionFeed,
subscribeTurnFeed,
subscribeDeltas,
}
// Thin subscription over SessionChatStore — all logic (seeding, feed events,

View file

@ -1,16 +1,16 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { ipcSessionsClient } from '@/lib/session-chat/client'
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
import { SessionListStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
import { SessionListStore, type SessionListStoreDeps } from '@/lib/session-chat/store'
const defaultDeps: SessionChatStoreDeps = {
const defaultDeps: SessionListStoreDeps = {
client: ipcSessionsClient,
subscribeFeed: subscribeSessionFeed,
}
// The session list (chat history sidebar): seeded from sessions:list, kept
// current by index-changed feed events. Logic lives in SessionListStore.
export function useSessions(deps: SessionChatStoreDeps = defaultDeps) {
export function useSessions(deps: SessionListStoreDeps = defaultDeps) {
const [store] = useState(() => new SessionListStore(deps))
useEffect(() => {
const disconnect = store.connect()

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SessionBusEvent, SessionState } from '@x/shared/src/sessions.js'
import type { SessionState } from '@x/shared/src/sessions.js'
import { isDurableTurnEvent, type TurnBusEvent } from '@x/shared/src/turns.js'
import { isChatMessage } from '@/lib/chat-conversation'
import type { SessionsClient } from './client'
import type { SessionFeedListener } from './feed'
@ -86,12 +87,16 @@ class FakeClient implements SessionsClient {
function makeStore() {
const client = new FakeClient()
let emit: SessionFeedListener = () => undefined
let emit: (event: TurnBusEvent) => void = () => undefined
let subscribed = 0
let unsubscribed = 0
// Live delta subscriptions by turn id (multiset semantics not needed: the
// store holds at most one).
const deltaSubs: string[] = []
offsetByTurn.clear()
const store = new SessionChatStore({
client,
subscribeFeed: (listener) => {
subscribeTurnFeed: (listener) => {
subscribed += 1
emit = listener
return () => {
@ -99,26 +104,44 @@ function makeStore() {
emit = () => undefined
}
},
subscribeDeltas: (turnId) => {
deltaSubs.push(turnId)
return () => {
const i = deltaSubs.indexOf(turnId)
if (i >= 0) deltaSubs.splice(i, 1)
}
},
})
const disconnect = store.connect()
return {
client,
store,
disconnect,
emit: (event: SessionBusEvent) => emit(event),
emit: (event: TurnBusEvent) => emit(event),
deltaSubs,
getSubscribed: () => subscribed,
getUnsubscribed: () => unsubscribed,
}
}
// Tags an event for the turn feed; durable events get their 1-based per-turn
// offset assigned automatically (mirroring the file line index the runtime
// stamps on the bus envelope).
const offsetByTurn = new Map<string, number>()
function turnEvent(
sessionId: string,
turnId: string,
event:
| TEvent
| { type: 'text_delta' | 'reasoning_delta'; turnId: string; modelCallIndex: number; delta: string },
): SessionBusEvent {
return { kind: 'turn-event', sessionId, turnId, event }
): TurnBusEvent {
if (!isDurableTurnEvent(event)) {
return { turnId, sessionId, event }
}
const offset = (offsetByTurn.get(turnId) ?? 0) + 1
offsetByTurn.set(turnId, offset)
return { turnId, sessionId, event, offset }
}
describe('SessionChatStore', () => {
@ -203,6 +226,35 @@ describe('SessionChatStore', () => {
).toEqual(['q1', 'a1', 'q2'])
})
it('keeps exactly one delta subscription, following the latest turn', async () => {
const { client, store, emit, deltaSubs, disconnect } = makeStore()
client.sessions.set(S1, sessionState(S1, ['turn-1']))
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1'))
await store.setSession(S1)
expect(deltaSubs).toEqual(['turn-1'])
emit(turnEvent(S1, 'turn-2', created('turn-2', S1, user('q2'))))
expect(deltaSubs).toEqual(['turn-2'])
disconnect()
expect(deltaSubs).toEqual([])
})
it('drops duplicate feed events already covered by the snapshot', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, []))
await store.setSession(S1)
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
// A replay of line 2 (e.g. snapshot/live race) must not double-apply —
// the reducer would throw on the impossible history.
emit({ turnId: 'turn-1', sessionId: S1, event: requested('turn-1', 0), offset: 2 })
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
expect(store.getSnapshot().error).toBeNull()
expect(store.getSnapshot().chatState?.isProcessing).toBe(false)
})
it('ignores events for other sessions', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, []))

View file

@ -2,11 +2,12 @@ import type { z } from 'zod'
import type { UserMessage } from '@x/shared/src/message.js'
import type { SessionBusEvent, SessionIndexEntry } from '@x/shared/src/sessions.js'
import {
isDurableTurnEvent,
reduceTurn,
type JsonValue,
type TurnBusEvent,
type TurnEvent,
type TurnState,
type TurnStreamEvent,
} from '@x/shared/src/turns.js'
import type { SendMessageConfig, SessionsClient } from './client'
import type { SessionFeedListener } from './feed'
@ -30,7 +31,12 @@ export interface SessionChatSnapshot {
export interface SessionChatStoreDeps {
client: SessionsClient
subscribeFeed: (listener: SessionFeedListener) => () => void
// The turns:events spine (durable events with offsets, plus deltas for
// turns this window subscribed to).
subscribeTurnFeed: (listener: (event: TurnBusEvent) => void) => () => void
// Declares "this window is watching turn X" so main forwards its deltas;
// returns the unsubscribe.
subscribeDeltas: (turnId: string) => () => void
}
// Framework-agnostic controller for one active session's chat. Owns all the
@ -39,7 +45,8 @@ export interface SessionChatStoreDeps {
// hook is a thin useSyncExternalStore subscription over it.
export class SessionChatStore {
private readonly client: SessionsClient
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
private readonly subscribeTurnFeed: (listener: (event: TurnBusEvent) => void) => () => void
private readonly subscribeDeltas: (turnId: string) => () => void
private feedDisconnect: (() => void) | null = null
private readonly listeners = new Set<() => void>()
@ -53,6 +60,9 @@ export class SessionChatStore {
private error: string | null = null
// Guards stale async loads after a session switch.
private generation = 0
// The turn whose deltas this window currently receives.
private deltaTurnId: string | null = null
private deltaUnsub: (() => void) | null = null
private snapshot: SessionChatSnapshot = {
sessionId: null,
@ -64,7 +74,8 @@ export class SessionChatStore {
constructor(deps: SessionChatStoreDeps) {
this.client = deps.client
this.subscribeFeed = deps.subscribeFeed
this.subscribeTurnFeed = deps.subscribeTurnFeed
this.subscribeDeltas = deps.subscribeDeltas
}
// Feed attachment is effect-managed and idempotent so React StrictMode's
@ -72,11 +83,28 @@ export class SessionChatStore {
// subscription would be torn down by the first cleanup and never restored).
connect(): () => void {
if (!this.feedDisconnect) {
this.feedDisconnect = this.subscribeFeed(this.onFeedEvent)
this.feedDisconnect = this.subscribeTurnFeed(this.onTurnEvent)
this.syncDeltas()
}
return () => {
this.feedDisconnect?.()
this.feedDisconnect = null
this.syncDeltas()
}
}
// Keep exactly one delta subscription: the latest turn, while connected.
private syncDeltas(): void {
const want =
this.feedDisconnect && this.latestEvents
? this.latestEvents[0].turnId
: null
if (want === this.deltaTurnId) return
this.deltaUnsub?.()
this.deltaUnsub = null
this.deltaTurnId = want
if (want) {
this.deltaUnsub = this.subscribeDeltas(want)
}
}
@ -99,6 +127,7 @@ export class SessionChatStore {
this.overlay = emptyOverlay()
this.error = null
this.loading = sessionId !== null
this.syncDeltas()
this.emit()
if (sessionId === null) return
@ -112,6 +141,7 @@ export class SessionChatStore {
this.priorTurns = reduced.slice(0, -1)
this.latestEvents = turns.length > 0 ? turns[turns.length - 1].events : null
this.loading = false
this.syncDeltas()
this.emit()
} catch (error) {
if (generation !== this.generation) return
@ -122,23 +152,37 @@ export class SessionChatStore {
}
}
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
if (event.kind !== 'turn-event' || event.sessionId !== this.sessionId) return
private onTurnEvent = (event: TurnBusEvent): void => {
if (this.sessionId === null || event.sessionId !== this.sessionId) return
const turnEvent = event.event
if (isDurable(turnEvent)) {
if (isDurableTurnEvent(turnEvent) && event.offset !== undefined) {
if (turnEvent.type === 'turn_created') {
// A new turn started for this session: freeze the previous latest.
this.freezeLatest()
this.latestEvents = [turnEvent]
this.overlay = emptyOverlay()
} else if (this.latestEvents && this.latestEvents[0].turnId === turnEvent.turnId) {
this.latestEvents.push(turnEvent)
this.syncDeltas()
} else if (this.latestEvents && this.latestEvents[0].turnId === event.turnId) {
// Offset join against the local log: drop already-known events,
// append the contiguous next line, refetch on a gap.
if (event.offset <= this.latestEvents.length) {
return
}
if (event.offset === this.latestEvents.length + 1) {
this.latestEvents.push(turnEvent)
} else {
void this.reloadTurn(event.turnId)
return
}
} else {
// An event for a turn we haven't seen (missed turn_created, e.g. the
// feed attached mid-turn): reconcile by refetching that turn.
void this.reloadTurn(event.turnId)
return
}
} else if (!this.latestEvents || this.latestEvents[0].turnId !== event.turnId) {
// Deltas are ephemeral: only the latest turn's deltas paint the overlay.
return
}
this.overlay = applyOverlay(this.overlay, turnEvent)
this.emit()
@ -164,6 +208,7 @@ export class SessionChatStore {
this.freezeLatest()
}
this.latestEvents = turn.events
this.syncDeltas()
this.emit()
} catch (error) {
// The next snapshot-worthy event will retry.
@ -237,10 +282,6 @@ export class SessionChatStore {
}
}
function isDurable(event: TurnStreamEvent): event is TEvent {
return event.type !== 'text_delta' && event.type !== 'reasoning_delta'
}
// ---------------------------------------------------------------------------
// Session list store
// ---------------------------------------------------------------------------
@ -250,6 +291,12 @@ export interface SessionListSnapshot {
loading: boolean
}
export interface SessionListStoreDeps {
client: SessionsClient
// sessions:events — index-changed entries for the session list.
subscribeFeed: (listener: SessionFeedListener) => () => void
}
export class SessionListStore {
private readonly client: SessionsClient
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
@ -259,7 +306,7 @@ export class SessionListStore {
private loading = true
private snapshot: SessionListSnapshot = { sessions: [], loading: true }
constructor(deps: SessionChatStoreDeps) {
constructor(deps: SessionListStoreDeps) {
this.client = deps.client
this.subscribeFeed = deps.subscribeFeed
}