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

@ -24,6 +24,8 @@ const execFileAsync = promisify(execFile);
import { RunEvent } from '@x/shared/dist/runs.js';
import { ServiceEvent } from '@x/shared/dist/service-events.js';
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
@ -634,6 +636,25 @@ export async function startRunsWatcher(): Promise<void> {
});
}
// New runtime: session bus → renderer windows (session-design.md §10).
function emitSessionEvent(event: SessionBusEvent): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('sessions:events', event);
}
}
}
let sessionsWatcher: (() => void) | null = null;
export function startSessionsWatcher(): void {
if (sessionsWatcher) {
return;
}
const sessionBus = container.resolve<EmitterSessionBus>('sessionBus');
sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event));
}
let servicesWatcher: (() => void) | null = null;
export async function startServicesWatcher(): Promise<void> {
if (servicesWatcher) {
@ -841,6 +862,51 @@ export function setupIpcHandlers() {
await runsCore.deleteRun(args.runId);
return { success: true };
},
// ── New runtime: sessions + turns ─────────────────────────
// Thin pass-throughs to the sessions service. sendMessage returns the
// turnId immediately; the turn advances in the background and the
// renderer reconciles via the sessions:events feed. Input-routing calls
// settle with that advance's outcome (the renderer fire-and-forgets).
'sessions:create': async (_event, args) => {
const sessionId = await container.resolve<ISessions>('sessions').createSession(args);
return { sessionId };
},
'sessions:list': async () => {
return { sessions: container.resolve<ISessions>('sessions').listSessions() };
},
'sessions:get': async (_event, args) => {
return container.resolve<ISessions>('sessions').getSession(args.sessionId);
},
'sessions:getTurn': async (_event, args) => {
return container.resolve<ISessions>('sessions').getTurn(args.turnId);
},
'sessions:sendMessage': async (_event, args) => {
return container.resolve<ISessions>('sessions').sendMessage(args.sessionId, args.input, args.config);
},
'sessions:respondToPermission': async (_event, args) => {
await container.resolve<ISessions>('sessions').respondToPermission(args.turnId, args.toolCallId, args.decision, args.metadata);
return { success: true };
},
'sessions:respondToAskHuman': async (_event, args) => {
await container.resolve<ISessions>('sessions').respondToAskHuman(args.turnId, args.toolCallId, args.answer);
return { success: true };
},
'sessions:stopTurn': async (_event, args) => {
await container.resolve<ISessions>('sessions').stopTurn(args.turnId, args.reason);
return { success: true };
},
'sessions:resumeTurn': async (_event, args) => {
await container.resolve<ISessions>('sessions').resumeTurn(args.sessionId);
return { success: true };
},
'sessions:setTitle': async (_event, args) => {
await container.resolve<ISessions>('sessions').setTitle(args.sessionId, args.title);
return { success: true };
},
'sessions:delete': async (_event, args) => {
await container.resolve<ISessions>('sessions').deleteSession(args.sessionId);
return { success: true };
},
'runs:downloadLog': async (event, args) => {
const runFileName = `${args.runId}.jsonl`;
if (path.basename(runFileName) !== runFileName) {

View file

@ -2,7 +2,7 @@ import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, typ
import path from "node:path";
import {
setupIpcHandlers,
startRunsWatcher,
startRunsWatcher, startSessionsWatcher,
startCodeSessionStatusWatcher,
startServicesWatcher,
startLiveNoteAgentWatcher,
@ -45,6 +45,7 @@ import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
import type { ISessions } from "@x/core/dist/sessions/index.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
@ -388,6 +389,11 @@ app.whenReady().then(async () => {
// start runs watcher
startRunsWatcher();
// New runtime: build the in-memory session index (startup scan) before the
// renderer can list sessions, then forward the session bus to windows.
await container.resolve<ISessions>('sessions').initialize();
startSessionsWatcher();
// start code-session status tracker (derives working/needs-you/idle + notifications)
startCodeSessionStatusWatcher();

View file

@ -6,7 +6,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@codemirror/language": "^6.12.3",
@ -83,6 +85,9 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
@ -91,9 +96,11 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.2.4",
"vitest": "catalog:"
}
}

View file

@ -0,0 +1,118 @@
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 { SessionsClient } from '@/lib/session-chat/client'
import type { SessionFeedListener } from '@/lib/session-chat/feed'
import {
assistantText,
completed,
completedTurnLog,
created,
requested,
sessionState,
turnCompleted,
user,
} from '@/lib/session-chat/test-fixtures'
import { isChatMessage } from '@/lib/chat-conversation'
import { useSessionChat } from './useSessionChat'
const S1 = 'sess-1'
function makeDeps() {
const calls: Array<{ method: string; args: unknown[] }> = []
let emit: SessionFeedListener = () => undefined
let unsubscribed = 0
const sessions = new Map([[S1, sessionState(S1, ['turn-1'])]])
const turns = new Map([['turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1')]])
const client: SessionsClient = {
create: async () => ({ sessionId: 'x' }),
list: async () => ({ sessions: [] }),
get: async (sessionId) => {
const state = sessions.get(sessionId)
if (!state) throw new Error('session not found')
return state
},
getTurn: async (turnId) => ({ turnId, events: turns.get(turnId) ?? [] }),
sendMessage: async (...args) => {
calls.push({ method: 'sendMessage', args })
return { turnId: 'turn-2' }
},
respondToPermission: async (...args) => {
calls.push({ method: 'respondToPermission', args })
},
respondToAskHuman: async (...args) => {
calls.push({ method: 'respondToAskHuman', args })
},
stopTurn: async (...args) => {
calls.push({ method: 'stopTurn', args })
},
resumeTurn: async () => undefined,
setTitle: async () => undefined,
delete: async () => undefined,
}
return {
deps: {
client,
subscribeFeed: (listener: SessionFeedListener) => {
emit = listener
return () => {
unsubscribed += 1
}
},
},
calls,
emit: (event: SessionBusEvent) => emit(event),
getUnsubscribed: () => unsubscribed,
}
}
describe('useSessionChat', () => {
it('seeds from the session, follows live events, and routes actions', async () => {
const { deps, calls, emit } = makeDeps()
const { result } = renderHook(() => useSessionChat(S1, deps))
await waitFor(() => {
expect(result.current.latestTurnId).toBe('turn-1')
})
expect(
result.current.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['q1', 'a1'])
// A new turn streams in over the feed.
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, [user('q2')]) })
emit({
kind: 'turn-event',
sessionId: S1,
turnId: 'turn-2',
event: { type: 'text_delta', turnId: 'turn-2', modelCallIndex: 0, delta: 'a2…' },
})
})
expect(result.current.latestTurnId).toBe('turn-2')
expect(result.current.chatState?.currentAssistantMessage).toBe('a2…')
expect(result.current.chatState?.isProcessing).toBe(true)
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') })
})
expect(result.current.chatState?.isProcessing).toBe(false)
await act(async () => {
await result.current.respondToPermission('tc1', 'deny')
await result.current.stop()
})
expect(calls).toEqual([
{ method: 'respondToPermission', args: ['turn-2', 'tc1', 'deny', undefined] },
{ method: 'stopTurn', args: ['turn-2'] },
])
})
it('unsubscribes from the feed on unmount', async () => {
const { deps, getUnsubscribed } = makeDeps()
const { unmount } = renderHook(() => useSessionChat(S1, deps))
unmount()
expect(getUnsubscribed()).toBe(1)
})
})

View file

@ -0,0 +1,36 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { ipcSessionsClient } from '@/lib/session-chat/client'
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
import { SessionChatStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
const defaultDeps: SessionChatStoreDeps = {
client: ipcSessionsClient,
subscribeFeed: subscribeSessionFeed,
}
// Thin subscription over SessionChatStore — all logic (seeding, feed events,
// reducer, overlay, action routing) lives in the store, which is unit-tested
// without React. `deps` is injectable for tests.
export function useSessionChat(
sessionId: string | null,
deps: SessionChatStoreDeps = defaultDeps,
) {
const [store] = useState(() => new SessionChatStore(deps))
useEffect(() => () => store.dispose(), [store])
useEffect(() => {
void store.setSession(sessionId)
}, [store, sessionId])
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
return useMemo(
() => ({
...snapshot,
sendMessage: store.sendMessage,
respondToPermission: store.respondToPermission,
answerAskHuman: store.answerAskHuman,
stop: store.stop,
}),
[snapshot, store],
)
}
export type SessionChat = ReturnType<typeof useSessionChat>

View file

@ -0,0 +1,32 @@
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'
const defaultDeps: SessionChatStoreDeps = {
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) {
const [store] = useState(() => new SessionListStore(deps))
useEffect(() => {
void store.load()
return () => store.dispose()
}, [store])
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
const client = deps.client
return useMemo(
() => ({
...snapshot,
createSession: (input: { title?: string } = {}) => client.create(input),
deleteSession: (sessionId: string) => client.delete(sessionId),
setTitle: (sessionId: string, title: string) => client.setTitle(sessionId, title),
}),
[snapshot, client],
)
}
export type Sessions = ReturnType<typeof useSessions>

View file

@ -0,0 +1,70 @@
import type { z } from 'zod'
import type { UserMessage } from '@x/shared/src/message.js'
import type { SessionIndexEntry, SessionState } from '@x/shared/src/sessions.js'
import type { JsonValue, RequestedAgent, TurnEvent } from '@x/shared/src/turns.js'
// Narrow, injectable surface over the sessions IPC channels so stores are
// testable with a plain fake instead of a window.ipc stub.
export interface SendMessageConfig {
agent: z.infer<typeof RequestedAgent>
autoPermission?: boolean
maxModelCalls?: number
}
export interface SessionsClient {
create(input: { title?: string }): Promise<{ sessionId: string }>
list(): Promise<{ sessions: SessionIndexEntry[] }>
get(sessionId: string): Promise<SessionState>
getTurn(turnId: string): Promise<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>
sendMessage(
sessionId: string,
input: z.infer<typeof UserMessage>,
config: SendMessageConfig,
): Promise<{ turnId: string }>
respondToPermission(
turnId: string,
toolCallId: string,
decision: 'allow' | 'deny',
metadata?: JsonValue,
): Promise<void>
respondToAskHuman(turnId: string, toolCallId: string, answer: string): Promise<void>
stopTurn(turnId: string, reason?: string): Promise<void>
resumeTurn(sessionId: string): Promise<void>
setTitle(sessionId: string, title: string): Promise<void>
delete(sessionId: string): Promise<void>
}
export const ipcSessionsClient: SessionsClient = {
create: (input) => window.ipc.invoke('sessions:create', input),
list: () => window.ipc.invoke('sessions:list', {}),
get: (sessionId) => window.ipc.invoke('sessions:get', { sessionId }),
getTurn: (turnId) => window.ipc.invoke('sessions:getTurn', { turnId }),
sendMessage: (sessionId, input, config) =>
window.ipc.invoke('sessions:sendMessage', { sessionId, input, config }),
respondToPermission: async (turnId, toolCallId, decision, metadata) => {
await window.ipc.invoke('sessions:respondToPermission', {
turnId,
toolCallId,
decision,
...(metadata === undefined ? {} : { metadata }),
})
},
respondToAskHuman: async (turnId, toolCallId, answer) => {
await window.ipc.invoke('sessions:respondToAskHuman', { turnId, toolCallId, answer })
},
stopTurn: async (turnId, reason) => {
await window.ipc.invoke('sessions:stopTurn', {
turnId,
...(reason === undefined ? {} : { reason }),
})
},
resumeTurn: async (sessionId) => {
await window.ipc.invoke('sessions:resumeTurn', { sessionId })
},
setTitle: async (sessionId, title) => {
await window.ipc.invoke('sessions:setTitle', { sessionId, title })
},
delete: async (sessionId) => {
await window.ipc.invoke('sessions:delete', { sessionId })
},
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
import { createSessionFeed, type SessionFeedListener } from './feed'
const event: SessionBusEvent = {
kind: 'index-changed',
sessionId: 's1',
entry: null,
}
describe('createSessionFeed', () => {
it('starts the source lazily on first subscribe and fans events out', () => {
let sourceStarted = 0
let push: SessionFeedListener = () => undefined
const feed = createSessionFeed((listener) => {
sourceStarted += 1
push = listener
return () => undefined
})
expect(sourceStarted).toBe(0)
const seenA: SessionBusEvent[] = []
const seenB: SessionBusEvent[] = []
feed.subscribe((e) => seenA.push(e))
feed.subscribe((e) => seenB.push(e))
expect(sourceStarted).toBe(1) // one shared IPC listener
push(event)
expect(seenA).toEqual([event])
expect(seenB).toEqual([event])
})
it('unsubscribes cleanly and isolates a throwing listener', () => {
let push: SessionFeedListener = () => undefined
const feed = createSessionFeed((listener) => {
push = listener
return () => undefined
})
const seen: SessionBusEvent[] = []
feed.subscribe(() => {
throw new Error('bad subscriber')
})
const unsubscribe = feed.subscribe((e) => seen.push(e))
push(event)
expect(seen).toEqual([event])
unsubscribe()
push(event)
expect(seen).toEqual([event])
})
})

View file

@ -0,0 +1,42 @@
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
export type SessionFeedListener = (event: SessionBusEvent) => void
export type SessionFeedSource = (listener: SessionFeedListener) => () => void
// One shared consumer of the sessions:events push channel; stores tap this
// fan-out instead of each opening their own IPC listener. Factory so tests
// can drive a fake source.
export function createSessionFeed(source: SessionFeedSource) {
const listeners = new Set<SessionFeedListener>()
let detach: (() => void) | null = null
const ensureStarted = () => {
if (detach) return
detach = source((event) => {
// Copy so (un)subscribing during dispatch is safe.
for (const listener of [...listeners]) {
try {
listener(event)
} catch {
// A misbehaving subscriber must never break the feed.
}
}
})
}
return {
subscribe(listener: SessionFeedListener): () => void {
ensureStarted()
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const appFeed = createSessionFeed((listener) => window.ipc.on('sessions:events', listener))
export function subscribeSessionFeed(listener: SessionFeedListener): () => void {
return appFeed.subscribe(listener)
}

View file

@ -0,0 +1,283 @@
import { describe, expect, it } from 'vitest'
import type { SessionBusEvent, SessionState } from '@x/shared/src/sessions.js'
import { isChatMessage } from '@/lib/chat-conversation'
import type { SessionsClient } from './client'
import type { SessionFeedListener } from './feed'
import { SessionChatStore, SessionListStore } from './store'
import {
assistantText,
completed,
completedTurnLog,
created,
indexEntry,
requested,
sessionState,
turnCompleted,
user,
type TEvent,
} from './test-fixtures'
const S1 = 'sess-1'
class FakeClient implements SessionsClient {
sessions = new Map<string, SessionState>()
turns = new Map<string, TEvent[]>()
calls: Array<{ method: string; args: unknown[] }> = []
// When set, get() defers until the returned resolver is invoked.
deferredGet: (() => void) | null = null
private record(method: string, ...args: unknown[]) {
this.calls.push({ method, args })
}
async create(input: { title?: string }) {
this.record('create', input)
return { sessionId: 'new-session' }
}
async list() {
this.record('list')
return { sessions: [...this.sessions.keys()].map((id) => indexEntry(id)) }
}
async get(sessionId: string) {
this.record('get', sessionId)
if (this.deferredGet === null) {
const state = this.sessions.get(sessionId)
if (!state) throw new Error(`session not found: ${sessionId}`)
return state
}
return new Promise<SessionState>((resolve, reject) => {
this.deferredGet = () => {
const state = this.sessions.get(sessionId)
if (state) resolve(state)
else reject(new Error(`session not found: ${sessionId}`))
}
})
}
async getTurn(turnId: string) {
this.record('getTurn', turnId)
const events = this.turns.get(turnId)
if (!events) throw new Error(`turn not found: ${turnId}`)
return { turnId, events }
}
async sendMessage(sessionId: string, input: unknown, config: unknown) {
this.record('sendMessage', sessionId, input, config)
return { turnId: 'turn-next' }
}
async respondToPermission(...args: unknown[]) {
this.record('respondToPermission', ...args)
}
async respondToAskHuman(...args: unknown[]) {
this.record('respondToAskHuman', ...args)
}
async stopTurn(...args: unknown[]) {
this.record('stopTurn', ...args)
}
async resumeTurn(...args: unknown[]) {
this.record('resumeTurn', ...args)
}
async setTitle(...args: unknown[]) {
this.record('setTitle', ...args)
}
async delete(...args: unknown[]) {
this.record('delete', ...args)
}
}
function makeStore() {
const client = new FakeClient()
let emit: SessionFeedListener = () => undefined
let unsubscribed = 0
const store = new SessionChatStore({
client,
subscribeFeed: (listener) => {
emit = listener
return () => {
unsubscribed += 1
}
},
})
return {
client,
store,
emit: (event: SessionBusEvent) => emit(event),
getUnsubscribed: () => unsubscribed,
}
}
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 }
}
describe('SessionChatStore', () => {
it('seeds a session: prior turns frozen, latest live, conversation composed', async () => {
const { client, store } = makeStore()
client.sessions.set(S1, sessionState(S1, ['turn-1', 'turn-2']))
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'first?', 'first answer'))
client.turns.set('turn-2', completedTurnLog('turn-2', S1, 'second?', 'second answer'))
await store.setSession(S1)
const snapshot = store.getSnapshot()
expect(snapshot.loading).toBe(false)
expect(snapshot.latestTurnId).toBe('turn-2')
expect(
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['first?', 'first answer', 'second?', 'second answer'])
expect(snapshot.chatState?.isProcessing).toBe(false)
})
it('applies live durable events through the shared reducer', 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, [user('go')])))
expect(store.getSnapshot().chatState?.isThinking).toBe(true)
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
const snapshot = store.getSnapshot()
expect(snapshot.latestTurnId).toBe('turn-1')
expect(snapshot.chatState?.isProcessing).toBe(false)
expect(
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['go', 'done'])
})
it('accumulates text deltas and clears them on the canonical response', 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, [user('go')])))
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'he' }))
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'y' }))
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('hey')
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('hey'))))
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('')
})
it('freezes the previous latest turn when a new turn starts', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, ['turn-1']))
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1'))
await store.setSession(S1)
emit(turnEvent(S1, 'turn-2', created('turn-2', S1, user('q2'))))
const snapshot = store.getSnapshot()
expect(snapshot.latestTurnId).toBe('turn-2')
expect(
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['q1', 'a1', 'q2'])
})
it('ignores events for other sessions', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, []))
await store.setSession(S1)
emit(turnEvent('other-session', 'turn-x', created('turn-x', 'other-session')))
expect(store.getSnapshot().chatState?.conversation).toEqual([])
})
it('reconciles an unknown mid-turn event by refetching the turn', async () => {
const { client, store, emit } = makeStore()
client.sessions.set(S1, sessionState(S1, []))
await store.setSession(S1)
// The feed attached mid-turn: we never saw turn_created for turn-9.
client.turns.set('turn-9', [
created('turn-9', S1, user('missed')),
requested('turn-9', 0, [user('missed')]),
])
emit(turnEvent(S1, 'turn-9', completed('turn-9', 0, assistantText('caught up'))))
await Promise.resolve()
await Promise.resolve()
expect(store.getSnapshot().latestTurnId).toBe('turn-9')
})
it('routes actions against the latest turn', 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)))
await store.sendMessage(user('next'), { agent: { agentId: 'copilot' } })
await store.respondToPermission('tc1', 'allow')
await store.answerAskHuman('ah1', '42')
await store.stop()
expect(client.calls.filter((c) => c.method !== 'get' && c.method !== 'getTurn')).toEqual([
{ method: 'sendMessage', args: [S1, user('next'), { agent: { agentId: 'copilot' } }] },
{ method: 'respondToPermission', args: ['turn-1', 'tc1', 'allow', undefined] },
{ method: 'respondToAskHuman', args: ['turn-1', 'ah1', '42'] },
{ method: 'stopTurn', args: ['turn-1'] },
])
})
it('rejects sendMessage without an active session', async () => {
const { store } = makeStore()
await expect(
store.sendMessage(user('x'), { agent: { agentId: 'copilot' } }),
).rejects.toThrowError('No active session')
})
it('drops stale loads after a session switch', async () => {
const { client, store } = makeStore()
client.sessions.set(S1, sessionState(S1, ['turn-1']))
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'old', 'old answer'))
client.sessions.set('sess-2', sessionState('sess-2', []))
client.deferredGet = () => undefined // arm deferral
const first = store.setSession(S1)
const release = client.deferredGet
client.deferredGet = null
const second = store.setSession('sess-2')
release?.() // S1's get resolves after the switch
await Promise.all([first, second])
expect(store.getSnapshot().sessionId).toBe('sess-2')
expect(store.getSnapshot().chatState?.conversation).toEqual([])
})
it('surfaces load errors and disposes its feed subscription', async () => {
const { store, getUnsubscribed } = makeStore()
await store.setSession('missing-session')
expect(store.getSnapshot().error).toMatch(/session not found/)
store.dispose()
expect(getUnsubscribed()).toBe(1)
})
})
describe('SessionListStore', () => {
it('loads, applies index updates and deletions, and sorts by updatedAt', async () => {
const client = new FakeClient()
client.sessions.set('a', sessionState('a', []))
client.sessions.set('b', sessionState('b', []))
let emit: SessionFeedListener = () => undefined
const store = new SessionListStore({
client,
subscribeFeed: (listener) => {
emit = listener
return () => undefined
},
})
await store.load()
expect(store.getSnapshot().sessions.map((s) => s.sessionId).sort()).toEqual(['a', 'b'])
emit({
kind: 'index-changed',
sessionId: 'c',
entry: indexEntry('c', { updatedAt: '2026-07-03T00:00:00Z' }),
})
expect(store.getSnapshot().sessions[0].sessionId).toBe('c')
emit({ kind: 'index-changed', sessionId: 'a', entry: null })
expect(store.getSnapshot().sessions.map((s) => s.sessionId)).toEqual(['c', 'b'])
})
})

View file

@ -0,0 +1,297 @@
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 {
reduceTurn,
type JsonValue,
type TurnEvent,
type TurnState,
type TurnStreamEvent,
} from '@x/shared/src/turns.js'
import type { SendMessageConfig, SessionsClient } from './client'
import type { SessionFeedListener } from './feed'
import {
applyOverlay,
buildSessionChatState,
emptyOverlay,
type LiveOverlay,
type SessionChatState,
} from './turn-view'
type TEvent = z.infer<typeof TurnEvent>
export interface SessionChatSnapshot {
sessionId: string | null
chatState: SessionChatState | null
latestTurnId: string | null
loading: boolean
error: string | null
}
export interface SessionChatStoreDeps {
client: SessionsClient
subscribeFeed: (listener: SessionFeedListener) => () => void
}
// Framework-agnostic controller for one active session's chat. Owns all the
// logic (seeding via getSession/getTurn, applying live feed events with the
// shared reducer, the ephemeral overlay, action routing); the useSessionChat
// hook is a thin useSyncExternalStore subscription over it.
export class SessionChatStore {
private readonly client: SessionsClient
private readonly unsubscribeFeed: () => void
private readonly listeners = new Set<() => void>()
private sessionId: string | null = null
// Settled earlier turns, reduced once and frozen.
private priorTurns: TurnState[] = []
// The latest turn's raw event log; re-reduced on each durable event.
private latestEvents: TEvent[] | null = null
private overlay: LiveOverlay = emptyOverlay()
private loading = false
private error: string | null = null
// Guards stale async loads after a session switch.
private generation = 0
private snapshot: SessionChatSnapshot = {
sessionId: null,
chatState: null,
latestTurnId: null,
loading: false,
error: null,
}
constructor(deps: SessionChatStoreDeps) {
this.client = deps.client
this.unsubscribeFeed = deps.subscribeFeed(this.onFeedEvent)
}
dispose(): void {
this.unsubscribeFeed()
this.listeners.clear()
}
subscribe = (onChange: () => void): (() => void) => {
this.listeners.add(onChange)
return () => {
this.listeners.delete(onChange)
}
}
getSnapshot = (): SessionChatSnapshot => this.snapshot
async setSession(sessionId: string | null): Promise<void> {
if (sessionId === this.sessionId) return
this.generation += 1
const generation = this.generation
this.sessionId = sessionId
this.priorTurns = []
this.latestEvents = null
this.overlay = emptyOverlay()
this.error = null
this.loading = sessionId !== null
this.emit()
if (sessionId === null) return
try {
const state = await this.client.get(sessionId)
const turns = await Promise.all(
state.turns.map((ref) => this.client.getTurn(ref.turnId)),
)
if (generation !== this.generation) return
const reduced = turns.map((turn) => reduceTurn(turn.events))
this.priorTurns = reduced.slice(0, -1)
this.latestEvents = turns.length > 0 ? turns[turns.length - 1].events : null
this.loading = false
this.emit()
} catch (error) {
if (generation !== this.generation) return
this.loading = false
this.error = error instanceof Error ? error.message : String(error)
this.emit()
}
}
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
if (event.kind !== 'turn-event' || event.sessionId !== this.sessionId) return
const turnEvent = event.event
if (isDurable(turnEvent)) {
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)
} 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
}
}
this.overlay = applyOverlay(this.overlay, turnEvent)
this.emit()
}
private freezeLatest(): void {
if (!this.latestEvents) return
try {
this.priorTurns = [...this.priorTurns, reduceTurn(this.latestEvents)]
} catch {
// A turn we can't reduce is dropped from history rather than wedging
// the whole conversation.
}
this.latestEvents = null
}
private async reloadTurn(turnId: string): Promise<void> {
const generation = this.generation
try {
const turn = await this.client.getTurn(turnId)
if (generation !== this.generation) return
if (this.latestEvents && this.latestEvents[0].turnId !== turnId) {
this.freezeLatest()
}
this.latestEvents = turn.events
this.emit()
} catch {
// The next snapshot-worthy event will retry.
}
}
// ── Actions ─────────────────────────────────────────────────────────────
sendMessage = async (
input: z.infer<typeof UserMessage>,
config: SendMessageConfig,
): Promise<{ turnId: string }> => {
if (!this.sessionId) throw new Error('No active session')
return this.client.sendMessage(this.sessionId, input, config)
}
respondToPermission = async (
toolCallId: string,
decision: 'allow' | 'deny',
metadata?: JsonValue,
): Promise<void> => {
const turnId = this.snapshot.latestTurnId
if (!turnId) return
await this.client.respondToPermission(turnId, toolCallId, decision, metadata)
}
answerAskHuman = async (toolCallId: string, answer: string): Promise<void> => {
const turnId = this.snapshot.latestTurnId
if (!turnId) return
await this.client.respondToAskHuman(turnId, toolCallId, answer)
}
stop = async (): Promise<void> => {
const turnId = this.snapshot.latestTurnId
if (!turnId) return
await this.client.stopTurn(turnId)
}
// ── Derivation ──────────────────────────────────────────────────────────
private emit(): void {
this.snapshot = this.derive()
for (const listener of [...this.listeners]) {
listener()
}
}
private derive(): SessionChatSnapshot {
let turns = this.priorTurns
let error = this.error
if (this.latestEvents) {
try {
turns = [...this.priorTurns, reduceTurn(this.latestEvents)]
} catch (reduceError) {
error =
reduceError instanceof Error ? reduceError.message : String(reduceError)
}
}
const latest = turns[turns.length - 1]
return {
sessionId: this.sessionId,
chatState:
this.sessionId !== null && !this.loading
? buildSessionChatState(turns, this.overlay)
: null,
latestTurnId: latest?.definition.turnId ?? null,
loading: this.loading,
error,
}
}
}
function isDurable(event: TurnStreamEvent): event is TEvent {
return event.type !== 'text_delta' && event.type !== 'reasoning_delta'
}
// ---------------------------------------------------------------------------
// Session list store
// ---------------------------------------------------------------------------
export interface SessionListSnapshot {
sessions: SessionIndexEntry[]
loading: boolean
}
export class SessionListStore {
private readonly client: SessionsClient
private readonly unsubscribeFeed: () => void
private readonly listeners = new Set<() => void>()
private entries = new Map<string, SessionIndexEntry>()
private loading = true
private snapshot: SessionListSnapshot = { sessions: [], loading: true }
constructor(deps: SessionChatStoreDeps) {
this.client = deps.client
this.unsubscribeFeed = deps.subscribeFeed(this.onFeedEvent)
}
dispose(): void {
this.unsubscribeFeed()
this.listeners.clear()
}
subscribe = (onChange: () => void): (() => void) => {
this.listeners.add(onChange)
return () => {
this.listeners.delete(onChange)
}
}
getSnapshot = (): SessionListSnapshot => this.snapshot
async load(): Promise<void> {
const { sessions } = await this.client.list()
this.entries = new Map(sessions.map((entry) => [entry.sessionId, entry]))
this.loading = false
this.emit()
}
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
if (event.kind !== 'index-changed') return
if (event.entry === null) {
this.entries.delete(event.sessionId)
} else {
this.entries.set(event.sessionId, event.entry)
}
this.emit()
}
private emit(): void {
this.snapshot = {
sessions: [...this.entries.values()].sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
),
loading: this.loading,
}
for (const listener of [...this.listeners]) {
listener()
}
}
}

View file

@ -0,0 +1,169 @@
import type { z } from 'zod'
import type { SessionIndexEntry, SessionState } from '@x/shared/src/sessions.js'
import type { ResolvedAgent, TurnEvent } from '@x/shared/src/turns.js'
// Compact turn-event builders for renderer tests. Sequences must satisfy the
// shared reducer's invariants (reduceTurn is what the store runs on them).
export type TEvent = z.infer<typeof TurnEvent>
export const TS = '2026-07-02T10:00:00Z'
export const FIXTURE_AGENT: z.infer<typeof ResolvedAgent> = {
agentId: 'copilot',
systemPrompt: 'SYS',
model: { provider: 'openai', model: 'gpt-fixture' },
tools: [],
}
export function user(text: string) {
return { role: 'user' as const, content: text }
}
export function assistantText(text: string) {
return { role: 'assistant' as const, content: text }
}
export function toolCallPart(id: string, name: string, args: unknown = {}) {
return { type: 'tool-call' as const, toolCallId: id, toolName: name, arguments: args }
}
export function created(
turnId: string,
sessionId: string,
input: ReturnType<typeof user> = user('hello'),
): TEvent {
return {
type: 'turn_created',
schemaVersion: 1,
turnId,
ts: TS,
sessionId,
agent: { requested: { agentId: 'copilot' }, resolved: FIXTURE_AGENT },
context: [],
input,
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 20 },
}
}
export function requested(turnId: string, index: number, messages: unknown[]): TEvent {
return {
type: 'model_call_requested',
turnId,
ts: TS,
modelCallIndex: index,
request: {
systemPrompt: 'SYS',
messages: messages as never,
tools: [],
parameters: {},
},
}
}
export function completed(
turnId: string,
index: number,
message: { role: 'assistant'; content: unknown },
): TEvent {
return {
type: 'model_call_completed',
turnId,
ts: TS,
modelCallIndex: index,
message: message as never,
finishReason: 'stop',
usage: {},
}
}
export function invocation(turnId: string, toolCallId: string, name: string): TEvent {
return {
type: 'tool_invocation_requested',
turnId,
ts: TS,
toolCallId,
toolId: `builtin:${name}`,
toolName: name,
execution: 'sync',
input: {},
}
}
export function toolResult(
turnId: string,
toolCallId: string,
name: string,
output: unknown = 'ok',
): TEvent {
return {
type: 'tool_result',
turnId,
ts: TS,
toolCallId,
toolName: name,
source: 'sync',
result: { output: output as never, isError: false },
}
}
export function turnCompleted(turnId: string, text = 'done'): TEvent {
return {
type: 'turn_completed',
turnId,
ts: TS,
output: assistantText(text),
finishReason: 'stop',
usage: {},
}
}
// A settled single-response turn.
export function completedTurnLog(
turnId: string,
sessionId: string,
question: string,
answer: string,
): TEvent[] {
return [
created(turnId, sessionId, user(question)),
requested(turnId, 0, [user(question)]),
completed(turnId, 0, assistantText(answer)),
turnCompleted(turnId, answer),
]
}
export function indexEntry(
sessionId: string,
overrides: Partial<SessionIndexEntry> = {},
): SessionIndexEntry {
return {
sessionId,
title: `Session ${sessionId}`,
createdAt: TS,
updatedAt: TS,
turnCount: 1,
latestTurnStatus: 'completed',
...overrides,
}
}
export function sessionState(
sessionId: string,
turnIds: string[],
): SessionState {
return {
definition: { type: 'session_created', schemaVersion: 1, sessionId, ts: TS },
title: `Session ${sessionId}`,
turns: turnIds.map((turnId, i) => ({
turnId,
sessionSeq: i + 1,
agentId: 'copilot',
model: FIXTURE_AGENT.model,
ts: TS,
})),
latestTurnId: turnIds[turnIds.length - 1],
createdAt: TS,
updatedAt: TS,
}
}

View file

@ -0,0 +1,272 @@
import { describe, expect, it } from 'vitest'
import { reduceTurn } from '@x/shared/src/turns.js'
import { isChatMessage, isErrorMessage, isToolCall } from '@/lib/chat-conversation'
import {
applyOverlay,
buildSessionChatState,
buildTurnConversation,
emptyOverlay,
} from './turn-view'
import {
TS,
assistantText,
completed,
completedTurnLog,
created,
invocation,
requested,
toolCallPart,
toolResult,
turnCompleted,
user,
type TEvent,
} from './test-fixtures'
const T1 = 'turn-1'
const S1 = 'sess-1'
function assistantCalls(...parts: Array<ReturnType<typeof toolCallPart>>) {
return { role: 'assistant' as const, content: parts }
}
describe('applyOverlay', () => {
it('accumulates text and reasoning deltas', () => {
let overlay = emptyOverlay()
overlay = applyOverlay(overlay, { type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: 'he' })
overlay = applyOverlay(overlay, { type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: 'y' })
overlay = applyOverlay(overlay, {
type: 'reasoning_delta',
turnId: T1,
modelCallIndex: 0,
delta: 'hmm',
})
expect(overlay.text).toBe('hey')
expect(overlay.reasoning).toBe('hmm')
})
it('clears text/reasoning when the canonical model response arrives', () => {
let overlay = { ...emptyOverlay(), text: 'streaming', reasoning: 'thinking' }
overlay = applyOverlay(overlay, completed(T1, 0, assistantText('final')))
expect(overlay.text).toBe('')
expect(overlay.reasoning).toBe('')
})
it('accumulates tool-output progress chunks and drops them on the terminal result', () => {
let overlay = emptyOverlay()
const progress = (chunk: string): TEvent => ({
type: 'tool_progress',
turnId: T1,
ts: TS,
toolCallId: 'tc1',
source: 'sync',
progress: { kind: 'tool-output', chunk },
})
overlay = applyOverlay(overlay, progress('$ ls\n'))
overlay = applyOverlay(overlay, progress('README.md\n'))
expect(overlay.toolOutput.tc1).toBe('$ ls\nREADME.md\n')
overlay = applyOverlay(overlay, toolResult(T1, 'tc1', 'executeCommand'))
expect(overlay.toolOutput.tc1).toBeUndefined()
})
it('ignores non-output progress payloads', () => {
const overlay = applyOverlay(emptyOverlay(), {
type: 'tool_progress',
turnId: T1,
ts: TS,
toolCallId: 'tc1',
source: 'sync',
progress: { pct: 50 },
})
expect(overlay.toolOutput).toEqual({})
})
})
describe('buildTurnConversation', () => {
it('maps user input, assistant text, and settled tool calls', () => {
const call = assistantCalls(toolCallPart('tc1', 'echo', { x: 1 }))
const state = reduceTurn([
created(T1, S1, user('run it')),
requested(T1, 0, [user('run it')]),
completed(T1, 0, call),
invocation(T1, 'tc1', 'echo'),
toolResult(T1, 'tc1', 'echo', { echoed: true }),
requested(T1, 1, [
user('run it'),
call,
{ role: 'tool', content: '{"echoed":true}', toolCallId: 'tc1', toolName: 'echo' },
]),
completed(T1, 1, assistantText('all done')),
turnCompleted(T1, 'all done'),
])
const items = buildTurnConversation(state)
expect(items.map((i) => (isToolCall(i) ? `tool:${i.name}:${i.status}` : isChatMessage(i) ? `${i.role}` : 'x'))).toEqual([
'user',
'tool:echo:completed',
'assistant',
])
const tool = items.find(isToolCall)
expect(tool?.result).toEqual({ echoed: true })
expect(tool?.input).toEqual({ x: 1 })
})
it('marks permission-pending tools pending and running tools running', () => {
const state = reduceTurn([
created(T1, S1),
requested(T1, 0, [user('hello')]),
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'), toolCallPart('r1', 'echo'))),
{
type: 'tool_permission_required',
turnId: T1,
ts: TS,
toolCallId: 'p1',
toolName: 'executeCommand',
request: { kind: 'command', commandNames: ['rm'] },
},
invocation(T1, 'r1', 'echo'),
])
const items = buildTurnConversation(state).filter(isToolCall)
expect(items.map((i) => i.status)).toEqual(['pending', 'running'])
})
it('renders user attachments and a failed turn as an error item', () => {
const input = {
role: 'user' as const,
content: [
{ type: 'text' as const, text: 'see file' },
{ type: 'attachment' as const, path: '/tmp/a.png', filename: 'a.png', mimeType: 'image/png' },
],
}
const state = reduceTurn([
created(T1, S1, input as never),
requested(T1, 0, [input]),
{ type: 'model_call_failed', turnId: T1, ts: TS, modelCallIndex: 0, error: 'boom' },
{ type: 'turn_failed', turnId: T1, ts: TS, error: 'boom', usage: {} },
])
const items = buildTurnConversation(state)
expect(isChatMessage(items[0]) && items[0].attachments?.[0].filename).toBe('a.png')
expect(isErrorMessage(items[1]) && items[1].message).toBe('boom')
})
})
describe('buildSessionChatState', () => {
it('composes the conversation across turns and derives processing flags', () => {
const prior = reduceTurn(completedTurnLog('turn-1', S1, 'first?', 'first answer'))
const latest = reduceTurn([
created('turn-2', S1, user('second?')),
requested('turn-2', 0, [user('second?')]),
])
const state = buildSessionChatState([prior, latest], emptyOverlay())
expect(
state.conversation.filter(isChatMessage).map((m) => m.content),
).toEqual(['first?', 'first answer', 'second?'])
expect(state.isProcessing).toBe(true) // latest turn idle = actively working
expect(state.isThinking).toBe(true)
})
it('is settled (not processing) when the latest turn is terminal', () => {
const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a'))
const state = buildSessionChatState([turn], emptyOverlay())
expect(state.isProcessing).toBe(false)
expect(state.isThinking).toBe(false)
})
it('exposes pending permissions as request events; waiting is not thinking', () => {
const turn = reduceTurn([
created(T1, S1),
requested(T1, 0, [user('hello')]),
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand', { command: 'rm -rf /' }))),
{
type: 'tool_permission_required',
turnId: T1,
ts: TS,
toolCallId: 'p1',
toolName: 'executeCommand',
request: { kind: 'command', commandNames: ['rm'] },
},
{
type: 'turn_suspended',
turnId: T1,
ts: TS,
pendingPermissions: [
{ toolCallId: 'p1', toolName: 'executeCommand', request: { kind: 'command', commandNames: ['rm'] } },
],
pendingAsyncTools: [],
usage: {},
},
])
const state = buildSessionChatState([turn], emptyOverlay())
const request = state.allPermissionRequests.get('p1')
expect(request).toMatchObject({
type: 'tool-permission-request',
toolCall: { toolCallId: 'p1', toolName: 'executeCommand' },
permission: { kind: 'command', commandNames: ['rm'] },
})
expect(state.isProcessing).toBe(true)
expect(state.isThinking).toBe(false)
})
it('maps human decisions to responses and classifier decisions to auto-decisions', () => {
const turn = reduceTurn([
created(T1, S1),
requested(T1, 0, [user('hello')]),
completed(T1, 0, assistantCalls(toolCallPart('h1', 'echo'), toolCallPart('c1', 'echo'))),
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'h1', toolName: 'echo', request: { kind: 'command', commandNames: ['x'] } },
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', request: { kind: 'command', commandNames: ['y'] } },
{ type: 'tool_permission_resolved', turnId: T1, ts: TS, toolCallId: 'h1', decision: 'allow', source: 'human' },
{ type: 'tool_permission_resolved', turnId: T1, ts: TS, toolCallId: 'c1', decision: 'deny', source: 'classifier', reason: 'risky' },
{ type: 'tool_result', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', source: 'runtime', result: { output: 'denied', isError: true } },
invocation(T1, 'h1', 'echo'),
toolResult(T1, 'h1', 'echo'),
])
const state = buildSessionChatState([turn], emptyOverlay())
expect(state.permissionResponses.get('h1')).toBe('approve')
expect(state.autoPermissionDecisions.get('c1')).toMatchObject({
decision: 'deny',
reason: 'risky',
})
})
it('exposes pending ask-human calls with question and options', () => {
const turn = reduceTurn([
created(T1, S1),
requested(T1, 0, [user('hello')]),
completed(T1, 0, assistantCalls(toolCallPart('ah1', 'ask-human', { question: 'Deploy?', options: ['Yes', 'No'] }))),
{
type: 'tool_invocation_requested',
turnId: T1,
ts: TS,
toolCallId: 'ah1',
toolId: 'builtin:ask-human',
toolName: 'ask-human',
execution: 'async',
input: { question: 'Deploy?', options: ['Yes', 'No'] },
},
])
const state = buildSessionChatState([turn], emptyOverlay())
expect(state.pendingAskHumanRequests.get('ah1')).toMatchObject({
query: 'Deploy?',
options: ['Yes', 'No'],
})
expect(state.isThinking).toBe(false) // waiting on the human, no shimmer
})
it('stitches live tool output onto the matching tool item', () => {
const turn = reduceTurn([
created(T1, S1),
requested(T1, 0, [user('hello')]),
completed(T1, 0, assistantCalls(toolCallPart('tc1', 'executeCommand'))),
invocation(T1, 'tc1', 'executeCommand'),
])
const overlay = { ...emptyOverlay(), toolOutput: { tc1: 'partial output' } }
const state = buildSessionChatState([turn], overlay)
const tool = state.conversation.filter(isToolCall)[0]
expect(tool.streamingOutput).toBe('partial output')
})
it('surfaces streaming text as currentAssistantMessage', () => {
const turn = reduceTurn([created(T1, S1), requested(T1, 0, [user('hello')])])
const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' })
expect(state.currentAssistantMessage).toBe('typing…')
})
})

View file

@ -0,0 +1,300 @@
import type { z } from 'zod'
import type {
AskHumanRequestEvent,
ToolPermissionAutoDecisionEvent,
ToolPermissionMetadata,
ToolPermissionRequestEvent,
} from '@x/shared/src/runs.js'
import {
deriveTurnStatus,
outstandingAsyncTools,
outstandingPermissions,
type ToolCallState,
type TurnState,
type TurnStreamEvent,
} from '@x/shared/src/turns.js'
import type {
ChatMessage,
ConversationItem,
ErrorMessage,
MessageAttachment,
PermissionResponse,
ToolCall,
} from '@/lib/chat-conversation'
// Pure derivations from reduced turn state (+ the ephemeral live overlay) to
// the view shapes the existing chat components already consume. No IPC, no
// React — everything here is unit-testable with plain state values.
// ---------------------------------------------------------------------------
// Live overlay: ephemeral streaming buffers (turn-runtime-design.md §14.4).
// ---------------------------------------------------------------------------
export type LiveOverlay = {
text: string
reasoning: string
toolOutput: Record<string, string>
}
export const emptyOverlay = (): LiveOverlay => ({ text: '', reasoning: '', toolOutput: {} })
// Accumulates deltas; canonical durable events supersede the buffers (the
// committed transcript now contains what was streaming).
export function applyOverlay(overlay: LiveOverlay, event: TurnStreamEvent): LiveOverlay {
switch (event.type) {
case 'text_delta':
return { ...overlay, text: overlay.text + event.delta }
case 'reasoning_delta':
return { ...overlay, reasoning: overlay.reasoning + event.delta }
case 'model_call_completed':
return { ...overlay, text: '', reasoning: '' }
case 'tool_progress': {
const progress = event.progress
if (
progress &&
typeof progress === 'object' &&
!Array.isArray(progress) &&
(progress as { kind?: unknown }).kind === 'tool-output' &&
typeof (progress as { chunk?: unknown }).chunk === 'string'
) {
const chunk = (progress as { chunk: string }).chunk
return {
...overlay,
toolOutput: {
...overlay.toolOutput,
[event.toolCallId]: (overlay.toolOutput[event.toolCallId] ?? '') + chunk,
},
}
}
return overlay
}
case 'tool_result': {
if (!(event.toolCallId in overlay.toolOutput)) return overlay
const toolOutput = { ...overlay.toolOutput }
delete toolOutput[event.toolCallId]
return { ...overlay, toolOutput }
}
default:
return overlay
}
}
// ---------------------------------------------------------------------------
// Conversation items
// ---------------------------------------------------------------------------
type UserContent = TurnState['definition']['input']['content']
function extractText(content: UserContent): string {
if (typeof content === 'string') return content
return content
.map((part) => (part.type === 'text' ? part.text : ''))
.filter(Boolean)
.join('\n')
}
function extractAttachments(content: UserContent): MessageAttachment[] | undefined {
if (typeof content === 'string') return undefined
const attachments = content.flatMap((part) =>
part.type === 'attachment'
? [
{
path: part.path,
filename: part.filename,
mimeType: part.mimeType,
...(part.size === undefined ? {} : { size: part.size }),
},
]
: [],
)
return attachments.length > 0 ? attachments : undefined
}
function toolStatus(tc: ToolCallState): ToolCall['status'] {
if (tc.result) return 'completed'
if (tc.permission && !tc.permission.resolved) return 'pending'
return 'running'
}
// One turn's contribution to the conversation: the user input, then per
// completed model call its text and tool calls (with live status/results).
export function buildTurnConversation(state: TurnState): ConversationItem[] {
const items: ConversationItem[] = []
const turnId = state.definition.turnId
let seq = 0
const ts = () => Date.parse(state.definition.ts) + seq++
const userText = extractText(state.definition.input.content)
const attachments = extractAttachments(state.definition.input.content)
if (userText || attachments) {
items.push({
id: `${turnId}:user`,
role: 'user',
content: userText,
timestamp: ts(),
...(attachments ? { attachments } : {}),
} satisfies ChatMessage)
}
const toolCallsById = new Map(state.toolCalls.map((tc) => [tc.toolCallId, tc]))
for (const call of state.modelCalls) {
if (call.response === undefined) continue
const content = call.response.content
const text =
typeof content === 'string'
? content
: content
.map((part) => (part.type === 'text' ? part.text : ''))
.filter(Boolean)
.join('\n')
if (text) {
items.push({
id: `${turnId}:a${call.index}`,
role: 'assistant',
content: text,
timestamp: ts(),
} satisfies ChatMessage)
}
if (Array.isArray(content)) {
for (const part of content) {
if (part.type !== 'tool-call') continue
const tc = toolCallsById.get(part.toolCallId)
items.push({
id: part.toolCallId,
name: part.toolName,
input: part.arguments as ToolCall['input'],
...(tc?.result ? { result: tc.result.result.output as ToolCall['result'] } : {}),
status: tc ? toolStatus(tc) : 'running',
timestamp: ts(),
} satisfies ToolCall)
}
}
}
if (state.terminal?.type === 'turn_failed') {
items.push({
id: `${turnId}:error`,
kind: 'error',
message: state.terminal.error,
timestamp: ts(),
} satisfies ErrorMessage)
}
return items
}
// ---------------------------------------------------------------------------
// Session chat state (superset of the ChatTabViewState fields)
// ---------------------------------------------------------------------------
type PermMeta = z.infer<typeof ToolPermissionMetadata>
export type SessionChatState = {
conversation: ConversationItem[]
currentAssistantMessage: string
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
// Composer blocked / Stop shown until the latest turn settles (waiting on a
// permission or ask-human still counts as processing).
isProcessing: boolean
// Actively working (non-terminal, nothing waiting on the user) — drives the
// "Thinking…" shimmer; deliberately false under permission/ask-human cards.
isThinking: boolean
}
function toolCallPartOf(tc: ToolCallState) {
return {
type: 'tool-call' as const,
toolCallId: tc.toolCallId,
toolName: tc.toolName,
arguments: tc.input,
}
}
// Compose the whole session's conversation: prior (settled) turns plus the
// latest turn, with the live overlay stitched onto the latest turn's items.
export function buildSessionChatState(
turns: TurnState[],
overlay: LiveOverlay,
): SessionChatState {
const conversation: ConversationItem[] = []
for (const turn of turns) {
conversation.push(...buildTurnConversation(turn))
}
for (let i = 0; i < conversation.length; i++) {
const item = conversation[i]
if ('name' in item && overlay.toolOutput[item.id]) {
conversation[i] = { ...item, streamingOutput: overlay.toolOutput[item.id] }
}
}
const latest = turns[turns.length - 1]
const status = latest ? deriveTurnStatus(latest) : undefined
const latestTurnId = latest?.definition.turnId ?? ''
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
const permissionResponses = new Map<string, PermissionResponse>()
const autoPermissionDecisions = new Map<
string,
z.infer<typeof ToolPermissionAutoDecisionEvent>
>()
const pendingAskHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
if (latest) {
for (const tc of outstandingPermissions(latest)) {
allPermissionRequests.set(tc.toolCallId, {
runId: latestTurnId,
type: 'tool-permission-request',
subflow: [],
toolCall: toolCallPartOf(tc),
permission: tc.permission?.required.request as PermMeta,
})
}
for (const tc of latest.toolCalls) {
const resolved = tc.permission?.resolved
if (!resolved) continue
if (resolved.source === 'human') {
permissionResponses.set(tc.toolCallId, resolved.decision === 'allow' ? 'approve' : 'deny')
} else if (resolved.source === 'classifier') {
autoPermissionDecisions.set(tc.toolCallId, {
runId: latestTurnId,
type: 'tool-permission-auto-decision',
subflow: [],
toolCallId: tc.toolCallId,
toolCall: toolCallPartOf(tc),
permission: tc.permission?.required.request as PermMeta,
decision: resolved.decision,
reason: resolved.reason ?? '',
})
}
}
for (const tc of outstandingAsyncTools(latest)) {
if (tc.toolName !== 'ask-human') continue
const input = (tc.input ?? {}) as { question?: unknown; options?: unknown }
pendingAskHumanRequests.set(tc.toolCallId, {
runId: latestTurnId,
type: 'ask-human-request',
toolCallId: tc.toolCallId,
subflow: [],
query: typeof input.question === 'string' ? input.question : '',
...(Array.isArray(input.options) && input.options.every((o) => typeof o === 'string')
? { options: input.options }
: {}),
})
}
}
const settled = status === 'completed' || status === 'failed' || status === 'cancelled'
return {
conversation,
currentAssistantMessage: overlay.text,
pendingAskHumanRequests,
allPermissionRequests,
permissionResponses,
autoPermissionDecisions,
isProcessing: latest !== undefined && !settled,
isThinking: status === 'idle',
}
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

View file

@ -1,5 +1,5 @@
import path from "path"
import { defineConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@ -18,4 +18,10 @@ export default defineConfig({
build: {
outDir: 'dist',
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
css: false,
},
})

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(),