mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
f916cb1d70
commit
876bc35e9e
15 changed files with 284 additions and 115 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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, []))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -357,18 +357,23 @@ which governs mutation of live logs, not their removal.
|
|||
|
||||
## 10. Event forwarding and live UI
|
||||
|
||||
1. For every `advanceTurn` it initiates, the session layer consumes
|
||||
`TurnExecution.events` and forwards each `TurnStreamEvent` — tagged with
|
||||
`sessionId` — through the application bus to renderer windows over one
|
||||
IPC channel (`sessions:events`).
|
||||
1. Live turn delivery is not the session layer's job: the turn runtime
|
||||
publishes every turn's events to the process-wide turn event bus
|
||||
(turn-runtime-design.md §17.1), which the app layer bridges to renderer
|
||||
windows over one IPC channel (`turns:events` — durable events broadcast
|
||||
with their file offsets; deltas only to windows subscribed to that turn).
|
||||
The session layer drains each `TurnExecution.events` it initiates so an
|
||||
unconsumed stream never buffers, and `sessions:events` carries only
|
||||
`session-index-changed` entries.
|
||||
2. When `outcome` settles, the session layer updates the index entry and
|
||||
publishes `session-index-changed`.
|
||||
3. The renderer follows the turn spec's historical/live pattern: fetch
|
||||
turns via `getTurn`, run the shared `reduceTurn` per turn, compose the
|
||||
session timeline turn-by-turn (each turn renders its input and its own
|
||||
activity; the referenced prefix is never re-rendered from context),
|
||||
append live durable events and re-reduce, and keep text/reasoning deltas
|
||||
in an ephemeral overlay cleared by canonical responses.
|
||||
join live durable events by file offset (drop covered, append
|
||||
contiguous, refetch on gap) and re-reduce, and keep text/reasoning
|
||||
deltas in an ephemeral overlay cleared by canonical responses.
|
||||
4. Pending approvals and ask-human prompts render from the suspended turn's
|
||||
reduced state, so they survive restarts without any session-layer
|
||||
bookkeeping.
|
||||
|
|
|
|||
|
|
@ -1435,9 +1435,10 @@ stream for the initiating caller.
|
|||
events with `offset <= snapshot.length` — no gaps, no duplicates, no
|
||||
sequence numbers in the durable schema.
|
||||
- Text/reasoning deltas are published without an offset (they are not
|
||||
durable). The app-layer IPC bridge (`turns:events`) forwards durable
|
||||
events only in v1; deltas still reach session chat via the session-layer
|
||||
forwarding. Scoping delta delivery to subscribed turns is future work.
|
||||
durable). The app-layer IPC bridge (`turns:events`) broadcasts durable
|
||||
events to every window; deltas are forwarded only to windows that
|
||||
declared they are watching that turn (`turns:subscribe` /
|
||||
`turns:unsubscribe`, a per-webContents registry in the app layer).
|
||||
- The bus is ephemeral and observational, like the lifecycle bus: listener
|
||||
errors are swallowed, nothing durable depends on delivery, and a crash
|
||||
losing listeners accurately reflects that no execution is known active.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionIndexEntry } from "@x/shared/dist/sessions.js";
|
||||
import type { TurnStreamEvent } from "@x/shared/dist/turns.js";
|
||||
import { EmitterSessionBus } from "../sessions/bus.js";
|
||||
import { TurnEventHub } from "../turns/event-hub.js";
|
||||
import { TurnInputError } from "../turns/api.js";
|
||||
import type { ISessions } from "../sessions/api.js";
|
||||
import { ChannelBridge, type ModelChoice } from "./bridge.js";
|
||||
|
|
@ -50,7 +50,7 @@ function askEvent(turnId: string, question: string, options?: string[]): TurnStr
|
|||
|
||||
interface Harness {
|
||||
bridge: ChannelBridge;
|
||||
bus: EmitterSessionBus;
|
||||
bus: TurnEventHub;
|
||||
replies: string[];
|
||||
reply: (text: string) => Promise<void>;
|
||||
sessions: {
|
||||
|
|
@ -72,9 +72,9 @@ const MODELS: ModelChoice[] = [
|
|||
];
|
||||
|
||||
function harness(entries: SessionIndexEntry[] = []): Harness {
|
||||
const bus = new EmitterSessionBus();
|
||||
const bus = new TurnEventHub();
|
||||
const publish = (turnId: string, event: TurnStreamEvent) =>
|
||||
bus.publish({ kind: "turn-event", sessionId: "s1", turnId, event });
|
||||
bus.publish({ turnId, sessionId: "s1", event });
|
||||
const sessions = {
|
||||
createSession: vi.fn(async () => "s1"),
|
||||
sendMessage: vi.fn(async () => ({ turnId: "t1" })),
|
||||
|
|
@ -86,7 +86,7 @@ function harness(entries: SessionIndexEntry[] = []): Harness {
|
|||
const listModels = vi.fn(async () => MODELS);
|
||||
const bridge = new ChannelBridge({
|
||||
sessions: sessions as unknown as ISessions,
|
||||
sessionBus: bus,
|
||||
turnEventBus: bus,
|
||||
listModels,
|
||||
});
|
||||
const replies: string[] = [];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { assistantText, lastAssistantText } from "../agents/headless.js";
|
|||
import { TurnInputError } from "../turns/api.js";
|
||||
import { ASK_HUMAN_TOOL } from "../turns/bridges/real-agent-resolver.js";
|
||||
import { TurnNotSettledError, type ISessions } from "../sessions/api.js";
|
||||
import type { EmitterSessionBus } from "../sessions/bus.js";
|
||||
import type { TurnEventHub } from "../turns/event-hub.js";
|
||||
|
||||
// Transport-agnostic command layer: inbound texts from a messaging channel
|
||||
// are parsed into commands (list / resume / new / stop / status) or forwarded
|
||||
|
|
@ -136,7 +136,7 @@ export class ChannelBridge {
|
|||
constructor(
|
||||
private readonly deps: {
|
||||
sessions: ISessions;
|
||||
sessionBus: EmitterSessionBus;
|
||||
turnEventBus: TurnEventHub;
|
||||
listModels: () => Promise<ModelChoice[]>;
|
||||
},
|
||||
) {}
|
||||
|
|
@ -502,8 +502,10 @@ export class ChannelBridge {
|
|||
const buffered: Array<{ turnId: string; settled: Settled }> = [];
|
||||
let waiter: { turnId: string; resolve: (settled: Settled) => void } | null = null;
|
||||
let cancelTimer: (() => void) | null = null;
|
||||
const unsubscribe = this.deps.sessionBus.subscribe((event) => {
|
||||
if (event.kind !== "turn-event") return;
|
||||
// The turn event spine carries every turn's events (deltas included
|
||||
// for subscribed windows, but settleOf ignores those); subscribeAll
|
||||
// because the turnId is unknown until sendMessage returns.
|
||||
const unsubscribe = this.deps.turnEventBus.subscribeAll((event) => {
|
||||
const settled = settleOf(event.event);
|
||||
if (!settled) return;
|
||||
if (waiter) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { ChannelsConfig, ChannelsStatus } from "@x/shared/dist/channels.js"
|
|||
import container from "../di/container.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import type { ISessions } from "../sessions/api.js";
|
||||
import type { EmitterSessionBus } from "../sessions/bus.js";
|
||||
import type { TurnEventHub } from "../turns/event-hub.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { listGatewayModels } from "../models/gateway.js";
|
||||
import { listOnboardingModels } from "../models/models-dev.js";
|
||||
|
|
@ -98,7 +98,7 @@ function ensureBridge(): ChannelBridge {
|
|||
if (!bridge) {
|
||||
bridge = new ChannelBridge({
|
||||
sessions: container.resolve<ISessions>("sessions"),
|
||||
sessionBus: container.resolve<EmitterSessionBus>("sessionBus"),
|
||||
turnEventBus: container.resolve<TurnEventHub>("turnEventBus"),
|
||||
listModels: listBridgeModels,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -644,7 +644,10 @@ describe("stopTurn and resumeTurn", () => {
|
|||
});
|
||||
|
||||
describe("event forwarding and index maintenance (13.6, 13.7)", () => {
|
||||
it("forwards stream events to the bus tagged with sessionId in order", async () => {
|
||||
it("drains the execution stream and publishes only index updates", async () => {
|
||||
// Live turn delivery is the turn event bus's job (published by the
|
||||
// runtime itself); the session bus carries index changes only. The
|
||||
// stream must still be consumed so it never buffers until settle.
|
||||
const { sessions, fake, bus } = makeSessions();
|
||||
const streamed: TurnStreamEvent[] = [
|
||||
{ type: "text_delta", turnId: "x", modelCallIndex: 0, delta: "he" },
|
||||
|
|
@ -652,19 +655,12 @@ describe("event forwarding and index maintenance (13.6, 13.7)", () => {
|
|||
];
|
||||
fake.script = () => ({ events: streamed, outcome: completedOutcome() });
|
||||
const sessionId = await sessions.createSession();
|
||||
const { turnId } = await sessions.sendMessage(sessionId, user("go"), {
|
||||
await sessions.sendMessage(sessionId, user("go"), {
|
||||
agent: { agentId: "copilot" },
|
||||
});
|
||||
await flush();
|
||||
const turnEvents = bus.events.filter((e) => e.kind === "turn-event");
|
||||
expect(turnEvents).toEqual(
|
||||
streamed.map((event) => ({
|
||||
kind: "turn-event",
|
||||
sessionId,
|
||||
turnId,
|
||||
event,
|
||||
})),
|
||||
);
|
||||
expect(bus.events.length).toBeGreaterThan(0);
|
||||
expect(bus.events.every((e) => e.kind === "index-changed")).toBe(true);
|
||||
});
|
||||
|
||||
it("outcome settlement updates the index entry's latest turn status", async () => {
|
||||
|
|
|
|||
|
|
@ -343,9 +343,11 @@ export class SessionsImpl implements ISessions {
|
|||
await execution.outcome;
|
||||
}
|
||||
|
||||
// Every advance this layer initiates: forward its stream to the bus
|
||||
// tagged with sessionId, keep the abort controller for stopTurn, and
|
||||
// update the index entry when the outcome settles.
|
||||
// Every advance this layer initiates: keep the abort controller for
|
||||
// stopTurn and update the index entry when the outcome settles. Live
|
||||
// event delivery is not this layer's job — the runtime publishes every
|
||||
// event to the turn event bus; the execution stream is drained so an
|
||||
// unconsumed HotStream never buffers events until settle.
|
||||
private startTrackedAdvance(
|
||||
sessionId: string | null,
|
||||
turnId: string,
|
||||
|
|
@ -368,14 +370,7 @@ export class SessionsImpl implements ISessions {
|
|||
void (async () => {
|
||||
try {
|
||||
for await (const event of execution.events) {
|
||||
if (sessionId !== null) {
|
||||
this.sessionBus.publish({
|
||||
kind: "turn-event",
|
||||
sessionId,
|
||||
turnId,
|
||||
event,
|
||||
});
|
||||
}
|
||||
void event;
|
||||
}
|
||||
} catch {
|
||||
// Infrastructure failures surface through the outcome.
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ const ipcSchemas = {
|
|||
},
|
||||
// ── 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.
|
||||
// the turns:events feed and the shared reduceTurn reducer.
|
||||
'sessions:create': {
|
||||
req: z.object({ title: z.string().optional() }),
|
||||
res: z.object({ sessionId: z.string() }),
|
||||
|
|
@ -545,12 +545,24 @@ const ipcSchemas = {
|
|||
},
|
||||
// Process-wide turn event spine: every turn's durable events (with file
|
||||
// offsets), regardless of who started the turn — session chat, headless
|
||||
// background/knowledge runners, spawned sub-agents. Deltas are not
|
||||
// broadcast here in v1; session chat streams them via sessions:events.
|
||||
// background/knowledge runners, spawned sub-agents. Text/reasoning deltas
|
||||
// ride the same channel but only reach windows that subscribed to that
|
||||
// turn via turns:subscribe.
|
||||
'turns:events': {
|
||||
req: z.custom<TurnBusEvent>(),
|
||||
res: z.null(),
|
||||
},
|
||||
// Per-window delta subscription: deltas are high-volume and ephemeral, so
|
||||
// they cross IPC only for turns this window declared it is watching.
|
||||
// Durable events are always broadcast regardless.
|
||||
'turns:subscribe': {
|
||||
req: z.object({ turnId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'turns:unsubscribe': {
|
||||
req: z.object({ turnId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'services:events': {
|
||||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
ModelDescriptor,
|
||||
type TurnStatus,
|
||||
type TurnStreamEvent,
|
||||
} from "./turns.js";
|
||||
import { ModelDescriptor, type TurnStatus } from "./turns.js";
|
||||
|
||||
// Durable session contract for the session layer (see
|
||||
// packages/core/docs/session-design.md). A session is an append-only chain
|
||||
|
|
@ -171,21 +167,14 @@ export interface SessionIndexEntry {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// What the renderer's single feed consumer receives over IPC: live turn
|
||||
// stream events (durable events + ephemeral deltas) and index updates.
|
||||
// entry: null signals deletion.
|
||||
export type SessionBusEvent =
|
||||
| {
|
||||
kind: "turn-event";
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
event: TurnStreamEvent;
|
||||
}
|
||||
| {
|
||||
kind: "index-changed";
|
||||
sessionId: string;
|
||||
entry: SessionIndexEntry | null;
|
||||
};
|
||||
// What the renderer's session-feed consumer receives over IPC: session index
|
||||
// updates only. Turn events (durable + deltas) travel on the turns:events
|
||||
// spine (TurnBusEvent in turns.ts). entry: null signals deletion.
|
||||
export type SessionBusEvent = {
|
||||
kind: "index-changed";
|
||||
sessionId: string;
|
||||
entry: SessionIndexEntry | null;
|
||||
};
|
||||
|
||||
export function sessionIndexEntry(
|
||||
state: SessionState,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue