diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 9665172a..c290486e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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 { }); } +// 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('sessionBus'); + sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event)); +} + let servicesWatcher: (() => void) | null = null; export async function startServicesWatcher(): Promise { 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('sessions').createSession(args); + return { sessionId }; + }, + 'sessions:list': async () => { + return { sessions: container.resolve('sessions').listSessions() }; + }, + 'sessions:get': async (_event, args) => { + return container.resolve('sessions').getSession(args.sessionId); + }, + 'sessions:getTurn': async (_event, args) => { + return container.resolve('sessions').getTurn(args.turnId); + }, + 'sessions:sendMessage': async (_event, args) => { + return container.resolve('sessions').sendMessage(args.sessionId, args.input, args.config); + }, + 'sessions:respondToPermission': async (_event, args) => { + await container.resolve('sessions').respondToPermission(args.turnId, args.toolCallId, args.decision, args.metadata); + return { success: true }; + }, + 'sessions:respondToAskHuman': async (_event, args) => { + await container.resolve('sessions').respondToAskHuman(args.turnId, args.toolCallId, args.answer); + return { success: true }; + }, + 'sessions:stopTurn': async (_event, args) => { + await container.resolve('sessions').stopTurn(args.turnId, args.reason); + return { success: true }; + }, + 'sessions:resumeTurn': async (_event, args) => { + await container.resolve('sessions').resumeTurn(args.sessionId); + return { success: true }; + }, + 'sessions:setTitle': async (_event, args) => { + await container.resolve('sessions').setTitle(args.sessionId, args.title); + return { success: true }; + }, + 'sessions:delete': async (_event, args) => { + await container.resolve('sessions').deleteSession(args.sessionId); + return { success: true }; + }, 'runs:downloadLog': async (event, args) => { const runFileName = `${args.runId}.jsonl`; if (path.basename(runFileName) !== runFileName) { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index e93d8ac5..fb033413 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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('sessions').initialize(); + startSessionsWatcher(); + // start code-session status tracker (derives working/needs-you/idle + notifications) startCodeSessionStatusWatcher(); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index eec078d6..0a8c9f9f 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -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:" } } diff --git a/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx b/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx new file mode 100644 index 00000000..30caf6a9 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx @@ -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) + }) +}) diff --git a/apps/x/apps/renderer/src/hooks/useSessionChat.ts b/apps/x/apps/renderer/src/hooks/useSessionChat.ts new file mode 100644 index 00000000..c598330c --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSessionChat.ts @@ -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 diff --git a/apps/x/apps/renderer/src/hooks/useSessions.ts b/apps/x/apps/renderer/src/hooks/useSessions.ts new file mode 100644 index 00000000..872c6a6c --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useSessions.ts @@ -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 diff --git a/apps/x/apps/renderer/src/lib/session-chat/client.ts b/apps/x/apps/renderer/src/lib/session-chat/client.ts new file mode 100644 index 00000000..87df44f0 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/client.ts @@ -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 + autoPermission?: boolean + maxModelCalls?: number +} + +export interface SessionsClient { + create(input: { title?: string }): Promise<{ sessionId: string }> + list(): Promise<{ sessions: SessionIndexEntry[] }> + get(sessionId: string): Promise + getTurn(turnId: string): Promise<{ turnId: string; events: Array> }> + sendMessage( + sessionId: string, + input: z.infer, + config: SendMessageConfig, + ): Promise<{ turnId: string }> + respondToPermission( + turnId: string, + toolCallId: string, + decision: 'allow' | 'deny', + metadata?: JsonValue, + ): Promise + respondToAskHuman(turnId: string, toolCallId: string, answer: string): Promise + stopTurn(turnId: string, reason?: string): Promise + resumeTurn(sessionId: string): Promise + setTitle(sessionId: string, title: string): Promise + delete(sessionId: string): Promise +} + +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 }) + }, +} diff --git a/apps/x/apps/renderer/src/lib/session-chat/feed.test.ts b/apps/x/apps/renderer/src/lib/session-chat/feed.test.ts new file mode 100644 index 00000000..86d00bbd --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/feed.test.ts @@ -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]) + }) +}) diff --git a/apps/x/apps/renderer/src/lib/session-chat/feed.ts b/apps/x/apps/renderer/src/lib/session-chat/feed.ts new file mode 100644 index 00000000..bac8a735 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/feed.ts @@ -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() + 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) +} diff --git a/apps/x/apps/renderer/src/lib/session-chat/store.test.ts b/apps/x/apps/renderer/src/lib/session-chat/store.test.ts new file mode 100644 index 00000000..c8b95bcf --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/store.test.ts @@ -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() + turns = new Map() + 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((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']) + }) +}) diff --git a/apps/x/apps/renderer/src/lib/session-chat/store.ts b/apps/x/apps/renderer/src/lib/session-chat/store.ts new file mode 100644 index 00000000..8dc3b1f7 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/store.ts @@ -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 + +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 { + 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 { + 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, + 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 => { + const turnId = this.snapshot.latestTurnId + if (!turnId) return + await this.client.respondToPermission(turnId, toolCallId, decision, metadata) + } + + answerAskHuman = async (toolCallId: string, answer: string): Promise => { + const turnId = this.snapshot.latestTurnId + if (!turnId) return + await this.client.respondToAskHuman(turnId, toolCallId, answer) + } + + stop = async (): Promise => { + 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() + 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 { + 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() + } + } +} diff --git a/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts b/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts new file mode 100644 index 00000000..381f8cb9 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts @@ -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 + +export const TS = '2026-07-02T10:00:00Z' + +export const FIXTURE_AGENT: z.infer = { + 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 = 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 { + 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, + } +} diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts new file mode 100644 index 00000000..4acbd6e9 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts @@ -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>) { + 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…') + }) +}) diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts new file mode 100644 index 00000000..fad07998 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -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 +} + +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 + +export type SessionChatState = { + conversation: ConversationItem[] + currentAssistantMessage: string + pendingAskHumanRequests: Map> + allPermissionRequests: Map> + permissionResponses: Map + autoPermissionDecisions: Map> + // 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>() + const permissionResponses = new Map() + const autoPermissionDecisions = new Map< + string, + z.infer + >() + const pendingAskHumanRequests = new Map>() + + 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', + } +} diff --git a/apps/x/apps/renderer/src/test/setup.ts b/apps/x/apps/renderer/src/test/setup.ts new file mode 100644 index 00000000..a9d0dd31 --- /dev/null +++ b/apps/x/apps/renderer/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/apps/x/apps/renderer/vite.config.ts b/apps/x/apps/renderer/vite.config.ts index 9bcca968..20b2b43b 100644 --- a/apps/x/apps/renderer/vite.config.ts +++ b/apps/x/apps/renderer/vite.config.ts @@ -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, + }, }) diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 067adb4f..a174da9b 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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()) }), + }, + 'sessions:get': { + req: z.object({ sessionId: z.string() }), + res: z.custom(), + }, + '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> }>(), + }, + '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(), + res: z.null(), + }, 'services:events': { req: ServiceEvent, res: z.null(),