import { useCallback, useMemo, useRef } from "react"; import { Effect } from "effect"; import { finalizeRunningToolBlocks, mergeCanonicalAndRuntimeEvents, reconcileReplayMessages, replayCursorAfterRuntimeHydration, runtimeStatusAcceptsControl, } from "@/features/agent/messages"; import { foldSessionEvents } from "@/features/agent/runtime/pi-event-applier"; import { settleTurnFinalizingTools } from "@/features/agent/runtime/session-status"; import { selectedContextPrompt, type ComposerPromptTemplateRef, type ComposerSkillRef, } from "@/features/agent/composer-context"; import type { Session, SessionId, UpdateSession } from "@/features/agent/runtime/types"; import type { BrowserBackend, ToolSelection } from "@/features/agent/tools/types"; import type { AgentQueueAction, AgentThinkingLevel, AgentToolAccess, } from "@/features/agent/contracts"; import * as api from "@/features/agent/runtime/api"; import { runtimeCanHydrateCanonicalSession, submitPromptTurn, type SubmitArgs, } from "@/features/agent/runtime/prompt-stream"; import { readTranscriptSnapshot } from "@/features/agent/workspace/transcript-cache"; import { sessionRuntimeController } from "@/features/agent/runtime/session-runtime-controller"; const EMPTY_SKILLS: ComposerSkillRef[] = []; const EMPTY_PROMPT_TEMPLATES: ComposerPromptTemplateRef[] = []; const inFlightReplays = new Set(); export type UseSessionEngineDeps = { /** Latest `tabs` snapshot — engine reads via a ref so it doesn't restart on every frame. */ tabs: Session[]; activeTabId: SessionId; modelId: string; thinkingLevel: AgentThinkingLevel; toolAccess: AgentToolAccess; cwd: string; browserToolEnabled: boolean; browserBackend: BrowserBackend; onPiSessionIdChange?: (piSessionId: string) => void; /** Mutate a single session record. */ updateSession: UpdateSession; /** Look up the per-session tool selection from the tools subsystem. */ selectionFor: (sessionId: SessionId) => ToolSelection; }; export type SessionEngine = { /** Send a freshly-typed prompt — orchestrates optimistic update + streaming. */ submitPrompt: (args: SubmitArgs) => Promise; /** Send a steer/follow-up control message while a turn is in progress. */ sendControl: (request: AgentControlRequest) => Promise<{ ok: boolean; error?: string }>; loadRuntimeStatus: ( runtime: string, piSessionId?: string | null, ) => Promise; abortTurn: (sessionId: SessionId) => Promise; loadAndReplay: (piSessionId: string, sessionId: SessionId) => Promise; /** Fetch and prepend the previous page of older history (tail paging). */ loadEarlier: (sessionId: SessionId) => Promise; compact: (sessionId: SessionId) => Promise; /** Probe whether the session's live runtime accepts steer/follow-up right * now: running/starting locally, and the runtime's reported pi session (if * any) matches ours. A failed probe counts as accepting — the turn API * itself is the authority and will reject if not. */ acceptsControl: ( tab: { status: Session["status"]; piSessionId?: string | null }, runtime: string, ) => Promise; }; export type AgentControlRequest = { mode: "steer" | "follow_up"; text: string; runtime: string; sessionId: SessionId; piSessionId?: string | null; queueAction?: AgentQueueAction; queueReplacement?: string; }; export function useSessionEngine(deps: UseSessionEngineDeps): SessionEngine { const { tabs, activeTabId, modelId, thinkingLevel, toolAccess, cwd, browserToolEnabled, browserBackend, onPiSessionIdChange, updateSession, selectionFor, } = deps; const tabsRef = useRef(tabs); tabsRef.current = tabs; const selectionForRef = useRef(selectionFor); selectionForRef.current = selectionFor; // Sessions with an in-flight "load earlier" page, so a double click / repeated // scroll doesn't fetch and prepend the same chunk twice. const loadingEarlierRef = useRef>(new Set()); const loadRuntimeStatusCb = useCallback(api.loadRuntimeStatus, []); const sendControl = useCallback( (request: AgentControlRequest): Promise<{ ok: boolean; error?: string }> => { const { mode, text, runtime, sessionId, piSessionId, queueAction, queueReplacement } = request; if (!text.trim() || !modelId) return Promise.resolve({ ok: false }); return Effect.runPromise( Effect.gen(function* () { const selection = selectionForRef.current(sessionId); const skills = selection.skills ?? EMPTY_SKILLS; const promptTemplates = selection.promptTemplates ?? EMPTY_PROMPT_TEMPLATES; const browserEnabledForTurn = browserToolEnabled; const message = selectedContextPrompt(text, skills); const contextualQueueReplacement = queueReplacement ? selectedContextPrompt(queueReplacement, skills) : undefined; const result = yield* Effect.tryPromise({ try: () => api.submitTurnCommand({ sessionId: runtime, modelId, thinkingLevel, toolAccess, message, cwd: cwd.trim() || undefined, piSessionId, mode, queueAction, queueReplacement: contextualQueueReplacement, browserToolEnabled: browserEnabledForTurn, browserSessionId: runtime, browserBackend, skills, promptTemplates, }), catch: (error) => error, }); updateSession(sessionId, (session) => ({ ...session, piSessionId: result.piSessionId || session.piSessionId, contextUsage: api.runtimeContextUsage(result.status, session.contextUsage), status: "running", })); // Same acceptance bookkeeping the prompt path does. Without it a // steer/follow-up got no accept-grace (so a stale runtime-list // snapshot could idle the session and tear down its stream // mid-turn) and no cursor rewind if the runtime's seq had restarted. sessionRuntimeController().noteTurnAccepted( sessionId, undefined, result.status?.eventSeq, ); if (result.piSessionId) onPiSessionIdChange?.(result.piSessionId); return { ok: true }; }).pipe( Effect.catch((error) => Effect.succeed({ ok: false, error: error instanceof Error ? error.message : "Message failed", }), ), ), ); }, [ browserToolEnabled, browserBackend, cwd, modelId, thinkingLevel, toolAccess, onPiSessionIdChange, updateSession, ], ); const submitPrompt = useCallback( (args: SubmitArgs) => submitPromptTurn( { activeTabId, browserToolEnabled, browserBackend, cwd, modelId, thinkingLevel, toolAccess, onPiSessionIdChange, selectionFor: selectionForRef.current, tabsRef, updateSession, }, args, ), [ activeTabId, modelId, thinkingLevel, toolAccess, cwd, browserToolEnabled, browserBackend, onPiSessionIdChange, updateSession, ], ); const abortTurn = useCallback( (sessionId: SessionId) => Effect.runPromise( Effect.gen(function* () { // Abort by the CONNECTION key (override-aware): after a server // restart the session's runtime lives under a different server key, // and /abort has no piSessionId fallback lookup. const runtime = sessionRuntimeController().connectionKey(sessionId); updateSession(sessionId, (session) => ({ ...session, status: "stopping" })); const cleared = yield* Effect.tryPromise({ try: () => api.abortSession(runtime), catch: (error) => error, }); // Settle the session fully. A direct status write bypasses the reducer // that normally finalizes tool badges on agent_end, and idling the // session detaches the SSE — so if the runtime's terminal event never // lands, any in-flight tool would render a perpetual "running" badge // and activeAssistantId would linger. Flush pending deltas first so the // last streamed text is committed before we finalize. sessionRuntimeController().flush(sessionId); updateSession(sessionId, settleTurnFinalizingTools); return cleared; }), ), [updateSession], ); const loadAndReplay = useCallback( (piSessionId: string, sessionId: SessionId) => { if (inFlightReplays.has(sessionId)) return Promise.resolve(); inFlightReplays.add(sessionId); return Effect.runPromise( Effect.gen(function* () { const cachedMessages = readTranscriptSnapshot(piSessionId); const seedCached = (session: Session) => session.messages.length === 0 && cachedMessages ? { ...session, messages: cachedMessages } : session; if (!cwd) { updateSession(sessionId, (session) => seedCached(session.status === "loading" ? { ...session, status: "idle" } : session), ); return; } updateSession(sessionId, (session) => ({ ...seedCached(session), status: "loading", error: "", })); // Canonical replay and the runtime-status probe are independent — the // status key is derived synchronously — so run them concurrently // instead of blocking the (now tail-limited) canonical read on the // status round-trip. const runtimeId = sessionRuntimeController().connectionKey(sessionId); const [replayResult, runtimeStatus] = yield* Effect.all( [ Effect.tryPromise({ try: () => api.loadCanonicalSession(piSessionId, cwd), catch: (error) => error, }).pipe(Effect.result), Effect.tryPromise({ try: () => api.loadRuntimeStatus(runtimeId, piSessionId), catch: () => null, }), ], { concurrency: "unbounded" }, ); if (replayResult._tag === "Success") { const { events, cursor, meta } = replayResult.success; const runtimeActive = runtimeCanHydrateCanonicalSession(runtimeStatus, piSessionId); const replayEvents = mergeCanonicalAndRuntimeEvents( events, runtimeActive ? runtimeStatus?.events : [], ); const { messages, title, startedAt, modelId: replayModelId, tokenStats, } = foldSessionEvents(replayEvents); const replaySeq = replayCursorAfterRuntimeHydration(runtimeStatus, piSessionId); updateSession(sessionId, (session) => ({ ...session, messages: reconcileReplayMessages(session.messages, messages), piSessionId, cwd: session.cwd || cwd, // Head-scan meta carries the real session model/title; the fold's // own title would be the tail slice's first user message, not the // session's first prompt. modelId: session.modelId || meta?.modelId || replayModelId || runtimeStatus?.modelId || modelId, title: meta?.title ?? title ?? session.title, startedAt: meta?.startedAt ?? startedAt ?? session.startedAt, tokenStats: tokenStats ?? undefined, // Lifetime spend is computed server-side from the whole rollout, // so it survives both compaction and the tail load's cutoff. usageTotals: meta?.usage ?? session.usageTotals, contextUsage: api.runtimeContextUsage(runtimeStatus, session.contextUsage), status: runtimeActive ? "running" : "idle", activeAssistantId: undefined, // A non-null cursor means the tail load left older history unread; // the timeline shows a "Load earlier" affordance while it is set. historyCursor: messages.length > 0 ? cursor : (session.historyCursor ?? null), // The replay has landed, so whatever came from the snapshot has // been superseded and must not keep asking to be replayed. hydratedFromCache: false, error: "", })); // Reattach the live stream from the hydrated cursor so EventSource // does not replay already-rendered content. sessionRuntimeController().noteReplayHydrated(sessionId, replaySeq); } else { const err = replayResult.failure; // Canonical read failed. If the runtime is still alive, don't strand the // session idle (which would drop the live stream — reconcile only // subscribes for live statuses): keep the seeded history, mark it running, // and reset the cursor so the reattached SSE replays the runtime backlog. if (runtimeCanHydrateCanonicalSession(runtimeStatus, piSessionId)) { updateSession(sessionId, (session) => ({ ...session, contextUsage: api.runtimeContextUsage(runtimeStatus, session.contextUsage), status: "running", activeAssistantId: undefined, error: "", })); sessionRuntimeController().noteReplayHydrated(sessionId, undefined); return; } updateSession(sessionId, (session) => ({ ...session, error: err instanceof Error ? err.message : "Failed to load session", status: "idle", })); } }).pipe( Effect.ensuring( Effect.sync(() => { inFlightReplays.delete(sessionId); }), ), ), ); }, [cwd, modelId, updateSession], ); // Page the previous (older) chunk of a tail-loaded transcript into view and // prepend it. Each page is snapped to a user-turn boundary and abuts the // current first message exactly (cursor = first loaded byte), so folding the // page on its own and prepending is equivalent to a single larger fold. const loadEarlier = useCallback( (sessionId: SessionId): Promise => { const session = tabsRef.current.find((tab) => tab.id === sessionId); const cursor = session?.historyCursor; if (!session || !session.piSessionId || !cwd || cursor == null) return Promise.resolve(); if (loadingEarlierRef.current.has(sessionId)) return Promise.resolve(); loadingEarlierRef.current.add(sessionId); const piSessionId = session.piSessionId; return Effect.runPromise( Effect.gen(function* () { const result = yield* Effect.tryPromise({ try: () => api.loadCanonicalSession(piSessionId, cwd, { before: cursor }), catch: (error) => error, }).pipe(Effect.result); if (result._tag !== "Success") return; const { messages: earlier } = foldSessionEvents(result.success.events); updateSession(sessionId, (current) => ({ ...current, messages: earlier.length > 0 ? [...earlier, ...current.messages] : current.messages, historyCursor: result.success.cursor, })); }).pipe( Effect.ensuring( Effect.sync(() => { loadingEarlierRef.current.delete(sessionId); }), ), ), ); }, [cwd, updateSession], ); const compact = useCallback( (sessionId: SessionId) => Effect.runPromise( Effect.gen(function* () { const session = tabsRef.current.find((tab) => tab.id === sessionId); if (!session || !modelId) return; updateSession(sessionId, (s) => ({ ...s, error: "" })); const result = yield* Effect.tryPromise({ try: () => api.compactSession({ sessionId: session.id, modelId, thinkingLevel, toolAccess, cwd: cwd.trim() || undefined, piSessionId: session.piSessionId, browserToolEnabled, browserSessionId: session.id, browserBackend, skills: selectionForRef.current(sessionId).skills ?? EMPTY_SKILLS, promptTemplates: selectionForRef.current(sessionId).promptTemplates ?? EMPTY_PROMPT_TEMPLATES, }), catch: (error) => error, }); const nextSessionId = result.status?.piSessionId || session.piSessionId; if (nextSessionId) { yield* Effect.tryPromise({ try: () => loadAndReplay(nextSessionId, sessionId), catch: (error) => error, }); } updateSession(sessionId, (s) => ({ ...s, contextUsage: api.runtimeContextUsage(result.status ?? null, null), tokenStats: undefined, })); }).pipe( Effect.catch((error) => Effect.sync(() => { updateSession(sessionId, (s) => ({ ...s, error: error instanceof Error ? error.message : "Compaction failed", })); }), ), ), ), [browserToolEnabled, browserBackend, cwd, loadAndReplay, modelId, thinkingLevel, updateSession], ); const acceptsControl = useCallback( async ( tab: { status: Session["status"]; piSessionId?: string | null }, runtime: string, ): Promise => { // "stopping" counts: the composer still draws itself as running there, and // a turn being torn down can still take a follow-up for the next one. if (tab.status !== "running" && tab.status !== "starting" && tab.status !== "stopping") { return false; } const status = await loadRuntimeStatusCb(runtime, tab.piSessionId).catch(() => null); return runtimeStatusAcceptsControl(status, tab.piSessionId); }, [loadRuntimeStatusCb], ); return useMemo( () => ({ submitPrompt, sendControl, loadRuntimeStatus: loadRuntimeStatusCb, abortTurn, loadAndReplay, loadEarlier, compact, acceptsControl, }), [ submitPrompt, sendControl, loadRuntimeStatusCb, abortTurn, loadAndReplay, loadEarlier, compact, acceptsControl, ], ); }