diff --git a/apps/mobile/.maestro/flows/conversation-lab.yml b/apps/mobile/.maestro/flows/conversation-lab.yml new file mode 100644 index 00000000000..a21b90e9007 --- /dev/null +++ b/apps/mobile/.maestro/flows/conversation-lab.yml @@ -0,0 +1,29 @@ +# Scroll-discipline regression suite. The lab screen +# (screens/(authenticated)/debug/conversation-lab) runs scripted +# stream/dip/overlay/keyboard/send-anchor interactions against the REAL +# Conversation component and self-asserts each invariant; this flow just +# drives it and fails if any scenario reports FAIL. Run on a signed-in +# dev-client sim: +# maestro test .maestro/flows/conversation-lab.yml +# +# Known gaps (add scenarios before touching the related code paths): +# pagination prepend while parked/anchored, empty-thread first send, +# rotation reflow (content can dip far beyond SPACER_SLACK), tool-sheet +# row expansion above the viewport. +appId: sh.superset.mobile +--- +- openLink: "superset://debug/conversation-lab" +- extendedWaitUntil: + visible: "lab-run-all" + timeout: 30000 +- tapOn: "lab-run-all" +- extendedWaitUntil: + visible: "state: done" + timeout: 120000 +- assertVisible: "followPin: PASS" +- assertVisible: "noMoveUnpinned: PASS" +- assertVisible: "overlayResolve: PASS" +- assertVisible: "keyboardPinned: PASS" +- assertVisible: "keyboardUnpinned: PASS" +- assertVisible: "sendAnchor: PASS" +- assertVisible: "sendAnchorFromHistory: PASS" diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 1e4fda45adc..ec6e1c5b297 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -58,10 +58,6 @@ export default function AuthenticatedLayout() { name="workspace/[id]/chat/[sessionId]" options={{ ...glassHeaderOptions, title: "Chat" }} /> - { return context; }; +/** Snapshot of the scroll bookkeeping — lab/regression-test surface. */ +export interface ConversationScrollState { + contentH: number; + isAtBottom: boolean; + offset: number; + pinned: boolean; + rawContentH: number; + spacer: number; + viewportH: number; +} + +export interface ConversationController { + /** + * ChatGPT-style send anchor: scrolls the item at `index` (the just-sent + * user message) to the top of the viewport and leaves whitespace below + * for the reply to stream into. Follow-to-bottom stays OFF afterwards — + * the reader re-arms it by scrolling to the end or tapping the button. + */ + scrollToAnchor: (index: number) => void; + /** Lab/regression-test surface (see the conversation-lab debug screen) — + * not for product code. */ + getScrollState: () => ConversationScrollState; + scrollTo: (offset: number, animated: boolean) => void; + setPinned: (pinned: boolean) => void; +} + export type ConversationProps = Omit< LegendListProps, "children" | "data" | "renderItem" @@ -49,8 +115,32 @@ export type ConversationProps = Omit< className?: string; contentContainerClassName?: string; children?: React.ReactNode; + /** Distance from the viewport top an anchored item rests at (header inset). */ + anchorOffsetTop?: number; + /** The content container's paddingBottom — part of the distance between + * the last item and the native content end. */ + contentPaddingBottom?: number; + controllerRef?: React.Ref; }; +/** + * Chat scroll discipline (the "soft pin" everyone expects from ChatGPT/Claude + * mobile), implemented around one invariant — the list only ever moves when + * the user asked it to: + * + * - Follow-to-bottom is ON only while `pinned`. A drag start unpins + * instantly (an upward swipe never fights the autoscroll); ending a + * drag/momentum at the bottom re-pins; the scroll-down button re-pins. + * Programmatic/streaming scroll events never re-pin by themselves. + * - Trailing scroll range is banked as a native bottom contentInset (NOT a + * spacer view: content-size and child-layout events arrive out of phase, + * and deriving "content minus spacer" from them oscillates). The inset + * keeps the current offset valid through bottom overlays resolving, the + * keyboard hiding, content dips, and the send anchor — the stream then + * consumes the range in place, so the view never moves involuntarily. + * - Viewport SHRINK while pinned (keyboard up, permission stack in) re-pins + * to keep the newest content visible; viewport GROWTH never moves content. + */ export const Conversation = ({ data, renderItem, @@ -59,65 +149,676 @@ export const Conversation = ({ contentContainerStyle, onScroll, children, + anchorOffsetTop = 0, + contentPaddingBottom = 0, + controllerRef, + ListFooterComponent, ...listProps }: ConversationProps) => { const listRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); + const [spacer, setSpacer] = useState(0); + const [revealed, setRevealed] = useState(false); + const revealedRef = useRef(false); + const revealTimerRef = useRef | null>(null); const resolvedContentContainerStyle = useResolveClassNames( contentContainerClassName ?? "", ); - const handleScroll = useCallback( + const pinnedRef = useRef(true); + const isDraggingRef = useRef(false); + const offsetRef = useRef(0); + const viewportHRef = useRef(0); + /** Viewport height as of the last onLayout — the shrink/growth branches + * compare against this, never viewportHRef (scroll events update that). */ + const lastLayoutHRef = useRef(0); + /** Largest viewport this screen has had — the spacer banks scroll range + * against THIS height so a bottom overlay leaving never clamps. */ + const maxViewportHRef = useRef(0); + const contentHRef = useRef(0); + const spacerRef = useRef(0); + /** While an anchor scroll is in flight the spacer holds a full-viewport + * floor so the scroll target exists; released when the scroll settles. */ + const anchorFloorRef = useRef(false); + const anchorTimerRef = useRef | null>(null); + /** Anchor scroll waiting for the just-sent tail to measure. */ + const pendingAnchorRef = useRef(false); + /** Last anchor target, for the settle snap (animated scrollTo lands a few + * points off; the final non-animated snap makes the anchor exact). */ + const anchorTargetRef = useRef(null); + /** The anchored item, kept until the anchor fully settles so the target + * can be recomputed from fresh measurements at each settle round. The KEY + * is the identity — pagination can prepend rows mid-anchor and shift + * every index (the index is the fallback when no keyExtractor exists). */ + const anchorIndexRef = useRef(null); + const anchorKeyRef = useRef(null); + const anchorSettleRoundsRef = useRef(0); + const lastScrollAtRef = useRef(0); + /** Provided footer height, measured natively. */ + const footerHRef = useRef(0); + const dataLengthRef = useRef(0); + const dataRef = useRef(data); + const keyExtractorRef = useRef< + ((item: ItemT, index: number) => string) | undefined + >(undefined); + /** Viewport growth from the keyboard sliding away reads as "the sheet + * left", not "the chat scrolled" — those growths keep following. */ + const keyboardHidingRef = useRef(false); + /** True from drag-begin until that gesture's momentum dies. Re-pinning is + * gated on it: programmatic scrolls also emit momentum-end events, and + * only the USER coming to rest at the bottom may re-arm following. */ + const gestureRef = useRef(false); + + useEffect(() => { + const willHide = Keyboard.addListener("keyboardWillHide", () => { + keyboardHidingRef.current = true; + }); + const didHide = Keyboard.addListener("keyboardDidHide", () => { + setTimeout(() => { + keyboardHidingRef.current = false; + }, 100); + }); + return () => { + willHide.remove(); + didHide.remove(); + if (anchorTimerRef.current) clearTimeout(anchorTimerRef.current); + }; + }, []); + + const setPinnedBoth = useCallback((value: boolean) => { + pinnedRef.current = value; + }, []); + + const reveal = useCallback(() => { + if (revealedRef.current) return; + revealedRef.current = true; + if (revealTimerRef.current) { + clearTimeout(revealTimerRef.current); + revealTimerRef.current = null; + } + setRevealed(true); + }, []); + + /** Every content change re-arms the quiet gate; once the initial page + * stops measuring, the follow has already parked us at the end. */ + const armRevealCheck = useCallback(() => { + if (revealedRef.current) return; + if (revealTimerRef.current) clearTimeout(revealTimerRef.current); + revealTimerRef.current = setTimeout(reveal, REVEAL_QUIET_MS); + }, [reveal]); + + useEffect(() => { + const cap = setTimeout(reveal, REVEAL_CAP_MS); + return () => { + clearTimeout(cap); + if (revealTimerRef.current) clearTimeout(revealTimerRef.current); + }; + }, [reveal]); + + /** The native content height. The spacer lives in contentInset, never in + * the content itself, so no subtraction (and no phase mixing) is needed. */ + const rawContentH = useCallback(() => contentHRef.current, []); + + const applySpacer = useCallback((next: number) => { + if (Math.abs(next - spacerRef.current) >= 1) { + spacerRef.current = next; + setSpacer(next); + } + }, []); + + /** + * Spacer maintenance, run on CONTENT and discrete LAYOUT events — never on + * scroll events (coupling it to scroll makes iOS bounce feed back into the + * content size and the list goes haywire). + * + * The scroll range a resting reader occupies must exist BEFORE anything + * takes it away: iOS clamps the offset natively, in the same frame the + * content shrinks or the viewport grows, so reacting afterwards is always + * one frame too late and the view visibly yanks. Two consequences: + * - the target is computed against the LARGEST viewport this screen has + * had (`maxViewportHRef`), so a bottom overlay (permission stack, + * keyboard) leaving never clamps — the range it frees is pre-banked; + * - near the end an extra SPACER_SLACK absorbs transient content dips + * (streaming re-measure shrinks content by a few dozen points before + * growing back). + * As content streams in, the target shrinks in lockstep, so the spacer is + * consumed in place and the view never moves; away from the end the + * spacer is 0 and the list behaves like a plain scroll view. + */ + const syncSpacer = useCallback(() => { + const floor = anchorFloorRef.current ? viewportHRef.current : 0; + const protectedNeed = + offsetRef.current + maxViewportHRef.current - rawContentH(); + const target = + protectedNeed > -SPACER_SLACK + ? Math.max(0, protectedNeed) + SPACER_SLACK + : 0; + const next = Math.max(floor, target); + dbg("syncSpacer", { + floor, + from: Math.round(spacerRef.current), + offset: Math.round(offsetRef.current), + protectedNeed: Math.round(protectedNeed), + raw: Math.round(rawContentH()), + to: Math.round(next), + }); + applySpacer(next); + }, [applySpacer, rawContentH]); + + /** + * Follow-to-bottom target. NEVER LegendList's scrollToEnd: that computes + * the target from its internal ESTIMATED total size, which after a + * remount/replay can exceed the laid-out content by thousands of points — + * and iOS setContentOffset doesn't clamp, so the view parks in blank + * space. The native-reported content height is the truth. + */ + const scrollToMeasuredEnd = useCallback( + (animated: boolean) => { + // The RAW end: the spacer keeps banked blank range past the true + // content, and "the bottom" the reader means is the newest content. + const target = Math.max(0, rawContentH() - viewportHRef.current); + dbg("scrollToMeasuredEnd", { animated, target: Math.round(target) }); + void listRef.current?.scrollToOffset({ animated, offset: target }); + }, + [rawContentH], + ); + + /** Signed distance from the viewport bottom edge to the TRUE end of + * content — negative when the view sits inside the banked blank inset. */ + const distanceToEnd = useCallback( + () => rawContentH() - viewportHRef.current - offsetRef.current, + [rawContentH], + ); + + const updateIsAtBottom = useCallback(() => { + // Before the first layout the viewport reads as 0 and every distance + // looks huge — keep the initial at-bottom assumption until measured. + if (viewportHRef.current === 0) return; + // Two-sided, matching the re-pin rule (evaluateRestingPin): a reader + // resting deep inside the banked blank space is NOT following, so the + // button must be there to take them back. + setIsAtBottom(Math.abs(distanceToEnd()) < AT_BOTTOM_THRESHOLD); + }, [distanceToEnd]); + + const readScrollEvent = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const distanceFromBottom = - contentSize.height - layoutMeasurement.height - contentOffset.y; - setIsAtBottom(distanceFromBottom < AT_BOTTOM_THRESHOLD); + offsetRef.current = contentOffset.y; + contentHRef.current = contentSize.height; + viewportHRef.current = layoutMeasurement.height; + }, + [], + ); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + readScrollEvent(event); + lastScrollAtRef.current = Date.now(); + dbg("scroll", { + offset: Math.round(offsetRef.current), + contentH: Math.round(contentHRef.current), + viewport: Math.round(viewportHRef.current), + }); + updateIsAtBottom(); onScroll?.(event); }, - [onScroll], + [onScroll, readScrollEvent, updateIsAtBottom], ); - const scrollToBottom = useCallback(() => { - listRef.current?.scrollToEnd({ animated: true }); + /** Aborts an in-flight send anchor without another scroll — the current + * offset is preserved (syncSpacer keeps its range valid). */ + const cancelAnchor = useCallback(() => { + if (anchorTimerRef.current) { + clearTimeout(anchorTimerRef.current); + anchorTimerRef.current = null; + } + pendingAnchorRef.current = false; + anchorFloorRef.current = false; + anchorTargetRef.current = null; + anchorIndexRef.current = null; + anchorKeyRef.current = null; + anchorSettleRoundsRef.current = 0; + syncSpacer(); + }, [syncSpacer]); + + const handleScrollBeginDrag = useCallback(() => { + isDraggingRef.current = true; + gestureRef.current = true; + // The user took the wheel: any programmatic following stops NOW — + // including an anchor mid-flight (its settle legs must never fight + // the drag). + setPinnedBoth(false); + if (anchorFloorRef.current || pendingAnchorRef.current) { + cancelAnchor(); + } + }, [cancelAnchor, setPinnedBoth]); + + /** Re-pin only when a user-driven scroll comes to rest AT the true end of + * content (two-sided: deep inside banked blank space doesn't count), and + * never while an anchor scroll is still in flight — its momentum-end + * fires here, and following would yank the fresh anchor to the bottom. */ + const evaluateRestingPin = useCallback( + (event: NativeSyntheticEvent) => { + readScrollEvent(event); + if (anchorFloorRef.current) return; + const distance = distanceToEnd(); + dbg("restingPin", { distance: Math.round(distance) }); + if (Math.abs(distance) < AT_BOTTOM_THRESHOLD) { + setPinnedBoth(true); + } + }, + [distanceToEnd, readScrollEvent, setPinnedBoth], + ); + + const handleScrollEndDrag = useCallback( + (event: NativeSyntheticEvent) => { + isDraggingRef.current = false; + // A fling isn't at rest yet — momentum-end evaluates it. Only a + // finger-up with no residual velocity settles the gesture here. + const velocity = Math.abs(event.nativeEvent.velocity?.y ?? 0); + if (velocity < REST_VELOCITY) { + gestureRef.current = false; + evaluateRestingPin(event); + } + }, + [evaluateRestingPin], + ); + + /** + * Anchor target computed FROM THE BOTTOM, in native coordinates only: + * `content end − paddingBottom − footer − tail items` is exact for the + * freshly measured tail, while absolute item positions + * (`positionAtIndex`/`scrollToIndex`) lean on estimates for every + * unmeasured item above and can land pages away. Note contentH itself + * still contains estimates for unmounted MIDDLE items (reader far up in + * history), so a target can be off until the scroll passes through and + * measures them — the settle rounds below correct for that. + */ + const computeAnchorTarget = useCallback( + (index: number): { allMeasured: boolean; target: number } | null => { + const list = listRef.current; + if (!list) return null; + const state = list.getState(); + let tail = 0; + let allMeasured = true; + for (let i = index; i < dataLengthRef.current; i += 1) { + const size = state.sizeAtIndex(i); + if (Number.isFinite(size) && size > 0) { + tail += size; + } else { + allMeasured = false; + tail += ESTIMATED_TAIL_ITEM; + } + } + return { + allMeasured, + target: Math.max( + 0, + contentHRef.current - + contentPaddingBottom - + footerHRef.current - + tail - + anchorOffsetTop, + ), + }; + }, + [anchorOffsetTop, contentPaddingBottom], + ); + + /** Re-resolve the anchored item's index — pagination can prepend rows + * mid-anchor, shifting every index under a stored number. */ + const resolveAnchorIndex = useCallback((): number | null => { + const key = anchorKeyRef.current; + const extract = keyExtractorRef.current; + if (key !== null && extract !== undefined) { + const items = dataRef.current; + // The anchor is a just-sent message — scan from the end. + for (let index = items.length - 1; index >= 0; index -= 1) { + if (extract(items[index], index) === key) return index; + } + return null; + } + return anchorIndexRef.current; }, []); + const settleAnchor = useCallback( + (fromTimer = false) => { + if (!anchorFloorRef.current) return; + // A slow far-history leg outlives the fallback timer — only settle + // from the timer once scrolling has actually gone quiet. + if (fromTimer && Date.now() - lastScrollAtRef.current < ANCHOR_QUIET_MS) { + if (anchorTimerRef.current) clearTimeout(anchorTimerRef.current); + anchorTimerRef.current = setTimeout( + () => settleAnchor(true), + ANCHOR_QUIET_MS, + ); + return; + } + if (anchorTimerRef.current) { + clearTimeout(anchorTimerRef.current); + anchorTimerRef.current = null; + } + const index = resolveAnchorIndex(); + const fresh = index !== null ? computeAnchorTarget(index) : null; + const target = fresh?.target ?? anchorTargetRef.current; + dbg("settleAnchor", { + offset: Math.round(offsetRef.current), + round: anchorSettleRoundsRef.current, + target: target === null ? null : Math.round(target), + }); + // The first leg aimed at a target computed from ESTIMATED heights for + // any unmounted history between the reader and the end; scrolling + // through measured them, so recompute — if the goalposts moved more + // than a snap's worth, run a short corrective leg before releasing. + if ( + target !== null && + Math.abs(offsetRef.current - target) > ANCHOR_SNAP_TOLERANCE && + anchorSettleRoundsRef.current < ANCHOR_SETTLE_MAX_ROUNDS + ) { + anchorSettleRoundsRef.current += 1; + anchorTargetRef.current = target; + anchorTimerRef.current = setTimeout( + () => settleAnchor(true), + ANCHOR_SETTLE_FALLBACK_MS, + ); + void listRef.current?.scrollToOffset({ + animated: true, + offset: target, + }); + return; + } + anchorFloorRef.current = false; + anchorTargetRef.current = null; + anchorIndexRef.current = null; + anchorKeyRef.current = null; + anchorSettleRoundsRef.current = 0; + // Animated scrollTo settles a few points off target — snap the last + // bit so the anchored message sits exactly where it should. + if ( + target !== null && + Math.abs(offsetRef.current - target) <= ANCHOR_SNAP_TOLERANCE + ) { + void listRef.current?.scrollToOffset({ + animated: false, + offset: target, + }); + offsetRef.current = target; + } + syncSpacer(); + }, + [computeAnchorTarget, resolveAnchorIndex, syncSpacer], + ); + + const handleMomentumScrollEnd = useCallback( + (event: NativeSyntheticEvent) => { + settleAnchor(); + // Programmatic scrolls emit momentum-end too — only a user + // gesture coming to rest may re-pin. + if (!isDraggingRef.current && gestureRef.current) { + gestureRef.current = false; + evaluateRestingPin(event); + } + }, + [evaluateRestingPin, settleAnchor], + ); + + const handleContentSizeChange = useCallback( + (_width: number, height: number) => { + contentHRef.current = height; + dbg("contentSize", { + height: Math.round(height), + raw: Math.round(rawContentH()), + spacer: Math.round(spacerRef.current), + offset: Math.round(offsetRef.current), + pinned: pinnedRef.current, + }); + updateIsAtBottom(); + syncSpacer(); + // Manual follow-to-bottom. Gated on refs, not props, so a stream + // chunk landing mid-gesture or mid-anchor can never race a stale + // list-level autoscroll into motion the user didn't ask for. + if ( + pinnedRef.current && + !isDraggingRef.current && + !anchorFloorRef.current + ) { + scrollToMeasuredEnd(false); + } + armRevealCheck(); + }, + [ + armRevealCheck, + rawContentH, + scrollToMeasuredEnd, + syncSpacer, + updateIsAtBottom, + ], + ); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const nextHeight = event.nativeEvent.layout.height; + // Compare against the last LAYOUT height, not viewportHRef — scroll + // events also carry the viewport and would consume the transition + // before this handler could branch on it. + const previousHeight = lastLayoutHRef.current; + lastLayoutHRef.current = nextHeight; + viewportHRef.current = nextHeight; + maxViewportHRef.current = Math.max(maxViewportHRef.current, nextHeight); + dbg("layout", { + prev: Math.round(previousHeight), + next: Math.round(nextHeight), + pinned: pinnedRef.current, + kbHiding: keyboardHidingRef.current, + offset: Math.round(offsetRef.current), + spacer: Math.round(spacerRef.current), + }); + if (previousHeight === 0) { + updateIsAtBottom(); + return; + } + if (nextHeight < previousHeight) { + if (pinnedRef.current) { + // Keyboard up / permission stack in: keep the newest content + // visible above the shrunken bottom edge. + scrollToMeasuredEnd(false); + } + // Anchored readers: nothing moves — the banked spacer already + // covers the range, syncSpacer just re-evaluates the target. + syncSpacer(); + } else if (nextHeight > previousHeight) { + if (pinnedRef.current && keyboardHidingRef.current) { + scrollToMeasuredEnd(false); + } else { + if (pinnedRef.current) { + // Viewport grew back (permission stack resolved). Re-pinning + // would shift the chat down to fill the gap — pause + // following instead; the reader re-arms with a scroll or + // the button. + setPinnedBoth(false); + } + // The banked spacer already covers the freed range (iOS + // clamps in the same frame, so growing it NOW would be too + // late) — the stream consumes the space in place. + syncSpacer(); + } + } + updateIsAtBottom(); + }, + [scrollToMeasuredEnd, setPinnedBoth, syncSpacer, updateIsAtBottom], + ); + + const scrollToBottom = useCallback(() => { + // Going to the true bottom: abort any in-flight anchor (a queued settle + // leg would yank the view right back) and drop the leftover anchor + // whitespace, so "the end" is the newest content, not blank space. + cancelAnchor(); + applySpacer(0); + setPinnedBoth(true); + requestAnimationFrame(() => { + // The content height still includes the spacer until the removal + // lays out — target the true end, not the stale total. + const target = Math.max(0, rawContentH() - viewportHRef.current); + dbg("scrollToBottom", { target: Math.round(target) }); + void listRef.current?.scrollToOffset({ animated: true, offset: target }); + }); + }, [applySpacer, cancelAnchor, rawContentH, setPinnedBoth]); + + /** First anchor leg: wait (a few frames) for the just-sent tail to + * measure, then scroll to the computed target. */ + const firePendingAnchorScroll = useCallback( + (attempt = 0) => { + if (!pendingAnchorRef.current) return; + const index = resolveAnchorIndex(); + if (index === null) { + // The anchored item left the data — nothing to anchor to. + cancelAnchor(); + return; + } + const computed = computeAnchorTarget(index); + if (computed === null) return; + if (!computed.allMeasured && attempt < ANCHOR_MEASURE_ATTEMPTS) { + requestAnimationFrame(() => firePendingAnchorScroll(attempt + 1)); + return; + } + pendingAnchorRef.current = false; + anchorIndexRef.current = index; + anchorTargetRef.current = computed.target; + dbg("anchorScroll", { + attempt, + contentH: Math.round(contentHRef.current), + footerH: Math.round(footerHRef.current), + target: Math.round(computed.target), + }); + void listRef.current?.scrollToOffset({ + animated: true, + offset: computed.target, + }); + }, + [cancelAnchor, computeAnchorTarget, resolveAnchorIndex], + ); + + useImperativeHandle( + controllerRef, + () => ({ + scrollToAnchor: (index: number) => { + dbg("scrollToAnchor", { index }); + setPinnedBoth(false); + anchorFloorRef.current = true; + pendingAnchorRef.current = true; + anchorIndexRef.current = index; + // Anchor by key when possible — pagination can prepend rows + // mid-anchor and shift every index. + const item = dataRef.current[index]; + const extract = keyExtractorRef.current; + anchorKeyRef.current = + item !== undefined && extract !== undefined + ? extract(item, index) + : null; + anchorSettleRoundsRef.current = 0; + // Full-viewport floor: guarantees the scroll range to put the + // message at the top exists no matter how short the thread is. + // The inset is a native prop — no layout pass to wait for. + applySpacer(Math.max(spacerRef.current, viewportHRef.current)); + if (anchorTimerRef.current) clearTimeout(anchorTimerRef.current); + anchorTimerRef.current = setTimeout( + () => settleAnchor(true), + ANCHOR_SETTLE_FALLBACK_MS, + ); + requestAnimationFrame(() => firePendingAnchorScroll()); + }, + getScrollState: () => ({ + contentH: contentHRef.current, + isAtBottom: Math.abs(distanceToEnd()) < AT_BOTTOM_THRESHOLD, + offset: offsetRef.current, + pinned: pinnedRef.current, + rawContentH: rawContentH(), + spacer: spacerRef.current, + viewportH: viewportHRef.current, + }), + scrollTo: (offset: number, animated: boolean) => { + void listRef.current?.scrollToOffset({ animated, offset }); + }, + setPinned: (pinned: boolean) => { + setPinnedBoth(pinned); + }, + }), + [ + applySpacer, + distanceToEnd, + firePendingAnchorScroll, + rawContentH, + setPinnedBoth, + settleAnchor, + ], + ); + const contextValue = useMemo( () => ({ isAtBottom, scrollToBottom }), [isAtBottom, scrollToBottom], ); + dataLengthRef.current = data.length; + dataRef.current = data; + keyExtractorRef.current = ( + listProps as { keyExtractor?: (item: ItemT, index: number) => string } + ).keyExtractor; + + const footer = ( + { + footerHRef.current = event.nativeEvent.layout.height; + }} + > + {typeof ListFooterComponent === "function" ? ( + + ) : ( + (ListFooterComponent ?? null) + )} + + ); + return ( - + {/* Invisible (but laid out) until the first page settles at the + bottom — the first visible frame is the full newest page. */} + {/* Not alignItemsAtEnd: bottom-anchoring short threads means ANY item growth (expanding a tool card) shoves the whole thread upward. Top-aligned short threads grow downward instead, so the tapped trigger stays put; long threads are unaffected. */} {children} diff --git a/apps/mobile/lib/collections/collections.ts b/apps/mobile/lib/collections/collections.ts index 16cee6085d3..7407be0ec36 100644 --- a/apps/mobile/lib/collections/collections.ts +++ b/apps/mobile/lib/collections/collections.ts @@ -4,7 +4,6 @@ import { snakeCamelMapper, } from "@electric-sql/client"; import type { - SelectChatSession, SelectGithubPullRequest, SelectInvitation, SelectMember, @@ -72,7 +71,6 @@ interface OrgCollections { v2Projects: Collection; v2Hosts: Collection; subscriptions: Collection; - chatSessions: Collection; githubPullRequests: Collection; } @@ -257,21 +255,6 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const chatSessions = createCollection( - electricCollectionOptions({ - id: `chat-sessions-${organizationId}`, - shapeOptions: { - url: electricUrl, - params: { table: "chat_sessions", organizationId }, - headers: electricHeaders, - columnMapper, - parser, - onError: handleElectricSyncError, - }, - getKey: (item) => item.id, - }), - ); - return { tasks, taskStatuses, @@ -282,7 +265,6 @@ function createOrgCollections(organizationId: string): OrgCollections { v2Projects, v2Hosts, subscriptions, - chatSessions, githubPullRequests, }; } diff --git a/apps/mobile/lib/host-service/client.ts b/apps/mobile/lib/host-service/client.ts index a5276e16cab..6ec5bf36d21 100644 --- a/apps/mobile/lib/host-service/client.ts +++ b/apps/mobile/lib/host-service/client.ts @@ -1,5 +1,6 @@ -// INTERIM: AppRouter comes from generated dist-types until the wire contract -// moves to a neutral package — see packages/host-service/docs/interim-router-types.md +// INTERIM: AppRouter comes from host-service's generated dist-types (compiled +// declarations, so Expo/RN never typechecks its Node-flavored source) until +// the wire contract moves to a neutral package. import type { AppRouter } from "@superset/host-service/router"; import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { createTRPCClient, httpLink, type TRPCClient } from "@trpc/client"; diff --git a/apps/mobile/lib/host/client.ts b/apps/mobile/lib/host/client.ts index 3223bdbf7cb..c410c53b0d7 100644 --- a/apps/mobile/lib/host/client.ts +++ b/apps/mobile/lib/host/client.ts @@ -1,19 +1,32 @@ import { - type AcpHostClient, - createAcpHostClient, createHostTransport, + createSessionsHostClient, + type SessionsHostClient, } from "@superset/host-client"; +import { + createSessionsSyncClient, + type SessionsSyncClient, + type WebSocketLike, +} from "@superset/host-service-sync/client"; import type { - AcpSessionsApi, - SessionScopedState, - SessionsPage, -} from "@superset/session-protocol"; + CancelTurnReceipt, + ContentBlock, + CreateSessionResult, + HostSnapshot, + PermissionOutcome, + ResolvePermissionReceipt, + SubmitTurnReceipt, + UpdateSessionReceipt, +} from "@superset/host-service-sync/protocol"; +import * as Crypto from "expo-crypto"; import { authClient, getJwt, setJwt } from "@/lib/auth/client"; import { env } from "@/lib/env"; -// Mobile binding of @superset/host-client: this file only supplies the relay -// URL from Expo env and the JWT from the auth client — transport mechanics -// (SuperJSON, 401-retry, WS token minting) live in the shared package. +// Mobile binding of @superset/host-client's canonical sessions surface: this +// file only supplies the relay URL from Expo env and the JWT from the auth +// client — transport mechanics (SuperJSON, 401-retry, WS token minting) live +// in the shared package, and the read plane is a per-host +// createSessionsSyncClient over `/sessions/sync`. export function getRelayUrl(): string { const url = env.EXPO_PUBLIC_RELAY_URL; @@ -59,37 +72,148 @@ function expiresSoon(token: string): boolean { } } -let acp: AcpHostClient | null = null; +let sessionsClient: SessionsHostClient | null = null; -function getAcpClient(): AcpHostClient { - acp ??= createAcpHostClient( +function getSessionsClient(): SessionsHostClient { + sessionsClient ??= createSessionsHostClient( createHostTransport({ getRelayUrl, getToken: getHostAuthToken }), ); - return acp; + return sessionsClient; +} + +/** + * The relay's WS proxy does not negotiate subprotocols, so unlike the + * package default this factory opens the socket without requesting one — + * the host's `/sessions/sync` route accepts either. + */ +function createRelayWebSocket(url: string): WebSocketLike { + const nativeSocket = new WebSocket(url); + const adapter: WebSocketLike = { + get readyState() { + return nativeSocket.readyState; + }, + get bufferedAmount() { + return nativeSocket.bufferedAmount; + }, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + send(data) { + nativeSocket.send(data); + }, + close(code, reason) { + nativeSocket.close(code, reason); + }, + }; + nativeSocket.onopen = () => adapter.onopen?.(); + nativeSocket.onmessage = (event) => adapter.onmessage?.({ data: event.data }); + nativeSocket.onclose = (event) => + adapter.onclose?.({ + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }); + nativeSocket.onerror = (event) => adapter.onerror?.(event); + return adapter; +} + +const clientInstanceId = Crypto.randomUUID(); +const syncClients = new Map(); + +/** + * One long-lived sync client per host, connected on first use. Screens + * subscribe/release sessions through it (useRetainSession); the socket and + * host stream stay up for the app's lifetime so the home list stays fresh. + */ +export function getHostSyncClient(routingKey: string): SessionsSyncClient { + let client = syncClients.get(routingKey); + if (!client) { + const sessions = getSessionsClient(); + client = createSessionsSyncClient({ + clientInstanceId, + clientVersion: "mobile", + syncUrl: sessions.syncUrl(routingKey), + api: sessions.syncApi(routingKey), + createWebSocket: (url) => createRelayWebSocket(url), + }); + client.connect(); + syncClients.set(routingKey, client); + } + return client; +} + +function mintRequestId(): string { + return Crypto.randomUUID(); +} + +export function listSessions(routingKey: string): Promise { + return getSessionsClient().list(routingKey); } -export function listAcpSessions( +export function createSession( routingKey: string, - input?: { workspaceId?: string; cursor?: string; limit?: number }, -): Promise { - return getAcpClient().listSessions(routingKey, input); + input: { workspaceId: string; activeModel?: string | null }, +): Promise { + return getSessionsClient().create(routingKey, { + requestId: mintRequestId(), + workspaceId: input.workspaceId, + agentId: "claude-code", + title: null, + settings: { + activeModel: input.activeModel ?? null, + activeMode: null, + effort: null, + configuration: {}, + }, + }); } -export function createAcpSession( +export function updateSession( routingKey: string, - input: { sessionId: string; workspaceId: string }, -): Promise { - return getAcpClient().createSession(routingKey, input); + input: { + sessionId: string; + title?: string | null; + archived?: boolean; + settings?: { activeModel?: string; activeMode?: string; effort?: string }; + }, +): Promise { + return getSessionsClient().update(routingKey, { + requestId: mintRequestId(), + ...input, + }); } -export function createAcpSessionsApi(routingKey: string): AcpSessionsApi { - return getAcpClient().sessionsApi(routingKey); +export function submitTurn( + routingKey: string, + input: { sessionId: string; threadId: string; content: ContentBlock[] }, +): Promise { + return getSessionsClient().submitTurn(routingKey, { + requestId: mintRequestId(), + ...input, + }); } -/** WS endpoint factory for the live update stream. */ -export function createAcpStreamUrl(options: { - routingKey: string; - sessionId: string; -}): () => Promise { - return getAcpClient().streamUrl(options); +export function cancelTurn( + routingKey: string, + input: { sessionId: string; turnId: string }, +): Promise { + return getSessionsClient().cancelTurn(routingKey, { + requestId: mintRequestId(), + ...input, + }); +} + +export function resolvePermission( + routingKey: string, + input: { + sessionId: string; + permissionId: string; + outcome: PermissionOutcome; + }, +): Promise { + return getSessionsClient().resolvePermission(routingKey, { + requestId: mintRequestId(), + ...input, + }); } diff --git a/apps/mobile/lib/trpc/host-chat-types.ts b/apps/mobile/lib/trpc/host-chat-types.ts deleted file mode 100644 index 93747d8d1f6..00000000000 --- a/apps/mobile/lib/trpc/host-chat-types.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Minimal local types for the host-service chat runtime surface that mobile - * uses over the relay. INTERIM — see `plans/mobile-chat-runtime.md` (D2). - * - * We do NOT import `@superset/host-service`'s `AppRouter`. Note this is a choice, - * not a hard block: setting `allowImportingTsExtensions: true` + node types in the - * mobile tsconfig makes the type-only import typecheck cleanly (desktop does exactly - * that — it extends `@superset/typescript/base.json`, which sets both). We decline - * because that would make node globals (`Buffer`, `process`) ambient in RN code that - * has no such runtime, and because `AppRouter = typeof appRouter` pulls the entire - * server type graph (db, daemon, mastra, node-pty) into every mobile typecheck. - * - * Caveat: this is a LOSSY subset and nothing enforces it against the router. The real - * shapes live in `@mastra/core/dist/harness/types.d.ts` (`HarnessDisplayState`, - * `HarnessMessage`, `HarnessMessageContent`) and are stricter than what's below — - * notably `content` is a 12-arm discriminated union, not `{ type; text? }`. - * - * Superseded by the normalized envelope (SCP v1, `packages/chat-protocol`), where - * host-service normalizes harness types so mastra/codex/claude-sdk types never cross - * the wire. Until then, keep this in sync by hand; the live E2E is the only check. - */ - -export interface ChatMessagePart { - type: string; - text?: string; - [key: string]: unknown; -} - -export interface ChatMessage { - id: string; - role: string; - content: ChatMessagePart[]; - createdAt?: string | Date; - stopReason?: string; - errorMessage?: string; -} - -export interface ChatDisplayState { - isRunning?: boolean; - currentMessage?: ChatMessage | null; - pendingApproval?: Record | null; - pendingQuestion?: Record | null; - pendingPlanApproval?: Record | null; - errorMessage?: string | null; - [key: string]: unknown; -} - -export interface ChatSnapshot { - displayState: ChatDisplayState; - messages: ChatMessage[]; -} - -export interface SendMessagePayload { - content: string; - files?: Array<{ data: string; mediaType: string; filename?: string }>; -} - -/** Per-send message metadata the host threads into the harness before the turn: - * `model` triggers `harness.switchModel({ scope: "thread" })`; `thinkingLevel` - * sets reasoning effort. Both are optional — the host keeps the current values - * when omitted. Mirrors the host router's `messageMetadataSchema`. */ -export interface MessageMetadata { - model?: string; - thinkingLevel?: "off" | "low" | "medium" | "high" | "xhigh"; -} - -interface SessionInput { - sessionId: string; - workspaceId: string; -} - -/** A live terminal-agent binding (read-only status), mirroring the host's - * `TerminalAgentBinding` (packages/host-service/src/terminal-agents/types.ts). - * Only live agent-bound sessions are returned (dead ones filtered server-side). - * Requires the agent lifecycle hooks to reach this host, so it can be empty - * even when PTY terminals exist — we merge it with `terminal.listSessions`. */ -export interface TerminalAgentBinding { - terminalId: string; - workspaceId: string; - agentId: string; - agentSessionId?: string; - definitionId?: string; - startedAt: number; - lastEventAt: number; - lastEventType: string; - /** The terminal's live title (OSC title sequence), when it has set one. */ - title: string | null; -} - -/** The subset of host-service `chat.*` procedures mobile calls, shaped like a - * tRPC proxy client (`.query`/`.mutate`). */ -export interface HostChatClient { - chat: { - getSnapshot: { query: (input: SessionInput) => Promise }; - sendMessage: { - mutate: ( - input: SessionInput & { - payload: SendMessagePayload; - metadata?: MessageMetadata; - }, - ) => Promise; - }; - respondToApproval: { - mutate: ( - input: SessionInput & { - payload: { - decision: "approve" | "decline" | "always_allow_category"; - }; - }, - ) => Promise; - }; - respondToQuestion: { - mutate: ( - input: SessionInput & { - payload: { questionId: string; answer: string }; - }, - ) => Promise; - }; - respondToPlan: { - mutate: ( - input: SessionInput & { - payload: { - planId: string; - response: { action: "approved" | "rejected"; feedback?: string }; - }; - }, - ) => Promise; - }; - }; - terminalAgents: { - list: { - query: () => Promise; - }; - listByWorkspace: { - query: (input: { - workspaceId: string; - }) => Promise; - }; - }; -} diff --git a/apps/mobile/lib/trpc/host-client.ts b/apps/mobile/lib/trpc/host-client.ts index 978621080bd..1b75012e164 100644 --- a/apps/mobile/lib/trpc/host-client.ts +++ b/apps/mobile/lib/trpc/host-client.ts @@ -2,9 +2,36 @@ import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import superjson from "superjson"; import { authClient, getJwt, setJwt } from "../auth/client"; import { env } from "../env"; -import type { HostChatClient } from "./host-chat-types"; +/** A live terminal-agent binding (read-only status), mirroring the host's + * `TerminalAgentBinding` (packages/host-service/src/terminal-agents/types.ts). + * Only live agent-bound sessions are returned (dead ones filtered server-side). + * Requires the agent lifecycle hooks to reach this host, so it can be empty + * even when PTY terminals exist. */ +export interface TerminalAgentBinding { + terminalId: string; + workspaceId: string; + agentId: string; + agentSessionId?: string; + definitionId?: string; + startedAt: number; + lastEventAt: number; + lastEventType: string; + /** The terminal's live title (OSC title sequence), when it has set one. */ + title: string | null; +} -export type HostClient = HostChatClient; +/** The subset of host-service procedures mobile still calls over the plain + * relay tRPC path, shaped like a tRPC proxy client (`.query`/`.mutate`). + * Chat rides the canonical `sessions.*` surface in lib/host/client instead. + * Typed as a local facade rather than the server's `AppRouter` so the RN + * typecheck never pulls in the server type graph (db, daemon, node-pty). */ +export interface HostServiceFacade { + terminalAgents: { + list: { + query: () => Promise; + }; + }; +} /** * Routing key the relay uses to identify a host-service tunnel: @@ -30,17 +57,15 @@ export function getHostRelayTrpcUrl( /** * A tRPC client bound to one workspace's host-service, reached over the relay - * tunnel and authenticated with the user's JWT (the relay path accepts the same - * JWT the app already manages). We type it against a local `HostChatClient` - * facade rather than the server's `AppRouter` — an interim tradeoff, not a hard - * constraint; see host-chat-types.ts for the verified reasoning. + * tunnel and authenticated with the user's JWT (the relay path accepts the + * same JWT the app already manages). */ export function createHostClient(params: { organizationId: string; hostId: string; -}): HostChatClient { +}): HostServiceFacade { const url = getHostRelayTrpcUrl(params.organizationId, params.hostId); - // biome-ignore lint/suspicious/noExplicitAny: proxy client is retyped to the local HostChatClient facade. + // biome-ignore lint/suspicious/noExplicitAny: proxy client is retyped to the local HostServiceFacade. const client = createTRPCProxyClient({ links: [ httpBatchLink({ @@ -66,5 +91,5 @@ export function createHostClient(params: { }), ], }); - return client as unknown as HostChatClient; + return client as unknown as HostServiceFacade; } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 87ee34a361d..cf22e2f02e9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -55,7 +55,8 @@ "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/host-client": "workspace:*", - "@superset/session-protocol": "workspace:*", + "@superset/host-service-react": "workspace:*", + "@superset/host-service-sync": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@tanstack/db": "0.6.14", diff --git a/apps/mobile/plans/acp-vs-claude-sdk.html b/apps/mobile/plans/acp-vs-claude-sdk.html deleted file mode 100644 index fb62878d256..00000000000 --- a/apps/mobile/plans/acp-vs-claude-sdk.html +++ /dev/null @@ -1,2513 +0,0 @@ - - - - -ACP vs Claude SDK — as implemented - - - - - -
-
-

Current ACP implementation compared with the direct Claude SDK design · complex interactions reconstructed from source

-

ACP-aligned protocol vs Claude SDK harness

-

Both trees implement the same architecture — a seq-stamped envelope journal on the host, one WebSocket - replayed with ?since=, and a pure client-side fold into timeline items. What differs is the vocabulary riding - inside the envelope, and who does the interpretive work. UI code is excluded from every number below.

-

ACP = current PR implementation · claude-agent-acp 0.56.0 child process · @agentclientprotocol/sdk 1.2.0  ·  - Claude SDK = archived direct-SDK design · @anthropic-ai/claude-agent-sdk 0.3.202 in-process

-

fidelity sources (⑩–⑮, §6) = claude-agent-acp 0.56.0 dist · @anthropic-ai/claude-agent-sdk 0.3.201 types - (this tree) · @agentclientprotocol/codex-acp 1.1.2 npm tarball, source-inspected

-
- -
-

1Where they actually differ

-

Seven divergences (D1–D7) carry the wire-protocol signal; everything else in phases ①–⑨ is the same machine - twice. Ranked, roughly, by how much they should weigh on the decision. D8–D10 are a different species: not “who does the work” - but “can the vocabulary even say it” — covered in phases ⑩–⑮ and the §6 fidelity matrix.

-
-
-

D1 — Where history lives hops ↓

-

ACP: host SQLite keeps the public session → native ACP session binding. A restarted - host lists the row as offline and resurrects it on demand with session/load. The adapter replays its native - on-disk transcript into a fresh, bounded 5,000-frame journal.

-

SDK: the journal is only a delivery buffer; truth is Claude's native JSONL transcript - on disk — survives restarts, powers retry() epochs.

-

Both sides recover completed history from Claude's native store. The ACP host owns registry metadata, not - a durable transcript. getMessages still pages the bounded ring, so content older than that retained window is - unavailable; disk-backed paging is required follow-up work. In-flight turns die with the host on both sides.

-
-
-

D2 — Stream vocabulary hops ↓

-

ACP: streams too — one agent_message_chunk per text delta, live during - the turn; chunks accumulate into one message and are never replaced (the adapter diffs the SDK's consolidated - message against what already streamed and forwards only any un-streamed tail). Tool calls arrive as UI-ready - tool_call frames (title/kind/status on the wire).

-

SDK: journals the raw model stream verbatim (message_start, - content_block_delta, …) and then a complete assistant message that supersedes the accumulated deltas - at fold time — ~10× the journal rows, interpretation deferred to the client fold.

-

Both sides stream token-by-token. The divergence is the vocabulary (semantic chunks vs raw stream events) - and whether a later authoritative message replaces what streamed — ACP: no; SDK: yes.

-
-
-

D3 — Permission contract hops ↓

-

ACP: ships the options on the wire; the client taps an optionId back — zero decision - logic on the device.

-

SDK: ships raw input + rule suggestions; the client must build a full - PermissionResult and invent the Allow / Always / Deny UX.

-
-
-

D4 — Questions (AskUserQuestion) hops ↓

-

ACP: the host converts the elicitation form into ordinary permission cards — mobile - has zero question code.

-

SDK: the host forwards the raw tool input and mobile special-cases it — the host has - zero question code; carries 3 parallel interaction channels vs ACP's 1.

-

Same tool, opposite ends do the work.

-
-
-

D5 — Process model hops ↓

-

ACP: boots a child process per session and needs 3 stdio round-trips - (initialize → session/new → set_mode) before it's usable.

-

SDK: one in-process query() call whose options carry cwd, mode and - model; its callbacks never cross a process boundary.

-
-
-

D6 — Sending a prompt hops ↓

-

ACP: takes plain ContentBlocks, allows mid-turn sends, and the host echoes your - bubble into the journal instantly.

-

SDK: needs a client-built SDKUserMessage, only accepts when idle, and - your bubble waits for the CLI's echo.

-
-
-

D7 — Turn end hops ↓

-

ACP: turn-end is host-internal — a stopReason answering a parked RPC; devices only - ever see the state snapshot.

-

SDK: journals an explicit result frame with duration, cost and usage — - billing telemetry for free.

-
-
-

D8 — Subagents hops ↓

-

ACP: the spawn becomes a generic think tool call, subagent prose is - deliberately dropped, and the tree survives only via the non-standard _meta.claudeCode.parentToolUseId - our fold happens to know about.

-

SDK: a typed spawn (subagent_type, model, isolation), every subagent - message tagged with parent_tool_use_id, transcript readable later by (sessionId, agentId).

-
-
-

D9 — Task graph → flat checklist hops ↓

-

ACP: the four Task tools are suppressed; each mutation re-emits one flat - plan — content + status only, priority invented as "medium", everything else - dropped on the adapter floor.

-

SDK: ID-addressed tasks with dependencies (blocks/blockedBy), - owner, metadata — a queryable graph.

-
-
-

D10 — Workflows & goals don't cross hops ↓

-

ACP: the workflow crosses as one opaque kind:"other" tool call; goals - don't cross at all — the adapter has no mapping for them.

-

SDK: a first-class Workflow tool (resumable runs, taskId/runId, - transcript dir) and an active_goal message type.

-
-
-

In the diagram below every diverging hop wears a magenta number — ≠ — with a one-line - “why it matters”. The Differences filter in the sticky bar hides every hop where the two sides are the same machine.

-
- -
-

2Scoreboard

-

Added lines vs each tree's base (git diff + untracked), production code only; UI and tests counted separately.

- - - - - - - - - - - - - - - - - -
metricACP (main)Claude SDKΔ sdk vs acp
LOC added, non-UI production3,0124,428+47%
protocol / envelope package254687+170%
host session harness1,3631,722+26%
journal (in-memory ring)9992−7%
client bindings (fold, stream, hooks)1,2961,927+49%
if ( added (non-UI)135201+49%
ternaries added~31~48+55%
switch / case clauses3 / 224 / 24≈
deepest nesting (both: WS stream route)109≈
new exported symbols (non-UI)~65~120+85%
wire frame kinds / interaction channels6 / 19 / 3
UI (excluded from comparison)1,4191,296−9%
tests (excluded)1,7513,759+115%
-

Method: ACP = staged + unstaged + untracked on main vs HEAD; SDK = uncommitted work in the worktree (its committed - base is upstream sync only). Untracked files counted whole. “UI” = presentational screens/components; fold, stream and hook logic - count as client bindings even when they live in .tsx. Lockfiles, plan docs, E2E scripts and packaging tooling excluded everywhere. - Caveat: the ACP tree has had more UI iteration; the SDK tree carries heavier excluded infra (978-line relay E2E script, - packaged-host validation, 7 Maestro flows).

- -
-
-

Same skeleton grew on both sides

-

Envelope {seq, sessionId, ts, frame}, append-only ring journal (cap 5,000), WS replay from a cursor, - admission-ack sends, parked-promise permissions, full-snapshot state frames, pure fold reducer. - The protocol choice didn't change the architecture — it changed the vocabulary, and who does the interpretive work.

-
-
-

ACP ships UI-ready events

-

Semantic update variants: tool_call / tool_call_update with patch-merge, titles and statuses - born at the protocol layer, permission options carried on the wire, one interaction channel (AskUserQuestion mapped - onto it via elicitation). Small client vocabulary; the fold does chunk-merge, patches, subagent tree.

-
-
-

SDK ships the raw model stream

-

Verbatim SDKMessages: partial deltas superseded by authoritative messages, uuid identity, tool results as - user-role messages, three parallel interaction channels, client-built PermissionResult. - Buys native-transcript history, retry epochs, catalog, usage/cost — costs +47% non-UI LOC and +49% ifs.

-
-
-
- -
-

3Every packet, end to end

-

create → attach → prompt → stream → permission → question → two devices → turn end → reset — - then the complex interactions: ⑩ subagents · ⑪ task graphs · - ⑫ plan mode · ⑬ terminal / background Bash · - ⑭ workflows · ⑮ goals. - One numbered arrow per hop; payloads sit under the actor that sends them. Blue is ACP-only, amber is SDK-only, grey exists on - both sides; dashed arrows are in-process calls that never touch a wire. Hops renumber automatically as you filter. - Phases ①–⑨ are captured traffic; ⑩–⑮ are reconstructed from adapter/SDK source and badged as such.

- -
-
-

Devices — derived state only

-

envelopes[] (deduped by seq) → fold() → FoldedTimeline { items, meta, state, lastSeq }.

-

The thread renders from items; status chip, composer gate and pending cards render from state. - Everything here is disposable — refolded from scratch on any resync.

-
-
-

Host — the only authority

-

SessionScopedState mutates only here; every mutation is journaled as a full-snapshot - state frame — clients receive status, never compute it.

-

SessionJournal: per-session in-memory catch-up ring, gapless within one runtime incarnation, cap 5,000. - Every append fans out to every subscribed socket in the same call.

-

Parked asks: ACP Map<requestId, resolve> of unanswered JSON-RPC requests · - SDK three Deferred maps (permissions / dialogs / elicitations).

-
-
-

Agent — conversation truth

-

ACP: one claude-agent-acp child process per active session; its embedded SDK - persists the native transcript. Host SQLite stores the lookup key, and session/load restores completed history.

-

SDK: in-process query() handle over a claude subprocess; history truth is - Claude's native JSONL transcript on disk, which outlives the journal.

-
-
-

The fold — pure, rerunnable

-
for (env of envelopes) {
-  if (env.seq <= lastSeq) continue    // dedupe
-  update|sdk  → append / merge an item
-  *_requested → attach a card to its tool
-  *_resolved  → flip that card in place
-  state       → replace timeline.state
-  reset       → stop; full resync
-}
-
-
- -
-
- -
-
-
- -
-
- -
- magenta number = the sides diverge here -
-
- Mobile UIapps/mobile - Relaytunnel by routingKey - Hosthost-service · journal + state owner - Agentclaude-agent-acp / claude CLI -
-
- - -
-
-

① Start a new session

-

Device mints the public uuid; create is idempotent on it. The host boots the agent — this is divergence - D5: a child process and a 3-RPC handshake vs one in-process call.

- -
-
user taps “New chat” — mobile mints the id
-
-
acp · sdkcreate is idempotent on this id
-
sessionId = randomUUID()
-// acp example: "7d1e4a02-9c1f-4f7e-b1de-2f6d1a0c55aa"
-// sdk example: "6f0d1c2e-4b7a-4c8e-9f21-3d5a7b9c1e0f"
-
-
- -
-
HTTPS POST create — superjson body, Bearer JWT
-
-
-
acplib/host/client.ts:60
-
POST {relay}/hosts/org_01J8…%3Amac-mini-m2/trpc/acpSessions.create
-authorization: Bearer <host JWT>
-
-{ "json": {
-    "sessionId": "7d1e4a02-9c1f-4f7e-b1de-2f6d1a0c55aa",
-    "workspaceId": "a34e11f8-24c1-47c3-9d0e-88f2b1c9a771"
-} }
-
sdk
-
POST {relay}/hosts/org_01J8…%3Amac-mini-m2/trpc/sessions.create
-authorization: Bearer <host JWT>
-
-{ "json": {
-    "sessionId": "6f0d1c2e-4b7a-4c8e-9f21-3d5a7b9c1e0f",
-    "workspaceId": "9a8b7c6d-5e4f-4a3b-8c1d-2e0f9a8b7c6d",
-    "permissionMode": "default"
-} }
-
-
-
acp · sdkrelay never parses the body — elided from here on
-
// looks up the host tunnel by routingKey
-// "org_01J8…:mac-mini-m2", forwards opaquely
-→ POST /trpc/acpSessions.create   // acp
-→ POST /trpc/sessions.create      // sdk
-
-
-
- -
-
forwarded tRPC call
-
-
-
acptrpc/router/acp-sessions.ts:64
-
// gate: PRECONDITION_FAILED unless SUPERSET_ACP_SESSIONS=1
-await runtime.acpSessions.create({ sessionId, workspaceId })
-// idempotent — same id returns the existing session
-
sdktrpc/router/sessions.ts
-
await runtime.sessions.create({ sessionId, workspaceId,
-  permissionMode: "default" })
-// env from login-shell snapshot, resolves system `claude` binary
-
-
-
- -
-
spawn adapter + initialize — JSON-RPC over stdio
-
≠ D5 — one child process per session; 3 stdio round-trips before it's usable
-
-
-
acpruntime/acp-sessions.ts:508,571
-
spawn(process.execPath, [claude-agent-acp entry],
-  { cwd, env: { …process.env, ELECTRON_RUN_AS_NODE: "1" } })
-
-{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
-  "params": {
-    "protocolVersion": 1,
-    "clientInfo": { "name": "superset-host", "version": "1" },
-    "clientCapabilities": {
-      "fs": { "readTextFile": false, "writeTextFile": false },
-      "terminal": false,
-      "elicitation": { "form": {} }  // re-enables AskUserQuestion
-    } } }
-

⤷ elicitation.form is the load-bearing line: without it the adapter disallows Claude Code's - built-in AskUserQuestion tool — see phase ⑥.

-
-
-
acpresponse (abbreviated)
-
{ "id": 1, "result": {
-    "protocolVersion": 1,
-    "agentCapabilities": { … } } }
-
-
-
- -
-
query() — in-process, no wire hop; the SDK spawns the CLI itself
-
≠ D5 — cwd, mode, model, callbacks all ride one function call; nothing to handshake
-
-
-
sdkruntime/sessions.ts:506
-
query({
-  prompt: asyncInputQueue,       // user turns pushed later
-  options: {
-    cwd: "/Users/kirill/.superset/worktrees/…",
-    sessionId: "c1a2d3e4-…",     // native Claude session uuid
-    permissionMode: "default",
-    includePartialMessages: true,
-    persistSession: true,
-    canUseTool, onUserDialog, onElicitation  // 3 host-local callbacks
-  } })
-// catalog = await q.initializationResult()
-

⤷ the three callbacks are host-local functions — an interaction never crosses a process - boundary until the host journals it.

-
-
-
- -
-
session/new
-
-
-
acp
-
{ "jsonrpc": "2.0", "id": 2, "method": "session/new",
-  "params": {
-    "cwd": "/Users/kirill/.superset/worktrees/…",
-    "mcpServers": []
-  } }
-
-
-
acpthe acp id never leaves the host
-
{ "id": 2, "result": {
-    "sessionId": "sess_9f3k…",   // adapter-internal
-    "modes": {
-      "currentModeId": "bypassPermissions",
-      "availableModes": [
-        { "id": "default", … }, { "id": "acceptEdits", … }, …
-      ] } } }
-
-
-
- -
-
session/set_mode — adapter boots in bypassPermissions; host forces default
-
-
-
acpD14-c
-
{ "jsonrpc": "2.0", "id": 3, "method": "session/set_mode",
-  "params": { "sessionId": "sess_9f3k…", "modeId": "default" } }
-
-
-
- -
-
journal seq 1 — state frame
-
-
acp · sdkin-memory ring, cap 5 000
-
journal.append({ kind: "state", state: { …, "status": "idle",
-  "lastSeq": 1 } })   // → envelope { seq: 1, sessionId, ts, frame }
-
-
- -
-
200 — full SessionScopedState
-
-
-
acp
-
{ "result": { "data": { "json": {
-  "sessionId": "7d1e4a02-…",
-  "workspaceId": "a34e11f8-…",
-  "harness": "claude-agent-acp",
-  "status": "idle",
-  "currentMode": {
-    "currentModeId": "default",
-    "availableModes": [ … ] },
-  "configOptions": [ … ],
-  "pendingPermissions": [],
-  "cwd": "/Users/kirill/.superset/worktrees/…",
-  "lastSeq": 1,
-  "lastStopReason": null,
-  "lastError": null } } } }
-
sdk3 pending arrays instead of 1
-
{ "result": { "data": { "json": {
-  "sessionId": "6f0d1c2e-…",
-  "claudeSessionId": "c1a2d3e4-…",
-  "workspaceId": "9a8b7c6d-…",
-  "harness": "claude",
-  "status": "idle",
-  "model": "claude-sonnet-5",
-  "permissionMode": "default",
-  "effort": null,
-  "pendingPermissions": [],
-  "pendingUserDialogs": [],
-  "pendingElicitations": [],
-  "cwd": "/Users/kirill/.superset/worktrees/…",
-  "lastSeq": 1,
-  "lastError": null } } } }
-
-
-
-
- - -
-
-

② Open an existing session — state, history, stream

-

Every cold attach, every resync, and a second device's join are the same triple: read state, read a history - page, dial the WS from a cursor. Hop two is divergence D1 — the single most consequential difference.

- -
-
GET state snapshot — tRPC query = GET + ?input=
-
-
-
acp
-
GET …/trpc/acpSessions.get?input=%7B%22json%22%3A…
-// decoded: { "json": { "sessionId": "7d1e4a02-…" } }
-
sdk
-
GET …/trpc/sessions.get?input=…
-// decoded: { "json": { "sessionId": "6f0d1c2e-…" } }
-
-
-
acp · sdk
-
← 200 SessionScopedState
-// same shape as ①'s "200 — full SessionScopedState";
-// this object IS the session's top-level state: status chip,
-// composer gate and pending cards all render from it
-
-
-
- -
-
GET history page — newest first, cursor walks backward
-
≠ D1 · critical — ACP currently pages its bounded ring; an offline session first rebuilds - that ring through a full session/load replay. The direct SDK design pages Claude's native JSONL transcript.
-
-
-
acp
-
GET …/trpc/acpSessions.getMessages?input=…
-// { "json": { "sessionId": "7d1e4a02-…", "limit": 200 } }
-
sdk
-
GET …/trpc/sessions.getMessages?input=…
-// { "json": { "sessionId": "6f0d1c2e-…", "limit": 200 } }
-
-
-
acpjournal page — rows ARE envelopes
-
{ "result": { "data": { "json": {
-  "items": [
-    { "seq": 38, "sessionId": "7d1e…", "ts": 1752107660102,
-      "frame": { "kind": "update", "update": {
-        "sessionUpdate": "user_message_chunk",
-        "content": { "type": "text",
-                     "text": "Create hello.txt…" } } } },
-    …  // timeline frame kinds only — state/reset excluded
-  ],
-  "nextCursor": "s38"   // older page: pass cursor "s38"
-} } } }
-
sdknative transcript — no seq on rows
-
{ "result": { "data": { "json": {
-  "items": [
-    { "type": "user", "uuid": "b4e2…",
-      "session_id": "c1a2d3e4-…",
-      "message": { "role": "user", "content": [
-        { "type": "text", "text": "List the files…" } ] },
-      "parent_tool_use_id": null,
-      "parent_agent_id": null },
-    …  // Claude's own JSONL rows, 200/page
-  ],
-  "nextCursor": "1kf3z"  // base36, cycle-guarded
-} } } }
-
-
-
- -
-
WS attach with cursor
-
-
-
acp
-
WSS {relay}/hosts/org_01J8…%3Amac-mini-m2
-    /acp-sessions/7d1e4a02-…/stream?token=<JWT>&since=51
-// since = folded.lastSeq (fallback state.lastSeq)
-
sdk
-
WSS …/sessions/6f0d1c2e-…/stream?token=<JWT>&since=57
-// since = state.lastSeq — emitState stamps its own seq
-// into state.lastSeq so resume lands right after it
-
-
-
acp · sdkruntime subscribe()
-
subscribe({ sessionId, since, onEnvelope })
-// journal.after(since) = (since, latest] replayed
-// synchronously before live attach → no gap window
-
-
-
- -
-
replay, then live — one envelope per WS message
-
-
-
acp · sdk
-
// envelopes (since, latest], then live — shapes as in the
-// phases below. Client rules make delivery exactly-once:
-//   seq <= lastSeq       → drop (at-least-once dedupe)
-//   seq == lastSeq + 1    → fold (the only accept path)
-//   seq >  lastSeq + 1    → gap → reconnect from cursor
-
-
-
- -
-
fold from scratch → timeline (pure, derived, disposable)
-
≠ D1 — ACP refolds one source; SDK merges two: transcript rows at seq 0 (uuid-keyed), live envelopes on top
-
-
acpsession-protocol/fold.ts
-
foldTimeline(journalPages)
-→ { "items": [ MessageItem | ToolCallItem | PlanItem ],
-    "meta": { "title", "usage", "currentMode", … },
-    "state": { … }, "lastSeq": 51 }
-
sdk
-
timelineFromSessionMessages(history)  // transcript → seq-0 items
-fold(bufferedEnvelopes)               // live rows on top
-→ { "items": [ Message | ToolCall | UserDialog | Elicitation ],
-    "meta": { "model", "permissionMode", "lastResult", … },
-    "state": { … }, "lastSeq": 57 }
-// same uuid live+transcript → upsert, not duplicate
-
-
-
- - -
-
-

③ Send a prompt

-

Sends are admission-acks on both sides — a turn can block for hours on a human, so the RPC returns the moment - the host accepts; completion only ever arrives on the stream. Divergence D6 lives in the payload shape, the send gate, - and who echoes your bubble.

- -
-
POST prompt — reply is an admission ack only
-
≠ D6 — plain blocks + mid-turn sends allowed vs client-built SDKUserMessage + idle-only gate
-
-
-
acpacpSessions.prompt
-
{ "json": {
-    "sessionId": "7d1e4a02-…",
-    "prompt": [ { "type": "text",
-        "text": "Implement exponential retry for webhook delivery." } ]
-} }
-// ← { "accepted": true }
-

⤷ mid-turn sends are allowed — prompts queue on the adapter.

-
sdksessions.sendMessage · createUserMessage()
-
{ "json": {
-    "sessionId": "6f0d1c2e-…",
-    "message": {
-      "type": "user",
-      "message": { "role": "user", "content": [ { "type": "text",
-          "text": "Implement exponential retry for webhook delivery." } ] },
-      "parent_tool_use_id": null
-    }
-} }
-// ← { "accepted": true }
-

⤷ requires status "idle" — mid-turn sends throw PRECONDITION_FAILED. The wire shape is the - SDK's own message type, built client-side.

-
-
-
- -
-
session/prompt — stays unanswered until the turn ends (phase ⑧)
-
-
-
acpacp-sessions.ts:296
-
{ "jsonrpc": "2.0", "id": 10, "method": "session/prompt",
-  "params": {
-    "sessionId": "sess_9f3k…",
-    "prompt": [ { "type": "text",
-        "text": "Implement exponential retry for webhook delivery." } ]
-} }
-
-
-
- -
-
input.push(message) — the prompt iterable yields; no wire packet
-
-
-
sdkAsyncInputQueue
-
input.push(message)   // AsyncInputQueue<SDKUserMessage>
-// the SDK's for-await loop consumes it and the subprocess
-// begins the turn; turn end is detected from the stream
-// (a `result` frame), not from any RPC response
-
-
-
- -
-
user bubble echo — journaled BEFORE forwarding, fanned out
-
≠ D6 — the adapter never echoes prompts, so the ACP host journals your bubble instantly; - the SDK bubble waits for the CLI's echo (phase ④)
-
-
-
acpseq 2 · one row per prompt block
-
{ "seq": 2, "sessionId": "7d1e4a02-…", "ts": 1752107660001,
-  "frame": {
-    "kind": "update",
-    "update": {
-      "sessionUpdate": "user_message_chunk",
-      "content": { "type": "text",
-        "text": "Implement exponential retry for webhook delivery." }
-    } } }
-

⤷ fold: appendChunk → new user MessageItem { id: "user:2" }.

-
-
-
- -
-
state → running — snapshot journaled, fanned out
-
-
-
acpseq 3 · composer stays enabled
-
{ "kind": "state",
-  "state": {
-    "sessionId": "7d1e4a02-…", "harness": "claude-agent-acp",
-    "status": "running", "title": null,
-    "pendingPermissions": [], "lastError": null,
-    "currentMode": { "currentModeId": "default" },
-    "lastSeq": 3, … } }
-
sdkseq 2 · composer disables (status ≠ idle)
-
{ "kind": "state",
-  "state": {
-    "sessionId": "6f0d1c2e-…", "status": "running",
-    "model": "claude-sonnet-5", "permissionMode": "default",
-    "pendingPermissions": [], "pendingUserDialogs": [],
-    "pendingElicitations": [], "lastSeq": 2, … } }
-
-
-
acp · sdkthe state-update mechanism
-
// host mutates SessionScopedState, then journals the WHOLE
-// snapshot. Fold: not a timeline item —
-// timeline.state = frame.state  (replace, never merge)
-
-
-
-
- - -
-
-

④ Text streams back

-

Divergence D2, in the flesh. Agent → host → journal → every socket, one call. Both sides stream - live, token by token; ACP's rows are semantic chunks that are never replaced, the SDK's are the raw model stream, later - superseded by an authoritative message.

- -
-
agent_message_chunk — journaled at host, fanned out same call
-
≠ D2 — streamed live, one row per text delta; chunks accumulate and are never replaced
-
-
-
acpseq 4 · session/update notification, verbatim
-
{ "seq": 4, "sessionId": "7d1e4a02-…", "ts": 1752107663210,
-  "frame": { "kind": "update", "update": {
-    "sessionUpdate": "agent_message_chunk",
-    "content": { "type": "text",
-      "text": "I'll inspect the delivery path and existing tests." }
-} } }
-

⤷ fold appendChunk: last item is an agent message → blocks[last].text += chunk — - merged in place, no new item. agent_thought_chunk is identical, folds to role "thought". - The SDK's consolidated assistant message never crosses: the adapter diffs it against the already-streamed blocks - (acp-agent.js:658) and forwards only an un-streamed tail.

-
-
-
- -
-
stream_event delta — one journal row PER delta
-
≠ D2 — every delta burns a seq; the journal grows ~10× faster for the same conversation
-
-
-
sdkseq 3…5 · raw Anthropic stream event, verbatim
-
{ "seq": 3, "sessionId": "6f0d1c2e-…", "ts": 1752191830123,
-  "frame": { "kind": "sdk", "message": {
-    "type": "stream_event",
-    "uuid": "3c9d41aa-…", "session_id": "c1a2d3e4-…",
-    "event": {
-      "type": "content_block_delta", "index": 0,
-      "delta": { "type": "text_delta", "text": "I'll inspect the " }
-    },
-    "parent_tool_use_id": null
-} } }
-

⤷ fold appendPartialBlock: accrete into - MessageItem{ partial: true, id: "message:assistant:3c9d…:0" }.

-
-
-
- -
-
authoritative assistant message — replaces the partial
-
-
-
sdkseq 6 · complete message, same uuid
-
{ "kind": "sdk", "message": {
-    "type": "assistant", "uuid": "3c9d41aa-…",
-    "message": {
-      "role": "assistant",
-      "content": [
-        { "type": "text",
-          "text": "I'll inspect the delivery path and existing tests." },
-        { "type": "tool_use", "id": "toolu_01AbCdEfGh", "name": "Write",
-          "input": { "file_path": "src/retry.ts", "content": "…" } }
-      ] }
-} }
-

⤷ fold: same uuid → replace the partial wholesale (partial: false); the embedded - tool_use spawns ToolCallItem{ status: "pending" }. The SDK user prompt also echoes back here - as an sdk user frame — that echo is what lands in the timeline.

-
acp side
-
// no replacement pass exists on ACP: chunks were already
-// final, and the tool call arrives as its own frame (next)
-
-
-
-
- - -
-
-

⑤ Tool call needs permission

-

The parked-promise pattern both sides share: the agent asks, the host parks the ask + journals two rows - (request, then state snapshot), the answer RPC resolves the parked promise back into the agent. Divergence D3 is - what rides the wire and who constructs the decision.
observed in the current real-adapter lane: - Claude can emit two Bash tool uses in parallel, but claude-agent-acp 0.56.0 exposes their permission callbacks to the - client one at a time. Superset supports a true multi-request stack; the deterministic ACP adapter pins that case.

- -
-
tool_call frame — UI-ready at birth
-
≠ D2/D3 — title, kind, status born at the protocol layer; on SDK they're derived at fold time
-
-
-
acpseq 5
-
{ "kind": "update", "update": {
-    "sessionUpdate": "tool_call",
-    "toolCallId": "toolu_01XyZaBcDe",
-    "title": "Write src/retry.ts",
-    "kind": "edit",
-    "status": "pending",
-    "rawInput": { "file_path": "src/retry.ts", "content": "…" }
-} }
-

⤷ fold upsertToolCall → new ToolCallItem. Later tool_call_update frames are - patches: present fields replace, absent keep — merged only at fold time, each patch its own row.

-
sdk side
-
// the tool call already rode inside the authoritative
-// assistant message in ④ — no separate tool-call frame
-
-
-
- -
-
session/request_permission — JSON-RPC REQUEST, parks at the host
-
-
-
acpacp-sessions.ts:731
-
{ "jsonrpc": "2.0", "id": 7,
-  "method": "session/request_permission",
-  "params": {
-    "sessionId": "sess_9f3k…",
-    "toolCall": { "toolCallId": "toolu_01XyZaBcDe",
-      "title": "Write src/retry.ts", "kind": "edit", "status": "pending" },
-    "options": [
-      { "optionId": "allow",  "name": "Allow",  "kind": "allow_once" },
-      { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
-    ] } }
-

⤷ host parks the response promise in pendingResolvers keyed by the JSON-RPC id - ("7") and pushes onto state.pendingPermissions.

-
-
-
- -
-
canUseTool() — in-process callback; a promise the host doesn't resolve yet
-
-
-
sdksessions.ts:482
-
canUseTool("Write",
-  { file_path: "src/retry.ts", content: "…" },
-  { requestId: "req_011CNxT9aB",
-    toolUseID: "toolu_01AbCdEfGh",
-    suggestions: [ /* addRules … */ ],
-    signal })          // ← parkPermission stores a Deferred
-                       //    keyed by the SDK's own requestId
-

⤷ same parking pattern, no wire: the Deferred sits in one of three pending maps - (permissions / dialogs / elicitations), each with its own frames and RPC.

-
-
-
- -
-
permission_requested + state — 2 rows journaled, fanned out
-
≠ D3 — ACP: the options ride the wire, client renders and never decides · SDK: raw input + - suggestions, client invents the Allow/Always/Deny UX
-
-
-
acpseq 6, then seq 7 state
-
{ "kind": "permission_requested",
-  "pending": {
-    "requestId": "7",
-    "toolCall": { "toolCallId": "toolu_01XyZaBcDe",
-      "title": "Write src/retry.ts", "kind": "edit", "status": "pending" },
-    "options": [
-      { "optionId": "allow",  "name": "Allow",  "kind": "allow_once" },
-      { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
-    ],
-    "requestedAt": 1752107665004 } }
-
-{ "kind": "state", "state": {
-    "status": "awaiting_permission",
-    "pendingPermissions": [ { "requestId": "7", … } ],
-    "lastSeq": 7, … } }
-

⤷ the UI acts on the state copy: PermissionStack renders from - state.pendingPermissions. The timeline copy attaches a PermissionView to its ToolCallItem — - for the record.

-
sdkseq 7, then seq 8 state
-
{ "kind": "permission_requested",
-  "request": {
-    "requestId": "req_011CNxT9aB",
-    "toolUseID": "toolu_01AbCdEfGh",
-    "toolName": "Write",
-    "input": { "file_path": "src/retry.ts", "content": "…" },
-    "suggestions": [ { "type": "addRules", "behavior": "allow",
-        "rules": [ { "toolName": "Write",
-                     "ruleContent": "src/retry.ts" } ],
-        "destination": "localSettings" } ],
-    "requestedAt": 1752191835001 } }
-
-{ "kind": "state", "state": {
-    "status": "requires_action",
-    "pendingPermissions": [ { "requestId": "req_011CNxT9aB", … } ],
-    "lastSeq": 8, … } }
-

⤷ PendingActions renders every card from the three pending arrays in state — never from - the timeline.

-
-
-
- -
-
answer — respondToPermission
-
≠ D3 — an optionId tap vs a client-constructed PermissionResult
-
-
-
acp
-
{ "json": {
-    "sessionId": "7d1e4a02-…",
-    "requestId": "7",
-    "outcome": { "outcome": "selected", "optionId": "allow" }
-} }
-// ← { "status": "resolved" }
-

⤷ the client sends back an optionId it was given — zero decision logic on the device.

-
sdk
-
{ "json": {
-    "sessionId": "6f0d1c2e-…",
-    "requestId": "req_011CNxT9aB",
-    "response": {
-      "behavior": "allow",
-      "updatedInput": { "file_path": "src/retry.ts", "content": "…" },
-      "toolUseID": "toolu_01AbCdEfGh",
-      "decisionClassification": "user_temporary"
-    }
-} }
-// ← { "status": "resolved" }
-

⤷ the client builds a full PermissionResult: echo (or edit) the input, classify the decision; - “Always allow” sends updatedPermissions: suggestions. bypassPermissions is stripped at this - boundary.

-
-
-
- -
-
unpark — the parked JSON-RPC request finally gets its response
-
-
-
acp
-
{ "jsonrpc": "2.0", "id": 7,
-  "result": { "outcome": { "outcome": "selected", "optionId": "allow" } } }
-

⤷ turn-cancel or adapter death settles unanswered asks as { "outcome": "cancelled" } - via the request's AbortSignal — nothing leaks.

-
-
-
- -
-
Deferred resolves — canUseTool returns in-process
-
-
-
sdk
-
// settlePermission(requestId).resolve(…) → canUseTool returns:
-{ behavior: "allow",
-  updatedInput: { file_path: "src/retry.ts", content: "…" },
-  toolUseID: "toolu_01AbCdEfGh",
-  decisionClassification: "user_temporary" }
-

⤷ the SDK runs the tool with updatedInput — which is also exactly how question answers - travel, next phase.

-
-
-
- -
-
permission_resolved + state → running — 2 rows, fanned out
-
-
-
acp · sdksame two-row pattern both sides
-
{ "kind": "permission_resolved", "requestId": …,
-  "outcome"/"response": { … } }        // resolution edge
-
-{ "kind": "state", "state": { "status": "running",
-    "pendingPermissions": [], … } }    // pending card disappears
-// SDK: status returns to "running" only once ALL THREE
-// pending maps are empty
-
-
-
-
- - -
-
-

⑥ The agent asks a question — AskUserQuestion

-

captured against claude-agent-acp 0.56.0 with an authenticated Sonnet/low real-adapter run
- Claude asks: “Which failures should retry?” The turn stays parked — answering does not create a new - prompt. Divergence D4: the interpretive work happens at opposite ends.

- -
-
elicitation/create (mode form) — parks like a permission
-
≠ D4 — rides the unstable elicitation.form capability advertised at initialize (phase ①)
-
-
-
acpacp-sessions.ts:777
-
{ "jsonrpc": "2.0", "id": 41, "method": "elicitation/create",
-  "params": {
-    "mode": "form",
-    "sessionId": "sess_9f3k…",
-    "toolCallId": "toolu_question_01",
-    "message": "Which failures should retry?",
-    "requestedSchema": {
-      "type": "object",
-      "properties": {
-        "question_0": {
-          "type": "string",
-          "oneOf": [
-            { "const": "429 + 5xx" },
-            { "const": "Also network failures" }
-          ] },
-        "question_0_custom": { "type": "string" }
-      } } } }
-

⤷ extractElicitationQuestions() keeps only question_<n> fields - (*_custom ignored — mobile is tap-only); non-form modes → cancel; no recognizable questions → decline.

-
-
-
- -
-
canUseTool("AskUserQuestion") — a question is just a tool asking permission
-
-
-
sdksessions.ts:482
-
canUseTool("AskUserQuestion",
-  { questions: [ {
-      header: "Retry scope",
-      question: "Which failures should retry?",
-      multiSelect: false,
-      options: [
-        { label: "429 + 5xx" },
-        { label: "Also network failures" } ] } ] },
-  { requestId: "req_011CQq2xYz",
-    toolUseID: "toolu_question_01", signal })
-

⤷ parked in the same permissions map as any Write or Bash — the host doesn't know questions - exist.

-
-
-
- -
-
host mints a question card — one per question_<n>, one at a time
-
≠ D4 — the question becomes an ordinary permission card; mobile has zero question-specific code
-
-
-
acpseq 10 + state seq 11
-
{ "kind": "permission_requested",
-  "pending": {
-    "requestId": "q-3f7c9d2e-…",          // randomUUID, host-minted
-    "toolCall": { "toolCallId": "toolu_question_01",
-      "title": "Which failures should retry?",
-      "kind": "other", "status": "pending" },
-    "options": [
-      { "optionId": "option-0", "name": "429 + 5xx",
-        "kind": "allow_once" },
-      { "optionId": "option-1", "name": "Also network failures",
-        "kind": "allow_once" },
-      { "optionId": "skip", "name": "Skip", "kind": "reject_once" }
-    ],
-    "requestedAt": 1752107668210 } }
-// + { "kind": "state", "state": { "status": "awaiting_permission", … } }
-

⤷ options = enum labels + Skip; card title = the question. Mobile renders and resolves this - exactly like the Write ask in ⑤.

-
-
-
- -
-
question forwarded raw — client special-cases it
-
≠ D4 — parseAskUserQuestions() sniffs toolName client-side; the host has zero question-specific code
-
-
-
sdkseq 11 + state seq 12
-
{ "kind": "permission_requested",
-  "request": {
-    "requestId": "req_011CQq2xYz",
-    "toolUseID": "toolu_question_01",
-    "toolName": "AskUserQuestion",
-    "input": { "questions": [ {
-        "header": "Retry scope",
-        "question": "Which failures should retry?",
-        "multiSelect": false,
-        "options": [ { "label": "429 + 5xx" },
-                     { "label": "Also network failures" } ] } ] },
-    "suggestions": [],
-    "requestedAt": 1752191841007 } }
-// + { "kind": "state", "state": { "status": "requires_action", … } }
-

⤷ UI: parseAskUserQuestions(request) matches toolName === "AskUserQuestion" and - strictly validates input.questions — match → multi-question QuestionCard; mismatch → plain - permission card.

-
-
-
- -
-
answer the question — same respondToPermission RPC
-
≠ D4 — ACP: card n resolved mints card n+1 until the form is done · - SDK: one RPC carries every answer
-
-
-
acp
-
{ "json": {
-    "sessionId": "7d1e4a02-…",
-    "requestId": "q-3f7c9d2e-…",
-    "outcome": { "outcome": "selected", "optionId": "option-0" }
-} }
-// ← { "status": "resolved" }
-
sdkbuildQuestionAnswers()
-
{ "json": {
-    "sessionId": "6f0d1c2e-…",
-    "requestId": "req_011CQq2xYz",
-    "response": {
-      "behavior": "allow",
-      "updatedInput": {
-        "questions": [ …input echoed… ],
-        "answers": { "Which failures should retry?": "429 + 5xx" }
-      },
-      "toolUseID": "toolu_question_01",
-      "decisionClassification": "user_temporary"
-    }
-} }
-// ← { "status": "resolved" }
-

⤷ tapped chips + free text merged per question (deduped, comma-joined), keyed by question text. - Declining sends behavior: "deny".

-
-
-
- -
-
answer reaches the agent — the turn was never interrupted; no new prompt exists
-
-
-
acphost aggregates cards → answers the parked id 41
-
{ "jsonrpc": "2.0", "id": 41,
-  "result": {
-    "action": "accept",
-    "content": { "question_0": "429 + 5xx" }
-} }
-

⤷ Skip → field omitted; cancel mid-form → action: "cancel" aborts the tool. - The adapter converts this into its own inner canUseTool result.

-
sdkin-process
-
// canUseTool("AskUserQuestion", …) resolves to:
-{ behavior: "allow",
-  updatedInput: { questions: […],
-    answers: { "Which failures should retry?": "429 + 5xx" } },
-  toolUseID: "toolu_question_01",
-  decisionClassification: "user_temporary" }
-

⤷ one hop instead of ACP's three (question → cards → aggregate), because host and agent share - a process.

-
-
-
-
- - -
-
-

⑦ Two devices — the answer race

-

Second phone, same account. It cold-attaches with the phase-② triple; the fetched state already carries the - pending card, so it renders the question before a single WS frame arrives. Then both devices answer.

-
— no divergence in this phase: identical race semantics on both sides (first answer wins, the - second RPC returns already_resolved, the loser's card clears via the state frame). Switch the filter back to - All steps to see the packets.
- -
-
second subscriber attached — nothing was “sent” to it
-
-
acp · sdkdevices never talk to each other
-
// Device B ran the phase-② triple: get + getMessages + WSS
-// The fetched state already contained
-//   pendingPermissions: [ the question card ]
-// From here every journal append fans out to BOTH sockets
-// in the same call.
-
-
- -
-
both answer — first wins — the second RPC is a no-op, not an error
-
-
-
acp · sdksettled by the parked promise
-
// Device B's respondToPermission → { "status": "resolved" }
-// Device A answers the same requestId a moment later:
-POST …/respondToPermission { "requestId": "q-3f7c9d2e-…", … }
-// ← { "status": "already_resolved" }
-
-
-
- -
-
convergence — resolution + state fan out ×2 sockets
-
-
-
acp · sdk
-
{ "kind": "permission_resolved", "requestId": "q-3f7c9d2e-…", … }
-{ "kind": "state", "state": { "status": "running",
-    "pendingPermissions": [], … } }
-// A's card clears because the state frame REPLACED its
-// sessionState — not because A was told it lost a race.
-// Any number of devices stay consistent by this one rule.
-
-
-
-
- - -
-
-

⑧ Tool result & turn end

-

The tool finishes, then the turn closes — divergence D7: ACP's turn-end never appears on the device - wire; the SDK journals an explicit result with usage and cost.

- -
-
tool completes — journaled, fanned out
-
-
-
acpseq 14 · the same patch shape, now with output
-
{ "kind": "update", "update": {
-    "sessionUpdate": "tool_call_update",
-    "toolCallId": "toolu_01XyZaBcDe",
-    "status": "completed",
-    "content": [ { "type": "content",
-        "content": { "type": "text", "text": "Wrote src/retry.ts" } } ]
-} }
-

⤷ fold: patch-merge onto the ToolCallItem → completed, content attached.

-
sdkseq 15 · result arrives as a user-role message (API convention)
-
{ "kind": "sdk", "message": {
-    "type": "user",
-    "message": { "role": "user", "content": [
-      { "type": "tool_result",
-        "tool_use_id": "toolu_01AbCdEfGh",
-        "content": [ { "type": "text", "text": "Wrote src/retry.ts" } ],
-        "is_error": false } ] }
-} }
-

⤷ fold: match tool_use_id → flip the ToolCallItem to completed / error in place.

-
-
-
- -
-
turn ends — the prompt RPC from ③ finally returns; host-internal only
-
≠ D7 — never journaled; devices only ever see the state snapshot two hops down. - A failed prompt journals prompt_rejected instead (fold retro-marks the user bubble failed)
-
-
-
acp
-
{ "jsonrpc": "2.0", "id": 10,
-  "result": { "stopReason": "end_turn" } }
-
-
-
- -
-
result frame — explicit, journaled turn end
-
≠ D7 — duration, num_turns, cost and usage journaled for free; fold keeps it as meta.lastResult
-
-
-
sdkseq 16 · sessions.ts:591
-
{ "kind": "sdk", "message": {
-    "type": "result", "subtype": "success",
-    "duration_ms": 41230, "num_turns": 3,
-    "total_cost_usd": 0.084,
-    "result": "Done. Retry with backoff implemented.",
-    "usage": { … }
-} }
-

⤷ host rule: a result with zero pending callbacks is the authoritative turn end — the CLI - doesn't reliably emit session_state_changed(idle).

-
-
-
- -
-
state → idle — the closing snapshot; composer re-enables
-
-
-
acpseq 15
-
{ "kind": "state", "state": {
-    "status": "idle", "lastStopReason": "end_turn",
-    "title": "Webhook retry with backoff",
-    "pendingPermissions": [], "lastSeq": 15, … } }
-
sdkseq 17
-
{ "kind": "state", "state": {
-    "status": "idle",
-    "pendingPermissions": [], "pendingUserDialogs": [],
-    "pendingElicitations": [], "lastSeq": 17, … } }
-
-
-
-
- - -
-
-

⑨ Reconnect, gaps, reset

-

Both sides use cursor replay inside one journal incarnation. The divergence is the history source used - for a full rebuild — D1 again, and where it bites hardest.

- -
-
redial after background — exponential backoff 250ms → 10s
-
-
-
acp · sdk
-
WSS …/stream ?since=15        // lastSeq the device has folded
-// dedupe / gap rules as in ② — “exactly once, in order” is
-// enforced by the CLIENT with two integer comparisons;
-// the server just replays. The current ACP client performs only
-// a shallow envelope check; full runtime parsing remains TODO.
-
-
-
- -
-
terminal reset — seq 0, then the socket closes (1000)
-
≠ D1 — same shape, but the SDK adds latestSeq and a whole epoch mechanism (retry(), cursor_ahead)
-
-
-
acpcursor evicted from the ring
-
{ "seq": 0, "sessionId": "7d1e4a02-…", "ts": …,
-  "frame": { "kind": "reset",
-             "reason": "journal_evicted" } }
-// also: invalid_since, session_not_found, session_load_failed
-// not journaled — seq 0 is out-of-band by construction
-
sdkring evicted / host restarted / retry epoch
-
{ "seq": 0, "sessionId": "6f0d1c2e-…", "ts": …,
-  "frame": { "kind": "reset",
-             "reason": "cursor_unavailable",
-             "latestSeq": 57 } }
-// also fired when the cursor is AHEAD of the journal.
-// retry() starts a new epoch: fresh journal, seq restarts
-// at 1, old subscribers get reset("session_restarted")
-
-
-
- -
-
full resync
-
≠ D1 · critical — after a host restart ACP resurrects from a registry row plus a full - session/load replay into a fresh ring; the direct SDK design reads pages from the JSONL transcript.
-
-
-
acpuseAcpSession.ts:113 — same reads as ②
-
GET  …/acpSessions.get             // fresh SessionScopedState
-GET  …/acpSessions.getMessages     // newest journal page
-// refold from scratch (pure) → resubscribe ?since=folded.lastSeq
-// older pages on demand via cursor "s<seq>"
-

⤷ the session lists as "offline" from its SQLite registry row. The first live call - (prompt / getMessages / stream attach) runs session/load and rebuilds a fresh journal. Numeric seqs - restart at 1; because the cursor has no incarnation id, an overlapping pre-restart cursor is not always detected. - Epoch-aware cursors are required follow-up work.

-
sdkuseClaudeSessionThread.ts:178
-
GET  …/sessions.get                     // state, incl. lastSeq
-WSS  …/stream ?since=state.lastSeq      // open FIRST, buffer
-GET  …/sessions.getMessages ×N          // drain full transcript
-// timelineFromSessionMessages(history)
-// → fold buffered envelopes on top → hydrated
-

⤷ transcript rows fold at seq 0 (uuid-keyed), buffered live envelopes land after — the overlap - upserts instead of duplicating. More moving parts; history survives.

-
-
-
-
- - -
-
-

⑩ Subagents

-

captured against claude-agent-acp 0.56.0; the current acceptance fixture runs a named five-agent Sonnet Workflow to completion with real token and tool usage; lifecycle details cross-checked against sdk 0.3.201 types
- Native: the Agent tool is a first-class spawn — the subagent has an id, a type, its own transcript file, and - every message it produces is tagged with the spawning tool_use. Over ACP: the adapter renames the spawn to a generic - think tool call, deliberately drops subagent prose, and tags nested tool calls with non-standard - _meta that our fold happens to understand — D8.

- -
-
the spawn — Agent (a.k.a. Task) tool_use
-
≠ D8 — SDK: a typed spawn with subagent_type / model / isolation on the wire · - ACP: a generic think call; the spawn options survive only because rawInput echoes the input
-
-
-
acptools.js:19 — case "Agent"/"Task" → kind "think"
-
{ "kind": "update", "update": {
-    "sessionUpdate": "tool_call",
-    "toolCallId": "toolu_01TaskAa",
-    "title": "Audit retry edge cases",     // = input.description
-    "kind": "think",                       // ← a SPAWN, labelled a thought
-    "status": "pending",
-    "content": [ { "type": "content", "content": {
-        "type": "text", "text": "Find every call site that retries webhook delivery…" } } ],
-    "rawInput": { "description": "Audit retry edge cases",
-      "prompt": "Find every call site that retries webhook delivery…",
-      "subagent_type": "Explore", "model": "haiku" },
-    "_meta": { "claudeCode": { "toolName": "Task" } }
-} }
-

⤷ subagent_type / model / isolation are not protocol fields — - no ACP client can rely on them; they ride only inside the untyped rawInput echo.

-
sdkAgentInput — sdk-tools.d.ts:410
-
{ "kind": "sdk", "message": {
-    "type": "assistant", "uuid": "9e2f61bb-…",
-    "message": { "role": "assistant", "content": [
-      { "type": "tool_use", "id": "toolu_01TaskAa", "name": "Agent",
-        "input": {
-          "description": "Audit retry edge cases",
-          "prompt": "Find every call site that retries webhook delivery…",
-          "subagent_type": "Explore",
-          "model": "haiku",
-          "name": "recon-retries",
-          "run_in_background": false,
-          "isolation": "worktree"
-        } } ] }
-} }
-

⤷ the input is a typed spawn: subagent_type · model · name · run_in_background · mode · - isolation ("worktree"/"remote"). The client sees exactly what was launched, and with what powers.

-
-
-
- -
-
subagent stream — every message tagged with the spawning tool_use
-
-
-
sdkparent_tool_use_id — first-class on every SDKMessage
-
{ "kind": "sdk", "message": {
-    "type": "assistant", "uuid": "b7c31d9a-…",
-    "session_id": "c1a2d3e4-…",
-    "parent_tool_use_id": "toolu_01TaskAa",   // ← the spawn
-    "message": { "role": "assistant", "content": [
-      { "type": "text", "text": "Scanning delivery/ for retry loops…" },
-      { "type": "tool_use", "id": "toolu_02Grep", "name": "Grep",
-        "input": { "pattern": "retry", "path": "src/delivery" } }
-    ] }
-} }
-

⤷ prose AND tool calls arrive, all tagged. Identity outlives the stream: - listSubagents(sessionId) → getSubagentMessages(sessionId, agentId) replays the - subagent's own JSONL transcript (…/<sessionId>/subagents/agent-<agentId>.jsonl); - SubagentStart/SubagentStop hooks carry agent_id + agent_type.

-
-
-
- -
-
adapter drops subagent prose — by design
-
≠ D8 · critical — text/thinking from a subagent never reaches ANY ACP client; there is no - frame for “subagent said something”
-
-
-
acpacp-agent.js:1729 — comment is verbatim from source
-
// subagent assistant message (parent_tool_use_id !== null):
-// "It is never streamed live and its text/thinking is internal
-//  to the tool call — keep dropping it so subagent prose
-//  doesn't leak into the top-level feed."
-content = message.message.content.filter(
-  (item) => item.type !== "text" && item.type !== "thinking");
-

⤷ only the tool_use blocks survive the filter and continue to the next hop.

-
-
-
- -
-
nested tool_call — nesting rides non-standard _meta
-
-
-
acpacp-agent.js:4085 — parentToolUseId stamped on every subagent-born update
-
{ "kind": "update", "update": {
-    "sessionUpdate": "tool_call",
-    "toolCallId": "toolu_02Grep",
-    "title": "grep \"retry\" src/delivery",
-    "kind": "search", "status": "pending",
-    "rawInput": { "pattern": "retry", "path": "src/delivery" },
-    "_meta": { "claudeCode": {
-        "toolName": "Grep",
-        "parentToolUseId": "toolu_01TaskAa" } }   // ← the whole tree
-} }
-

⤷ _meta is ACP's sanctioned extension point, but nothing about this key is standard: - a generic ACP client renders this call flat, as a sibling of the Task it belongs to.

-
-
-
- -
-
who can rebuild — and later re-read — the subagent
-
-
-
acpsession-protocol fold.ts — upsertToolCall re-homing
-
// OUR fold knows the adapter's private key:
-claudeParentToolUseId(src) =>
-  src._meta?.claudeCode?.parentToolUseId ?? null
-// a tagged frame re-homes the item under its Task — splice
-// from top level, re-insert as ToolCallItem.children[]
-// Different adapter → different key. Standard client → no tree.
-// session/load can rebuild retained tree frames from native history;
-// frames older than the host ring remain unavailable.
-
sdksdk.d.ts:760 — any time later
-
// by (sessionId, agentId), long after the stream:
-getSubagentMessages("c1a2d3e4-…", "3f2b…")
-// → the subagent's full message history from its own
-//   transcript file; survives host restarts
-
-
-
-
- - -
-
-

⑪ Native tasks — graph in, checklist out

-

reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
- Native: an ID-addressed task graph (TaskCreate / TaskUpdate / TaskList / TaskGet) with dependencies, owner and metadata. - Over ACP: the four tools are suppressed and each mutation re-emits one flat plan snapshot — - content + status only — D9.

- -
-
TaskCreate / TaskUpdate — building the graph, on the wire verbatim
-
-
-
sdksdk-tools.d.ts:2381 — TaskCreateInput / TaskUpdateInput
-
{ "type": "tool_use", "name": "TaskCreate",
-  "input": { "subject": "Add retry queue",
-    "description": "Durable queue with exponential backoff",
-    "activeForm": "Adding retry queue",
-    "metadata": { "epic": "webhooks" } } }
-// → tool_result: { "task": { "id": "task-1", … } }   ← addressable
-
-{ "type": "tool_use", "name": "TaskUpdate",
-  "input": { "taskId": "task-2", "status": "in_progress",
-    "addBlockedBy": [ "task-1" ],          // dependency edges
-    "owner": "recon-retries" } }           // who's on it
-
-// TaskGet  → { task: { id, subject, description, status,
-//                      blocks: [], blockedBy: ["task-1"] } }
-// TaskList → { tasks: [ { id, subject, status, owner,
-//                         blockedBy: [] }, … ] }
-

⤷ ids, dependency edges (blocks/blockedBy), owner, metadata — a real graph, - queryable mid-run. TodoWrite (content/status/activeForm) still exists alongside as the lightweight form.

-
-
-
- -
-
adapter: suppress the tools, accumulate private state
-
-
-
acpacp-agent.js:3797 · tools.js:598
-
// no tool_call frame is ever emitted for these:
-shouldEmitToolCall = toolName !== "TodoWrite"
-                     && !isTaskTool(toolName)   // TaskCreate/Update/List/Get
-// at tool_result, mutate an in-adapter Map instead:
-applyTaskCreate(state, input, output)  // keeps subject, status,
-applyTaskUpdate(state, input)          // activeForm, description
-// TaskList / TaskGet: invisible — reads never cross
-
-
-
- -
-
one flat plan frame — re-emitted whole after every mutation
-
≠ D9 — ID-addressed graph in, flat checklist out; every entry's priority is invented - as "medium" by construction
-
-
-
acptools.js:629 — taskStateToPlanEntries
-
{ "kind": "update", "update": {
-    "sessionUpdate": "plan",
-    "entries": [
-      { "content": "Add retry queue",
-        "status": "in_progress", "priority": "medium" },
-      { "content": "Wire dead-letter alerts",
-        "status": "pending",     "priority": "medium" }
-    ] } }
-

⤷ entries carry content + status only; priority is hardcoded "medium". - Dropped on the adapter floor: taskId · description · activeForm · - blocks/blockedBy · owner · metadata. TodoWrite takes the same door (tools.js:553) and loses its - activeForm too. Fold: PlanItem is replaced wholesale per frame — no diffing possible without ids.

-
-
-
-
- - -
-
-

⑫ Plan mode — enter, plan, exit

-

observed in the ACP spike · recorded in plans/done/20260710-session-harness-acp.md · shapes matched to adapter source 0.56.0
- The one complex interaction ACP handles better than raw: plan-exit crosses as an ordinary permission request wearing - kind:"switch_mode", with the plan document as content and the target permission modes as tappable options.

- -
-
session/set_mode — entering plan mode; echoed back as a notification
-
-
-
acpsame RPC as phase ①'s third round-trip
-
{ "jsonrpc": "2.0", "id": 12, "method": "session/set_mode",
-  "params": { "sessionId": "sess_9f3k…", "modeId": "plan" } }
-// ← { "id": 12, "result": {} }
-// then, as its own session/update notification:
-{ "sessionUpdate": "current_mode_update",
-  "currentModeId": "plan" }
-// → journaled inside the next state snapshot (currentMode)
-
-
-
- -
-
plan exit — a permission request wearing kind "switch_mode"
-
≠ — the adapter turns ExitPlanMode into a ready-made mode-switch decision: optionIds ARE - permission modes, the plan document rides as ordinary content
-
-
-
acpacp-agent.js:2195 — options filtered by availableModes
-
{ "jsonrpc": "2.0", "id": 19, "method": "session/request_permission",
-  "params": {
-    "sessionId": "sess_9f3k…",
-    "toolCall": {
-      "toolCallId": "toolu_01ExitPlan",
-      "title": "Ready to code?",
-      "kind": "switch_mode",
-      "content": [ { "type": "content", "content": {
-          "type": "text", "text": "## Plan\n1. Add retry queue…" } } ],
-      "rawInput": { "plan": "## Plan\n1. Add retry queue…" }
-    },
-    "options": [
-      { "optionId": "auto",        "name": "Yes, and use \"auto\" mode",       "kind": "allow_always" },
-      { "optionId": "acceptEdits", "name": "Yes, and auto-accept edits",      "kind": "allow_always" },
-      { "optionId": "default",     "name": "Yes, and manually approve edits", "kind": "allow_once"    },
-      { "optionId": "plan",        "name": "No, keep planning",               "kind": "reject_once"   }
-    ] } }
-

⤷ crosses to devices via the exact phase-⑤ machinery: permission_requested row + state - snapshot; mobile renders option buttons it already knows how to render. A bypassPermissions option is prepended - only when the adapter allows bypass.

-
-
-
- -
-
accept — the mode actually changes, atomically with the answer
-
-
-
acpacp-agent.js:2195 — accept path
-
{ "id": 19, "result": { "outcome": {
-    "outcome": "selected", "optionId": "acceptEdits" } } }
-// adapter reacts: emits
-{ "sessionUpdate": "current_mode_update",
-  "currentModeId": "acceptEdits" }
-// and resolves ExitPlanMode inside the SDK as
-//   { behavior: "allow", updatedInput, updatedPermissions:
-//     [ { type: "setMode", mode: "acceptEdits",
-//         destination: "session" } ] }
-// tapping "plan" instead → deny:
-//   "User rejected request to exit plan mode."
-
-
-
- -
-
canUseTool("ExitPlanMode") — same tool, zero interpretation
-
≠ — the SDK forwards a plain permission ask; that accepting should ALSO flip - permissionMode is semantics the client must know by itself
-
-
-
sdkjournaled as phase ⑤'s permission_requested shape
-
canUseTool("ExitPlanMode",
-  { plan: "## Plan\n1. Add retry queue…" },
-  { requestId, toolUseID,
-    suggestions: [ /* setMode … */ ] })
-// the wire says "a tool wants permission" — nothing more.
-// Building "Ready to code?" UX, offering target modes, and
-// flipping permissionMode on accept is all client code.
-
-
-
-
- - -
-
-

⑬ Terminal & background Bash

-

reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
- ACP the spec has client-owned terminals (terminal/create, terminal/output, …) gated on - clientCapabilities.terminal. Our host says false — and this adapter wouldn't call them anyway: - it never invokes terminal/* and gates Bash output streaming on a non-standard _meta - capability instead.

- -
-
what our host advertised back in phase ①
-
-
-
acpacp-sessions.ts:612 — initialize
-
"clientCapabilities": {
-  "fs": { "readTextFile": false, "writeTextFile": false },
-  "terminal": false,          // ← no client-owned terminals
-  "elicitation": { "form": {} }
-}
-// and no _meta["terminal_output"] either — see below
-

⤷ grep the adapter dist for terminal/create: zero calls. Standard ACP terminals are - simply not implemented by claude-agent-acp; its only terminal integration is its own _meta extension.

-
-
-
- -
-
Bash on the wire — adapter-owned "execute", output only at the end
-
≠ — long-running commands show a heartbeat, not output; stdout arrives in one lump at - completion. The SDK side can poll incremental output on demand
-
-
-
acptools.js — case "Bash" → kind "execute" · acp-agent.js:1751 tool_progress
-
{ "kind": "update", "update": {
-    "sessionUpdate": "tool_call",
-    "toolCallId": "toolu_01Bash",
-    "title": "bun test --watch=false",
-    "kind": "execute", "status": "pending",
-    "content": [ { "type": "content", "content": {
-        "type": "text", "text": "Run the test suite" } } ],
-    "rawInput": { "command": "bun test --watch=false",
-      "description": "Run the test suite",
-      "run_in_background": true },
-    "_meta": { "claudeCode": { "toolName": "Bash" } } } }
-
-// long-running: heartbeat patches, NOT output
-{ "sessionUpdate": "tool_call_update",
-  "toolCallId": "toolu_01Bash", "status": "in_progress",
-  "_meta": { "claudeCode": { "toolResponse":
-      { "elapsedTimeSeconds": 45 } } } }
-
-// completion — stdout lands once, in one piece
-{ "sessionUpdate": "tool_call_update",
-  "toolCallId": "toolu_01Bash", "status": "completed",
-  "content": [ /* full output as text */ ], "rawOutput": { … } }
-

⤷ a client that advertises _meta["terminal_output"] = true unlocks the adapter's own - lifecycle (_meta.terminal_info / terminal_output / terminal_exit on these same frames) — still - adapter-owned, still not ACP's terminal/* methods.

-
-
-
- -
-
native background shell — spawn, poll, kill — all addressable
-
-
-
sdkBashInput.run_in_background — sdk-tools.d.ts:430 · TaskStop :2628
-
{ "type": "tool_use", "name": "Bash",
-  "input": { "command": "bun test --watch=false",
-             "run_in_background": true } }
-// → tool_result names the background task; from then on:
-//    TaskOutput { task_id }   — incremental output, on demand
-//    TaskStop   { task_id }   — kill it
-// result frames enumerate background_tasks:
-//    { id, type: "shell", command, status, … }
-

⤷ over ACP those follow-ups have no special case in the adapter switch — TaskOutput / TaskStop - surface as generic kind:"other" tool calls (tools.js default arm).

-
-
-
-
- - -
-
-

⑭ Workflows

-

reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
- Native: a first-class Workflow tool runs a multi-agent program — agent() / - parallel() / pipeline() / phase() — with a run id and a resume path. - Over ACP: one opaque tool call — D10.

- -
-
Workflow tool_use — a resumable multi-agent program
-
-
-
sdkWorkflowInput — sdk-tools.d.ts:2462 · WorkflowOutput :3443
-
{ "type": "tool_use", "name": "Workflow",
-  "input": {
-    "script": "export const meta = { name: \"triage\", … }
-       const findings = await parallel([
-         agent(\"scan delivery/\"), agent(\"scan api/\") ])
-       await phase(\"fix\", () => pipeline(…))",
-    "args": { "area": "webhooks" } } }
-// → tool_result (WorkflowOutput):
-{ "status": "async_launched",
-  "taskId": "task-9", "taskType": "local_workflow",
-  "workflowName": "triage",
-  "runId": "run-01H…",              // ← resumeFromRunId target
-  "transcriptDir": "…/subagents/",
-  "scriptPath": "…/workflows/triage.ts" }
-

⤷ the run is addressable (taskId/runId), its transcripts land on disk, and a re-run with - resumeFromRunId replays completed agent() calls from cache instead of re-executing.

-
-
-
- -
-
the same thing over ACP — one opaque tool call
-
≠ D10 — a resumable multi-agent program crosses as kind "other"; its lifecycle - events don't cross at all
-
-
-
acptools.js default arm — no "Workflow" case exists
-
{ "kind": "update", "update": {
-    "sessionUpdate": "tool_call",
-    "toolCallId": "toolu_01Wf",
-    "title": "Workflow",            // default: title = tool name
-    "kind": "other", "status": "pending",
-    "rawInput": { "script": "export const meta = …", "args": { … } },
-    "_meta": { "claudeCode": { "toolName": "Workflow" } } } }
-// …then a single "completed" patch carrying the
-// WorkflowOutput as rawOutput. That's the whole story:
-// task_started / task_summary system frames → no case in the
-// adapter's system-subtype switch → dropped. Sub-run activity
-// crosses only as whatever agent() spawns emit (phase ⑩ rules).
-
-
-
-
- - -
-
-

⑮ Goal setting

-

verified at type/dist level only — no captured frames exist for this one
- Goals exist in the SDK as a streamed message type — not a tool — and the ACP adapter has no mapping for them. - This is the cleanest example of D10: a capability that simply does not cross the wire.

- -
-
active_goal — plumbed through the SDK stream
-
-
-
sdksdk.mjs (0.3.201) · StdoutMessage union, sdk.d.ts:6406
-
// bundled sdk.mjs forwards it into the message stream:
-if (e.type === "active_goal") { this.inputStream.enqueue(e) }
-// StdoutMessage names coreTypes.SDKActiveGoalMessage
-// (alongside SDKTaskSummaryMessage, SDKPostTurnSummaryMessage)
-// — but 0.3.201 exports no shape for it and ships no Goal
-// tool in ToolInputSchemas: plumbing, not yet public API.
-
-
-
- -
-
adapter: no mapping — goals do not cross
-
≠ D10 — zero mentions of "goal" in claude-agent-acp 0.56.0; an active_goal message - falls through to the loop's catch-all
-
-
-
acpacp-agent.js:1787 message loop · utils.js:69 unreachable()
-
// known-but-ignored types get an explicit `break`:
-//   tool_use_summary · auth_status · prompt_suggestion ·
-//   conversation_reset · control_request_progress
-// anything else — active_goal included — hits:
-default: unreachable(message)   // logged, dropped, never emitted
-
-
-
- -
-
aside — codex-acp DOES map goals… via _meta again
-
-
-
codex-acp 1.1.2dist/index.js — createCodexSessionInfoUpdate
-
// thread/goal/updated crosses as:
-{ "sessionUpdate": "session_info_update",
-  "_meta": { "codex": { "goal": {
-      "objective": "…", "status": "…", "tokenBudget": … } } } }
-// thread/goal/cleared → _meta.codex.goal: null
-// standard ACP field? no — the extension point, again.
-// Two adapters, two private _meta dialects for the same idea.
-
-
-
-
- -
-
-
- -
-

4Types per layer

-

The four load-bearing shapes, trimmed to essentials. Where the two sides are identical, the shape is shown once.
- Current ACP validation status: the shared TypeScript surface is strict, but the runtime boundary is not fully Zod-checked. - tRPC scalar inputs are parsed; ACP content uses shallow checks; relay outputs, nested WS frames, adapter responses and SQLite - rows are still trusted after partial checks. The exact boundary matrix lives in packages/host-service/docs/acp-sessions.md.

- -
-

L1 · wire envelope — what travels mobile ↔ host

-

Same wrapper on both sides. The difference is the frame union: 6 kinds vs 9 — three interaction channels instead of one.

-
-
-
ACPpackages/session-protocol/src/envelope.ts:4
-
export type SessionUpdateFrame =
-  | { kind: "update"; update: SessionUpdate }   // ACP session/update, verbatim
-  | { kind: "permission_requested"; pending: PendingPermission }
-  | { kind: "permission_resolved"; requestId: string;
-      outcome: RequestPermissionOutcome }
-  | { kind: "prompt_rejected"; reason: string; promptStartSeq: number }
-  | { kind: "state"; state: SessionScopedState }  // full snapshot on every change
-  | { kind: "reset"; reason: string };            // cursor unservable — resync
-
-export interface SessionUpdateEnvelope {
-  seq: number;        // per-session, monotonic from 1, gapless
-  sessionId: string;
-  ts: number;
-  frame: SessionUpdateFrame;
-}
-
-
-
Claude SDKpackages/session-protocol/src/events.ts:25
-
export type SessionEventFrame =
-  | { kind: "sdk"; message: SDKMessage }          // raw SDK frame, verbatim
-  | { kind: "permission_requested"; request: PendingPermissionRequest }
-  | { kind: "permission_resolved"; requestId: string;
-      response: SessionPermissionResult }
-  | { kind: "user_dialog_requested"; request: PendingUserDialogRequest }
-  | { kind: "user_dialog_resolved"; requestId: string;
-      response: UserDialogResult }
-  | { kind: "elicitation_requested"; request: PendingElicitationRequest }
-  | { kind: "elicitation_resolved"; requestId: string;
-      response: ElicitationResult }
-  | { kind: "state"; state: SessionScopedState }
-  | { kind: "reset"; reason: string; latestSeq?: number };
-
-export interface SessionEventEnvelope {
-  seq: number; sessionId: string; ts: number;
-  frame: SessionEventFrame;
-}
-
-
-
- -
-

L2 · journal row — what the host stores

-

The journal row IS the envelope on both sides, and the append is the same idea character for character.

-
-
Both — identical mechanismhost-service …/journal.ts · 99 vs 92 LOC
-
append(sessionId: string, frame: Frame): Envelope {
-  const envelope = { seq: this.nextSeq, sessionId, ts: Date.now(), frame };
-  this.nextSeq += 1;
-  this.entries.push(envelope);
-  if (this.entries.length > this.capacity) this.entries.shift(); // in-memory ring, cap 5,000
-  return envelope;  // fanned out to WS subscribers in the same call
-}
-
-

ACP — the bounded journal currently serves both catch-up and history: - getMessages pages retained rows backwards (cursor s<seq>, message-frame kinds only). A restart uses the SQLite - registry and session/load to rebuild a fresh ring, but rows beyond the cap remain unavailable. · - Claude SDK — the journal is only a live-delivery buffer: history truth is Claude's native - JSONL transcript; retry() opens a new journal epoch (seq restarts at 1).

-
- -
-

L3 · host session surface — the harness wrapping the agent

-

One manager class each, same lifecycle verbs. ACP wraps a child process spoken to over JSON-RPC/stdio; - the SDK holds an in-process query() handle whose callbacks are host-local.

-
-
-
ACP…/acp-sessions/acp-sessions.ts:166 · 978 LOC
-
export class AcpSessionManager {
-  create({ sessionId, workspaceId })            // idempotent
-  prompt({ sessionId, prompt })                 // ContentBlock[] → accepted
-  respondToPermission({ sessionId, requestId, outcome })
-  cancel · setMode · setConfigOption
-  get · list · getMessages({ beforeSeq })       // pages the journal
-  subscribe({ sessionId, since, onEnvelope })   // sync replay + live
-}
-// per session: claude-agent-acp CHILD PROCESS (ACP JSON-RPC over
-// stdio), journal, subscribers, parked permission resolvers
-// (Map<requestId, resolve>)
-
-
-
Claude SDK…/sessions/sessions.ts:123 · 965 LOC
-
export class ClaudeSessionManager {
-  create(input)                                 // starts query(); idempotent
-  retry(input)                                  // NEW Query + native transcript
-  sendMessage(input)                            // requires status "idle"
-  respondToPermission · respondToUserDialog · respondToElicitation
-  interrupt · setModel · setPermissionMode · getCatalog
-  get · list · getMessages(input)               // native JSONL transcript
-  subscribe({ sessionId, since, onEnvelope })
-}
-// per session: IN-PROCESS query() handle (canUseTool / onUserDialog /
-// onElicitation callbacks), AsyncInputQueue, journal, 3 pending maps
-// + claude-runtime.ts: executable resolution, trusted-shell env (228 LOC)
-
-
-
- -
-

L4 · client timeline item — what the fold produces for the UI

-

ACP nests subagents as a tree and has first-class plans; the SDK tracks partial-stream state and gives - dialogs/elicitations their own timeline kinds.

-
-
-
ACPsession-protocol/src/fold/fold.ts:24 · 595 LOC
-
export type TimelineItem = MessageItem | ToolCallItem | PlanItem;
-
-interface MessageItem  { kind: "message"; role: "user" | "agent" | "thought";
-                         blocks: ContentBlock[]; failed: boolean }
-interface ToolCallItem { kind: "tool_call"; id: string;      // toolCallId
-                         call: ToolCall;                     // merged patches
-                         permissions: PermissionView[];
-                         children: TimelineItem[] }     // subagent TREE (Task)
-interface PlanItem     { kind: "plan"; entries: PlanEntry[]; removed: boolean }
-// every item carries startSeq / endSeq
-
-
-
Claude SDKsession-protocol/src/fold/fold.ts:28 · 704 LOC
-
export type TimelineItem =
-  MessageItem | ToolCallItem | UserDialogItem | ElicitationItem;
-
-interface MessageItem  { kind: "message"; role: "user" | "assistant" | "system";
-                         blocks: TimelineContentBlock[];
-                         partial: boolean }       // delta-accretion state
-interface ToolCallItem { kind: "tool_call"; id: string;      // toolUseID
-                         name: string; input: unknown; result: unknown | null;
-                         status: "pending" | "completed" | "error" | "denied";
-                         permissions: { request; response }[] }
-// + UserDialogItem / ElicitationItem: { request, response | null }
-// every item carries startSeq / endSeq
-
-
-
-
- -
-

5Seq journal & UI folding — the lower-level contract

-

Where the two sides converged exactly, and where the SDK fold earns its extra 109 lines.

- -
-
-

ACP

-
seq assignment
-
Host only, per-session, gapless from 1. Append and WS fan-out happen in the same call — no gap window.
-
row granularity
-
One row per semantic event: every chunk, every tool_call / patch, every permission edge, every state snapshot. No write-time coalescing.
-
append vs update
-
Rows are immutable — everything appends. All merging is client-side at fold time: chunk concat, patch merge, resolution attach.
-
history source
-
Claude's native store survives; the host reaches it only through full session/load replay. getMessages then walks the bounded ring backwards (cursor s<seq>; state/reset frames excluded), so this is not complete disk-backed pagination yet.
-
replay & dedupe
-
Server replays (since, latest]; client drops seq ≤ lastSeq, treats seq > lastSeq+1 as a gap (reconnect from cursor), treats reset as stop-and-resync. Backoff 250ms → 10s.
-
fold hot spots
-
Consecutive-chunk merge · ACP patch semantics · subagent tree re-homing via _meta.claudeCode.parentToolUseId · placeholder tool item when a permission's tool_call fell off the loaded page.
-
-
-

Claude SDK

-
seq assignment
-
Same — host, per-session, gapless from 1. Plus epochs: retry() creates a fresh journal restarting at 1 and pushes reset("session_restarted") to old subscribers.
-
row granularity
-
One row per SDKMessage — every stream delta is its own row — plus interaction lifecycle edges and state snapshots.
-
append vs update
-
Same append-only journal. The fold replaces partial messages with the authoritative assistant message and flips tool / dialog items in place.
-
history source
-
The journal is only a recovery buffer. Truth = Claude's native JSONL transcript; transcript rows have no seq (folded at seq 0, uuid-keyed so live re-delivery upserts instead of duplicating).
-
replay & dedupe
-
Same semantics, plus cursor_ahead detection (first replayed seq below the cursor after a restart) and zod validation of every frame — an invalid frame forces a reconnect.
-
fold hot spots
-
Delta accretion + authoritative replacement (uuid identity) · tool linkage via toolUseID with a placeholder when the permission wins the race · tool results arriving as user-role messages · transcript-vs-live merge at seq 0.
-
-
- -
-

Worked example · raw journal rows → folded timeline

-

Six rows in on both sides. ACP folds to three items; the SDK folds to two — its text and tool call ride the same assistant message.

-
-
-
ACPfold.ts semantics, per fold tests
-
seq 10  update  user_message_chunk   "write hello.txt"
-seq 11  update  tool_call            tc-1 "Write hello.txt" pending
-seq 12  permission_requested         req 7 → tc-1, options [allow, reject]
-seq 13  permission_resolved          req 7 → selected "allow"
-seq 14  update  tool_call_update     tc-1 completed, content "done"
-seq 15  update  agent_message_chunk  "Created hello.txt"
-// ────────────────────── folds to ──────────────────────
-MessageItem  { role: "user", blocks: ["write hello.txt"] }         // 10
-ToolCallItem { id: "tc-1", status: "completed",                    // 11–14
-               permissions: [{ req 7, resolution: allow }] }
-MessageItem  { role: "agent", blocks: ["Created hello.txt"] }      // 15
-
-
-
Claude SDKfold.ts semantics, per fold tests
-
seq 41  sdk  stream_event  content_block_start "I'll run"     (uuid A)
-seq 42  sdk  stream_event  content_block_delta " ls for you." (uuid A)
-seq 43  sdk  assistant (A) [text "I'll run ls for you.",
-                            tool_use toolu_9 Bash {command: "ls"}]
-seq 44  permission_requested  req_7 → toolu_9 Bash
-seq 45  permission_resolved   req_7 → { behavior: "allow" }
-seq 46  sdk  user  [tool_result toolu_9 "README.md\nsrc"]
-// ────────────────────── folds to ──────────────────────
-MessageItem  { role: "assistant", partial: false,                  // 41–43
-               blocks: ["I'll run ls for you."] }
-               // deltas built it partial; 43 replaced it authoritative
-ToolCallItem { id: "toolu_9", name: "Bash", status: "completed",   // 43–46
-               permissions: [{ req_7, response: allow }] }
-
-
-
-
- -
-

6Capability fidelity — four stacks, one matrix

-

What each capability looks like natively, and what's left of it after crossing an ACP adapter. - Claude columns are verified against claude-agent-acp 0.56.0 dist + sdk 0.3.201 types in this tree; Codex columns against the - @agentclientprotocol/codex-acp 1.1.2 npm tarball (source-inspected). Magenta - marks fidelity lost in translation.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
capabilitynative claude sdkclaude → acpnative codex app-servercodex → acp
conversationsession (uuid) + JSONL transcript on disk; resume, fork, retry epochsconversation survives through native transcript + host registry + session/load; host paging is limited to the rebuilt ring (D1)thread → turns → items; persisted, resumable, listablefull fidelity — resume / list / close / delete all advertised
planExitPlanMode plan document + TodoWrite entries (content/status/activeForm)flat plan entries; activeForm dropped, priority invented "medium"; plan doc rides the switch_mode permission (⑫)turn plan items with step + status per stepflat plan; every entry forced priority "medium" (updatePlan maps status + step only)
workflowfirst-class Workflow tool — script, taskId/runId, transcript dir, resumable (⑭)one opaque kind:"other" tool call; lifecycle frames droppedno equivalent first-class workflow entityn/a — nothing to map
subagent identityagent id + subagent_type + own transcript, readable by (sessionId, agentId) (⑩)mostly lost — a kind:"think" call; identity = the tool_use id; type only in untyped rawInputchild threads with collab-agent items carrying sender/receiver thread idsnot exposed — child threads excluded from session/list (sourceKinds filter), fork not advertised; thread ids only inside rawInput
subagent activityfull message stream tagged parent_tool_use_id — prose + tools + hooksprose deliberately dropped; nested tool calls only via private _meta.claudeCodecollaboration items + subAgentActivity events on the threadcollab calls become kind:"other"; subAgentActivity explicitly ignored (under the dist's "//ignored types" arm)
dependencies + ownerstasks carry blocks/blockedBy edges, owner, metadata (⑪)all dropped — the adapter never reads those fieldsnot represented in the item modelnot represented
parallel executionparallel() in workflows, background agents/shells, task graph as coordinationopaque — concurrent tool calls interleave with no grouping constructagent collaboration primitives (spawn/send across threads)opaque — generic tool calls, relationships live only in rawInput
-
-

ACP itself has no standard model for any row below "plan": no workflow entity, no task graph, no subagent - addressing — _meta is the protocol's sanctioned extension point, and both adapters lean on it in mutually - incompatible dialects (_meta.claudeCode.parentToolUseId vs _meta.codex.goal). Codex "native" cells - describe the app-server protocol as consumed by codex-acp's own handlers; we have not run a Codex app-server directly.

- -
-
-

ACP has no model for the hard rows

-

Conversation, permissions, modes, plans: standardized. Workflows, task graphs, subagent identity: not in the - protocol at all — every adapter invents a private _meta dialect or silently flattens. The spike doc said - it early: “_meta fields are the sanctioned extension point on every type.”

-
-
-

Good chat plane, not a workflow plane

-

As a common chat/control plane ACP earns its keep — one client vocabulary, permission options on the wire, - plan checklists for free. As a portable workflow/multi-agent model it isn't one: the richest capabilities of both - agents either flatten (tasks → checklist), go opaque (workflows), or vanish (subagent prose, goals).

-
-
-

What a rich Superset UI would need

-

Either agent-specific _meta contracts (we already parse claudeCode.parentToolUseId; - we'd add codex.goal, and re-do it per agent) or a Superset-owned normalized layer above the adapters — - native SDK/app-server in, one internal schema out. The waterfall above is the inventory of what that layer must preserve.

-
-
-
- -
-

Read the numbers with this in mind: the ACP tree has had more UI iteration (plus an already-deleted MCP-bridge detour); - the SDK tree carries heavier supporting infrastructure — a 978-line relay E2E script, packaged-host validation, 7 Maestro flows — - all excluded above. Both are dev-gated. The ACP implementation persists a SQLite session registry and resurrects offline - sessions on demand through session/load; the direct design reads the same native transcript through SDK helpers. - In-flight turns still die with the host on both sides.

-

Sources: the current ACP PR implementation and the archived direct-SDK design. Payloads are reconstructed from the actual type definitions and handlers at the - cited file:line locations; seq numbers are illustrative but consistent within each side. The adapter's initialize/session-new - responses were never captured on the wire and are marked abbreviated rather than invented.

-

Phases ⑩–⑮ and §6 were added after the original capture and are reconstructed from source, not from runs - (each phase carries its own provenance badge): claude-agent-acp 0.56.0 dist + @anthropic-ai/claude-agent-sdk 0.3.201 types as - vendored in this tree, and the @agentclientprotocol/codex-acp 1.1.2 npm tarball. Exception: phase ⑫'s plan-mode shapes were - observed live in the ACP spike recorded in plans/done/20260710-session-harness-acp.md. Goal setting (⑮) is plumbing-level in sdk 0.3.201 — - a stream message type with no published shape — and has no adapter mapping; we state only what the dist shows.

-
-
- - - - diff --git a/apps/mobile/plans/mobile-chat-runtime.md b/apps/mobile/plans/mobile-chat-runtime.md deleted file mode 100644 index 792fefb6c2a..00000000000 --- a/apps/mobile/plans/mobile-chat-runtime.md +++ /dev/null @@ -1,108 +0,0 @@ -# [mobile] Live chat runtime - -> **Scope: legacy Mastra chat only.** This is not the ACP live-session design. -> Current ACP behavior is documented in -> `packages/host-service/docs/acp-sessions.md`; ACP follow-ups are in -> `plans/acp-session-follow-ups.md`. - -Status: implemented for the Mastra-backed mobile chat path; static validation has passed. -Repeat the live host/relay E2E before merge. - -## Scope - -Mobile can render and drive an existing host-owned mastracode chat session: - -- list sessions from synced `chat_sessions` metadata; -- open a thread and poll the owning host's runtime snapshot over the relay; -- send messages and answer approvals, questions, and plan approvals; -- show live terminal-agent rows as read-only status while the host is online. - -Mobile still does not create sessions or persist chat messages to the cloud. - -## Data Flow - -| Data | Source | Mobile path | -|---|---|---| -| Chat session metadata | Cloud Postgres | Electric collection | -| Chat messages and streaming state | Host-service mastracode runtime | Relay tRPC, `chat.getSnapshot` | -| Chat send / approval / question / plan responses | Host-service runtime | Relay tRPC, `chat.*` mutations | -| Terminal-agent status | Host SQLite binding store | Interim relay tRPC, `terminalAgents.listByWorkspace` | - -The relay URL is `${EXPO_PUBLIC_RELAY_URL}/hosts/:/trpc`. -`v2Workspaces.hostId` is the host machine id, and `v2Hosts.isOnline` is the -liveness signal. - -## Decisions - -### D1. Host runtime remains authoritative for message content. - -Message history is not in cloud Postgres or Electric today; it lives in the -host-service process. Mobile therefore requires the owning host to be online and -tunnelled. This keeps the first implementation small, but it means no offline -chat history and no push/subscription yet. - -### D2. Mobile uses a small hand-written host tRPC facade. - -`apps/mobile/lib/trpc/host-chat-types.ts` defines only the host procedures and -payload shapes mobile calls. This avoids importing the full host-service router -type graph into React Native, but it is intentionally lossy and not enforced by -TypeScript against the server. Live E2E is the contract check until a normalized -chat protocol package exists. - -### D3. Titles are seeded from the first host send. - -Host `chat.sendMessage` fire-and-forgets a cloud `chat.updateSession` with the -first 80 characters of the message. Cloud update uses `COALESCE(existing, new)` -so the fallback title only fills an empty title and does not clobber a later -desktop-generated title. - -### D4. Terminal rows are an interim host query. - -Mobile mixes chat rows and terminal-agent rows in one workspace list. Terminal -rows come from `terminalAgents.listByWorkspace` while the host is online. The -longer-term fix is to sync terminal session/status rows to cloud and Electric; -see `plans/cross-client-session-tab-sync.md`. - -### D5. Workspace route uses a stack, not the old floating tab bar. - -The native Chat/Changes tab bar overlapped the thread composer. The workspace -route is now a plain stack; `changes` remains reachable only by explicit -navigation until that surface is real. - -### D6. Local relay directory fallback is dev-only. - -`apps/relay/src/directory.ts` uses an in-memory owner map when -`FLY_MACHINE_ID === "local"`. This avoids local Upstash placeholder failures -when running a single relay process. Production still uses the Redis directory. - -## Known Gaps - -- `EXPO_PUBLIC_RELAY_URL` is optional in env parsing but host-client creation - currently throws if it is unset. Fix before treating unset relay config as a - supported boot path. -- Snapshot polling should be gated by host liveness to avoid noisy 250ms errors - while offline. -- Send failures are currently silent: the optimistic message is removed and the - composer does not render retry/edit state. -- The mobile host facade can drift from host-service procedure types. -- Terminal rows are not shared across clients when the host is offline. - -## Verification - -Static checks run on this branch: - -- `bun run --cwd apps/mobile typecheck` -- `bun run --cwd apps/mobile lint` -- `git diff --check` - -Live E2E to repeat before merge: - -1. Run API, Electric proxy, and relay locally. -2. Start desktop signed in as the same user, with host-service exposed through - the relay. -3. Create a real workspace and chat session on desktop. -4. Run mobile with `EXPO_PUBLIC_RELAY_URL` pointing at the relay. -5. Confirm the workspace and session appear through Electric. -6. Open the mobile thread, send a message, and verify desktop receives it. -7. Trigger an approval or question and answer it from mobile. -8. Stop the desktop host and verify mobile shows the offline path. diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx index 270799bdec1..12e0f7adb5e 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx @@ -1,5 +1,6 @@ import { LegendList } from "@legendapp/list/react-native"; import type { SelectGithubPullRequest } from "@superset/db/schema"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useLiveQuery } from "@tanstack/react-db"; import { useQueryClient } from "@tanstack/react-query"; import { isAfter } from "date-fns"; @@ -25,7 +26,7 @@ import { OrganizationHeaderButton } from "./components/OrganizationHeaderButton" import { OrganizationSwitcherSheet } from "./components/OrganizationSwitcherSheet"; import { SessionRow } from "./components/SessionRow"; import { WorkspaceRow } from "./components/WorkspaceRow"; -import { useHostAcpSessions } from "./hooks/useHostAcpSessions"; +import { useHostSessions } from "./hooks/useHostSessions"; import { useHostTerminalAgents } from "./hooks/useHostTerminalAgents"; import { useVisibleDiffStats } from "./hooks/useVisibleDiffStats"; import { useWorkspacesFilterStore } from "./stores/workspacesFilterStore"; @@ -95,7 +96,12 @@ export function HomeScreen() { (q) => q.from({ githubPullRequests: collections.githubPullRequests }), [collections], ); - const { sessionsByWorkspace } = useHostAcpSessions(selectedHost); + const { sessionsByWorkspace } = useHostSessions(selectedHost); + // Every listed session lives on the selected host; its rows' menu actions + // (rename/archive) go through the canonical sessions surface on that host. + const hostRoutingKey = selectedHost + ? buildHostRoutingKey(selectedHost.organizationId, selectedHost.machineId) + : null; const sortedProjects = useMemo( () => [...projects].sort((a, b) => a.name.localeCompare(b.name)), @@ -252,7 +258,7 @@ export function HomeScreen() { void queryClient.invalidateQueries({ queryKey: ["host-service", "workspaces", "list"], }); - void queryClient.invalidateQueries({ queryKey: ["acp-sessions", "list"] }); + void queryClient.invalidateQueries({ queryKey: ["sessions", "list"] }); void queryClient.invalidateQueries({ queryKey: ["diff-stats"] }); }, [queryClient]); @@ -327,10 +333,11 @@ export function HomeScreen() { /> router.push( - `/(authenticated)/workspace/${item.workspaceId}/chat/acp/${item.row.id}`, + `/(authenticated)/workspace/${item.workspaceId}/chat/${item.row.id}`, ) } /> @@ -345,6 +352,7 @@ export function HomeScreen() { cache, router, attentionByWorkspace, + hostRoutingKey, ], ); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/NewChatWidget.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/NewChatWidget.tsx index 1f97ab6eb54..ff68adae5b1 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/NewChatWidget.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/NewChatWidget.tsx @@ -35,7 +35,6 @@ import { tint, truncationMode, } from "@expo/ui/swift-ui/modifiers"; -import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useRouter } from "expo-router"; @@ -59,6 +58,7 @@ import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; import { getHostServiceClientByUrl } from "@/lib/host-service/client"; import { cn } from "@/lib/utils"; import { useAfterTransitionEnd } from "@/screens/(authenticated)/(home)/hooks/useAfterTransitionEnd"; +import { CHAT_MODELS } from "@/screens/(authenticated)/(home)/utils/chatModels"; import { type ChatTarget, useChatTargetStore, @@ -132,9 +132,7 @@ export function NewChatWidget({ }); const createChatWorkspace = useCreateChatWorkspace(); - const selectedModel = SUPERSET_CHAT_MODELS.find( - (model) => model.id === modelId, - ); + const selectedModel = CHAT_MODELS.find((model) => model.id === modelId); const branchLabel = baseBranch ?? branchData?.defaultBranch ?? "default"; const draftRef = useRef(""); const [hasText, setHasText] = useState(false); @@ -227,7 +225,11 @@ export function NewChatWidget({ if (text.trim().length === 0 && attachments.length === 0) return; if (chatTarget) { startWorkspaceChat - .mutateAsync({ target: chatTarget, message: { text, attachments } }) + .mutateAsync({ + target: chatTarget, + modelId, + message: { text, attachments }, + }) .then(() => { clearChatTarget(); clearComposer(); @@ -246,8 +248,7 @@ export function NewChatWidget({ modelId, message: { text, attachments }, }) - .then((result) => { - if (!result.agents[0]?.ok) return; + .then(() => { setBaseBranch(null); clearComposer(); }) diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts index 707ed038a98..22426aeecd6 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useCreateChatWorkspace/useCreateChatWorkspace.ts @@ -1,14 +1,15 @@ +import type { ContentBlock } from "@superset/host-service-sync/protocol"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { randomUUID } from "expo-crypto"; import { File } from "expo-file-system"; import { useRouter } from "expo-router"; import { Alert } from "react-native"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { createSession, submitTurn } from "@/lib/host/client"; import { getHostServiceClientByUrl } from "@/lib/host-service/client"; +import { resolveChatModelId } from "@/screens/(authenticated)/(home)/utils/chatModels"; import type { NewChatTarget } from "../useNewChatTargets"; -const FALLBACK_MEDIA_TYPE = "application/octet-stream"; - interface CreateChatWorkspaceArgs { target: NewChatTarget; baseBranch: string | null; @@ -17,9 +18,11 @@ interface CreateChatWorkspaceArgs { } /** - * Creates a workspace on the target host with the "superset" agent sugar — - * the host creates the cloud chat session and fires the first message - * itself. Attachments upload to the same host first (they're host-local). + * Creates a workspace on the target host, then starts a canonical + * `sessions.*` chat in it (sessions.create + submitTurn — the same plane + * `useStartWorkspaceChat` uses for existing workspaces) and navigates to the + * live thread. Image attachments ride the first turn as canonical image + * blocks; there is no host-side upload on this plane. */ export function useCreateChatWorkspace() { const router = useRouter(); @@ -32,50 +35,35 @@ export function useCreateChatWorkspace() { modelId, message, }: CreateChatWorkspaceArgs) => { - const client = getHostServiceClientByUrl(target.hostUrl); - - const attachmentIds = await Promise.all( - message.attachments.map(async (attachment) => { - const base64 = await new File(attachment.uri).base64(); - const uploaded = await client.attachments.upload.mutate({ - data: { kind: "base64", data: base64 }, - mediaType: attachment.mediaType ?? FALLBACK_MEDIA_TYPE, - originalFilename: attachment.name, - }); - return uploaded.attachmentId; - }), - ); + const content = await buildTurnContent(message); - return client.workspaces.create.mutate({ + const client = getHostServiceClientByUrl(target.hostUrl); + const created = await client.workspaces.create.mutate({ id: randomUUID(), projectId: target.projectId, baseBranch: baseBranch ?? undefined, - agents: [ - { - agent: "superset", - prompt: message.text.trim(), - attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, - model: modelId, - }, - ], }); + const workspaceId = created.workspace.id; + + const session = await createSession(target.routingKey, { + workspaceId, + activeModel: resolveChatModelId(modelId), + }); + await submitTurn(target.routingKey, { + sessionId: session.session.id, + threadId: session.session.mainThreadId, + content, + }); + return { workspaceId, sessionId: session.session.id }; }, - onSuccess: (result) => { + onSuccess: ({ workspaceId, sessionId }) => { void queryClient.invalidateQueries({ queryKey: ["host-service", "workspaces", "list"], }); - const workspaceId = result.workspace.id; - const agentResult = result.agents[0]; - if (agentResult?.ok && agentResult.kind === "chat") { - router.push( - `/(authenticated)/workspace/${workspaceId}/chat/${agentResult.sessionId}`, - ); - return; - } - // No thread to open — the new workspace appears in the home list. - if (agentResult && !agentResult.ok) { - Alert.alert("Chat failed to start", agentResult.error); - } + void queryClient.invalidateQueries({ queryKey: ["sessions", "list"] }); + router.push( + `/(authenticated)/workspace/${workspaceId}/chat/${sessionId}`, + ); }, onError: (error) => { Alert.alert( @@ -85,3 +73,30 @@ export function useCreateChatWorkspace() { }, }); } + +/** + * First-turn content: the prompt text plus any image attachments as base64 + * image blocks. Rejecting non-images up front (before the workspace is + * created) keeps a doomed submit from leaving an empty workspace behind. + */ +async function buildTurnContent( + message: PromptInputMessage, +): Promise { + const content: ContentBlock[] = []; + const text = message.text.trim(); + if (text.length > 0) content.push({ type: "text", text }); + for (const attachment of message.attachments) { + const mimeType = attachment.mediaType; + if (!mimeType?.startsWith("image/")) { + throw new Error( + "Only image attachments are supported in live sessions yet", + ); + } + const data = await new File(attachment.uri).base64(); + content.push({ type: "image", mimeType, data }); + } + if (content.length === 0) { + throw new Error("Nothing to send"); + } + return content; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts index b041cbea0d3..f0d8717ede7 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useNewChatTargets/useNewChatTargets.ts @@ -1,3 +1,4 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useLiveQuery } from "@tanstack/react-db"; import { useQueries } from "@tanstack/react-query"; import { compareDesc } from "date-fns"; @@ -20,6 +21,8 @@ export interface NewChatTarget { machineId: string; hostName: string; hostUrl: string; + /** `${organizationId}:${machineId}` — the canonical sessions relay key. */ + routingKey: string; } export function targetKeyFor(projectId: string, machineId: string) { @@ -56,6 +59,7 @@ export function useNewChatTargets(workspaces: HostWorkspaceItem[] = []): { machineId: host.machineId, name: host.name, hostUrl: buildRelayHostUrl(host.organizationId, host.machineId), + routingKey: buildHostRoutingKey(host.organizationId, host.machineId), })), [hosts], ); @@ -86,6 +90,7 @@ export function useNewChatTargets(workspaces: HostWorkspaceItem[] = []): { machineId: host.machineId, hostName: host.name, hostUrl: host.hostUrl, + routingKey: host.routingKey, }); } }); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceChat/useStartWorkspaceChat.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceChat/useStartWorkspaceChat.ts index e22ac3e2bd7..b3b8fb1c9fb 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceChat/useStartWorkspaceChat.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceChat/useStartWorkspaceChat.ts @@ -1,11 +1,11 @@ import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import * as Crypto from "expo-crypto"; import { useRouter } from "expo-router"; import { Alert } from "react-native"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; -import { createAcpSession, createAcpSessionsApi } from "@/lib/host/client"; +import { createSession, submitTurn } from "@/lib/host/client"; +import { resolveChatModelId } from "@/screens/(authenticated)/(home)/utils/chatModels"; import type { ChatTarget } from "../../../../stores/chatTargetStore"; export function useStartWorkspaceChat(workspaces: HostWorkspaceItem[]) { @@ -15,9 +15,12 @@ export function useStartWorkspaceChat(workspaces: HostWorkspaceItem[]) { return useMutation({ mutationFn: async ({ target, + modelId, message, }: { target: ChatTarget; + /** Chat-model alias; omitted (file-comment flows) = harness default. */ + modelId?: string; message: PromptInputMessage; }) => { const workspace = workspaces.find( @@ -31,23 +34,24 @@ export function useStartWorkspaceChat(workspaces: HostWorkspaceItem[]) { workspace.organizationId, target.hostId, ); - const sessionId = Crypto.randomUUID(); - await createAcpSession(routingKey, { - sessionId, + // The host mints the session id (sessions.create result carries it). + const created = await createSession(routingKey, { workspaceId: target.workspaceId, + activeModel: modelId ? resolveChatModelId(modelId) : null, }); - await createAcpSessionsApi(routingKey).prompt({ - sessionId, - prompt: [{ type: "text", text: message.text.trim() }], + await submitTurn(routingKey, { + sessionId: created.session.id, + threadId: created.session.mainThreadId, + content: [{ type: "text", text: message.text.trim() }], }); - return { workspaceId: target.workspaceId, sessionId }; + return { workspaceId: target.workspaceId, sessionId: created.session.id }; }, onSuccess: ({ workspaceId, sessionId }) => { void queryClient.invalidateQueries({ - queryKey: ["acp-sessions", "list"], + queryKey: ["sessions", "list"], }); router.push( - `/(authenticated)/workspace/${workspaceId}/chat/acp/${sessionId}`, + `/(authenticated)/workspace/${workspaceId}/chat/${sessionId}`, ); }, onError: (error) => { diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts index 31ccc9d5848..f68508a8014 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newChatPreferencesStore/newChatPreferencesStore.ts @@ -1,10 +1,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; - -const DEFAULT_MODEL_ID = - SUPERSET_CHAT_MODELS[0]?.id ?? "anthropic/claude-opus-4-8"; +import { DEFAULT_CHAT_MODEL_ID } from "@/screens/(authenticated)/(home)/utils/chatModels"; interface NewChatPreferencesStore { modelId: string; @@ -20,7 +17,7 @@ interface NewChatPreferencesStore { export const useNewChatPreferencesStore = create()( persist( (set) => ({ - modelId: DEFAULT_MODEL_ID, + modelId: DEFAULT_CHAT_MODEL_ID, targetKey: null, baseBranch: null, setModelId: (modelId) => set({ modelId }), diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/SessionRow/SessionRow.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/SessionRow/SessionRow.tsx index 69bc2f7a695..ec3737f51f2 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/SessionRow/SessionRow.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/SessionRow/SessionRow.tsx @@ -7,19 +7,25 @@ import type { SessionRowData } from "../../utils/sessionRows"; import { SessionRowMenu } from "./components/SessionRowMenu"; import { compactTime } from "./utils/compactTime"; -// Chat sessions carry no agent/model column yet; they all run through the -// superset agent on Claude models, hence the fixed logo. +// Sessions all run the claude-code agent for now, hence the fixed logo. export function SessionRow({ row, + routingKey, onPress, className, }: { row: SessionRowData; + /** Sessions host for menu actions; null hides the mutating actions. */ + routingKey: string | null; onPress: () => void; className?: string; }) { return ( - + + void queryClient.invalidateQueries({ queryKey: ["sessions", "list"] }); + const renameSession = async () => { + if (!routingKey) return; const name = await prompt({ title: "Rename chat", defaultValue: title, @@ -23,30 +32,33 @@ export function SessionRowMenu({ const trimmed = name?.trim(); if (!trimmed || trimmed === title) return; try { - await apiClient.chat.updateSession.mutate({ - sessionId, - title: trimmed, - }); + await updateSession(routingKey, { sessionId, title: trimmed }); + refreshList(); } catch { Alert.alert("Rename failed"); } }; - const deleteSession = () => { - Alert.alert("Delete chat?", title, [ + // The canonical surface has no hard delete; archiving removes the session + // from every list while the host keeps the transcript. + const archiveSession = () => { + if (!routingKey) return; + Alert.alert("Archive chat?", title, [ { style: "cancel", text: "Cancel" }, { style: "destructive", - text: "Delete", + text: "Archive", onPress: () => { - apiClient.chat.deleteSession - .mutate({ sessionId }) - .catch(() => Alert.alert("Delete failed")); + updateSession(routingKey, { sessionId, archived: true }) + .then(refreshList) + .catch(() => Alert.alert("Archive failed")); }, }, ]); }; + if (!routingKey) return <>{children}; + // The Link exists solely because Link.Menu must be a direct child of // Link; navigation is prevented and taps fall through to the row. return ( @@ -60,17 +72,9 @@ export function SessionRowMenu({ void renameSession()}> Rename - - Delete + + Archive - - Alert.alert("Forking is not available yet")} - > - Fork - - ); diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions/index.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions/index.ts deleted file mode 100644 index cde0071acc0..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - type AcpSessionsHost, - getAcpSessionsQueryKey, - type UseHostAcpSessionsResult, - useHostAcpSessions, -} from "./useHostAcpSessions"; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions/useHostAcpSessions.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions/useHostAcpSessions.ts deleted file mode 100644 index a62525334d0..00000000000 --- a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions/useHostAcpSessions.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { - SessionScopedState, - SessionsPage, -} from "@superset/session-protocol"; -import { buildHostRoutingKey } from "@superset/shared/host-routing"; -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; -import { listAcpSessions } from "@/lib/host/client"; - -export interface AcpSessionsHost { - organizationId: string; - machineId: string; - isOnline: boolean; -} - -const SESSIONS_REFETCH_INTERVAL_MS = 30_000; -const SESSIONS_PAGE_LIMIT = 200; - -export function getAcpSessionsQueryKey(machineId: string | null) { - return ["acp-sessions", "list", machineId] as const; -} - -export interface UseHostAcpSessionsResult { - sessionsByWorkspace: Map; - /** Capability probe: the host's list answer carries `enabled`. */ - enabled: boolean; - /** - * True once the host answered or failed (or is offline). Gates empty - * states only — existing rows always render (cache-first rule). - */ - isReady: boolean; -} - -/** - * ACP sessions served by one host's `acpSessions.list` over the relay, - * host-wide (all workspaces in one page). Same healing model as - * useHostWorkspaces: 30s poll plus focus/pull refetch. - */ -export function useHostAcpSessions( - host: AcpSessionsHost | null, -): UseHostAcpSessionsResult { - const routingKey = host?.isOnline - ? buildHostRoutingKey(host.organizationId, host.machineId) - : null; - - const query = useQuery({ - queryKey: getAcpSessionsQueryKey(host?.machineId ?? null), - enabled: routingKey !== null, - refetchInterval: SESSIONS_REFETCH_INTERVAL_MS, - retry: 1, - networkMode: "always" as const, - queryFn: async (): Promise => { - if (!routingKey) return { items: [], nextCursor: null, enabled: false }; - return listAcpSessions(routingKey, { limit: SESSIONS_PAGE_LIMIT }); - }, - }); - - const sessionsByWorkspace = useMemo(() => { - const byWorkspace = new Map(); - for (const session of query.data?.items ?? []) { - const group = byWorkspace.get(session.workspaceId); - if (group) group.push(session); - else byWorkspace.set(session.workspaceId, [session]); - } - return byWorkspace; - }, [query.data]); - - return { - sessionsByWorkspace, - enabled: query.data?.enabled ?? false, - isReady: routingKey === null || query.isSuccess || query.isError, - }; -} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostSessions/index.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostSessions/index.ts new file mode 100644 index 00000000000..21fe127b86f --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostSessions/index.ts @@ -0,0 +1,6 @@ +export { + getSessionsQueryKey, + type SessionsHost, + type UseHostSessionsResult, + useHostSessions, +} from "./useHostSessions"; diff --git a/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostSessions/useHostSessions.ts b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostSessions/useHostSessions.ts new file mode 100644 index 00000000000..f35a5e1a24c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home/hooks/useHostSessions/useHostSessions.ts @@ -0,0 +1,75 @@ +import type { Session } from "@superset/host-service-sync/protocol"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { listSessions } from "@/lib/host/client"; + +export interface SessionsHost { + organizationId: string; + machineId: string; + isOnline: boolean; +} + +const SESSIONS_REFETCH_INTERVAL_MS = 30_000; + +export function getSessionsQueryKey(machineId: string | null) { + return ["sessions", "list", machineId] as const; +} + +export interface UseHostSessionsResult { + sessionsByWorkspace: Map; + /** + * Capability probe: hosts without the sessions surface (pre-release gate + * off) answer the list with an error, which reads as disabled here. + */ + enabled: boolean; + /** + * True once the host answered or failed (or is offline). Gates empty + * states only — existing rows always render (cache-first rule). + */ + isReady: boolean; +} + +/** + * Canonical sessions served by one host's `sessions.list` over the relay, + * host-wide (all workspaces in one snapshot). Same healing model as + * useHostWorkspaces: 30s poll plus focus/pull refetch. The thread screen + * itself rides the live `/sessions/sync` socket — this list is the cheap + * cold surface behind the home and workspace screens. + */ +export function useHostSessions( + host: SessionsHost | null, +): UseHostSessionsResult { + const routingKey = host?.isOnline + ? buildHostRoutingKey(host.organizationId, host.machineId) + : null; + + const query = useQuery({ + queryKey: getSessionsQueryKey(host?.machineId ?? null), + enabled: routingKey !== null, + refetchInterval: SESSIONS_REFETCH_INTERVAL_MS, + retry: 1, + networkMode: "always" as const, + queryFn: async (): Promise => { + if (!routingKey) return []; + const snapshot = await listSessions(routingKey); + return snapshot.sessions; + }, + }); + + const sessionsByWorkspace = useMemo(() => { + const byWorkspace = new Map(); + for (const session of query.data ?? []) { + const group = byWorkspace.get(session.workspaceId); + if (group) group.push(session); + else byWorkspace.set(session.workspaceId, [session]); + } + return byWorkspace; + }, [query.data]); + + return { + sessionsByWorkspace, + enabled: query.isSuccess, + isReady: routingKey === null || query.isSuccess || query.isError, + }; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/utils/sessionRows/sessionRows.ts b/apps/mobile/screens/(authenticated)/(home)/home/utils/sessionRows/sessionRows.ts index f9c6528aca3..6a22fb8bb6b 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/utils/sessionRows/sessionRows.ts +++ b/apps/mobile/screens/(authenticated)/(home)/home/utils/sessionRows/sessionRows.ts @@ -1,21 +1,19 @@ -import type { SessionScopedState } from "@superset/session-protocol"; +import type { Session } from "@superset/host-service-sync/protocol"; export interface SessionRowData { id: string; title: string; ts: number; - status: SessionScopedState["status"]; + status: Session["runState"]; } -export function buildSessionRows( - sessions: SessionScopedState[], -): SessionRowData[] { +export function buildSessionRows(sessions: Session[]): SessionRowData[] { return sessions .map((session) => ({ - id: session.sessionId, + id: session.id, title: session.title ?? "New session", - ts: session.updatedAt, - status: session.status, + ts: session.lastActivityAt, + status: session.runState, })) .sort((a, b) => b.ts - a.ts); } diff --git a/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx index 8423b06d04f..a5a0186a036 100644 --- a/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/new-chat/model/ModelPickerScreen.tsx @@ -1,10 +1,10 @@ import Ionicons from "@expo/vector-icons/Ionicons"; -import { SUPERSET_CHAT_MODELS } from "@superset/shared/agent-models"; import { Stack, useRouter } from "expo-router"; import { Pressable, ScrollView } from "react-native"; import { Text } from "@/components/ui/text"; import { useTheme } from "@/hooks/useTheme"; import { useNewChatPreferencesStore } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/stores/newChatPreferencesStore"; +import { CHAT_MODELS } from "@/screens/(authenticated)/(home)/utils/chatModels"; import { ProviderLogo } from "./components/ProviderLogo"; export function ModelPickerScreen() { @@ -21,7 +21,7 @@ export function ModelPickerScreen() { router.back()} /> - {SUPERSET_CHAT_MODELS.map((model) => { + {CHAT_MODELS.map((model) => { const isSelected = model.id === modelId; return ( ({ ...model, provider: "Anthropic" })); + +/** Same default the widget shipped with when it targeted the cloud chat. */ +export const DEFAULT_CHAT_MODEL_ID = "opus"; + +/** + * A persisted model preference survives catalog swaps (the store predates the + * canonical-sessions catalog), so ids are validated before they reach + * `sessions.create` — a stale id degrades to the harness default instead of + * failing the create. + */ +export function resolveChatModelId(modelId: string): string | null { + return CHAT_MODELS.some((model) => model.id === modelId) ? modelId : null; +} diff --git a/apps/mobile/screens/(authenticated)/(home)/utils/chatModels/index.ts b/apps/mobile/screens/(authenticated)/(home)/utils/chatModels/index.ts new file mode 100644 index 00000000000..2ab2db12bd0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/utils/chatModels/index.ts @@ -0,0 +1,6 @@ +export { + CHAT_MODELS, + type ChatModel, + DEFAULT_CHAT_MODEL_ID, + resolveChatModelId, +} from "./chatModels"; diff --git a/apps/mobile/screens/(authenticated)/debug/conversation-lab/ConversationLabScreen.tsx b/apps/mobile/screens/(authenticated)/debug/conversation-lab/ConversationLabScreen.tsx new file mode 100644 index 00000000000..09ad86d4d3e --- /dev/null +++ b/apps/mobile/screens/(authenticated)/debug/conversation-lab/ConversationLabScreen.tsx @@ -0,0 +1,462 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + Conversation, + type ConversationController, +} from "@/components/ai-elements/conversation"; +import { Text } from "@/components/ui/text"; + +/** + * Hidden scroll-regression lab (deep link: superset://debug/conversation-lab). + * + * Mounts the real Conversation component on synthetic data and runs scripted + * interaction scenarios that assert the scroll invariants we ship by + * (AGENTS.md next to conversation.tsx). Each scenario self-asserts and + * reports `PASS` / `FAIL(reason)`; the Maestro flow + * (.maestro/conversation-lab.yaml) taps "run all" and asserts every PASS — + * a simplification that breaks an invariant fails visibly instead of + * shipping. + */ + +const ANCHOR_OFFSET_TOP = 8; +const CONTENT_PADDING_BOTTOM = 16; +const OVERLAY_HEIGHT = 280; +/** At-end tolerance: layout rounding plus the sub-point scroll jitter iOS + * reports while settling. */ +const AT_END_EPSILON = 3; +const ANCHOR_EPSILON = 4; + +interface LabItem { + id: string; + role: "assistant" | "user"; + text: string; +} + +const WORDS = + "the scroll view only ever moves when the reader asked it to and every other event converts into trailing whitespace instead".split( + " ", + ); + +/** Deterministic filler paragraph — item heights vary but never randomly. */ +function paragraph(seed: number, sentences: number): string { + const parts: string[] = []; + for (let s = 0; s < sentences; s += 1) { + const length = 6 + ((seed * 7 + s * 11) % 13); + const words: string[] = []; + for (let w = 0; w < length; w += 1) { + words.push(WORDS[(seed + s * 5 + w) % WORDS.length]); + } + parts.push(words.join(" ")); + } + return parts.join(". "); +} + +function initialItems(): LabItem[] { + const items: LabItem[] = []; + for (let i = 0; i < 60; i += 1) { + items.push({ + id: `seed-${i}`, + role: i % 4 === 0 ? "user" : "assistant", + text: `#${i} ${paragraph(i, 1 + (i % 4))}`, + }); + } + return items; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type Measured = { x: number; y: number; width: number; height: number }; + +function measureInWindow(view: View): Promise { + return new Promise((resolve) => { + view.measureInWindow((x, y, width, height) => + resolve({ x, y, width, height }), + ); + }); +} + +type ScenarioKey = + | "followPin" + | "noMoveUnpinned" + | "overlayResolve" + | "keyboardPinned" + | "keyboardUnpinned" + | "sendAnchor" + | "sendAnchorFromHistory"; + +const SCENARIOS: ScenarioKey[] = [ + "followPin", + "noMoveUnpinned", + "overlayResolve", + "keyboardPinned", + "keyboardUnpinned", + "sendAnchor", + "sendAnchorFromHistory", +]; + +export function ConversationLabScreen() { + const controllerRef = useRef(null); + const listBoxRef = useRef(null); + const inputRef = useRef(null); + const rowRefs = useRef(new Map()); + const [items, setItems] = useState(initialItems); + const itemsRef = useRef(items); + itemsRef.current = items; + const [overlayVisible, setOverlayVisible] = useState(false); + const [results, setResults] = useState>>( + {}, + ); + const [runState, setRunState] = useState<"done" | "idle" | "running">("idle"); + const counterRef = useRef(0); + + const appendChunk = useCallback((sentences: number) => { + counterRef.current += 1; + const id = counterRef.current; + setItems((previous) => [ + ...previous, + { + id: `chunk-${id}`, + role: "assistant", + text: paragraph(id, sentences), + }, + ]); + }, []); + + const appendUserMessage = useCallback((): string => { + counterRef.current += 1; + const id = `sent-${counterRef.current}`; + setItems((previous) => [ + ...previous, + { id, role: "user", text: `anchored message ${counterRef.current}` }, + ]); + return id; + }, []); + + const state = useCallback(() => { + const controller = controllerRef.current; + if (controller === null) throw new Error("controller not mounted"); + return controller.getScrollState(); + }, []); + + /** Distance from the viewport bottom edge to the true end of content. */ + const scrollToRawEnd = useCallback(async () => { + const controller = controllerRef.current; + if (controller === null) throw new Error("controller not mounted"); + const s = state(); + controller.scrollTo(Math.max(0, s.rawContentH - s.viewportH), false); + await wait(120); + }, [state]); + + // --- scenarios ---------------------------------------------------------- + + /** Pinned at the bottom, streaming content: the view follows the true end + * on every chunk. */ + const runFollowPin = useCallback(async (): Promise => { + const controller = controllerRef.current; + if (controller === null) return "FAIL(no controller)"; + await scrollToRawEnd(); + controller.setPinned(true); + await wait(150); + for (let i = 0; i < 4; i += 1) { + appendChunk(1 + (i % 3)); + await wait(280); + const s = state(); + const endOffset = Math.max(0, s.rawContentH - s.viewportH); + if (Math.abs(s.offset - endOffset) > AT_END_EPSILON) { + return `FAIL(chunk ${i}: offset ${s.offset.toFixed(1)} != end ${endOffset.toFixed(1)})`; + } + if (!s.pinned) return `FAIL(chunk ${i}: lost pin)`; + } + return "PASS"; + }, [appendChunk, scrollToRawEnd, state]); + + /** Reader parked mid-history: streaming below (including a transient + * content dip) must not move the view by a single point. */ + const runNoMoveUnpinned = useCallback(async (): Promise => { + const controller = controllerRef.current; + if (controller === null) return "FAIL(no controller)"; + controller.setPinned(false); + const s0 = state(); + controller.scrollTo( + Math.max(0, (s0.rawContentH - s0.viewportH) * 0.5), + false, + ); + await wait(200); + const before = state().offset; + for (let i = 0; i < 3; i += 1) { + appendChunk(2); + await wait(220); + } + // Transient dip: remove the last chunk, then put it back — streaming + // re-measurement shrinks content like this all the time. + setItems((previous) => previous.slice(0, -1)); + await wait(220); + appendChunk(2); + await wait(220); + const after = state().offset; + if (Math.abs(after - before) > 1) { + return `FAIL(moved ${before.toFixed(1)} -> ${after.toFixed(1)})`; + } + return "PASS"; + }, [appendChunk, state]); + + /** A bottom overlay (permission stack stand-in) appearing and resolving + * must not move an unpinned reader parked near the end. */ + const runOverlayResolve = useCallback(async (): Promise => { + const controller = controllerRef.current; + if (controller === null) return "FAIL(no controller)"; + controller.setPinned(false); + await scrollToRawEnd(); + // A content event banks the trailing slack for the current position. + appendChunk(1); + await wait(250); + const before = state().offset; + setOverlayVisible(true); + await wait(350); + const shown = state().offset; + if (Math.abs(shown - before) > 1) { + setOverlayVisible(false); + return `FAIL(overlay-in moved ${before.toFixed(1)} -> ${shown.toFixed(1)})`; + } + setOverlayVisible(false); + await wait(350); + const after = state().offset; + if (Math.abs(after - before) > 1) { + return `FAIL(overlay-out moved ${before.toFixed(1)} -> ${after.toFixed(1)})`; + } + return "PASS"; + }, [appendChunk, scrollToRawEnd, state]); + + /** Focusing the composer (keyboard up, viewport shrinks through the + * KeyboardAvoidingView) keeps the newest content visible while pinned, + * and dismissing follows back down — the real send flow's bookends. */ + const runKeyboardPinned = useCallback(async (): Promise => { + const controller = controllerRef.current; + const input = inputRef.current; + if (controller === null || input === null) return "FAIL(no input)"; + await scrollToRawEnd(); + controller.setPinned(true); + await wait(150); + const viewportBefore = state().viewportH; + input.focus(); + await wait(700); + const shown = state(); + if (shown.viewportH >= viewportBefore) { + input.blur(); + return "FAIL(keyboard never resized the viewport)"; + } + const shownEnd = Math.max(0, shown.rawContentH - shown.viewportH); + if (Math.abs(shown.offset - shownEnd) > AT_END_EPSILON) { + input.blur(); + return `FAIL(keyboard-in: offset ${shown.offset.toFixed(1)} != end ${shownEnd.toFixed(1)})`; + } + input.blur(); + Keyboard.dismiss(); + await wait(700); + const after = state(); + const afterEnd = Math.max(0, after.rawContentH - after.viewportH); + if (Math.abs(after.offset - afterEnd) > AT_END_EPSILON) { + return `FAIL(keyboard-out: offset ${after.offset.toFixed(1)} != end ${afterEnd.toFixed(1)})`; + } + return "PASS"; + }, [scrollToRawEnd, state]); + + /** The keyboard coming and going must not move an unpinned reader + * parked in history. */ + const runKeyboardUnpinned = useCallback(async (): Promise => { + const controller = controllerRef.current; + const input = inputRef.current; + if (controller === null || input === null) return "FAIL(no input)"; + controller.setPinned(false); + const s0 = state(); + controller.scrollTo( + Math.max(0, (s0.rawContentH - s0.viewportH) * 0.4), + false, + ); + await wait(200); + const before = state().offset; + input.focus(); + await wait(700); + const shown = state().offset; + if (Math.abs(shown - before) > 1) { + input.blur(); + return `FAIL(keyboard-in moved ${before.toFixed(1)} -> ${shown.toFixed(1)})`; + } + input.blur(); + Keyboard.dismiss(); + await wait(700); + const after = state().offset; + if (Math.abs(after - before) > 1) { + return `FAIL(keyboard-out moved ${before.toFixed(1)} -> ${after.toFixed(1)})`; + } + return "PASS"; + }, [state]); + + /** Sending a message anchors it below the top edge with whitespace + * beneath — asserted on the actual on-screen position of the bubble. */ + const runSendAnchor = useCallback( + async (fromHistory: boolean): Promise => { + const controller = controllerRef.current; + const listBox = listBoxRef.current; + if (controller === null || listBox === null) { + return "FAIL(no controller)"; + } + controller.setPinned(false); + if (fromHistory) { + controller.scrollTo(0, false); + } else { + await scrollToRawEnd(); + } + await wait(250); + const id = appendUserMessage(); + const index = itemsRef.current.length; // the item lands at this index + await wait(60); + controller.scrollToAnchor(index); + // First leg + up to two corrective legs. + await wait(3200); + const row = rowRefs.current.get(id); + if (row === undefined) return "FAIL(anchored row not mounted)"; + const rowBox = await measureInWindow(row); + const box = await measureInWindow(listBox); + const delta = rowBox.y - box.y - ANCHOR_OFFSET_TOP; + if (Math.abs(delta) > ANCHOR_EPSILON) { + return `FAIL(bubble ${delta.toFixed(1)}pt off the anchor line)`; + } + return "PASS"; + }, + [appendUserMessage, scrollToRawEnd], + ); + + const running = useRef(false); + const runAll = useCallback(async () => { + if (running.current) return; + running.current = true; + setRunState("running"); + setResults({}); + const table: Record Promise> = { + followPin: runFollowPin, + keyboardPinned: runKeyboardPinned, + keyboardUnpinned: runKeyboardUnpinned, + noMoveUnpinned: runNoMoveUnpinned, + overlayResolve: runOverlayResolve, + sendAnchor: () => runSendAnchor(false), + sendAnchorFromHistory: () => runSendAnchor(true), + }; + for (const key of SCENARIOS) { + let outcome: string; + try { + outcome = await table[key](); + } catch (cause) { + outcome = `FAIL(${cause instanceof Error ? cause.message : String(cause)})`; + } + setResults((previous) => ({ ...previous, [key]: outcome })); + await wait(200); + } + setRunState("done"); + running.current = false; + }, [ + runFollowPin, + runKeyboardPinned, + runKeyboardUnpinned, + runNoMoveUnpinned, + runOverlayResolve, + runSendAnchor, + ]); + + // Rows unmount as data changes; drop stale refs so measures can't hit + // detached nodes. + useEffect(() => { + const alive = new Set(items.map((item) => item.id)); + for (const key of rowRefs.current.keys()) { + if (!alive.has(key)) rowRefs.current.delete(key); + } + }, [items]); + + const insets = useSafeAreaInsets(); + + return ( + // Same keyboard geometry as SessionThread: the scroll view shrinks + // when the composer focuses, exercising the real viewport branches. + + + void runAll()} + > + + run all scenarios + + + + {`state: ${runState}`} + + {SCENARIOS.map((key) => ( + + {`${key}: ${results[key] ?? "—"}`} + + ))} + + + item.id} + anchorOffsetTop={ANCHOR_OFFSET_TOP} + contentPaddingBottom={CONTENT_PADDING_BOTTOM} + controllerRef={controllerRef} + contentContainerStyle={{ + paddingBottom: CONTENT_PADDING_BOTTOM, + paddingHorizontal: 12, + paddingTop: 8, + }} + renderItem={({ item }) => ( + { + if (node !== null) rowRefs.current.set(item.id, node); + }} + className={ + item.role === "user" + ? "bg-secondary ml-12 mb-2 rounded-xl px-3 py-2" + : "mb-2 px-1" + } + > + {item.text} + + )} + /> + + {overlayVisible ? ( + + overlay (permission stack stand-in) + + ) : null} + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/debug/conversation-lab/index.ts b/apps/mobile/screens/(authenticated)/debug/conversation-lab/index.ts new file mode 100644 index 00000000000..3f141b64061 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/debug/conversation-lab/index.ts @@ -0,0 +1 @@ +export { ConversationLabScreen } from "./ConversationLabScreen"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx index be437bf724d..28bb341003f 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx @@ -1,3 +1,4 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { GitPullRequestArrow } from "lucide-react-native"; @@ -11,7 +12,7 @@ import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; import { cn } from "@/lib/utils"; import { NewChatWidget } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget"; import { SessionRow } from "@/screens/(authenticated)/(home)/home/components/SessionRow"; -import { useHostAcpSessions } from "@/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions"; +import { useHostSessions } from "@/screens/(authenticated)/(home)/home/hooks/useHostSessions"; import { buildSessionRows } from "@/screens/(authenticated)/(home)/home/utils/sessionRows"; import { PressableScale } from "@/screens/(authenticated)/components/PressableScale"; import { useWorkspaceChangeset } from "../hooks/useWorkspaceChangeset"; @@ -39,7 +40,10 @@ export function WorkspaceScreen() { const insets = useSafeAreaInsets(); const { workspace, host } = useWorkspaceHost(id ?? null); - const { sessionsByWorkspace, isReady } = useHostAcpSessions(host); + const { sessionsByWorkspace, isReady } = useHostSessions(host); + const routingKey = host + ? buildHostRoutingKey(host.organizationId, host.machineId) + : null; const { renameWorkspace, deleteWorkspace, copyId, shareWorkspace } = useWorkspaceHeaderActions(workspace, host); const changeset = useWorkspaceChangeset(id ?? null); @@ -121,11 +125,10 @@ export function WorkspaceScreen() { {index > 0 && } - router.push( - `/(authenticated)/workspace/${id}/chat/acp/${row.id}`, - ) + router.push(`/(authenticated)/workspace/${id}/chat/${row.id}`) } /> diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/ChatThreadScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/ChatThreadScreen.tsx deleted file mode 100644 index dd94685d8c6..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/ChatThreadScreen.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useLiveQuery } from "@tanstack/react-db"; -import { Stack, useLocalSearchParams } from "expo-router"; -// Imported from expo-router's vendored copy on purpose: this reads the SAME -// HeaderHeightContext that expo-router's Stack populates. Declaring -// `@react-navigation/elements` as our own dep would pull a second copy with a -// different context instance and always return 0. -import { useHeaderHeight } from "expo-router/build/react-navigation/elements/Header/useHeaderHeight"; -import { - ActivityIndicator, - KeyboardAvoidingView, - Platform, - View, -} from "react-native"; -import { ConversationEmptyState } from "@/components/ai-elements/conversation"; -import { Text } from "@/components/ui/text"; -import { useCollections } from "@/screens/(authenticated)/providers/CollectionsProvider"; -import { GlassHeaderTitle } from "../components/GlassHeaderTitle"; -import { ChatComposer } from "./components/ChatComposer"; -import { ChatMessageList } from "./components/ChatMessageList"; -import { ChatPendingActions } from "./components/ChatPendingActions"; -import type { ChatMessage } from "./hooks/useChatThread"; -import { useChatThread } from "./hooks/useChatThread"; - -export function ChatThreadScreen() { - const { id, sessionId } = useLocalSearchParams<{ - id: string; - sessionId: string; - }>(); - - const chat = useChatThread({ sessionId, workspaceId: id }); - - // Resolve the session title for the nav header (cache-first: render whatever - // synced row we have; blank until it lands). Same full-scan-then-filter shape - // as ChatSessionsScreen — the collection is small. - const collections = useCollections(); - const { data: sessionRows } = useLiveQuery( - (q) => q.from({ chatSessions: collections.chatSessions }), - [collections], - ); - const sessionTitle = - sessionRows?.find((s) => s.id === sessionId)?.title ?? "Chat"; - - // The [sessionId] header is transparent (glass) so content is full-bleed - // (top y=0). Because KeyboardAvoidingView measures its frame relative to its - // parent (now the full-screen content view) while the keyboard frame is - // window-absolute, the two share the same origin and the offset is 0. We - // still use the header height to inset the message list below the glass bar. - const headerHeight = useHeaderHeight(); - const hasBanner = !chat.hostOnline || Boolean(chat.error); - - // Resolving the workspace row from synced data (cache-first): only a spinner - // when we truly have nothing yet. - if (chat.workspaceResolving) { - return ( - - - - ); - } - - if (!chat.hostId || !chat.organizationId) { - return ( - - - - ); - } - - const hasMessages = chat.messages.length > 0 || chat.currentMessage != null; - - return ( - - , - }} - /> - - {hasBanner ? ( - // Banners sit below the transparent header (the message list scrolls - // under it, but these status strips shouldn't be obscured by the glass). - - {!chat.hostOnline ? ( - - - Host offline — open Superset on your Mac to reach this session. - - - ) : null} - - {chat.error ? ( - - - {chat.error} - - - ) : null} - - ) : null} - - - {chat.isConversationLoading && !hasMessages ? ( - - - - ) : hasMessages ? ( - - ) : ( - - )} - - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/AcpSessionThreadScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/SessionThreadScreen.tsx similarity index 81% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/AcpSessionThreadScreen.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/SessionThreadScreen.tsx index 5f021db43d0..dd77204e405 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/AcpSessionThreadScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/SessionThreadScreen.tsx @@ -2,8 +2,8 @@ import { Stack, useLocalSearchParams } from "expo-router"; import { ActivityIndicator, View } from "react-native"; import { Text } from "@/components/ui/text"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; -import { useHostAcpSessions } from "@/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions"; -import { useHostRoutingKey } from "../../../hooks/useHostRoutingKey"; +import { useHostSessions } from "@/screens/(authenticated)/(home)/home/hooks/useHostSessions"; +import { useHostRoutingKey } from "../../hooks/useHostRoutingKey"; import { SessionThread } from "./components/SessionThread"; /** @@ -11,7 +11,7 @@ import { SessionThread } from "./components/SessionThread"; * before mounting the live thread. Auth tokens are minted lazily per * request/connect inside lib/host/client, so no token gate is needed here. */ -export function AcpSessionThreadScreen() { +export function SessionThreadScreen() { const { id, sessionId } = useLocalSearchParams<{ id: string; sessionId: string; @@ -19,11 +19,11 @@ export function AcpSessionThreadScreen() { const routingKey = useHostRoutingKey(id); const { workspace, host } = useWorkspaceHost(id ?? null); - const { sessionsByWorkspace } = useHostAcpSessions(host); + const { sessionsByWorkspace } = useHostSessions(host); const session = id ? sessionsByWorkspace .get(id) - ?.find((candidate) => candidate.sessionId === sessionId) + ?.find((candidate) => candidate.id === sessionId) : undefined; if (!sessionId) return null; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/components/ModelPicker/ModelPicker.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/components/ModelPicker/ModelPicker.tsx deleted file mode 100644 index f7316a5850b..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/components/ModelPicker/ModelPicker.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { BottomSheet, Group, Host, RNHostView } from "@expo/ui/swift-ui"; -import { - background, - environment, - presentationDragIndicator, -} from "@expo/ui/swift-ui/modifiers"; -import Ionicons from "@expo/vector-icons/Ionicons"; -import { ChevronsUpDownIcon } from "lucide-react-native"; -import { useMemo, useState } from "react"; -import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; -import { Button } from "@/components/ui/button"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; -import { useTheme } from "@/hooks/useTheme"; - -export interface ModelOption { - id: string; - name: string; - provider: string; -} - -/** Keeps the list from growing past a comfortable sheet height; `fitToContents` - * sizes the sheet to whatever this scroller reports. Same treatment as the - * workspace filter sheet's project list. */ -const LIST_MAX_HEIGHT = 340; - -/** - * Composer model control: a small "{model name} ⌄" chip (à la Codex) that opens - * the model list in a native SwiftUI bottom sheet — the same `@expo/ui/swift-ui` - * `BottomSheet` the workspaces screen uses, so detents, drag indicator and - * scrolling all behave like the rest of the app. - */ -export function ModelPicker({ - models, - activeId, - onSelect, - disabled, -}: { - models: ModelOption[]; - /** The model the next send will use: the user's pick, else the catalog default. */ - activeId?: string; - onSelect: (modelId: string) => void; - disabled?: boolean; -}) { - const theme = useTheme(); - const { width } = useWindowDimensions(); - const [isPresented, setIsPresented] = useState(false); - - const activeModel = models.find((model) => model.id === activeId); - // Fall back to the raw id: a session on a model that isn't in the cloud - // catalog should still show *something* truthful rather than "Model". - const label = activeModel?.name ?? activeId ?? "Model"; - - // Group by provider, preserving the catalog's own ordering within a group. - const groups = useMemo(() => { - const byProvider = new Map(); - for (const model of models) { - const existing = byProvider.get(model.provider); - if (existing) existing.push(model); - else byProvider.set(model.provider, [model]); - } - return [...byProvider.entries()]; - }, [models]); - - const handleSelect = (modelId: string) => { - onSelect(modelId); - setIsPresented(false); - }; - - return ( - <> - - - - - - - - - {groups.map(([provider, providerModels]) => ( - - - {provider} - - {providerModels.map((model) => ( - handleSelect(model.id)} - > - - {model.name} - - {model.id === activeId ? ( - - ) : null} - - ))} - - ))} - - - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/components/ModelPicker/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/components/ModelPicker/index.ts deleted file mode 100644 index 65b360d3199..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/components/ModelPicker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type ModelOption, ModelPicker } from "./ModelPicker"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/hooks/useSelectedModel/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/hooks/useSelectedModel/index.ts deleted file mode 100644 index 1f7dc646429..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/hooks/useSelectedModel/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSelectedModel } from "./useSelectedModel"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/hooks/useSelectedModel/useSelectedModel.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/hooks/useSelectedModel/useSelectedModel.ts deleted file mode 100644 index 0a0af433715..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/hooks/useSelectedModel/useSelectedModel.ts +++ /dev/null @@ -1,59 +0,0 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useCallback, useEffect, useSyncExternalStore } from "react"; - -const STORAGE_KEY = "chat.selectedModelId"; - -// Module-level store rather than component state: several composers can be -// mounted across sessions, and they must all agree on the current preference. -let selectedModelId: string | undefined; -let hydrated = false; -const listeners = new Set<() => void>(); - -const emit = () => { - for (const listener of listeners) listener(); -}; - -const subscribe = (listener: () => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -}; - -const getSnapshot = () => selectedModelId; - -/** - * The user's model preference, mirroring desktop's `chatPreferences.selectedModelId` - * (`ChatPaneInterface.tsx`): persisted across launches and sent with **every** - * message as `metadata.model`, which makes the host `switchModel` before the turn. - * - * Until the user picks something there is no preference at all, and the composer - * falls back to the catalog default — again matching desktop. The host does not - * report which model a session is on, so a pick made on another client is not - * reflected here. - */ -export function useSelectedModel(): [string | undefined, (id: string) => void] { - const modelId = useSyncExternalStore(subscribe, getSnapshot); - - // Read-through hydration, once per app launch. Until it lands the chip shows - // the host's model, which is a truthful thing to show rather than a flash of - // the wrong label. - useEffect(() => { - if (hydrated) return; - hydrated = true; - void AsyncStorage.getItem(STORAGE_KEY).then((stored) => { - if (stored && !selectedModelId) { - selectedModelId = stored; - emit(); - } - }); - }, []); - - const select = useCallback((next: string) => { - selectedModelId = next; - emit(); - void AsyncStorage.setItem(STORAGE_KEY, next); - }, []); - - return [modelId, select]; -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/index.ts deleted file mode 100644 index bc47fe7ec34..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChatComposer } from "./ChatComposer"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/ChatMessageList.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/ChatMessageList.tsx deleted file mode 100644 index 3dc7ad63387..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/ChatMessageList.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { useRef } from "react"; -import { - ActivityIndicator, - type NativeScrollEvent, - type NativeSyntheticEvent, - ScrollView, - View, -} from "react-native"; -import { MessageResponse } from "@/components/ai-elements/message"; -import { - Reasoning, - ReasoningContent, - ReasoningTrigger, -} from "@/components/ai-elements/reasoning"; -import { Text } from "@/components/ui/text"; -import type { ChatMessage } from "../../hooks/useChatThread"; -import { ToolPartView } from "./components/ToolPartView"; - -type Part = ChatMessage["content"][number]; -type Rec = Record; - -/** - * The snapshot carries raw mastracode `HarnessMessage` content, not AI-SDK - * UIMessage parts. Tool calls are `{ type: "tool_call", id, name, args }` and - * their output arrives as a SEPARATE `{ type: "tool_result", id, name, result, - * isError }` part paired by `id`. `thinking` parts hold text under `.thinking`. - * We flatten `content` into renderable items here (mirroring desktop's - * AssistantMessage), pairing each call with its result and dropping the - * standalone result parts that a call already consumed. - */ -type RenderPart = - | { kind: "text"; text: string } - | { kind: "reasoning"; text: string } - | { - kind: "tool"; - call: { name: string; args: unknown; id?: string }; - result?: { result: unknown; isError?: boolean }; - } - | { kind: "attachment"; label: string }; - -function str(v: unknown): string | undefined { - return typeof v === "string" ? v : undefined; -} - -function buildRenderParts(content: Part[]): RenderPart[] { - const resultsById = new Map(); - const callIds = new Set(); - for (const p of content) { - const rec = p as Rec; - const id = str(rec.id); - if (rec.type === "tool_result" && id) { - resultsById.set(id, { - result: rec.result, - isError: Boolean(rec.isError), - }); - } else if (rec.type === "tool_call" && id) { - callIds.add(id); - } - } - - const out: RenderPart[] = []; - for (const p of content) { - const rec = p as Rec; - const type = str(rec.type) ?? ""; - if (type === "text") { - const text = str(rec.text) ?? ""; - if (text) out.push({ kind: "text", text }); - } else if (type === "thinking" || type === "reasoning") { - const text = str(rec.thinking) ?? str(rec.text) ?? ""; - if (text) out.push({ kind: "reasoning", text }); - } else if (type === "tool_call") { - const id = str(rec.id); - out.push({ - kind: "tool", - call: { name: str(rec.name) ?? "tool", args: rec.args, id }, - result: id ? resultsById.get(id) : undefined, - }); - } else if (type === "tool_result") { - // Skip results a tool_call already rendered; render true orphans alone. - const id = str(rec.id); - if (!id || !callIds.has(id)) { - out.push({ - kind: "tool", - call: { name: str(rec.name) ?? "tool", args: undefined, id }, - result: { result: rec.result, isError: Boolean(rec.isError) }, - }); - } - } else if (type === "image" || type === "file") { - out.push({ kind: "attachment", label: type }); - } - // om_* observational-memory and unknown parts are intentionally dropped. - } - return out; -} - -function RenderPartView({ - part, - isUser, - isStreaming, -}: { - part: RenderPart; - isUser: boolean; - isStreaming: boolean; -}) { - switch (part.kind) { - case "text": - if (isUser) { - return ( - - {part.text} - - ); - } - return {part.text}; - case "reasoning": - return ( - - - {part.text} - - ); - case "tool": - return ( - - ); - case "attachment": - return ( - - - 📎 {part.label === "image" ? "image" : "file"} - - - ); - } -} - -function MessageBubble({ - from, - content, - streaming, - isStreaming = false, -}: { - from: string; - content: Part[]; - streaming?: boolean; - isStreaming?: boolean; -}) { - const isUser = from === "user"; - const parts = buildRenderParts(content); - // User messages sit in a bordered bubble; agent messages are borderless - // full-width (markdown prose), like Claude Code / ChatGPT. - return ( - - - - {parts.map((part, i) => ( - - ))} - {streaming ? ( - - - thinking… - - ) : null} - - - - ); -} - -export function ChatMessageList({ - messages, - currentMessage, - isRunning, - topInset = 0, -}: { - messages: ChatMessage[]; - currentMessage: ChatMessage | null; - isRunning: boolean; - /** Extra top padding so the first message clears the transparent glass - * header while the list still scrolls under it. */ - topInset?: number; -}) { - const scrollRef = useRef(null); - // Stick-to-bottom: only auto-scroll on content growth when the user is - // already near the bottom. Otherwise expanding a collapsible tool/task card - // (which grows content height) would yank them down to the latest message. - const atBottomRef = useRef(true); - - const handleScroll = (e: NativeSyntheticEvent) => { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; - const distanceFromBottom = - contentSize.height - (contentOffset.y + layoutMeasurement.height); - atBottomRef.current = distanceFromBottom < 80; - }; - - const showStreaming = - isRunning && currentMessage && currentMessage.role === "assistant"; - const streamingHasContent = - showStreaming && (currentMessage?.content?.length ?? 0) > 0; - - return ( - { - if (atBottomRef.current) { - scrollRef.current?.scrollToEnd({ animated: true }); - } - }} - > - {messages.map((message) => ( - - ))} - {showStreaming ? ( - - ) : null} - {isRunning && !showStreaming ? ( - - - - working… - - - ) : null} - - ); -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/components/ToolPartView/ToolPartView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/components/ToolPartView/ToolPartView.tsx deleted file mode 100644 index 3b3381d24ca..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/components/ToolPartView/ToolPartView.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { CheckCircleIcon, CircleIcon, LoaderIcon } from "lucide-react-native"; -import { View } from "react-native"; -import { Task, TaskContent, TaskTrigger } from "@/components/ai-elements/task"; -import { - Tool, - ToolContent, - ToolHeader, - ToolInput, - ToolOutput, - type ToolState, -} from "@/components/ai-elements/tool"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; -import { cn } from "@/lib/utils"; - -/** - * A mastracode `tool_call` content part: `{ type: "tool_call", id, name, args }`. - * Its result arrives as a SEPARATE `tool_result` part paired by `id` — the caller - * resolves that pairing and passes it in as `result`. - */ -export interface ToolCallPart { - name: string; - args: unknown; - id?: string; -} - -/** The paired `tool_result` part: `{ type: "tool_result", result, isError }`. */ -export interface ToolResultPart { - result: unknown; - isError?: boolean; -} - -/** - * Renders a single tool call using the shared ai-elements kit. `task_write` - * becomes a status checklist; every other tool renders as the collapsible Tool - * card (real name + JSON args + result/error). - * - * The snapshot uses mastracode `HarnessMessage` parts, NOT AI-SDK-v5 UIMessage - * parts — the name is `call.name`, the input is `call.args`, and the output is - * the paired `tool_result` part's `result` (`isError` distinguishes failures). - * This mirrors desktop's AssistantMessage renderer. - */ -export function ToolPartView({ - call, - result, - isStreaming, -}: { - call: ToolCallPart; - result?: ToolResultPart; - isStreaming?: boolean; -}) { - const name = call.name || "tool"; - const state: ToolState = result?.isError - ? "output-error" - : result - ? "output-available" - : isStreaming - ? "input-streaming" - : "input-available"; - - if (name === "task_write") { - return ; - } - - const { output, errorText } = splitResult(result); - - return ( - - - - {call.args === undefined ? null : } - - - - ); -} - -/** Split a `tool_result` into the ToolOutput props (error text vs. output). */ -function splitResult(result?: ToolResultPart): { - output?: unknown; - errorText?: string; -} { - if (!result) return {}; - if (result.isError) { - const text = - typeof result.result === "string" - ? result.result - : JSON.stringify(result.result, null, 2); - return { errorText: text }; - } - return { output: result.result }; -} - -type TodoStatus = "pending" | "in_progress" | "completed"; - -interface Todo { - content: string; - status: TodoStatus; -} - -const STATUS_ICON: Record = { - pending: , - in_progress: , - completed: , -}; - -/** Renders the `task_write` todos as a collapsible status checklist. */ -function TaskWriteChecklist({ todos }: { todos: Todo[] }) { - const completed = todos.filter((t) => t.status === "completed").length; - const title = todos.length - ? `Tasks · ${completed}/${todos.length}` - : "Update Tasks"; - - return ( - - - - {todos.map((todo, i) => ( - - {STATUS_ICON[todo.status]} - - {todo.content} - - - ))} - - - ); -} - -function asTodos(args: unknown): Todo[] { - if (!args || typeof args !== "object") return []; - const todos = (args as { todos?: unknown }).todos; - if (!Array.isArray(todos)) return []; - return todos - .filter( - (t): t is Record => Boolean(t) && typeof t === "object", - ) - .map((t) => ({ - content: - typeof t.content === "string" ? t.content : String(t.content ?? ""), - status: normalizeTodoStatus(t.status), - })); -} - -function normalizeTodoStatus(raw: unknown): TodoStatus { - return raw === "in_progress" || raw === "completed" ? raw : "pending"; -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/components/ToolPartView/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/components/ToolPartView/index.ts deleted file mode 100644 index dd668c994ff..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/components/ToolPartView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ToolPartView } from "./ToolPartView"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/index.ts deleted file mode 100644 index bfb4873a9e4..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatMessageList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChatMessageList } from "./ChatMessageList"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatPendingActions/ChatPendingActions.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatPendingActions/ChatPendingActions.tsx deleted file mode 100644 index 23d79c1d4a5..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatPendingActions/ChatPendingActions.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useState } from "react"; -import { Pressable, TextInput, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import type { UseChatThreadResult } from "../../hooks/useChatThread"; - -function ActionButton({ - label, - tone = "neutral", - onPress, -}: { - label: string; - tone?: "primary" | "danger" | "neutral"; - onPress: () => void; -}) { - const cls = - tone === "primary" - ? "bg-primary" - : tone === "danger" - ? "bg-destructive" - : "bg-muted"; - const textCls = - tone === "neutral" ? "text-foreground" : "text-primary-foreground"; - return ( - - {label} - - ); -} - -function Card({ - title, - detail, - children, -}: { - title: string; - detail?: string; - children: React.ReactNode; -}) { - return ( - - {title} - {detail ? ( - {detail} - ) : null} - {children} - - ); -} - -/** Renders any pending interaction (tool approval, question, plan approval) with - * actions that drive the runtime over the relay. Field access is defensive - * because the concrete shapes come from the mastracode harness. */ -export function ChatPendingActions({ - pendingApproval, - pendingQuestion, - pendingPlanApproval, - respondToApproval, - respondToQuestion, - respondToPlan, -}: Pick< - UseChatThreadResult, - | "pendingApproval" - | "pendingQuestion" - | "pendingPlanApproval" - | "respondToApproval" - | "respondToQuestion" - | "respondToPlan" ->) { - const [answer, setAnswer] = useState(""); - - if (pendingApproval) { - const rec = pendingApproval as Record; - const toolName = - (rec.toolName as string) ?? (rec.title as string) ?? "a tool"; - return ( - - - respondToApproval("approve")} - /> - respondToApproval("decline")} - /> - - respondToApproval("always_allow_category")} - /> - - ); - } - - if (pendingQuestion) { - const rec = pendingQuestion as Record; - const questionId = String(rec.questionId ?? rec.id ?? ""); - const questionText = - (rec.question as string) ?? - (rec.prompt as string) ?? - "The agent asked a question."; - const options = Array.isArray(rec.options) - ? (rec.options as unknown[]).map(String) - : []; - return ( - - {options.length > 0 ? ( - - {options.map((opt) => ( - respondToQuestion(questionId, opt)} - /> - ))} - - ) : ( - - - - - { - if (!answer.trim()) return; - respondToQuestion(questionId, answer.trim()); - setAnswer(""); - }} - /> - - )} - - ); - } - - if (pendingPlanApproval) { - const rec = pendingPlanApproval as Record; - const planId = String(rec.planId ?? rec.id ?? ""); - return ( - - - respondToPlan(planId, "approved")} - /> - respondToPlan(planId, "rejected")} - /> - - - ); - } - - return null; -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatPendingActions/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatPendingActions/index.ts deleted file mode 100644 index d80449b3ac9..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatPendingActions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ChatPendingActions } from "./ChatPendingActions"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/SessionThread.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/SessionThread.tsx new file mode 100644 index 00000000000..8df29059858 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/SessionThread.tsx @@ -0,0 +1,326 @@ +import { + SessionsSyncProvider, + useRetainSession, + useSession, + useSessionStream, + useSessionsSyncClient, + useSessionTimeline, +} from "@superset/host-service-react"; +import type { + PermissionOutcome, + SettingOption, +} from "@superset/host-service-sync/protocol"; +import { Stack } from "expo-router"; +// Imported from expo-router's vendored copy on purpose: this reads the SAME +// HeaderHeightContext that expo-router's Stack populates. Declaring +// `@react-navigation/elements` as our own dep would pull a second copy with a +// different context instance and always return 0. +import { useHeaderHeight } from "expo-router/build/react-navigation/elements/Header/useHeaderHeight"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Keyboard, + KeyboardAvoidingView, + Platform, + View, +} from "react-native"; +import { + Conversation, + type ConversationController, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { Text } from "@/components/ui/text"; +import { + cancelTurn, + getHostSyncClient, + resolvePermission, + submitTurn, + updateSession, +} from "@/lib/host/client"; +import { GlassHeaderTitle } from "@/screens/(authenticated)/workspace/[id]/chat/components/GlassHeaderTitle"; +import { Composer } from "./components/Composer"; +import { PermissionStack } from "./components/PermissionStack"; +import { TimelineItemView } from "./components/TimelineItemView"; +import { TypingIndicator } from "./components/TypingIndicator"; +import { getSessionThreadPresentation } from "./utils/getSessionThreadPresentation"; + +export function SessionThread({ + routingKey, + sessionId, +}: { + routingKey: string; + sessionId: string; +}) { + const client = useMemo(() => getHostSyncClient(routingKey), [routingKey]); + return ( + + + + ); +} + +function SessionThreadBody({ + routingKey, + sessionId, +}: { + routingKey: string; + sessionId: string; +}) { + const client = useSessionsSyncClient(); + useRetainSession(sessionId, "focused"); + const session = useSession(sessionId); + const stream = useSessionStream(sessionId); + const timeline = useSessionTimeline(sessionId); + const [actionError, setActionError] = useState(null); + const [isLoadingOlder, setIsLoadingOlder] = useState(false); + const conversationRef = useRef(null); + // Set on send; consumed when the echoed user message lands in the + // timeline, anchoring it to the top ChatGPT-style. + const pendingAnchorRef = useRef(false); + + const runState = session?.runState; + const mainThreadId = session?.mainThreadId ?? null; + const activeTurnId = timeline.activeTurnId; + + // Rejects on failure so the Composer can put the typed message back — the + // prompt input clears synchronously on submit. + const handleSend = useCallback( + async (text: string) => { + if (mainThreadId === null) return; + setActionError(null); + // The reply takes a while — drop the keyboard so the user can watch + // it stream in, and anchor their message to the top once it echoes. + Keyboard.dismiss(); + pendingAnchorRef.current = true; + try { + await submitTurn(routingKey, { + sessionId, + threadId: mainThreadId, + content: [{ type: "text", text }], + }); + } catch (cause) { + pendingAnchorRef.current = false; + setActionError(cause instanceof Error ? cause.message : String(cause)); + throw cause; + } + }, + [routingKey, sessionId, mainThreadId], + ); + + const handleStop = useCallback(() => { + if (activeTurnId === null) return; + cancelTurn(routingKey, { sessionId, turnId: activeTurnId }).catch( + (cause) => { + setActionError(cause instanceof Error ? cause.message : String(cause)); + }, + ); + }, [routingKey, sessionId, activeTurnId]); + + const handleRespond = useCallback( + async (permissionId: string, outcome: PermissionOutcome) => { + setActionError(null); + await resolvePermission(routingKey, { sessionId, permissionId, outcome }); + }, + [routingKey, sessionId], + ); + + const handleSetSetting = useCallback( + (option: SettingOption, value: string) => { + // The chips are limited to catalogs updateSession's settings surface + // covers (see PICKABLE_KINDS in the Composer). + const field = + option.kind === "model" + ? "activeModel" + : option.kind === "mode" + ? "activeMode" + : option.kind === "effort" + ? "effort" + : null; + if (field === null) return; + setActionError(null); + updateSession(routingKey, { + sessionId, + settings: { [field]: value }, + }).catch((cause) => { + setActionError(cause instanceof Error ? cause.message : String(cause)); + }); + }, + [routingKey, sessionId], + ); + + const loadOlder = useCallback(() => { + setIsLoadingOlder(true); + client + .fetchOlderEvents(sessionId) + .catch(() => { + // Scrollback failures are retried by scrolling again; no banner. + }) + .finally(() => setIsLoadingOlder(false)); + }, [client, sessionId]); + + const isLoading = + stream === null || + ((stream.status === "idle" || + stream.status === "subscribing" || + stream.status === "replaying") && + stream.eventIds.length === 0); + + // Session.error is host state (cleared when a new turn starts); the fold's + // lastError covers turn failures the entity no longer reflects. + const stateError = session?.error?.code ?? timeline.lastError; + const errorText = actionError ?? stream?.error?.code ?? stateError; + const { + bannerError, + canCompose, + composerStatus, + emptyDescription, + emptyTitle, + isDead, + reconnecting, + } = getSessionThreadPresentation({ + runState, + streamStatus: stream?.status, + isLoading, + errorText, + }); + + // The just-sent user message, for the ChatGPT-style top anchor. + const lastUserIndex = useMemo(() => { + for (let index = timeline.items.length - 1; index >= 0; index -= 1) { + const item = timeline.items[index]; + if (item.kind === "message" && item.role === "user") return index; + } + return -1; + }, [timeline.items]); + const lastUserMessageId = + lastUserIndex >= 0 ? timeline.items[lastUserIndex]?.id : null; + const lastUserIndexRef = useRef(lastUserIndex); + lastUserIndexRef.current = lastUserIndex; + + useEffect(() => { + if (!pendingAnchorRef.current) return; + if (lastUserMessageId === null) return; + pendingAnchorRef.current = false; + conversationRef.current?.scrollToAnchor(lastUserIndexRef.current); + }, [lastUserMessageId]); + + // Glass-header geometry: the header is + // transparent so content is full-bleed (offset 0) and the list scrolls under + // the blurred bar, inset by the header height. + const headerHeight = useHeaderHeight(); + const hasBanner = reconnecting || isDead || Boolean(bannerError); + + const title = session?.title ?? null; + + return ( + + }} + /> + {hasBanner ? ( + // Banners sit below the transparent header (the list scrolls under + // it, but these status strips shouldn't be obscured by the glass). + + {reconnecting ? ( + + + Reconnecting to stream… + + + ) : null} + {isDead ? ( + + + Session ended — history is read-only. + + + ) : null} + {bannerError ? ( + + + {bannerError} + + + ) : null} + + ) : null} + {isLoading ? ( + // The last page loads as ONE snapshot (the host resurrects on + // read) — hold the spinner until it lands so Conversation mounts + // with the full page and its first-paint veil reveals it parked + // at the bottom. Never mount on a partial stream. + + + + ) : ( + item.id} + anchorOffsetTop={headerHeight + 8} + contentPaddingBottom={16} + controllerRef={conversationRef} + contentContainerClassName="px-4 pb-4 gap-3" + contentContainerStyle={{ + paddingTop: (hasBanner ? 0 : headerHeight) + 16, + }} + // Backward pagination: scrolling near the top pulls the next older + // journal page; maintainVisibleContentPosition keeps the viewport + // anchored while the page is prepended. + maintainVisibleContentPosition + onStartReached={stream?.hasOlder ? loadOlder : undefined} + ListHeaderComponent={isLoadingOlder ? OlderPageIndicator : undefined} + // The permission card already signals a blocking ask, so only + // "running" shows the dots. + ListFooterComponent={ + runState === "running" ? TypingIndicator : undefined + } + renderItem={({ item }) => ( + + )} + > + {timeline.items.length === 0 ? ( + + ) : null} + + + )} + {/* Blocking asks stack above the composer (a request buried in the + timeline — inside a closed tool sheet or scrolled away — would + deadlock the turn). Answered cards dismiss; the resolution stays + on the tool call's record in its detail sheet. */} + + {/* Dead, offline, and not-yet-loaded sessions cannot accept prompts. In + particular, keep the composer hidden after a failed session/load so the + destructive banner is not paired with an action guaranteed to fail. */} + {canCompose ? ( + + ) : null} + + ); +} + +function OlderPageIndicator() { + return ( + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/ChatComposer.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/Composer.tsx similarity index 58% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/ChatComposer.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/Composer.tsx index 6c18d42b5e2..193de4b1966 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/ChatComposer/ChatComposer.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/Composer.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import type { SettingOption } from "@superset/host-service-sync/protocol"; import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; import { type ReactNode, useEffect, useState } from "react"; import { Keyboard, Platform, View } from "react-native"; @@ -11,54 +11,58 @@ import { PromptInputSubmit, PromptInputTextarea, PromptInputTools, + usePromptInputController, } from "@/components/ai-elements/prompt-input"; -import { apiClient } from "@/lib/trpc/client"; -import { ModelPicker } from "./components/ModelPicker"; -import { useSelectedModel } from "./hooks/useSelectedModel"; +import { OptionPicker } from "./components/OptionPicker"; // iOS 26 Liquid Glass (expo's official module); falls back to the solid card // surface on older iOS / Android / when Reduce Transparency is on. const GLASS = isLiquidGlassAvailable(); +/** The catalogs updateSession can act on; `other` entries (agent personas, + * fast-mode selects) stay hidden until the update surface covers them. */ +const PICKABLE_KINDS = new Set([ + "mode", + "model", + "effort", +]); + /** - * Chat composer built on the shared ai-elements PromptInput kit: a rounded - * input with a footer toolbar (mode chip + circular submit). Send is - * fire-and-forget — the text clears the instant you hit send (the sent message - * shows optimistically), and there is deliberately no send spinner. + * The session composer: glass surface, message field, footer chips. + * The footer chips (mode, model, effort) are built from the session's + * harness-reported `settingOptions` catalogs, kept fresh through session + * upserts. The submit button doubles as a stop button while a turn runs. */ -export function ChatComposer({ +export function Composer({ onSend, - disabled, + onStop, + onSetSetting, + settingOptions, + status, }: { - onSend: (text: string, model?: string) => void | Promise; - disabled?: boolean; + onSend: (text: string) => Promise; + onStop: () => void; + onSetSetting: (option: SettingOption, value: string) => void; + settingOptions: SettingOption[]; + status: "ready" | "streaming"; }) { const insets = useSafeAreaInsets(); const keyboardShown = useKeyboardShown(); + const controller = usePromptInputController(); - // Model catalog comes from the CLOUD API (the host relay doesn't expose it); - // it rarely changes, so keep it fresh for the session. - const modelsQuery = useQuery({ - queryKey: ["chat", "models"], - queryFn: () => apiClient.chat.getModels.query(), - staleTime: Number.POSITIVE_INFINITY, - }); - const models = modelsQuery.data?.models ?? []; - const [selectedModelId, selectModel] = useSelectedModel(); - // Mirrors desktop's `activeModel = selectedModel ?? defaultModel`: the user's - // pick, else the catalog's first entry. - const activeModelId = selectedModelId ?? models[0]?.id; + const pickers = settingOptions.filter( + (option) => PICKABLE_KINDS.has(option.kind) && option.options.length > 0, + ); // Non-async on purpose: returning a non-Promise makes PromptInput clear the - // input synchronously on submit (see prompt-input.tsx `submit`). We do not - // await the send — failures are handled elsewhere, not by blocking the input. + // input synchronously on submit (see prompt-input.tsx `submit`), so the send + // never blocks the field. That clear is destructive, so a send that never + // lands puts the typed message back — the banner alone would leave the user + // with nothing to retry. const handleSubmit = (message: PromptInputMessage) => { const text = message.text.trim(); if (!text) return; - // Every send carries the active model, exactly as desktop does: the host - // only calls `switchModel` when `metadata.model` is present, so sending it - // on the first message alone would leave every later turn on the old model. - void onSend(text, activeModelId); + onSend(text).catch(() => controller.textInput.setInput(message.text)); }; const surface = ( @@ -67,20 +71,25 @@ export function ChatComposer({ onSubmit={handleSubmit} > - + - + {pickers.map((option) => ( + onSetSetting(option, value)} + options={option.options.map((choice) => ({ + id: choice.value, + name: choice.name, + description: choice.description, + }))} + /> + ))} - {/* No status → always the arrow icon; empty sends gated by canSubmit. */} - + ); diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/OptionPicker.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/OptionPicker.tsx similarity index 89% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/OptionPicker.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/OptionPicker.tsx index b9116c05ce4..0183e80c035 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/OptionPicker.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/OptionPicker.tsx @@ -20,15 +20,14 @@ export interface PickerOption { } /** Keeps the list from growing past a comfortable sheet height; `fitToContents` - * sizes the sheet to whatever this scroller reports. Same treatment as the - * mastra composer's ModelPicker. */ + * sizes the sheet to whatever this scroller reports. */ const LIST_MAX_HEIGHT = 340; /** - * Fork of the mastra ModelPicker generalized to any ACP select: a small - * "{current value} ⌄" chip that opens the option list in a native SwiftUI - * bottom sheet. The options are whatever the ACP session reported (model / - * effort config options, permission modes) — nothing is hardcoded client-side. + * A generic settings-catalog picker: a small "{current value} ⌄" chip that + * opens the option list in a native SwiftUI bottom sheet. The options are + * whatever the session's harness reported (model / mode / effort catalogs) — + * nothing is hardcoded client-side. */ export function OptionPicker({ accessibilityLabel, diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/components/OptionPicker/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/Composer/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionRequestView/PermissionRequestView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionRequestView/PermissionRequestView.tsx similarity index 79% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionRequestView/PermissionRequestView.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionRequestView/PermissionRequestView.tsx index a307365d1ae..3cb282a145e 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionRequestView/PermissionRequestView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionRequestView/PermissionRequestView.tsx @@ -1,9 +1,9 @@ +import type { PermissionOption } from "@superset/host-service-sync/protocol"; import { makeSelectedOutcome, - type PermissionOption, - type PermissionView, selectedOptionIds, -} from "@superset/session-protocol"; + type TimelinePermissionView, +} from "@superset/host-service-sync/timeline"; import { useState } from "react"; import { Pressable, View } from "react-native"; import { Text } from "@/components/ui/text"; @@ -23,7 +23,7 @@ export function PermissionRequestView({ view, onRespond, }: { - view: PermissionView; + view: TimelinePermissionView; onRespond: RespondToPermission; }) { // Optimistic highlight: color the answered option(s) immediately while the @@ -37,21 +37,21 @@ export function PermissionRequestView({ const answered = resolved || pendingIds !== null; const multiSelect = view.multiSelect === true; - // Multi-select resolutions carry several picked ids (selectedOptionIds - // unpacks the outcome's _meta); a cancelled outcome highlights none. + // Multi-select resolutions carry several picked ids; a cancelled outcome + // highlights none. const selectedIds = view.resolution ? new Set(selectedOptionIds(view.resolution)) : (pendingIds ?? picked); const isReject = (option: PermissionOption) => - option.kind === "reject_once" || option.kind === "reject_always"; + option.kind === "rejectOnce" || option.kind === "rejectAlways"; const respond = (requestIds: ReadonlySet) => { setPendingIds(requestIds); - onRespond(view.requestId, makeSelectedOutcome([...requestIds])).catch( + onRespond(view.permissionId, makeSelectedOutcome([...requestIds])).catch( (cause) => { console.warn( - `[acp] permission response failed (${view.requestId})`, + `[sessions] permission response failed (${view.permissionId})`, cause, ); setPendingIds(null); @@ -63,16 +63,16 @@ export function PermissionRequestView({ if (multiSelect && !isReject(option)) { setPicked((prev) => { const next = new Set(prev); - if (next.has(option.optionId)) { - next.delete(option.optionId); + if (next.has(option.id)) { + next.delete(option.id); } else { - next.add(option.optionId); + next.add(option.id); } return next; }); return; } - respond(new Set([option.optionId])); + respond(new Set([option.id])); }; return ( @@ -82,9 +82,9 @@ export function PermissionRequestView({ {view.options.map((option) => ( handleOption(option)} @@ -92,9 +92,9 @@ export function PermissionRequestView({ void; }) { // Dismissed the moment an option is tapped, while the response round-trips; - // restored on failure. State eventually removes resolved entries from - // `pending`, which prunes this set's relevance naturally. + // restored on a retryable failure. State eventually removes resolved entries + // from `pending`, which prunes this set's relevance naturally. const [answeredIds, setAnsweredIds] = useState>( () => new Set(), ); - const visible = pending.filter((entry) => !answeredIds.has(entry.requestId)); + const visible = pending.filter( + (entry) => !answeredIds.has(entry.permissionId), + ); const front = visible[0]; if (!front) return null; - // Multi-select cards answer with every picked option (makeSelectedOutcome - // packs the extras into the outcome's _meta); single-select passes one id. - const answer = (requestId: string, optionIds: string[]) => { - setAnsweredIds((prev) => new Set(prev).add(requestId)); - onRespond(requestId, makeSelectedOutcome(optionIds)).catch(() => { + // Multi-select cards answer with every picked option; single-select passes + // one id — the outcome's optionIds array carries either. + const answer = (permissionId: string, optionIds: string[]) => { + setAnsweredIds((prev) => new Set(prev).add(permissionId)); + onRespond(permissionId, makeSelectedOutcome(optionIds)).catch((cause) => { + // Restoring a settled ask would put back a card that can never be + // dismissed — only a failure worth retrying brings it back. + if (isAlreadySettled(cause)) return; setAnsweredIds((prev) => { const next = new Set(prev); - next.delete(requestId); + next.delete(permissionId); return next; }); + onError(cause instanceof Error ? cause.message : String(cause)); }); }; @@ -59,7 +80,11 @@ export function PermissionStack({ style={{ marginBottom: -6 }} /> ) : null} - + ); } diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/PermissionCard.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/PermissionCard.tsx similarity index 85% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/PermissionCard.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/PermissionCard.tsx index e30cc911743..d4b17b92288 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/PermissionCard.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/PermissionCard.tsx @@ -4,10 +4,8 @@ import { environment, presentationDragIndicator, } from "@expo/ui/swift-ui/modifiers"; -import type { - PendingPermission, - PermissionOption, -} from "@superset/session-protocol"; +import type { PermissionOption } from "@superset/host-service-sync/protocol"; +import type { TimelinePendingPermission } from "@superset/host-service-sync/timeline"; import { CheckIcon, ChevronRightIcon } from "lucide-react-native"; import { useState } from "react"; import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; @@ -32,8 +30,8 @@ export function PermissionCard({ pending, onAnswer, }: { - pending: PendingPermission; - onAnswer: (requestId: string, optionIds: string[]) => void; + pending: TimelinePendingPermission; + onAnswer: (permissionId: string, optionIds: string[]) => void; }) { const theme = useTheme(); const { width, height } = useWindowDimensions(); @@ -46,22 +44,22 @@ export function PermissionCard({ const [picked, setPicked] = useState>(() => new Set()); const multiSelect = pending.multiSelect === true; - const title = pending.toolCall.title || pending.toolCall.kind || "Permission"; + const title = pending.toolCall.title || "Permission"; const isReject = (option: PermissionOption) => - option.kind === "reject_once" || option.kind === "reject_always"; + option.kind === "rejectOnce" || option.kind === "rejectAlways"; const handleOption = (option: PermissionOption) => { if (!multiSelect || isReject(option)) { - onAnswer(pending.requestId, [option.optionId]); + onAnswer(pending.permissionId, [option.id]); return; } setPicked((prev) => { const next = new Set(prev); - if (next.has(option.optionId)) { - next.delete(option.optionId); + if (next.has(option.id)) { + next.delete(option.id); } else { - next.add(option.optionId); + next.add(option.id); } return next; }); @@ -93,7 +91,7 @@ export function PermissionCard({ {pending.options.map((option) => ( handleOption(option)} @@ -103,14 +101,14 @@ export function PermissionCard({ "text-sm", isReject(option) ? "text-destructive" - : picked.has(option.optionId) + : picked.has(option.id) ? "text-primary font-medium" : "text-foreground", )} > {option.name} - {picked.has(option.optionId) ? ( + {picked.has(option.id) ? ( ) : null} @@ -120,7 +118,7 @@ export function PermissionCard({ accessibilityRole="button" disabled={picked.size === 0} className="border-border border-t py-2" - onPress={() => onAnswer(pending.requestId, [...picked])} + onPress={() => onAnswer(pending.permissionId, [...picked])} > {title} - {pending.toolCall.rawInput === undefined ? null : ( - + {pending.toolCall.input === null ? null : ( + )} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/components/PermissionCard/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/PermissionStack/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/PermissionStack/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/TimelineItemView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/TimelineItemView.tsx similarity index 75% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/TimelineItemView.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/TimelineItemView.tsx index f69a8a6ca8e..2005b3a5c47 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/TimelineItemView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/TimelineItemView.tsx @@ -1,16 +1,13 @@ -import type { - RequestPermissionOutcome, - RespondToPermissionResult, - TimelineItem, -} from "@superset/session-protocol"; +import type { PermissionOutcome } from "@superset/host-service-sync/protocol"; +import type { TimelineItem } from "@superset/host-service-sync/timeline"; import { MessageItemView } from "./components/MessageItemView"; import { PlanItemView } from "./components/PlanItemView"; import { ToolCallItemView } from "./components/ToolCallItemView"; export type RespondToPermission = ( - requestId: string, - outcome: RequestPermissionOutcome, -) => Promise; + permissionId: string, + outcome: PermissionOutcome, +) => Promise; export function TimelineItemView({ item, diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/MessageItemView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/MessageItemView.tsx similarity index 76% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/MessageItemView.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/MessageItemView.tsx index 8eab49f5971..fb503467e09 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/MessageItemView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/MessageItemView.tsx @@ -1,4 +1,5 @@ -import type { ContentBlock, MessageItem } from "@superset/session-protocol"; +import type { ContentBlock } from "@superset/host-service-sync/protocol"; +import type { TimelineMessageItem } from "@superset/host-service-sync/timeline"; import { CircleAlertIcon } from "lucide-react-native"; import { View } from "react-native"; import { MessageResponse } from "@/components/ai-elements/message"; @@ -13,19 +14,19 @@ import { Text } from "@/components/ui/text"; function blocksToText(blocks: ContentBlock[]): string { return blocks .map((block) => { - if (block.type === "text") return block.text; - if (block.type === "resource_link") return block.uri; + if (block.type === "text" || block.type === "thought") return block.text; + if (block.type === "resource") return block.uri; return `[${block.type}]`; }) .join(""); } /** - * Same visual language as the mastra ChatMessageList bubbles: user messages + * Visual language: user messages * sit in a bordered right-aligned bubble, agent messages are borderless * full-width markdown prose, and thoughts collapse behind a Reasoning trigger. */ -export function MessageItemView({ item }: { item: MessageItem }) { +export function MessageItemView({ item }: { item: TimelineMessageItem }) { const text = blocksToText(item.blocks); if (!text.trim()) return null; @@ -39,7 +40,7 @@ export function MessageItemView({ item }: { item: MessageItem }) { } if (item.role === "user") { - // A failed prompt (session/prompt rejected after the message was + // A failed prompt (the turn carrying this message failed after it was // journaled) shifts the bubble left for an iMessage-style alert mark. return ( diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/MessageItemView/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/PlanItemView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/PlanItemView.tsx similarity index 84% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/PlanItemView.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/PlanItemView.tsx index 563b5aba161..b364b0b524a 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/PlanItemView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/PlanItemView.tsx @@ -1,14 +1,14 @@ -import type { PlanItem } from "@superset/session-protocol"; +import type { TimelinePlanItem } from "@superset/host-service-sync/timeline"; import { View } from "react-native"; import { Text } from "@/components/ui/text"; const STATUS_MARK: Record = { pending: "○", - in_progress: "◐", + inProgress: "◐", completed: "●", }; -export function PlanItemView({ item }: { item: PlanItem }) { +export function PlanItemView({ item }: { item: TimelinePlanItem }) { if (item.removed || item.entries.length === 0) return null; return ( diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/PlanItemView/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/ToolCallItemView.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/ToolCallItemView.tsx similarity index 67% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/ToolCallItemView.tsx rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/ToolCallItemView.tsx index 2c43b4c3c95..ec6a741a279 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/ToolCallItemView.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/ToolCallItemView.tsx @@ -6,17 +6,17 @@ import { } from "@expo/ui/swift-ui/modifiers"; import type { TimelineItem, - ToolCall, - ToolCallItem, -} from "@superset/session-protocol"; + TimelineToolCall, + TimelineToolCallItem, +} from "@superset/host-service-sync/timeline"; import { ChevronRightIcon, WrenchIcon, XIcon } from "lucide-react-native"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import Animated, { + Easing, useAnimatedStyle, useDerivedValue, withRepeat, - withSequence, withTiming, } from "react-native-reanimated"; import { ToolInput, ToolOutput } from "@/components/ai-elements/tool"; @@ -27,22 +27,35 @@ import { PermissionRequestView } from "../../../PermissionRequestView"; import type { RespondToPermission } from "../../TimelineItemView"; import { MessageItemView } from "../MessageItemView"; import { PlanItemView } from "../PlanItemView"; +import { lineDiffStats } from "./utils/lineDiffStats"; /** The sheet scroller never grows past this fraction of the window. */ const SHEET_MAX_HEIGHT_FRACTION = 0.7; -function toolOutputText(call: ToolCall): string { - const parts: string[] = []; - for (const entry of call.content ?? []) { - if (entry.type === "content" && entry.content.type === "text") { - parts.push(entry.content.text); - } else if (entry.type === "diff") { - parts.push(`[diff] ${entry.path}`); - } else if (entry.type === "terminal") { - parts.push(`[terminal ${entry.terminalId}]`); +function toolOutputText(call: TimelineToolCall): string { + if (call.output === null) return ""; + if (typeof call.output === "string") return call.output; + return JSON.stringify(call.output, null, 2); +} + +/** + * Timeline row title. For command-running tools the adapter's title IS the + * raw command line — surface the model-written `description` from the input + * instead; the command itself stays in the detail sheet. + */ +function toolRowTitle(call: TimelineToolCall): string { + const input = call.input; + if (input !== null && typeof input === "object" && !Array.isArray(input)) { + const { command, description } = input as Record; + if ( + typeof command === "string" && + typeof description === "string" && + description.length > 0 + ) { + return description; } } - return parts.join("\n"); + return call.title || call.tool.name || "tool"; } /** Nested tool calls run so far — the subagent row's subtitle. */ @@ -83,7 +96,7 @@ function ChildItemView({ * that never grows in place (expanding cards inside the list shifts the * scroll). Tapping it opens the detail (parameters, permission record, nested * subagent items, output) in a native SwiftUI bottom sheet, same template as - * the composer's OptionPicker. Live permission asks surface in the + * the PermissionCard's detail sheet. Live permission asks surface in the * PermissionStack above the composer; the copy here is the for-the-record * resolution, visible only when the sheet is opened. */ @@ -91,7 +104,7 @@ export function ToolCallItemView({ item, onRespond, }: { - item: ToolCallItem; + item: TimelineToolCallItem; onRespond: RespondToPermission; }) { const theme = useTheme(); @@ -101,12 +114,25 @@ export function ToolCallItemView({ // tool rows per thread that's real weight, so mount lazily on first open. const [sheetMounted, setSheetMounted] = useState(false); - const status = item.call.status; - const isRunning = status !== "completed" && status !== "failed"; - const failed = status === "failed"; - const title = item.call.title || item.call.kind || "tool"; + const state = item.call.state; + const isRunning = + state !== "succeeded" && state !== "failed" && state !== "cancelled"; + const failed = state === "failed"; + const title = toolRowTitle(item.call); + // The sheet keeps the adapter's full title (for Bash, the command line). + const sheetTitle = item.call.title || item.call.tool.name || "tool"; const output = toolOutputText(item.call); + // GitHub-style change stats for file-editing tools, from the tool input + // (the input streams in via updates, so this appears once it's complete). + // Memoized on the input identity: the strings it diffs run to megabytes, + // far too heavy to redo on every unrelated render of a timeline row. + const diff = useMemo( + () => lineDiffStats(item.call.tool.name, item.call.input), + [item.call.tool.name, item.call.input], + ); + const showDiff = diff !== null && diff.additions + diff.deletions > 0; + const nestedToolCount = countToolCalls(item.children); const subtitle = nestedToolCount > 0 @@ -114,16 +140,19 @@ export function ToolCallItemView({ : null; // Running indicator: the title breathes while the tool executes — no badge - // chrome, the fade loop itself is the signal (same as the mastra ToolHeader). + // chrome, the fade loop itself is the signal. + // Auto-reversed with a sine ease so the fade is equally smooth in both + // directions — no snap at the loop seam. const pulse = useDerivedValue( () => isRunning ? withRepeat( - withSequence( - withTiming(0.35, { duration: 700 }), - withTiming(1, { duration: 700 }), - ), + withTiming(0.35, { + duration: 800, + easing: Easing.inOut(Easing.sin), + }), -1, + true, ) : withTiming(1, { duration: 200 }), [isRunning], @@ -153,6 +182,20 @@ export function ToolCallItemView({ {title} + {showDiff ? ( + + {diff.additions > 0 ? ( + + +{diff.additions} + + ) : null} + {diff.deletions > 0 ? ( + + −{diff.deletions} + + ) : null} + + ) : null} {/* No status chrome — only a failure leaves a subtle faded mark. */} {failed ? ( @@ -189,14 +232,14 @@ export function ToolCallItemView({ className="font-semibold text-base" style={{ color: theme.foreground }} > - {title} + {sheetTitle} - {item.call.rawInput === undefined ? null : ( - + {item.call.input === null ? null : ( + )} {item.permissions.map((view) => ( diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/index.ts new file mode 100644 index 00000000000..f69b6194ff4 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/index.ts @@ -0,0 +1 @@ +export { type LineDiffStats, lineDiffStats } from "./lineDiffStats"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/lineDiffStats.test.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/lineDiffStats.test.ts new file mode 100644 index 00000000000..8bcc0ec4536 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/lineDiffStats.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { lineDiffStats } from "./lineDiffStats"; + +describe("lineDiffStats", () => { + test("Edit: pure insertion counts only added lines", () => { + expect( + lineDiffStats("Edit", { + file_path: "/x/LOG.md", + old_string: "rep-19", + new_string: "rep-19\nrep-20-polish", + }), + ).toEqual({ additions: 1, deletions: 0 }); + }); + + test("Edit: replacement counts both sides after prefix/suffix trim", () => { + expect( + lineDiffStats("Edit", { + old_string: "a\nb\nc\nd", + new_string: "a\nX\nY\nZ\nd", + }), + ).toEqual({ additions: 3, deletions: 2 }); + }); + + test("Edit: identical strings diff to zero", () => { + expect( + lineDiffStats("Edit", { + old_string: "same\nlines", + new_string: "same\nlines", + }), + ).toEqual({ additions: 0, deletions: 0 }); + }); + + test("Write counts content lines git-style (trailing newline ends a line)", () => { + expect(lineDiffStats("Write", { content: "one\ntwo\n" })).toEqual({ + additions: 2, + deletions: 0, + }); + expect(lineDiffStats("Write", { content: "one\ntwo" })).toEqual({ + additions: 2, + deletions: 0, + }); + expect(lineDiffStats("Write", { content: "" })).toEqual({ + additions: 0, + deletions: 0, + }); + }); + + test("MultiEdit sums per-edit stats", () => { + expect( + lineDiffStats("MultiEdit", { + edits: [ + { old_string: "a", new_string: "a\nb" }, + { old_string: "x\ny", new_string: "z" }, + ], + }), + ).toEqual({ additions: 2, deletions: 2 }); + }); + + test("non-file tools and malformed input return null", () => { + expect(lineDiffStats("Bash", { command: "ls" })).toBeNull(); + expect(lineDiffStats("Edit", { old_string: "a" })).toBeNull(); + expect(lineDiffStats("Edit", null)).toBeNull(); + expect(lineDiffStats("Write", { content: 42 })).toBeNull(); + }); +}); diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/lineDiffStats.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/lineDiffStats.ts new file mode 100644 index 00000000000..ef657e98b9a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/components/ToolCallItemView/utils/lineDiffStats/lineDiffStats.ts @@ -0,0 +1,100 @@ +import type { JsonValue } from "@superset/host-service-sync/protocol"; + +export interface LineDiffStats { + additions: number; + deletions: number; +} + +/** Past this the strings aren't worth diffing on the UI thread. */ +const MAX_DIFF_CHARS = 2_000_000; + +/** + * Git-style `+N −M` line counts for a file-editing tool call, derived from + * the tool INPUT (the canonical log carries no patch). For a single + * contiguous edit — the overwhelmingly common shape — trimming the common + * prefix/suffix lines gives exactly the numbers git would report; scattered + * multi-hunk old/new strings degrade to a slight overcount, never an error. + * Returns null for non-file tools or inputs that don't parse. + */ +export function lineDiffStats( + toolName: string, + input: JsonValue, +): LineDiffStats | null { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return null; + } + const record = input as Record; + switch (toolName) { + case "Write": { + const content = record.content; + if (typeof content !== "string" || content.length > MAX_DIFF_CHARS) { + return null; + } + return { additions: countLines(content), deletions: 0 }; + } + case "Edit": { + return pairStats(record.old_string, record.new_string); + } + case "MultiEdit": { + const edits = record.edits; + if (!Array.isArray(edits)) return null; + let additions = 0; + let deletions = 0; + let sawAny = false; + for (const edit of edits) { + if (typeof edit !== "object" || edit === null || Array.isArray(edit)) { + continue; + } + const editRecord = edit as Record; + const stats = pairStats(editRecord.old_string, editRecord.new_string); + if (!stats) continue; + sawAny = true; + additions += stats.additions; + deletions += stats.deletions; + } + return sawAny ? { additions, deletions } : null; + } + default: + return null; + } +} + +function pairStats( + oldValue: JsonValue | undefined, + newValue: JsonValue | undefined, +): LineDiffStats | null { + if (typeof oldValue !== "string" || typeof newValue !== "string") { + return null; + } + if (oldValue.length + newValue.length > MAX_DIFF_CHARS) return null; + const oldLines = oldValue.split("\n"); + const newLines = newValue.split("\n"); + let prefix = 0; + while ( + prefix < oldLines.length && + prefix < newLines.length && + oldLines[prefix] === newLines[prefix] + ) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < oldLines.length - prefix && + suffix < newLines.length - prefix && + oldLines[oldLines.length - 1 - suffix] === + newLines[newLines.length - 1 - suffix] + ) { + suffix += 1; + } + return { + additions: newLines.length - prefix - suffix, + deletions: oldLines.length - prefix - suffix, + }; +} + +/** Line count the way git reports it: a trailing newline ends the last line + * instead of starting an empty one. */ +function countLines(content: string): number { + if (content === "") return 0; + return content.split("\n").length - (content.endsWith("\n") ? 1 : 0); +} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/TimelineItemView/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TimelineItemView/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TypingIndicator/TypingIndicator.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TypingIndicator/TypingIndicator.tsx new file mode 100644 index 00000000000..0ecc31bcb1b --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TypingIndicator/TypingIndicator.tsx @@ -0,0 +1,269 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import Animated, { + type SharedValue, + useAnimatedStyle, + useFrameCallback, + useSharedValue, +} from "react-native-reanimated"; +import { useTheme } from "@/hooks/useTheme"; + +// Caret boids — "6 boids · dots · speed-fade · tight · 1.7× speed". +// Faithful port of the web caret flock (canvas → Reanimated): six dots +// milling inside a caret-shaped containment ellipse, each dot's opacity +// tracking its current speed so the flock shimmers as individuals +// accelerate and coast. The simulation runs per-frame on the UI thread via +// useFrameCallback; nothing re-renders. + +// World: height is always 100 units; width = 100 * aspect. +const ASPECT = 0.55; // caret proportions +const WORLD_W = 100 * ASPECT; +const CENTER_X = WORLD_W / 2; +const HOME = 0.9; // "tight" area multiplier +const RADIUS_X = Math.min(32, WORLD_W / 2 - 5) * HOME; // containment ellipse +const RADIUS_Y = 42 * HOME; + +// Motion constants. +const MAX_SPEED = 62 * 1.7; // the 1.7× +const MIN_SPEED = MAX_SPEED * 0.45; // min speed floor — never stalls +const MAX_FORCE = 260; +const R_NEIGHBOR = 26; +const R_SEPARATION = 10; +const BOID_COUNT = 6; +const DOT_SIZE_MULTIPLIER = 1.2; + +// On-screen caret box (~0.62em × 1.15em of the chat text size), in points. +const BOX_H = 18; +const BOX_W = Math.ceil(BOX_H * ASPECT); +const SCALE = BOX_H / 100; +const DOT_RADIUS = Math.max(0.85, BOX_H * 0.03) * DOT_SIZE_MULTIPLIER; + +const SETTLE_STEPS = 90; // settle before first paint + +interface Boid { + x: number; + y: number; + vx: number; + vy: number; +} + +/** Scatter inside the ellipse with random headings. */ +function makeBoids(): Boid[] { + const boids: Boid[] = []; + for (let index = 0; index < BOID_COUNT; index += 1) { + const angle = Math.random() * Math.PI * 2; + const radius = Math.random() * 0.7; + const heading = Math.random() * Math.PI * 2; + boids.push({ + x: CENTER_X + Math.cos(angle) * radius * RADIUS_X, + y: 50 + Math.sin(angle) * radius * RADIUS_Y, + vx: Math.cos(heading) * MAX_SPEED * 0.7, + vy: Math.sin(heading) * MAX_SPEED * 0.7, + }); + } + return boids; +} + +/** + * One simulation tick, mutating `boids` in place. Textbook flocking + * (separation 1.5 / alignment 0.55 / cohesion 0.45) + soft elliptical + * containment (mills in place instead of flying off) + jitter and a + * min-speed floor (the two things that keep it looping forever without + * ever parking). + */ +function step(boids: Boid[], dt: number): void { + "worklet"; + // steer(desired direction) = clamp(norm(dir)·MAX − vel, FORCE) + function steer( + boid: Boid, + towardX: number, + towardY: number, + acc: [number, number], + weight: number, + ) { + const magnitude = Math.hypot(towardX, towardY); + if (magnitude < 1e-6) return; + let dx = (towardX / magnitude) * MAX_SPEED - boid.vx; + let dy = (towardY / magnitude) * MAX_SPEED - boid.vy; + const force = Math.hypot(dx, dy); + if (force > MAX_FORCE) { + dx = (dx / force) * MAX_FORCE; + dy = (dy / force) * MAX_FORCE; + } + acc[0] += dx * weight; + acc[1] += dy * weight; + } + + const acc: [number, number] = [0, 0]; + for (const boid of boids) { + let sepX = 0; + let sepY = 0; + let alignX = 0; + let alignY = 0; + let cohX = 0; + let cohY = 0; + let neighbors = 0; + for (const other of boids) { + if (other === boid) continue; + const dx = boid.x - other.x; + const dy = boid.y - other.y; + const distance = Math.hypot(dx, dy); + if (distance > R_NEIGHBOR || distance < 1e-6) continue; + neighbors += 1; + if (distance < R_SEPARATION) { + sepX += dx / (distance * distance); + sepY += dy / (distance * distance); + } + alignX += other.vx; + alignY += other.vy; + cohX += other.x; + cohY += other.y; + } + acc[0] = 0; + acc[1] = 0; + if (neighbors > 0) { + if (sepX !== 0 || sepY !== 0) steer(boid, sepX, sepY, acc, 1.5); + steer(boid, alignX / neighbors, alignY / neighbors, acc, 0.55); + steer( + boid, + cohX / neighbors - boid.x, + cohY / neighbors - boid.y, + acc, + 0.45, + ); + } + // Soft elliptical containment — mills in place instead of flying off. + const homeX = CENTER_X - boid.x; + const homeY = 50 - boid.y; + const homeDistance = Math.hypot(homeX, homeY); + const eccentric = Math.hypot(homeX / RADIUS_X, homeY / RADIUS_Y); + if (eccentric > 1 && homeDistance > 1e-3) { + const pull = (eccentric - 1) * homeDistance * 0.9; + acc[0] += (homeX / homeDistance) * MAX_FORCE * 0.16 * pull; + acc[1] += (homeY / homeDistance) * MAX_FORCE * 0.16 * pull; + } + // Jitter — keeps the flock from settling into equilibrium. + acc[0] += (Math.random() - 0.5) * 170; + acc[1] += (Math.random() - 0.5) * 170; + + // Integrate, clamp speed to [MIN, MAX], bounce off hard walls. + boid.vx += acc[0] * dt; + boid.vy += acc[1] * dt; + const speed = Math.hypot(boid.vx, boid.vy) || 1e-6; + const clamp = + speed > MAX_SPEED + ? MAX_SPEED / speed + : speed < MIN_SPEED + ? MIN_SPEED / speed + : 1; + boid.vx *= clamp; + boid.vy *= clamp; + boid.x += boid.vx * dt; + boid.y += boid.vy * dt; + if (boid.x < 2) { + boid.x = 2; + boid.vx = Math.abs(boid.vx); + } + if (boid.x > WORLD_W - 2) { + boid.x = WORLD_W - 2; + boid.vx = -Math.abs(boid.vx); + } + if (boid.y < 2) { + boid.y = 2; + boid.vy = Math.abs(boid.vy); + } + if (boid.y > 98) { + boid.y = 98; + boid.vy = -Math.abs(boid.vy); + } + } +} + +function Dot({ + boids, + index, + color, +}: { + boids: SharedValue; + index: number; + color: string; +}) { + const style = useAnimatedStyle(() => { + const boid = boids.value[index]; + if (boid === undefined) return { opacity: 0 }; + // Speed-fade: current speed → alpha 0.2..0.9 (why it reads as activity). + const speed = Math.hypot(boid.vx, boid.vy); + const normalized = Math.min( + 1, + Math.max(0, (speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)), + ); + return { + opacity: 0.9 * (0.22 + 0.78 * normalized), + transform: [ + { translateX: boid.x * SCALE - DOT_RADIUS }, + { translateY: boid.y * SCALE - DOT_RADIUS }, + ], + }; + }, [boids, index]); + + return ( + + ); +} + +const DOT_KEYS = Array.from( + { length: BOID_COUNT }, + (_, index) => `boid-${index}`, +); + +/** + * The "agent is working" indicator: a caret-sized flock of boids in place + * of typing dots. Deliberately no card, border, spinner, or label — the + * milling flock is the whole signal. + */ +export function TypingIndicator() { + const theme = useTheme(); + const initial = useMemo(() => { + const boids = makeBoids(); + for (let tick = 0; tick < SETTLE_STEPS; tick += 1) step(boids, 1 / 60); + return boids; + }, []); + const boids = useSharedValue(initial); + + useFrameCallback((frame) => { + const dt = Math.min( + 0.05, + (frame.timeSincePreviousFrame ?? 1_000 / 60) / 1_000, + ); + const next = boids.value.map((boid) => ({ ...boid })); + step(next, dt); + boids.value = next; + }); + + return ( + + + {DOT_KEYS.map((key, index) => ( + + ))} + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TypingIndicator/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TypingIndicator/index.ts new file mode 100644 index 00000000000..fce9377cb61 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/components/TypingIndicator/index.ts @@ -0,0 +1 @@ +export { TypingIndicator } from "./TypingIndicator"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.test.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.test.ts similarity index 85% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.test.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.test.ts index addd46274e5..7d2320a1ced 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.test.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.test.ts @@ -4,8 +4,8 @@ import { getSessionThreadPresentation } from "./getSessionThreadPresentation"; describe("getSessionThreadPresentation", () => { test("a failed offline resurrection explains the missing transcript and disables compose", () => { const presentation = getSessionThreadPresentation({ - status: "offline", - streamStatus: "stopped", + runState: "offline", + streamStatus: "idle", isLoading: false, errorText: "No stored session to load: native-session-id", }); @@ -25,8 +25,8 @@ describe("getSessionThreadPresentation", () => { test("loading, live, permission, reconnecting, and dead states stay distinct", () => { expect( getSessionThreadPresentation({ - status: "offline", - streamStatus: "connecting", + runState: "offline", + streamStatus: "subscribing", isLoading: true, errorText: null, }), @@ -37,8 +37,8 @@ describe("getSessionThreadPresentation", () => { }); expect( getSessionThreadPresentation({ - status: "idle", - streamStatus: "reconnecting", + runState: "idle", + streamStatus: "reset", isLoading: false, errorText: null, }), @@ -50,16 +50,16 @@ describe("getSessionThreadPresentation", () => { }); expect( getSessionThreadPresentation({ - status: "awaiting_permission", - streamStatus: "open", + runState: "running", + streamStatus: "live", isLoading: false, errorText: null, }), ).toMatchObject({ canCompose: true, composerStatus: "streaming" }); expect( getSessionThreadPresentation({ - status: "dead", - streamStatus: "reconnecting", + runState: "closed", + streamStatus: "reset", isLoading: false, errorText: null, }), diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.ts similarity index 59% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.ts index cab4b3124ae..4fbe3e357c1 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/getSessionThreadPresentation.ts @@ -1,5 +1,5 @@ -import type { SessionStatus } from "@superset/session-protocol"; -import type { StreamStatus } from "@superset/session-protocol/client"; +import type { SessionStreamStatus } from "@superset/host-service-sync/client"; +import type { SessionRunState } from "@superset/host-service-sync/protocol"; export interface SessionThreadPresentation { bannerError: string | null; @@ -12,23 +12,23 @@ export interface SessionThreadPresentation { } export function getSessionThreadPresentation({ - status, + runState, streamStatus, isLoading, errorText, }: { - status: SessionStatus | undefined; - streamStatus: StreamStatus; + runState: SessionRunState | undefined; + streamStatus: SessionStreamStatus | undefined; isLoading: boolean; errorText: string | null; }): SessionThreadPresentation { - const isDead = status === "dead"; + const isDead = runState === "closed" || runState === "failed"; + // A blocking permission ask keeps runState "running" — the canonical run + // state has no separate awaiting_permission arm. const canCompose = - status === "idle" || - status === "running" || - status === "awaiting_permission"; + runState === "idle" || runState === "running" || runState === "cancelling"; const composerStatus = - status === "running" || status === "awaiting_permission" + runState === "running" || runState === "cancelling" ? ("streaming" as const) : ("ready" as const); @@ -37,7 +37,7 @@ export function getSessionThreadPresentation({ canCompose, composerStatus, isDead, - reconnecting: streamStatus === "reconnecting" && !isDead, + reconnecting: streamStatus === "reset" && !isDead, emptyTitle: isLoading ? "Connecting…" : errorText diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/index.ts rename to apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/index.ts diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/index.ts deleted file mode 100644 index 32b6f1cf6f5..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { ChatMessage, UseChatThreadResult } from "./useChatThread"; -export { useChatThread } from "./useChatThread"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts deleted file mode 100644 index 7deaf5d36cb..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/hooks/useChatThread/useChatThread.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; -import type { - ChatMessage, - ChatMessagePart, - MessageMetadata, - SendMessagePayload, -} from "@/lib/trpc/host-chat-types"; -import { createHostClient } from "@/lib/trpc/host-client"; - -export type { ChatMessage } from "@/lib/trpc/host-chat-types"; - -// Poll cadence for the runtime snapshot (matches the desktop chat pane). -const FPS = 4; -const REFETCH_MS = Math.max(16, Math.floor(1000 / FPS)); -const GC_MS = 60_000; - -function findLastUserMessageIndex(messages: ChatMessage[]): number { - for (let i = messages.length - 1; i >= 0; i -= 1) { - if (messages[i]?.role === "user") return i; - } - return -1; -} - -/** - * While a turn is streaming, the in-flight assistant turn is rendered from - * `currentMessage`; drop those same assistant messages from history so they - * aren't shown twice. (Simplified port of the desktop helper — we keep every - * non-assistant message in the active turn.) - */ -function withoutActiveTurnAssistantHistory( - messages: ChatMessage[], - currentMessage: ChatMessage | null, - isRunning: boolean, -): ChatMessage[] { - if (!isRunning || !currentMessage || currentMessage.role !== "assistant") { - return messages; - } - const start = findLastUserMessageIndex(messages) + 1; - const previous = messages.slice(0, start); - const activeNonAssistant = messages - .slice(start) - .filter((m) => m.role !== "assistant"); - return [...previous, ...activeNonAssistant]; -} - -function messageText(message: ChatMessage): string { - return message.content - .filter((p: ChatMessagePart) => p.type === "text") - .map((p: ChatMessagePart) => p.text ?? "") - .join(""); -} - -export interface UseChatThreadResult { - /** Workspace/host resolution status. */ - hostId: string | null; - organizationId: string | null; - hostOnline: boolean; - /** True until we know whether the workspace row exists in synced data. */ - workspaceResolving: boolean; - /** Conversation. */ - messages: ChatMessage[]; - currentMessage: ChatMessage | null; - isRunning: boolean; - pendingApproval: Record | null; - pendingQuestion: Record | null; - pendingPlanApproval: Record | null; - isConversationLoading: boolean; - error: string | null; - /** Commands. */ - /** `model` is a catalog id; when set the host switches the thread to it - * before running the turn. Omit to keep the session's current model. */ - sendMessage: (text: string, model?: string) => Promise; - respondToApproval: ( - decision: "approve" | "decline" | "always_allow_category", - ) => Promise; - respondToQuestion: (questionId: string, answer: string) => Promise; - respondToPlan: ( - planId: string, - action: "approved" | "rejected", - feedback?: string, - ) => Promise; - isSending: boolean; -} - -export function useChatThread(params: { - sessionId: string; - workspaceId: string; -}): UseChatThreadResult { - const { sessionId, workspaceId } = params; - const { workspace, host, isResolving } = useWorkspaceHost(workspaceId); - const organizationId = workspace?.organizationId ?? null; - const hostId = workspace?.hostId ?? null; - const hostOnline = host?.isOnline ?? false; - const workspaceResolving = isResolving; - - const client = useMemo(() => { - if (!organizationId || !hostId) return null; - return createHostClient({ organizationId, hostId }); - }, [organizationId, hostId]); - - const queryEnabled = Boolean(client && sessionId && workspaceId); - - const snapshotQuery = useQuery({ - queryKey: ["chat-snapshot", organizationId, hostId, workspaceId, sessionId], - enabled: queryEnabled, - refetchInterval: REFETCH_MS, - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - gcTime: GC_MS, - queryFn: async () => { - if (!client) throw new Error("Host client unavailable"); - return client.chat.getSnapshot.query({ sessionId, workspaceId }); - }, - }); - - const snapshot = snapshotQuery.data ?? null; - const displayState = snapshot?.displayState ?? null; - const currentMessage = displayState?.currentMessage ?? null; - const isRunning = displayState?.isRunning ?? false; - const pendingApproval = displayState?.pendingApproval ?? null; - const pendingQuestion = displayState?.pendingQuestion ?? null; - const pendingPlanApproval = displayState?.pendingPlanApproval ?? null; - const historicalMessages: ChatMessage[] = snapshot?.messages ?? []; - - // Optimistic user message (cleared once it appears in synced history). - const [optimistic, setOptimistic] = useState(null); - const optimisticTextRef = useRef(null); - useEffect(() => { - if (!optimisticTextRef.current) return; - const text = optimisticTextRef.current; - const found = historicalMessages.some( - (m) => m.role === "user" && messageText(m) === text, - ); - if (found) { - setOptimistic(null); - optimisticTextRef.current = null; - } - }, [historicalMessages]); - - const messages = useMemo(() => { - const withOptimistic = optimistic - ? [...historicalMessages, optimistic] - : historicalMessages; - return withoutActiveTurnAssistantHistory( - withOptimistic, - currentMessage, - isRunning, - ); - }, [historicalMessages, optimistic, currentMessage, isRunning]); - - const sendMutation = useMutation({ - mutationFn: async (vars: { - payload: SendMessagePayload; - metadata?: MessageMetadata; - }) => { - if (!client) throw new Error("Host client unavailable"); - return client.chat.sendMessage.mutate({ - sessionId, - workspaceId, - payload: vars.payload, - metadata: vars.metadata, - }); - }, - }); - const approvalMutation = useMutation({ - mutationFn: async ( - decision: "approve" | "decline" | "always_allow_category", - ) => { - if (!client) throw new Error("Host client unavailable"); - return client.chat.respondToApproval.mutate({ - sessionId, - workspaceId, - payload: { decision }, - }); - }, - }); - const questionMutation = useMutation({ - mutationFn: async (vars: { questionId: string; answer: string }) => { - if (!client) throw new Error("Host client unavailable"); - return client.chat.respondToQuestion.mutate({ - sessionId, - workspaceId, - payload: vars, - }); - }, - }); - const planMutation = useMutation({ - mutationFn: async (vars: { - planId: string; - response: { action: "approved" | "rejected"; feedback?: string }; - }) => { - if (!client) throw new Error("Host client unavailable"); - return client.chat.respondToPlan.mutate({ - sessionId, - workspaceId, - payload: vars, - }); - }, - }); - - const sendMessage = useCallback( - async (text: string, model?: string) => { - const trimmed = text.trim(); - if (!trimmed) return; - optimisticTextRef.current = trimmed; - setOptimistic({ - id: `optimistic-${trimmed.length}-${trimmed.slice(0, 8)}`, - role: "user", - content: [{ type: "text", text: trimmed }], - createdAt: new Date(), - }); - try { - await sendMutation.mutateAsync({ - payload: { content: trimmed }, - // The host switches the thread's model before the turn when set; - // omitted → keep the current model. - metadata: model ? { model } : undefined, - }); - } catch (err) { - setOptimistic(null); - optimisticTextRef.current = null; - throw err; - } - }, - [sendMutation], - ); - - const respondToApproval = useCallback( - async (decision: "approve" | "decline" | "always_allow_category") => { - await approvalMutation.mutateAsync(decision); - }, - [approvalMutation], - ); - const respondToQuestion = useCallback( - async (questionId: string, answer: string) => { - await questionMutation.mutateAsync({ questionId, answer }); - }, - [questionMutation], - ); - const respondToPlan = useCallback( - async ( - planId: string, - action: "approved" | "rejected", - feedback?: string, - ) => { - await planMutation.mutateAsync({ - planId, - response: { action, feedback }, - }); - }, - [planMutation], - ); - - const runtimeError = - typeof displayState?.errorMessage === "string" && - displayState.errorMessage.trim() - ? displayState.errorMessage.trim() - : null; - const queryError = snapshotQuery.error - ? snapshotQuery.error instanceof Error - ? snapshotQuery.error.message - : String(snapshotQuery.error) - : null; - - return { - hostId, - organizationId, - hostOnline, - workspaceResolving, - messages, - currentMessage, - isRunning, - pendingApproval, - pendingQuestion, - pendingPlanApproval, - isConversationLoading: - queryEnabled && - snapshotQuery.data === undefined && - snapshotQuery.isLoading, - error: runtimeError ?? queryError, - sendMessage, - respondToApproval, - respondToQuestion, - respondToPlan, - isSending: sendMutation.isPending, - }; -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/index.ts index 0c90798c3b2..1a6383cc299 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/index.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/[sessionId]/index.ts @@ -1 +1 @@ -export { ChatThreadScreen } from "./ChatThreadScreen"; +export { SessionThreadScreen } from "./SessionThreadScreen"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/SessionThread.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/SessionThread.tsx deleted file mode 100644 index 543b7439f97..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/SessionThread.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { - useAcpPermissions, - useAcpSession, -} from "@superset/session-protocol/react"; -import { Stack } from "expo-router"; -// Imported from expo-router's vendored copy on purpose: this reads the SAME -// HeaderHeightContext that expo-router's Stack populates. Declaring -// `@react-navigation/elements` as our own dep would pull a second copy with a -// different context instance and always return 0. -import { useHeaderHeight } from "expo-router/build/react-navigation/elements/Header/useHeaderHeight"; -import { useCallback, useMemo, useState } from "react"; -import { - ActivityIndicator, - KeyboardAvoidingView, - Platform, - View, -} from "react-native"; -import { - Conversation, - ConversationEmptyState, - ConversationScrollButton, -} from "@/components/ai-elements/conversation"; -import { Text } from "@/components/ui/text"; -import { createAcpSessionsApi, createAcpStreamUrl } from "@/lib/host/client"; -import { GlassHeaderTitle } from "@/screens/(authenticated)/workspace/[id]/chat/components/GlassHeaderTitle"; -import { Composer } from "./components/Composer"; -import { PermissionStack } from "./components/PermissionStack"; -import { TimelineItemView } from "./components/TimelineItemView"; -import { getSessionThreadPresentation } from "./utils/getSessionThreadPresentation"; - -export function SessionThread({ - routingKey, - sessionId, -}: { - routingKey: string; - sessionId: string; -}) { - const api = useMemo(() => createAcpSessionsApi(routingKey), [routingKey]); - const streamUrl = useMemo( - () => createAcpStreamUrl({ routingKey, sessionId }), - [routingKey, sessionId], - ); - const session = useAcpSession({ sessionId, api, streamUrl }); - const permissions = useAcpPermissions(session); - const [actionError, setActionError] = useState(null); - - const status = session.state?.status; - - const handleSend = useCallback( - (text: string) => { - setActionError(null); - session.actions.prompt([{ type: "text", text }]).catch((cause) => { - setActionError(cause instanceof Error ? cause.message : String(cause)); - }); - }, - [session.actions], - ); - - const handleStop = useCallback(() => { - session.actions.cancel().catch((cause) => { - setActionError(cause instanceof Error ? cause.message : String(cause)); - }); - }, [session.actions]); - - const handleSetMode = useCallback( - (modeId: string) => { - setActionError(null); - session.actions.setMode(modeId).catch((cause) => { - setActionError(cause instanceof Error ? cause.message : String(cause)); - }); - }, - [session.actions], - ); - - const handleSetConfigOption = useCallback( - (configId: string, value: string) => { - setActionError(null); - session.actions.setConfigOption(configId, value).catch((cause) => { - setActionError(cause instanceof Error ? cause.message : String(cause)); - }); - }, - [session.actions], - ); - - // lastError is cleared host-side when a new prompt starts, so anything - // here is about the current/most recent turn, not a stale failure. - const stateError = session.state?.lastError ?? null; - const errorText = actionError ?? session.error?.message ?? stateError; - const { - bannerError, - canCompose, - composerStatus, - emptyDescription, - emptyTitle, - isDead, - reconnecting, - } = getSessionThreadPresentation({ - status, - streamStatus: session.streamStatus, - isLoading: session.isLoading, - errorText, - }); - - // Same glass-header geometry as the mastra ChatThreadScreen: the header is - // transparent so content is full-bleed (offset 0) and the list scrolls under - // the blurred bar, inset by the header height. - const headerHeight = useHeaderHeight(); - const hasBanner = reconnecting || isDead || Boolean(bannerError); - - // The adapter pushes the Claude-generated session title via - // session_info_update; the host parks it on session-scoped state (survives - // resyncs that only fetch the newest page) with the folded meta as backup. - const title = session.state?.title ?? session.timeline.meta.title; - - return ( - - }} - /> - {hasBanner ? ( - // Banners sit below the transparent header (the list scrolls under - // it, but these status strips shouldn't be obscured by the glass). - - {reconnecting ? ( - - - Reconnecting to stream… - - - ) : null} - {isDead ? ( - - - Session ended — history is read-only. - - - ) : null} - {bannerError ? ( - - - {bannerError} - - - ) : null} - - ) : null} - item.id} - contentContainerClassName="px-4 pb-4 gap-3" - contentContainerStyle={{ - paddingTop: (hasBanner ? 0 : headerHeight) + 16, - }} - // Backward pagination: scrolling near the top pulls the next older - // journal page; maintainVisibleContentPosition keeps the viewport - // anchored while the page is prepended. - maintainVisibleContentPosition - onStartReached={session.hasOlder ? session.loadOlder : undefined} - ListHeaderComponent={ - session.isLoadingOlder ? OlderPageIndicator : undefined - } - // Same running chip as the mastra ChatMessageList; the permission - // card already signals awaiting_permission, so only "running" shows it. - ListFooterComponent={ - status === "running" ? WorkingIndicator : undefined - } - renderItem={({ item }) => ( - - )} - > - {session.timeline.items.length === 0 ? ( - - ) : null} - - - {/* Blocking asks stack above the composer (a request buried in the - timeline — inside a closed tool sheet or scrolled away — would - deadlock the turn). Answered cards dismiss; the resolution stays - on the tool call's record in its detail sheet. */} - - {/* Dead, offline, and not-yet-loaded sessions cannot accept prompts. In - particular, keep the composer hidden after a failed session/load so the - destructive banner is not paired with an action guaranteed to fail. */} - {canCompose ? ( - - ) : null} - - ); -} - -function OlderPageIndicator() { - return ( - - - - ); -} - -function WorkingIndicator() { - return ( - - - - working… - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/Composer.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/Composer.tsx deleted file mode 100644 index ed8af8c7298..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/components/Composer/Composer.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import type { - SessionConfigOption, - SessionModeState, -} from "@superset/session-protocol"; -import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; -import { type ReactNode, useEffect, useState } from "react"; -import { Keyboard, Platform, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - PromptInput, - PromptInputBody, - PromptInputFooter, - type PromptInputMessage, - PromptInputSubmit, - PromptInputTextarea, - PromptInputTools, -} from "@/components/ai-elements/prompt-input"; -import { OptionPicker, type PickerOption } from "./components/OptionPicker"; - -// iOS 26 Liquid Glass (expo's official module); falls back to the solid card -// surface on older iOS / Android / when Reduce Transparency is on. -const GLASS = isLiquidGlassAvailable(); - -type SelectConfigOption = Extract; - -/** ACP select options come flat or grouped; the chip sheet renders them flat. */ -function flattenSelectOptions( - options: SelectConfigOption["options"], -): PickerOption[] { - return options.flatMap((entry) => - "group" in entry - ? entry.options.map((option) => ({ - id: option.value, - name: option.name, - description: option.description, - })) - : [ - { - id: entry.value, - name: entry.name, - description: entry.description, - }, - ], - ); -} - -/** - * Fork of the mastra ChatComposer (chat/[sessionId]/components/ChatComposer) - * for ACP sessions — same glass surface, paddings and footer layout. The - * footer chips (permission mode, model, effort, ...) are built from what the - * ACP session itself reports: `modes` and `configOptions` from session/new, - * kept live via current_mode_update / config_option_update. The submit button - * doubles as a stop button while a turn is running. - */ -export function Composer({ - onSend, - onStop, - onSetMode, - onSetConfigOption, - status, - currentMode, - configOptions, -}: { - onSend: (text: string) => void; - onStop: () => void; - onSetMode: (modeId: string) => void; - onSetConfigOption: (configId: string, value: string) => void; - status: "ready" | "streaming"; - currentMode: SessionModeState | null; - configOptions: SessionConfigOption[]; -}) { - const insets = useSafeAreaInsets(); - const keyboardShown = useKeyboardShown(); - - // The adapter mirrors the permission modes as a `mode` config option (the - // dedicated mode picker below already covers it) and exposes a Fast-mode - // toggle as an on/off select (`fast`) — a bare "On"/"Off" chip with no label - // is meaningless in the composer, so it's hidden until we render toggles. - const selectOptions = configOptions.filter( - (option): option is SelectConfigOption => - option.type === "select" && option.id !== "mode" && option.id !== "fast", - ); - - // Non-async on purpose: returning a non-Promise makes PromptInput clear the - // input synchronously on submit (see prompt-input.tsx `submit`). We do not - // await the send — failures are handled elsewhere, not by blocking the input. - const handleSubmit = (message: PromptInputMessage) => { - const text = message.text.trim(); - if (!text) return; - onSend(text); - }; - - const surface = ( - - - - - - - {currentMode && currentMode.availableModes.length > 0 ? ( - ({ - id: mode.id, - name: mode.name, - description: mode.description, - }))} - /> - ) : null} - {selectOptions.map((option) => ( - onSetConfigOption(option.id, value)} - options={flattenSelectOptions(option.options)} - /> - ))} - - - - - ); - - return ( - - {surface} - - ); -} - -/** Wraps the composer in a Liquid Glass container when available. */ -function GlassSurface({ - enabled, - children, -}: { - enabled: boolean; - children: ReactNode; -}) { - if (!enabled) return <>{children}; - return ( - - {children} - - ); -} - -/** Tracks keyboard visibility using the built-in RN Keyboard module (no extra - * native dep). iOS gets the `will` events for a frame-synced transition. */ -function useKeyboardShown(): boolean { - const [shown, setShown] = useState(false); - useEffect(() => { - const showEvent = - Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow"; - const hideEvent = - Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; - const show = Keyboard.addListener(showEvent, () => setShown(true)); - const hide = Keyboard.addListener(hideEvent, () => setShown(false)); - return () => { - show.remove(); - hide.remove(); - }; - }, []); - return shown; -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/index.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/index.ts deleted file mode 100644 index e46c3ad8753..00000000000 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AcpSessionThreadScreen } from "./AcpSessionThreadScreen"; diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/components/GlassHeaderTitle/GlassHeaderTitle.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/components/GlassHeaderTitle/GlassHeaderTitle.tsx index e0c763f81d3..07252ddeaae 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/chat/components/GlassHeaderTitle/GlassHeaderTitle.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/chat/components/GlassHeaderTitle/GlassHeaderTitle.tsx @@ -1,9 +1,13 @@ import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; -import { View } from "react-native"; +import { useWindowDimensions, View } from "react-native"; import { Text } from "@/components/ui/text"; const GLASS = isLiquidGlassAvailable(); +/** How much of the screen the title pill may claim before truncating — the + * back button is a compact floating circle, so the title gets most of it. */ +const TITLE_MAX_WIDTH_FRACTION = 0.7; + /** * Floating glass title pill for the chat thread headers. The nav bar itself is * fully transparent (no full-width blur bar — iOS 26 renders the back button @@ -12,11 +16,13 @@ const GLASS = isLiquidGlassAvailable(); * older iOS / Android / Reduce Transparency. */ export function GlassHeaderTitle({ title }: { title: string | null }) { + const { width } = useWindowDimensions(); if (!title) return null; const label = ( {title} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx index e5548244ec1..2e743acbc20 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/finish-review/FinishReviewSheet.tsx @@ -6,9 +6,12 @@ import { Alert, ScrollView, TextInput, View } from "react-native"; import { Text } from "@/components/ui/text"; import type { HostWorkspaceItem } from "@/hooks/useHostWorkspaces"; import { useWorkspaceHost } from "@/hooks/useWorkspaceHost"; -import { createAcpSessionsApi } from "@/lib/host/client"; +import { submitTurn } from "@/lib/host/client"; import { useStartWorkspaceChat } from "@/screens/(authenticated)/(home)/home/components/NewChatWidget/hooks/useStartWorkspaceChat"; -import { useHostAcpSessions } from "@/screens/(authenticated)/(home)/home/hooks/useHostAcpSessions"; +import { + getSessionsQueryKey, + useHostSessions, +} from "@/screens/(authenticated)/(home)/home/hooks/useHostSessions"; import { buildSessionRows } from "@/screens/(authenticated)/(home)/home/utils/sessionRows"; import { PressableScale } from "@/screens/(authenticated)/components/PressableScale"; import { @@ -43,7 +46,7 @@ export function FinishReviewSheet() { const workspaceId = id ?? ""; const { workspace, host } = useWorkspaceHost(workspaceId || null); - const { sessionsByWorkspace } = useHostAcpSessions(host); + const { sessionsByWorkspace } = useHostSessions(host); const comments = useDraftCommentsStore( (state) => state.commentsByWorkspace[workspaceId] ?? NO_COMMENTS, ); @@ -88,22 +91,25 @@ export function FinishReviewSheet() { router.back(); return; } + const targetSession = (sessionsByWorkspace.get(workspaceId) ?? []).find( + (session) => session.id === target, + ); + if (!targetSession) throw new Error("Session no longer exists"); const routingKey = buildHostRoutingKey( host.organizationId, host.machineId, ); - await createAcpSessionsApi(routingKey).prompt({ + await submitTurn(routingKey, { sessionId: target, - prompt: [{ type: "text", text: prompt }], + threadId: targetSession.mainThreadId, + content: [{ type: "text", text: prompt }], }); clearWorkspace(workspaceId); void queryClient.invalidateQueries({ - queryKey: ["acp-sessions", "list"], + queryKey: getSessionsQueryKey(host.machineId), }); router.back(); - router.push( - `/(authenticated)/workspace/${workspaceId}/chat/acp/${target}`, - ); + router.push(`/(authenticated)/workspace/${workspaceId}/chat/${target}`); } catch (cause) { Alert.alert( "Could not send review", diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts index e4caf1eb6a0..373173d7b81 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/hooks/useWorkspaceHeaderActions/useWorkspaceHeaderActions.ts @@ -3,12 +3,11 @@ import type { SelectV2Host } from "@superset/db/schema"; import { buildHostRoutingKey } from "@superset/shared/host-routing"; import { useQueryClient } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; -import * as Crypto from "expo-crypto"; import { useRouter } from "expo-router"; import { useState } from "react"; import { Alert, Share } from "react-native"; import type { HostWorkspaceRow } from "@/hooks/useHostWorkspaces"; -import { createAcpSession } from "@/lib/host/client"; +import { createSession } from "@/lib/host/client"; import { buildRelayHostUrl, getHostServiceClientByUrl, @@ -31,16 +30,14 @@ export function useWorkspaceHeaderActions( host.organizationId, host.machineId, ); - const sessionId = Crypto.randomUUID(); - await createAcpSession(routingKey, { - sessionId, + const created = await createSession(routingKey, { workspaceId: workspace.id, }); void queryClient.invalidateQueries({ - queryKey: ["acp-sessions", "list"], + queryKey: ["sessions", "list"], }); router.push( - `/(authenticated)/workspace/${workspace.id}/chat/acp/${sessionId}`, + `/(authenticated)/workspace/${workspace.id}/chat/${created.session.id}`, ); } catch (cause) { Alert.alert( diff --git a/bun.lock b/bun.lock index c04506ea351..16ff443f13d 100644 --- a/bun.lock +++ b/bun.lock @@ -523,7 +523,8 @@ "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/host-client": "workspace:*", - "@superset/session-protocol": "workspace:*", + "@superset/host-service-react": "workspace:*", + "@superset/host-service-sync": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@tanstack/db": "0.6.14", @@ -832,7 +833,7 @@ "name": "@superset/host-client", "version": "0.0.1", "dependencies": { - "@superset/session-protocol": "workspace:*", + "@superset/host-service-sync": "workspace:*", "superjson": "2.2.6", }, "devDependencies": { @@ -855,6 +856,7 @@ "@mastra/memory": "1.18.0", "@octokit/rest": "22.0.1", "@superset/chat": "workspace:*", + "@superset/host-service-sync": "workspace:*", "@superset/port-scanner": "workspace:*", "@superset/pty-daemon": "workspace:*", "@superset/session-protocol": "workspace:*", @@ -892,6 +894,39 @@ "@anush008/tokenizers-linux-arm64-gnu": "0.6.0", }, }, + "packages/host-service-react": { + "name": "@superset/host-service-react", + "version": "0.0.1", + "dependencies": { + "@superset/host-service-sync": "workspace:*", + "zustand": "5.0.12", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/bun": "1.3.14", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "react": "19.2.3", + "react-dom": "19.2.3", + "typescript": "6.0.3", + }, + "peerDependencies": { + "react": ">=19", + }, + }, + "packages/host-service-sync": { + "name": "@superset/host-service-sync", + "version": "0.0.1", + "dependencies": { + "zod": "4.3.6", + "zustand": "5.0.12", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/bun": "1.3.14", + "typescript": "6.0.3", + }, + }, "packages/local-db": { "name": "@superset/local-db", "version": "0.1.0", @@ -1023,15 +1058,8 @@ "@superset/typescript": "workspace:*", "@types/bun": "1.3.14", "@types/node": "24.12.0", - "@types/react": "19.2.14", "typescript": "6.0.3", }, - "peerDependencies": { - "react": "19.2.3", - }, - "optionalPeers": [ - "react", - ], }, "packages/shared": { "name": "@superset/shared", @@ -2905,6 +2933,10 @@ "@superset/host-service": ["@superset/host-service@workspace:packages/host-service"], + "@superset/host-service-react": ["@superset/host-service-react@workspace:packages/host-service-react"], + + "@superset/host-service-sync": ["@superset/host-service-sync@workspace:packages/host-service-sync"], + "@superset/local-db": ["@superset/local-db@workspace:packages/local-db"], "@superset/macos-process-metrics": ["@superset/macos-process-metrics@workspace:packages/macos-process-metrics"], diff --git a/packages/host-client/package.json b/packages/host-client/package.json index e615087b71f..705a266d04d 100644 --- a/packages/host-client/package.json +++ b/packages/host-client/package.json @@ -8,9 +8,9 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./acp": { - "types": "./src/acp/index.ts", - "default": "./src/acp/index.ts" + "./sessions": { + "types": "./src/sessions/index.ts", + "default": "./src/sessions/index.ts" } }, "scripts": { @@ -18,7 +18,7 @@ "test": "bun test --pass-with-no-tests" }, "dependencies": { - "@superset/session-protocol": "workspace:*", + "@superset/host-service-sync": "workspace:*", "superjson": "2.2.6" }, "devDependencies": { diff --git a/packages/host-client/src/acp/acp.ts b/packages/host-client/src/acp/acp.ts deleted file mode 100644 index 8f1ce25977a..00000000000 --- a/packages/host-client/src/acp/acp.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { - AcpSessionsApi, - SessionScopedState, - SessionsPage, -} from "@superset/session-protocol"; -import type { HostTransport } from "../transport"; - -/** - * ACP-sessions surface of a host, bound to a transport. Inputs/outputs are - * typed via @superset/session-protocol — the same contracts the host router - * implements — so clients never import the host's own modules. - */ -export interface AcpHostClient { - listSessions( - routingKey: string, - input?: { workspaceId?: string; cursor?: string; limit?: number }, - ): Promise; - createSession( - routingKey: string, - input: { sessionId: string; workspaceId: string }, - ): Promise; - /** Command/read API for one host, shaped for useAcpSession. */ - sessionsApi(routingKey: string): AcpSessionsApi; - /** WS URL factory for a session's live update stream. */ - streamUrl(options: { - routingKey: string; - sessionId: string; - }): () => Promise; -} - -export function createAcpHostClient(transport: HostTransport): AcpHostClient { - const call = ( - routingKey: string, - procedure: string, - input: unknown, - method: "GET" | "POST", - ) => transport.call({ routingKey, procedure, input, method }); - - return { - listSessions: (routingKey, input) => - call(routingKey, "acpSessions.list", input ?? {}, "GET"), - createSession: (routingKey, input) => - call(routingKey, "acpSessions.create", input, "POST"), - sessionsApi: (routingKey) => ({ - get: (input) => call(routingKey, "acpSessions.get", input, "GET"), - getMessages: (input) => - call(routingKey, "acpSessions.getMessages", input, "GET"), - prompt: (input) => call(routingKey, "acpSessions.prompt", input, "POST"), - respondToPermission: (input) => - call(routingKey, "acpSessions.respondToPermission", input, "POST"), - cancel: (input) => call(routingKey, "acpSessions.cancel", input, "POST"), - setMode: (input) => - call(routingKey, "acpSessions.setMode", input, "POST"), - setConfigOption: (input) => - call(routingKey, "acpSessions.setConfigOption", input, "POST"), - }), - streamUrl: ({ routingKey, sessionId }) => - transport.streamUrl({ - routingKey, - path: `acp-sessions/${encodeURIComponent(sessionId)}/stream`, - }), - }; -} diff --git a/packages/host-client/src/acp/index.ts b/packages/host-client/src/acp/index.ts deleted file mode 100644 index b8d8f770309..00000000000 --- a/packages/host-client/src/acp/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type AcpHostClient, createAcpHostClient } from "./acp"; diff --git a/packages/host-client/src/index.ts b/packages/host-client/src/index.ts index 991f41de74f..764a3ad82e5 100644 --- a/packages/host-client/src/index.ts +++ b/packages/host-client/src/index.ts @@ -1,2 +1,2 @@ -export * from "./acp"; +export * from "./sessions"; export * from "./transport"; diff --git a/packages/host-client/src/sessions/index.ts b/packages/host-client/src/sessions/index.ts new file mode 100644 index 00000000000..f36fb0e9774 --- /dev/null +++ b/packages/host-client/src/sessions/index.ts @@ -0,0 +1 @@ +export * from "./sessions"; diff --git a/packages/host-client/src/sessions/sessions.ts b/packages/host-client/src/sessions/sessions.ts new file mode 100644 index 00000000000..df893f3c1a6 --- /dev/null +++ b/packages/host-client/src/sessions/sessions.ts @@ -0,0 +1,86 @@ +import type { SessionsSyncApi } from "@superset/host-service-sync/client"; +import type { + CancelTurnInput, + CancelTurnReceipt, + CreateSessionInput, + CreateSessionResult, + HostSnapshot, + ResolvePermissionInput, + ResolvePermissionReceipt, + SubmitTurnInput, + SubmitTurnReceipt, + UpdateSessionInput, + UpdateSessionReceipt, +} from "@superset/host-service-sync/protocol"; +import type { HostTransport } from "../transport"; + +/** + * Canonical sessions surface of a host (plans/host-sessions-sync.md), bound + * to a transport. Inputs/outputs are typed via @superset/host-service-sync — + * the same contracts the host's `sessions.*` router implements — so clients + * never import the host's own modules. The read plane lives in the sync + * client (`syncApi` + `syncUrl` feed `createSessionsSyncClient`); the + * methods here are the command plane. + */ +export interface SessionsHostClient { + list(routingKey: string): Promise; + create( + routingKey: string, + input: CreateSessionInput, + ): Promise; + update( + routingKey: string, + input: UpdateSessionInput, + ): Promise; + submitTurn( + routingKey: string, + input: SubmitTurnInput, + ): Promise; + cancelTurn( + routingKey: string, + input: CancelTurnInput, + ): Promise; + resolvePermission( + routingKey: string, + input: ResolvePermissionInput, + ): Promise; + /** Query facade for one host, shaped for createSessionsSyncClient. */ + syncApi(routingKey: string): SessionsSyncApi; + /** WS URL factory for the host's `/sessions/sync` stream. */ + syncUrl(routingKey: string): () => Promise; +} + +export function createSessionsHostClient( + transport: HostTransport, +): SessionsHostClient { + const call = ( + routingKey: string, + procedure: string, + input: unknown, + method: "GET" | "POST", + ) => transport.call({ routingKey, procedure, input, method }); + + return { + list: (routingKey) => call(routingKey, "sessions.list", undefined, "GET"), + create: (routingKey, input) => + call(routingKey, "sessions.create", input, "POST"), + update: (routingKey, input) => + call(routingKey, "sessions.update", input, "POST"), + submitTurn: (routingKey, input) => + call(routingKey, "sessions.submitTurn", input, "POST"), + cancelTurn: (routingKey, input) => + call(routingKey, "sessions.cancelTurn", input, "POST"), + resolvePermission: (routingKey, input) => + call(routingKey, "sessions.resolvePermission", input, "POST"), + syncApi: (routingKey) => ({ + list: () => call(routingKey, "sessions.list", undefined, "GET"), + get: (input) => call(routingKey, "sessions.get", input, "GET"), + getEvents: (input) => + call(routingKey, "sessions.getEvents", input, "GET"), + resolveToolCall: (input) => + call(routingKey, "sessions.resolveToolCall", input, "POST"), + }), + syncUrl: (routingKey) => + transport.streamUrl({ routingKey, path: "sessions/sync" }), + }; +} diff --git a/packages/host-client/src/transport/transport.test.ts b/packages/host-client/src/transport/transport.test.ts index 2b17e754bbc..35dcc60c46e 100644 --- a/packages/host-client/src/transport/transport.test.ts +++ b/packages/host-client/src/transport/transport.test.ts @@ -45,14 +45,14 @@ describe("createHostTransport", () => { ); const result = await transport.call<{ ok: boolean }>({ routingKey: "rk", - procedure: "acpSessions.list", + procedure: "sessions.list", input: { workspaceId: "ws" }, method: "GET", }); expect(result).toEqual({ ok: true }); const url = new URL(requests[0]?.url ?? ""); // Trailing slash on the relay URL must not double up in the path. - expect(url.pathname).toBe("/hosts/rk/trpc/acpSessions.list"); + expect(url.pathname).toBe("/hosts/rk/trpc/sessions.list"); expect(JSON.parse(url.searchParams.get("input") ?? "")).toEqual( SuperJSON.serialize({ workspaceId: "ws" }), ); @@ -65,7 +65,7 @@ describe("createHostTransport", () => { ); await transport.call({ routingKey: "rk", - procedure: "acpSessions.prompt", + procedure: "sessions.submitTurn", input: { sessionId: "s1" }, method: "POST", }); @@ -135,7 +135,7 @@ describe("createHostTransport", () => { expect( await transport.call({ routingKey: "rk", - procedure: "acpSessions.cancel", + procedure: "sessions.cancelTurn", input: { sessionId: "s1" }, method: "POST", }), diff --git a/packages/host-client/src/transport/transport.ts b/packages/host-client/src/transport/transport.ts index 8cbadd6c3ee..704cda7092f 100644 --- a/packages/host-client/src/transport/transport.ts +++ b/packages/host-client/src/transport/transport.ts @@ -4,7 +4,7 @@ import SuperJSON from "superjson"; * Framework-free client transport for talking to a host-service through the * relay (`/hosts/:routingKey/...`). The relay proxies tRPC as plain HTTP and * WebSockets as raw sockets, so this is fetch + SuperJSON, not a tRPC client: - * consumers type the boundary via @superset/session-protocol (or + * consumers type the boundary via @superset/host-service-sync (or * `import type { AppRouter } from "@superset/host-service/router"`) instead of * dragging host-only modules into their type-check. * @@ -25,7 +25,7 @@ export interface HostClientConfig { export interface HostCallOptions { routingKey: string; - /** Dotted tRPC path, e.g. "acpSessions.list". */ + /** Dotted tRPC path, e.g. "sessions.list". */ procedure: string; input?: unknown; method: "GET" | "POST"; diff --git a/packages/host-service-react/package.json b/packages/host-service-react/package.json new file mode 100644 index 00000000000..76267b3649d --- /dev/null +++ b/packages/host-service-react/package.json @@ -0,0 +1,32 @@ +{ + "name": "@superset/host-service-react", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "test": "bun test --pass-with-no-tests" + }, + "dependencies": { + "@superset/host-service-sync": "workspace:*", + "zustand": "5.0.12" + }, + "peerDependencies": { + "react": ">=19" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/bun": "1.3.14", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "react": "19.2.3", + "react-dom": "19.2.3", + "typescript": "6.0.3" + } +} diff --git a/packages/host-service-react/src/bindings.test.tsx b/packages/host-service-react/src/bindings.test.tsx new file mode 100644 index 00000000000..1d5e785692a --- /dev/null +++ b/packages/host-service-react/src/bindings.test.tsx @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import type { + SessionStreamState, + SessionsSyncClient, + SessionsSyncState, +} from "@superset/host-service-sync/client"; +import type { + Session, + SessionEvent, +} from "@superset/host-service-sync/protocol"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createStore } from "zustand/vanilla"; +import { SessionsSyncProvider } from "./context"; +import { useSession, useSessionsList } from "./hooks"; +import { useSessionTimeline } from "./useSessionTimeline"; + +const SESSION_ID = "session-1"; + +function makeSession(): Session { + return { + id: SESSION_ID, + workspaceId: "workspace-1", + title: "Greeting", + mainThreadId: `${SESSION_ID}:main`, + agent: { id: "claude-code", displayName: "Claude Code" }, + runState: "idle", + capabilities: { + threadModel: "nested", + threadFidelity: "partial", + canResume: true, + supportsPermissions: true, + supportsModes: true, + supportsModels: true, + }, + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + settingOptions: [], + eventHead: "cursor-2", + createdAt: 1_000, + updatedAt: 1_001, + lastActivityAt: 1_001, + archivedAt: null, + closedAt: null, + error: null, + }; +} + +function makeEvents(): SessionEvent[] { + return [ + { + id: "event-1", + sessionId: SESSION_ID, + threadId: `${SESSION_ID}:main`, + cursor: "cursor-1", + occurredAt: 1_000, + causationId: null, + payload: { + type: "messageStarted", + message: { + id: "m-user", + sessionId: SESSION_ID, + threadId: `${SESSION_ID}:main`, + turnId: "turn-1", + role: "user", + content: [{ type: "text", text: "say hi" }], + createdAt: 1_000, + }, + }, + }, + ]; +} + +function makeState(): SessionsSyncState { + const events = makeEvents(); + const stream: SessionStreamState = { + status: "live", + latestCursor: "cursor-1", + oldestCursor: "cursor-1", + hasOlder: false, + eventIds: events.map((event) => event.id), + eventsById: Object.fromEntries(events.map((event) => [event.id, event])), + estimatedEventBytes: 0, + retainCount: 1, + retention: "focused", + lastAccessedAt: 1_001, + error: null, + }; + return { + connection: { + status: "connected", + hostId: "host-1", + connectionId: "conn-1", + error: null, + }, + hostSubscription: { status: "live", latestCursor: "cursor-1" }, + sessionsById: { [SESSION_ID]: makeSession() }, + sessionOrder: [SESSION_ID], + threadsById: {}, + pendingPermissionsById: {}, + clientToolCallsById: {}, + streamsBySessionId: { [SESSION_ID]: stream }, + totalEstimatedEventBytes: 0, + }; +} + +function makeClient(): SessionsSyncClient { + return { + store: createStore(() => makeState()), + connect: () => {}, + disconnect: () => {}, + retainSession: () => () => {}, + fetchOlderEvents: () => Promise.resolve(), + registerToolResolver: () => () => {}, + resolveToolCall: () => Promise.resolve(), + }; +} + +function Probe() { + const sessions = useSessionsList(); + const session = useSession(SESSION_ID); + const timeline = useSessionTimeline(SESSION_ID); + const first = timeline.items[0]; + return ( +
+ {sessions.length} + {session?.title} + + {first?.kind === "message" && first.blocks[0]?.type === "text" + ? `${first.role}:${first.blocks[0].text}` + : "none"} + +
+ ); +} + +describe("host-service-react bindings", () => { + test("hooks read sessions and folded timeline through the provider", () => { + const markup = renderToStaticMarkup( + + + , + ); + expect(markup).toContain(">1<"); + expect(markup).toContain(">Greeting<"); + expect(markup).toContain(">user:say hi<"); + }); + + test("useSessionsSyncClient throws without a provider", () => { + expect(() => renderToStaticMarkup()).toThrow( + /SessionsSyncProvider/, + ); + }); +}); diff --git a/packages/host-service-react/src/context.tsx b/packages/host-service-react/src/context.tsx new file mode 100644 index 00000000000..4505325ff51 --- /dev/null +++ b/packages/host-service-react/src/context.tsx @@ -0,0 +1,35 @@ +import type { SessionsSyncClient } from "@superset/host-service-sync/client"; +import { createContext, type ReactNode, useContext } from "react"; + +const SessionsSyncContext = createContext(null); + +/** + * Makes a SessionsSyncClient available to the hooks in this package. The + * provider does NOT own the client lifecycle — the app constructs the client + * (transport, auth, URLs are app concerns) and decides when to connect and + * disconnect; keeping one long-lived client per host across screen mounts is + * the intended shape. + */ +export function SessionsSyncProvider({ + client, + children, +}: { + client: SessionsSyncClient; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useSessionsSyncClient(): SessionsSyncClient { + const client = useContext(SessionsSyncContext); + if (client === null) { + throw new Error( + "useSessionsSyncClient requires a ancestor", + ); + } + return client; +} diff --git a/packages/host-service-react/src/hooks.ts b/packages/host-service-react/src/hooks.ts new file mode 100644 index 00000000000..9b21ea517a1 --- /dev/null +++ b/packages/host-service-react/src/hooks.ts @@ -0,0 +1,61 @@ +import type { + SessionRetention, + SessionStreamState, + SessionsSyncState, +} from "@superset/host-service-sync/client"; +import type { Session } from "@superset/host-service-sync/protocol"; +import { useEffect, useMemo } from "react"; +import { useStore } from "zustand"; +import { useSessionsSyncClient } from "./context"; + +/** Read a slice of the sync client's store; re-renders on slice change. */ +export function useSessionsSyncState( + selector: (state: SessionsSyncState) => T, +): T { + const client = useSessionsSyncClient(); + return useStore(client.store, selector); +} + +export function useConnection(): SessionsSyncState["connection"] { + return useSessionsSyncState((state) => state.connection); +} + +const EMPTY_SESSIONS: Session[] = []; + +/** All sessions the host stream has surfaced, newest activity first. */ +export function useSessionsList(): Session[] { + const sessionsById = useSessionsSyncState((state) => state.sessionsById); + const sessionOrder = useSessionsSyncState((state) => state.sessionOrder); + return useMemo(() => { + const sessions = sessionOrder + .map((id) => sessionsById[id]) + .filter((session): session is Session => session !== undefined); + return sessions.length > 0 ? sessions : EMPTY_SESSIONS; + }, [sessionsById, sessionOrder]); +} + +export function useSession(sessionId: string): Session | null { + return useSessionsSyncState((state) => state.sessionsById[sessionId] ?? null); +} + +export function useSessionStream(sessionId: string): SessionStreamState | null { + return useSessionsSyncState( + (state) => state.streamsBySessionId[sessionId] ?? null, + ); +} + +/** + * Hold a subscription on a session's stream for the lifetime of the calling + * component. "focused" is the reading surface; releasing drops to warm + * retention inside the client (ref-counted, LRU-evicted). + */ +export function useRetainSession( + sessionId: string | null, + reason: Exclude = "focused", +): void { + const client = useSessionsSyncClient(); + useEffect(() => { + if (sessionId === null) return; + return client.retainSession(sessionId, reason); + }, [client, sessionId, reason]); +} diff --git a/packages/host-service-react/src/index.ts b/packages/host-service-react/src/index.ts new file mode 100644 index 00000000000..962358e09e6 --- /dev/null +++ b/packages/host-service-react/src/index.ts @@ -0,0 +1,10 @@ +export { SessionsSyncProvider, useSessionsSyncClient } from "./context"; +export { + useConnection, + useRetainSession, + useSession, + useSessionStream, + useSessionsList, + useSessionsSyncState, +} from "./hooks"; +export { useSessionTimeline } from "./useSessionTimeline"; diff --git a/packages/host-service-react/src/useSessionTimeline.ts b/packages/host-service-react/src/useSessionTimeline.ts new file mode 100644 index 00000000000..6a893d96a4b --- /dev/null +++ b/packages/host-service-react/src/useSessionTimeline.ts @@ -0,0 +1,50 @@ +import type { SessionEvent } from "@superset/host-service-sync/protocol"; +import { + emptyTimeline, + foldTimeline, + type Timeline, +} from "@superset/host-service-sync/timeline"; +import { useMemo, useRef } from "react"; +import { useSessionStream } from "./hooks"; + +interface TimelineCache { + sessionId: string; + eventIds: readonly string[]; + timeline: Timeline; +} + +/** + * The session's ordered events folded into renderable timeline items. + * Folds incrementally: an appended tail (the streaming common case) folds + * only the new events; any prefix change (older-page prepend, reset replace) + * re-folds from scratch. Subscribe separately via useRetainSession — this + * hook only reads. + */ +export function useSessionTimeline(sessionId: string): Timeline { + const stream = useSessionStream(sessionId); + const cache = useRef(null); + const eventIds = stream?.eventIds; + const eventsById = stream?.eventsById; + return useMemo(() => { + if (eventIds === undefined || eventsById === undefined) { + cache.current = null; + return emptyTimeline(); + } + const events = eventIds + .map((id) => eventsById[id]) + .filter((event): event is SessionEvent => event !== undefined); + const previous = cache.current; + const isAppend = + previous !== null && + previous.sessionId === sessionId && + events.length >= previous.eventIds.length && + (previous.eventIds.length === 0 || + (eventIds[0] === previous.eventIds[0] && + eventIds[previous.eventIds.length - 1] === + previous.eventIds[previous.eventIds.length - 1])); + const base = isAppend ? previous.timeline : emptyTimeline(); + const timeline = foldTimeline(base, events); + cache.current = { sessionId, eventIds, timeline }; + return timeline; + }, [sessionId, eventIds, eventsById]); +} diff --git a/packages/host-service-react/tsconfig.json b/packages/host-service-react/tsconfig.json new file mode 100644 index 00000000000..3f856942164 --- /dev/null +++ b/packages/host-service-react/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@superset/typescript/base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["bun"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/host-service-sync/package.json b/packages/host-service-sync/package.json new file mode 100644 index 00000000000..5800e595a1e --- /dev/null +++ b/packages/host-service-sync/package.json @@ -0,0 +1,41 @@ +{ + "name": "@superset/host-service-sync", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./protocol": { + "types": "./src/protocol/index.ts", + "default": "./src/protocol/index.ts" + }, + "./projection": { + "types": "./src/projection/index.ts", + "default": "./src/projection/index.ts" + }, + "./client": { + "types": "./src/client/index.ts", + "default": "./src/client/index.ts" + }, + "./timeline": { + "types": "./src/timeline/index.ts", + "default": "./src/timeline/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "test": "bun test --pass-with-no-tests" + }, + "dependencies": { + "zod": "4.3.6", + "zustand": "5.0.12" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/bun": "1.3.14", + "typescript": "6.0.3" + } +} diff --git a/packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts b/packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts new file mode 100644 index 00000000000..8ff3f337db4 --- /dev/null +++ b/packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts @@ -0,0 +1,1116 @@ +import { describe, expect, test } from "bun:test"; +import { + type EventsWindow, + type HostSnapshot, + type Session, + type SessionEvent, + type SessionSnapshot, + type SyncClientPacket, + syncClientPacketSchema, + type Thread, +} from "../../protocol"; +import type { + SessionsSyncApi, + SessionsSyncLogEvent, + WebSocketLike, +} from "../types"; +import { createSessionsSyncClient } from "./createSessionsSyncClient"; + +const timestamp = 1_783_772_200_000; + +class FakeWebSocket implements WebSocketLike { + readyState = 0; + bufferedAmount = 0; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: + | ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) + | null = null; + onerror: ((event: unknown) => void) | null = null; + readonly sent: string[] = []; + readonly protocols: string[]; + closeCode: number | null = null; + + constructor(protocols: string[]) { + this.protocols = protocols; + } + + send(data: string): void { + this.sent.push(data); + } + + close(code?: number): void { + this.closeCode = code ?? 1000; + this.readyState = 3; + } + + emitOpen(): void { + this.readyState = 1; + this.onopen?.(); + } + + emitMessage(packet: unknown): void { + this.onmessage?.({ data: JSON.stringify(packet) }); + } + + emitClose(code = 1006): void { + this.readyState = 3; + this.onclose?.({ code, wasClean: false }); + } + + packets(): SyncClientPacket[] { + return this.sent.map((value) => + syncClientPacketSchema.parse(JSON.parse(value)), + ); + } +} + +function session(id: string): Session { + return { + id, + workspaceId: "workspace-1", + title: id, + mainThreadId: `${id}:main`, + agent: { id: "claude-code", displayName: "Claude Code" }, + runState: "running", + capabilities: { + threadModel: "nested", + threadFidelity: "partial", + canResume: true, + supportsPermissions: true, + supportsModes: true, + supportsModels: true, + }, + settings: { + activeModel: "anthropic/claude-sonnet-4-6", + activeMode: "default", + effort: "low", + configuration: {}, + }, + settingOptions: [], + eventHead: null, + createdAt: timestamp, + updatedAt: timestamp, + lastActivityAt: timestamp, + archivedAt: null, + closedAt: null, + error: null, + }; +} + +function mainThread(sessionId: string): Thread { + return { + id: `${sessionId}:main`, + sessionId, + kind: "main", + parentThreadId: null, + origin: { type: "sessionCreated" }, + fidelity: "full", + title: null, + runState: "idle", + eventHead: null, + createdAt: timestamp, + updatedAt: timestamp, + lastActivityAt: timestamp, + }; +} + +function hostSnapshotOf( + sessions: Session[], + head: string | null = "host-head-1", + extras?: Partial< + Pick + >, +): HostSnapshot { + return { + sessions, + pendingPermissions: extras?.pendingPermissions ?? [], + openClientToolCalls: extras?.openClientToolCalls ?? [], + head, + }; +} + +function sessionSnapshotOf( + sessionId: string, + options?: { + head?: string; + recentEvents?: SessionEvent[]; + hasOlderEvents?: boolean; + }, +): SessionSnapshot { + const recentEvents = options?.recentEvents ?? []; + return { + session: session(sessionId), + threads: [mainThread(sessionId)], + activeTurns: [], + pendingPermissions: [], + openToolCalls: [], + recentEvents, + hasOlderEvents: options?.hasOlderEvents ?? false, + head: options?.head ?? recentEvents.at(-1)?.cursor ?? "session-head-0", + }; +} + +function emptyWindow( + sessionId: string, + head: string, + hasMoreBefore = false, +): EventsWindow { + return { + sessionId, + threadId: null, + items: [], + range: { + oldest: null, + newest: null, + hasMoreBefore, + truncatedBefore: false, + }, + head, + }; +} + +function helloAck(requestId: string) { + return { + type: "helloAck", + protocolVersion: 1, + requestId, + hostId: "host-1", + connectionId: "connection-1", + serverTime: timestamp, + limits: { maxSubscriptions: 32, maxFrameBytes: 1024 * 1024 }, + }; +} + +function messagePacket(sessionId: string, sequence: number) { + return { + type: "event", + hostId: "host-1", + subscriptionId: `session:${sessionId}`, + stream: "session", + sessionId, + threadId: `${sessionId}:main`, + cursor: `cursor-${sequence}`, + event: { + id: `event-${sequence}`, + sessionId, + threadId: `${sessionId}:main`, + cursor: `cursor-${sequence}`, + occurredAt: timestamp + sequence, + causationId: null, + payload: { + type: "messageDelta", + messageId: "message-1", + content: { type: "text", text: String(sequence) }, + }, + }, + }; +} + +function permissionSessionEvent( + sessionId: string, + sequence: number, + payload: + | { type: "requested"; permissionId: string } + | { type: "resolved"; permissionId: string }, +): SessionEvent { + return { + id: `event-${sequence}`, + sessionId, + threadId: `${sessionId}:main`, + cursor: `cursor-${sequence}`, + occurredAt: timestamp + sequence, + causationId: null, + payload: + payload.type === "requested" + ? { + type: "permissionRequested", + permission: { + id: payload.permissionId, + sessionId, + threadId: `${sessionId}:main`, + toolCallId: "tool-1", + options: [{ id: "allow", name: "Allow", kind: "allowOnce" }], + multiSelect: false, + requestedAt: timestamp + sequence, + }, + } + : { + type: "permissionResolved", + permissionId: payload.permissionId, + outcome: { type: "cancelled" }, + }, + }; +} + +function permissionEventPacket( + sessionId: string, + sequence: number, + payload: + | { type: "requested"; permissionId: string } + | { type: "resolved"; permissionId: string }, +) { + const event = permissionSessionEvent(sessionId, sequence, payload); + return { + type: "event", + hostId: "host-1", + subscriptionId: `session:${sessionId}`, + stream: "session", + sessionId, + threadId: event.threadId, + cursor: event.cursor, + event, + }; +} + +/** + * A fully-stubbed sessions API. Every method can be overridden per test. + * The default host lists session-1/session-2: the client prunes retained + * sessions the host does not list, so tests that retain must list. + */ +function apiStub(overrides?: Partial): SessionsSyncApi { + return { + list: + overrides?.list ?? + (async () => + hostSnapshotOf([session("session-1"), session("session-2")])), + get: + overrides?.get ?? (async ({ sessionId }) => sessionSnapshotOf(sessionId)), + getEvents: + overrides?.getEvents ?? + (async ({ sessionId }) => emptyWindow(sessionId, "history-head")), + resolveToolCall: overrides?.resolveToolCall ?? (async () => {}), + }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("createSessionsSyncClient", () => { + /** Boots one client; `open()` connects and completes the hello handshake. */ + function bootClient(options?: { + api?: SessionsSyncApi; + logs?: SessionsSyncLogEvent[]; + limits?: Partial< + Parameters[0]["limits"] & object + >; + now?: () => number; + createWebSocket?: (url: string, protocols: string[]) => WebSocketLike; + }) { + const sockets: FakeWebSocket[] = []; + const client = createSessionsSyncClient({ + clientInstanceId: "client-1", + clientVersion: "1.0.0", + syncUrl: "ws://host/sessions/sync", + reconnectDelayMs: 1, + now: options?.now, + limits: options?.limits, + logger: options?.logs + ? { log: (event) => options.logs?.push(event) } + : undefined, + createWebSocket: + options?.createWebSocket ?? + ((_url, protocols) => { + const socket = new FakeWebSocket(protocols); + sockets.push(socket); + return socket; + }), + api: options?.api ?? apiStub(), + }); + return { + client, + sockets, + async open() { + client.connect(); + const socket = sockets[0] as FakeWebSocket; + socket.emitOpen(); + const hello = socket.packets()[0]; + socket.emitMessage( + helloAck(hello?.type === "hello" ? hello.requestId : ""), + ); + await flush(); + return socket; + }, + }; + } + + test("cold path: tRPC snapshots seed the store, socket resumes every stream", async () => { + let listCalls = 0; + const getCalls: string[] = []; + const harness = bootClient({ + api: apiStub({ + list: async () => { + listCalls += 1; + return hostSnapshotOf([session("session-1"), session("session-2")]); + }, + get: async ({ sessionId }) => { + getCalls.push(sessionId); + return sessionSnapshotOf(sessionId, { head: "history-head" }); + }, + }), + }); + harness.client.retainSession("session-1", "focused"); + harness.client.retainSession("session-2", "running"); + const first = harness.sockets.length + ? (harness.sockets[0] as FakeWebSocket) + : null; + expect(first).toBeNull(); + const socket = await harness.open(); + expect(socket.protocols).toEqual(["superset.sessions.sync"]); + + expect(listCalls).toBe(1); + expect(getCalls.sort()).toEqual(["session-1", "session-2"]); + expect( + harness.client.store.getState().sessionsById["session-1"]?.settings + .activeModel, + ).toBe("anthropic/claude-sonnet-4-6"); + const subscriptions = socket + .packets() + .filter((packet) => packet.type === "subscribe"); + expect(subscriptions.map((packet) => packet.subscriptionId).sort()).toEqual( + ["host", "session:session-1", "session:session-2"], + ); + expect( + subscriptions.find((packet) => packet.subscriptionId === "host")?.after, + ).toBe("host-head-1"); + expect( + subscriptions.find( + (packet) => packet.subscriptionId === "session:session-1", + )?.after, + ).toBe("history-head"); + + socket.emitMessage(messagePacket("session-1", 1)); + expect( + harness.client.store.getState().streamsBySessionId["session-1"]?.eventIds, + ).toEqual(["event-1"]); + + socket.emitClose(); + await Bun.sleep(5); + expect(harness.sockets).toHaveLength(2); + const second = harness.sockets[1] as FakeWebSocket; + second.emitOpen(); + const reconnectHello = second.packets()[0]; + second.emitMessage( + helloAck( + reconnectHello?.type === "hello" ? reconnectHello.requestId : "", + ), + ); + await flush(); + + // Warm reconnect resumes from cursors — no snapshot refetches. + expect(listCalls).toBe(1); + expect(getCalls).toHaveLength(2); + const resumed = second + .packets() + .filter((packet) => packet.type === "subscribe"); + expect(resumed).toHaveLength(3); + expect( + resumed.find((packet) => packet.subscriptionId === "session:session-1") + ?.after, + ).toBe("cursor-1"); + expect( + resumed.find((packet) => packet.subscriptionId === "host")?.after, + ).toBe("host-head-1"); + harness.client.disconnect(); + }); + + test("a gated-off host (null head) leaves the client idle with no subscriptions", async () => { + const harness = bootClient({ + api: apiStub({ list: async () => hostSnapshotOf([], null) }), + }); + const socket = await harness.open(); + expect(harness.client.store.getState().hostSubscription.status).toBe( + "idle", + ); + expect( + socket.packets().filter((packet) => packet.type === "subscribe"), + ).toHaveLength(0); + harness.client.disconnect(); + }); + + test("keeps released sessions warm and logs explicit LRU disposal", () => { + const logs: SessionsSyncLogEvent[] = []; + let clock = 0; + const client = createSessionsSyncClient({ + clientInstanceId: "client-1", + clientVersion: "1.0.0", + syncUrl: "ws://unused", + now: () => ++clock, + limits: { maxWarmSubscriptions: 1, warmSubscriptionTtlMs: 60_000 }, + logger: { log: (event) => logs.push(event) }, + api: apiStub(), + }); + + client.retainSession("session-1", "focused")(); + client.retainSession("session-2", "focused")(); + + expect( + client.store.getState().streamsBySessionId["session-1"]?.retention, + ).toBe("none"); + expect( + client.store.getState().streamsBySessionId["session-2"]?.retention, + ).toBe("warm"); + expect(logs).toContainEqual( + expect.objectContaining({ + event: "sessions_sync.subscription_disposed", + sessionId: "session-1", + reason: "warm_lru_eviction", + }), + ); + client.disconnect(); + }); + + test("reference-counts multiple consumers without duplicating a logical subscription", async () => { + const getCalls: string[] = []; + const harness = bootClient({ + api: apiStub({ + get: async ({ sessionId }) => { + getCalls.push(sessionId); + return sessionSnapshotOf(sessionId, { head: "head" }); + }, + }), + }); + const releaseFirst = harness.client.retainSession("session-1", "focused"); + const releaseSecond = harness.client.retainSession("session-1", "focused"); + expect( + harness.client.store.getState().streamsBySessionId["session-1"] + ?.retainCount, + ).toBe(2); + const socket = await harness.open(); + expect(getCalls).toEqual(["session-1"]); + expect( + socket + .packets() + .filter( + (packet) => + packet.type === "subscribe" && + packet.subscriptionId === "session:session-1", + ), + ).toHaveLength(1); + + releaseFirst(); + expect( + harness.client.store.getState().streamsBySessionId["session-1"] + ?.retainCount, + ).toBe(1); + releaseSecond(); + expect( + harness.client.store.getState().streamsBySessionId["session-1"] + ?.retention, + ).toBe("warm"); + const releaseThird = harness.client.retainSession("session-1", "focused"); + expect( + socket + .packets() + .filter( + (packet) => + packet.type === "subscribe" && + packet.subscriptionId === "session:session-1", + ), + ).toHaveLength(1); + releaseThird(); + harness.client.disconnect(); + }); + + test("bounds retained events and emits structured eviction logs", async () => { + const logs: SessionsSyncLogEvent[] = []; + const harness = bootClient({ logs, limits: { maxEventsPerSession: 2 } }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + for (let sequence = 1; sequence <= 3; sequence++) { + socket.emitMessage(messagePacket("session-1", sequence)); + } + + const stream = + harness.client.store.getState().streamsBySessionId["session-1"]; + expect(stream?.eventIds).toEqual(["event-2", "event-3"]); + expect(stream?.hasOlder).toBe(true); + expect(logs).toContainEqual( + expect.objectContaining({ + event: "sessions_sync.cache_evicted", + reason: "session_event_limit", + eventsDropped: 1, + }), + ); + harness.client.disconnect(); + }); + + test("publishes resolver capability changes without opening another socket", async () => { + const harness = bootClient(); + const socket = await harness.open(); + + const release = harness.client.registerToolResolver({ + name: "ui.ask_user", + version: 1, + }); + expect(harness.sockets).toHaveLength(1); + expect(socket.packets().at(-1)).toEqual( + expect.objectContaining({ + type: "toolResolversChanged", + toolResolvers: [{ name: "ui.ask_user", version: 1 }], + }), + ); + release(); + expect(socket.packets().at(-1)).toEqual( + expect.objectContaining({ + type: "toolResolversChanged", + toolResolvers: [], + }), + ); + harness.client.disconnect(); + }); + + test("keeps concurrent permissions and resolves a client tool claimlessly", async () => { + const resolutions: unknown[] = []; + const clientTool = { + id: "tool-client", + sessionId: "session-1", + threadId: "session-1:main", + turnId: "turn-1", + parentToolCallId: null, + tool: { name: "ui.ask_user", version: 1 }, + title: "Choose storage", + input: { question: "Which store?" }, + resolver: { + type: "client" as const, + capability: "ui.ask_user", + routing: "anyCapableClient" as const, + }, + state: "available" as const, + createdAt: timestamp, + updatedAt: timestamp, + expiresAt: null, + }; + const permissions = ["permission-1", "permission-2"].map((id) => ({ + id, + sessionId: "session-1", + threadId: "session-1:main", + toolCallId: `tool-${id}`, + options: [ + { id: "allow_once", name: "Allow once", kind: "allowOnce" as const }, + ], + multiSelect: false, + requestedAt: timestamp, + })); + const harness = bootClient({ + api: apiStub({ + list: async () => + hostSnapshotOf([session("session-1")], "host-head-1", { + pendingPermissions: permissions, + openClientToolCalls: [clientTool], + }), + resolveToolCall: async (input) => { + resolutions.push(input); + }, + }), + }); + const socket = await harness.open(); + + expect( + Object.keys(harness.client.store.getState().pendingPermissionsById), + ).toEqual(["permission-1", "permission-2"]); + socket.emitMessage({ + type: "event", + hostId: "host-1", + subscriptionId: "host", + stream: "host", + sessionId: "session-1", + threadId: null, + cursor: "host-cursor-2", + event: { type: "permissionResolved", permissionId: "permission-1" }, + }); + expect( + Object.keys(harness.client.store.getState().pendingPermissionsById), + ).toEqual(["permission-2"]); + + // No claim step: any device answers; the host arbitrates first-write-wins. + await harness.client.resolveToolCall({ + toolCallId: "tool-client", + outcome: { type: "succeeded", output: { selectedOptionId: "sqlite" } }, + }); + expect(resolutions).toHaveLength(1); + expect(resolutions[0]).toEqual( + expect.objectContaining({ + sessionId: "session-1", + toolCallId: "tool-client", + outcome: { type: "succeeded", output: { selectedOptionId: "sqlite" } }, + }), + ); + socket.emitMessage({ + type: "event", + hostId: "host-1", + subscriptionId: "host", + stream: "host", + sessionId: "session-1", + threadId: null, + cursor: "host-cursor-4", + event: { type: "clientToolCallResolved", toolCallId: "tool-client" }, + }); + expect( + harness.client.store.getState().clientToolCallsById["tool-client"], + ).toBeUndefined(); + harness.client.disconnect(); + }); + + test("closes and logs a frame that fails the canonical runtime schema", () => { + const logs: SessionsSyncLogEvent[] = []; + const harness = bootClient({ logs }); + harness.client.connect(); + const socket = harness.sockets[0] as FakeWebSocket; + socket.emitOpen(); + socket.emitMessage({ + type: "event", + stream: "session", + sessionId: "session-1", + // Deliberately missing every other required field. + }); + expect(socket.closeCode).toBe(1008); + expect(logs).toContainEqual( + expect.objectContaining({ + event: "sessions_sync.socket_dropped", + reason: "invalid_frame", + }), + ); + harness.client.disconnect(); + }); + + test("rejects schema-valid packets from the wrong host or logical stream", async () => { + const invalidPackets = [ + { ...messagePacket("session-1", 1), hostId: "host-other" }, + { + ...messagePacket("session-1", 1), + subscriptionId: "session:session-other", + }, + ]; + for (const invalidPacket of invalidPackets) { + const harness = bootClient(); + const socket = await harness.open(); + socket.emitMessage(invalidPacket); + expect(socket.closeCode).toBe(1008); + harness.client.disconnect(); + } + }); + + test("accepts exactly one matching hello acknowledgement per socket", async () => { + const harness = bootClient(); + harness.client.connect(); + const socket = harness.sockets[0] as FakeWebSocket; + socket.emitOpen(); + const hello = socket.packets()[0]; + const acknowledgement = helloAck( + hello?.type === "hello" ? hello.requestId : "", + ); + socket.emitMessage(acknowledgement); + expect(harness.client.store.getState().connection.status).toBe("connected"); + socket.emitMessage(acknowledgement); + expect(socket.closeCode).toBe(1008); + harness.client.disconnect(); + }); + + test("a host reset refetches the list as a replacement set and disposes absent streams", async () => { + const listResults = [ + hostSnapshotOf([session("session-1")], "host-head-1"), + hostSnapshotOf([], "host-head-2"), + ]; + let listCalls = 0; + const harness = bootClient({ + api: apiStub({ + list: async () => { + const result = listResults[Math.min(listCalls, 1)]; + listCalls += 1; + return result as HostSnapshot; + }, + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + socket.emitMessage(messagePacket("session-1", 1)); + expect( + harness.client.store.getState().totalEstimatedEventBytes, + ).toBeGreaterThan(0); + + // The host restarted: our host cursor is foreign, the hub answers reset. + socket.emitMessage({ + type: "reset", + subscriptionId: "host", + stream: "host", + sessionId: null, + code: "CURSOR_INVALID", + recovery: "refetchSnapshot", + }); + await flush(); + + expect(listCalls).toBe(2); + expect( + harness.client.store.getState().sessionsById["session-1"], + ).toBeUndefined(); + expect( + harness.client.store.getState().streamsBySessionId["session-1"], + ).toBeUndefined(); + expect(harness.client.store.getState().totalEstimatedEventBytes).toBe(0); + expect( + socket + .packets() + .some( + (packet) => + packet.type === "unsubscribe" && + packet.subscriptionId === "session:session-1", + ), + ).toBe(true); + // The new head is subscribed from directly. + expect( + socket + .packets() + .filter( + ( + packet, + ): packet is Extract => + packet.type === "subscribe" && packet.subscriptionId === "host", + ) + .map((packet) => packet.after), + ).toEqual(["host-head-1", "host-head-2"]); + harness.client.disconnect(); + }); + + test("a session reset refetches the snapshot before resubscribing", async () => { + const logs: SessionsSyncLogEvent[] = []; + let getCalls = 0; + const harness = bootClient({ + logs, + api: apiStub({ + get: async ({ sessionId }) => { + getCalls += 1; + return sessionSnapshotOf(sessionId, { + head: `history-head-${getCalls}`, + }); + }, + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + expect(getCalls).toBe(1); + + socket.emitMessage({ + type: "reset", + subscriptionId: "session:session-1", + stream: "session", + sessionId: "session-1", + code: "CURSOR_EXPIRED", + recovery: "refetchSnapshot", + }); + await flush(); + expect(getCalls).toBe(2); + const sessionSubscriptions = socket + .packets() + .filter( + (packet): packet is Extract => + packet.type === "subscribe" && + packet.subscriptionId === "session:session-1", + ); + expect(sessionSubscriptions.map((packet) => packet.after)).toEqual([ + "history-head-1", + "history-head-2", + ]); + expect(logs).toContainEqual( + expect.objectContaining({ + event: "sessions_sync.stream_reset", + code: "CURSOR_EXPIRED", + }), + ); + harness.client.disconnect(); + }); + + test("a reset loop trips the circuit breaker and parks the stream in error", async () => { + const logs: SessionsSyncLogEvent[] = []; + let getCalls = 0; + const harness = bootClient({ + logs, + limits: { maxStreamResetsPerWindow: 2, streamResetWindowMs: 60_000 }, + api: apiStub({ + get: async ({ sessionId }) => { + getCalls += 1; + return sessionSnapshotOf(sessionId, { head: `head-${getCalls}` }); + }, + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + expect(getCalls).toBe(1); + + const reset = { + type: "reset", + subscriptionId: "session:session-1", + stream: "session", + sessionId: "session-1", + code: "CURSOR_INVALID", + recovery: "refetchSnapshot", + }; + socket.emitMessage(reset); + await flush(); + expect(getCalls).toBe(2); + + // The second reset inside the window exhausts the budget: park, don't loop. + socket.emitMessage(reset); + await flush(); + expect(getCalls).toBe(2); + const stream = + harness.client.store.getState().streamsBySessionId["session-1"]; + expect(stream?.status).toBe("error"); + expect(stream?.error).toEqual({ code: "RESET_LOOP", retryable: true }); + expect(logs).toContainEqual( + expect.objectContaining({ + event: "sessions_sync.reset_loop", + sessionId: "session-1", + resets: 2, + }), + ); + harness.client.disconnect(); + }); + + test("a snapshot refetch resets the cached window instead of splicing a hidden gap", async () => { + let getCalls = 0; + const harness = bootClient({ + api: apiStub({ + get: async ({ sessionId }) => { + getCalls += 1; + return sessionSnapshotOf(sessionId, { + head: getCalls === 1 ? "cursor-0" : "cursor-10", + }); + }, + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + + socket.emitMessage(messagePacket("session-1", 1)); + socket.emitMessage(messagePacket("session-1", 2)); + expect( + harness.client.store.getState().streamsBySessionId["session-1"]?.eventIds, + ).toEqual(["event-1", "event-2"]); + + // The stream resets far ahead of the cached window (cursor-10): + // everything cached is stale and must not splice into the new baseline. + socket.emitMessage({ + type: "reset", + subscriptionId: "session:session-1", + stream: "session", + sessionId: "session-1", + code: "CURSOR_EXPIRED", + recovery: "refetchSnapshot", + }); + await flush(); + socket.emitMessage(messagePacket("session-1", 11)); + + const stream = + harness.client.store.getState().streamsBySessionId["session-1"]; + expect(stream?.eventIds).toEqual(["event-11"]); + expect(stream?.latestCursor).toBe("cursor-11"); + // Byte accounting followed the drop: the total equals the one stream. + expect(harness.client.store.getState().totalEstimatedEventBytes).toBe( + stream?.estimatedEventBytes ?? -1, + ); + harness.client.disconnect(); + }); + + test("prepended history never resurrects a resolved permission", async () => { + const request = permissionSessionEvent("session-1", 35, { + type: "requested", + permissionId: "perm-old", + }); + const harness = bootClient({ + api: apiStub({ + get: async ({ sessionId }) => + sessionSnapshotOf(sessionId, { + head: "cursor-50", + hasOlderEvents: true, + }), + getEvents: async ({ sessionId }) => ({ + sessionId, + threadId: null, + items: [request], + range: { + oldest: { + eventId: request.id, + cursor: request.cursor, + occurredAt: request.occurredAt, + }, + newest: { + eventId: request.id, + cursor: request.cursor, + occurredAt: request.occurredAt, + }, + hasMoreBefore: false, + truncatedBefore: false, + }, + head: "cursor-60", + }), + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + + // Live: a resolve for a permission whose request predates the window — + // a no-op fold. The card must not exist before or after paging back. + socket.emitMessage( + permissionEventPacket("session-1", 60, { + type: "resolved", + permissionId: "perm-old", + }), + ); + expect(harness.client.store.getState().pendingPermissionsById).toEqual({}); + + await harness.client.fetchOlderEvents("session-1"); + const state = harness.client.store.getState(); + expect(state.pendingPermissionsById).toEqual({}); + // The historical event itself is retained in the log window. + expect(state.streamsBySessionId["session-1"]?.eventIds).toEqual([ + "event-35", + "event-60", + ]); + harness.client.disconnect(); + }); + + test("history fetched while live events stream in never erases them or moves the cursor", async () => { + let resolveOlder: (value: EventsWindow) => void = () => {}; + let getEventsCalls = 0; + const harness = bootClient({ + api: apiStub({ + get: async ({ sessionId }) => + sessionSnapshotOf(sessionId, { + head: "cursor-5", + hasOlderEvents: true, + }), + getEvents: async () => { + getEventsCalls += 1; + return new Promise((resolve) => { + resolveOlder = resolve; + }); + }, + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + expect( + harness.client.store.getState().streamsBySessionId["session-1"]?.eventIds, + ).toEqual([]); + + const fetching = harness.client.fetchOlderEvents("session-1", { + limit: 10, + }); + await flush(); + expect(getEventsCalls).toBe(1); + socket.emitMessage(messagePacket("session-1", 7)); + resolveOlder(emptyWindow("session-1", "cursor-99")); + await fetching; + + const stream = + harness.client.store.getState().streamsBySessionId["session-1"]; + expect(stream?.eventIds).toEqual(["event-7"]); + // The prepended (empty) page reported the server head cursor-99, but a + // prepend must never advance the live cursor past what we received. + expect(stream?.latestCursor).toBe("cursor-7"); + harness.client.disconnect(); + }); + + test("a seed resolving after session removal leaves no ghost stream", async () => { + let resolveSeed: (value: SessionSnapshot) => void = () => {}; + const harness = bootClient({ + api: apiStub({ + get: async () => + new Promise((resolve) => { + resolveSeed = resolve; + }), + }), + }); + harness.client.retainSession("session-1", "focused"); + const socket = await harness.open(); + + // The session disappears while its seed request is in flight. + socket.emitMessage({ + type: "event", + hostId: "host-1", + subscriptionId: "host", + stream: "host", + sessionId: "session-1", + threadId: null, + cursor: "host-cursor-2", + event: { type: "sessionRemoved", reason: "archived" }, + }); + resolveSeed(sessionSnapshotOf("session-1", { head: "cursor-9" })); + await flush(); + + expect( + harness.client.store.getState().streamsBySessionId["session-1"], + ).toBeUndefined(); + expect(harness.client.store.getState().totalEstimatedEventBytes).toBe(0); + expect( + harness.sockets[0] + ?.packets() + .filter( + (packet) => + packet.type === "subscribe" && + packet.subscriptionId === "session:session-1", + ), + ).toEqual([]); + harness.client.disconnect(); + }); + + test("session packets for never-subscribed sessions are dropped, not folded and not fatal", async () => { + const logs: SessionsSyncLogEvent[] = []; + const harness = bootClient({ logs }); + const socket = await harness.open(); + + socket.emitMessage(messagePacket("session-ghost", 1)); + + expect( + harness.client.store.getState().streamsBySessionId["session-ghost"], + ).toBeUndefined(); + // Not connection-fatal: stragglers legitimately race an unsubscribe. + expect(socket.closeCode).toBeNull(); + expect(logs).toContainEqual({ + event: "sessions_sync.stale_packet_dropped", + sessionId: "session-ghost", + packetType: "event", + }); + harness.client.disconnect(); + }); + + test("socket factory failures surface a log event and a connection error", async () => { + const logs: SessionsSyncLogEvent[] = []; + const sockets: FakeWebSocket[] = []; + let attempts = 0; + const client = createSessionsSyncClient({ + clientInstanceId: "client-1", + clientVersion: "1.0.0", + syncUrl: "ws://host/sessions/sync", + reconnectDelayMs: 1, + logger: { log: (event) => logs.push(event) }, + createWebSocket: (_url, protocols) => { + attempts += 1; + if (attempts === 1) throw new Error("no websocket here"); + const socket = new FakeWebSocket(protocols); + sockets.push(socket); + return socket; + }, + api: apiStub(), + }); + client.connect(); + expect(logs).toContainEqual({ + event: "sessions_sync.connect_failed", + reason: "socket_factory_threw", + }); + expect(client.store.getState().connection.error).toEqual({ + code: "SOCKET_FACTORY_FAILED", + retryable: true, + }); + expect(client.store.getState().connection.status).toBe("reconnecting"); + + // The retry loop still runs — and succeeds once the factory recovers. + await Bun.sleep(5); + expect(sockets).toHaveLength(1); + client.disconnect(); + }); +}); diff --git a/packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts b/packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts new file mode 100644 index 00000000000..d23a5efd44b --- /dev/null +++ b/packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts @@ -0,0 +1,1210 @@ +import { createStore } from "zustand/vanilla"; +import { + clientInstanceIdSchema, + clientVersionSchema, + type EventsWindow, + eventsWindowSchema, + getEventsInputSchema, + getSessionInputSchema, + hostSnapshotSchema, + resolveToolCallInputSchema, + SESSIONS_SYNC_WEBSOCKET_PROTOCOL, + type SessionId, + type SyncClientPacket, + type SyncServerPacket, + sessionIdSchema, + sessionSnapshotSchema, + syncClientPacketSchema, + syncServerPacketSchema, + type ToolResolverDescriptor, + toolCallIdSchema, + toolResolverDescriptorSchema, +} from "../../protocol"; +import type { + CreateSessionsSyncClientOptions, + SessionRetention, + SessionStreamState, + SessionsSyncClient, + SessionsSyncLogEvent, + SessionsSyncState, + WebSocketLike, +} from "../types"; +import { + applyHostEvent, + applyHostSnapshot, + applySessionSnapshot, + createEmptyStream, + createInitialState, + mergeSessionEvents, + resolveLimits, +} from "./state"; + +const SOCKET_OPEN = 1; +const MAX_RECONNECT_DELAY_MS = 30_000; +const HOST_SUBSCRIPTION_ID = "host"; +const DEFAULT_HISTORY_WINDOW = 50; +const HOST_RESET_KEY = "$host"; + +function createPlatformWebSocket( + url: string, + protocols: string[], +): WebSocketLike { + const nativeSocket = new WebSocket(url, protocols); + const adapter: WebSocketLike = { + get readyState() { + return nativeSocket.readyState; + }, + get bufferedAmount() { + return nativeSocket.bufferedAmount; + }, + onopen: null, + onmessage: null, + onclose: null, + onerror: null, + send(data) { + nativeSocket.send(data); + }, + close(code, reason) { + nativeSocket.close(code, reason); + }, + }; + nativeSocket.onopen = () => adapter.onopen?.(); + nativeSocket.onmessage = (event) => adapter.onmessage?.({ data: event.data }); + nativeSocket.onclose = (event) => + adapter.onclose?.({ + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }); + nativeSocket.onerror = (event) => adapter.onerror?.(event); + return adapter; +} + +export function createSessionsSyncClient( + options: CreateSessionsSyncClientOptions, +): SessionsSyncClient { + clientInstanceIdSchema.parse(options.clientInstanceId); + clientVersionSchema.parse(options.clientVersion); + if (!options.api) { + throw new Error("sessions sync client requires a sessions API facade"); + } + const api = options.api; + const now = options.now ?? Date.now; + const limits = resolveLimits(options.limits); + const store = createStore(() => createInitialState()); + const createWebSocket = options.createWebSocket ?? createPlatformWebSocket; + const reconnectDelayMs = options.reconnectDelayMs ?? 500; + if (!Number.isSafeInteger(reconnectDelayMs) || reconnectDelayMs < 0) { + throw new Error( + `invalid sessions sync reconnect delay: ${reconnectDelayMs}`, + ); + } + const retainReasons = new Map< + SessionId, + Record, number> + >(); + const warmTimers = new Map>(); + const seedRetryTimers = new Map>(); + const seedRetryAttempts = new Map(); + const resolverCounts = new Map< + string, + { descriptor: ToolResolverDescriptor; count: number } + >(); + const seedPromises = new Map>(); + /** + * Reset circuit breaker per stream (host + sessions). A stream whose + * resets exceed the window budget parks in `error` instead of looping + * the cold path — a reset loop is a host bug the client cannot fix. + */ + const resetWindows = new Map< + string, + { count: number; windowStart: number } + >(); + /** + * Sessions whose subscribe packet went out on the CURRENT socket. An + * unsubscribe is only valid for these — e.g. a `sessionRemoved` for a + * session we never subscribed must not send one, because the hub answers + * unknown-subscription unsubscribes with an explicit error frame. + */ + const sentSessionSubscriptions = new Set(); + + let socket: WebSocketLike | null = null; + let reconnectTimer: ReturnType | null = null; + let connectionEpoch = 0; + let reconnectAttempts = 0; + let requestCounter = 0; + let stopped = true; + let negotiatedMaxFrameBytes = limits.maxFrameBytes; + let helloRequestId: string | null = null; + let hostSeedEpoch = -1; + let hostSeedPromise: Promise | null = null; + let hostSeedRetryTimer: ReturnType | null = null; + let hostSeedRetryAttempts = 0; + let helloTimer: ReturnType | null = null; + let heartbeatTimer: ReturnType | null = null; + let pongTimer: ReturnType | null = null; + let pendingPingNonce: string | null = null; + + function log(event: SessionsSyncLogEvent): void { + options.logger?.log(event); + } + + function nextRequestId(): string { + requestCounter += 1; + return `${options.clientInstanceId}:${requestCounter}`; + } + + function activeToolResolvers(): ToolResolverDescriptor[] { + return [...resolverCounts.values()] + .map((entry) => entry.descriptor) + .sort( + (left, right) => + left.name.localeCompare(right.name) || left.version - right.version, + ); + } + + function setConnection( + patch: Partial, + ): void { + store.setState((state) => ({ + connection: { ...state.connection, ...patch }, + })); + } + + function setHostSubscription( + patch: Partial, + ): void { + store.setState((state) => ({ + hostSubscription: { ...state.hostSubscription, ...patch }, + })); + } + + function setSessionStream( + sessionId: SessionId, + patch: Partial, + ): void { + store.setState((state) => { + const current = + state.streamsBySessionId[sessionId] ?? createEmptyStream(now()); + return { + streamsBySessionId: { + ...state.streamsBySessionId, + [sessionId]: { ...current, ...patch }, + }, + }; + }); + } + + function isConnected(): boolean { + return ( + socket?.readyState === SOCKET_OPEN && + store.getState().connection.status === "connected" + ); + } + + function send(packet: SyncClientPacket): boolean { + const current = socket; + if (!current || current.readyState !== SOCKET_OPEN) return false; + const parsed = syncClientPacketSchema.safeParse(packet); + if (!parsed.success) { + throw new Error(`invalid outbound sessions sync packet: ${packet.type}`); + } + const queuedBytes = current.bufferedAmount ?? 0; + if (queuedBytes > limits.maxSocketQueuedBytes) { + log({ + event: "sessions_sync.socket_dropped", + reason: "send_buffer_limit", + queuedBytes, + limitBytes: limits.maxSocketQueuedBytes, + }); + current.close(1013, "sync send buffer exceeded"); + return false; + } + current.send(JSON.stringify(parsed.data)); + return true; + } + + function sessionSubscriptionId(sessionId: SessionId): string { + return `session:${sessionId}`; + } + + /** Inverse of `sessionSubscriptionId` for ids echoed back by the server. */ + function sessionIdFromSubscriptionId( + subscriptionId: string | null, + ): SessionId | null { + if (!subscriptionId?.startsWith("session:")) return null; + return subscriptionId.slice("session:".length); + } + + /** + * Returns false when the stream's reset budget is exhausted — the caller + * must park the stream instead of re-running the cold path. + */ + function admitReset(key: string, sessionId: SessionId | null): boolean { + const at = now(); + const entry = resetWindows.get(key); + const next = + entry && at - entry.windowStart <= limits.streamResetWindowMs + ? { count: entry.count + 1, windowStart: entry.windowStart } + : { count: 1, windowStart: at }; + resetWindows.set(key, next); + if (next.count >= limits.maxStreamResetsPerWindow) { + log({ + event: "sessions_sync.reset_loop", + sessionId, + resets: next.count, + windowMs: limits.streamResetWindowMs, + }); + return false; + } + return true; + } + + function backoffDelay(attempts: number): number { + return Math.min(reconnectDelayMs * 2 ** attempts, MAX_RECONNECT_DELAY_MS); + } + + /** + * A failed snapshot has no other way back: helloAck and a host reset are + * the only callers of the cold path, and neither re-fires on a socket + * that stays open. The retry dies with the socket or the epoch. + */ + function scheduleHostSeedRetry(epoch: number): void { + if (hostSeedRetryTimer) return; + const delay = backoffDelay(hostSeedRetryAttempts); + hostSeedRetryAttempts += 1; + hostSeedRetryTimer = setTimeout(() => { + hostSeedRetryTimer = null; + if (stopped || epoch !== connectionEpoch) return; + seedHostAndSubscribe(); + }, delay); + } + + function clearHostSeedRetry(): void { + if (hostSeedRetryTimer) clearTimeout(hostSeedRetryTimer); + hostSeedRetryTimer = null; + hostSeedRetryAttempts = 0; + } + + function scheduleSeedRetry(sessionId: SessionId, epoch: number): void { + if (seedRetryTimers.has(sessionId)) return; + const attempts = seedRetryAttempts.get(sessionId) ?? 0; + seedRetryAttempts.set(sessionId, attempts + 1); + const timer = setTimeout(() => { + seedRetryTimers.delete(sessionId); + if (stopped || epoch !== connectionEpoch || !wantsSession(sessionId)) { + seedRetryAttempts.delete(sessionId); + return; + } + seedAndSubscribe(sessionId); + }, backoffDelay(attempts)); + seedRetryTimers.set(sessionId, timer); + } + + function clearSeedRetry(sessionId: SessionId): void { + const timer = seedRetryTimers.get(sessionId); + if (timer) clearTimeout(timer); + seedRetryTimers.delete(sessionId); + seedRetryAttempts.delete(sessionId); + } + + function clearSeedRetries(): void { + for (const timer of seedRetryTimers.values()) clearTimeout(timer); + seedRetryTimers.clear(); + seedRetryAttempts.clear(); + } + + function wantsSession(sessionId: SessionId): boolean { + // A session with no stream yet is NOT desired — treating `undefined` + // as desired made retainSession skip the initial seed+subscribe for + // sessions first retained while already connected. + const retention = + store.getState().streamsBySessionId[sessionId]?.retention ?? "none"; + return retention !== "none"; + } + + function sendHostSubscription(after: string): void { + setHostSubscription({ status: "subscribing" }); + send({ + type: "subscribe", + requestId: nextRequestId(), + subscriptionId: HOST_SUBSCRIPTION_ID, + stream: { type: "host" }, + after, + }); + } + + /** + * The host-stream cold path: with a cursor, resume straight over the + * socket; without one, fetch the host snapshot over tRPC and subscribe + * from its head. Reset recovery re-enters here with a nulled cursor, so + * recovery IS the cold path. + */ + function seedHostAndSubscribe(): void { + if (!isConnected()) return; + const cursor = store.getState().hostSubscription.latestCursor; + if (cursor !== null) { + sendHostSubscription(cursor); + return; + } + if (hostSeedPromise && hostSeedEpoch === connectionEpoch) return; + hostSeedEpoch = connectionEpoch; + setHostSubscription({ status: "subscribing" }); + const epoch = connectionEpoch; + hostSeedPromise = api + .list() + .then((value) => { + if (stopped || epoch !== connectionEpoch) return; + const snapshot = hostSnapshotSchema.parse(value); + hostSeedRetryAttempts = 0; + // Streams for sessions the host no longer lists are stale, so + // stop the socket feeding them. The retain itself is app intent + // nothing will re-state, so it outlives the snapshot: a session + // created concurrently seeds when its upsert lands. + const listed = new Set(snapshot.sessions.map((session) => session.id)); + for (const subscribed of [...sentSessionSubscriptions]) { + if (!listed.has(subscribed)) unsubscribeSession(subscribed); + } + store.setState(applyHostSnapshot(store.getState(), snapshot), true); + if (snapshot.head !== null) { + sendHostSubscription(snapshot.head); + } + for (const retained of retainReasons.keys()) { + if (listed.has(retained)) seedRetainedSession(retained); + } + }) + .catch(() => { + if (stopped || epoch !== connectionEpoch) return; + setHostSubscription({ status: "error" }); + setConnection({ + error: { code: "HOST_SNAPSHOT_FAILED", retryable: true }, + }); + scheduleHostSeedRetry(epoch); + }) + .finally(() => { + if (hostSeedEpoch === epoch) hostSeedPromise = null; + }); + } + + function sendSessionSubscription(sessionId: SessionId): void { + if (!isConnected() || !wantsSession(sessionId)) return; + const stream = store.getState().streamsBySessionId[sessionId]; + const after = stream?.latestCursor; + if (!after) return; + setSessionStream(sessionId, { status: "subscribing", error: null }); + const sent = send({ + type: "subscribe", + requestId: nextRequestId(), + subscriptionId: sessionSubscriptionId(sessionId), + stream: { type: "session", sessionId }, + after, + }); + if (sent) sentSessionSubscriptions.add(sessionId); + } + + function applyWindow( + sessionId: SessionId, + window: EventsWindow, + position: "prepend" | "replace", + ): void { + const result = mergeSessionEvents({ + state: store.getState(), + sessionId, + events: window.items, + position, + ...(position === "replace" ? { latestCursor: window.head } : {}), + hasMoreBefore: window.range.hasMoreBefore, + now: now(), + limits, + }); + store.setState(result.state, true); + for (const event of result.logs) log(event); + } + + /** + * The session-stream cold path: with a cursor, resume straight over the + * socket; without one, fetch the session snapshot over tRPC (projection + + * recent tail + head) and subscribe from its head. + */ + function seedAndSubscribe(sessionId: SessionId): void { + if (!isConnected() || !wantsSession(sessionId)) return; + const stream = store.getState().streamsBySessionId[sessionId]; + if (stream?.latestCursor != null) { + sendSessionSubscription(sessionId); + return; + } + if (seedPromises.has(sessionId)) return; + setSessionStream(sessionId, { status: "subscribing", error: null }); + const epoch = connectionEpoch; + const promise = api + .get(getSessionInputSchema.parse({ sessionId })) + .then((value) => { + // Released or removed while the seed was in flight: folding the + // snapshot now would recreate a ghost stream nothing owns. + if (!wantsSession(sessionId)) return; + const snapshot = sessionSnapshotSchema.parse(value); + if (snapshot.session.id !== sessionId) { + throw new Error("session snapshot response identity mismatch"); + } + seedRetryAttempts.delete(sessionId); + store.setState( + applySessionSnapshot(store.getState(), snapshot, now()), + true, + ); + // The snapshot leaves the stream `subscribing`, which is a lie + // once the socket is gone: nothing will answer the subscribe. + if (!isConnected()) { + setSessionStream(sessionId, { status: "idle" }); + return; + } + sendSessionSubscription(sessionId); + }) + .catch(() => { + setSessionStream(sessionId, { + status: "error", + error: { code: "SNAPSHOT_LOAD_FAILED", retryable: true }, + }); + if (wantsSession(sessionId)) scheduleSeedRetry(sessionId, epoch); + }) + .finally(() => { + seedPromises.delete(sessionId); + }); + seedPromises.set(sessionId, promise); + } + + /** + * Re-seeds a session the app still retains but the store has no stream + * for — a host snapshot prunes streams it does not list, while the retain + * records intent no consumer will re-state. + */ + function seedRetainedSession(sessionId: SessionId): void { + const counts = retainReasons.get(sessionId); + if (!counts) return; + if ( + sentSessionSubscriptions.has(sessionId) || + seedPromises.has(sessionId) + ) { + return; + } + const stream = store.getState().streamsBySessionId[sessionId]; + if ((stream?.retention ?? "none") === "none") { + setSessionStream(sessionId, { + retainCount: counts.focused + counts.running + counts.warm, + retention: currentRetention(counts), + lastAccessedAt: now(), + }); + } + seedAndSubscribe(sessionId); + } + + function unsubscribeSession(sessionId: SessionId): void { + clearSeedRetry(sessionId); + if (sentSessionSubscriptions.delete(sessionId) && isConnected()) { + send({ + type: "unsubscribe", + requestId: nextRequestId(), + subscriptionId: sessionSubscriptionId(sessionId), + }); + } + setSessionStream(sessionId, { status: "idle" }); + } + + /** + * The socket is gone: every stream that claimed to be live is now merely + * cached. Downgrade to `idle` (data and cursors intact) so "live" always + * means "caught up on an open socket" — error/reset statuses survive. + */ + function markStreamsDisconnected(): void { + const stale = new Set(["subscribing", "replaying", "live"]); + store.setState((state) => ({ + hostSubscription: stale.has(state.hostSubscription.status) + ? { ...state.hostSubscription, status: "idle" as const } + : state.hostSubscription, + streamsBySessionId: Object.fromEntries( + Object.entries(state.streamsBySessionId).map(([sessionId, stream]) => [ + sessionId, + stale.has(stream.status) + ? { ...stream, status: "idle" as const } + : stream, + ]), + ), + })); + } + + function scheduleReconnect(): void { + if (stopped || reconnectTimer) return; + setConnection({ status: "reconnecting" }); + const delay = backoffDelay(reconnectAttempts); + reconnectAttempts += 1; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + openConnection(); + }, delay); + } + + function stopHeartbeat(): void { + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + if (pongTimer) clearTimeout(pongTimer); + pongTimer = null; + pendingPingNonce = null; + } + + function clearHelloTimer(): void { + if (helloTimer) clearTimeout(helloTimer); + helloTimer = null; + } + + /** + * A half-open socket reports `OPEN` forever and answers nothing, so an + * unanswered ping is the only evidence the connection is dead. Closing is + * the whole recovery: the close handler owns reconnect. + */ + function startHeartbeat(): void { + stopHeartbeat(); + heartbeatTimer = setInterval(() => { + if (pongTimer || !isConnected()) return; + const nonce = nextRequestId(); + if (!send({ type: "ping", nonce })) return; + pendingPingNonce = nonce; + pongTimer = setTimeout(() => { + pongTimer = null; + pendingPingNonce = null; + socket?.close(1001, "sessions sync heartbeat timeout"); + }, limits.heartbeatTimeoutMs); + }, limits.heartbeatIntervalMs); + } + + function dropInvalidSocket( + reason: "invalid_frame" | "frame_size_limit", + ): void { + log({ + event: "sessions_sync.socket_dropped", + reason, + queuedBytes: null, + limitBytes: negotiatedMaxFrameBytes, + }); + socket?.close(1008, reason.replaceAll("_", " ")); + } + + function handleServerPacket(packet: SyncServerPacket): void { + if (packet.type === "helloAck") { + helloRequestId = null; + clearHelloTimer(); + startHeartbeat(); + negotiatedMaxFrameBytes = Math.min( + limits.maxFrameBytes, + packet.limits.maxFrameBytes, + ); + reconnectAttempts = 0; + setConnection({ + status: "connected", + hostId: packet.hostId, + connectionId: packet.connectionId, + error: null, + }); + seedHostAndSubscribe(); + for (const [sessionId, stream] of Object.entries( + store.getState().streamsBySessionId, + )) { + if (stream.retention !== "none") seedAndSubscribe(sessionId); + } + return; + } + + if (packet.type === "pong") { + if (packet.nonce !== pendingPingNonce) return; + pendingPingNonce = null; + if (pongTimer) clearTimeout(pongTimer); + pongTimer = null; + return; + } + + if (packet.type === "event" && packet.stream === "host") { + if (packet.event.type === "sessionRemoved") { + retainReasons.delete(packet.sessionId); + clearWarmTimer(packet.sessionId); + unsubscribeSession(packet.sessionId); + } + store.setState(applyHostEvent(store.getState(), packet), true); + if (packet.event.type === "sessionUpsert") { + seedRetainedSession(packet.event.session.id); + } + return; + } + + if (packet.type === "event" && packet.stream === "session") { + const result = mergeSessionEvents({ + state: store.getState(), + sessionId: packet.sessionId, + events: [packet.event], + position: "append", + latestCursor: packet.cursor, + now: now(), + limits, + }); + store.setState(result.state, true); + for (const event of result.logs) log(event); + return; + } + + if (packet.type === "subscribed") { + if (packet.stream === "host") { + setHostSubscription({ status: "replaying" }); + } else if (packet.sessionId) { + setSessionStream(packet.sessionId, { status: "replaying" }); + } + return; + } + + if (packet.type === "caughtUp") { + if (packet.stream === "host") { + resetWindows.delete(HOST_RESET_KEY); + store.setState({ + hostSubscription: { + status: "live", + latestCursor: packet.through, + }, + }); + } else if (packet.sessionId) { + resetWindows.delete(packet.sessionId); + setSessionStream(packet.sessionId, { + status: "live", + latestCursor: packet.through, + }); + } + return; + } + + if (packet.type === "unsubscribed") { + if (packet.sessionId) { + setSessionStream(packet.sessionId, { status: "idle" }); + } + return; + } + + if (packet.type === "reset") { + log({ + event: "sessions_sync.stream_reset", + sessionId: packet.sessionId, + code: packet.code, + recovery: packet.recovery, + }); + if (packet.stream === "host") { + if (!admitReset(HOST_RESET_KEY, null)) { + store.setState({ + hostSubscription: { status: "error", latestCursor: null }, + }); + return; + } + store.setState({ + hostSubscription: { status: "reset", latestCursor: null }, + }); + seedHostAndSubscribe(); + } else if (packet.sessionId) { + if (!admitReset(packet.sessionId, packet.sessionId)) { + setSessionStream(packet.sessionId, { + status: "error", + latestCursor: null, + error: { code: "RESET_LOOP", retryable: true }, + }); + return; + } + setSessionStream(packet.sessionId, { + status: "reset", + latestCursor: null, + }); + seedAndSubscribe(packet.sessionId); + } + return; + } + + if (packet.type === "error") { + if (packet.requestId === helloRequestId) { + helloRequestId = null; + setConnection({ + status: packet.retryable ? "reconnecting" : "disconnected", + error: { code: packet.code, retryable: packet.retryable }, + }); + if (!packet.retryable) stopped = true; + socket?.close(1008, "sessions sync hello rejected"); + return; + } + if (packet.sessionId) { + setSessionStream(packet.sessionId, { + status: "error", + error: { code: packet.code, retryable: packet.retryable }, + }); + return; + } + const scoped = sessionIdFromSubscriptionId(packet.subscriptionId); + if (scoped !== null) { + // A session-scoped echo the server could not resolve to a stream + // (e.g. answering an unsubscribe it does not hold). Never + // connection-fatal; fold it only into a stream that still expects + // traffic. + const stream = store.getState().streamsBySessionId[scoped]; + if (stream && stream.status !== "idle") { + setSessionStream(scoped, { + status: "error", + error: { code: packet.code, retryable: packet.retryable }, + }); + } + return; + } + setConnection({ + error: { code: packet.code, retryable: packet.retryable }, + }); + } + } + + function hasValidPacketContext(packet: SyncServerPacket): boolean { + const state = store.getState(); + if (packet.type === "helloAck") { + return ( + helloRequestId !== null && + packet.requestId === helloRequestId && + state.connection.status !== "connected" && + (state.connection.hostId === null || + state.connection.hostId === packet.hostId) + ); + } + if ( + packet.type === "error" && + helloRequestId !== null && + packet.requestId === helloRequestId + ) { + return packet.subscriptionId === null && packet.sessionId === null; + } + if ( + helloRequestId !== null || + state.connection.status !== "connected" || + state.connection.hostId === null + ) { + return false; + } + if ("hostId" in packet && packet.hostId !== state.connection.hostId) { + return false; + } + + switch (packet.type) { + case "event": + return packet.stream === "host" + ? packet.subscriptionId === HOST_SUBSCRIPTION_ID + : packet.subscriptionId === sessionSubscriptionId(packet.sessionId); + case "subscribed": + case "caughtUp": + case "unsubscribed": + case "reset": + return packet.stream === "host" + ? packet.subscriptionId === HOST_SUBSCRIPTION_ID + : packet.sessionId !== null && + packet.subscriptionId === sessionSubscriptionId(packet.sessionId); + case "error": + // A null sessionId with any subscriptionId is valid: the server + // echoes ids it cannot resolve to a stream (e.g. an unsubscribe + // for a subscription it does not hold). + if (packet.subscriptionId === null || packet.sessionId === null) { + return true; + } + return ( + packet.subscriptionId === sessionSubscriptionId(packet.sessionId) + ); + case "pong": + return true; + } + } + + /** + * Session-scoped packets that create or mutate stream/projection state + * are honored only for sessions whose subscribe went out on THIS socket. + * Anything else (a misrouted frame, a straggler after an unsubscribe) is + * dropped without folding — but never connection-fatal, because in-flight + * frames legitimately race a just-sent unsubscribe. + */ + function unscopedSessionIdOf(packet: SyncServerPacket): SessionId | null { + switch (packet.type) { + case "event": + return packet.stream === "session" && + !sentSessionSubscriptions.has(packet.sessionId) + ? packet.sessionId + : null; + case "subscribed": + case "caughtUp": + case "reset": + return packet.stream === "session" && + packet.sessionId !== null && + !sentSessionSubscriptions.has(packet.sessionId) + ? packet.sessionId + : null; + default: + return null; + } + } + + function handleRawFrame(data: unknown): void { + if (typeof data !== "string") { + dropInvalidSocket("invalid_frame"); + return; + } + if (data.length * 2 > negotiatedMaxFrameBytes) { + dropInvalidSocket("frame_size_limit"); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + dropInvalidSocket("invalid_frame"); + return; + } + const result = syncServerPacketSchema.safeParse(parsed); + if (!result.success) { + dropInvalidSocket("invalid_frame"); + return; + } + if (!hasValidPacketContext(result.data)) { + dropInvalidSocket("invalid_frame"); + return; + } + const staleSessionId = unscopedSessionIdOf(result.data); + if (staleSessionId !== null) { + log({ + event: "sessions_sync.stale_packet_dropped", + sessionId: staleSessionId, + packetType: result.data.type, + }); + return; + } + handleServerPacket(result.data); + } + + function attachSocket(url: string, epoch: number): void { + if (stopped || epoch !== connectionEpoch) return; + let created: WebSocketLike; + try { + created = createWebSocket(url, [SESSIONS_SYNC_WEBSOCKET_PROTOCOL]); + } catch { + // Surface the failure — a silent retry loop with a null error is + // undiagnosable from the outside. + log({ + event: "sessions_sync.connect_failed", + reason: "socket_factory_threw", + }); + setConnection({ + error: { code: "SOCKET_FACTORY_FAILED", retryable: true }, + }); + scheduleReconnect(); + return; + } + socket = created; + created.onopen = () => { + if (socket !== created || stopped) return; + helloRequestId = nextRequestId(); + const hello: SyncClientPacket = { + type: "hello", + protocolVersion: 1, + requestId: helloRequestId, + clientInstanceId: options.clientInstanceId, + clientVersion: options.clientVersion, + toolResolvers: activeToolResolvers(), + }; + send(hello); + clearHelloTimer(); + helloTimer = setTimeout(() => { + helloTimer = null; + if (socket !== created || stopped) return; + created.close(1001, "sessions sync hello timeout"); + }, limits.helloTimeoutMs); + }; + created.onmessage = (event) => { + if (socket !== created || stopped) return; + handleRawFrame(event.data); + }; + created.onerror = () => { + // The close event owns reconnect scheduling. + }; + created.onclose = () => { + if (socket !== created) return; + socket = null; + helloRequestId = null; + clearHelloTimer(); + stopHeartbeat(); + clearHostSeedRetry(); + clearSeedRetries(); + // The reset budget is per connection: a fresh socket earns a fresh + // one, so a parked stream recovers on reconnect. + resetWindows.clear(); + sentSessionSubscriptions.clear(); + markStreamsDisconnected(); + if (!stopped) scheduleReconnect(); + }; + } + + function openConnection(): void { + if (stopped || socket) return; + const epoch = ++connectionEpoch; + setConnection({ + status: reconnectAttempts === 0 ? "connecting" : "reconnecting", + error: null, + }); + const failSyncUrl = (): void => { + log({ event: "sessions_sync.connect_failed", reason: "sync_url_failed" }); + setConnection({ error: { code: "SYNC_URL_FAILED", retryable: true } }); + scheduleReconnect(); + }; + let value: string | Promise; + try { + value = + typeof options.syncUrl === "function" + ? options.syncUrl() + : options.syncUrl; + } catch { + failSyncUrl(); + return; + } + if (typeof value === "string") { + attachSocket(value, epoch); + return; + } + void value.then( + (url) => attachSocket(url, epoch), + () => failSyncUrl(), + ); + } + + function currentRetention( + counts: Record, number>, + ): Exclude { + if (counts.focused > 0) return "focused"; + if (counts.running > 0) return "running"; + return "warm"; + } + + function clearWarmTimer(sessionId: SessionId): void { + const timer = warmTimers.get(sessionId); + if (timer) clearTimeout(timer); + warmTimers.delete(sessionId); + } + + function disposeWarm( + sessionId: SessionId, + reason: "warm_lru_eviction" | "warm_ttl", + warmRank: number | null, + ): void { + const stream = store.getState().streamsBySessionId[sessionId]; + if (!stream || stream.retainCount > 0 || stream.retention !== "warm") + return; + clearWarmTimer(sessionId); + unsubscribeSession(sessionId); + setSessionStream(sessionId, { retention: "none", retainCount: 0 }); + log({ + event: "sessions_sync.subscription_disposed", + sessionId, + reason, + warmRank, + latestCursor: stream.latestCursor, + }); + } + + function enforceWarmLimit(): void { + const warm = Object.entries(store.getState().streamsBySessionId) + .filter( + ([, stream]) => stream.retention === "warm" && stream.retainCount === 0, + ) + .sort( + ([, left], [, right]) => right.lastAccessedAt - left.lastAccessedAt, + ); + for ( + let index = limits.maxWarmSubscriptions; + index < warm.length; + index++ + ) { + const sessionId = warm[index]?.[0]; + if (sessionId) disposeWarm(sessionId, "warm_lru_eviction", index + 1); + } + } + + function scheduleWarmExpiry(sessionId: SessionId): void { + clearWarmTimer(sessionId); + const timer = setTimeout(() => { + warmTimers.delete(sessionId); + disposeWarm(sessionId, "warm_ttl", null); + }, limits.warmSubscriptionTtlMs); + warmTimers.set(sessionId, timer); + } + + return { + store, + + connect(): void { + if (!stopped) return; + stopped = false; + resetWindows.clear(); + for (const [sessionId, stream] of Object.entries( + store.getState().streamsBySessionId, + )) { + if (stream.retention === "warm" && stream.retainCount === 0) { + scheduleWarmExpiry(sessionId); + } + } + openConnection(); + }, + + disconnect(): void { + for (const timer of warmTimers.values()) clearTimeout(timer); + warmTimers.clear(); + clearHelloTimer(); + stopHeartbeat(); + clearHostSeedRetry(); + clearSeedRetries(); + if (stopped) return; + stopped = true; + connectionEpoch += 1; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; + const current = socket; + socket = null; + helloRequestId = null; + sentSessionSubscriptions.clear(); + if (current) { + current.onopen = null; + current.onmessage = null; + current.onerror = null; + current.onclose = null; + current.close(1000, "sync client disconnected"); + } + markStreamsDisconnected(); + setConnection({ + status: "disconnected", + connectionId: null, + }); + }, + + retainSession(sessionId, reason) { + sessionIdSchema.parse(sessionId); + clearWarmTimer(sessionId); + const wasDesired = wantsSession(sessionId); + const counts = retainReasons.get(sessionId) ?? { + focused: 0, + running: 0, + warm: 0, + }; + counts[reason] += 1; + retainReasons.set(sessionId, counts); + const retainCount = counts.focused + counts.running + counts.warm; + setSessionStream(sessionId, { + retainCount, + retention: currentRetention(counts), + lastAccessedAt: now(), + }); + if (!wasDesired) seedAndSubscribe(sessionId); + + let released = false; + return () => { + if (released) return; + released = true; + const current = retainReasons.get(sessionId); + if (!current) return; + current[reason] = Math.max(0, current[reason] - 1); + const nextCount = current.focused + current.running + current.warm; + if (nextCount > 0) { + retainReasons.set(sessionId, current); + setSessionStream(sessionId, { + retainCount: nextCount, + retention: currentRetention(current), + lastAccessedAt: now(), + }); + return; + } + retainReasons.delete(sessionId); + setSessionStream(sessionId, { + retainCount: 0, + retention: "warm", + lastAccessedAt: now(), + }); + scheduleWarmExpiry(sessionId); + enforceWarmLimit(); + }; + }, + + async fetchOlderEvents(sessionId, fetchOptions): Promise { + sessionIdSchema.parse(sessionId); + const stream = + store.getState().streamsBySessionId[sessionId] ?? + createEmptyStream(now()); + const beforeCursor = stream.oldestCursor; + if (beforeCursor !== null && !stream.hasOlder) return; + const input = getEventsInputSchema.parse({ + sessionId, + ...(beforeCursor === null ? {} : { beforeCursor }), + limit: fetchOptions?.limit ?? DEFAULT_HISTORY_WINDOW, + }); + const value = await api.getEvents(input); + const window = eventsWindowSchema.parse(value); + if (window.sessionId !== sessionId || window.threadId !== null) { + throw new Error("session history response identity mismatch"); + } + // Re-read after the await: live events may have appended while the + // request was in flight, and replacing then would erase them. A + // stream that vanished mid-flight (session removed) stays gone. + const current = store.getState().streamsBySessionId[sessionId]; + if (!current && !wantsSession(sessionId)) return; + applyWindow( + sessionId, + window, + (current?.eventIds.length ?? 0) === 0 ? "replace" : "prepend", + ); + }, + + registerToolResolver(descriptor) { + const parsedDescriptor = toolResolverDescriptorSchema.parse(descriptor); + const key = `${parsedDescriptor.name}${parsedDescriptor.version}`; + const existing = resolverCounts.get(key); + if (existing) { + existing.count += 1; + } else { + resolverCounts.set(key, { descriptor: parsedDescriptor, count: 1 }); + if (isConnected()) { + send({ + type: "toolResolversChanged", + requestId: nextRequestId(), + toolResolvers: activeToolResolvers(), + }); + } + } + let released = false; + return () => { + if (released) return; + released = true; + const current = resolverCounts.get(key); + if (!current) return; + current.count -= 1; + if (current.count > 0) return; + resolverCounts.delete(key); + if (isConnected()) { + send({ + type: "toolResolversChanged", + requestId: nextRequestId(), + toolResolvers: activeToolResolvers(), + }); + } + }; + }, + + async resolveToolCall(input): Promise { + toolCallIdSchema.parse(input.toolCallId); + const toolCall = store.getState().clientToolCallsById[input.toolCallId]; + if (!toolCall) { + throw new Error(`tool call not available: ${input.toolCallId}`); + } + const request = resolveToolCallInputSchema.parse({ + requestId: nextRequestId(), + sessionId: toolCall.sessionId, + toolCallId: input.toolCallId, + outcome: input.outcome, + }); + await api.resolveToolCall(request); + }, + }; +} diff --git a/packages/host-service-sync/src/client/createSessionsSyncClient/index.ts b/packages/host-service-sync/src/client/createSessionsSyncClient/index.ts new file mode 100644 index 00000000000..07877a52e15 --- /dev/null +++ b/packages/host-service-sync/src/client/createSessionsSyncClient/index.ts @@ -0,0 +1 @@ +export * from "./createSessionsSyncClient"; diff --git a/packages/host-service-sync/src/client/createSessionsSyncClient/state.ts b/packages/host-service-sync/src/client/createSessionsSyncClient/state.ts new file mode 100644 index 00000000000..bb79f5d208d --- /dev/null +++ b/packages/host-service-sync/src/client/createSessionsSyncClient/state.ts @@ -0,0 +1,584 @@ +import type { + HostEventPacket, + HostSnapshot, + Session, + SessionEvent, + SessionId, + SessionSnapshot, +} from "../../protocol"; +import { + DEFAULT_SESSIONS_SYNC_LIMITS, + type SessionStreamState, + type SessionsSyncLimits, + type SessionsSyncLogEvent, + type SessionsSyncState, +} from "../types"; + +export function createEmptyStream(now: number): SessionStreamState { + return { + status: "idle", + latestCursor: null, + oldestCursor: null, + hasOlder: false, + eventIds: [], + eventsById: {}, + estimatedEventBytes: 0, + retainCount: 0, + retention: "none", + lastAccessedAt: now, + error: null, + }; +} + +export function createInitialState(): SessionsSyncState { + return { + connection: { + status: "disconnected", + hostId: null, + connectionId: null, + error: null, + }, + hostSubscription: { status: "idle", latestCursor: null }, + sessionsById: {}, + sessionOrder: [], + threadsById: {}, + pendingPermissionsById: {}, + clientToolCallsById: {}, + streamsBySessionId: {}, + totalEstimatedEventBytes: 0, + }; +} + +export function resolveLimits( + overrides: Partial | undefined, +): SessionsSyncLimits { + const limits = { ...DEFAULT_SESSIONS_SYNC_LIMITS, ...overrides }; + for (const [name, value] of Object.entries(limits)) { + const permitsZero = + name === "maxWarmSubscriptions" || name === "warmSubscriptionTtlMs"; + if ( + !Number.isSafeInteger(value) || + (permitsZero ? value < 0 : value <= 0) + ) { + throw new Error(`invalid sessions sync limit ${name}: ${value}`); + } + } + return limits; +} + +function estimateEventBytes(event: SessionEvent): number { + // A conservative synchronous approximation that works in browser, React + // Native, Bun, and Node without requiring Buffer or TextEncoder polyfills. + return JSON.stringify(event).length * 2; +} + +function sessionOrder(sessionsById: Record): SessionId[] { + return Object.values(sessionsById) + .sort((left, right) => { + if (right.lastActivityAt !== left.lastActivityAt) { + return right.lastActivityAt - left.lastActivityAt; + } + return left.id.localeCompare(right.id); + }) + .map((session) => session.id); +} + +function withoutKey( + record: Record, + key: string, +): Record { + if (!(key in record)) return record; + const next = { ...record }; + delete next[key]; + return next; +} + +function sessionScopedRecord( + items: T[], +): Record { + return Object.fromEntries(items.map((item) => [item.id, item])); +} + +/** + * Folds one session event into session-scoped derived state. This never + * mutates the Session entity itself: the host stream (tRPC host snapshot + + * one sessionUpsert per fold) is the sole entity authority, and a seeded or + * replayed history window can be older than the snapshot entity — folding + * settings/error/updatedAt from it would regress a fresher entity. + */ +function applySessionProjection( + state: SessionsSyncState, + event: SessionEvent, +): Pick< + SessionsSyncState, + "threadsById" | "pendingPermissionsById" | "clientToolCallsById" +> { + let threadsById = state.threadsById; + let pendingPermissionsById = state.pendingPermissionsById; + let clientToolCallsById = state.clientToolCallsById; + const payload = event.payload; + + if (payload.type === "threadCreated" || payload.type === "threadUpdated") { + threadsById = { ...threadsById, [payload.thread.id]: payload.thread }; + } else if (payload.type === "permissionRequested") { + pendingPermissionsById = { + ...pendingPermissionsById, + [payload.permission.id]: payload.permission, + }; + } else if (payload.type === "permissionResolved") { + pendingPermissionsById = withoutKey( + pendingPermissionsById, + payload.permissionId, + ); + } else if (payload.type === "toolCallStarted") { + if (payload.toolCall.resolver.type === "client") { + clientToolCallsById = { + ...clientToolCallsById, + [payload.toolCall.id]: payload.toolCall, + }; + } + } else if (payload.type === "toolCallUpdated") { + const existing = clientToolCallsById[payload.toolCallId]; + if (existing) { + const stateUpdate = payload.update.state; + if ( + stateUpdate === "succeeded" || + stateUpdate === "failed" || + stateUpdate === "cancelled" + ) { + clientToolCallsById = withoutKey( + clientToolCallsById, + payload.toolCallId, + ); + } else { + clientToolCallsById = { + ...clientToolCallsById, + [payload.toolCallId]: { + ...existing, + ...(payload.update.title === undefined + ? {} + : { title: payload.update.title }), + ...(payload.update.input === undefined + ? {} + : { input: payload.update.input }), + ...(stateUpdate === undefined ? {} : { state: stateUpdate }), + updatedAt: payload.update.updatedAt, + }, + }; + } + } + } + + return { + threadsById, + pendingPermissionsById, + clientToolCallsById, + }; +} + +interface EventMergeResult { + state: SessionsSyncState; + logs: SessionsSyncLogEvent[]; +} + +export function mergeSessionEvents(options: { + state: SessionsSyncState; + sessionId: SessionId; + events: SessionEvent[]; + position: "append" | "prepend" | "replace"; + latestCursor?: string; + hasMoreBefore?: boolean; + now: number; + limits: SessionsSyncLimits; +}): EventMergeResult { + const { + state, + sessionId, + events, + position, + latestCursor, + hasMoreBefore, + now, + limits, + } = options; + const previous = + state.streamsBySessionId[sessionId] ?? createEmptyStream(now); + let eventIds = position === "replace" ? [] : [...previous.eventIds]; + const eventsById = position === "replace" ? {} : { ...previous.eventsById }; + let estimatedEventBytes = + position === "replace" ? 0 : previous.estimatedEventBytes; + const uniqueEvents: SessionEvent[] = []; + + for (const event of events) { + if (eventsById[event.id]) continue; + eventsById[event.id] = event; + estimatedEventBytes += estimateEventBytes(event); + uniqueEvents.push(event); + } + + const incomingIds = uniqueEvents.map((event) => event.id); + eventIds = + position === "prepend" + ? [...incomingIds, ...eventIds] + : [...eventIds, ...incomingIds]; + + let totalEstimatedEventBytes = + state.totalEstimatedEventBytes - + previous.estimatedEventBytes + + estimatedEventBytes; + const logs: SessionsSyncLogEvent[] = []; + let evictedOldEvents = false; + + function dropOldest( + reason: Extract< + SessionsSyncLogEvent, + { event: "sessions_sync.cache_evicted" } + >["reason"], + ): void { + let dropped = 0; + let freed = 0; + while ( + eventIds.length > 0 && + (eventIds.length > limits.maxEventsPerSession || + estimatedEventBytes > limits.maxEstimatedBytesPerSession) + ) { + const eventId = eventIds.shift(); + if (!eventId) break; + const event = eventsById[eventId]; + if (!event) continue; + const size = estimateEventBytes(event); + freed += size; + dropped += 1; + estimatedEventBytes -= size; + delete eventsById[eventId]; + } + if (dropped > 0) { + evictedOldEvents = true; + totalEstimatedEventBytes -= freed; + const oldest = eventIds[0]; + logs.push({ + event: "sessions_sync.cache_evicted", + sessionId, + reason, + eventsDropped: dropped, + estimatedBytesFreed: freed, + oldestRetainedCursor: oldest + ? (eventsById[oldest]?.cursor ?? null) + : null, + totalEstimatedBytesAfter: totalEstimatedEventBytes, + }); + } + } + + if (eventIds.length > limits.maxEventsPerSession) { + dropOldest("session_event_limit"); + } + if (estimatedEventBytes > limits.maxEstimatedBytesPerSession) { + dropOldest("session_byte_limit"); + } + + // A prepended history page must never advance the live cursor: its head + // is the server's head, and adopting it while behind would skip the gap + // between the last received live event and that head on resubscribe. + const nextLatestCursor = + position === "prepend" + ? (previous.latestCursor ?? latestCursor ?? null) + : (latestCursor ?? uniqueEvents.at(-1)?.cursor ?? previous.latestCursor); + + let nextState: SessionsSyncState = { + ...state, + streamsBySessionId: { + ...state.streamsBySessionId, + [sessionId]: { + ...previous, + latestCursor: nextLatestCursor, + oldestCursor: eventIds[0] + ? (eventsById[eventIds[0]]?.cursor ?? previous.oldestCursor) + : previous.oldestCursor, + hasOlder: + evictedOldEvents || + (hasMoreBefore === undefined ? previous.hasOlder : hasMoreBefore), + eventIds, + eventsById, + estimatedEventBytes, + lastAccessedAt: now, + }, + }, + totalEstimatedEventBytes, + }; + + // Fold derived projections from appended live events and replace-seeded + // windows only. Prepended history is log, not current state: an older + // page can contain a permissionRequested whose resolve lies in a page + // never loaded, and folding it would resurrect a settled card. + if (position !== "prepend") { + for (const event of uniqueEvents) { + nextState = { ...nextState, ...applySessionProjection(nextState, event) }; + } + } + + const totalEvictions = new Map< + SessionId, + { eventsDropped: number; estimatedBytesFreed: number } + >(); + while ( + nextState.totalEstimatedEventBytes > limits.maxTotalEstimatedEventBytes + ) { + const candidates = Object.entries(nextState.streamsBySessionId) + .filter(([, stream]) => stream.eventIds.length > 0) + .sort(([, left], [, right]) => { + const leftWeight = + left.retention === "none" ? 0 : left.retention === "warm" ? 1 : 2; + const rightWeight = + right.retention === "none" ? 0 : right.retention === "warm" ? 1 : 2; + return ( + leftWeight - rightWeight || left.lastAccessedAt - right.lastAccessedAt + ); + }); + const candidate = candidates[0]; + if (!candidate) break; + const [candidateSessionId, candidateStream] = candidate; + const firstId = candidateStream.eventIds[0]; + if (!firstId) break; + const firstEvent = candidateStream.eventsById[firstId]; + if (!firstEvent) break; + const size = estimateEventBytes(firstEvent); + const candidateEvents = { ...candidateStream.eventsById }; + delete candidateEvents[firstId]; + const candidateIds = candidateStream.eventIds.slice(1); + const nextOldestCursor = candidateIds[0] + ? (candidateEvents[candidateIds[0]]?.cursor ?? null) + : null; + nextState = { + ...nextState, + streamsBySessionId: { + ...nextState.streamsBySessionId, + [candidateSessionId]: { + ...candidateStream, + eventIds: candidateIds, + eventsById: candidateEvents, + estimatedEventBytes: candidateStream.estimatedEventBytes - size, + hasOlder: true, + oldestCursor: nextOldestCursor, + }, + }, + totalEstimatedEventBytes: nextState.totalEstimatedEventBytes - size, + }; + const aggregate = totalEvictions.get(candidateSessionId) ?? { + eventsDropped: 0, + estimatedBytesFreed: 0, + }; + aggregate.eventsDropped += 1; + aggregate.estimatedBytesFreed += size; + totalEvictions.set(candidateSessionId, aggregate); + } + for (const [evictedSessionId, aggregate] of totalEvictions) { + logs.push({ + event: "sessions_sync.cache_evicted", + sessionId: evictedSessionId, + reason: "total_byte_limit", + eventsDropped: aggregate.eventsDropped, + estimatedBytesFreed: aggregate.estimatedBytesFreed, + oldestRetainedCursor: + nextState.streamsBySessionId[evictedSessionId]?.oldestCursor ?? null, + totalEstimatedBytesAfter: nextState.totalEstimatedEventBytes, + }); + } + + return { state: nextState, logs }; +} + +/** + * Folds the tRPC `sessions.list` response — the host snapshot. Prunes + * cached state for sessions the host no longer lists; overlap with host + * events replayed after the snapshot is harmless because host events are + * idempotent upserts/removals keyed by entity id. + */ +export function applyHostSnapshot( + state: SessionsSyncState, + snapshot: HostSnapshot, +): SessionsSyncState { + const sessionsById = Object.fromEntries( + snapshot.sessions.map((session) => [session.id, session]), + ); + const sessionIds = new Set(Object.keys(sessionsById)); + const threadsById = Object.fromEntries( + Object.entries(state.threadsById).filter(([, thread]) => + sessionIds.has(thread.sessionId), + ), + ); + const streamsBySessionId = Object.fromEntries( + Object.entries(state.streamsBySessionId).filter(([sessionId]) => + sessionIds.has(sessionId), + ), + ); + const totalEstimatedEventBytes = Object.values(streamsBySessionId).reduce( + (total, stream) => total + stream.estimatedEventBytes, + 0, + ); + return { + ...state, + hostSubscription: { + status: snapshot.head === null ? "idle" : "subscribing", + latestCursor: snapshot.head, + }, + sessionsById, + sessionOrder: sessionOrder(sessionsById), + threadsById, + pendingPermissionsById: sessionScopedRecord(snapshot.pendingPermissions), + clientToolCallsById: sessionScopedRecord(snapshot.openClientToolCalls), + streamsBySessionId, + totalEstimatedEventBytes, + }; +} + +export function applyHostEvent( + state: SessionsSyncState, + packet: HostEventPacket, +): SessionsSyncState { + const event = packet.event; + let sessionsById = state.sessionsById; + let threadsById = state.threadsById; + let pendingPermissionsById = state.pendingPermissionsById; + let clientToolCallsById = state.clientToolCallsById; + let streamsBySessionId = state.streamsBySessionId; + let totalEstimatedEventBytes = state.totalEstimatedEventBytes; + + if (event.type === "sessionUpsert") { + sessionsById = { + ...sessionsById, + [event.session.id]: event.session, + }; + } else if (event.type === "sessionRemoved") { + sessionsById = withoutKey(sessionsById, packet.sessionId); + threadsById = Object.fromEntries( + Object.entries(threadsById).filter( + ([, thread]) => thread.sessionId !== packet.sessionId, + ), + ); + pendingPermissionsById = Object.fromEntries( + Object.entries(pendingPermissionsById).filter( + ([, permission]) => permission.sessionId !== packet.sessionId, + ), + ); + clientToolCallsById = Object.fromEntries( + Object.entries(clientToolCallsById).filter( + ([, toolCall]) => toolCall.sessionId !== packet.sessionId, + ), + ); + const stream = streamsBySessionId[packet.sessionId]; + if (stream) { + totalEstimatedEventBytes -= stream.estimatedEventBytes; + streamsBySessionId = withoutKey(streamsBySessionId, packet.sessionId); + } + } else if (event.type === "permissionAvailable") { + pendingPermissionsById = { + ...pendingPermissionsById, + [event.permission.id]: event.permission, + }; + } else if (event.type === "permissionResolved") { + pendingPermissionsById = withoutKey( + pendingPermissionsById, + event.permissionId, + ); + } else if (event.type === "clientToolCallAvailable") { + clientToolCallsById = { + ...clientToolCallsById, + [event.toolCall.id]: event.toolCall, + }; + } else if (event.type === "clientToolCallResolved") { + clientToolCallsById = withoutKey(clientToolCallsById, event.toolCallId); + } + + return { + ...state, + hostSubscription: { + ...state.hostSubscription, + latestCursor: packet.cursor, + }, + sessionsById, + sessionOrder: sessionOrder(sessionsById), + threadsById, + pendingPermissionsById, + clientToolCallsById, + streamsBySessionId, + totalEstimatedEventBytes, + }; +} + +/** + * Folds the tRPC `sessions.get` response — the session snapshot. The recent + * tail replaces the cached window entirely: the cache may end anywhere + * behind the snapshot head, and keeping it would splice a hidden gap + * between stale events and the live stream that follows. + */ +export function applySessionSnapshot( + state: SessionsSyncState, + snapshot: SessionSnapshot, + now: number, +): SessionsSyncState { + const sessionId = snapshot.session.id; + const sessionsById = { + ...state.sessionsById, + [sessionId]: snapshot.session, + }; + const threadsById = Object.fromEntries([ + ...Object.entries(state.threadsById).filter( + ([, thread]) => thread.sessionId !== sessionId, + ), + ...snapshot.threads.map((thread) => [thread.id, thread] as const), + ]); + const pendingPermissionsById = Object.fromEntries([ + ...Object.entries(state.pendingPermissionsById).filter( + ([, permission]) => permission.sessionId !== sessionId, + ), + ...snapshot.pendingPermissions.map( + (permission) => [permission.id, permission] as const, + ), + ]); + const clientToolCallsById = Object.fromEntries([ + ...Object.entries(state.clientToolCallsById).filter( + ([, toolCall]) => toolCall.sessionId !== sessionId, + ), + ...snapshot.openToolCalls.map( + (toolCall) => [toolCall.id, toolCall] as const, + ), + ]); + const existing = + state.streamsBySessionId[sessionId] ?? createEmptyStream(now); + const eventsById = Object.fromEntries( + snapshot.recentEvents.map((event) => [event.id, event] as const), + ); + const eventIds = snapshot.recentEvents.map((event) => event.id); + const estimatedEventBytes = snapshot.recentEvents.reduce( + (total, event) => total + estimateEventBytes(event), + 0, + ); + return { + ...state, + sessionsById, + sessionOrder: sessionOrder(sessionsById), + threadsById, + pendingPermissionsById, + clientToolCallsById, + streamsBySessionId: { + ...state.streamsBySessionId, + [sessionId]: { + ...existing, + status: "subscribing", + latestCursor: snapshot.head, + eventIds, + eventsById, + estimatedEventBytes, + oldestCursor: snapshot.recentEvents[0]?.cursor ?? null, + hasOlder: snapshot.hasOlderEvents, + lastAccessedAt: now, + error: null, + }, + }, + totalEstimatedEventBytes: + state.totalEstimatedEventBytes - + existing.estimatedEventBytes + + estimatedEventBytes, + }; +} diff --git a/packages/host-service-sync/src/client/index.ts b/packages/host-service-sync/src/client/index.ts new file mode 100644 index 00000000000..0a56465d72f --- /dev/null +++ b/packages/host-service-sync/src/client/index.ts @@ -0,0 +1,2 @@ +export * from "./createSessionsSyncClient"; +export * from "./types"; diff --git a/packages/host-service-sync/src/client/types.ts b/packages/host-service-sync/src/client/types.ts new file mode 100644 index 00000000000..8dec440ec61 --- /dev/null +++ b/packages/host-service-sync/src/client/types.ts @@ -0,0 +1,222 @@ +import type { + EventsWindow, + GetEventsInput, + GetSessionInput, + HostSnapshot, + PermissionRequest, + ResolveToolCallInput, + Session, + SessionEvent, + SessionId, + SessionSnapshot, + Thread, + ThreadId, + ToolCall, + ToolCallId, + ToolResolverDescriptor, +} from "../protocol"; + +export interface WebSocketLike { + readonly readyState: number; + readonly bufferedAmount?: number; + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: + | ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) + | null; + onerror: ((event: unknown) => void) | null; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +export type ConnectionStatus = + | "disconnected" + | "connecting" + | "connected" + | "reconnecting"; + +export type SessionStreamStatus = + | "idle" + | "subscribing" + | "replaying" + | "live" + | "reset" + | "error"; + +export type SessionRetention = "focused" | "running" | "warm" | "none"; + +export interface SessionStreamState { + status: SessionStreamStatus; + latestCursor: string | null; + oldestCursor: string | null; + hasOlder: boolean; + eventIds: string[]; + eventsById: Record; + estimatedEventBytes: number; + retainCount: number; + retention: SessionRetention; + lastAccessedAt: number; + error: { code: string; retryable: boolean } | null; +} + +export interface SessionsSyncState { + connection: { + status: ConnectionStatus; + hostId: string | null; + connectionId: string | null; + error: { code: string; retryable: boolean } | null; + }; + hostSubscription: { + status: SessionStreamStatus; + latestCursor: string | null; + }; + sessionsById: Record; + sessionOrder: SessionId[]; + threadsById: Record; + pendingPermissionsById: Record; + clientToolCallsById: Record; + streamsBySessionId: Record; + totalEstimatedEventBytes: number; +} + +export interface SessionsSyncLimits { + maxWarmSubscriptions: number; + warmSubscriptionTtlMs: number; + maxEventsPerSession: number; + maxEstimatedBytesPerSession: number; + maxTotalEstimatedEventBytes: number; + maxSocketQueuedBytes: number; + maxFrameBytes: number; + /** + * Reset circuit breaker: the `maxStreamResetsPerWindow`-th reset on one + * stream inside `streamResetWindowMs` stops auto-recovery and parks the + * stream in `error` — a reset loop is a host bug, never something a + * client can subscribe its way out of. + */ + maxStreamResetsPerWindow: number; + streamResetWindowMs: number; + /** + * Liveness: a half-open socket answers nothing and closes nothing, so an + * unanswered ping is the only proof the connection is gone. + */ + heartbeatIntervalMs: number; + heartbeatTimeoutMs: number; + helloTimeoutMs: number; +} + +export const DEFAULT_SESSIONS_SYNC_LIMITS: SessionsSyncLimits = { + maxWarmSubscriptions: 10, + warmSubscriptionTtlMs: 30 * 60 * 1_000, + maxEventsPerSession: 10_000, + maxEstimatedBytesPerSession: 32 * 1024 * 1024, + maxTotalEstimatedEventBytes: 128 * 1024 * 1024, + maxSocketQueuedBytes: 8 * 1024 * 1024, + maxFrameBytes: 1024 * 1024, + maxStreamResetsPerWindow: 3, + streamResetWindowMs: 30_000, + heartbeatIntervalMs: 30_000, + heartbeatTimeoutMs: 10_000, + helloTimeoutMs: 15_000, +}; + +export type SessionsSyncLogEvent = + | { + event: "sessions_sync.cache_evicted"; + sessionId: SessionId; + reason: "session_event_limit" | "session_byte_limit" | "total_byte_limit"; + eventsDropped: number; + estimatedBytesFreed: number; + oldestRetainedCursor: string | null; + totalEstimatedBytesAfter: number; + } + | { + event: "sessions_sync.subscription_disposed"; + sessionId: SessionId; + reason: "warm_lru_eviction" | "warm_ttl" | "client_disconnect"; + warmRank: number | null; + latestCursor: string | null; + } + | { + event: "sessions_sync.socket_dropped"; + reason: "invalid_frame" | "frame_size_limit" | "send_buffer_limit"; + queuedBytes: number | null; + limitBytes: number; + } + | { + event: "sessions_sync.stream_reset"; + sessionId: SessionId | null; + code: string; + recovery: string; + } + | { + // A schema-valid session-scoped packet for a session with no + // subscription sent on this socket: dropped, never folded. + event: "sessions_sync.stale_packet_dropped"; + sessionId: SessionId; + packetType: string; + } + | { + event: "sessions_sync.connect_failed"; + reason: "socket_factory_threw" | "sync_url_failed"; + } + | { + // The reset circuit breaker tripped: the stream is parked in `error` + // and will not auto-resubscribe until the client reconnects. + event: "sessions_sync.reset_loop"; + sessionId: SessionId | null; + resets: number; + windowMs: number; + }; + +export interface SessionsSyncLogger { + log(event: SessionsSyncLogEvent): void; +} + +/** + * The injected typed `sessions.*` facade. Each method mirrors one tRPC + * procedure and takes that procedure's canonical input object. Snapshots + * ride here — `list` and `get` are the cold path the socket resumes from. + */ +export interface SessionsSyncApi { + list(): Promise; + get(input: GetSessionInput): Promise; + getEvents(input: GetEventsInput): Promise; + resolveToolCall(input: ResolveToolCallInput): Promise; +} + +export interface CreateSessionsSyncClientOptions { + clientInstanceId: string; + clientVersion: string; + syncUrl: string | (() => string | Promise); + /** Required: snapshots ride tRPC, so the client cannot cold-start without it. */ + api: SessionsSyncApi; + createWebSocket?: (url: string, protocols: string[]) => WebSocketLike; + logger?: SessionsSyncLogger; + limits?: Partial; + reconnectDelayMs?: number; + now?: () => number; +} + +export interface SessionsSyncClient { + readonly store: import("zustand/vanilla").StoreApi; + connect(): void; + disconnect(): void; + retainSession( + sessionId: SessionId, + reason: Exclude, + ): () => void; + fetchOlderEvents( + sessionId: SessionId, + options?: { limit?: number }, + ): Promise; + registerToolResolver(descriptor: ToolResolverDescriptor): () => void; + /** + * Answer a client tool call (`ui.ask_user`-style). No claim step: the + * card renders on every capable device and the first resolve to reach + * the host wins; later resolves reject with the host's stale error. + */ + resolveToolCall(input: { + toolCallId: ToolCallId; + outcome: ResolveToolCallInput["outcome"]; + }): Promise; +} diff --git a/packages/host-service-sync/src/index.ts b/packages/host-service-sync/src/index.ts new file mode 100644 index 00000000000..1324bda3839 --- /dev/null +++ b/packages/host-service-sync/src/index.ts @@ -0,0 +1,4 @@ +export * from "./client"; +export * from "./projection"; +export * from "./protocol"; +export * from "./timeline"; diff --git a/packages/host-service-sync/src/package-boundaries.test.ts b/packages/host-service-sync/src/package-boundaries.test.ts new file mode 100644 index 00000000000..b586b83cc6b --- /dev/null +++ b/packages/host-service-sync/src/package-boundaries.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; + +const packageRoot = resolve(import.meta.dir, ".."); + +describe("host-service-sync package boundary", () => { + test("has no React or platform runtime dependency", async () => { + const packageJson = (await Bun.file( + resolve(packageRoot, "package.json"), + ).json()) as { + dependencies?: Record; + peerDependencies?: Record; + }; + const runtimeNames = [ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.peerDependencies ?? {}), + ].sort(); + expect(runtimeNames).toEqual(["zod", "zustand"]); + }); + + test("source imports no React, Expo, Node runtime, host implementation, or ACP SDK", async () => { + const prohibited = [ + /\bfrom\s+["']react(?:\/|["'])/, + /\bfrom\s+["']react-native(?:\/|["'])/, + /\bfrom\s+["']expo(?:\/|["'])/, + /\bfrom\s+["']node:/, + /\bfrom\s+["']@superset\/host-service(?:\/|["'])/, + /\bfrom\s+["']@agentclientprotocol\//, + ]; + const violations: string[] = []; + const glob = new Bun.Glob("src/**/*.ts"); + for await (const relativePath of glob.scan({ cwd: packageRoot })) { + if (relativePath.endsWith(".test.ts")) continue; + const source = await Bun.file(resolve(packageRoot, relativePath)).text(); + if (prohibited.some((pattern) => pattern.test(source))) { + violations.push(relativePath); + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/host-service-sync/src/projection/fixtures/cards-and-settings.json b/packages/host-service-sync/src/projection/fixtures/cards-and-settings.json new file mode 100644 index 00000000000..e2e0034713b --- /dev/null +++ b/packages/host-service-sync/src/projection/fixtures/cards-and-settings.json @@ -0,0 +1,404 @@ +{ + "name": "cards-and-settings", + "description": "A snapshot-seeded running session folds permission and client-tool cards, settings, plan, and a cancelled turn.", + "inputs": [ + { + "type": "snapshot", + "cursor": "cursor-10", + "value": { + "sessionId": "session-1", + "cursor": "cursor-10", + "session": { + "id": "session-1", + "workspaceId": "workspace-1", + "title": "Storage migration", + "mainThreadId": "thread-main", + "agent": { + "id": "claude-code", + "displayName": "Claude Code" + }, + "runState": "running", + "capabilities": { + "threadModel": "nested", + "threadFidelity": "partial", + "canResume": true, + "supportsPermissions": true, + "supportsModes": true, + "supportsModels": true + }, + "settings": { + "activeModel": "anthropic/claude-sonnet-4-6", + "activeMode": "default", + "effort": "low", + "configuration": {} + }, + "eventHead": "cursor-10", + "createdAt": 1783772200000, + "updatedAt": 1783772200010, + "lastActivityAt": 1783772200010, + "archivedAt": null, + "closedAt": null, + "error": null + }, + "threadsById": { + "thread-main": { + "id": "thread-main", + "sessionId": "session-1", + "kind": "main", + "parentThreadId": null, + "origin": { + "type": "sessionCreated" + }, + "fidelity": "full", + "title": null, + "runState": "running", + "eventHead": "cursor-9", + "createdAt": 1783772200000, + "updatedAt": 1783772200009, + "lastActivityAt": 1783772200009 + } + }, + "activeTurnsById": { + "turn-9": { + "id": "turn-9", + "sessionId": "session-1", + "threadId": "thread-main", + "status": "running", + "originatingClientInstanceId": null, + "createdAt": 1783772200009, + "updatedAt": 1783772200009 + } + }, + "pendingPermissionsById": {}, + "activeToolCallsById": { + "tool-9": { + "id": "tool-9", + "sessionId": "session-1", + "threadId": "thread-main", + "turnId": "turn-9", + "parentToolCallId": null, + "tool": { + "name": "fs.write", + "version": 1 + }, + "title": "Write config.json", + "input": { + "path": "config.json" + }, + "resolver": { + "type": "host" + }, + "state": "awaitingPermission", + "createdAt": 1783772200009, + "updatedAt": 1783772200010, + "expiresAt": null + } + }, + "plan": [] + } + }, + { + "type": "event", + "cursor": "cursor-11", + "value": { + "id": "event-11", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-11", + "occurredAt": 1783772200011, + "causationId": null, + "payload": { + "type": "permissionRequested", + "permission": { + "id": "permission-1", + "sessionId": "session-1", + "threadId": "thread-main", + "toolCallId": "tool-9", + "options": [ + { + "id": "allow_once", + "name": "Allow once", + "kind": "allowOnce" + }, + { + "id": "reject_once", + "name": "Reject", + "kind": "rejectOnce" + } + ], + "multiSelect": false, + "requestedAt": 1783772200011 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-12", + "value": { + "id": "event-12", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-12", + "occurredAt": 1783772200012, + "causationId": null, + "payload": { + "type": "toolCallStarted", + "toolCall": { + "id": "tool-11", + "sessionId": "session-1", + "threadId": "thread-main", + "turnId": "turn-9", + "parentToolCallId": null, + "tool": { + "name": "ui.ask_user", + "version": 1 + }, + "title": "Choose storage", + "input": { + "question": "Which store?" + }, + "resolver": { + "type": "client", + "capability": "ui.ask_user", + "routing": "anyCapableClient" + }, + "state": "available", + "createdAt": 1783772200012, + "updatedAt": 1783772200012, + "expiresAt": null + } + } + } + }, + { + "type": "event", + "cursor": "cursor-13", + "value": { + "id": "event-13", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-13", + "occurredAt": 1783772200013, + "causationId": "request-permission-1", + "payload": { + "type": "permissionResolved", + "permissionId": "permission-1", + "outcome": { + "type": "selected", + "optionIds": ["allow_once"] + } + } + } + }, + { + "type": "event", + "cursor": "cursor-14", + "value": { + "id": "event-14", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-14", + "occurredAt": 1783772200014, + "causationId": null, + "payload": { + "type": "toolCallUpdated", + "toolCallId": "tool-9", + "update": { + "state": "running", + "updatedAt": 1783772200014 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-15", + "value": { + "id": "event-15", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-15", + "occurredAt": 1783772200015, + "causationId": "request-claim-1", + "payload": { + "type": "toolCallUpdated", + "toolCallId": "tool-11", + "update": { + "state": "available", + "updatedAt": 1783772200015 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-16", + "value": { + "id": "event-16", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-16", + "occurredAt": 1783772200016, + "causationId": "request-resolve-1", + "payload": { + "type": "toolCallUpdated", + "toolCallId": "tool-11", + "update": { + "state": "succeeded", + "updatedAt": 1783772200016 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-17", + "value": { + "id": "event-17", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-17", + "occurredAt": 1783772200017, + "causationId": null, + "payload": { + "type": "toolCallUpdated", + "toolCallId": "tool-9", + "update": { + "state": "succeeded", + "updatedAt": 1783772200017 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-18", + "value": { + "id": "event-18", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-18", + "occurredAt": 1783772200018, + "causationId": "request-settings-1", + "payload": { + "type": "settingsUpdated", + "settings": { + "activeModel": "anthropic/claude-opus-4-8", + "activeMode": "acceptEdits", + "effort": "high", + "configuration": { + "sandbox": true + } + } + } + } + }, + { + "type": "event", + "cursor": "cursor-19", + "value": { + "id": "event-19", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-19", + "occurredAt": 1783772200019, + "causationId": null, + "payload": { + "type": "planUpdated", + "plan": [ + { + "id": "step-1", + "content": "Ship the sync server", + "status": "inProgress", + "priority": "high" + } + ] + } + } + }, + { + "type": "event", + "cursor": "cursor-20", + "value": { + "id": "event-20", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-20", + "occurredAt": 1783772200020, + "causationId": "request-cancel-1", + "payload": { + "type": "turnCancelled", + "turnId": "turn-9" + } + } + } + ], + "expected": { + "sessionId": "session-1", + "cursor": "cursor-20", + "session": { + "id": "session-1", + "workspaceId": "workspace-1", + "title": "Storage migration", + "mainThreadId": "thread-main", + "agent": { + "id": "claude-code", + "displayName": "Claude Code" + }, + "runState": "idle", + "capabilities": { + "threadModel": "nested", + "threadFidelity": "partial", + "canResume": true, + "supportsPermissions": true, + "supportsModes": true, + "supportsModels": true + }, + "settings": { + "activeModel": "anthropic/claude-opus-4-8", + "activeMode": "acceptEdits", + "effort": "high", + "configuration": { + "sandbox": true + } + }, + "eventHead": "cursor-20", + "createdAt": 1783772200000, + "updatedAt": 1783772200020, + "lastActivityAt": 1783772200020, + "archivedAt": null, + "closedAt": null, + "error": null + }, + "threadsById": { + "thread-main": { + "id": "thread-main", + "sessionId": "session-1", + "kind": "main", + "parentThreadId": null, + "origin": { + "type": "sessionCreated" + }, + "fidelity": "full", + "title": null, + "runState": "cancelled", + "eventHead": "cursor-20", + "createdAt": 1783772200000, + "updatedAt": 1783772200020, + "lastActivityAt": 1783772200020 + } + }, + "activeTurnsById": {}, + "pendingPermissionsById": {}, + "activeToolCallsById": {}, + "plan": [ + { + "id": "step-1", + "content": "Ship the sync server", + "status": "inProgress", + "priority": "high" + } + ] + } +} diff --git a/packages/host-service-sync/src/projection/fixtures/turn-lifecycle.json b/packages/host-service-sync/src/projection/fixtures/turn-lifecycle.json new file mode 100644 index 00000000000..6fda487cf60 --- /dev/null +++ b/packages/host-service-sync/src/projection/fixtures/turn-lifecycle.json @@ -0,0 +1,284 @@ +{ + "name": "turn-lifecycle", + "description": "A fresh idle session runs one assistant turn with a host tool call to completion.", + "inputs": [ + { + "type": "snapshot", + "cursor": "cursor-0", + "value": { + "sessionId": "session-1", + "cursor": "cursor-0", + "session": { + "id": "session-1", + "workspaceId": "workspace-1", + "title": "Reconnect investigation", + "mainThreadId": "thread-main", + "agent": { + "id": "claude-code", + "displayName": "Claude Code" + }, + "runState": "idle", + "capabilities": { + "threadModel": "nested", + "threadFidelity": "partial", + "canResume": true, + "supportsPermissions": true, + "supportsModes": true, + "supportsModels": true + }, + "settings": { + "activeModel": "anthropic/claude-sonnet-4-6", + "activeMode": "default", + "effort": "low", + "configuration": {} + }, + "eventHead": "cursor-0", + "createdAt": 1783772200000, + "updatedAt": 1783772200000, + "lastActivityAt": 1783772200000, + "archivedAt": null, + "closedAt": null, + "error": null + }, + "threadsById": { + "thread-main": { + "id": "thread-main", + "sessionId": "session-1", + "kind": "main", + "parentThreadId": null, + "origin": { + "type": "sessionCreated" + }, + "fidelity": "full", + "title": null, + "runState": "idle", + "eventHead": null, + "createdAt": 1783772200000, + "updatedAt": 1783772200000, + "lastActivityAt": 1783772200000 + } + }, + "activeTurnsById": {}, + "pendingPermissionsById": {}, + "activeToolCallsById": {}, + "plan": [] + } + }, + { + "type": "event", + "cursor": "cursor-1", + "value": { + "id": "event-1", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-1", + "occurredAt": 1783772200001, + "causationId": "request-turn-1", + "payload": { + "type": "turnStarted", + "turn": { + "id": "turn-1", + "sessionId": "session-1", + "threadId": "thread-main", + "status": "running", + "originatingClientInstanceId": "client-1", + "createdAt": 1783772200001, + "updatedAt": 1783772200001 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-2", + "value": { + "id": "event-2", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-2", + "occurredAt": 1783772200002, + "causationId": null, + "payload": { + "type": "messageStarted", + "message": { + "id": "message-1", + "sessionId": "session-1", + "threadId": "thread-main", + "turnId": "turn-1", + "role": "assistant", + "content": [], + "createdAt": 1783772200002 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-3", + "value": { + "id": "event-3", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-3", + "occurredAt": 1783772200003, + "causationId": null, + "payload": { + "type": "toolCallStarted", + "toolCall": { + "id": "tool-1", + "sessionId": "session-1", + "threadId": "thread-main", + "turnId": "turn-1", + "parentToolCallId": null, + "tool": { + "name": "fs.read", + "version": 1 + }, + "title": "Read README.md", + "input": { + "path": "README.md" + }, + "resolver": { + "type": "host" + }, + "state": "running", + "createdAt": 1783772200003, + "updatedAt": 1783772200003, + "expiresAt": null + } + } + } + }, + { + "type": "event", + "cursor": "cursor-4", + "value": { + "id": "event-4", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-4", + "occurredAt": 1783772200004, + "causationId": null, + "payload": { + "type": "toolCallUpdated", + "toolCallId": "tool-1", + "update": { + "state": "succeeded", + "updatedAt": 1783772200004 + } + } + } + }, + { + "type": "event", + "cursor": "cursor-5", + "value": { + "id": "event-5", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-5", + "occurredAt": 1783772200005, + "causationId": null, + "payload": { + "type": "messageDelta", + "messageId": "message-1", + "content": { + "type": "text", + "text": "Done." + } + } + } + }, + { + "type": "event", + "cursor": "cursor-6", + "value": { + "id": "event-6", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-6", + "occurredAt": 1783772200006, + "causationId": null, + "payload": { + "type": "messageCompleted", + "messageId": "message-1" + } + } + }, + { + "type": "event", + "cursor": "cursor-7", + "value": { + "id": "event-7", + "sessionId": "session-1", + "threadId": "thread-main", + "cursor": "cursor-7", + "occurredAt": 1783772200007, + "causationId": null, + "payload": { + "type": "turnCompleted", + "turnId": "turn-1", + "stopReason": "endTurn" + } + } + } + ], + "expected": { + "sessionId": "session-1", + "cursor": "cursor-7", + "session": { + "id": "session-1", + "workspaceId": "workspace-1", + "title": "Reconnect investigation", + "mainThreadId": "thread-main", + "agent": { + "id": "claude-code", + "displayName": "Claude Code" + }, + "runState": "idle", + "capabilities": { + "threadModel": "nested", + "threadFidelity": "partial", + "canResume": true, + "supportsPermissions": true, + "supportsModes": true, + "supportsModels": true + }, + "settings": { + "activeModel": "anthropic/claude-sonnet-4-6", + "activeMode": "default", + "effort": "low", + "configuration": {} + }, + "eventHead": "cursor-7", + "createdAt": 1783772200000, + "updatedAt": 1783772200007, + "lastActivityAt": 1783772200007, + "archivedAt": null, + "closedAt": null, + "error": null + }, + "threadsById": { + "thread-main": { + "id": "thread-main", + "sessionId": "session-1", + "kind": "main", + "parentThreadId": null, + "origin": { + "type": "sessionCreated" + }, + "fidelity": "full", + "title": null, + "runState": "completed", + "eventHead": "cursor-7", + "createdAt": 1783772200000, + "updatedAt": 1783772200007, + "lastActivityAt": 1783772200007 + } + }, + "activeTurnsById": {}, + "pendingPermissionsById": {}, + "activeToolCallsById": {}, + "plan": [] + } +} diff --git a/packages/host-service-sync/src/projection/index.ts b/packages/host-service-sync/src/projection/index.ts new file mode 100644 index 00000000000..7b9d923ce23 --- /dev/null +++ b/packages/host-service-sync/src/projection/index.ts @@ -0,0 +1,9 @@ +export { + ProjectionError, + type ProjectionErrorCode, + type ProjectionInput, + projectionInputSchema, + type SessionProjection, + sessionProjectionSchema, +} from "./projection"; +export { reduceProjection } from "./reduceProjection"; diff --git a/packages/host-service-sync/src/projection/projection.ts b/packages/host-service-sync/src/projection/projection.ts new file mode 100644 index 00000000000..a4553c9617e --- /dev/null +++ b/packages/host-service-sync/src/projection/projection.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; +import { + cursorSchema, + permissionIdSchema, + permissionRequestSchema, + planEntrySchema, + sessionEventSchema, + sessionIdSchema, + sessionSchema, + threadIdSchema, + threadSchema, + toolCallIdSchema, + toolCallSchema, + turnIdSchema, + turnSchema, +} from "../protocol"; + +/** + * The authoritative derived state of one session: what a pure fold of the + * session event stream produces. The host maintains it as public authority; + * clients fold the same reducer over snapshots plus live events. + * + * The projection deliberately excludes transcript bodies: messages and tool + * outputs live in the durable event log, not here. + */ +export const sessionProjectionSchema = z + .object({ + sessionId: sessionIdSchema, + // The cursor of the last input folded into this projection. + cursor: cursorSchema, + session: sessionSchema, + threadsById: z.record(threadIdSchema, threadSchema), + // Turns that have not reached a terminal status yet. + activeTurnsById: z.record(turnIdSchema, turnSchema), + pendingPermissionsById: z.record( + permissionIdSchema, + permissionRequestSchema, + ), + // Tool calls that have not reached a terminal state yet, host- and + // client-resolved alike; consumers filter by resolver type. + activeToolCallsById: z.record(toolCallIdSchema, toolCallSchema), + // The latest planUpdated payload, empty until one arrives. + plan: z.array(planEntrySchema).max(10_000), + }) + .superRefine((projection, context) => { + if (projection.session.id !== projection.sessionId) { + context.addIssue({ + code: "custom", + path: ["session", "id"], + message: "session.id does not match projection sessionId", + }); + } + const mainThread = projection.threadsById[projection.session.mainThreadId]; + if (!mainThread || mainThread.kind !== "main") { + context.addIssue({ + code: "custom", + path: ["threadsById"], + message: "projection must contain the session main thread (kind main)", + }); + } + const collections = [ + ["threadsById", projection.threadsById, 10_000], + ["activeTurnsById", projection.activeTurnsById, 1_000], + ["pendingPermissionsById", projection.pendingPermissionsById, 1_000], + ["activeToolCallsById", projection.activeToolCallsById, 10_000], + ] as const; + for (const [name, record, cap] of collections) { + const entries = Object.entries(record); + if (entries.length > cap) { + context.addIssue({ + code: "custom", + path: [name], + message: `${name} exceeds ${cap} entries`, + }); + } + for (const [key, value] of entries) { + if (value.id !== key) { + context.addIssue({ + code: "custom", + path: [name, key, "id"], + message: "record key does not match entity id", + }); + } + if (value.sessionId !== projection.sessionId) { + context.addIssue({ + code: "custom", + path: [name, key, "sessionId"], + message: "entity sessionId does not match projection sessionId", + }); + } + } + } + for (const [turnId, turn] of Object.entries(projection.activeTurnsById)) { + if (turn.status !== "accepted" && turn.status !== "running") { + context.addIssue({ + code: "custom", + path: ["activeTurnsById", turnId, "status"], + message: "active turn must not be terminal", + }); + } + } + for (const [toolCallId, toolCall] of Object.entries( + projection.activeToolCallsById, + )) { + if ( + toolCall.state === "succeeded" || + toolCall.state === "failed" || + toolCall.state === "cancelled" + ) { + context.addIssue({ + code: "custom", + path: ["activeToolCallsById", toolCallId, "state"], + message: "active tool call must not be terminal", + }); + } + } + }); + +export const projectionInputSchema = z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("snapshot"), + cursor: cursorSchema, + value: sessionProjectionSchema, + }), + z.object({ + type: z.literal("event"), + cursor: cursorSchema, + value: sessionEventSchema, + }), + ]) + .superRefine((input, context) => { + if (input.value.cursor !== input.cursor) { + context.addIssue({ + code: "custom", + path: ["value", "cursor"], + message: "input cursor does not match nested value cursor", + }); + } + }); + +export type SessionProjection = z.infer; +export type ProjectionInput = z.infer; + +export type ProjectionErrorCode = + | "PROJECTION_NOT_INITIALIZED" + | "PROJECTION_SESSION_MISMATCH"; + +/** + * A programming error in reducer dispatch: inputs must be validated and + * routed to the projection of the session they belong to before folding. + */ +export class ProjectionError extends Error { + readonly code: ProjectionErrorCode; + + constructor(code: ProjectionErrorCode, message: string) { + super(message); + this.name = "ProjectionError"; + this.code = code; + } +} diff --git a/packages/host-service-sync/src/projection/reduceProjection.test.ts b/packages/host-service-sync/src/projection/reduceProjection.test.ts new file mode 100644 index 00000000000..83eb8a48471 --- /dev/null +++ b/packages/host-service-sync/src/projection/reduceProjection.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import type { SessionEvent } from "../protocol"; +import { + ProjectionError, + type ProjectionInput, + projectionInputSchema, + type SessionProjection, + sessionProjectionSchema, +} from "./projection"; +import { reduceProjection } from "./reduceProjection"; + +const timestamp = 1_783_772_200_000; + +const GOLDEN_FIXTURES = ["turn-lifecycle", "cards-and-settings"]; + +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object") { + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + Object.freeze(value); + } + return value; +} + +function baseProjection(): SessionProjection { + return sessionProjectionSchema.parse({ + sessionId: "session-1", + cursor: "cursor-0", + session: { + id: "session-1", + workspaceId: "workspace-1", + title: "Reconnect investigation", + mainThreadId: "thread-main", + agent: { id: "claude-code", displayName: "Claude Code" }, + runState: "idle", + capabilities: { + threadModel: "nested", + threadFidelity: "partial", + canResume: true, + supportsPermissions: true, + supportsModes: true, + supportsModels: true, + }, + settings: { + activeModel: "anthropic/claude-sonnet-4-6", + activeMode: "default", + effort: "low", + configuration: {}, + }, + eventHead: "cursor-0", + createdAt: timestamp, + updatedAt: timestamp, + lastActivityAt: timestamp, + archivedAt: null, + closedAt: null, + error: null, + }, + threadsById: { + "thread-main": { + id: "thread-main", + sessionId: "session-1", + kind: "main", + parentThreadId: null, + origin: { type: "sessionCreated" }, + fidelity: "full", + title: null, + runState: "idle", + eventHead: null, + createdAt: timestamp, + updatedAt: timestamp, + lastActivityAt: timestamp, + }, + }, + activeTurnsById: {}, + pendingPermissionsById: {}, + activeToolCallsById: {}, + plan: [], + }); +} + +function eventInput( + sequence: number, + payload: SessionEvent["payload"], +): ProjectionInput { + return projectionInputSchema.parse({ + type: "event", + cursor: `cursor-${sequence}`, + value: { + id: `event-${sequence}`, + sessionId: "session-1", + threadId: "thread-main", + cursor: `cursor-${sequence}`, + occurredAt: timestamp + sequence, + causationId: null, + payload, + }, + }); +} + +function projectionErrorCode(run: () => unknown): string | null { + try { + run(); + return null; + } catch (error) { + if (error instanceof ProjectionError) return error.code; + throw error; + } +} + +function permissionRequested(sequence: number, permissionId: string) { + return eventInput(sequence, { + type: "permissionRequested", + permission: { + id: permissionId, + sessionId: "session-1", + threadId: "thread-main", + toolCallId: `tool-${permissionId}`, + options: [{ id: "allow_once", name: "Allow once", kind: "allowOnce" }], + multiSelect: false, + requestedAt: timestamp + sequence, + }, + }); +} + +describe("reduceProjection", () => { + for (const name of GOLDEN_FIXTURES) { + test(`golden: ${name} folds to its pinned projection`, async () => { + const fixture = (await Bun.file( + resolve(import.meta.dir, "fixtures", `${name}.json`), + ).json()) as { inputs: unknown[]; expected: unknown }; + let projection: SessionProjection | null = null; + for (const rawInput of fixture.inputs) { + projection = reduceProjection( + projection, + projectionInputSchema.parse(rawInput), + ); + } + // The reducer must emit projections its own schema accepts. + const actual = sessionProjectionSchema.parse(projection); + expect(actual).toEqual(sessionProjectionSchema.parse(fixture.expected)); + }); + } + + test("requires a snapshot first and rejects cross-session inputs", () => { + const projection = baseProjection(); + const foreignEvent = projectionInputSchema.parse({ + type: "event", + cursor: "cursor-1", + value: { + id: "event-1", + sessionId: "session-other", + threadId: "thread-other", + cursor: "cursor-1", + occurredAt: timestamp + 1, + causationId: null, + payload: { + type: "messageCompleted", + messageId: "message-1", + }, + }, + }); + + expect( + projectionErrorCode(() => reduceProjection(null, foreignEvent)), + ).toBe("PROJECTION_NOT_INITIALIZED"); + expect( + projectionErrorCode(() => reduceProjection(projection, foreignEvent)), + ).toBe("PROJECTION_SESSION_MISMATCH"); + expect( + projectionErrorCode(() => + reduceProjection(projection, { + type: "snapshot", + cursor: "cursor-0", + value: { ...baseProjection(), sessionId: "session-other" }, + }), + ), + ).toBe("PROJECTION_SESSION_MISMATCH"); + }); + + test("treats replayed references to untracked entities as no-ops", () => { + const projection = baseProjection(); + const afterGhostUpdate = reduceProjection( + projection, + eventInput(1, { + type: "toolCallUpdated", + toolCallId: "tool-ghost", + update: { state: "succeeded", updatedAt: timestamp + 1 }, + }), + ); + const afterDuplicateResolve = reduceProjection( + afterGhostUpdate, + eventInput(2, { + type: "permissionResolved", + permissionId: "permission-ghost", + outcome: { type: "cancelled" }, + }), + ); + + expect(afterDuplicateResolve.activeToolCallsById).toEqual({}); + expect(afterDuplicateResolve.pendingPermissionsById).toEqual({}); + expect(afterDuplicateResolve.session.runState).toBe("idle"); + // Bookkeeping still advances: replay overlap must move the head. + expect(afterDuplicateResolve.cursor).toBe("cursor-2"); + expect(afterDuplicateResolve.session.eventHead).toBe("cursor-2"); + }); + + test("a terminal event for an untracked turn never terminalizes a live thread", () => { + const started = reduceProjection( + baseProjection(), + eventInput(1, { + type: "turnStarted", + turn: { + id: "turn-1", + sessionId: "session-1", + threadId: "thread-main", + status: "running", + originatingClientInstanceId: null, + createdAt: timestamp + 1, + updatedAt: timestamp + 1, + }, + }), + ); + // Replay overlap: a cancellation for a turn this projection never + // tracked must be a full no-op — not just on activeTurnsById, but on + // the thread's run state too. + const afterGhost = reduceProjection( + started, + eventInput(2, { type: "turnCancelled", turnId: "turn-ghost" }), + ); + expect(afterGhost.threadsById["thread-main"]?.runState).toBe("running"); + expect(afterGhost.activeTurnsById["turn-1"]).toBeDefined(); + expect(afterGhost.session.runState).toBe("running"); + }); + + test("never mutates the current projection or the input", () => { + const projection = deepFreeze(baseProjection()); + const input = deepFreeze( + eventInput(1, { + type: "turnStarted", + turn: { + id: "turn-1", + sessionId: "session-1", + threadId: "thread-main", + status: "running", + originatingClientInstanceId: "client-1", + createdAt: timestamp + 1, + updatedAt: timestamp + 1, + }, + }), + ); + + const next = reduceProjection(projection, input); + expect(next.session.runState).toBe("running"); + expect(next.threadsById["thread-main"]?.runState).toBe("running"); + expect(projection.session.runState).toBe("idle"); + expect(projection.threadsById["thread-main"]?.runState).toBe("idle"); + }); + + test("tracks pending permissions and open client tool calls in the projection maps", () => { + let projection = baseProjection(); + projection = reduceProjection( + projection, + permissionRequested(5, "permission-late"), + ); + projection = reduceProjection( + projection, + eventInput(6, { + type: "permissionRequested", + permission: { + id: "permission-early", + sessionId: "session-1", + threadId: "thread-main", + toolCallId: "tool-permission-early", + options: [ + { id: "allow_once", name: "Allow once", kind: "allowOnce" }, + ], + multiSelect: false, + requestedAt: timestamp + 1, + }, + }), + ); + projection = reduceProjection( + projection, + eventInput(7, { + type: "toolCallStarted", + toolCall: { + id: "tool-ask", + sessionId: "session-1", + threadId: "thread-main", + turnId: "turn-1", + parentToolCallId: null, + tool: { name: "ui.ask_user", version: 1 }, + title: "Choose storage", + input: { question: "Which store?" }, + resolver: { + type: "client", + capability: "ui.ask_user", + routing: "anyCapableClient", + }, + state: "available", + createdAt: timestamp + 7, + updatedAt: timestamp + 7, + expiresAt: null, + }, + }), + ); + + // Attention/badging is client-derived now: the projection only carries + // the low-level primitives — pending permissions and open tool calls. + expect(Object.keys(projection.pendingPermissionsById).sort()).toEqual([ + "permission-early", + "permission-late", + ]); + expect(projection.activeToolCallsById["tool-ask"]?.state).toBe("available"); + + projection = reduceProjection( + projection, + eventInput(8, { + type: "permissionResolved", + permissionId: "permission-early", + outcome: { type: "selected", optionIds: ["allow_once"] }, + }), + ); + expect(Object.keys(projection.pendingPermissionsById)).toEqual([ + "permission-late", + ]); + }); + + test("surfaces a failed turn's error and clears it when a new turn starts", () => { + let projection = reduceProjection( + baseProjection(), + eventInput(1, { + type: "turnStarted", + turn: { + id: "turn-1", + sessionId: "session-1", + threadId: "thread-main", + status: "running", + originatingClientInstanceId: null, + createdAt: timestamp + 1, + updatedAt: timestamp + 1, + }, + }), + ); + projection = reduceProjection( + projection, + eventInput(2, { + type: "turnFailed", + turnId: "turn-1", + error: { + code: "ADAPTER_PROTOCOL_ERROR", + retryable: true, + recovery: "retry", + occurredAt: timestamp + 2, + }, + }), + ); + expect(projection.session.runState).toBe("idle"); + expect(projection.session.error?.code).toBe("ADAPTER_PROTOCOL_ERROR"); + expect(projection.threadsById["thread-main"]?.runState).toBe("failed"); + expect(projection.activeTurnsById).toEqual({}); + + projection = reduceProjection( + projection, + eventInput(3, { + type: "turnStarted", + turn: { + id: "turn-2", + sessionId: "session-1", + threadId: "thread-main", + status: "accepted", + originatingClientInstanceId: "client-1", + createdAt: timestamp + 3, + updatedAt: timestamp + 3, + }, + }), + ); + expect(projection.session.error).toBeNull(); + expect(projection.session.runState).toBe("running"); + }); +}); diff --git a/packages/host-service-sync/src/projection/reduceProjection.ts b/packages/host-service-sync/src/projection/reduceProjection.ts new file mode 100644 index 00000000000..21ce153f150 --- /dev/null +++ b/packages/host-service-sync/src/projection/reduceProjection.ts @@ -0,0 +1,205 @@ +import type { + SessionEvent, + Thread, + ThreadRunState, + ToolCall, + ToolCallUpdate, + TurnId, +} from "../protocol"; +import { + ProjectionError, + type ProjectionInput, + type SessionProjection, +} from "./projection"; + +const TERMINAL_TOOL_CALL_STATES: ReadonlySet = new Set([ + "succeeded", + "failed", + "cancelled", +]); + +/** + * The one pure projection reducer shared by host projection and client + * folding. Callers guarantee inputs are schema-validated, ordered, and + * deduplicated by event id; replay overlap is safe because entity-reference + * updates for unknown ids are idempotent no-ops. + * + * Semantics the event stream cannot express are out of scope: runState only + * toggles between running/idle from turn lifecycle here, while host-liveness + * states (starting, offline, cancelling, closed) enter via snapshots. + */ +export function reduceProjection( + current: SessionProjection | null, + input: ProjectionInput, +): SessionProjection { + if (input.type === "snapshot") { + if (current !== null && current.sessionId !== input.value.sessionId) { + throw new ProjectionError( + "PROJECTION_SESSION_MISMATCH", + "snapshot belongs to a different session than the current projection", + ); + } + return input.value; + } + if (current === null) { + throw new ProjectionError( + "PROJECTION_NOT_INITIALIZED", + "cannot fold an event before a snapshot initializes the projection", + ); + } + const event = input.value; + if (event.sessionId !== current.sessionId) { + throw new ProjectionError( + "PROJECTION_SESSION_MISMATCH", + "event belongs to a different session than the current projection", + ); + } + + const next: SessionProjection = { + ...current, + cursor: input.cursor, + session: { + ...current.session, + eventHead: event.cursor, + updatedAt: event.occurredAt, + lastActivityAt: event.occurredAt, + }, + threadsById: { ...current.threadsById }, + activeTurnsById: { ...current.activeTurnsById }, + pendingPermissionsById: { ...current.pendingPermissionsById }, + activeToolCallsById: { ...current.activeToolCallsById }, + }; + + const payload = event.payload; + switch (payload.type) { + case "threadCreated": + case "threadUpdated": + next.threadsById[payload.thread.id] = payload.thread; + break; + case "turnStarted": + next.activeTurnsById[payload.turn.id] = payload.turn; + next.session.runState = "running"; + // A newly accepted turn supersedes any earlier surfaced error. + next.session.error = null; + setThreadRunState(next, event.threadId, "running"); + break; + case "turnCompleted": + finishTurn(next, payload.turnId, event.threadId, "completed"); + break; + case "turnCancelled": + finishTurn(next, payload.turnId, event.threadId, "cancelled"); + break; + case "turnFailed": + finishTurn(next, payload.turnId, event.threadId, "failed"); + next.session.error = payload.error; + break; + case "messageStarted": + case "messageDelta": + case "messageCompleted": + // Transcript bodies live in the event log, not the projection. + break; + case "toolCallStarted": + if (!TERMINAL_TOOL_CALL_STATES.has(payload.toolCall.state)) { + next.activeToolCallsById[payload.toolCall.id] = payload.toolCall; + } + break; + case "toolCallUpdated": { + const existing = next.activeToolCallsById[payload.toolCallId]; + if (!existing) break; + const updated = applyToolCallUpdate(existing, payload.update); + if (TERMINAL_TOOL_CALL_STATES.has(updated.state)) { + delete next.activeToolCallsById[payload.toolCallId]; + } else { + next.activeToolCallsById[payload.toolCallId] = updated; + } + break; + } + case "permissionRequested": + next.pendingPermissionsById[payload.permission.id] = payload.permission; + break; + case "permissionResolved": + delete next.pendingPermissionsById[payload.permissionId]; + break; + case "planUpdated": + next.plan = payload.plan; + break; + case "settingsUpdated": + next.session.settings = payload.settings; + break; + case "error": + next.session.error = payload.error; + break; + default: + payload satisfies never; + } + + touchThread(next, event.threadId, event); + if ( + next.session.runState === "running" && + Object.keys(next.activeTurnsById).length === 0 + ) { + next.session.runState = "idle"; + } + return next; +} + +function finishTurn( + projection: SessionProjection, + turnId: TurnId, + threadId: string, + threadRunState: ThreadRunState, +): void { + // Replay overlap safety: a terminal event for a turn this projection never + // tracked must be a full no-op, or it would terminalize a live thread. + if (!projection.activeTurnsById[turnId]) return; + delete projection.activeTurnsById[turnId]; + // The host terminalizes a turn's tool calls before ending it, but a window + // missing those updates must not leak "running" entries forever. + for (const [toolCallId, toolCall] of Object.entries( + projection.activeToolCallsById, + )) { + if (toolCall.turnId === turnId) { + delete projection.activeToolCallsById[toolCallId]; + } + } + setThreadRunState(projection, threadId, threadRunState); +} + +function setThreadRunState( + projection: SessionProjection, + threadId: string, + runState: ThreadRunState, +): void { + const thread = projection.threadsById[threadId]; + if (!thread) return; + projection.threadsById[threadId] = { ...thread, runState }; +} + +function touchThread( + projection: SessionProjection, + threadId: string, + event: SessionEvent, +): void { + const thread = projection.threadsById[threadId]; + if (!thread) return; + projection.threadsById[threadId] = { + ...thread, + eventHead: event.cursor, + updatedAt: event.occurredAt, + lastActivityAt: event.occurredAt, + } satisfies Thread; +} + +function applyToolCallUpdate( + toolCall: ToolCall, + update: ToolCallUpdate, +): ToolCall { + return { + ...toolCall, + // `output` intentionally has no home here: outcomes live in the log. + ...(update.title !== undefined ? { title: update.title } : {}), + ...(update.input !== undefined ? { input: update.input } : {}), + ...(update.state !== undefined ? { state: update.state } : {}), + updatedAt: update.updatedAt, + }; +} diff --git a/packages/host-service-sync/src/protocol/api.ts b/packages/host-service-sync/src/protocol/api.ts new file mode 100644 index 00000000000..6e477189b7d --- /dev/null +++ b/packages/host-service-sync/src/protocol/api.ts @@ -0,0 +1,576 @@ +/** + * Input/output schemas for the host `sessions.*` tRPC procedures. + * + * The host router parses every input AND output with these schemas; typed + * `AppRouter` inference never replaces runtime validation. Every mutation + * carries an idempotent `requestId`, and mutation outputs acknowledge + * admission (receipts), not completion — completion arrives as canonical + * events whose `causationId` echoes the request id. + * + * Snapshots ride tRPC (`list` = host snapshot, `get` = session snapshot); + * the sync socket carries only per-event frames. Every snapshot and window + * carries the `head` cursor the client subscribes from. + */ +import { z } from "zod"; +import { + contentBlockSchema, + permissionOutcomeSchema, + permissionRequestSchema, + sessionSchema, + sessionSettingsSchema, + threadSchema, + toolCallOutcomeSchema, + toolCallSchema, + turnSchema, +} from "./entities"; +import { sessionEventSchema } from "./events"; +import { + cursorSchema, + eventIdSchema, + permissionIdSchema, + requestIdSchema, + sessionIdSchema, + threadIdSchema, + timestampSchema, + toolCallIdSchema, + turnIdSchema, + workspaceIdSchema, +} from "./primitives"; + +const eventPageLimitSchema = z.number().int().safe().min(1).max(100); + +/** + * `list` returns the complete host snapshot: every non-archived session plus + * the cross-session pending cards a sessions list needs for attention + * badges, and the host-stream cursor to subscribe from. No pagination — + * a host's live session count is small by construction. + * + * Ungated capability probe: hosts with sessions disabled answer empty + * arrays and a null head. + */ +export const hostSnapshotSchema = z + .object({ + sessions: z.array(sessionSchema).max(10_000), + pendingPermissions: z.array(permissionRequestSchema).max(10_000), + openClientToolCalls: z.array(toolCallSchema).max(10_000), + head: cursorSchema.nullable(), + }) + .superRefine((snapshot, context) => { + const sessionIds = new Set(); + for (const [index, session] of snapshot.sessions.entries()) { + if (sessionIds.has(session.id)) { + context.addIssue({ + code: "custom", + path: ["sessions", index, "id"], + message: "duplicate session id in host snapshot", + }); + } + sessionIds.add(session.id); + } + const permissionIds = new Set(); + for (const [index, permission] of snapshot.pendingPermissions.entries()) { + if (!sessionIds.has(permission.sessionId)) { + context.addIssue({ + code: "custom", + path: ["pendingPermissions", index, "sessionId"], + message: "pending permission references an unlisted session", + }); + } + if (permissionIds.has(permission.id)) { + context.addIssue({ + code: "custom", + path: ["pendingPermissions", index, "id"], + message: "duplicate permission id in host snapshot", + }); + } + permissionIds.add(permission.id); + } + const toolCallIds = new Set(); + for (const [index, toolCall] of snapshot.openClientToolCalls.entries()) { + if (!sessionIds.has(toolCall.sessionId)) { + context.addIssue({ + code: "custom", + path: ["openClientToolCalls", index, "sessionId"], + message: "client tool call references an unlisted session", + }); + } + if (toolCall.resolver.type !== "client") { + context.addIssue({ + code: "custom", + path: ["openClientToolCalls", index, "resolver", "type"], + message: "client tool call must use a client resolver", + }); + } + if (toolCallIds.has(toolCall.id)) { + context.addIssue({ + code: "custom", + path: ["openClientToolCalls", index, "id"], + message: "duplicate tool call id in host snapshot", + }); + } + toolCallIds.add(toolCall.id); + } + if ( + snapshot.head === null && + (snapshot.sessions.length > 0 || + snapshot.pendingPermissions.length > 0 || + snapshot.openClientToolCalls.length > 0) + ) { + context.addIssue({ + code: "custom", + path: ["head"], + message: "a null head (sessions disabled) requires an empty snapshot", + }); + } + }); + +export const createSessionInputSchema = z.object({ + requestId: requestIdSchema, + workspaceId: workspaceIdSchema, + agentId: z.string().min(1).max(256), + title: z.string().max(4_096).nullable(), + settings: sessionSettingsSchema, +}); + +export const createSessionResultSchema = z + .object({ + session: sessionSchema, + mainThread: threadSchema, + }) + .superRefine((result, context) => { + if (result.mainThread.id !== result.session.mainThreadId) { + context.addIssue({ + code: "custom", + path: ["mainThread", "id"], + message: "mainThread.id does not match session.mainThreadId", + }); + } + if (result.mainThread.sessionId !== result.session.id) { + context.addIssue({ + code: "custom", + path: ["mainThread", "sessionId"], + message: "mainThread.sessionId does not match session.id", + }); + } + if (result.mainThread.kind !== "main") { + context.addIssue({ + code: "custom", + path: ["mainThread", "kind"], + message: "create result mainThread must have kind main", + }); + } + }); + +export const getSessionInputSchema = z.object({ sessionId: sessionIdSchema }); + +/** + * `get` returns the session snapshot: full projection (threads, active + * turns, pending cards, open client tool calls) plus a bounded tail of + * recent events for one-round-trip first paint, and the session-stream + * `head` cursor to subscribe from. Older content is `getEvents` territory. + */ +export const sessionSnapshotSchema = z + .object({ + session: sessionSchema, + threads: z.array(threadSchema).max(10_000), + activeTurns: z.array(turnSchema).max(1_000), + pendingPermissions: z.array(permissionRequestSchema).max(10_000), + openToolCalls: z.array(toolCallSchema).max(10_000), + recentEvents: z.array(sessionEventSchema).max(200), + // Whether events older than the recent tail exist (pageable via + // getEvents, or lost to ring truncation — either way, not "complete"). + hasOlderEvents: z.boolean(), + head: cursorSchema, + }) + .superRefine((snapshot, context) => { + const sessionId = snapshot.session.id; + const threadsById = new Map(); + for (const [index, thread] of snapshot.threads.entries()) { + if (thread.sessionId !== sessionId) { + context.addIssue({ + code: "custom", + path: ["threads", index, "sessionId"], + message: "thread sessionId does not match snapshot session id", + }); + } + if (threadsById.has(thread.id)) { + context.addIssue({ + code: "custom", + path: ["threads", index, "id"], + message: "duplicate thread id in session snapshot", + }); + } + threadsById.set(thread.id, thread); + } + const mainThread = threadsById.get(snapshot.session.mainThreadId); + if (!mainThread || mainThread.kind !== "main") { + context.addIssue({ + code: "custom", + path: ["threads"], + message: "session snapshot must contain its declared main thread", + }); + } + for (const [index, thread] of snapshot.threads.entries()) { + if ( + thread.parentThreadId !== null && + !threadsById.has(thread.parentThreadId) + ) { + context.addIssue({ + code: "custom", + path: ["threads", index, "parentThreadId"], + message: "thread parent is absent from the session snapshot", + }); + } + } + const turnIds = new Set(); + for (const [index, turn] of snapshot.activeTurns.entries()) { + if (turn.sessionId !== sessionId) { + context.addIssue({ + code: "custom", + path: ["activeTurns", index, "sessionId"], + message: "turn sessionId does not match snapshot session id", + }); + } + if (!threadsById.has(turn.threadId)) { + context.addIssue({ + code: "custom", + path: ["activeTurns", index, "threadId"], + message: "turn thread is absent from the session snapshot", + }); + } + if (turn.status !== "accepted" && turn.status !== "running") { + context.addIssue({ + code: "custom", + path: ["activeTurns", index, "status"], + message: "active turn must have an active status", + }); + } + if (turnIds.has(turn.id)) { + context.addIssue({ + code: "custom", + path: ["activeTurns", index, "id"], + message: "duplicate turn id in session snapshot", + }); + } + turnIds.add(turn.id); + } + const permissionIds = new Set(); + for (const [index, permission] of snapshot.pendingPermissions.entries()) { + if (permission.sessionId !== sessionId) { + context.addIssue({ + code: "custom", + path: ["pendingPermissions", index, "sessionId"], + message: "permission sessionId does not match snapshot session id", + }); + } + if (!threadsById.has(permission.threadId)) { + context.addIssue({ + code: "custom", + path: ["pendingPermissions", index, "threadId"], + message: "permission thread is absent from the session snapshot", + }); + } + if (permissionIds.has(permission.id)) { + context.addIssue({ + code: "custom", + path: ["pendingPermissions", index, "id"], + message: "duplicate permission id in session snapshot", + }); + } + permissionIds.add(permission.id); + } + const toolCallIds = new Set(); + for (const [index, toolCall] of snapshot.openToolCalls.entries()) { + if (toolCall.sessionId !== sessionId) { + context.addIssue({ + code: "custom", + path: ["openToolCalls", index, "sessionId"], + message: "tool call sessionId does not match snapshot session id", + }); + } + if (toolCall.resolver.type !== "client") { + context.addIssue({ + code: "custom", + path: ["openToolCalls", index, "resolver", "type"], + message: "client tool call must use a client resolver", + }); + } + if (!threadsById.has(toolCall.threadId)) { + context.addIssue({ + code: "custom", + path: ["openToolCalls", index, "threadId"], + message: "tool call thread is absent from the session snapshot", + }); + } + if (toolCallIds.has(toolCall.id)) { + context.addIssue({ + code: "custom", + path: ["openToolCalls", index, "id"], + message: "duplicate tool call id in session snapshot", + }); + } + toolCallIds.add(toolCall.id); + } + const eventIds = new Set(); + for (const [index, event] of snapshot.recentEvents.entries()) { + if (event.sessionId !== sessionId) { + context.addIssue({ + code: "custom", + path: ["recentEvents", index, "sessionId"], + message: "recent event sessionId does not match snapshot session id", + }); + } + if (eventIds.has(event.id)) { + context.addIssue({ + code: "custom", + path: ["recentEvents", index, "id"], + message: "duplicate event id in session snapshot", + }); + } + eventIds.add(event.id); + } + // The tail ends at the snapshot head: subscribing `after: head` must + // yield exactly the events the snapshot has not already delivered. + const lastRecent = snapshot.recentEvents.at(-1); + if (lastRecent && lastRecent.cursor !== snapshot.head) { + context.addIssue({ + code: "custom", + path: ["head"], + message: "head does not match the newest recent event cursor", + }); + } + }); + +/** + * One patch mutation for everything mutable on a session: title, archive/ + * close lifecycle, and harness settings. Absent fields stay unchanged. + * Settings fields are optional but NOT nullable: no current harness can + * clear a model/mode/effort back to "unset", so accepting null would ack a + * command whose advertised semantics the host cannot honor. + */ +export const updateSessionInputSchema = z + .object({ + requestId: requestIdSchema, + sessionId: sessionIdSchema, + title: z.string().max(4_096).nullable().optional(), + archived: z.boolean().optional(), + closed: z.boolean().optional(), + settings: z + .object({ + activeModel: z.string().min(1).max(256).optional(), + activeMode: z.string().min(1).max(256).optional(), + effort: z.string().min(1).max(256).optional(), + }) + .optional(), + }) + .superRefine((input, context) => { + if ( + input.title === undefined && + input.archived === undefined && + input.closed === undefined && + (input.settings === undefined || + (input.settings.activeModel === undefined && + input.settings.activeMode === undefined && + input.settings.effort === undefined)) + ) { + context.addIssue({ + code: "custom", + path: [], + message: "update must change at least one field", + }); + } + }); + +export const updateSessionReceiptSchema = z.object({ + requestId: requestIdSchema, + sessionId: sessionIdSchema, + status: z.literal("accepted"), +}); + +/** + * Backwards-only scrollback over the durable-ish log. No cursor = the + * newest window; page older by passing `range.oldest.cursor` back as + * `beforeCursor`. Forward catch-up is the sync socket's job, never this + * query's. + */ +export const getEventsInputSchema = z.object({ + sessionId: sessionIdSchema, + threadId: threadIdSchema.optional(), + beforeCursor: cursorSchema.optional(), + limit: eventPageLimitSchema.optional(), +}); + +/** Identifies one end of a loaded history window. */ +export const historyBoundarySchema = z.object({ + eventId: eventIdSchema, + cursor: cursorSchema, + occurredAt: timestampSchema, +}); + +export const eventsWindowSchema = z + .object({ + sessionId: sessionIdSchema, + threadId: threadIdSchema.nullable(), + items: z.array(sessionEventSchema).max(100), + range: z.object({ + oldest: historyBoundarySchema.nullable(), + newest: historyBoundarySchema.nullable(), + hasMoreBefore: z.boolean(), + // True when the window reaches the oldest event the host retains but + // earlier history existed and is irrecoverable (the source journal + // evicted it before the host observed it). hasMoreBefore stays false — + // paging back yields nothing — yet the log is not the full session. + truncatedBefore: z.boolean(), + }), + head: cursorSchema, + }) + .superRefine((window, context) => { + const ids = new Set(); + for (const [index, event] of window.items.entries()) { + if (event.sessionId !== window.sessionId) { + context.addIssue({ + code: "custom", + path: ["items", index, "sessionId"], + message: "event sessionId does not match window sessionId", + }); + } + if (window.threadId !== null && event.threadId !== window.threadId) { + context.addIssue({ + code: "custom", + path: ["items", index, "threadId"], + message: "event threadId does not match window threadId", + }); + } + if (ids.has(event.id)) { + context.addIssue({ + code: "custom", + path: ["items", index, "id"], + message: "duplicate event id in window", + }); + } + ids.add(event.id); + } + const first = window.items[0]; + const last = window.items.at(-1); + const boundaryMatches = ( + boundary: { eventId: string; cursor: string; occurredAt: number } | null, + item: (typeof window.items)[number], + ) => + boundary !== null && + boundary.eventId === item.id && + boundary.cursor === item.cursor && + boundary.occurredAt === item.occurredAt; + if (first && !boundaryMatches(window.range.oldest, first)) { + context.addIssue({ + code: "custom", + path: ["range", "oldest"], + message: "range.oldest does not identify the first (oldest) item", + }); + } + if (last && !boundaryMatches(window.range.newest, last)) { + context.addIssue({ + code: "custom", + path: ["range", "newest"], + message: "range.newest does not identify the last (newest) item", + }); + } + if ( + window.items.length === 0 && + (window.range.oldest !== null || window.range.newest !== null) + ) { + context.addIssue({ + code: "custom", + path: ["range"], + message: "an empty window cannot declare range boundaries", + }); + } + if (window.range.truncatedBefore && window.range.hasMoreBefore) { + context.addIssue({ + code: "custom", + path: ["range", "hasMoreBefore"], + message: "a truncated window cannot also report more history before it", + }); + } + }); + +export const submitTurnInputSchema = z.object({ + requestId: requestIdSchema, + sessionId: sessionIdSchema, + threadId: threadIdSchema, + content: z.array(contentBlockSchema).min(1).max(10_000), +}); + +export const submitTurnReceiptSchema = z.object({ + requestId: requestIdSchema, + turnId: turnIdSchema, + status: z.literal("accepted"), +}); + +export const cancelTurnInputSchema = z.object({ + requestId: requestIdSchema, + sessionId: sessionIdSchema, + turnId: turnIdSchema, +}); + +export const cancelTurnReceiptSchema = z.object({ + requestId: requestIdSchema, + turnId: turnIdSchema, + status: z.literal("accepted"), +}); + +export const resolvePermissionInputSchema = z.object({ + requestId: requestIdSchema, + sessionId: sessionIdSchema, + permissionId: permissionIdSchema, + outcome: permissionOutcomeSchema, +}); + +export const resolvePermissionReceiptSchema = z.object({ + requestId: requestIdSchema, + permissionId: permissionIdSchema, + status: z.literal("accepted"), +}); + +/** + * Client tools have no claim/lease step: the card renders on every capable + * device and the first resolve to reach the host wins. Later resolves fail + * with the host's stale-answer error and the losers' cards drain via the + * `clientToolCallResolved` host event. + */ +export const resolveToolCallInputSchema = z.object({ + requestId: requestIdSchema, + sessionId: sessionIdSchema, + toolCallId: toolCallIdSchema, + outcome: toolCallOutcomeSchema, +}); + +export const resolveToolCallReceiptSchema = z.object({ + requestId: requestIdSchema, + toolCallId: toolCallIdSchema, + status: z.literal("accepted"), +}); + +export type HostSnapshot = z.infer; +export type CreateSessionInput = z.infer; +export type CreateSessionResult = z.infer; +export type GetSessionInput = z.infer; +export type SessionSnapshot = z.infer; +export type UpdateSessionInput = z.infer; +export type UpdateSessionReceipt = z.infer; +export type GetEventsInput = z.infer; +export type HistoryBoundary = z.infer; +export type EventsWindow = z.infer; +export type SubmitTurnInput = z.infer; +export type SubmitTurnReceipt = z.infer; +export type CancelTurnInput = z.infer; +export type CancelTurnReceipt = z.infer; +export type ResolvePermissionInput = z.infer< + typeof resolvePermissionInputSchema +>; +export type ResolvePermissionReceipt = z.infer< + typeof resolvePermissionReceiptSchema +>; +export type ResolveToolCallInput = z.infer; +export type ResolveToolCallReceipt = z.infer< + typeof resolveToolCallReceiptSchema +>; diff --git a/packages/host-service-sync/src/protocol/entities.ts b/packages/host-service-sync/src/protocol/entities.ts new file mode 100644 index 00000000000..d45b25f8df9 --- /dev/null +++ b/packages/host-service-sync/src/protocol/entities.ts @@ -0,0 +1,336 @@ +import { z } from "zod"; +import { + clientInstanceIdSchema, + eventIdSchema, + jsonValueSchema, + messageIdSchema, + permissionIdSchema, + sessionErrorSchema, + sessionIdSchema, + threadIdSchema, + timestampSchema, + toolCallIdSchema, + turnIdSchema, + workspaceIdSchema, +} from "./primitives"; + +const shortTextSchema = z.string().max(4_096); +const identifierSchema = z.string().min(1).max(256); + +export const contentBlockSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("text"), + text: z.string().max(16 * 1024 * 1024), + }), + // Assistant reasoning. Modeled as content (not a message role) so thought + // and prose interleave in one message the way harnesses stream them. + z.object({ + type: z.literal("thought"), + text: z.string().max(16 * 1024 * 1024), + }), + z.object({ + type: z.literal("image"), + mimeType: z.string().min(1).max(256), + data: z.string().max(16 * 1024 * 1024), + }), + z.object({ + type: z.literal("resource"), + uri: z.string().min(1).max(16_384), + name: z.string().max(1_024).nullable(), + mimeType: z.string().max(256).nullable(), + }), +]); + +export type ContentBlock = z.infer; + +export const sessionRunStateSchema = z.enum([ + "starting", + "idle", + "running", + "cancelling", + "offline", + "failed", + "closed", +]); + +export const threadRunStateSchema = z.enum([ + "idle", + "running", + "completed", + "cancelled", + "failed", +]); + +export const sessionSettingsSchema = z.object({ + activeModel: identifierSchema.nullable(), + activeMode: identifierSchema.nullable(), + effort: identifierSchema.nullable(), + configuration: z.record(identifierSchema, jsonValueSchema), +}); + +export const settingOptionChoiceSchema = z.object({ + value: identifierSchema, + name: z.string().min(1).max(1_024), + description: z.string().max(4_096).nullable(), +}); + +/** + * A harness-reported settings catalog: the choices behind one settings field. + * `kind` names the `SessionSettings` field the catalog backs (model/mode/ + * effort map onto `updateSession.settings`; `other` entries key into + * `settings.configuration` and are display-only for now). Catalogs are + * session-level metadata composed from live harness state — they ride the + * session entity (snapshots + sessionUpsert), never the event log. + */ +export const settingOptionSchema = z.object({ + id: identifierSchema, + name: z.string().min(1).max(1_024), + kind: z.enum(["model", "mode", "effort", "other"]), + currentValue: identifierSchema.nullable(), + options: z.array(settingOptionChoiceSchema).max(100), +}); + +export const sessionCapabilitiesSchema = z.object({ + threadModel: z.enum(["single", "nested"]), + threadFidelity: z.enum(["full", "partial", "flattened"]), + canResume: z.boolean(), + supportsPermissions: z.boolean(), + supportsModes: z.boolean(), + supportsModels: z.boolean(), +}); + +export const sessionSchema = z.object({ + id: sessionIdSchema, + workspaceId: workspaceIdSchema, + title: shortTextSchema.nullable(), + mainThreadId: threadIdSchema, + agent: z.object({ + id: identifierSchema, + displayName: z.string().min(1).max(1_024), + }), + runState: sessionRunStateSchema, + capabilities: sessionCapabilitiesSchema, + settings: sessionSettingsSchema, + // default([]) so snapshots from hosts predating catalogs still parse. + settingOptions: z.array(settingOptionSchema).max(100).default([]), + eventHead: z.string().min(1).max(4_096).nullable(), + createdAt: timestampSchema, + updatedAt: timestampSchema, + lastActivityAt: timestampSchema, + archivedAt: timestampSchema.nullable(), + closedAt: timestampSchema.nullable(), + error: sessionErrorSchema.nullable(), +}); + +export const threadOriginSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("sessionCreated") }), + z.object({ + type: z.literal("subagent"), + spawnedByEventId: eventIdSchema, + spawnedByToolCallId: toolCallIdSchema, + }), + z.object({ + type: z.literal("harness"), + name: identifierSchema, + spawnedByEventId: eventIdSchema.nullable(), + }), +]); + +export const threadSchema = z + .object({ + id: threadIdSchema, + sessionId: sessionIdSchema, + kind: z.enum(["main", "subagent", "harness"]), + parentThreadId: threadIdSchema.nullable(), + origin: threadOriginSchema, + fidelity: z.enum(["full", "partial"]), + title: shortTextSchema.nullable(), + runState: threadRunStateSchema, + eventHead: z.string().min(1).max(4_096).nullable(), + createdAt: timestampSchema, + updatedAt: timestampSchema, + lastActivityAt: timestampSchema, + }) + .superRefine((thread, context) => { + const expectedOrigin = + thread.kind === "main" ? "sessionCreated" : thread.kind; + if (thread.origin.type !== expectedOrigin) { + context.addIssue({ + code: "custom", + path: ["origin", "type"], + message: `thread kind ${thread.kind} requires ${expectedOrigin} origin`, + }); + } + if (thread.kind === "main" && thread.parentThreadId !== null) { + context.addIssue({ + code: "custom", + path: ["parentThreadId"], + message: "main thread cannot have a parent", + }); + } + if (thread.kind !== "main" && thread.parentThreadId === null) { + context.addIssue({ + code: "custom", + path: ["parentThreadId"], + message: `${thread.kind} thread requires a parent`, + }); + } + }); + +export const stopReasonSchema = z.enum([ + "endTurn", + "cancelled", + "maxTokens", + "refusal", + "error", + "other", +]); + +export const turnSchema = z.object({ + id: turnIdSchema, + sessionId: sessionIdSchema, + threadId: threadIdSchema, + status: z.enum(["accepted", "running", "completed", "cancelled", "failed"]), + originatingClientInstanceId: clientInstanceIdSchema.nullable(), + createdAt: timestampSchema, + updatedAt: timestampSchema, +}); + +export const messageSchema = z.object({ + id: messageIdSchema, + sessionId: sessionIdSchema, + threadId: threadIdSchema, + turnId: turnIdSchema, + role: z.enum(["user", "assistant", "system"]), + content: z.array(contentBlockSchema).max(10_000), + createdAt: timestampSchema, +}); + +export const toolResolverSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("host") }), + z.object({ + type: z.literal("client"), + capability: identifierSchema, + routing: z.enum(["originatingClient", "anyCapableClient"]), + }), + z.object({ + type: z.literal("external"), + provider: identifierSchema, + resolverId: identifierSchema, + }), +]); + +export const toolCallStateSchema = z.enum([ + "requested", + "awaitingPermission", + "available", + "running", + "succeeded", + "failed", + "cancelled", +]); + +export const toolCallSchema = z.object({ + id: toolCallIdSchema, + sessionId: sessionIdSchema, + threadId: threadIdSchema, + turnId: turnIdSchema, + parentToolCallId: toolCallIdSchema.nullable(), + tool: z.object({ + name: identifierSchema, + version: z.number().int().safe().positive(), + }), + title: shortTextSchema, + input: jsonValueSchema, + resolver: toolResolverSchema, + state: toolCallStateSchema, + createdAt: timestampSchema, + updatedAt: timestampSchema, + expiresAt: timestampSchema.nullable(), +}); + +export const toolCallUpdateSchema = z.object({ + title: shortTextSchema.optional(), + input: jsonValueSchema.optional(), + output: jsonValueSchema.optional(), + state: toolCallStateSchema.optional(), + updatedAt: timestampSchema, +}); + +export const permissionOptionSchema = z.object({ + id: identifierSchema, + name: z.string().min(1).max(1_024), + kind: z.enum([ + "allowOnce", + "allowAlways", + "rejectOnce", + "rejectAlways", + "other", + ]), +}); + +export const permissionRequestSchema = z.object({ + id: permissionIdSchema, + sessionId: sessionIdSchema, + threadId: threadIdSchema, + toolCallId: toolCallIdSchema, + options: z.array(permissionOptionSchema).min(1).max(100), + multiSelect: z.boolean(), + requestedAt: timestampSchema, +}); + +export const permissionOutcomeSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("selected"), + optionIds: z.array(identifierSchema).min(1).max(100), + }), + z.object({ type: z.literal("cancelled") }), +]); + +export const planEntrySchema = z.object({ + id: identifierSchema, + content: z.string().max(64 * 1024), + status: z.enum(["pending", "inProgress", "completed"]), + priority: z.enum(["low", "medium", "high"]).nullable(), +}); + +export const toolCallOutcomeSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("succeeded"), + output: jsonValueSchema, + }), + z.object({ + type: z.literal("failed"), + error: z.object({ + code: identifierSchema, + message: shortTextSchema, + retryable: z.boolean(), + }), + }), + z.object({ + type: z.literal("cancelled"), + reason: shortTextSchema.nullable(), + }), +]); + +export type SessionRunState = z.infer; +export type ThreadRunState = z.infer; +export type SessionSettings = z.infer; +export type SettingOptionChoice = z.infer; +export type SettingOption = z.infer; +export type SessionCapabilities = z.infer; +export type Session = z.infer; +export type ThreadOrigin = z.infer; +export type Thread = z.infer; +export type StopReason = z.infer; +export type Turn = z.infer; +export type Message = z.infer; +export type ToolResolver = z.infer; +export type ToolCallState = z.infer; +export type ToolCall = z.infer; +export type ToolCallUpdate = z.infer; +export type PermissionOption = z.infer; +export type PermissionRequest = z.infer; +export type PermissionOutcome = z.infer; +export type PlanEntry = z.infer; +export type ToolCallOutcome = z.infer; diff --git a/packages/host-service-sync/src/protocol/events.ts b/packages/host-service-sync/src/protocol/events.ts new file mode 100644 index 00000000000..fddac6ce1e2 --- /dev/null +++ b/packages/host-service-sync/src/protocol/events.ts @@ -0,0 +1,181 @@ +import { z } from "zod"; +import { + contentBlockSchema, + messageSchema, + permissionOutcomeSchema, + permissionRequestSchema, + planEntrySchema, + sessionSchema, + sessionSettingsSchema, + stopReasonSchema, + threadSchema, + toolCallSchema, + toolCallUpdateSchema, + turnSchema, +} from "./entities"; +import { + cursorSchema, + eventIdSchema, + permissionIdSchema, + requestIdSchema, + sessionErrorSchema, + sessionIdSchema, + threadIdSchema, + timestampSchema, + toolCallIdSchema, + turnIdSchema, +} from "./primitives"; + +export const sessionEventPayloadSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("threadCreated"), thread: threadSchema }), + z.object({ type: z.literal("threadUpdated"), thread: threadSchema }), + z.object({ type: z.literal("turnStarted"), turn: turnSchema }), + z.object({ + type: z.literal("turnCompleted"), + turnId: turnIdSchema, + stopReason: stopReasonSchema, + }), + z.object({ + type: z.literal("turnFailed"), + turnId: turnIdSchema, + error: sessionErrorSchema, + }), + z.object({ type: z.literal("turnCancelled"), turnId: turnIdSchema }), + z.object({ type: z.literal("messageStarted"), message: messageSchema }), + z.object({ + type: z.literal("messageDelta"), + messageId: z.string().min(1).max(256), + content: contentBlockSchema, + }), + z.object({ + type: z.literal("messageCompleted"), + messageId: z.string().min(1).max(256), + }), + z.object({ + type: z.literal("toolCallStarted"), + toolCall: toolCallSchema, + }), + z.object({ + type: z.literal("toolCallUpdated"), + toolCallId: toolCallIdSchema, + update: toolCallUpdateSchema, + }), + z.object({ + type: z.literal("permissionRequested"), + permission: permissionRequestSchema, + }), + z.object({ + type: z.literal("permissionResolved"), + permissionId: permissionIdSchema, + outcome: permissionOutcomeSchema, + }), + z.object({ + type: z.literal("planUpdated"), + plan: z.array(planEntrySchema).max(10_000), + }), + z.object({ + type: z.literal("settingsUpdated"), + settings: sessionSettingsSchema, + }), + z.object({ type: z.literal("error"), error: sessionErrorSchema }), +]); + +export const sessionEventSchema = z + .object({ + id: eventIdSchema, + sessionId: sessionIdSchema, + threadId: threadIdSchema, + cursor: cursorSchema, + occurredAt: timestampSchema, + // The idempotent requestId of the mutation this event resulted from, + // or null for host/adapter-originated transitions. + causationId: requestIdSchema.nullable(), + payload: sessionEventPayloadSchema, + }) + .superRefine((event, context) => { + const payload = event.payload; + const thread = + payload.type === "threadCreated" || payload.type === "threadUpdated" + ? payload.thread + : null; + const nested = + payload.type === "turnStarted" + ? payload.turn + : payload.type === "messageStarted" + ? payload.message + : payload.type === "toolCallStarted" + ? payload.toolCall + : payload.type === "permissionRequested" + ? payload.permission + : null; + const nestedSessionId = thread?.sessionId ?? nested?.sessionId; + const nestedThreadId = thread?.id ?? nested?.threadId; + if (nestedSessionId !== undefined && nestedSessionId !== event.sessionId) { + context.addIssue({ + code: "custom", + path: ["payload"], + message: "nested sessionId does not match event sessionId", + }); + } + if (nestedThreadId !== undefined && nestedThreadId !== event.threadId) { + context.addIssue({ + code: "custom", + path: ["payload"], + message: "nested threadId does not match event threadId", + }); + } + if ( + payload.type === "turnStarted" && + payload.turn.status !== "accepted" && + payload.turn.status !== "running" + ) { + // The projection stores started turns as active; a terminal status + // here would mint a projection that violates its own invariants. + context.addIssue({ + code: "custom", + path: ["payload", "turn", "status"], + message: "turnStarted must carry an active turn status", + }); + } + }); + +export const hostEventSchema = z + .discriminatedUnion("type", [ + z.object({ type: z.literal("sessionUpsert"), session: sessionSchema }), + z.object({ + type: z.literal("sessionRemoved"), + reason: z.enum(["archived", "closed", "deleted"]), + }), + z.object({ + type: z.literal("permissionAvailable"), + permission: permissionRequestSchema, + }), + z.object({ + type: z.literal("permissionResolved"), + permissionId: permissionIdSchema, + }), + z.object({ + type: z.literal("clientToolCallAvailable"), + toolCall: toolCallSchema, + }), + z.object({ + type: z.literal("clientToolCallResolved"), + toolCallId: toolCallIdSchema, + }), + ]) + .superRefine((event, context) => { + if ( + event.type === "clientToolCallAvailable" && + event.toolCall.resolver.type !== "client" + ) { + context.addIssue({ + code: "custom", + path: ["toolCall", "resolver", "type"], + message: "client tool call must use a client resolver", + }); + } + }); + +export type SessionEventPayload = z.infer; +export type SessionEvent = z.infer; +export type HostEvent = z.infer; diff --git a/packages/host-service-sync/src/protocol/index.ts b/packages/host-service-sync/src/protocol/index.ts new file mode 100644 index 00000000000..5eae2d9b297 --- /dev/null +++ b/packages/host-service-sync/src/protocol/index.ts @@ -0,0 +1,6 @@ +export * from "./api"; +export * from "./entities"; +export * from "./events"; +export * from "./primitives"; +export * from "./routes"; +export * from "./sync"; diff --git a/packages/host-service-sync/src/protocol/primitives.ts b/packages/host-service-sync/src/protocol/primitives.ts new file mode 100644 index 00000000000..480d67876a3 --- /dev/null +++ b/packages/host-service-sync/src/protocol/primitives.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +const MAX_JSON_STRING_LENGTH = 16 * 1024 * 1024; +const MAX_JSON_COLLECTION_ITEMS = 10_000; +const MAX_JSON_OBJECT_KEYS = 10_000; +const MAX_JSON_DEPTH = 64; + +const jsonPrimitiveSchema = z.union([ + z.null(), + z.boolean(), + z.number(), + z.string().max(MAX_JSON_STRING_LENGTH), +]); + +function jsonObjectSchema( + valueSchema: z.ZodType, +): z.ZodType> { + return z + .record(z.string().max(256), valueSchema) + .superRefine((value, context) => { + if (Object.keys(value).length > MAX_JSON_OBJECT_KEYS) { + context.addIssue({ + code: "too_big", + maximum: MAX_JSON_OBJECT_KEYS, + origin: "object", + inclusive: true, + message: `JSON object exceeds ${MAX_JSON_OBJECT_KEYS} keys`, + }); + } + }); +} + +let boundedJsonValueSchema: z.ZodType = jsonPrimitiveSchema; +for (let depth = 0; depth < MAX_JSON_DEPTH; depth += 1) { + const childSchema = boundedJsonValueSchema; + boundedJsonValueSchema = z.union([ + jsonPrimitiveSchema, + z.array(childSchema).max(MAX_JSON_COLLECTION_ITEMS), + jsonObjectSchema(childSchema), + ]); +} + +export const jsonValueSchema = boundedJsonValueSchema; + +export const protocolVersionSchema = z.literal(1); +export type ProtocolVersion = z.infer; + +export const requestIdSchema = z.string().min(1).max(512); +export const hostIdSchema = z.string().min(1).max(256); +export const connectionIdSchema = z.string().min(1).max(256); +export const clientInstanceIdSchema = z.string().min(1).max(256); +export const workspaceIdSchema = z.string().min(1).max(256); +export const sessionIdSchema = z.string().min(1).max(256); +export const threadIdSchema = z.string().min(1).max(256); +export const turnIdSchema = z.string().min(1).max(256); +export const eventIdSchema = z.string().min(1).max(256); +export const messageIdSchema = z.string().min(1).max(256); +export const toolCallIdSchema = z.string().min(1).max(256); +export const permissionIdSchema = z.string().min(1).max(256); +export const subscriptionIdSchema = z.string().min(1).max(512); +export const cursorSchema = z.string().min(1).max(4_096); +export const timestampSchema = z.number().int().safe().nonnegative(); + +export type RequestId = z.infer; +export type HostId = z.infer; +export type ConnectionId = z.infer; +export type ClientInstanceId = z.infer; +export type WorkspaceId = z.infer; +export type SessionId = z.infer; +export type ThreadId = z.infer; +export type TurnId = z.infer; +export type EventId = z.infer; +export type MessageId = z.infer; +export type ToolCallId = z.infer; +export type PermissionId = z.infer; +export type SubscriptionId = z.infer; +export type Cursor = z.infer; + +export const sessionErrorCodeSchema = z.enum([ + "SESSION_NOT_FOUND", + "SESSION_CLOSED", + "WORKSPACE_NOT_FOUND", + "NATIVE_SESSION_NOT_FOUND", + "ADAPTER_UNAVAILABLE", + "ADAPTER_PROTOCOL_ERROR", + "AUTH_REQUIRED", + "UNSUPPORTED_OPERATION", + "INTERNAL_ERROR", +]); + +export const sessionErrorRecoverySchema = z.enum([ + "retry", + "reauthenticate", + "startNewSession", + "archiveSession", + "none", +]); + +export const sessionErrorSchema = z.object({ + code: sessionErrorCodeSchema, + retryable: z.boolean(), + recovery: sessionErrorRecoverySchema, + occurredAt: timestampSchema, +}); + +export type SessionErrorCode = z.infer; +export type SessionErrorRecovery = z.infer; +export type SessionError = z.infer; + +export const syncErrorCodeSchema = z.enum([ + "INVALID_PACKET", + "UNSUPPORTED_PROTOCOL_VERSION", + "UNAUTHORIZED", + "SUBSCRIPTION_LIMIT", + "SESSION_NOT_FOUND", + "CURSOR_INVALID", + "CURSOR_EXPIRED", + "HISTORY_REWRITTEN", + "OVERLOADED", + "INTERNAL_ERROR", +]); + +export type SyncErrorCode = z.infer; diff --git a/packages/host-service-sync/src/protocol/protocol.test.ts b/packages/host-service-sync/src/protocol/protocol.test.ts new file mode 100644 index 00000000000..1d98ee2c2f0 --- /dev/null +++ b/packages/host-service-sync/src/protocol/protocol.test.ts @@ -0,0 +1,562 @@ +import { describe, expect, test } from "bun:test"; +import { + createSessionResultSchema, + eventsWindowSchema, + getEventsInputSchema, + hostSnapshotSchema, + jsonValueSchema, + SESSIONS_SYNC_PATH, + SESSIONS_SYNC_WEBSOCKET_PROTOCOL, + sessionEventSchema, + sessionSchema, + sessionSnapshotSchema, + syncClientPacketSchema, + syncErrorCodeSchema, + syncServerPacketSchema, + threadSchema, + toolCallSchema, + updateSessionInputSchema, +} from "./index"; + +const now = 1_783_772_200_000; + +function session(id = "session-1") { + return { + id, + workspaceId: "workspace-1", + title: "Reconnect investigation", + mainThreadId: "thread-main", + agent: { id: "claude-code", displayName: "Claude Code" }, + runState: "running" as const, + capabilities: { + threadModel: "nested" as const, + threadFidelity: "partial" as const, + canResume: true, + supportsPermissions: true, + supportsModes: true, + supportsModels: true, + }, + settings: { + activeModel: "anthropic/claude-sonnet-4-6", + activeMode: "default", + effort: "low", + configuration: {}, + }, + eventHead: "cursor-10", + createdAt: now, + updatedAt: now, + lastActivityAt: now, + archivedAt: null, + closedAt: null, + error: null, + }; +} + +function messageEvent(overrides?: { + sessionId?: string; + threadId?: string; + id?: string; + cursor?: string; +}) { + return { + id: overrides?.id ?? "event-1", + sessionId: overrides?.sessionId ?? "session-1", + threadId: overrides?.threadId ?? "thread-main", + cursor: overrides?.cursor ?? "cursor-1", + occurredAt: now, + causationId: null, + payload: { + type: "messageDelta" as const, + messageId: "message-1", + content: { type: "text" as const, text: "hello" }, + }, + }; +} + +function clientToolCall(state: "available" | "running" = "available") { + return { + id: "tool-1", + sessionId: "session-1", + threadId: "thread-main", + turnId: "turn-1", + parentToolCallId: null, + tool: { name: "ui.ask_user", version: 1 }, + title: "Choose storage", + input: { + question: "Which store?", + options: ["sqlite", "files"], + }, + resolver: { + type: "client" as const, + capability: "ui.ask_user", + routing: "anyCapableClient" as const, + }, + state, + createdAt: now, + updatedAt: now, + expiresAt: null, + }; +} + +function mainThread() { + return { + id: "thread-main", + sessionId: "session-1", + kind: "main" as const, + parentThreadId: null, + origin: { type: "sessionCreated" as const }, + fidelity: "full" as const, + title: null, + runState: "idle" as const, + eventHead: null, + createdAt: now, + updatedAt: now, + lastActivityAt: now, + }; +} + +describe("host sessions protocol", () => { + test("pins the unversioned sync endpoint and stable error codes", () => { + expect(SESSIONS_SYNC_PATH).toBe("/sessions/sync"); + expect(SESSIONS_SYNC_WEBSOCKET_PROTOCOL).toBe("superset.sessions.sync"); + // No `v1` route/protocol names; versioning lives in the handshake. + expect(SESSIONS_SYNC_PATH).not.toContain("v1"); + expect(SESSIONS_SYNC_WEBSOCKET_PROTOCOL).not.toContain("v1"); + expect(syncErrorCodeSchema.options).toContain( + "UNSUPPORTED_PROTOCOL_VERSION", + ); + expect(syncErrorCodeSchema.options).toContain("OVERLOADED"); + }); + + test("parses a host-owned session with authoritative active model", () => { + const parsed = sessionSchema.parse(session()); + expect(parsed.id).toBe("session-1"); + expect(parsed.settings.activeModel).toBe("anthropic/claude-sonnet-4-6"); + expect("nativeSessionId" in parsed).toBe(false); + }); + + test("requires packet and nested session identity to agree", () => { + const result = syncServerPacketSchema.safeParse({ + type: "event", + hostId: "host-1", + subscriptionId: "host", + stream: "host", + sessionId: "session-1", + threadId: null, + cursor: "host-cursor-1", + event: { type: "sessionUpsert", session: session("session-2") }, + }); + + expect(result.success).toBe(false); + }); + + test("host snapshot: cross-references and the gated-off empty shape", () => { + const snapshot = { + sessions: [session()], + pendingPermissions: [], + openClientToolCalls: [clientToolCall()], + head: "h1-000000000001", + }; + expect(hostSnapshotSchema.safeParse(snapshot).success).toBe(true); + // A tool call for an unlisted session cannot appear. + expect( + hostSnapshotSchema.safeParse({ + ...snapshot, + openClientToolCalls: [ + { ...clientToolCall(), sessionId: "session-other" }, + ], + }).success, + ).toBe(false); + // Host-resolved calls have no business in the client-tool list. + expect( + hostSnapshotSchema.safeParse({ + ...snapshot, + openClientToolCalls: [ + { ...clientToolCall(), resolver: { type: "host" } }, + ], + }).success, + ).toBe(false); + // Gated-off hosts answer empty with a null head — and nothing else. + expect( + hostSnapshotSchema.safeParse({ + sessions: [], + pendingPermissions: [], + openClientToolCalls: [], + head: null, + }).success, + ).toBe(true); + expect( + hostSnapshotSchema.safeParse({ ...snapshot, head: null }).success, + ).toBe(false); + }); + + test("session snapshot: main thread, active turns, and a head-anchored tail", () => { + const recent = messageEvent({ id: "event-9", cursor: "cursor-9" }); + const snapshot = { + session: session(), + threads: [mainThread()], + activeTurns: [ + { + id: "turn-1", + sessionId: "session-1", + threadId: "thread-main", + status: "running" as const, + originatingClientInstanceId: null, + createdAt: now, + updatedAt: now, + }, + ], + pendingPermissions: [], + openToolCalls: [], + recentEvents: [recent], + hasOlderEvents: true, + head: "cursor-9", + }; + expect(sessionSnapshotSchema.safeParse(snapshot).success).toBe(true); + // The declared main thread must be present. + expect( + sessionSnapshotSchema.safeParse({ ...snapshot, threads: [] }).success, + ).toBe(false); + // A terminal turn is not an active turn. + expect( + sessionSnapshotSchema.safeParse({ + ...snapshot, + activeTurns: [ + { ...snapshot.activeTurns[0], status: "completed" as const }, + ], + }).success, + ).toBe(false); + // The recent tail must end exactly at the snapshot head, or subscribing + // after the head would skip or duplicate events. + expect( + sessionSnapshotSchema.safeParse({ ...snapshot, head: "cursor-10" }) + .success, + ).toBe(false); + }); + + test("requires detailed packet, event, and nested thread identity to agree", () => { + const event = { + id: "event-1", + sessionId: "session-1", + threadId: "thread-main", + cursor: "cursor-1", + occurredAt: now, + causationId: null, + payload: { + type: "threadCreated", + thread: { + id: "thread-other", + sessionId: "session-1", + kind: "subagent", + parentThreadId: "thread-main", + origin: { + type: "subagent", + spawnedByEventId: "event-spawn", + spawnedByToolCallId: "tool-spawn", + }, + fidelity: "partial", + title: "Audit auth", + runState: "running", + eventHead: null, + createdAt: now, + updatedAt: now, + lastActivityAt: now, + }, + }, + }; + + expect(sessionEventSchema.safeParse(event).success).toBe(false); + }); + + test("getEvents is backwards-only: no anchors, no forward paging, capped at 100", () => { + expect( + getEventsInputSchema.safeParse({ sessionId: "session-1" }).success, + ).toBe(true); + expect( + getEventsInputSchema.safeParse({ + sessionId: "session-1", + beforeCursor: "cursor-40", + limit: 100, + }).success, + ).toBe(true); + expect( + getEventsInputSchema.safeParse({ sessionId: "session-1", limit: 101 }) + .success, + ).toBe(false); + expect( + getEventsInputSchema.safeParse({ + sessionId: "session-1", + anchor: { type: "latest" }, + }).success, + ).toBe(true); // unknown keys are stripped, not errors + const parsed = getEventsInputSchema.parse({ + sessionId: "session-1", + anchor: { type: "latest" }, + }); + expect("anchor" in parsed).toBe(false); + }); + + test("requires history windows whose boundaries match their items", () => { + const first = messageEvent({ id: "event-1", cursor: "cursor-1" }); + const last = messageEvent({ id: "event-2", cursor: "cursor-2" }); + const boundary = (event: { id: string; cursor: string }) => ({ + eventId: event.id, + cursor: event.cursor, + occurredAt: now, + }); + const window = eventsWindowSchema.parse({ + sessionId: "session-1", + threadId: null, + items: [first, last], + range: { + oldest: boundary(first), + newest: boundary(last), + hasMoreBefore: true, + truncatedBefore: false, + }, + head: "cursor-10", + }); + expect(window.threadId).toBeNull(); + expect(window.items).toHaveLength(2); + // Items must belong to the window's session. + expect( + eventsWindowSchema.safeParse({ + ...window, + sessionId: "session-other", + }).success, + ).toBe(false); + // Boundaries must agree with the first/last items. + expect( + eventsWindowSchema.safeParse({ + ...window, + range: { ...window.range, oldest: boundary(last) }, + }).success, + ).toBe(false); + expect( + eventsWindowSchema.safeParse({ + ...window, + range: { ...window.range, newest: boundary(first) }, + }).success, + ).toBe(false); + // An empty window carries no boundaries. + expect( + eventsWindowSchema.safeParse({ + ...window, + items: [], + }).success, + ).toBe(false); + expect( + eventsWindowSchema.safeParse({ + sessionId: "session-1", + threadId: null, + items: [], + range: { + oldest: null, + newest: null, + hasMoreBefore: false, + truncatedBefore: false, + }, + head: "cursor-10", + }).success, + ).toBe(true); + // Truncation means the earlier history is gone — a window cannot both + // declare the log truncated and promise more pages before it. + expect( + eventsWindowSchema.safeParse({ + ...window, + range: { + ...window.range, + hasMoreBefore: true, + truncatedBefore: true, + }, + }).success, + ).toBe(false); + // A boundary must agree with its item on every field, not just the id — + // a lying cursor or timestamp corrupts pagination anchors downstream. + expect( + eventsWindowSchema.safeParse({ + ...window, + range: { + ...window.range, + oldest: { ...boundary(first), cursor: "cursor-other" }, + }, + }).success, + ).toBe(false); + expect( + eventsWindowSchema.safeParse({ + ...window, + range: { + ...window.range, + newest: { ...boundary(last), occurredAt: now + 1 }, + }, + }).success, + ).toBe(false); + }); + + test("rejects a turnStarted event that carries a terminal turn status", () => { + const started = { + ...messageEvent(), + payload: { + type: "turnStarted" as const, + turn: { + id: "turn-1", + sessionId: "session-1", + threadId: "thread-main", + status: "running" as const, + originatingClientInstanceId: null, + createdAt: now, + updatedAt: now, + }, + }, + }; + expect(sessionEventSchema.safeParse(started).success).toBe(true); + expect( + sessionEventSchema.safeParse({ + ...started, + payload: { + ...started.payload, + turn: { ...started.payload.turn, status: "completed" as const }, + }, + }).success, + ).toBe(false); + }); + + test("update is one patch mutation: settings accepted, null and no-ops rejected", () => { + const base = { requestId: "req-1", sessionId: "session-1" }; + expect( + updateSessionInputSchema.safeParse({ ...base, title: "Renamed" }).success, + ).toBe(true); + expect( + updateSessionInputSchema.safeParse({ + ...base, + settings: { activeMode: "plan" }, + }).success, + ).toBe(true); + // No harness can clear a setting back to unset; accepting null would + // ack a command the host cannot honor. + expect( + updateSessionInputSchema.safeParse({ + ...base, + settings: { activeModel: null }, + }).success, + ).toBe(false); + // An empty patch is a caller bug, not an idempotent success. + expect(updateSessionInputSchema.safeParse(base).success).toBe(false); + expect( + updateSessionInputSchema.safeParse({ ...base, settings: {} }).success, + ).toBe(false); + }); + + test("rejects a session event packet whose cursor disagrees with its event", () => { + const event = messageEvent(); + const packet = { + type: "event" as const, + hostId: "host-1", + subscriptionId: "sub-session-1", + stream: "session" as const, + sessionId: event.sessionId, + threadId: event.threadId, + cursor: event.cursor, + event, + }; + expect(syncServerPacketSchema.safeParse(packet).success).toBe(true); + expect( + syncServerPacketSchema.safeParse({ ...packet, cursor: "cursor-2" }) + .success, + ).toBe(false); + }); + + test("does not permit a generic or structurally invalid thread", () => { + const thread = mainThread(); + expect(threadSchema.safeParse(thread).success).toBe(true); + expect( + threadSchema.safeParse({ + ...thread, + kind: "subagent", + }).success, + ).toBe(false); + expect( + createSessionResultSchema.safeParse({ + session: session(), + mainThread: { ...thread, id: "wrong-thread" }, + }).success, + ).toBe(false); + }); + + test("subscribe always carries a cursor — there is no null-cursor snapshot path", () => { + const subscribe = { + type: "subscribe", + requestId: "request-1", + subscriptionId: "session:session-1", + stream: { type: "session", sessionId: "session-1" }, + after: "cursor-5", + }; + expect(syncClientPacketSchema.safeParse(subscribe).success).toBe(true); + expect( + syncClientPacketSchema.safeParse({ ...subscribe, after: null }).success, + ).toBe(false); + }); + + test("requires host/session control packet identity to match its stream", () => { + expect( + syncServerPacketSchema.safeParse({ + type: "caughtUp", + subscriptionId: "host", + stream: "host", + sessionId: "session-1", + through: "cursor-1", + }).success, + ).toBe(false); + expect( + syncServerPacketSchema.safeParse({ + type: "reset", + subscriptionId: "session:session-1", + stream: "session", + sessionId: null, + code: "CURSOR_EXPIRED", + recovery: "refetchSnapshot", + }).success, + ).toBe(false); + }); + + test("versions the handshake only and rejects unknown packet variants", () => { + const hello = { + type: "hello", + protocolVersion: 1, + requestId: "request-1", + clientInstanceId: "client-1", + clientVersion: "1.0.0", + toolResolvers: [], + }; + expect(syncClientPacketSchema.safeParse(hello).success).toBe(true); + expect( + syncClientPacketSchema.safeParse({ ...hello, protocolVersion: 2 }) + .success, + ).toBe(false); + // Non-handshake packets carry no version field; a stray one is stripped. + const ping = syncClientPacketSchema.parse({ + type: "ping", + nonce: "1", + version: 1, + }); + expect("version" in ping).toBe(false); + expect( + syncClientPacketSchema.safeParse({ + type: "acp_session_update", + }).success, + ).toBe(false); + }); + + test("bounds recursive JSON extension payloads", () => { + let withinLimit: unknown = null; + for (let depth = 0; depth < 64; depth += 1) withinLimit = [withinLimit]; + expect(jsonValueSchema.safeParse(withinLimit).success).toBe(true); + expect(jsonValueSchema.safeParse([withinLimit]).success).toBe(false); + }); + + test("models a client-resolved question as a normal tool call", () => { + const parsed = toolCallSchema.parse(clientToolCall()); + + expect(parsed.resolver.type).toBe("client"); + }); +}); diff --git a/packages/host-service-sync/src/protocol/routes.ts b/packages/host-service-sync/src/protocol/routes.ts new file mode 100644 index 00000000000..097fb62bb4b --- /dev/null +++ b/packages/host-service-sync/src/protocol/routes.ts @@ -0,0 +1,7 @@ +/** + * Queries and commands travel over tRPC (`sessions.*`); the only raw HTTP + * surface this package owns is the synchronization WebSocket. Protocol + * versioning lives in the hello/hello_ack handshake, never in route names. + */ +export const SESSIONS_SYNC_PATH = "/sessions/sync"; +export const SESSIONS_SYNC_WEBSOCKET_PROTOCOL = "superset.sessions.sync"; diff --git a/packages/host-service-sync/src/protocol/sync.ts b/packages/host-service-sync/src/protocol/sync.ts new file mode 100644 index 00000000000..811a8ed0c59 --- /dev/null +++ b/packages/host-service-sync/src/protocol/sync.ts @@ -0,0 +1,314 @@ +import { z } from "zod"; +import { hostEventSchema, sessionEventSchema } from "./events"; +import { + clientInstanceIdSchema, + connectionIdSchema, + cursorSchema, + hostIdSchema, + protocolVersionSchema, + requestIdSchema, + sessionIdSchema, + subscriptionIdSchema, + syncErrorCodeSchema, + threadIdSchema, + timestampSchema, +} from "./primitives"; + +export const toolResolverDescriptorSchema = z.object({ + name: z.string().min(1).max(256), + version: z.number().int().safe().positive(), +}); + +export const clientVersionSchema = z.string().min(1).max(256); + +export const syncStreamSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("host") }), + z.object({ type: z.literal("session"), sessionId: sessionIdSchema }), +]); + +export const helloPacketSchema = z.object({ + type: z.literal("hello"), + // The handshake is the only place the protocol version appears. + protocolVersion: protocolVersionSchema, + requestId: requestIdSchema, + clientInstanceId: clientInstanceIdSchema, + clientVersion: clientVersionSchema, + toolResolvers: z.array(toolResolverDescriptorSchema).max(1_000), +}); + +export const toolResolversChangedPacketSchema = z.object({ + type: z.literal("toolResolversChanged"), + requestId: requestIdSchema, + toolResolvers: z.array(toolResolverDescriptorSchema).max(1_000), +}); + +/** + * `after` is required: snapshots ride tRPC (`sessions.list` / `sessions.get` + * return `head`), so a subscribe always resumes from a cursor the host + * handed out. An unservable cursor yields `reset` and the client re-runs + * the tRPC cold path — there is no snapshot-over-socket fallback. + */ +export const subscribePacketSchema = z.object({ + type: z.literal("subscribe"), + requestId: requestIdSchema, + subscriptionId: subscriptionIdSchema, + stream: syncStreamSchema, + after: cursorSchema, +}); + +export const unsubscribePacketSchema = z.object({ + type: z.literal("unsubscribe"), + requestId: requestIdSchema, + subscriptionId: subscriptionIdSchema, +}); + +export const pingPacketSchema = z.object({ + type: z.literal("ping"), + nonce: z.string().min(1).max(256), +}); + +export const syncClientPacketSchema = z.discriminatedUnion("type", [ + helloPacketSchema, + toolResolversChangedPacketSchema, + subscribePacketSchema, + unsubscribePacketSchema, + pingPacketSchema, +]); + +export const helloAckPacketSchema = z.object({ + type: z.literal("helloAck"), + protocolVersion: protocolVersionSchema, + requestId: requestIdSchema, + hostId: hostIdSchema, + connectionId: connectionIdSchema, + serverTime: timestampSchema, + limits: z.object({ + maxSubscriptions: z.number().int().safe().positive(), + maxFrameBytes: z.number().int().safe().positive(), + }), +}); + +export const subscribedPacketSchema = z + .object({ + type: z.literal("subscribed"), + requestId: requestIdSchema, + subscriptionId: subscriptionIdSchema, + stream: z.enum(["host", "session"]), + sessionId: sessionIdSchema.nullable(), + replay: z.object({ + fromExclusive: cursorSchema, + through: cursorSchema, + }), + }) + .superRefine((packet, context) => { + if ( + (packet.stream === "host" && packet.sessionId !== null) || + (packet.stream === "session" && packet.sessionId === null) + ) { + context.addIssue({ + code: "custom", + path: ["sessionId"], + message: "sessionId presence does not match stream", + }); + } + }); + +export const hostEventPacketSchema = z + .object({ + type: z.literal("event"), + hostId: hostIdSchema, + subscriptionId: subscriptionIdSchema, + stream: z.literal("host"), + sessionId: sessionIdSchema, + threadId: threadIdSchema.nullable(), + cursor: cursorSchema, + event: hostEventSchema, + }) + .superRefine((packet, context) => { + const event = packet.event; + const nestedSessionId = + event.type === "sessionUpsert" + ? event.session.id + : event.type === "permissionAvailable" + ? event.permission.sessionId + : event.type === "clientToolCallAvailable" + ? event.toolCall.sessionId + : packet.sessionId; + if (nestedSessionId !== packet.sessionId) { + context.addIssue({ + code: "custom", + path: ["event"], + message: "host event sessionId does not match packet sessionId", + }); + } + const nestedThreadId = + event.type === "permissionAvailable" + ? event.permission.threadId + : event.type === "clientToolCallAvailable" + ? event.toolCall.threadId + : null; + if (nestedThreadId !== null && packet.threadId !== nestedThreadId) { + context.addIssue({ + code: "custom", + path: ["threadId"], + message: "host event threadId does not match packet threadId", + }); + } + }); + +export const sessionEventPacketSchema = z + .object({ + type: z.literal("event"), + hostId: hostIdSchema, + subscriptionId: subscriptionIdSchema, + stream: z.literal("session"), + sessionId: sessionIdSchema, + threadId: threadIdSchema, + cursor: cursorSchema, + event: sessionEventSchema, + }) + .superRefine((packet, context) => { + if (packet.event.sessionId !== packet.sessionId) { + context.addIssue({ + code: "custom", + path: ["event", "sessionId"], + message: "session event sessionId does not match packet sessionId", + }); + } + if (packet.event.threadId !== packet.threadId) { + context.addIssue({ + code: "custom", + path: ["event", "threadId"], + message: "session event threadId does not match packet threadId", + }); + } + if (packet.event.cursor !== packet.cursor) { + // The hub delivers each event at its own cursor; a divergence would + // leave reconnect state and the stored log at different positions. + context.addIssue({ + code: "custom", + path: ["event", "cursor"], + message: "session event cursor does not match packet cursor", + }); + } + }); + +export const caughtUpPacketSchema = z + .object({ + type: z.literal("caughtUp"), + subscriptionId: subscriptionIdSchema, + stream: z.enum(["host", "session"]), + sessionId: sessionIdSchema.nullable(), + through: cursorSchema, + }) + .superRefine((packet, context) => { + if ( + (packet.stream === "host" && packet.sessionId !== null) || + (packet.stream === "session" && packet.sessionId === null) + ) { + context.addIssue({ + code: "custom", + path: ["sessionId"], + message: "sessionId presence does not match stream", + }); + } + }); + +export const unsubscribedPacketSchema = z + .object({ + type: z.literal("unsubscribed"), + requestId: requestIdSchema, + subscriptionId: subscriptionIdSchema, + stream: z.enum(["host", "session"]), + sessionId: sessionIdSchema.nullable(), + through: cursorSchema.nullable(), + }) + .superRefine((packet, context) => { + if ( + (packet.stream === "host" && packet.sessionId !== null) || + (packet.stream === "session" && packet.sessionId === null) + ) { + context.addIssue({ + code: "custom", + path: ["sessionId"], + message: "sessionId presence does not match stream", + }); + } + }); + +/** + * Reset recovery is always the cold path: re-fetch the snapshot over tRPC + * (`list` for the host stream, `get` for a session stream) and resubscribe + * from its head. One enum value on purpose — recovery must never fork. + */ +export const resetPacketSchema = z + .object({ + type: z.literal("reset"), + subscriptionId: subscriptionIdSchema, + stream: z.enum(["host", "session"]), + sessionId: sessionIdSchema.nullable(), + code: z.enum(["CURSOR_EXPIRED", "CURSOR_INVALID", "HISTORY_REWRITTEN"]), + recovery: z.literal("refetchSnapshot"), + }) + .superRefine((packet, context) => { + if ( + (packet.stream === "host" && packet.sessionId !== null) || + (packet.stream === "session" && packet.sessionId === null) + ) { + context.addIssue({ + code: "custom", + path: ["sessionId"], + message: "sessionId presence does not match stream", + }); + } + }); + +export const syncErrorPacketSchema = z.object({ + type: z.literal("error"), + requestId: requestIdSchema.nullable(), + subscriptionId: subscriptionIdSchema.nullable(), + sessionId: sessionIdSchema.nullable(), + code: syncErrorCodeSchema, + retryable: z.boolean(), +}); + +export const pongPacketSchema = z.object({ + type: z.literal("pong"), + nonce: z.string().min(1).max(256), +}); + +export const syncServerPacketSchema = z.union([ + helloAckPacketSchema, + subscribedPacketSchema, + hostEventPacketSchema, + sessionEventPacketSchema, + caughtUpPacketSchema, + unsubscribedPacketSchema, + resetPacketSchema, + syncErrorPacketSchema, + pongPacketSchema, +]); + +export type ToolResolverDescriptor = z.infer< + typeof toolResolverDescriptorSchema +>; +export type SyncStream = z.infer; +export type HelloPacket = z.infer; +export type ToolResolversChangedPacket = z.infer< + typeof toolResolversChangedPacketSchema +>; +export type SubscribePacket = z.infer; +export type UnsubscribePacket = z.infer; +export type PingPacket = z.infer; +export type SyncClientPacket = z.infer; +export type HelloAckPacket = z.infer; +export type SubscribedPacket = z.infer; +export type HostEventPacket = z.infer; +export type SessionEventPacket = z.infer; +export type SyncEventPacket = HostEventPacket | SessionEventPacket; +export type CaughtUpPacket = z.infer; +export type UnsubscribedPacket = z.infer; +export type ResetPacket = z.infer; +export type SyncErrorPacket = z.infer; +export type PongPacket = z.infer; +export type SyncServerPacket = z.infer; diff --git a/packages/host-service-sync/src/timeline/index.ts b/packages/host-service-sync/src/timeline/index.ts new file mode 100644 index 00000000000..ab509301ccb --- /dev/null +++ b/packages/host-service-sync/src/timeline/index.ts @@ -0,0 +1 @@ +export * from "./timeline"; diff --git a/packages/host-service-sync/src/timeline/timeline.test.ts b/packages/host-service-sync/src/timeline/timeline.test.ts new file mode 100644 index 00000000000..244f79e3f3b --- /dev/null +++ b/packages/host-service-sync/src/timeline/timeline.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, test } from "bun:test"; +import type { SessionEvent, SessionEventPayload } from "../protocol/events"; +import { + emptyTimeline, + foldTimeline, + makeSelectedOutcome, + selectedOptionIds, + type TimelineMessageItem, + type TimelineToolCallItem, +} from "./timeline"; + +const SESSION_ID = "session-1"; +const MAIN_THREAD = "session-1:main"; + +function makeEvents( + payloads: Array< + SessionEventPayload | { threadId: string; payload: SessionEventPayload } + >, +): SessionEvent[] { + return payloads.map((entry, index) => { + const payload = "payload" in entry ? entry.payload : entry; + const threadId = "payload" in entry ? entry.threadId : MAIN_THREAD; + return { + id: `event-${index + 1}`, + sessionId: SESSION_ID, + threadId, + cursor: `cursor-${index + 1}`, + occurredAt: 1_000 + index, + causationId: null, + payload, + }; + }); +} + +function userMessage( + id: string, + turnId: string, + text: string, +): SessionEventPayload { + return { + type: "messageStarted", + message: { + id, + sessionId: SESSION_ID, + threadId: MAIN_THREAD, + turnId, + role: "user", + content: [{ type: "text", text }], + createdAt: 1_000, + }, + }; +} + +function assistantMessageStarted( + id: string, + turnId: string, +): SessionEventPayload { + return { + type: "messageStarted", + message: { + id, + sessionId: SESSION_ID, + threadId: MAIN_THREAD, + turnId, + role: "assistant", + content: [], + createdAt: 1_001, + }, + }; +} + +function toolCallStarted( + toolCallId: string, + overrides: Partial<{ + parentToolCallId: string | null; + threadId: string; + title: string; + }> = {}, +): SessionEventPayload { + return { + type: "toolCallStarted", + toolCall: { + id: toolCallId, + sessionId: SESSION_ID, + threadId: overrides.threadId ?? MAIN_THREAD, + turnId: "turn-1", + parentToolCallId: overrides.parentToolCallId ?? null, + tool: { name: "bash", version: 1 }, + title: overrides.title ?? "Run command", + input: { command: "ls" }, + resolver: { type: "host" }, + state: "running", + createdAt: 1_002, + updatedAt: 1_002, + expiresAt: null, + }, + }; +} + +describe("foldTimeline", () => { + test("folds a user prompt and streamed assistant reply with thought split", () => { + const events = makeEvents([ + userMessage("m-user", "turn-1", "say hi"), + assistantMessageStarted("m-agent", "turn-1"), + { + type: "messageDelta", + messageId: "m-agent", + content: { type: "thought", text: "thinking about " }, + }, + { + type: "messageDelta", + messageId: "m-agent", + content: { type: "thought", text: "greetings" }, + }, + { + type: "messageDelta", + messageId: "m-agent", + content: { type: "text", text: "hi " }, + }, + { + type: "messageDelta", + messageId: "m-agent", + content: { type: "text", text: "there" }, + }, + { type: "messageCompleted", messageId: "m-agent" }, + ]); + const timeline = foldTimeline(emptyTimeline(), events); + expect(timeline.items).toHaveLength(3); + const [user, thought, reply] = timeline.items as TimelineMessageItem[]; + expect(user?.role).toBe("user"); + expect(user?.blocks).toEqual([{ type: "text", text: "say hi" }]); + expect(thought?.role).toBe("thought"); + expect(thought?.blocks).toEqual([ + { type: "text", text: "thinking about greetings" }, + ]); + expect(reply?.role).toBe("agent"); + expect(reply?.blocks).toEqual([{ type: "text", text: "hi there" }]); + }); + + test("incremental fold matches folding all events at once", () => { + const events = makeEvents([ + userMessage("m-user", "turn-1", "say hi"), + assistantMessageStarted("m-agent", "turn-1"), + { + type: "messageDelta", + messageId: "m-agent", + content: { type: "text", text: "hi " }, + }, + { + type: "messageDelta", + messageId: "m-agent", + content: { type: "text", text: "there" }, + }, + ]); + const full = foldTimeline(emptyTimeline(), events); + const first = foldTimeline(emptyTimeline(), events.slice(0, 2)); + const resumed = foldTimeline(first, events); + expect(resumed.items).toEqual(full.items); + expect(resumed.eventCount).toBe(events.length); + // The earlier fold value is untouched (copy-on-write): the empty-content + // assistant messageStarted creates no item until deltas arrive. + expect(first.items).toHaveLength(1); + }); + + test("tool call updates merge and permissions attach and resolve", () => { + const events = makeEvents([ + toolCallStarted("tool-1"), + { + type: "permissionRequested", + permission: { + id: "perm-1", + sessionId: SESSION_ID, + threadId: MAIN_THREAD, + toolCallId: "tool-1", + options: [ + { id: "allow", name: "Allow", kind: "allowOnce" }, + { id: "deny", name: "Deny", kind: "rejectOnce" }, + ], + multiSelect: false, + requestedAt: 1_003, + }, + }, + ]); + let timeline = foldTimeline(emptyTimeline(), events); + expect(timeline.pendingPermissions).toHaveLength(1); + expect(timeline.pendingPermissions[0]?.toolCall.title).toBe("Run command"); + + const resolved = makeEvents([ + toolCallStarted("tool-1"), + events[1]?.payload as SessionEventPayload, + { + type: "permissionResolved", + permissionId: "perm-1", + outcome: { type: "selected", optionIds: ["allow"] }, + }, + { + type: "toolCallUpdated", + toolCallId: "tool-1", + update: { state: "succeeded", output: { ok: true }, updatedAt: 1_005 }, + }, + ]); + timeline = foldTimeline(emptyTimeline(), resolved); + expect(timeline.pendingPermissions).toHaveLength(0); + const item = timeline.items[0] as TimelineToolCallItem; + expect(item.call.state).toBe("succeeded"); + expect(item.call.output).toEqual({ ok: true }); + expect(item.permissions[0]?.resolution).toEqual({ + type: "selected", + optionIds: ["allow"], + }); + }); + + test("subagent thread items nest under the spawning tool call", () => { + const subThread = "session-1:sub"; + const events = makeEvents([ + toolCallStarted("task-1", { title: "Task" }), + { + type: "threadCreated", + thread: { + id: subThread, + sessionId: SESSION_ID, + kind: "subagent", + parentThreadId: MAIN_THREAD, + origin: { + type: "subagent", + spawnedByEventId: "event-1", + spawnedByToolCallId: "task-1", + }, + fidelity: "full", + title: null, + runState: "running", + eventHead: null, + createdAt: 1_003, + updatedAt: 1_003, + lastActivityAt: 1_003, + }, + }, + { + threadId: subThread, + payload: toolCallStarted("tool-child", { + threadId: subThread, + title: "Child", + }), + }, + ]); + const timeline = foldTimeline(emptyTimeline(), events); + expect(timeline.items).toHaveLength(1); + const task = timeline.items[0] as TimelineToolCallItem; + expect(task.id).toBe("task-1"); + expect(task.children).toHaveLength(1); + expect((task.children[0] as TimelineToolCallItem).id).toBe("tool-child"); + }); + + test("plan upserts in place and empty plan marks removed", () => { + const entry = (id: string, status: "pending" | "completed") => ({ + id, + content: `step ${id}`, + status, + priority: null, + }); + const events = makeEvents([ + { type: "planUpdated", plan: [entry("1", "pending")] }, + { type: "planUpdated", plan: [entry("1", "completed")] }, + ]); + let timeline = foldTimeline(emptyTimeline(), events); + expect(timeline.items).toHaveLength(1); + expect( + timeline.items[0]?.kind === "plan" && + timeline.items[0].entries[0]?.status, + ).toBe("completed"); + + timeline = foldTimeline( + timeline, + [...events, ...makeEvents([{ type: "planUpdated", plan: [] }])].map( + (event, index) => ({ ...event, id: `event-${index + 1}` }), + ), + ); + expect( + timeline.items[0]?.kind === "plan" && timeline.items[0].removed, + ).toBe(true); + }); + + test("turnFailed marks the turn's user message and records the error", () => { + const events = makeEvents([ + userMessage("m-user", "turn-1", "do a thing"), + { + type: "turnFailed", + turnId: "turn-1", + error: { + code: "INTERNAL_ERROR", + retryable: false, + recovery: "none", + occurredAt: 1_001, + }, + }, + ]); + const timeline = foldTimeline(emptyTimeline(), events); + const user = timeline.items[0] as TimelineMessageItem; + expect(user.failed).toBe(true); + expect(timeline.lastError).toBe("INTERNAL_ERROR"); + }); +}); + +describe("outcome helpers", () => { + test("round-trips single and multi selections", () => { + expect(selectedOptionIds(makeSelectedOutcome(["a"]))).toEqual(["a"]); + expect(selectedOptionIds(makeSelectedOutcome(["a", "b"]))).toEqual([ + "a", + "b", + ]); + expect(selectedOptionIds({ type: "cancelled" })).toEqual([]); + expect(() => makeSelectedOutcome([])).toThrow(); + }); +}); diff --git a/packages/host-service-sync/src/timeline/timeline.ts b/packages/host-service-sync/src/timeline/timeline.ts new file mode 100644 index 00000000000..e4c75b9fb95 --- /dev/null +++ b/packages/host-service-sync/src/timeline/timeline.ts @@ -0,0 +1,602 @@ +import type { + ContentBlock, + PermissionOption, + PermissionOutcome, + PlanEntry, + SessionSettings, + ToolCall, +} from "../protocol/entities"; +import type { SessionEvent } from "../protocol/events"; +import type { JsonValue } from "../protocol/primitives"; + +// --------------------------------------------------------------------------- +// Timeline model: canonical SessionEvents folded into renderable items. The +// successor of session-protocol's envelope fold, rebuilt over the sessions +// sync protocol (plans/host-sessions-sync.md). Pure and framework-free: the +// React bindings memoize over it, the fold itself never touches a store. +// --------------------------------------------------------------------------- + +export type TimelineMessageRole = "user" | "agent" | "thought"; + +export interface TimelineMessageItem { + kind: "message"; + /** Stable render key: `${messageId}:${role}:${ordinal}`. */ + id: string; + role: TimelineMessageRole; + /** Null when the owning message started before this window. */ + turnId: string | null; + blocks: ContentBlock[]; + /** True when the turn carrying this user message failed. */ + failed: boolean; +} + +export interface TimelinePermissionView { + permissionId: string; + options: PermissionOption[]; + requestedAt: number; + /** Collect picks and answer on Done instead of resolving on first tap. */ + multiSelect: boolean; + /** null while unresolved. */ + resolution: PermissionOutcome | null; +} + +/** Canonical ToolCall plus the output accumulated from toolCallUpdated. */ +export interface TimelineToolCall extends ToolCall { + output: JsonValue | null; +} + +export interface TimelineToolCallItem { + kind: "tool_call"; + /** The canonical toolCallId. */ + id: string; + call: TimelineToolCall; + permissions: TimelinePermissionView[]; + /** + * Nested activity: child tool calls (`parentToolCallId`) and the timelines + * of subagent threads spawned by this call (`thread.origin. + * spawnedByToolCallId`) render inside their parent's card. + */ + children: TimelineItem[]; +} + +export interface TimelinePlanItem { + kind: "plan"; + id: string; + entries: PlanEntry[]; + removed: boolean; +} + +export type TimelineItem = + | TimelineMessageItem + | TimelineToolCallItem + | TimelinePlanItem; + +/** A blocking ask, denormalized for permission cards above the composer. */ +export interface TimelinePendingPermission { + permissionId: string; + sessionId: string; + toolCallId: string; + /** Title/input snapshot of the tool call at request time (may update). */ + toolCall: { title: string; input: JsonValue }; + options: PermissionOption[]; + multiSelect: boolean; + requestedAt: number; +} + +export interface Timeline { + items: TimelineItem[]; + /** Unresolved permission asks, oldest first. */ + pendingPermissions: TimelinePendingPermission[]; + /** Latest settingsUpdated payload, if any event carried one. */ + settings: SessionSettings | null; + /** Error code of the last `error`/`turnFailed` event (banner copy). */ + lastError: string | null; + /** The running turn's id (cancelTurn target), null between turns. */ + activeTurnId: string | null; + /** Number of events folded so far — the incremental-fold cursor. */ + eventCount: number; +} + +export function emptyTimeline(): Timeline { + return { + items: [], + pendingPermissions: [], + settings: null, + lastError: null, + activeTurnId: null, + eventCount: 0, + }; +} + +/** + * Fold `events[timeline.eventCount..]` into a new Timeline. Pure with + * copy-on-write along touched paths, so React consumers see reference + * changes exactly when content changes. Events must be the session's full + * ordered list (the sync client store's job); to re-fold after a prepend or + * reset, start from `emptyTimeline()`. + */ +export function foldTimeline( + timeline: Timeline, + events: readonly SessionEvent[], +): Timeline { + if (events.length <= timeline.eventCount) { + return timeline; + } + const next: FoldState = { + items: [...timeline.items], + pendingPermissions: [...timeline.pendingPermissions], + settings: timeline.settings, + lastError: timeline.lastError, + activeTurnId: timeline.activeTurnId, + eventCount: timeline.eventCount, + messageRoles: new Map(), + messageTurns: new Map(), + threadParents: new Map(), + }; + rebuildFoldIndexes(next, events); + for (let i = timeline.eventCount; i < events.length; i++) { + const event = events[i]; + if (event !== undefined) { + foldEvent(next, event); + } + next.eventCount = i + 1; + } + return { + items: next.items, + pendingPermissions: next.pendingPermissions, + settings: next.settings, + lastError: next.lastError, + activeTurnId: next.activeTurnId, + eventCount: next.eventCount, + }; +} + +// --------------------------------------------------------------------------- +// Outcome helpers shared by permission UIs. +// --------------------------------------------------------------------------- + +export function makeSelectedOutcome( + optionIds: readonly string[], +): PermissionOutcome { + if (optionIds.length === 0) { + throw new Error("makeSelectedOutcome requires at least one optionId"); + } + return { type: "selected", optionIds: [...optionIds] }; +} + +/** Every option id a `selected` outcome carries; [] for cancelled. */ +export function selectedOptionIds(outcome: PermissionOutcome): string[] { + return outcome.type === "selected" ? [...outcome.optionIds] : []; +} + +// --------------------------------------------------------------------------- +// Internals. +// --------------------------------------------------------------------------- + +interface FoldState extends Timeline { + /** messageId → canonical role, for routing deltas. */ + messageRoles: Map; + /** messageId → owning turn, for turn-failure marking. */ + messageTurns: Map; + /** threadId → toolCallId whose card hosts that thread's items. */ + threadParents: Map; +} + +/** + * The message-role, message-turn, and thread-parent indexes are derivable + * facts, not carried on the public Timeline (it stays a plain renderable + * value). Incremental folds rebuild them by rescanning the already-folded + * prefix — a cheap field read per event — so a fold of any granularity + * produces the same Timeline as one full fold. + */ +function rebuildFoldIndexes( + state: FoldState, + events: readonly SessionEvent[], +): void { + const end = Math.min(state.eventCount, events.length); + for (let i = 0; i < end; i++) { + const payload = events[i]?.payload; + if (payload === undefined) continue; + if (payload.type === "messageStarted") { + state.messageRoles.set(payload.message.id, payload.message.role); + state.messageTurns.set(payload.message.id, payload.message.turnId); + } else if ( + (payload.type === "threadCreated" || payload.type === "threadUpdated") && + payload.thread.origin.type === "subagent" + ) { + state.threadParents.set( + payload.thread.id, + payload.thread.origin.spawnedByToolCallId, + ); + } + } +} + +function foldEvent(state: FoldState, event: SessionEvent): void { + const payload = event.payload; + switch (payload.type) { + case "threadCreated": + case "threadUpdated": { + if (payload.thread.origin.type === "subagent") { + state.threadParents.set( + payload.thread.id, + payload.thread.origin.spawnedByToolCallId, + ); + } + break; + } + case "turnStarted": { + // Only the main thread's turn is cancellable from the composer; + // subagent turns come and go underneath it. + if (!state.threadParents.has(event.threadId)) { + state.activeTurnId = payload.turn.id; + } + break; + } + case "turnFailed": { + markTurnFailed(state, payload.turnId); + state.lastError = payload.error.code; + if (state.activeTurnId === payload.turnId) state.activeTurnId = null; + break; + } + case "turnCompleted": + case "turnCancelled": { + if (state.activeTurnId === payload.turnId) state.activeTurnId = null; + break; + } + case "messageStarted": { + state.messageRoles.set(payload.message.id, payload.message.role); + state.messageTurns.set(payload.message.id, payload.message.turnId); + for (const block of payload.message.content) { + appendMessageBlock(state, event, payload.message.id, block); + } + break; + } + case "messageDelta": { + appendMessageBlock(state, event, payload.messageId, payload.content); + break; + } + case "messageCompleted": + break; + case "toolCallStarted": { + upsertToolCall(state, event, payload.toolCall.id, (existing) => + existing + ? { + ...existing, + call: { ...payload.toolCall, output: existing.call.output }, + } + : { + kind: "tool_call", + id: payload.toolCall.id, + call: { ...payload.toolCall, output: null }, + permissions: [], + children: [], + }, + ); + break; + } + case "toolCallUpdated": { + upsertToolCall(state, event, payload.toolCallId, (existing) => { + const base: TimelineToolCallItem = existing ?? { + kind: "tool_call", + id: payload.toolCallId, + call: placeholderToolCall(event, payload.toolCallId), + permissions: [], + children: [], + }; + const update = payload.update; + return { + ...base, + call: { + ...base.call, + ...(update.title !== undefined ? { title: update.title } : {}), + ...(update.input !== undefined ? { input: update.input } : {}), + ...(update.state !== undefined ? { state: update.state } : {}), + ...(update.output !== undefined ? { output: update.output } : {}), + updatedAt: update.updatedAt, + }, + }; + }); + break; + } + case "permissionRequested": { + const permission = payload.permission; + const view: TimelinePermissionView = { + permissionId: permission.id, + options: permission.options, + requestedAt: permission.requestedAt, + multiSelect: permission.multiSelect, + resolution: null, + }; + upsertToolCall(state, event, permission.toolCallId, (existing) => { + const base: TimelineToolCallItem = existing ?? { + kind: "tool_call", + id: permission.toolCallId, + call: placeholderToolCall(event, permission.toolCallId), + permissions: [], + children: [], + }; + return { ...base, permissions: [...base.permissions, view] }; + }); + const host = findToolCall(state.items, permission.toolCallId); + state.pendingPermissions.push({ + permissionId: permission.id, + sessionId: permission.sessionId, + toolCallId: permission.toolCallId, + toolCall: { + title: host?.call.title ?? "", + input: host?.call.input ?? null, + }, + options: permission.options, + multiSelect: permission.multiSelect, + requestedAt: permission.requestedAt, + }); + break; + } + case "permissionResolved": { + resolvePermissionView(state.items, payload.permissionId, payload.outcome); + state.pendingPermissions = state.pendingPermissions.filter( + (pending) => pending.permissionId !== payload.permissionId, + ); + break; + } + case "planUpdated": { + const container = containerFor(state, event.threadId); + const existing = findOpenPlan(container); + if (existing) { + replaceItem(container, existing, { + ...existing, + entries: payload.plan, + removed: payload.plan.length === 0, + }); + } else if (payload.plan.length > 0) { + container.push({ + kind: "plan", + id: `plan:${event.id}`, + entries: payload.plan, + removed: false, + }); + } + break; + } + case "settingsUpdated": { + state.settings = payload.settings; + break; + } + case "error": { + state.lastError = payload.error.code; + break; + } + default: { + payload satisfies never; + break; + } + } +} + +/** + * Resolve the item array an event's thread renders into: the root list for + * the main thread, or the spawning tool call's children for a subagent + * thread. Copy-on-write happens in the callers via patchToolCall. + */ +function containerFor(state: FoldState, threadId: string): TimelineItem[] { + const parentToolCallId = state.threadParents.get(threadId); + if (parentToolCallId === undefined) return state.items; + const parent = findToolCall(state.items, parentToolCallId); + if (!parent) return state.items; + // Mutating children in place would leak into the previous fold; swap the + // parent for a copy first and return the fresh array. + let fresh: TimelineItem[] = state.items; + patchToolCall(state.items, parentToolCallId, (item) => { + const children = [...item.children]; + fresh = children; + return { ...item, children }; + }); + return fresh; +} + +function appendMessageBlock( + state: FoldState, + event: SessionEvent, + messageId: string, + block: ContentBlock, +): void { + const canonicalRole = state.messageRoles.get(messageId) ?? "assistant"; + const role: TimelineMessageRole = + canonicalRole === "user" + ? "user" + : block.type === "thought" + ? "thought" + : "agent"; + // Thought blocks render as text inside the Reasoning collapsible. + const renderBlock: ContentBlock = + block.type === "thought" ? { type: "text", text: block.text } : block; + const container = containerFor(state, event.threadId); + const last = container[container.length - 1]; + if ( + last?.kind === "message" && + last.role === role && + last.id.startsWith(`${messageId}:`) + ) { + const blocks = [...last.blocks]; + const previous = blocks[blocks.length - 1]; + if (previous?.type === "text" && renderBlock.type === "text") { + // Streaming fragments of one message concatenate; whole user blocks + // get a paragraph break (the host journals prompts as full blocks). + const separator = role === "user" ? "\n\n" : ""; + blocks[blocks.length - 1] = { + ...previous, + text: previous.text + separator + renderBlock.text, + }; + } else { + blocks.push(renderBlock); + } + container[container.length - 1] = { ...last, blocks }; + return; + } + container.push({ + kind: "message", + id: `${messageId}:${role}:${state.eventCount}`, + role, + turnId: state.messageTurns.get(messageId) ?? null, + blocks: [renderBlock], + failed: false, + }); +} + +function markTurnFailed(state: FoldState, turnId: string): void { + for (let i = state.items.length - 1; i >= 0; i--) { + const item = state.items[i]; + if ( + item?.kind === "message" && + item.role === "user" && + item.turnId === turnId + ) { + state.items[i] = { ...item, failed: true }; + return; + } + } + // Fallback: the rejection always follows the journaled prompt closely. + for (let i = state.items.length - 1; i >= 0; i--) { + const item = state.items[i]; + if (item?.kind === "message" && item.role === "user") { + state.items[i] = { ...item, failed: true }; + return; + } + } +} + +function placeholderToolCall( + event: SessionEvent, + toolCallId: string, +): TimelineToolCall { + // A permission/update for a call we never saw (history page cut mid-turn): + // synthesize enough of a record that the item stays renderable. + return { + id: toolCallId, + sessionId: event.sessionId, + threadId: event.threadId, + turnId: "", + parentToolCallId: null, + tool: { name: "unknown", version: 1 }, + title: "", + input: null, + resolver: { type: "host" }, + state: "requested", + createdAt: event.occurredAt, + updatedAt: event.occurredAt, + expiresAt: null, + output: null, + }; +} + +function upsertToolCall( + state: FoldState, + event: SessionEvent, + toolCallId: string, + build: (existing: TimelineToolCallItem | null) => TimelineToolCallItem, +): void { + if (patchToolCall(state.items, toolCallId, (item) => build(item))) { + return; + } + const item = build(null); + const parentId = + item.call.parentToolCallId ?? state.threadParents.get(event.threadId); + if ( + parentId !== undefined && + parentId !== null && + patchToolCall(state.items, parentId, (parent) => ({ + ...parent, + children: [...parent.children, item], + })) + ) { + return; + } + containerFor(state, event.threadId).push(item); +} + +/** + * Find a tool call anywhere in the tree (newest-first at every level) and + * swap it via `patch`, rewriting ancestors copy-on-write. + */ +function patchToolCall( + items: TimelineItem[], + toolCallId: string, + patch: (item: TimelineToolCallItem) => TimelineToolCallItem, +): boolean { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item?.kind !== "tool_call") continue; + if (item.id === toolCallId) { + items[i] = patch(item); + return true; + } + if (item.children.length === 0) continue; + const children = [...item.children]; + if (patchToolCall(children, toolCallId, patch)) { + items[i] = { ...item, children }; + return true; + } + } + return false; +} + +function findToolCall( + items: readonly TimelineItem[], + toolCallId: string, +): TimelineToolCallItem | null { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item?.kind !== "tool_call") continue; + if (item.id === toolCallId) return item; + const nested = findToolCall(item.children, toolCallId); + if (nested) return nested; + } + return null; +} + +function resolvePermissionView( + items: TimelineItem[], + permissionId: string, + outcome: PermissionOutcome, +): boolean { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item?.kind !== "tool_call") continue; + const index = item.permissions.findIndex( + (view) => view.permissionId === permissionId, + ); + if (index !== -1) { + const target = item.permissions[index]; + if (!target) return false; + const permissions = [...item.permissions]; + permissions[index] = { ...target, resolution: outcome }; + items[i] = { ...item, permissions }; + return true; + } + if (item.children.length === 0) continue; + const children = [...item.children]; + if (resolvePermissionView(children, permissionId, outcome)) { + items[i] = { ...item, children }; + return true; + } + } + return false; +} + +function findOpenPlan(items: TimelineItem[]): TimelinePlanItem | undefined { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item?.kind === "plan" && !item.removed) return item; + } + return undefined; +} + +function replaceItem( + items: TimelineItem[], + previous: TimelineItem, + next: TimelineItem, +): void { + const index = items.indexOf(previous); + if (index !== -1) items[index] = next; +} diff --git a/packages/host-service-sync/tsconfig.json b/packages/host-service-sync/tsconfig.json new file mode 100644 index 00000000000..1d01d748215 --- /dev/null +++ b/packages/host-service-sync/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@superset/typescript/base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["bun"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/host-service/docs/acp-sessions.md b/packages/host-service/docs/acp-sessions.md deleted file mode 100644 index 15d88112643..00000000000 --- a/packages/host-service/docs/acp-sessions.md +++ /dev/null @@ -1,294 +0,0 @@ -# ACP Sessions - -This is the current implementation reference for host-owned ACP sessions. The -active remaining work is tracked in -[`plans/acp-session-follow-ups.md`](../../../plans/acp-session-follow-ups.md). -The comparison page at -[`apps/mobile/plans/acp-vs-claude-sdk.html`](../../../apps/mobile/plans/acp-vs-claude-sdk.html) -is a protocol comparison, not the source of truth for lifecycle behavior. - -## Current Topology - -```text -mobile UI - -> @superset/host-client (relay HTTP + WebSocket transport) - -> host-service acpSessions tRPC router + /acp-sessions/:id/stream - -> AcpSessionManager - -> one claude-agent-acp child per active session - -> Claude's native on-disk session store - -shared client code today - @superset/session-protocol - - ACP type re-exports - - Superset state and envelope types - - tRPC input schemas and API interfaces - - timeline fold and WebSocket sync client - - React hooks under ./react -``` - -The desktop app only decides whether to enable the harness and starts the -host-service: canary and dev builds spawn host children with -`SUPERSET_ACP_SESSIONS=1`, stable builds never do (see -`apps/desktop/src/main/lib/build-channel.ts`). There is no user-facing -setting. The desktop app does not render this ACP session UI yet. - -On the current unified mobile home, long-press a workspace and choose **Live -sessions**. That opens the workspace-scoped ACP list; the old workspace chat -index was removed by the unified-home work and is not an entry point anymore. - -## Session Identity And Persistence - -The public Superset session id is different from the adapter's native ACP -session id. The host database stores the binding in `acp_sessions`: - -```text -session_id (primary key) -workspace_id -acp_session_id -harness -cwd -title -last_stop_reason -created_at -updated_at -``` - -The row is registry metadata only. Host SQLite does not store message bodies, -tool payloads, permission payloads, or journal frames. Conversation content -remains in Claude's native session store managed behind `claude-agent-acp`. - -Every state emission best-effort upserts the registry row. A registry write -failure is logged and does not stop a live turn, so restart recovery is not -guaranteed if the write failed. - -## Lifecycle - -### Create - -1. The client mints the public session id and calls `acpSessions.create`. -2. The host resolves the workspace id to its current worktree path. -3. `AcpSessionManager` spawns `claude-agent-acp`, runs `initialize`, then - `session/new`. -4. The host forces a newly created session out of the adapter's default - `bypassPermissions` mode. -5. The initial state frame enters the in-memory journal and the registry row is - written. - -Create is idempotent for the same public session and workspace. Reusing a -session id with a different workspace is a conflict. - -### Host Restart - -1. A new manager reads all `acp_sessions` rows into an `offline` map. -2. `list` and `get` return those rows as `status: "offline"` without spawning a - process. -3. A command, `getMessages`, or stream attach calls `ensureLive`. -4. The manager spawns a new adapter and calls `session/load` with the persisted - native id and cwd. -5. The adapter streams the complete native transcript as `session/update` - notifications. The manager builds a fresh bounded journal and then accepts - new turns. - -An in-flight turn does not survive a host restart. Replay can recover completed -transcript content, but pending permissions and process-local callbacks are -gone. Any replayed tool call left open is terminalized by the host. - -`session/load` failure leaves the registry row offline and retryable. The stream -route emits `reset { reason: "session_load_failed" }`; tRPC calls surface the -load error. Mobile renders that exact error in a destructive banner and keeps -the composer hidden until resurrection succeeds; it does not silently create a -replacement session. - -### Adapter Exit - -An adapter exit marks the runtime `dead`, resolves pending interactions as -cancelled, terminalizes open tool calls, and emits final state. Dead runtimes -remain readable in memory, with at most 20 retained per host process. Their -registry rows remain. A dead runtime is not restarted in the same host process; -after a host restart its registry row is offline and can be loaded again. - -There is no session delete or registry garbage collection path yet. - -## Memory And History - -Each active runtime holds: - -- mutable session state; -- the adapter child and ACP connection; -- pending permission resolvers and open tool ids; -- subscribers; -- a ring journal capped at 5,000 envelopes. - -The host does not hold a separate folded message list. The ring is bounded, so -memory does not grow for the full lifetime of an arbitrarily long session. -However, the current design still gives the ring two jobs: - -1. recent WebSocket catch-up with `?since=`; -2. `getMessages` history pagination. - -That second job is the remaining design problem. ACP `session/load` replays the -entire native transcript but exposes no paginated history API. The ring retains -only its newest 5,000 frames, so history older than the retained window cannot -be fetched. Shrinking the ring now would reduce available history rather than -move older pages to disk. - -The target design is explicit in the follow-up plan: `getMessages` pages a -disk-backed history source, while the in-memory ring keeps only a small recent -catch-up window. The React layer should retain only pages the user has loaded -plus a bounded live-event buffer. - -## Stream Contract - -Every envelope has a per-runtime numeric sequence: - -```ts -interface SessionUpdateEnvelope { - seq: number; - sessionId: string; - ts: number; - frame: SessionUpdateFrame; -} -``` - -Within one runtime incarnation, sequences are gapless. A subscriber resumes -from `since`; the server replays `(since, latest]`, then attaches the live -listener synchronously. Duplicate sequences are ignored by the client. A gap -causes reconnect. An evicted cursor causes `journal_evicted` reset and a full -state/history resync. - -A host restart creates a fresh numeric sequence space. The protocol does not -yet carry a journal incarnation id, so a pre-restart cursor whose number -overlaps the rebuilt journal is not always distinguishable from a current -cursor. Epoch-aware cursors are required before restart recovery can claim a -complete stale-cursor guarantee. - -## Type And Validation Status - -The answer to "is every boundary strictly typed and Zod-validated?" is no. - -Compile-time typing is mostly strict: - -- host, shared sync code, and mobile consume the same ACP and Superset types; -- production ACP code has no intentional `any`; -- official ACP extension fields such as `_meta`, `rawInput`, and `rawOutput` - remain `unknown` by protocol design. - -Runtime validation is incomplete: - -| Boundary | Current validation | -| --- | --- | -| tRPC inputs | Zod validates ids, cursors, limits, modes, and scalar config values. ACP content blocks and permission outcomes use shallow `z.custom` checks. | -| Adapter requests and notifications | The official ACP SDK parses registered request/notification params. | -| Adapter responses | Trusted from the SDK connection; response payloads are not parsed again by Superset. | -| Relay HTTP outputs | The outer tRPC/SuperJSON envelope is checked shallowly, then asserted to the caller's generic output type. | -| WebSocket envelopes | JSON syntax plus `seq` and `frame.kind` are checked; nested frame payloads are not fully parsed. | -| SQLite registry rows | Typed by Drizzle at compile time; no Zod parse or SQL enum checks on read. | - -The ACP SDK ships JSON Schema but does not export its generated Zod modules. -The follow-up work must provide canonical Superset validators for every authored -state/frame/page shape and a deliberate strategy for ACP payload validation. - -## Feature Gate - -The feature is internal-channel only. The desktop coordinator starts the host -with `SUPERSET_ACP_SESSIONS=1` on canary and dev builds -(`isInternalBuild()` in `apps/desktop/src/main/lib/build-channel.ts`); stable -builds never set it and there is no user-facing setting. - -- When off, the WebSocket route is not mounted. -- Every ACP tRPC procedure except `list` rejects with - `PRECONDITION_FAILED`. -- `list` returns `{ items: [], nextCursor: null, enabled: false }`, which is the - mobile capability probe. - -## Test Coverage - -### Authenticated real-Claude lane: primary evidence - -The `ACP_E2E=1` suites are the primary acceptance evidence for the ACP/model -boundary. Nothing at that boundary is mocked: they use the machine's logged-in -Claude account, a real Sonnet model, the pinned `claude-agent-acp` executable, -real ACP JSON-RPC over stdio, `AcpSessionManager`, and the real WebSocket -route/client. They are skipped in ordinary CI only because CI does not have a -Claude login and the runs spend real tokens; the skip does not make them -optional after relevant local changes. - -Run both suites on an authenticated Mac whenever changing the ACP runtime, -adapter or SDK version, Workflow handling, permissions, questions, -cancellation, streaming, reconnect, sequencing, or resurrection: - -```bash -cd packages/host-service -ACP_E2E=1 ACP_E2E_MODEL=sonnet ACP_E2E_EFFORT=low \ - bun test \ - test/integration/acp-sessions.integration.test.ts \ - test/integration/acp-sessions-stream.integration.test.ts -``` - -The manager suite proves real initialize/create/prompt/fold behavior, a saved -multi-agent Workflow, `AskUserQuestion`, real tool permissions, cancellation, -duplicate permission resolution, parallel tool use, and adapter death. The -Workflow test does not stop at the asynchronous launch acknowledgement: it -waits for the persisted Workflow run to reach `completed`, then asserts five -real Sonnet agents, non-zero token and tool usage, two parallel audits, and the -final structured verdict. - -The stream suite puts the same real adapter/model behind the real WebSocket -route and sync client. It proves identical gapless delivery to concurrent -subscribers, mid-turn disconnect/cursor reconnect without gaps or duplicates, -and eviction reset followed by a clean reattach. - -With `claude-agent-acp` 0.56.0, Claude emits two parallel tool uses together but -the adapter exposes their permission callbacks to Superset one at a time. The -manager and mobile stack still support truly simultaneous pending requests; -that manager behavior is pinned by the deterministic backup lane below. - -### Deterministic lane: belt and suspenders - -The always-run deterministic tests provide cheap breadth and precise failure -injection, but they do not prove real Claude or real-adapter compatibility. They -cover the journal, fold, reconnect client, generic host transport, router -mapping, fake-adapter ACP flow, WebSocket fan-out, permissions, elicitations, -concurrent permission requests, cancellation, adapter crash, eviction resets, -and registry-based manager resurrection. - -`acp-host-client.e2e.test.ts` also starts the real `createApp` HTTP/tRPC host -behind a local relay-shaped prefix and drives it only through -`@superset/host-client` plus the real WebSocket sync client. It closes the host, -server, adapter children, and an on-disk SQLite registry. Before restart it -answers an `AskUserQuestion` form, cancels an in-flight tool, handles two -simultaneous permissions, and catches up a disconnected stream. It then reopens -the DB, proves offline listing and `session/load` resurrection, and deletes a -native transcript to pin both the tRPC error and `session_load_failed` stream -reset. - -The mobile presentation helper has a colocated test that pins the corresponding -load-failure UX: the offline session cannot compose, the empty state says -`Session could not be resumed`, and the explanatory copy identifies the missing -native transcript. A full-device Maestro scenario is still required. - -Still required before treating the boundary as production-hardened: - -- a separate host OS process kill/respawn (the current boundary test rebuilds - the app/server/manager in one Bun test process); -- the production `better-sqlite3` driver under Node; -- stale cursors across journal incarnations; -- prompt/permission/cancel/config races; -- slow-subscriber and retention limits; -- a secure automated runner for the authenticated real-Claude lane, if CI can - provide isolated credentials and explicit usage accounting; -- iOS Maestro flows for restart, background/reconnect, pagination, and failures. - -## Source Map - -- Runtime: `packages/host-service/src/runtime/acp-sessions/` -- Router: `packages/host-service/src/trpc/router/acp-sessions/` -- Host DB table: `packages/host-service/src/db/schema.ts` -- Shared contracts/sync/hooks: `packages/session-protocol/` -- Shared relay transport: `packages/host-client/` -- Mobile binding: `apps/mobile/lib/host/client.ts` -- Mobile UI: `apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/` -- Mobile resume-failure presentation test: - `apps/mobile/screens/(authenticated)/workspace/[id]/chat/acp/[sessionId]/components/SessionThread/utils/getSessionThreadPresentation/` -- Focused integration plan: `plans/host-integration-test.md` -- All remaining work: `plans/acp-session-follow-ups.md` diff --git a/packages/host-service/docs/interim-router-types.md b/packages/host-service/docs/interim-router-types.md deleted file mode 100644 index d7a96018db7..00000000000 --- a/packages/host-service/docs/interim-router-types.md +++ /dev/null @@ -1,65 +0,0 @@ -# INTERIM: compiled router type declarations (`dist-types`) - -**Status: temporary.** This machinery exists so non-Node clients (today: `apps/mobile`) -can consume `AppRouter` without typechecking host-service's Node-flavored source under -their own tsconfig (Expo/RN fails on `.ts` import extensions, its ambient `ProcessEnv`, -and DOM-style timer types — in this package and its workspace deps). - -**End state that replaces it:** the wire contract moves to a runtime-neutral package -(zod schemas + inferred types per procedure; SCP v1 / `packages/chat-protocol` is the -same pattern for chat). Clients consume the contract package; host-service asserts its -router conforms. Once that lands, everything below gets reverted. - -## Every change that enables this (from `feat(host-service): ship compiled router type declarations for cross-runtime consumers`) - -Declaration emit for the router's type graph — the four packages the router types reach: - -| File | Change | -|---|---| -| `packages/host-service/tsconfig.types.json` | new — `emitDeclarationOnly` into `dist-types/` | -| `packages/host-service/package.json` | `build:types` script; **types-only `./router` export** → `./dist-types/trpc/router/router.d.ts` | -| `packages/port-scanner/{tsconfig.types.json,package.json}` | same, **plus `exports.*.types` repointed from `src/*.ts` to `dist-types/*.d.ts`** (affects all consumers, not just mobile) | -| `packages/pty-daemon/{tsconfig.types.json,package.json}` | same as port-scanner | -| `packages/workspace-fs/{tsconfig.types.json,package.json}` | same as port-scanner | -| `turbo.jsonc` | `build:types` task (`outputs: dist-types/**`); `typecheck.dependsOn` gains `^build:types` | -| `.gitignore` | `dist-types` (artifacts are generated, never committed) | - -Nameability shims — declaration emit can't reference unexported types (TS4023/TS2742), -so these were exported solely for the emit: - -| File | Change | -|---|---| -| `packages/host-service/src/trpc/index.ts` | `RouterMeta` exported | -| `packages/host-service/src/trpc/router/project/handlers.ts` | `CreateResult` exported | -| `packages/host-service/src/trpc/router/workspace-creation/procedures/search-github-issues.ts` | result type exported | -| `packages/host-service/src/trpc/router/workspace-creation/procedures/search-pull-requests.ts` | result type exported | -| `packages/chat/src/server/desktop/index.ts` | re-exports `AnthropicEnvVariables`, `AuthStatus`, `ResolvedSlashCommand` | - -Consumer side: - -| File | Change | -|---|---| -| `apps/mobile/package.json` | deps on `@superset/host-service` (type-only) and `@trpc/server` (for `inferRouterOutputs`) | -| `apps/mobile/lib/host-service/client.ts` | `import type { AppRouter } from "@superset/host-service/router"`; output aliases via `inferRouterOutputs` | - -## Known costs (why this isn't the end state) - -- IDE types go stale until `build:types` reruns (turbo covers CI, not your editor). -- Four packages carry build steps whose only consumer is mobile's typecheck. -- The `exports.types` repointing in port-scanner/pty-daemon/workspace-fs changed what - *desktop and every other consumer* typecheck against too. -- Server-internal types (mastra harness types in the chat router) still cross the wire. - -## Revert checklist (do this when the contract package lands) - -1. Point `apps/mobile/lib/host-service/client.ts` at the contract package's types; - drop mobile's `@superset/host-service` and `@trpc/server` deps. -2. Delete the four `tsconfig.types.json` files and their `build:types` scripts. -3. Restore `exports.*.types` in port-scanner/pty-daemon/workspace-fs to the `src/*.ts` - paths (see the commit named above for the originals). -4. Remove the `./router` export from `packages/host-service/package.json`. -5. Remove `build:types` from `turbo.jsonc` and `^build:types` from `typecheck.dependsOn`; - drop `dist-types` from `.gitignore`. -6. The nameability `export type` shims are harmless to keep, but no longer required. -7. `grep -rn "dist-types\|build:types\|host-service/router" --include="*.json" --include="*.ts"` - should come back empty (this doc excepted). diff --git a/packages/host-service/drizzle/0012_chunky_legion.sql b/packages/host-service/drizzle/0012_chunky_legion.sql new file mode 100644 index 00000000000..a9224f8b5be --- /dev/null +++ b/packages/host-service/drizzle/0012_chunky_legion.sql @@ -0,0 +1,8 @@ +CREATE TABLE `session_meta` ( + `session_id` text PRIMARY KEY NOT NULL, + `title_overridden` integer DEFAULT false NOT NULL, + `title` text, + `archived_at` integer, + `closed_at` integer, + `updated_at` integer NOT NULL +); diff --git a/packages/host-service/drizzle/meta/0012_snapshot.json b/packages/host-service/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000000..7c045fd6e9d --- /dev/null +++ b/packages/host-service/drizzle/meta/0012_snapshot.json @@ -0,0 +1,980 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "02a3462f-3137-4453-8bd3-20c081f848c3", + "prevId": "a0f12251-cf89-49f4-987c-ad404a666d0f", + "tables": { + "acp_sessions": { + "name": "acp_sessions", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acp_session_id": { + "name": "acp_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_stop_reason": { + "name": "last_stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "acp_sessions_workspace_id_idx": { + "name": "acp_sessions_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_agent_configs": { + "name": "host_agent_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_id": { + "name": "preset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "prompt_transport": { + "name": "prompt_transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_args_json": { + "name": "prompt_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_agent_configs_display_order_idx": { + "name": "host_agent_configs_display_order_idx", + "columns": [ + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_settings": { + "name": "host_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_meta": { + "name": "session_meta", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title_overridden": { + "name": "title_overridden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_agent_bindings": { + "name": "terminal_agent_bindings", + "columns": { + "terminal_id": { + "name": "terminal_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_type": { + "name": "last_event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "terminal_agent_bindings_workspace_id_idx": { + "name": "terminal_agent_bindings_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk": { + "name": "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk", + "tableFrom": "terminal_agent_bindings", + "tableTo": "terminal_sessions", + "columnsFrom": [ + "terminal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispose_requested_at": { + "name": "dispose_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_cloud_deletes": { + "name": "workspace_cloud_deletes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'worktree'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cloud_synced_at": { + "name": "cloud_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + }, + "workspaces_one_main_per_project": { + "name": "workspaces_one_main_per_project", + "columns": [ + "project_id" + ], + "isUnique": true, + "where": "type = 'main'" + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index 6ec29d73e99..7a5c0ade8ae 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1784490907723, "tag": "0011_project_identity_fields", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1784933535451, + "tag": "0012_chunky_legion", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 3ef4330437e..4f405391a9d 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -73,6 +73,7 @@ "@mastra/memory": "1.18.0", "@octokit/rest": "22.0.1", "@superset/chat": "workspace:*", + "@superset/host-service-sync": "workspace:*", "@superset/port-scanner": "workspace:*", "@superset/pty-daemon": "workspace:*", "@superset/session-protocol": "workspace:*", diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index e9a32802914..d9242c99955 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -2,6 +2,7 @@ import { createNodeWebSocket } from "@hono/node-ws"; import { trpcServer } from "@hono/trpc-server"; import { Octokit } from "@octokit/rest"; import { ChatService } from "@superset/chat/server/desktop"; +import { SESSIONS_SYNC_PATH } from "@superset/host-service-sync/protocol"; import { eq } from "drizzle-orm"; import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; @@ -15,7 +16,6 @@ import type { HostAuthProvider } from "./providers/host-auth"; import type { ModelProviderRuntimeResolver } from "./providers/model-providers"; import { AcpSessionManager, - registerAcpSessionStreamRoute, SqliteAcpSessionPersistence, } from "./runtime/acp-sessions"; import { ChatRuntimeManager } from "./runtime/chat"; @@ -25,6 +25,12 @@ import { createGitFactory } from "./runtime/git"; import { runMainWorkspaceSweep } from "./runtime/main-workspace-sweep"; import { runProjectBackfill } from "./runtime/project-backfill"; import { PullRequestRuntimeManager } from "./runtime/pull-requests"; +import { + CanonicalSessionsRuntime, + registerSessionsSyncRoute, + SessionsSyncHub, + SqliteSessionMetaStore, +} from "./runtime/sessions"; import { runWorkspaceBackfill } from "./runtime/workspace-backfill"; import { registerWorkspaceTerminalRoute } from "./terminal/terminal"; import { @@ -122,7 +128,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { // per-workspace. ChatService is a long-lived singleton wrapping mastra's // auth storage; the `host.auth.*` router proxies to it. const chatService = options.chatService ?? new ChatService(); - // ACP session harness (docs/acp-sessions.md) — owns Claude Code + // ACP session harness (legacy ACP surface; target: plans/host-sessions-sync.md) — owns Claude Code // adapter child processes. Fully parallel to the mastra chat runtime. // Pre-release, so internal-channel only: the desktop coordinator spawns // hosts with SUPERSET_ACP_SESSIONS=1 on canary/dev builds, never on @@ -150,13 +156,43 @@ export function createApp(options: CreateAppOptions): CreateAppResult { persistence: new SqliteAcpSessionPersistence(db), }); + // Canonical Host Sessions projection over the ACP manager: serves the + // `sessions.*` router and /sessions/sync. Constructing it is inert — + // it only tracks a session once a canonical call touches it — so it is + // safe to build even when the gate is off; the router gate keeps it idle. + // The meta store persists top-level session metadata (title/archive/ + // close) only — conversation content stays in the vendor's own + // transcript, resumed via the native session id in the registry. + const canonicalSessions = new CanonicalSessionsRuntime({ + port: acpSessions, + metaStore: new SqliteSessionMetaStore(db), + }); + // The hub attaches live listeners to the runtime, so gate its existence + // too — an idle runtime with listeners would still schedule flush work. + const sessionsSyncHub = acpSessionsEnabled + ? new SessionsSyncHub({ + runtime: canonicalSessions, + hostId: config.organizationId, + }) + : null; + if (acpSessionsEnabled) { + // The delete side of the metadata store: drop rows whose session left + // the registry. Hygiene only — a failure must not block startup. + try { + canonicalSessions.sweepOrphanedSessionMeta(); + } catch (error) { + console.warn("[sessions] failed to sweep orphaned session meta", error); + } + } + const runtime = { - acpSessions, acpSessionsEnabled, auth: chatService, chat: chatRuntime, filesystem, pullRequests: pullRequestRuntime, + sessions: canonicalSessions, + sessionsSyncHub, }; const app = new Hono(); const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); @@ -234,7 +270,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }; app.use("/terminal/*", wsAuth); app.use("/events", wsAuth); - app.use("/acp-sessions/*", wsAuth); + app.use(SESSIONS_SYNC_PATH, wsAuth); registerEventBusRoute({ app, eventBus, upgradeWebSocket }); registerWorkspaceTerminalRoute({ @@ -243,10 +279,10 @@ export function createApp(options: CreateAppOptions): CreateAppResult { eventBus, upgradeWebSocket, }); - if (acpSessionsEnabled) { - registerAcpSessionStreamRoute({ + if (sessionsSyncHub) { + registerSessionsSyncRoute({ app, - sessions: acpSessions, + hub: sessionsSyncHub, upgradeWebSocket, }); } @@ -286,6 +322,16 @@ export function createApp(options: CreateAppOptions): CreateAppResult { } catch (err) { console.warn("[host-service] pullRequestRuntime.stop failed:", err); } + try { + sessionsSyncHub?.dispose(); + } catch (err) { + console.warn("[host-service] sessionsSyncHub.dispose failed:", err); + } + try { + canonicalSessions.dispose(); + } catch (err) { + console.warn("[host-service] canonicalSessions.dispose failed:", err); + } try { await acpSessions.dispose(); } catch (err) { diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index 24f310d4108..f8d966dede1 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -222,7 +222,7 @@ export const workspaces = sqliteTable( ); /** - * Registry of ACP agent sessions (docs/acp-sessions.md). One row per + * Registry of ACP agent sessions (legacy ACP surface; target: plans/host-sessions-sync.md). One row per * session, kept fresh on every state emit. Rows survive host restarts so the * manager can list them as `offline` and resurrect on demand via the * adapter's `session/load` — the journal itself is not persisted; transcript @@ -245,6 +245,26 @@ export const acpSessions = sqliteTable( (table) => [index("acp_sessions_workspace_id_idx").on(table.workspaceId)], ); +/** + * Top-level canonical session metadata (plans/host-sessions-sync.md): the + * host-side edits the canonical `sessions.*` layer owns and the adapter has + * no notion of — title override, archive, close. Deliberately NO message or + * event content: the vendor transcript is the source of truth for + * conversation content, resumed via the native session id in + * `acp_sessions`. `title_overridden` distinguishes "no override" from an + * explicit null (cleared) title. + */ +export const sessionMeta = sqliteTable("session_meta", { + sessionId: text("session_id").primaryKey(), + titleOverridden: integer("title_overridden", { mode: "boolean" }) + .notNull() + .default(false), + title: text(), + archivedAt: integer("archived_at"), + closedAt: integer("closed_at"), + updatedAt: integer("updated_at").notNull(), +}); + /** * Tombstones for workspaces deleted while the cloud was unreachable. The * reconciler drains this into `v2Workspace.delete` calls; rows are removed diff --git a/packages/host-service/src/runtime/acp-sessions/acp-sessions.ts b/packages/host-service/src/runtime/acp-sessions/acp-sessions.ts index 97357f142b0..d55a99a4e02 100644 --- a/packages/host-service/src/runtime/acp-sessions/acp-sessions.ts +++ b/packages/host-service/src/runtime/acp-sessions/acp-sessions.ts @@ -210,6 +210,7 @@ export class AcpSessionManager { * resurrection. Disjoint from `runtimes` by construction. */ private readonly offline = new Map(); + private hydrationFailed = false; constructor(options: AcpSessionManagerOptions) { this.resolveWorkspaceCwd = options.resolveWorkspaceCwd; @@ -228,6 +229,7 @@ export class AcpSessionManager { this.offline.set(record.sessionId, record); } } catch (error) { + this.hydrationFailed = true; console.warn( "[acp-sessions] failed to load persisted session registry", error, @@ -236,6 +238,10 @@ export class AcpSessionManager { } } + registryHydrationFailed(): boolean { + return this.hydrationFailed; + } + /** * Idempotent create: returns the existing session's state when the id is * already live (or dead) and bound to the same workspace. @@ -910,6 +916,19 @@ export class AcpSessionManager { dead.sort((a, b) => a.state.updatedAt - b.state.updatedAt); for (const runtime of dead.slice(0, dead.length - MAX_DEAD_RUNTIMES)) { this.runtimes.delete(runtime.state.sessionId); + // The session must stay addressable: park it as an offline record so + // get() keeps serving it and a live mutation can resurrect it. + this.offline.set(runtime.state.sessionId, { + sessionId: runtime.state.sessionId, + workspaceId: runtime.state.workspaceId, + acpSessionId: runtime.acpSessionId, + harness: runtime.state.harness, + cwd: runtime.state.cwd, + title: runtime.state.title, + lastStopReason: runtime.state.lastStopReason, + createdAt: runtime.state.createdAt, + updatedAt: runtime.state.updatedAt, + }); } } diff --git a/packages/host-service/src/runtime/acp-sessions/index.ts b/packages/host-service/src/runtime/acp-sessions/index.ts index 6aac39b4ba9..c99a096c764 100644 --- a/packages/host-service/src/runtime/acp-sessions/index.ts +++ b/packages/host-service/src/runtime/acp-sessions/index.ts @@ -11,7 +11,3 @@ export { type AcpSessionRecord, SqliteAcpSessionPersistence, } from "./persistence"; -export { - type AcpSessionStreamSource, - registerAcpSessionStreamRoute, -} from "./stream"; diff --git a/packages/host-service/src/runtime/acp-sessions/stream.test.ts b/packages/host-service/src/runtime/acp-sessions/stream.test.ts deleted file mode 100644 index d21e2c35ec5..00000000000 --- a/packages/host-service/src/runtime/acp-sessions/stream.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import type { AddressInfo } from "node:net"; -import { type ServerType, serve } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import type { - SessionUpdateEnvelope, - SessionUpdateFrame, -} from "@superset/session-protocol"; -import { - type SessionSubscription, - subscribeToSession, -} from "@superset/session-protocol/client"; -import { Hono } from "hono"; -import { AcpSessionNotFoundError } from "./acp-sessions"; -import { SessionJournal } from "./journal"; -import { - type AcpSessionStreamSource, - registerAcpSessionStreamRoute, -} from "./stream"; - -const SESSION_ID = "stream-test-session"; - -function textFrame(text: string): SessionUpdateFrame { - return { - kind: "update", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text }, - }, - }; -} - -/** - * Journal-backed stand-in for AcpSessionManager.subscribe with identical - * semantics: default-live cursor, synchronous replay, reset on eviction. - */ -class StubStreamSource implements AcpSessionStreamSource { - readonly journal: SessionJournal; - readonly subscribers = new Set<(envelope: SessionUpdateEnvelope) => void>(); - - constructor(capacity = 100) { - this.journal = new SessionJournal(capacity); - } - - emit(frame: SessionUpdateFrame): SessionUpdateEnvelope { - const envelope = this.journal.append(SESSION_ID, frame); - for (const subscriber of [...this.subscribers]) { - subscriber(envelope); - } - return envelope; - } - - subscribe(input: { - sessionId: string; - since?: number; - onEnvelope: (envelope: SessionUpdateEnvelope) => void; - }): () => void { - if (input.sessionId !== SESSION_ID) { - throw new AcpSessionNotFoundError( - `ACP session not found: ${input.sessionId}`, - ); - } - const since = input.since ?? this.journal.latestSeq; - const backlog = this.journal.after(since); - if (backlog === null) { - input.onEnvelope({ - seq: this.journal.latestSeq, - sessionId: SESSION_ID, - ts: Date.now(), - frame: { kind: "reset", reason: "journal_evicted" }, - }); - return () => {}; - } - for (const envelope of backlog) { - input.onEnvelope(envelope); - } - this.subscribers.add(input.onEnvelope); - return () => { - this.subscribers.delete(input.onEnvelope); - }; - } -} - -async function waitFor( - predicate: () => boolean, - timeoutMs = 5_000, - label = "condition", -): Promise { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -describe("acp-sessions stream route", () => { - let server: ServerType | null = null; - const openSubscriptions: SessionSubscription[] = []; - - async function startServer(source: AcpSessionStreamSource): Promise { - const app = new Hono(); - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); - registerAcpSessionStreamRoute({ app, sessions: source, upgradeWebSocket }); - const started = await new Promise((resolve) => { - const instance = serve({ fetch: app.fetch, port: 0 }, () => - resolve(instance), - ); - }); - injectWebSocket(started); - server = started; - const { port } = started.address() as AddressInfo; - return `ws://127.0.0.1:${port}`; - } - - function connect(options: { - baseUrl: string; - sessionId?: string; - since?: number; - onEnvelope?: (envelope: SessionUpdateEnvelope) => void; - onReset?: (reason: string) => void; - }): { subscription: SessionSubscription; received: SessionUpdateEnvelope[] } { - const received: SessionUpdateEnvelope[] = []; - const subscription = subscribeToSession({ - streamUrl: `${options.baseUrl}/acp-sessions/${options.sessionId ?? SESSION_ID}/stream`, - since: options.since, - onEnvelope: (envelope) => { - received.push(envelope); - options.onEnvelope?.(envelope); - }, - onReset: options.onReset, - }); - openSubscriptions.push(subscription); - return { subscription, received }; - } - - afterEach(async () => { - for (const subscription of openSubscriptions.splice(0)) { - subscription.close(); - } - if (server) { - const current = server; - server = null; - // Sockets the SERVER closed can linger half-accounted in bun's ws - // compat and wedge a graceful close — force-drop them first. - ( - current as unknown as { closeAllConnections?: () => void } - ).closeAllConnections?.(); - await new Promise((resolve) => { - current.close(() => resolve()); - }); - } - }); - - test("replays the tail from `since`, then streams live; concurrent subscribers see identical gapless envelopes", async () => { - const source = new StubStreamSource(); - source.emit(textFrame("one")); - source.emit(textFrame("two")); - source.emit(textFrame("three")); - const baseUrl = await startServer(source); - - const a = connect({ baseUrl, since: 0 }); - await waitFor(() => a.received.length === 3, 5_000, "A's replay"); - - const b = connect({ baseUrl, since: 0 }); - await waitFor(() => b.received.length === 3, 5_000, "B's replay"); - - source.emit(textFrame("four")); - source.emit(textFrame("five")); - await waitFor( - () => a.received.length === 5 && b.received.length === 5, - 5_000, - "live envelopes on both subscribers", - ); - - const seqsOf = (envelopes: SessionUpdateEnvelope[]) => - envelopes.map((envelope) => envelope.seq); - expect(seqsOf(a.received)).toEqual([1, 2, 3, 4, 5]); - expect(JSON.stringify(a.received)).toBe(JSON.stringify(b.received)); - }); - - test("a client that disconnects mid-stream catches up via its cursor with no gaps or duplicates", async () => { - const source = new StubStreamSource(); - const baseUrl = await startServer(source); - - const first = connect({ baseUrl, since: 0 }); - source.emit(textFrame("one")); - source.emit(textFrame("two")); - await waitFor(() => first.received.length === 2, 5_000, "initial stream"); - first.subscription.close(); - await waitFor( - () => source.subscribers.size === 0, - 5_000, - "server-side unsubscribe", - ); - - // Missed while offline. - source.emit(textFrame("three")); - source.emit(textFrame("four")); - - const second = connect({ baseUrl, since: first.subscription.lastSeq }); - await waitFor(() => second.received.length === 2, 5_000, "catch-up replay"); - source.emit(textFrame("five")); - await waitFor(() => second.received.length === 3, 5_000, "post-catch-up"); - - const combined = [...first.received, ...second.received].map( - (envelope) => envelope.seq, - ); - expect(combined).toEqual([1, 2, 3, 4, 5]); - }); - - test("an evicted cursor gets a reset frame and the subscription stops", async () => { - const source = new StubStreamSource(5); - for (let i = 0; i < 10; i += 1) { - source.emit(textFrame(`frame ${i + 1}`)); - } - const baseUrl = await startServer(source); - - const resets: string[] = []; - const { received } = connect({ - baseUrl, - since: 1, - onReset: (reason) => { - resets.push(reason); - }, - }); - await waitFor(() => resets.length > 0, 5_000, "reset frame"); - expect(resets[0]).toBe("journal_evicted"); - // The reset is terminal: nothing was delivered as a normal envelope, - // and later emissions must not reach the stopped subscription. - expect(received).toEqual([]); - source.emit(textFrame("after reset")); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(received).toEqual([]); - expect(source.subscribers.size).toBe(0); - }); - - test("an unknown session id gets a session_not_found reset instead of a reconnect loop", async () => { - const source = new StubStreamSource(); - const baseUrl = await startServer(source); - - const resets: string[] = []; - connect({ - baseUrl, - sessionId: "no-such-session", - since: 0, - onReset: (reason) => { - resets.push(reason); - }, - }); - await waitFor(() => resets.length > 0, 5_000, "reset frame"); - expect(resets[0]).toBe("session_not_found"); - }); - - test("a malformed since cursor gets an invalid_since reset", async () => { - const source = new StubStreamSource(); - const baseUrl = await startServer(source); - - const resets: string[] = []; - const received: SessionUpdateEnvelope[] = []; - const subscription = subscribeToSession({ - streamUrl: `${baseUrl}/acp-sessions/${SESSION_ID}/stream?since=banana`, - onEnvelope: (envelope) => received.push(envelope), - onReset: (reason) => { - resets.push(reason); - }, - }); - openSubscriptions.push(subscription); - await waitFor(() => resets.length > 0, 5_000, "reset frame"); - expect(resets[0]).toBe("invalid_since"); - expect(received).toEqual([]); - }); - - test("omitting since starts the stream live from now", async () => { - const source = new StubStreamSource(); - source.emit(textFrame("history")); - source.emit(textFrame("more history")); - const baseUrl = await startServer(source); - - const live = connect({ baseUrl }); - await waitFor(() => source.subscribers.size === 1, 5_000, "subscription"); - source.emit(textFrame("live")); - await waitFor(() => live.received.length === 1, 5_000, "live envelope"); - expect(live.received[0]?.seq).toBe(3); - }); -}); diff --git a/packages/host-service/src/runtime/acp-sessions/stream.ts b/packages/host-service/src/runtime/acp-sessions/stream.ts deleted file mode 100644 index 9f27c0dd875..00000000000 --- a/packages/host-service/src/runtime/acp-sessions/stream.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { NodeWebSocket } from "@hono/node-ws"; -import type { SessionUpdateEnvelope } from "@superset/session-protocol"; -import type { Hono } from "hono"; -import { AcpSessionNotFoundError } from "./acp-sessions"; - -/** - * The slice of `AcpSessionManager` the stream route needs. Tests inject a - * stub backed by a bare `SessionJournal`; app.ts passes the real manager. - */ -export interface AcpSessionStreamSource { - subscribe(input: { - sessionId: string; - since?: number; - onEnvelope: (envelope: SessionUpdateEnvelope) => void; - }): () => void; - /** - * Resurrect a persisted-but-offline session before attaching (the manager - * implements this; journal-backed test stubs may omit it). Resolving - * without effect for live/dead/unknown ids is expected — `subscribe` - * raises its own NotFound for the unknown case. - */ - ensureLive?(sessionId: string): Promise; -} - -interface RegisterAcpSessionStreamRouteOptions { - app: Hono; - sessions: AcpSessionStreamSource; - upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; - /** Test/embedding hook; production uses the host-local default path. */ - path?: string; -} - -const SOCKET_OPEN = 1; -// Same rationale as the terminal route: with no ACK flow control, a client -// that stops draining would grow the host's send buffer without bound. -// Envelopes are small, so blowing past this means the client is effectively -// gone — drop it; reconnect-with-cursor replays what it missed. The drop -// path is intentionally untested: forcing 8MB of unread kernel buffer in a -// test is flaky-by-construction, and a false trip only costs a reconnect. -const WS_SEND_BUFFER_CAP_BYTES = 8 * 1024 * 1024; - -// Structural slice of hono/ws's WSContext; `raw` is the underlying `ws` -// socket (present for node-ws), read for `bufferedAmount` back-pressure. -type StreamSocket = { - send: (data: string) => void; - close: (code?: number, reason?: string) => void; - readyState: number; - raw?: { readonly bufferedAmount?: number }; -}; - -/** - * `undefined` when the query param is absent (subscribe live from now), - * `null` when present but not a non-negative integer. - */ -function parseSince(raw: string | undefined): number | undefined | null { - if (raw === undefined) return undefined; - // Digits only: Number("") and Number(" ") are 0, which would silently turn - // a malformed cursor into a full-journal replay. - if (!/^\d+$/.test(raw)) return null; - const value = Number(raw); - if (!Number.isSafeInteger(value)) return null; - return value; -} - -/** - * Terminal frames for cursors the server can't serve. The client stops on any - * `reset` before seq validation, so the nominal seq 0 is never inspected. - */ -function sendReset(socket: StreamSocket, sessionId: string, reason: string) { - if (socket.readyState !== SOCKET_OPEN) return; - const envelope: SessionUpdateEnvelope = { - seq: 0, - sessionId, - ts: Date.now(), - frame: { kind: "reset", reason }, - }; - socket.send(JSON.stringify(envelope)); -} - -/** - * `/acp-sessions/:sessionId/stream?since=` — one JSON - * `SessionUpdateEnvelope` per WS message (docs/acp-sessions.md). - * With `since`, the retained journal tail is replayed before going live; an - * unservable cursor gets a single `reset` frame and the socket closes — the - * client resyncs via get/getMessages and reconnects. The route is - * server-to-client only; client messages are ignored. Auth is the same - * `wsAuth` guard app.ts applies to `/terminal/*`. - */ -export function registerAcpSessionStreamRoute({ - app, - sessions, - upgradeWebSocket, - path = "/acp-sessions/:sessionId/stream", -}: RegisterAcpSessionStreamRouteOptions) { - app.get( - path, - upgradeWebSocket((c) => { - const sessionId = c.req.param("sessionId") ?? ""; - const sinceRaw = c.req.query("since") ?? undefined; - let unsubscribe: (() => void) | null = null; - let closed = false; - const detach = () => { - const current = unsubscribe; - unsubscribe = null; - current?.(); - }; - - return { - onOpen: (_event, ws) => { - const socket = ws as StreamSocket; - const since = parseSince(sinceRaw); - if (since === null) { - sendReset(socket, sessionId, "invalid_since"); - socket.close(1008, "invalid since cursor"); - return; - } - const attach = () => { - try { - unsubscribe = sessions.subscribe({ - sessionId, - since, - onEnvelope: (envelope) => { - if (socket.readyState !== SOCKET_OPEN) { - detach(); - return; - } - if ( - (socket.raw?.bufferedAmount ?? 0) > WS_SEND_BUFFER_CAP_BYTES - ) { - detach(); - try { - socket.close(1013, "stream back-pressure"); - } catch { - // best-effort; close may race an already-closing socket - } - return; - } - socket.send(JSON.stringify(envelope)); - // The manager emits `reset` when the cursor was evicted; the - // client stops on it, so free the server side too. - if (envelope.frame.kind === "reset") { - detach(); - socket.close(1000, "cursor reset"); - } - }, - }); - } catch (error) { - if (error instanceof AcpSessionNotFoundError) { - sendReset(socket, sessionId, "session_not_found"); - socket.close(1008, "session not found"); - return; - } - console.error( - "[acp-sessions] unexpected error attaching stream", - error, - ); - socket.close(1011, "stream attach error"); - } - }; - if (!sessions.ensureLive) { - attach(); - return; - } - // Resurrect before attaching so a stream opened right after a - // host restart replays the loaded transcript instead of dying on - // session_not_found. onOpen can't await, so attach in the - // continuation — `closed` guards the socket going away meanwhile. - void sessions.ensureLive(sessionId).then( - () => { - if (closed || socket.readyState !== SOCKET_OPEN) return; - attach(); - }, - (error) => { - if (closed || socket.readyState !== SOCKET_OPEN) return; - // The client resyncs over tRPC, where the load error is - // visible instead of swallowed by a socket close. - console.warn( - "[acp-sessions] stream attach: session/load failed", - error, - ); - sendReset(socket, sessionId, "session_load_failed"); - socket.close(1011, "session load failed"); - }, - ); - }, - - onMessage: () => { - // Server-to-client stream; inputs ride the tRPC router. - }, - - onClose: () => { - closed = true; - detach(); - }, - - onError: () => { - closed = true; - detach(); - }, - }; - }), - ); -} diff --git a/packages/host-service/src/runtime/sessions/canonical-sessions.durable.test.ts b/packages/host-service/src/runtime/sessions/canonical-sessions.durable.test.ts new file mode 100644 index 00000000000..af7f56318d4 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/canonical-sessions.durable.test.ts @@ -0,0 +1,239 @@ +/** + * Durable behaviors of CanonicalSessionsRuntime: persisted TOP-LEVEL session + * metadata (title/archive/close overrides — deliberately no messages or + * events; the vendor transcript is the source of truth, resumed via the + * native session id) and generation-tagged session cursors (every tracking + * is a new log generation, so cursors from a dead one reset + * deterministically). The vanilla storeless semantics stay pinned by + * canonical-sessions.test.ts. + */ +import { describe, expect, test } from "bun:test"; +import { sessionSnapshotSchema } from "@superset/host-service-sync/protocol"; +import { CanonicalSessionsRuntime } from "./canonical-sessions"; +import type { SessionMetaStore } from "./session-meta-store"; +import { FakeAcpPort, T0, WORKSPACE } from "./testing/fake-acp-port"; +import { makeMetaStoreDb } from "./testing/sqlite-store"; +import { acpMainThreadId } from "./translate-acp"; + +let bootSerial = 0; + +/** + * Runtime over a shared port + meta store with deterministic clock/id/ + * generation mints. Call again with the same port/store to model the next + * host process; the boot serial salts generations so two boots never mint + * the same tag, like the random default. + */ +function bootRuntime( + port: FakeAcpPort, + metaStore: SessionMetaStore, +): CanonicalSessionsRuntime { + bootSerial += 1; + const boot = bootSerial; + let clock = T0 + 500_000; + let generationSerial = 0; + let mintSerial = 0; + return new CanonicalSessionsRuntime({ + port, + metaStore, + now: () => { + clock += 1; + return clock; + }, + mintSessionId: () => { + mintSerial += 1; + return `session-minted-${mintSerial}`; + }, + mintGeneration: () => { + generationSerial += 1; + return `boot${boot}gen${generationSerial}`; + }, + }); +} + +describe("CanonicalSessionsRuntime with a session meta store", () => { + test("title/archive/close overrides survive a host reboot", async () => { + const port = new FakeAcpPort(); + const { metaStore } = makeMetaStoreDb(); + const first = bootRuntime(port, metaStore); + port.seed("session-a"); + port.seed("session-b"); + port.seed("session-c"); + + await first.updateSession({ + requestId: "req-title-1", + sessionId: "session-a", + title: "Renamed A", + }); + await first.updateSession({ + requestId: "req-archive-1", + sessionId: "session-b", + archived: true, + }); + await first.updateSession({ + requestId: "req-close-1", + sessionId: "session-c", + closed: true, + }); + const archivedAt = (await first.getSession({ sessionId: "session-b" })) + .session.archivedAt; + expect(archivedAt).not.toBeNull(); + + // Host dies; a fresh runtime over the same DB loads the metadata back. + first.dispose(); + const second = bootRuntime(port, metaStore); + + const renamed = await second.getSession({ sessionId: "session-a" }); + sessionSnapshotSchema.parse(renamed); + expect(renamed.session.title).toBe("Renamed A"); + + const archived = await second.getSession({ sessionId: "session-b" }); + expect(archived.session.archivedAt).toBe(archivedAt ?? 0); + // Archived rows stay out of the host snapshot scope after the reboot. + expect( + second + .hostSnapshotData() + .sessions.some((session) => session.id === "session-b"), + ).toBe(false); + + const closed = await second.getSession({ sessionId: "session-c" }); + expect(closed.session.closedAt).not.toBeNull(); + expect(closed.session.runState).toBe("closed"); + let rejected: unknown = null; + try { + await second.submitTurn({ + requestId: "req-send-1", + sessionId: "session-c", + threadId: acpMainThreadId("session-c"), + content: [{ type: "text", text: "hi" }], + }); + } catch (error) { + rejected = error; + } + expect(String(rejected)).toContain("closed"); + + // Un-archiving after the reboot persists too. + await second.updateSession({ + requestId: "req-unarchive-1", + sessionId: "session-b", + archived: false, + }); + second.dispose(); + const third = bootRuntime(port, metaStore); + const unarchived = await third.getSession({ sessionId: "session-b" }); + expect(unarchived.session.archivedAt).toBeNull(); + }); + + test("createSession titles persist without an explicit update", async () => { + const port = new FakeAcpPort(); + const { metaStore } = makeMetaStoreDb(); + const first = bootRuntime(port, metaStore); + const created = await first.createSession({ + requestId: "req-create-1", + workspaceId: WORKSPACE, + agentId: "claude-code", + title: "Fix relay test", + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }); + expect(created.session.title).toBe("Fix relay test"); + + first.dispose(); + const second = bootRuntime(port, metaStore); + const reloaded = await second.getSession({ sessionId: created.session.id }); + expect(reloaded.session.title).toBe("Fix relay test"); + }); + + test("a rebooted host mints a new log generation: old session cursors reject deterministically", async () => { + const port = new FakeAcpPort(); + const { metaStore } = makeMetaStoreDb(); + const first = bootRuntime(port, metaStore); + port.seed("session-a"); + + await first.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "hello" }], + }); + port.completeTurn("session-a", "end_turn"); + const before = await first.getEvents({ sessionId: "session-a" }); + const headBefore = before.head; + expect(before.items.length).toBeGreaterThan(0); + + // The host process dies; the vendor journal (the fake stands in for the + // vendor's own transcript store) survives and the log is rebuilt. + first.dispose(); + const second = bootRuntime(port, metaStore); + await second.warmSession("session-a"); + + // Same payload stream re-derived, but a different generation: every + // pre-reboot cursor is foreign — reset into the cold path, exactly how + // host cursors from a dead hub incarnation behave. + expect(second.sessionReplay("session-a", headBefore)).toEqual({ + ok: false, + reason: "foreign_cursor", + }); + let notFound: unknown = null; + try { + await second.getEvents({ + sessionId: "session-a", + beforeCursor: headBefore, + }); + } catch (error) { + notFound = error; + } + expect(String(notFound)).toContain("Unknown cursor"); + + const after = await second.getEvents({ sessionId: "session-a" }); + expect(after.head).not.toBe(headBefore); + expect(after.items.map((event) => event.payload.type)).toEqual( + before.items.map((event) => event.payload.type), + ); + // The rebuild re-derives content from the vendor journal, which never + // carried request attribution — the documented for-now limitation. + expect(after.items.every((event) => event.causationId === null)).toBe(true); + + // The zero cursor stays generation-less and always servable. + const fromZero = second.sessionReplay("session-a", "c000000000000"); + if (!fromZero.ok) throw new Error("zero cursor must stay servable"); + expect(fromZero.events).toEqual(after.items); + }); + + test("sweepOrphanedSessionMeta drops rows for sessions the registry no longer knows", async () => { + const port = new FakeAcpPort(); + const { metaStore } = makeMetaStoreDb(); + const first = bootRuntime(port, metaStore); + port.seed("session-kept"); + port.seed("session-doomed"); + await first.updateSession({ + requestId: "req-title-1", + sessionId: "session-kept", + title: "Keeper", + }); + await first.updateSession({ + requestId: "req-title-2", + sessionId: "session-doomed", + title: "Doomed", + }); + expect( + metaStore + .loadAll() + .map((record) => record.sessionId) + .sort(), + ).toEqual(["session-doomed", "session-kept"]); + + first.dispose(); + port.sessions.delete("session-doomed"); + const second = bootRuntime(port, metaStore); + second.sweepOrphanedSessionMeta(); + expect(metaStore.loadAll().map((record) => record.sessionId)).toEqual([ + "session-kept", + ]); + const kept = await second.getSession({ sessionId: "session-kept" }); + expect(kept.session.title).toBe("Keeper"); + }); +}); diff --git a/packages/host-service/src/runtime/sessions/canonical-sessions.test.ts b/packages/host-service/src/runtime/sessions/canonical-sessions.test.ts new file mode 100644 index 00000000000..7855d889b1d --- /dev/null +++ b/packages/host-service/src/runtime/sessions/canonical-sessions.test.ts @@ -0,0 +1,791 @@ +import { describe, expect, test } from "bun:test"; +import { + createSessionResultSchema, + eventsWindowSchema, + sessionEventSchema, + sessionSnapshotSchema, +} from "@superset/host-service-sync/protocol"; +import { selectedOptionIds } from "@superset/session-protocol"; +import { CanonicalSessionsError } from "./canonical-sessions"; +import { + FakeAcpPort, + MODEL_OPTIONS, + makeRuntime, + WORKSPACE, +} from "./testing/fake-acp-port"; +import { acpMainThreadId } from "./translate-acp"; + +type ErrorCode = CanonicalSessionsError["code"]; + +function expectCode(error: unknown, code: ErrorCode) { + expect(error).toBeInstanceOf(CanonicalSessionsError); + expect((error as CanonicalSessionsError).code).toBe(code); +} + +async function rejectsWith(promise: Promise, code: ErrorCode) { + let thrown: unknown = null; + try { + await promise; + } catch (error) { + thrown = error; + } + expect(thrown).not.toBeNull(); + expectCode(thrown, code); +} + +describe("CanonicalSessionsRuntime", () => { + test("createSession is idempotent and yields a schema-valid session + main thread", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + + const result = await runtime.createSession({ + requestId: "req-create-1", + workspaceId: WORKSPACE, + agentId: "claude-code", + title: "My session", + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }); + createSessionResultSchema.parse(result); + expect(result.session.id).toBe("session-minted-1"); + expect(result.session.title).toBe("My session"); + expect(result.session.mainThreadId).toBe( + acpMainThreadId("session-minted-1"), + ); + expect(result.mainThread.kind).toBe("main"); + expect(port.createCalls).toBe(1); + + // Same requestId: same receipt, no second adapter spawn, no new mint. + const replay = await runtime.createSession({ + requestId: "req-create-1", + workspaceId: WORKSPACE, + agentId: "claude-code", + title: "My session", + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }); + expect(replay).toBe(result); + expect(port.createCalls).toBe(1); + + await rejectsWith( + runtime.createSession({ + requestId: "req-create-2", + workspaceId: WORKSPACE, + agentId: "codex", + title: null, + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }), + "NOT_IMPLEMENTED", + ); + }); + + test("submitTurn: admission receipt, causation, idempotent replay", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + const receipt = await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "hello agent" }], + }); + expect(receipt.status).toBe("accepted"); + expect(port.promptCalls).toBe(1); + + const replay = await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "hello agent" }], + }); + expect(replay).toBe(receipt); + expect(port.promptCalls).toBe(1); + + const window = await runtime.getEvents({ sessionId: "session-a" }); + eventsWindowSchema.parse(window); + const turnStarted = window.items.find( + (event) => event.payload.type === "turnStarted", + ); + expect(turnStarted?.causationId).toBe("req-send-1"); + expect( + turnStarted?.payload.type === "turnStarted" + ? turnStarted.payload.turn.id + : null, + ).toBe(receipt.turnId); + + // Wrong thread and thought content are rejected before the adapter. + await rejectsWith( + runtime.submitTurn({ + requestId: "req-send-2", + sessionId: "session-a", + threadId: "thread-elsewhere", + content: [{ type: "text", text: "x" }], + }), + "BAD_REQUEST", + ); + await rejectsWith( + runtime.submitTurn({ + requestId: "req-send-3", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "thought", text: "sneaky" }], + }), + "BAD_REQUEST", + ); + }); + + test("turn lifecycle folds into the snapshot: running then idle", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "do a thing" }], + }); + const running = await runtime.getSession({ sessionId: "session-a" }); + sessionSnapshotSchema.parse(running); + expect(running.session.runState).toBe("running"); + + port.emitUpdate("session-a", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "done!" }, + messageId: "acp-msg-1", + }); + port.completeTurn("session-a", "end_turn"); + + const idle = await runtime.getSession({ sessionId: "session-a" }); + sessionSnapshotSchema.parse(idle); + expect(idle.session.runState).toBe("idle"); + expect(idle.session.eventHead).not.toBeNull(); + + const window = await runtime.getEvents({ sessionId: "session-a" }); + expect(window.items.map((event) => event.payload.type)).toContain( + "turnCompleted", + ); + for (const event of window.items) { + sessionEventSchema.parse(event); + } + }); + + test("permission round-trip: public id in snapshot, resolve maps to native id", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "write a file" }], + }); + port.emitUpdate("session-a", { + sessionUpdate: "tool_call", + toolCallId: "toolu_write_1", + title: "Write config", + kind: "edit", + status: "pending", + }); + port.requestPermission("session-a", "7", "toolu_write_1"); + + const snapshot = await runtime.getSession({ sessionId: "session-a" }); + sessionSnapshotSchema.parse(snapshot); + expect(snapshot.pendingPermissions).toHaveLength(1); + const pending = snapshot.pendingPermissions[0]; + if (!pending) throw new Error("expected a pending permission"); + expect(pending.toolCallId).toBe("toolu_write_1"); + expect(pending.threadId).toBe(acpMainThreadId("session-a")); + + const receipt = await runtime.resolvePermission({ + requestId: "req-resolve-1", + sessionId: "session-a", + permissionId: pending.id, + outcome: { type: "selected", optionIds: ["allow"] }, + }); + expect(receipt.status).toBe("accepted"); + const respond = port.respondCalls[0]; + if (!respond) throw new Error("expected a respondToPermission call"); + expect(respond.requestId).toBe("7"); + expect(selectedOptionIds(respond.outcome)).toEqual(["allow"]); + + const after = await runtime.getSession({ sessionId: "session-a" }); + expect(after.pendingPermissions).toHaveLength(0); + + // The resolution event carries the resolving request's causation. + const window = await runtime.getEvents({ sessionId: "session-a" }); + const resolved = window.items.find( + (event) => event.payload.type === "permissionResolved", + ); + expect(resolved?.causationId).toBe("req-resolve-1"); + + await rejectsWith( + runtime.resolvePermission({ + requestId: "req-resolve-2", + sessionId: "session-a", + permissionId: pending.id, + outcome: { type: "cancelled" }, + }), + "NOT_FOUND", + ); + }); + + test("cancelTurn: cancels the active turn, no-ops a finished one, rejects unknown ids", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + const receipt = await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "long task" }], + }); + port.requestPermission("session-a", "9", "toolu_x"); + + const cancel = await runtime.cancelTurn({ + requestId: "req-cancel-1", + sessionId: "session-a", + turnId: receipt.turnId, + }); + expect(cancel.status).toBe("accepted"); + expect(port.cancelCalls).toBe(1); + + const snapshot = await runtime.getSession({ sessionId: "session-a" }); + expect(snapshot.session.runState).toBe("idle"); + expect(snapshot.pendingPermissions).toHaveLength(0); + + // Cancelling the already-finished turn again: idempotent admission, no + // second adapter cancel (receipt replay path). + const again = await runtime.cancelTurn({ + requestId: "req-cancel-1", + sessionId: "session-a", + turnId: receipt.turnId, + }); + expect(again).toBe(cancel); + expect(port.cancelCalls).toBe(1); + + // A NEW cancel request against the finished turn admits without touching + // the adapter. + const fresh = await runtime.cancelTurn({ + requestId: "req-cancel-2", + sessionId: "session-a", + turnId: receipt.turnId, + }); + expect(fresh.status).toBe("accepted"); + expect(port.cancelCalls).toBe(1); + + await rejectsWith( + runtime.cancelTurn({ + requestId: "req-cancel-3", + sessionId: "session-a", + turnId: "turn-i-made-up", + }), + "NOT_FOUND", + ); + }); + + test("updateSession settings drive the config catalog and attribute the settings event", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a", { configOptions: MODEL_OPTIONS }); + + const receipt = await runtime.updateSession({ + requestId: "req-model-1", + sessionId: "session-a", + settings: { activeModel: "claude-fable-5" }, + }); + expect(receipt).toEqual({ + requestId: "req-model-1", + sessionId: "session-a", + status: "accepted", + }); + + const snapshot = await runtime.getSession({ sessionId: "session-a" }); + expect(snapshot.session.settings.activeModel).toBe("claude-fable-5"); + + const window = await runtime.getEvents({ sessionId: "session-a" }); + const settingsEvents = window.items.filter( + (event) => event.payload.type === "settingsUpdated", + ); + const attributed = settingsEvents.find( + (event) => event.causationId === "req-model-1", + ); + expect(attributed).toBeDefined(); + + // No effort option in the catalog → unsupported. + await rejectsWith( + runtime.updateSession({ + requestId: "req-effort-1", + sessionId: "session-a", + settings: { effort: "high" }, + }), + "NOT_IMPLEMENTED", + ); + }); + + test("getEvents: newest window, backwards paging, thread filter, unknown cursors", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "chat" }], + }); + for (let index = 0; index < 5; index += 1) { + port.emitUpdate("session-a", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `chunk ${index}` }, + messageId: "acp-msg-1", + }); + } + port.completeTurn("session-a", "end_turn"); + + // No cursor = the newest window (everything here fits one page). + const all = await runtime.getEvents({ sessionId: "session-a" }); + eventsWindowSchema.parse(all); + expect(all.items.length).toBeGreaterThan(5); + expect(all.range.hasMoreBefore).toBe(false); + expect(all.range.truncatedBefore).toBe(false); + expect(all.head).toBe(all.range.newest?.cursor ?? ""); + + // Paging back from the newest window stitches the log exactly, and + // hasMoreBefore flips off once the page reaches the log head. + const pageSize = Math.ceil(all.items.length / 2); + const newest = await runtime.getEvents({ + sessionId: "session-a", + limit: pageSize, + }); + eventsWindowSchema.parse(newest); + expect(newest.items).toEqual(all.items.slice(-pageSize)); + expect(newest.range.hasMoreBefore).toBe(true); + const older = await runtime.getEvents({ + sessionId: "session-a", + beforeCursor: newest.range.oldest?.cursor, + limit: pageSize, + }); + eventsWindowSchema.parse(older); + expect([...older.items, ...newest.items]).toEqual(all.items); + expect(older.range.hasMoreBefore).toBe(false); + // Every window reports the same log head, not its own newest item. + expect(older.head).toBe(all.head); + + // Thread filter restricts items. + const filtered = await runtime.getEvents({ + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + }); + eventsWindowSchema.parse(filtered); + expect( + filtered.items.every( + (event) => event.threadId === acpMainThreadId("session-a"), + ), + ).toBe(true); + + // An unknown beforeCursor is NOT_FOUND, never a silent empty window. + await rejectsWith( + runtime.getEvents({ + sessionId: "session-a", + beforeCursor: "c999999999999", + }), + "NOT_FOUND", + ); + }); + + test("hostSnapshotData mixes tracked (composed) and untracked (synthesized) rows", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-tracked"); + port.seed("session-cold", { title: "cold row" }); + port.seed("session-offline", { status: "offline" }); + + await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-tracked", + threadId: acpMainThreadId("session-tracked"), + content: [{ type: "text", text: "hi" }], + }); + + const data = runtime.hostSnapshotData(); + expect(data.sessions).toHaveLength(3); + const byId = new Map(data.sessions.map((session) => [session.id, session])); + expect(byId.get("session-tracked")?.runState).toBe("running"); + expect(byId.get("session-tracked")?.eventHead).not.toBeNull(); + expect(byId.get("session-cold")?.title).toBe("cold row"); + expect(byId.get("session-cold")?.eventHead).toBeNull(); + expect(byId.get("session-offline")?.runState).toBe("offline"); + // Untracked rows have no permission linkage; the ACP harness resolves + // every tool host-side. + expect(data.pendingPermissions).toHaveLength(0); + expect(data.openClientToolCalls).toHaveLength(0); + }); + + test("offline sessions: reads resurrect and answer the recent tail", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + const seeded = port.seed("session-off", { status: "offline" }); + seeded.resurrectFrames = [ + { + kind: "update", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "from before the restart" }, + }, + }, + { + kind: "update", + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "old reply" }, + messageId: "acp-old-1", + }, + }, + ]; + + // Cold read: resurrects (the same session/load the zero-head subscribe + // would run moments later) and answers the replayed tail in ONE + // snapshot — a device's first paint must be the full last page, never + // an event-by-event replay over the socket. + const cold = await runtime.getSession({ sessionId: "session-off" }); + sessionSnapshotSchema.parse(cold); + expect(cold.session.runState).not.toBe("offline"); + expect(cold.head).not.toBe("c000000000000"); + expect(cold.hasOlderEvents).toBe(false); + const coldTexts = cold.recentEvents + .filter((event) => event.payload.type === "messageDelta") + .map((event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" + ? event.payload.content.text + : null, + ); + expect(coldTexts).toContain("from before the restart"); + expect(coldTexts).toContain("old reply"); + + // Live path: resurrect, replay the loaded transcript, admit the prompt. + const receipt = await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-off", + threadId: acpMainThreadId("session-off"), + content: [{ type: "text", text: "welcome back" }], + }); + expect(receipt.status).toBe("accepted"); + + const window = await runtime.getEvents({ sessionId: "session-off" }); + const texts = window.items + .filter((event) => event.payload.type === "messageDelta") + .map((event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" + ? event.payload.content.text + : null, + ); + expect(texts).toContain("from before the restart"); + expect(texts).toContain("old reply"); + expect(texts).toContain("welcome back"); + + const live = await runtime.getSession({ sessionId: "session-off" }); + expect(live.session.runState).toBe("running"); + }); + + test("updateSession overrides: title, archive, close (and closed blocks prompts)", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + const titled = await runtime.updateSession({ + requestId: "req-title-1", + sessionId: "session-a", + title: "Renamed", + }); + expect(titled).toEqual({ + requestId: "req-title-1", + sessionId: "session-a", + status: "accepted", + }); + const renamed = await runtime.getSession({ sessionId: "session-a" }); + expect(renamed.session.title).toBe("Renamed"); + + await runtime.updateSession({ + requestId: "req-archive-1", + sessionId: "session-a", + archived: true, + }); + const archived = await runtime.getSession({ sessionId: "session-a" }); + expect(archived.session.archivedAt).not.toBeNull(); + expect(archived.session.title).toBe("Renamed"); + // Archived rows leave the host snapshot scope but stay readable. + expect(runtime.hostSnapshotData().sessions).toHaveLength(0); + + await runtime.updateSession({ + requestId: "req-close-1", + sessionId: "session-a", + closed: true, + }); + const closed = await runtime.getSession({ sessionId: "session-a" }); + expect(closed.session.closedAt).not.toBeNull(); + expect(closed.session.runState).toBe("closed"); + + await rejectsWith( + runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "hi" }], + }), + "PRECONDITION_FAILED", + ); + + await runtime.updateSession({ + requestId: "req-open-1", + sessionId: "session-a", + closed: false, + }); + const reopened = await runtime.getSession({ sessionId: "session-a" }); + expect(reopened.session.closedAt).toBeNull(); + const receipt = await runtime.submitTurn({ + requestId: "req-send-2", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "hi again" }], + }); + expect(receipt.status).toBe("accepted"); + }); + + test("subagent threads surface in the snapshot; client tool resolution is refused", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + + await runtime.submitTurn({ + requestId: "req-send-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "spawn a subagent" }], + }); + port.emitUpdate("session-a", { + sessionUpdate: "tool_call", + toolCallId: "toolu_task_1", + title: "Explore", + kind: "execute", + status: "in_progress", + _meta: { claudeCode: { toolName: "Task" } }, + }); + port.emitUpdate("session-a", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "child says hi" }, + messageId: "acp-child-1", + _meta: { claudeCode: { parentToolUseId: "toolu_task_1" } }, + }); + + const snapshot = await runtime.getSession({ sessionId: "session-a" }); + sessionSnapshotSchema.parse(snapshot); + expect(snapshot.threads.map((thread) => thread.kind).sort()).toEqual([ + "main", + "subagent", + ]); + const subagent = snapshot.threads.find( + (thread) => thread.kind === "subagent", + ); + if (!subagent) throw new Error("expected a subagent thread"); + expect(subagent.parentThreadId).toBe(acpMainThreadId("session-a")); + + // The ACP harness resolves every tool host-side: a live host tool call + // refuses client resolution, unknown ids are NOT_FOUND. + await rejectsWith( + runtime.resolveToolCall({ + requestId: "req-rtc-1", + sessionId: "session-a", + toolCallId: "toolu_task_1", + outcome: { type: "cancelled", reason: null }, + }), + "PRECONDITION_FAILED", + ); + await rejectsWith( + runtime.resolveToolCall({ + requestId: "req-rtc-2", + sessionId: "session-a", + toolCallId: "toolu_unknown", + outcome: { type: "cancelled", reason: null }, + }), + "NOT_FOUND", + ); + }); + + test("unknown session ids propagate the manager error on every surface", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + let thrown: unknown = null; + try { + await runtime.getSession({ sessionId: "session-ghost" }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect(String(thrown)).toContain("session-ghost"); + }); + + test("receipts: reusing a requestId with a different payload is a CONFLICT, never an alias", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + port.seed("session-b"); + + const receipt = await runtime.submitTurn({ + requestId: "req-shared", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "hello" }], + }); + expect(receipt.status).toBe("accepted"); + + // Same requestId pointed at another session must not replay A's turn + // receipt as if B's submission succeeded. + await rejectsWith( + runtime.submitTurn({ + requestId: "req-shared", + sessionId: "session-b", + threadId: acpMainThreadId("session-b"), + content: [{ type: "text", text: "hello" }], + }), + "CONFLICT", + ); + + // Same requestId, same session, different content: also a conflict. + await rejectsWith( + runtime.submitTurn({ + requestId: "req-shared", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "something else" }], + }), + "CONFLICT", + ); + expect(port.promptCalls).toBe(1); + }); + + test("receipts: concurrent duplicates join the in-flight call instead of double-executing", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + + const input = { + requestId: "req-create-race", + workspaceId: WORKSPACE, + agentId: "claude-code", + title: null, + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }; + const [first, second] = await Promise.all([ + runtime.createSession(input), + runtime.createSession(input), + ]); + expect(port.createCalls).toBe(1); + expect(second).toBe(first); + }); + + test("receipts: a failed admission is forgotten so the same requestId can retry", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + + const input = { + requestId: "req-create-retry", + workspaceId: WORKSPACE, + agentId: "codex", + title: null, + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }; + await rejectsWith(runtime.createSession(input), "NOT_IMPLEMENTED"); + + // The failure was not cached as a poisoned receipt, and the changed + // payload is not a CONFLICT because the failed entry is gone. + const retried = await runtime.createSession({ + ...input, + agentId: "claude-code", + }); + expect(retried.session.id).toBe("session-minted-1"); + }); + + test("evicted journal head surfaces as truncatedBefore once the window hits the oldest retained event", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + // History the host will never see: journaled and evicted before the + // runtime ever tracks the session. + for (let index = 0; index < 4; index += 1) { + port.emitUpdate("session-a", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `lost ${index}` }, + }); + } + port.evictJournal("session-a"); + + // First touch subscribes from seq 0, receives the journal's reset + // frame, and re-attaches live with an empty log. + const before = await runtime.getEvents({ sessionId: "session-a" }); + eventsWindowSchema.parse(before); + expect(before.items).toHaveLength(0); + expect(before.range.hasMoreBefore).toBe(false); + expect(before.range.truncatedBefore).toBe(true); + + // Life goes on after the hole: new frames land in the canonical log. + port.emitUpdate("session-a", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "after the hole" }, + }); + const after = await runtime.getEvents({ sessionId: "session-a" }); + eventsWindowSchema.parse(after); + expect(after.items.length).toBeGreaterThan(1); + expect(after.range.hasMoreBefore).toBe(false); + expect(after.range.truncatedBefore).toBe(true); + + // A window that hasn't paged back to the oldest retained event keeps + // the flag off — there is still local history to fetch first. + const tail = await runtime.getEvents({ sessionId: "session-a", limit: 1 }); + expect(tail.range.hasMoreBefore).toBe(true); + expect(tail.range.truncatedBefore).toBe(false); + }); + + test("intact history keeps truncatedBefore off even at the very first event", async () => { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + port.seed("session-a"); + port.emitUpdate("session-a", { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "complete history" }, + }); + + const window = await runtime.getEvents({ sessionId: "session-a" }); + eventsWindowSchema.parse(window); + expect(window.items.length).toBeGreaterThan(0); + expect(window.range.hasMoreBefore).toBe(false); + expect(window.range.truncatedBefore).toBe(false); + }); +}); diff --git a/packages/host-service/src/runtime/sessions/canonical-sessions.ts b/packages/host-service/src/runtime/sessions/canonical-sessions.ts new file mode 100644 index 00000000000..75e77364dcf --- /dev/null +++ b/packages/host-service/src/runtime/sessions/canonical-sessions.ts @@ -0,0 +1,1383 @@ +import { randomUUID } from "node:crypto"; +import { + reduceProjection, + type SessionProjection, +} from "@superset/host-service-sync/projection"; +import type { + CancelTurnInput, + CancelTurnReceipt, + ContentBlock, + CreateSessionInput, + CreateSessionResult, + EventsWindow, + GetEventsInput, + GetSessionInput, + HostSnapshot, + PermissionRequest, + ResolvePermissionInput, + ResolvePermissionReceipt, + ResolveToolCallInput, + ResolveToolCallReceipt, + Session, + SessionCapabilities, + SessionEvent, + SessionSnapshot, + SubmitTurnInput, + SubmitTurnReceipt, + Thread, + UpdateSessionInput, + UpdateSessionReceipt, +} from "@superset/host-service-sync/protocol"; +import type { + ContentBlock as AcpContentBlock, + StopReason as AcpStopReason, + RequestPermissionOutcome, + SessionConfigOption, + SessionScopedState, + SessionUpdateEnvelope, +} from "@superset/session-protocol"; +import { makeSelectedOutcome } from "@superset/session-protocol"; +import type { SessionMetaStore } from "./session-meta-store"; +import { + AcpSessionEventTranslator, + acpMainThreadId, + type SessionEventDraft, + settingOptionsFromScopedState, + settingsFromScopedState, +} from "./translate-acp"; + +/** + * The narrow slice of `AcpSessionManager` the canonical runtime consumes. + * Structural, so deterministic fakes can drive the runtime in tests without + * spawning adapters. Every member matches the manager's signature exactly. + */ +export interface AcpSessionsPort { + create(input: { + sessionId: string; + workspaceId: string; + }): Promise; + get(sessionId: string): SessionScopedState; + ensureLive(sessionId: string): Promise; + list(input: { workspaceId?: string; cursor?: string; limit?: number }): { + items: SessionScopedState[]; + nextCursor: string | null; + }; + prompt(input: { sessionId: string; prompt: AcpContentBlock[] }): { + accepted: true; + turn: Promise<{ stopReason: AcpStopReason }>; + }; + respondToPermission(input: { + sessionId: string; + requestId: string; + outcome: RequestPermissionOutcome; + }): { status: "resolved" | "already_resolved" }; + cancel(input: { sessionId: string }): Promise; + setMode(input: { sessionId: string; modeId: string }): Promise; + setConfigOption(input: { + sessionId: string; + configId: string; + value: string | boolean; + }): Promise; + subscribe(input: { + sessionId: string; + since?: number; + onEnvelope: (envelope: SessionUpdateEnvelope) => void; + }): () => void; + /** + * True when the durable registry could not be fully loaded, so list() may + * omit sessions that still exist on disk. + */ + registryHydrationFailed?(): boolean; +} + +export type CanonicalSessionsErrorCode = + | "NOT_FOUND" + | "BAD_REQUEST" + | "NOT_IMPLEMENTED" + | "PRECONDITION_FAILED" + | "CONFLICT" + | "INTERNAL"; + +/** Transport-agnostic failure; the tRPC router maps codes onto TRPCError. */ +export class CanonicalSessionsError extends Error { + constructor( + readonly code: CanonicalSessionsErrorCode, + message: string, + ) { + super(message); + this.name = "CanonicalSessionsError"; + } +} + +export interface CanonicalSessionsRuntimeOptions { + port: AcpSessionsPort; + /** Injectable for deterministic tests; stamps archivedAt/closedAt only. */ + now?: () => number; + /** Injectable for deterministic tests; mints createSession ids. */ + mintSessionId?: () => string; + /** + * Durable top-level session metadata (title/archive/close overrides). + * Deliberately metadata-only: conversation content is NEVER persisted + * here — the vendor transcript is the source of truth, resumed via the + * native session id — so the canonical event log stays in memory and is + * rebuilt from vendor replay under a fresh generation on every track. + */ + metaStore?: SessionMetaStore; + /** Injectable for deterministic tests; mints log generation tags. */ + mintGeneration?: () => string; +} + +/** + * A change to the host-stream view (plans/host-sessions-sync.md): session + * rows entering/leaving/updating within the not-archived-not-closed scope, + * plus attention-worthy permission transitions. The sync hub turns these into + * host-stream `event` packets with its own cursor sequence. + */ +export type HostChange = + | { type: "sessionUpsert"; sessionId: string; session: Session } + | { + type: "sessionRemoved"; + sessionId: string; + reason: "archived" | "closed" | "deleted"; + } + | { + type: "permissionAvailable"; + sessionId: string; + threadId: string; + permission: PermissionRequest; + } + | { type: "permissionResolved"; sessionId: string; permissionId: string }; + +/** + * Result of a cursor-based catch-up read against one session's event log. + * `foreign_cursor` = the cursor belongs to a dead log generation (or a + * future serial) — irrecoverable per-cursor, the client rebuilds from the + * snapshot cold path. + */ +export type SessionReplay = + | { ok: true; events: SessionEvent[]; head: string } + | { ok: false; reason: "untracked" | "foreign_cursor" }; + +/** The tRPC host snapshot minus `head`, which the sync hub owns. */ +export type HostSnapshotData = Omit; + +interface TrackedSession { + sessionId: string; + workspaceId: string; + translator: AcpSessionEventTranslator; + /** + * Cursor namespace tag for one lifetime of this in-memory log. Minted + * fresh on every track: the log is rebuilt from vendor replay each time, + * so cursors from a previous tracking (a dead host process) must fail + * deterministically instead of aliasing serials in the new log. + */ + generation: string; + /** The canonical log, cursor-stamped. In-memory; content is never + * persisted — the vendor transcript is the source of truth. Ring-bounded + * at {@link MAX_SESSION_LOG_EVENTS}: `events[i]` holds serial + * `evictedCount + i + 1`. */ + events: SessionEvent[]; + /** Events dropped from the front of the ring; their serials are gone. */ + evictedCount: number; + projection: SessionProjection; + cursorSerial: number; + lastSeq: number; + unsubscribe: () => void; + /** + * The journal head was evicted before we attached, so events cover only + * what streamed after tracking began. Pre-attach pendings are unknowable. + */ + resetSeen: boolean; + foldError: string | null; +} + +/** Host-side edits the adapter has no notion of; survive untracked sessions. */ +interface SessionOverrides { + title?: string | null; + archivedAt: number | null; + closedAt: number | null; +} + +const CLAUDE_AGENT = { id: "claude-code", displayName: "Claude Code" }; + +const ACP_CAPABILITIES: SessionCapabilities = { + threadModel: "nested", + threadFidelity: "partial", + canResume: true, + supportsPermissions: true, + supportsModes: true, + supportsModels: true, +}; + +const MAX_RECEIPTS = 10_000; +/** + * Ring bound on each tracked session's in-memory event log. A subscriber + * whose cursor falls off the ring gets `foreign_cursor` → reset → snapshot + * cold path, the same recovery as a dead generation; scrollback past the + * ring reports `truncatedBefore`. + */ +const MAX_SESSION_LOG_EVENTS = 5_000; +const DEFAULT_PAGE_LIMIT = 50; +/** Events in the `get` snapshot tail — one round trip paints the recent + * conversation; older content is getEvents territory. */ +const SNAPSHOT_TAIL_LIMIT = 50; +const DEFAULT_EVENT_PAGE_LIMIT = 100; + +/** + * The generation-less "from the very beginning" cursor. It is the head of a + * never-tracked session's synthesized snapshot, and subscribing after it is + * always servable when the log's own start is still retained. + */ +const ZERO_CURSOR = "c000000000000"; + +/** + * Minted cursors are `c-`: the generation tags one + * lifetime of the log, so a cursor from a dead generation fails parsing + * deterministically (reset → snapshot rebuild) instead of silently aliasing + * a serial in the new log. Within a generation the padded serial keeps + * lexicographic order = numeric order, which the sync hub's replay/live + * handoff compare relies on. + */ +function mintCursor(generation: string, serial: number): string { + return `c${generation}-${String(serial).padStart(12, "0")}`; +} + +const CURSOR_PATTERN = /^c([a-z0-9]{1,32})-(\d{12})$/; + +function parseCursor( + cursor: string, +): { generation: string; serial: number } | "zero" | null { + if (cursor === ZERO_CURSOR) return "zero"; + const match = CURSOR_PATTERN.exec(cursor); + const generation = match?.[1]; + const serial = match?.[2]; + if (generation === undefined || serial === undefined) return null; + return { generation, serial: Number(serial) }; +} + +function mapStatusToRunState( + status: SessionScopedState["status"], +): Session["runState"] { + switch (status) { + case "starting": + return "starting"; + case "idle": + return "idle"; + case "running": + case "awaiting_permission": + return "running"; + case "offline": + return "offline"; + case "dead": + return "failed"; + default: + status satisfies never; + return "idle"; + } +} + +function synthesizeMainThread(state: SessionScopedState): Thread { + return { + id: acpMainThreadId(state.sessionId), + sessionId: state.sessionId, + kind: "main", + parentThreadId: null, + origin: { type: "sessionCreated" }, + fidelity: "full", + title: null, + runState: "idle", + eventHead: null, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + lastActivityAt: state.updatedAt, + }; +} + +function toAcpContentBlock(block: ContentBlock): AcpContentBlock { + switch (block.type) { + case "text": + return { type: "text", text: block.text }; + case "thought": + throw new CanonicalSessionsError( + "BAD_REQUEST", + "thought blocks cannot be submitted as user content", + ); + case "image": + return { type: "image", mimeType: block.mimeType, data: block.data }; + case "resource": + return { + type: "resource_link", + uri: block.uri, + name: block.name ?? block.uri, + ...(block.mimeType !== null ? { mimeType: block.mimeType } : {}), + }; + default: + block satisfies never; + throw new CanonicalSessionsError("BAD_REQUEST", "unknown content block"); + } +} + +/** + * The canonical `sessions.*` runtime: composes the ACP session manager (via + * `AcpSessionsPort`) with the per-session translator into the protocol's + * canonical entities — a cursor-stamped `SessionEvent` log plus a projection + * folded through the shared reducer, exactly what clients fold themselves. + * + * Ownership split: + * - the manager owns adapter processes, journals, and liveness; + * - the translator owns ACP → canonical normalization (deterministic); + * - this runtime owns tracking (subscribing a translator per session), + * cursor assignment, requestId idempotency receipts, and host-side session + * edits (title/archive/close) the adapter has no notion of. + * + * Mutation outputs are admission receipts, never completion; completion + * arrives as events whose `causationId` echoes the requestId. The event log + * lives in memory only — content is deliberately never persisted (the + * vendor transcript is the source of truth, resumed via the native session + * id); what persists is the top-level session metadata in the + * {@link SessionMetaStore} (title/archive/close overrides), so + * sidebar state survives host restarts. + * + * Tracking is lazy. Sessions the manager knows but this runtime has not yet + * touched (offline rows from before a restart, sessions created through the + * legacy acpSessions router) are synthesized from `SessionScopedState` in + * list/get responses — with empty attention, since permission linkage only + * exists once tracked. Live-path mutations resurrect and track on demand. + */ +export class CanonicalSessionsRuntime { + private readonly port: AcpSessionsPort; + private readonly now: () => number; + private readonly mintSessionId: () => string; + private readonly metaStore: SessionMetaStore | undefined; + private readonly mintGeneration: () => string; + + private readonly tracked = new Map(); + private readonly incarnations = new Map(); + private readonly overrides = new Map(); + /** `${procedure}:${requestId}` → in-flight/settled admission, FIFO-bounded. */ + private readonly receipts = new Map< + string, + { fingerprint: string; promise: Promise } + >(); + + private readonly sessionEventListeners = new Set< + (event: SessionEvent) => void + >(); + private readonly hostChangeListeners = new Set< + (change: HostChange) => void + >(); + private readonly dirtySessions = new Set(); + private dirtyFlushScheduled = false; + private disposed = false; + + constructor(options: CanonicalSessionsRuntimeOptions) { + this.port = options.port; + this.now = options.now ?? Date.now; + this.mintSessionId = + options.mintSessionId ?? (() => `session-${randomUUID()}`); + this.metaStore = options.metaStore; + this.mintGeneration = + options.mintGeneration ?? (() => randomUUID().slice(0, 8)); + if (this.metaStore) { + try { + for (const record of this.metaStore.loadAll()) { + this.overrides.set(record.sessionId, { + ...(record.titleOverridden ? { title: record.title } : {}), + archivedAt: record.archivedAt, + closedAt: record.closedAt, + }); + } + } catch (error) { + console.warn( + "[sessions] failed to load persisted session metadata", + error, + ); + } + } + } + + // ------------------------------------------------------------------------- + // Queries + // ------------------------------------------------------------------------- + + async getSession(input: GetSessionInput): Promise { + // Resurrect on read: this snapshot IS a device's cold first paint, and + // it must carry the recent tail in ONE response. The old zero-head + // fallback made the hub resurrect moments later anyway — and then + // stream the ENTIRE replayed transcript event-by-event over the + // socket, so the thread visibly built up from the first message. + // Same session/load work, done here, returns the last page atomically. + let tracked: TrackedSession | null; + try { + tracked = await this.ensureTracked(input.sessionId, { + resurrect: true, + }); + } catch { + // Resurrection failed (transcript unloadable, adapter refused): + // fall back to the passive snapshot — subscribing from the zero + // head stays the last-resort recovery. Unknown ids re-throw + // NotFound from the port read below. + tracked = this.tracked.get(input.sessionId) ?? null; + } + if (!tracked) { + const state = this.port.get(input.sessionId); + return { + session: this.synthesizeSession(state), + threads: [synthesizeMainThread(state)], + activeTurns: [], + pendingPermissions: [], + openToolCalls: [], + recentEvents: [], + hasOlderEvents: false, + head: ZERO_CURSOR, + }; + } + const recentEvents = tracked.events.slice(-SNAPSHOT_TAIL_LIMIT); + return { + session: this.composeSession(tracked, this.port.get(input.sessionId)), + threads: Object.values(tracked.projection.threadsById), + activeTurns: Object.values(tracked.projection.activeTurnsById), + pendingPermissions: Object.values( + tracked.projection.pendingPermissionsById, + ), + // The ACP harness resolves every tool on the host side. + openToolCalls: Object.values( + tracked.projection.activeToolCallsById, + ).filter((toolCall) => toolCall.resolver.type === "client"), + recentEvents, + hasOlderEvents: + tracked.events.length > recentEvents.length || tracked.resetSeen, + head: this.headCursor(tracked), + }; + } + + /** + * Backwards-only scrollback over the in-memory log. No cursor = the + * newest window; page older by passing `range.oldest.cursor` back. + * Forward catch-up is the sync socket's job, never this query's. + */ + async getEvents(input: GetEventsInput): Promise { + const tracked = await this.requireTracked(input.sessionId); + const threadId = input.threadId ?? null; + const filtered = + threadId === null + ? tracked.events + : tracked.events.filter((event) => event.threadId === threadId); + const limit = input.limit ?? DEFAULT_EVENT_PAGE_LIMIT; + let endExclusive = filtered.length; + if (input.beforeCursor !== undefined) { + const index = filtered.findIndex( + (event) => event.cursor === input.beforeCursor, + ); + if (index === -1) { + throw new CanonicalSessionsError( + "NOT_FOUND", + `Unknown cursor: ${input.beforeCursor}`, + ); + } + endExclusive = index; + } + const start = Math.max(0, endExclusive - limit); + const items = filtered.slice(start, endExclusive); + const first = items[0]; + const last = items[items.length - 1]; + return { + sessionId: input.sessionId, + threadId, + items, + range: { + oldest: first + ? { + eventId: first.id, + cursor: first.cursor, + occurredAt: first.occurredAt, + } + : null, + newest: last + ? { + eventId: last.id, + cursor: last.cursor, + occurredAt: last.occurredAt, + } + : null, + hasMoreBefore: start > 0, + // The log head was lost — to journal eviction before tracking began + // (resetSeen) or to the ring bound after it: once a window reaches + // our oldest event, say so instead of passing off partial history + // as complete. + truncatedBefore: + (tracked.resetSeen || tracked.evictedCount > 0) && start === 0, + }, + head: this.headCursor(tracked), + }; + } + + // ------------------------------------------------------------------------- + // Mutations — idempotent by requestId; outputs are admission receipts + // ------------------------------------------------------------------------- + + async createSession(input: CreateSessionInput): Promise { + return this.withReceipt("createSession", input, async () => { + if (input.agentId !== CLAUDE_AGENT.id) { + throw new CanonicalSessionsError( + "NOT_IMPLEMENTED", + `Unsupported agent: ${input.agentId} (this host speaks ${CLAUDE_AGENT.id})`, + ); + } + const sessionId = this.mintSessionId(); + const state = await this.port.create({ + sessionId, + workspaceId: input.workspaceId, + }); + const tracked = this.tracked.get(sessionId) ?? this.track(state); + if (input.title !== null) { + this.setOverrides(sessionId, { + ...(this.overrides.get(sessionId) ?? { + archivedAt: null, + closedAt: null, + }), + title: input.title, + }); + } + try { + await this.applySettings(tracked, input.requestId, input.settings); + } catch { + // Best-effort on create: the harness may reject unknown options, and + // the settings_updated events already tell clients what actually took. + } + const session = this.composeSession(tracked, this.port.get(sessionId)); + const mainThread = tracked.projection.threadsById[session.mainThreadId]; + if (!mainThread) { + throw new CanonicalSessionsError( + "INTERNAL", + "main thread missing after create", + ); + } + return { session, mainThread }; + }); + } + + /** + * One patch mutation for title/archive/close and harness settings. Settings + * ride the harness config surface and require a live session; pure + * host-side edits (title/archive/close) apply to offline rows too. + */ + async updateSession( + input: UpdateSessionInput, + ): Promise { + return this.withReceipt("updateSession", input, async () => { + const settings = input.settings; + const wantsSettings = + settings !== undefined && + (settings.activeModel !== undefined || + settings.activeMode !== undefined || + settings.effort !== undefined); + const tracked = wantsSettings + ? await this.requireTracked(input.sessionId) + : await this.ensureTracked(input.sessionId, { resurrect: false }); + const previous = this.overrides.get(input.sessionId); + this.setOverrides(input.sessionId, { + title: input.title !== undefined ? input.title : previous?.title, + archivedAt: + input.archived === undefined + ? (previous?.archivedAt ?? null) + : input.archived + ? (previous?.archivedAt ?? this.now()) + : null, + closedAt: + input.closed === undefined + ? (previous?.closedAt ?? null) + : input.closed + ? (previous?.closedAt ?? this.now()) + : null, + }); + if (wantsSettings && tracked) { + await this.applySettings(tracked, input.requestId, settings); + } + const state = this.port.get(input.sessionId); + const session = tracked + ? this.composeSession(tracked, state) + : this.synthesizeSession(state); + this.emitScopeTransition(input.sessionId, previous, session); + return { + requestId: input.requestId, + sessionId: input.sessionId, + status: "accepted" as const, + }; + }); + } + + async submitTurn(input: SubmitTurnInput): Promise { + return this.withReceipt("submitTurn", input, async () => { + // Closed is a pure host-side override; reject before resurrecting the + // adapter so a closed session never spawns a child process. + if (this.overrides.get(input.sessionId)?.closedAt != null) { + throw new CanonicalSessionsError( + "PRECONDITION_FAILED", + "Session is closed", + ); + } + const tracked = await this.requireTracked(input.sessionId); + if (input.threadId !== tracked.translator.mainThreadId) { + throw new CanonicalSessionsError( + "BAD_REQUEST", + "Only the main thread accepts prompts on this harness", + ); + } + const prompt = input.content.map(toAcpContentBlock); + tracked.translator.attributeNextTurn({ + requestId: input.requestId, + clientInstanceId: null, + }); + this.port.prompt({ sessionId: input.sessionId, prompt }); + // prompt() journals the user's chunks synchronously before returning, so + // the translator has already minted (or reused) the turn. + const turnId = + tracked.translator.turnIdFor(input.requestId) ?? + tracked.translator.activeTurnId(); + if (!turnId) { + throw new CanonicalSessionsError( + "INTERNAL", + "prompt admitted but no turn was minted", + ); + } + return { + requestId: input.requestId, + turnId, + status: "accepted" as const, + }; + }); + } + + async cancelTurn(input: CancelTurnInput): Promise { + return this.withReceipt("cancelTurn", input, async () => { + const tracked = await this.ensureTracked(input.sessionId, { + resurrect: false, + }); + if (!tracked || !tracked.translator.knowsTurn(input.turnId)) { + throw new CanonicalSessionsError( + "NOT_FOUND", + `Unknown turn: ${input.turnId}`, + ); + } + if (tracked.translator.activeTurnId() === input.turnId) { + await this.port.cancel({ sessionId: input.sessionId }); + } + // A turn that already ended: cancellation is an idempotent no-op. + return { + requestId: input.requestId, + turnId: input.turnId, + status: "accepted" as const, + }; + }); + } + + async resolvePermission( + input: ResolvePermissionInput, + ): Promise { + return this.withReceipt("resolvePermission", input, async () => { + const tracked = await this.ensureTracked(input.sessionId, { + resurrect: false, + }); + const nativeRequestId = + tracked?.translator.nativePermissionRequestId(input.permissionId) ?? + null; + if (!tracked || nativeRequestId === null) { + throw new CanonicalSessionsError( + "NOT_FOUND", + `No pending permission: ${input.permissionId}`, + ); + } + tracked.translator.attributePermissionResolution( + nativeRequestId, + input.requestId, + ); + const outcome: RequestPermissionOutcome = + input.outcome.type === "selected" + ? makeSelectedOutcome(input.outcome.optionIds) + : { outcome: "cancelled" }; + try { + // Both "resolved" and "already_resolved" admit the request — the + // permission_resolved event is the completion either way. + this.port.respondToPermission({ + sessionId: input.sessionId, + requestId: nativeRequestId, + outcome, + }); + } catch (error) { + tracked.translator.clearPermissionResolutionCausation( + nativeRequestId, + input.requestId, + ); + throw error; + } + return { + requestId: input.requestId, + permissionId: input.permissionId, + status: "accepted" as const, + }; + }); + } + + async resolveToolCall( + input: ResolveToolCallInput, + ): Promise { + const tracked = await this.ensureTracked(input.sessionId, { + resurrect: false, + }); + if (tracked?.projection.activeToolCallsById[input.toolCallId]) { + throw new CanonicalSessionsError( + "PRECONDITION_FAILED", + "Tool call is host-resolved; clients cannot resolve it", + ); + } + throw new CanonicalSessionsError( + "NOT_FOUND", + `Unknown tool call: ${input.toolCallId}`, + ); + } + + dispose(): void { + this.disposed = true; + for (const tracked of this.tracked.values()) { + tracked.unsubscribe(); + } + this.tracked.clear(); + this.sessionEventListeners.clear(); + this.hostChangeListeners.clear(); + this.dirtySessions.clear(); + } + + // ------------------------------------------------------------------------- + // Sync feed — live listeners and replay accessors for /sessions/sync + // ------------------------------------------------------------------------- + + /** Every canonical event as it folds, across all tracked sessions. */ + onSessionEvent(listener: (event: SessionEvent) => void): () => void { + this.sessionEventListeners.add(listener); + return () => { + this.sessionEventListeners.delete(listener); + }; + } + + /** Host-stream changes; see {@link HostChange}. */ + onHostChange(listener: (change: HostChange) => void): () => void { + this.hostChangeListeners.add(listener); + return () => { + this.hostChangeListeners.delete(listener); + }; + } + + /** + * Resurrect + track so replay/live accessors can serve the session. The + * sync hub calls this before capturing a subscription's replay window. + */ + async warmSession(sessionId: string): Promise { + await this.requireTracked(sessionId); + } + + sessionHead(sessionId: string): string | null { + const tracked = this.tracked.get(sessionId); + if (!tracked) return null; + return this.headCursor(tracked); + } + + /** + * Events strictly after `after` (null or the zero cursor = everything). + * A cursor from a dead log generation — a previous tracking of this + * session, in this process or a dead one — or beyond the head is + * `foreign_cursor`: the caller answers with a protocol reset and the + * client re-runs the snapshot cold path. + */ + sessionReplay(sessionId: string, after: string | null): SessionReplay { + const tracked = this.tracked.get(sessionId); + if (!tracked) return { ok: false, reason: "untracked" }; + const head = this.headCursor(tracked); + if (after === null || after === ZERO_CURSOR) { + // "From the very beginning" is only gapless while the ring still + // holds the beginning. + if (tracked.evictedCount > 0) { + return { ok: false, reason: "foreign_cursor" }; + } + return { ok: true, events: [...tracked.events], head }; + } + const parsed = parseCursor(after); + if ( + parsed === null || + parsed === "zero" || + parsed.generation !== tracked.generation || + parsed.serial > tracked.cursorSerial || + // The suffix after this cursor starts below the ring floor — some of + // it was evicted, so a gapless replay is impossible. + parsed.serial < tracked.evictedCount + ) { + return { ok: false, reason: "foreign_cursor" }; + } + // Serials are 1-based and gapless; the ring offset maps serial → index. + return { + ok: true, + events: tracked.events.slice(parsed.serial - tracked.evictedCount), + head, + }; + } + + /** + * The tRPC host snapshot, minus the hub-owned head: every non-archived, + * non-closed session the manager knows. Pending permissions come from + * tracked sessions only — untracked rows have no permission linkage yet. + * The ACP harness resolves every tool host-side, so the client-tool list + * is empty by construction. + */ + hostSnapshotData(): HostSnapshotData { + const sessions: Session[] = []; + const pendingPermissions: PermissionRequest[] = []; + let cursor: string | undefined; + do { + const page = this.port.list({ cursor, limit: DEFAULT_PAGE_LIMIT }); + for (const state of page.items) { + if (this.outOfScope(state.sessionId)) continue; + const tracked = this.tracked.get(state.sessionId); + sessions.push( + tracked + ? this.composeSession(tracked, state) + : this.synthesizeSession(state), + ); + if (tracked) { + pendingPermissions.push( + ...Object.values(tracked.projection.pendingPermissionsById), + ); + } + } + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + return { sessions, pendingPermissions, openClientToolCalls: [] }; + } + + /** + * Garbage-collect persisted session metadata whose session no longer + * exists in the manager's registry — the delete side of the metadata + * store. Run once at startup (app.ts); sessions deleted while the host + * runs are collected on the next boot. Best-effort by contract: a + * failure must never block serving. + */ + sweepOrphanedSessionMeta(): void { + if (!this.metaStore) return; + // An incomplete registry would make every missing session look orphaned + // and delete its metadata; skip the sweep until a clean boot. + if (this.port.registryHydrationFailed?.()) return; + const known = new Set(); + let cursor: string | undefined; + do { + const page = this.port.list({ cursor, limit: DEFAULT_PAGE_LIMIT }); + for (const state of page.items) { + known.add(state.sessionId); + } + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + for (const sessionId of [...this.overrides.keys()]) { + if (known.has(sessionId)) continue; + this.overrides.delete(sessionId); + this.metaStore.delete(sessionId); + } + } + + /** Write an overrides entry through to the metadata store (best-effort). */ + private setOverrides(sessionId: string, overrides: SessionOverrides): void { + this.overrides.set(sessionId, overrides); + if (!this.metaStore) return; + try { + this.metaStore.upsert({ + sessionId, + // Same test composeSession applies: undefined = no override + // (adapter title shows through), null = explicitly cleared. + titleOverridden: overrides.title !== undefined, + title: overrides.title ?? null, + archivedAt: overrides.archivedAt, + closedAt: overrides.closedAt, + }); + } catch (error) { + console.warn( + `[sessions] failed to persist session metadata for ${sessionId}`, + error, + ); + } + } + + private outOfScope(sessionId: string): boolean { + const overrides = this.overrides.get(sessionId); + return overrides?.archivedAt != null || overrides?.closedAt != null; + } + + private emitHostChange(change: HostChange): void { + for (const listener of [...this.hostChangeListeners]) { + try { + listener(change); + } catch { + // Same discipline as session listeners: never poison the fold path. + } + } + } + + /** + * Host-stream session_upsert rows coalesce per microtask: a burst of folds + * yields one upsert carrying the final composed row, not one per event. + */ + private markDirty(sessionId: string): void { + if (this.hostChangeListeners.size === 0) return; + this.dirtySessions.add(sessionId); + if (this.dirtyFlushScheduled) return; + this.dirtyFlushScheduled = true; + queueMicrotask(() => { + this.dirtyFlushScheduled = false; + const dirty = [...this.dirtySessions]; + this.dirtySessions.clear(); + for (const id of dirty) { + const tracked = this.tracked.get(id); + if (!tracked || this.outOfScope(id)) continue; + let state: SessionScopedState; + try { + state = this.port.get(id); + } catch { + continue; + } + this.emitHostChange({ + type: "sessionUpsert", + sessionId: id, + session: this.composeSession(tracked, state), + }); + } + }); + } + + /** + * updateSession moved a session into or out of the host-stream scope. + * Archive/close are runtime overrides that never touch the fold path, so + * they need explicit host-change emission. + */ + private emitScopeTransition( + sessionId: string, + previous: SessionOverrides | undefined, + session: Session, + ): void { + const wasInScope = + (previous?.archivedAt ?? null) === null && + (previous?.closedAt ?? null) === null; + const isInScope = session.archivedAt === null && session.closedAt === null; + if (wasInScope && !isInScope) { + this.emitHostChange({ + type: "sessionRemoved", + sessionId, + reason: session.closedAt !== null ? "closed" : "archived", + }); + } else if (isInScope) { + // Covers re-entry into scope and in-scope edits (title changes). + this.emitHostChange({ type: "sessionUpsert", sessionId, session }); + } + } + + // ------------------------------------------------------------------------- + // Tracking + // ------------------------------------------------------------------------- + + /** + * Track on demand. Returns null only for offline sessions when + * `resurrect` is false (reads synthesize instead); unknown ids throw the + * manager's NotFound. Resurrection loads the stored transcript into a + * fresh journal for the new translator incarnation — but session/load + * buffers only a bounded tail, so a long transcript replays truncated. + * The rebuilt log is a new generation: cursors from the previous + * tracking reset deterministically into the snapshot cold path. + */ + private async ensureTracked( + sessionId: string, + options: { resurrect: boolean }, + ): Promise { + const existing = this.tracked.get(sessionId); + if (existing) return existing; + let state = this.port.get(sessionId); + if (state.status === "offline") { + if (!options.resurrect) return null; + await this.port.ensureLive(sessionId); + const raced = this.tracked.get(sessionId); + if (raced) return raced; + state = this.port.get(sessionId); + } + // A resurrection parked on ensureLive can resume after dispose; a track + // here would attach a journal subscription nothing will ever remove. + if (this.disposed) { + throw new CanonicalSessionsError("INTERNAL", "Runtime is disposed"); + } + return this.track(state); + } + + private async requireTracked(sessionId: string): Promise { + const tracked = await this.ensureTracked(sessionId, { resurrect: true }); + if (!tracked) { + throw new CanonicalSessionsError( + "INTERNAL", + "resurrection did not yield a live session", + ); + } + return tracked; + } + + private track(state: SessionScopedState): TrackedSession { + const incarnation = (this.incarnations.get(state.sessionId) ?? 0) + 1; + this.incarnations.set(state.sessionId, incarnation); + const tracked: TrackedSession = { + sessionId: state.sessionId, + workspaceId: state.workspaceId, + translator: new AcpSessionEventTranslator({ + sessionId: state.sessionId, + idScope: `${state.sessionId}-${incarnation}`, + }), + // Fresh namespace per tracking: the log is rebuilt from vendor + // replay, so cursors minted by any previous tracking are foreign + // by construction and reset deterministically. + generation: this.mintGeneration(), + events: [], + evictedCount: 0, + projection: this.baselineProjection(state), + cursorSerial: 0, + lastSeq: 0, + unsubscribe: () => {}, + resetSeen: false, + foldError: null, + }; + this.tracked.set(state.sessionId, tracked); + const onEnvelope = (envelope: SessionUpdateEnvelope) => + this.fold(tracked, envelope); + // With `since: 0` the retained journal replays synchronously before the + // listener attaches; an evicted head delivers one reset frame and does + // NOT attach, so re-subscribe live-only in that case (partial history). + tracked.unsubscribe = this.port.subscribe({ + sessionId: state.sessionId, + since: 0, + onEnvelope, + }); + if (tracked.resetSeen) { + tracked.unsubscribe = this.port.subscribe({ + sessionId: state.sessionId, + onEnvelope, + }); + } + return tracked; + } + + private fold(tracked: TrackedSession, envelope: SessionUpdateEnvelope): void { + if (envelope.frame.kind === "reset") { + tracked.resetSeen = true; + return; + } + tracked.lastSeq = envelope.seq; + let drafts: ReturnType; + try { + drafts = tracked.translator.translate(envelope); + } catch (error) { + // The subscriber callback runs inside the manager's journal fan-out; + // a throw here would corrupt unrelated subscribers. Record and skip. + tracked.foldError = + error instanceof Error ? error.message : String(error); + console.warn( + `[sessions] dropped untranslatable frame for ${tracked.sessionId}:`, + error, + ); + return; + } + for (const draft of drafts) { + this.appendEvent(tracked, draft); + } + } + + /** Mint the cursor, fold, and fan out one canonical event. */ + private appendEvent(tracked: TrackedSession, draft: SessionEventDraft): void { + tracked.cursorSerial += 1; + const event: SessionEvent = { + ...draft, + cursor: mintCursor(tracked.generation, tracked.cursorSerial), + }; + tracked.events.push(event); + if (tracked.events.length > MAX_SESSION_LOG_EVENTS) { + tracked.events.shift(); + tracked.evictedCount += 1; + } + try { + tracked.projection = reduceProjection(tracked.projection, { + type: "event", + cursor: event.cursor, + value: event, + }); + } catch (error) { + tracked.foldError = + error instanceof Error ? error.message : String(error); + console.warn( + `[sessions] projection fold failed for ${tracked.sessionId} at ${event.cursor}:`, + error, + ); + } + // The event is in the log regardless of local projection health — + // clients fold with their own reducer, so they still get it. + for (const listener of [...this.sessionEventListeners]) { + try { + listener(event); + } catch { + // Listener faults must not corrupt the journal fan-out this + // callback runs inside. + } + } + if (event.payload.type === "permissionRequested") { + this.emitHostChange({ + type: "permissionAvailable", + sessionId: event.sessionId, + threadId: event.payload.permission.threadId, + permission: event.payload.permission, + }); + } else if (event.payload.type === "permissionResolved") { + this.emitHostChange({ + type: "permissionResolved", + sessionId: event.sessionId, + permissionId: event.payload.permissionId, + }); + } + this.markDirty(tracked.sessionId); + } + + private headCursor(tracked: TrackedSession): string { + return mintCursor(tracked.generation, tracked.cursorSerial); + } + + // ------------------------------------------------------------------------- + // Composition + // ------------------------------------------------------------------------- + + private baselineProjection(state: SessionScopedState): SessionProjection { + const mainThreadId = acpMainThreadId(state.sessionId); + return { + sessionId: state.sessionId, + cursor: ZERO_CURSOR, + session: { + id: state.sessionId, + workspaceId: state.workspaceId, + title: state.title, + mainThreadId, + agent: { ...CLAUDE_AGENT }, + runState: mapStatusToRunState(state.status), + capabilities: { ...ACP_CAPABILITIES }, + settings: settingsFromScopedState(state), + settingOptions: settingOptionsFromScopedState(state), + eventHead: null, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + lastActivityAt: state.updatedAt, + archivedAt: null, + closedAt: null, + error: null, + }, + threadsById: { [mainThreadId]: synthesizeMainThread(state) }, + activeTurnsById: {}, + pendingPermissionsById: {}, + activeToolCallsById: {}, + plan: [], + }; + } + + /** + * The projection's session plus what events cannot express: host-liveness + * (offline/dead ride the manager state, never the log), host-side edits + * (title/archive/close), and the live adapter title. + */ + private composeSession( + tracked: TrackedSession, + state: SessionScopedState, + ): Session { + const overrides = this.overrides.get(tracked.sessionId); + const base = tracked.projection.session; + const closedAt = overrides?.closedAt ?? null; + return { + ...base, + // Catalogs are live-state metadata (the projection's copy is frozen at + // baseline); recompose so option-list rebuilds show through. + settingOptions: settingOptionsFromScopedState(state), + title: + overrides && overrides.title !== undefined + ? overrides.title + : state.title, + runState: + closedAt !== null + ? "closed" + : state.status === "offline" + ? "offline" + : state.status === "dead" + ? "failed" + : base.runState, + archivedAt: overrides?.archivedAt ?? null, + closedAt, + }; + } + + /** A session this runtime never tracked, from manager state alone. */ + private synthesizeSession(state: SessionScopedState): Session { + const overrides = this.overrides.get(state.sessionId); + const closedAt = overrides?.closedAt ?? null; + return { + id: state.sessionId, + workspaceId: state.workspaceId, + title: + overrides && overrides.title !== undefined + ? overrides.title + : state.title, + mainThreadId: acpMainThreadId(state.sessionId), + agent: { ...CLAUDE_AGENT }, + runState: + closedAt !== null ? "closed" : mapStatusToRunState(state.status), + capabilities: { ...ACP_CAPABILITIES }, + settings: settingsFromScopedState(state), + settingOptions: settingOptionsFromScopedState(state), + eventHead: null, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + lastActivityAt: state.updatedAt, + archivedAt: overrides?.archivedAt ?? null, + closedAt, + error: null, + }; + } + + // ------------------------------------------------------------------------- + // Settings + // ------------------------------------------------------------------------- + + /** + * Applies a settings batch through the harness config surface. One + * causation arming covers the batch: the first settings-bearing state + * frame after this carries the requestId. + */ + private async applySettings( + tracked: TrackedSession, + requestId: string, + settings: { + activeModel?: string | null; + activeMode?: string | null; + effort?: string | null; + configuration?: Record; + }, + ): Promise { + const { sessionId } = tracked; + // Validate the whole batch before the first port mutation so a request + // the host can reject upfront never half-applies. + const modelOption = + settings.activeModel != null + ? this.findConfigOption(sessionId, "model", "model") + : undefined; + if (settings.activeModel != null && !modelOption) { + throw new CanonicalSessionsError( + "NOT_IMPLEMENTED", + "The harness exposes no model option", + ); + } + const effortOption = + settings.effort != null + ? this.findConfigOption(sessionId, "thought_level", "effort") + : undefined; + if (settings.effort != null && !effortOption) { + throw new CanonicalSessionsError( + "NOT_IMPLEMENTED", + "The harness exposes no effort option", + ); + } + const configurationEntries: Array<[string, string | boolean]> = []; + for (const [configId, value] of Object.entries( + settings.configuration ?? {}, + )) { + if (typeof value !== "string" && typeof value !== "boolean") { + throw new CanonicalSessionsError( + "BAD_REQUEST", + `Configuration option ${configId} must be a string or boolean`, + ); + } + configurationEntries.push([configId, value]); + } + tracked.translator.attributeNextSettingsChange(requestId); + try { + if (settings.activeMode != null) { + await this.port.setMode({ sessionId, modeId: settings.activeMode }); + } + if (modelOption && settings.activeModel != null) { + await this.port.setConfigOption({ + sessionId, + configId: modelOption.id, + value: settings.activeModel, + }); + } + if (effortOption && settings.effort != null) { + await this.port.setConfigOption({ + sessionId, + configId: effortOption.id, + value: settings.effort, + }); + } + for (const [configId, value] of configurationEntries) { + await this.port.setConfigOption({ sessionId, configId, value }); + } + } catch (error) { + tracked.translator.clearSettingsCausation(requestId); + throw error; + } + } + + private findConfigOption( + sessionId: string, + category: string, + id: string, + ): SessionConfigOption | undefined { + const options = this.port.get(sessionId).configOptions; + return ( + options.find((option) => option.category === category) ?? + options.find((option) => option.id === id) + ); + } + + // ------------------------------------------------------------------------- + // Idempotency receipts + // ------------------------------------------------------------------------- + + /** + * At-most-once admission per `${procedure}:${requestId}`. The entry is + * registered BEFORE execution so a concurrent duplicate joins the in-flight + * promise instead of re-executing (two racing createSessions must not mint + * two sessions). The full input is fingerprinted: replaying a requestId + * with a different payload — including a different sessionId — is a + * CONFLICT, never a silent alias onto the first call's receipt. Failed + * executions are forgotten so a retry re-runs; the FIFO bound means a + * receipt older than MAX_RECEIPTS admissions can re-execute, which the + * at-least-once event contract tolerates. + */ + private async withReceipt( + procedure: string, + input: { requestId: string }, + execute: () => Promise, + ): Promise { + const key = `${procedure}:${input.requestId}`; + const fingerprint = JSON.stringify(input); + const existing = this.receipts.get(key); + if (existing) { + if (existing.fingerprint !== fingerprint) { + throw new CanonicalSessionsError( + "CONFLICT", + `requestId ${input.requestId} was already used for a different ${procedure} payload`, + ); + } + return existing.promise as Promise; + } + const entry = { fingerprint, promise: execute() }; + this.receipts.set(key, entry); + if (this.receipts.size > MAX_RECEIPTS) { + const oldest = this.receipts.keys().next().value; + if (oldest !== undefined) this.receipts.delete(oldest); + } + entry.promise.catch(() => { + if (this.receipts.get(key) === entry) this.receipts.delete(key); + }); + return entry.promise as Promise; + } +} diff --git a/packages/host-service/src/runtime/sessions/index.ts b/packages/host-service/src/runtime/sessions/index.ts new file mode 100644 index 00000000000..9bf74c3dff6 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/index.ts @@ -0,0 +1,33 @@ +export { + type AcpSessionsPort, + CanonicalSessionsError, + type CanonicalSessionsErrorCode, + CanonicalSessionsRuntime, + type CanonicalSessionsRuntimeOptions, + type HostChange, + type HostSnapshotData, + type SessionReplay, +} from "./canonical-sessions"; +export { + type SessionMetaRecord, + type SessionMetaStore, + SqliteSessionMetaStore, +} from "./session-meta-store"; +export { + type SessionsSyncConnection, + SessionsSyncHub, + type SessionsSyncHubOptions, + type SessionsSyncSource, + type SyncSocket, +} from "./sync-hub"; +export { + type RegisterSessionsSyncRouteOptions, + registerSessionsSyncRoute, +} from "./sync-route"; +export { + AcpSessionEventTranslator, + type AcpSessionEventTranslatorOptions, + acpMainThreadId, + type SessionEventDraft, + settingsFromScopedState, +} from "./translate-acp"; diff --git a/packages/host-service/src/runtime/sessions/session-meta-store.ts b/packages/host-service/src/runtime/sessions/session-meta-store.ts new file mode 100644 index 00000000000..66b9af9ad50 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/session-meta-store.ts @@ -0,0 +1,74 @@ +import { eq } from "drizzle-orm"; +import type { HostDb } from "../../db"; +import { sessionMeta } from "../../db/schema"; + +/** + * One session's persisted top-level canonical metadata: the host-side edits + * (title override, archive, close) the adapter has no notion of. This is + * deliberately ALL the canonical layer persists — no messages, no events; + * conversation content lives in the vendor's own transcript and is resumed + * via the native session id in the `acp_sessions` registry. + */ +export interface SessionMetaRecord { + sessionId: string; + /** Distinguishes "no override" (adapter title shows) from a cleared title. */ + titleOverridden: boolean; + title: string | null; + archivedAt: number | null; + closedAt: number | null; +} + +/** + * Durable store behind CanonicalSessionsRuntime's session overrides. + * `loadAll` seeds the runtime at construction; `upsert` runs on every + * override write (create title, update) and is best-effort — a failure must + * never break the live path. + */ +export interface SessionMetaStore { + loadAll(): SessionMetaRecord[]; + upsert(record: SessionMetaRecord): void; + delete(sessionId: string): void; +} + +export class SqliteSessionMetaStore implements SessionMetaStore { + constructor(private readonly db: HostDb) {} + + loadAll(): SessionMetaRecord[] { + return this.db + .select() + .from(sessionMeta) + .all() + .map((row) => ({ + sessionId: row.sessionId, + titleOverridden: row.titleOverridden, + title: row.title, + archivedAt: row.archivedAt, + closedAt: row.closedAt, + })); + } + + upsert(record: SessionMetaRecord): void { + const now = Date.now(); + this.db + .insert(sessionMeta) + .values({ ...record, updatedAt: now }) + .onConflictDoUpdate({ + target: sessionMeta.sessionId, + set: { + titleOverridden: record.titleOverridden, + title: record.title, + archivedAt: record.archivedAt, + closedAt: record.closedAt, + updatedAt: now, + }, + }) + .run(); + } + + delete(sessionId: string): void { + this.db + .delete(sessionMeta) + .where(eq(sessionMeta.sessionId, sessionId)) + .run(); + } +} diff --git a/packages/host-service/src/runtime/sessions/sync-hub.stress.test.ts b/packages/host-service/src/runtime/sessions/sync-hub.stress.test.ts new file mode 100644 index 00000000000..af1b4e8a3d4 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/sync-hub.stress.test.ts @@ -0,0 +1,845 @@ +import { describe, expect, test } from "bun:test"; +import type { + HostEventPacket, + SessionEventPacket, + SyncServerPacket, +} from "@superset/host-service-sync/protocol"; +import { CanonicalSessionsError } from "./canonical-sessions"; +import type { SessionsSyncConnection } from "./sync-hub"; +import { + chunk, + eventPackets, + expectPacket, + type FakeSyncSocket, + flush, + makeHub, + openClient, + seedLive, + subscribeFrame, + unsubscribeFrame, + ZERO_HOST_CURSOR, + ZERO_SESSION_CURSOR, +} from "./testing/sync-harness"; +import { acpMainThreadId } from "./translate-acp"; + +// --------------------------------------------------------------------------- +// Deterministic randomness — mulberry32, so a failing seed replays exactly. +// --------------------------------------------------------------------------- + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +class Rng { + private readonly next: () => number; + constructor(seed: number) { + this.next = mulberry32(seed); + } + float(): number { + return this.next(); + } + pick(items: readonly T[]): T { + const item = items[Math.floor(this.next() * items.length)]; + if (item === undefined) throw new Error("pick from an empty list"); + return item; + } + chance(probability: number): boolean { + return this.next() < probability; + } +} + +function hostSerialOf(cursor: string): number { + const match = /^h[a-z0-9]+-(\d{12})$/.exec(cursor); + if (!match) throw new Error(`not a host cursor: ${cursor}`); + return Number(match[1]); +} + +// --------------------------------------------------------------------------- +// Subscription oracle +// --------------------------------------------------------------------------- + +type TrackerPhase = "awaiting_subscribed" | "replaying" | "live" | "done"; + +/** + * Client-side oracle for one subscription. Enforces the packet grammar + * (subscribed → replay → caughtUp → live → unsubscribed), strict cursor + * monotonicity, host-serial contiguity, and folds host events so the + * end-of-run check can compare against the runtime's truth. + */ +class SubscriptionTracker { + phase: TrackerPhase = "awaiting_subscribed"; + /** Every event cursor delivered — replay and live — in arrival order. */ + readonly cursors: string[] = []; + through: string | null = null; + lastDelivered: string | null = null; + /** Host-stream folds; non-null only for zero-cursor host subscriptions. */ + membership: Set | null = null; + pendingPermissions: Set | null = null; + + constructor( + readonly subscriptionId: string, + readonly stream: "host" | "session", + readonly sessionId: string | null, + readonly after: string, + ) { + // A zero-cursor host subscribe replays the whole ring, so folding from + // empty sets reconstructs the host scope; a mid-stream resume cannot. + if (stream === "host" && after === ZERO_HOST_CURSOR) { + this.membership = new Set(); + this.pendingPermissions = new Set(); + } + } + + private get tag(): string { + const scope = this.sessionId ? `:${this.sessionId}` : ""; + return `${this.subscriptionId} (${this.stream}${scope}, after=${this.after})`; + } + + feed(packet: SyncServerPacket): void { + switch (this.phase) { + case "awaiting_subscribed": { + if (packet.type !== "subscribed") { + throw new Error( + `${this.tag}: expected subscribed, got ${packet.type}`, + ); + } + if ( + packet.stream !== this.stream || + (packet.sessionId ?? null) !== this.sessionId + ) { + throw new Error(`${this.tag}: subscribed for the wrong stream`); + } + if (packet.replay.fromExclusive !== this.after) { + throw new Error( + `${this.tag}: fromExclusive ${packet.replay.fromExclusive} != requested after`, + ); + } + this.through = packet.replay.through; + this.lastDelivered = packet.replay.through; + this.phase = "replaying"; + return; + } + case "replaying": { + if (packet.type === "event") { + this.acceptEvent(packet, { live: false }); + return; + } + if (packet.type === "caughtUp") { + if (packet.through !== this.through) { + throw new Error( + `${this.tag}: caughtUp through ${packet.through} != ${this.through}`, + ); + } + this.phase = "live"; + return; + } + throw new Error(`${this.tag}: unexpected ${packet.type} during replay`); + } + case "live": { + if (packet.type === "event") { + this.acceptEvent(packet, { live: true }); + return; + } + if (packet.type === "unsubscribed") { + if ( + packet.stream !== this.stream || + (packet.sessionId ?? null) !== this.sessionId + ) { + throw new Error(`${this.tag}: unsubscribed for the wrong stream`); + } + if (packet.through !== this.lastDelivered) { + throw new Error( + `${this.tag}: unsubscribed through ${packet.through} != last delivered ${this.lastDelivered}`, + ); + } + this.phase = "done"; + return; + } + throw new Error(`${this.tag}: unexpected ${packet.type} while live`); + } + case "done": + throw new Error(`${this.tag}: ${packet.type} after unsubscribed`); + } + } + + private acceptEvent( + packet: HostEventPacket | SessionEventPacket, + { live }: { live: boolean }, + ): void { + if (packet.stream !== this.stream) { + throw new Error(`${this.tag}: event for the wrong stream`); + } + if (this.through === null) { + throw new Error(`${this.tag}: event before subscribed`); + } + const floor = this.cursors[this.cursors.length - 1] ?? this.after; + if (packet.cursor <= floor) { + throw new Error( + `${this.tag}: cursor ${packet.cursor} <= ${floor} (duplicate or regression)`, + ); + } + if (live) { + if (packet.cursor <= this.through) { + throw new Error( + `${this.tag}: live cursor ${packet.cursor} inside the replay window`, + ); + } + } else if (packet.cursor > this.through) { + throw new Error( + `${this.tag}: replay cursor ${packet.cursor} beyond through ${this.through}`, + ); + } + if (packet.stream === "session") { + if (packet.sessionId !== this.sessionId) { + throw new Error(`${this.tag}: event for session ${packet.sessionId}`); + } + if (packet.event.cursor !== packet.cursor) { + throw new Error(`${this.tag}: packet cursor != event cursor`); + } + } else { + // Host serials are minted one per change and every registered + // subscription hears every change, so the stream must be contiguous. + const expectedSerial = hostSerialOf(floor) + 1; + if (hostSerialOf(packet.cursor) !== expectedSerial) { + throw new Error( + `${this.tag}: host serial gap — got ${packet.cursor}, expected serial ${expectedSerial}`, + ); + } + const event = packet.event; + if (this.membership) { + if (event.type === "sessionUpsert") { + this.membership.add(packet.sessionId); + } else if (event.type === "sessionRemoved") { + this.membership.delete(packet.sessionId); + } + } + if (this.pendingPermissions) { + if (event.type === "permissionAvailable") { + this.pendingPermissions.add(event.permission.id); + } else if (event.type === "permissionResolved") { + this.pendingPermissions.delete(event.permissionId); + } + } + } + this.cursors.push(packet.cursor); + this.lastDelivered = packet.cursor; + } +} + +// --------------------------------------------------------------------------- +// Churn model +// --------------------------------------------------------------------------- + +interface ClientState { + name: string; + socket: FakeSyncSocket; + connection: SessionsSyncConnection; + /** Every tracker ever created, including unsubscribed and dead ones. */ + trackers: Map; + /** Mirror of the hub's registered-subscription set for this connection. */ + active: Set; + /** Error codes the model deliberately provoked, in send order. */ + expectedErrors: string[]; + alive: boolean; + subscriptionSerial: number; +} + +const SESSIONS = ["session-a", "session-b", "session-c", "session-d"] as const; +const MAX_SUBSCRIPTIONS = 6; +const MAX_CLIENTS = 5; +const OPS_PER_SEED = 220; +const SEEDS = [11, 23, 37, 41, 59]; + +async function runChurn(seed: number): Promise { + const rng = new Rng(seed); + const { port, runtime, hub } = makeHub({ + limits: { maxSubscriptions: MAX_SUBSCRIPTIONS }, + }); + for (const sessionId of SESSIONS) seedLive(port, sessionId); + // Track everything up front so permission and turn folds are live from the + // start; hostSnapshotData's pendings come from tracked sessions only. + for (const sessionId of SESSIONS) await runtime.warmSession(sessionId); + await flush(); + + const clients: ClientState[] = []; + let clientSerial = 0; + let requestSerial = 0; + let permissionSerial = 0; + const outstandingPermissions: Array<{ + sessionId: string; + requestId: string; + }> = []; + const archived = new Set(); + + const openNewClient = async (): Promise => { + clientSerial += 1; + const { socket, client } = await openClient(hub); + const state: ClientState = { + name: `client-${clientSerial}`, + socket, + connection: client, + trackers: new Map(), + active: new Set(), + expectedErrors: [], + alive: true, + subscriptionSerial: 0, + }; + clients.push(state); + return state; + }; + + const aliveClients = () => clients.filter((client) => client.alive); + + const route = (client: ClientState, packet: SyncServerPacket): void => { + switch (packet.type) { + case "pong": + case "helloAck": + throw new Error(`${client.name}: unexpected ${packet.type}`); + case "error": { + const expected = client.expectedErrors.shift(); + if (packet.code !== expected) { + throw new Error( + `${client.name}: unexpected error ${packet.code} (expected ${expected ?? "no error"})`, + ); + } + return; + } + case "reset": + // Every cursor this model resumes from was previously delivered by + // this hub incarnation and nothing is ever evicted, so a reset is + // always a hub bug here. + throw new Error( + `${client.name}: unexpected reset for ${packet.subscriptionId}`, + ); + case "subscribed": + case "caughtUp": + case "event": + case "unsubscribed": { + const tracker = client.trackers.get(packet.subscriptionId); + if (!tracker) { + throw new Error( + `${client.name}: packet for unknown subscription ${packet.subscriptionId}`, + ); + } + tracker.feed(packet); + return; + } + } + }; + + const drainAll = (): void => { + for (const client of clients) { + const packets = client.socket.take(); + if (!client.alive) { + if (packets.length > 0) { + throw new Error(`${client.name}: received packets after going away`); + } + continue; + } + for (const packet of packets) route(client, packet); + if (client.expectedErrors.length > 0) { + throw new Error( + `${client.name}: expected ${client.expectedErrors.join(", ")} never arrived`, + ); + } + if (client.socket.closed) { + // The only server-initiated close in this run is back-pressure. + expect(client.socket.closed.code).toBe(1013); + client.alive = false; + } + } + }; + + const settle = async (): Promise => { + await flush(); + drainAll(); + }; + + const rememberedCursor = ( + stream: "host" | "session", + sessionId: string | null, + ): string | null => { + // Cursors from dead connections are deliberately included: resuming + // from a killed client's last delivery is the reconnect story. + const candidates: string[] = []; + for (const client of clients) { + for (const tracker of client.trackers.values()) { + if (tracker.stream !== stream) continue; + if (stream === "session" && tracker.sessionId !== sessionId) continue; + if (tracker.lastDelivered !== null) { + candidates.push(tracker.lastDelivered); + } + } + } + return candidates.length > 0 ? rng.pick(candidates) : null; + }; + + const subscribeAction = async (client: ClientState): Promise => { + client.subscriptionSerial += 1; + if (client.active.size >= MAX_SUBSCRIPTIONS) { + // Deliberately overflow: the hub must refuse with a retryable + // SUBSCRIPTION_LIMIT and register nothing. + client.expectedErrors.push("SUBSCRIPTION_LIMIT"); + await client.connection.handleMessage( + subscribeFrame({ + subscriptionId: `${client.name}-overflow-${client.subscriptionSerial}`, + stream: { type: "host" }, + }), + ); + return; + } + const subscriptionId = `${client.name}-sub-${client.subscriptionSerial}`; + if (rng.chance(0.6)) { + const sessionId = rng.pick(SESSIONS); + // The zero cursor is the "from scratch" idiom: what a client that + // fetched an empty tRPC snapshot holds. + const after = + (rng.chance(0.5) ? rememberedCursor("session", sessionId) : null) ?? + ZERO_SESSION_CURSOR; + client.trackers.set( + subscriptionId, + new SubscriptionTracker(subscriptionId, "session", sessionId, after), + ); + client.active.add(subscriptionId); + await client.connection.handleMessage( + subscribeFrame({ + subscriptionId, + stream: { type: "session", sessionId }, + after, + }), + ); + return; + } + const after = + (rng.chance(0.5) ? rememberedCursor("host", null) : null) ?? + ZERO_HOST_CURSOR; + client.trackers.set( + subscriptionId, + new SubscriptionTracker(subscriptionId, "host", null, after), + ); + client.active.add(subscriptionId); + await client.connection.handleMessage( + subscribeFrame({ subscriptionId, stream: { type: "host" }, after }), + ); + }; + + await openNewClient(); + await openNewClient(); + await settle(); + + for (let op = 0; op < OPS_PER_SEED; op += 1) { + if (aliveClients().length === 0) await openNewClient(); + const roll = rng.float(); + if (roll < 0.24) { + port.emitUpdate(rng.pick(SESSIONS), chunk(`churn ${seed}:${op}`)); + } else if (roll < 0.32) { + port.emitState(rng.pick(SESSIONS), {}); + } else if (roll < 0.38) { + const candidates = SESSIONS.filter((id) => !archived.has(id)); + if (candidates.length > 0) { + const sessionId = rng.pick(candidates); + requestSerial += 1; + try { + await runtime.submitTurn({ + requestId: `req-turn-${seed}-${requestSerial}`, + sessionId, + threadId: acpMainThreadId(sessionId), + content: [{ type: "text", text: `turn ${op}` }], + }); + } catch (error) { + // Busy/receipt conflicts are turn-lifecycle concerns, not what + // this suite measures; anything else is a real failure. + if (!(error instanceof CanonicalSessionsError)) throw error; + } + } + } else if (roll < 0.44) { + // Permissions only on in-scope sessions: their host events are not + // scope-filtered, which would desync the pendings fold below. + const candidates = SESSIONS.filter((id) => !archived.has(id)); + if (candidates.length > 0) { + const sessionId = rng.pick(candidates); + permissionSerial += 1; + const requestId = `perm-${seed}-${permissionSerial}`; + port.requestPermission( + sessionId, + requestId, + `toolu-${permissionSerial}`, + ); + outstandingPermissions.push({ sessionId, requestId }); + } + } else if (roll < 0.5) { + if (outstandingPermissions.length > 0) { + const index = Math.floor(rng.float() * outstandingPermissions.length); + const [pending] = outstandingPermissions.splice(index, 1); + if (pending) { + port.respondToPermission({ + sessionId: pending.sessionId, + requestId: pending.requestId, + outcome: { outcome: "selected", optionId: "allow" }, + }); + } + } + } else if (roll < 0.55) { + const candidates = SESSIONS.filter( + (id) => + !archived.has(id) && + !outstandingPermissions.some((pending) => pending.sessionId === id), + ); + if (candidates.length > 0) { + const sessionId = rng.pick(candidates); + requestSerial += 1; + await runtime.updateSession({ + requestId: `req-archive-${seed}-${requestSerial}`, + sessionId, + archived: true, + }); + archived.add(sessionId); + } + } else if (roll < 0.59) { + const candidates = SESSIONS.filter((id) => archived.has(id)); + if (candidates.length > 0) { + const sessionId = rng.pick(candidates); + requestSerial += 1; + await runtime.updateSession({ + requestId: `req-restore-${seed}-${requestSerial}`, + sessionId, + archived: false, + }); + archived.delete(sessionId); + } + } else if (roll < 0.72) { + await subscribeAction(rng.pick(aliveClients())); + } else if (roll < 0.79) { + const withSubs = aliveClients().filter( + (client) => client.active.size > 0, + ); + if (withSubs.length > 0) { + const client = rng.pick(withSubs); + const subscriptionId = rng.pick([...client.active]); + client.active.delete(subscriptionId); + requestSerial += 1; + await client.connection.handleMessage( + unsubscribeFrame( + subscriptionId, + `req-unsub-${seed}-${requestSerial}`, + ), + ); + } + } else if (roll < 0.83) { + if (clients.length < MAX_CLIENTS) await openNewClient(); + } else if (roll < 0.86) { + const candidates = aliveClients(); + if (candidates.length > 1) { + const client = rng.pick(candidates); + client.connection.dispose(); + client.alive = false; + } + } else if (roll < 0.9) { + const client = rng.pick(aliveClients()); + client.expectedErrors.push("INVALID_PACKET"); + await client.connection.handleMessage("{ this is not json"); + } else if (roll < 0.93) { + const withSubs = aliveClients().filter( + (client) => client.active.size > 0, + ); + if (withSubs.length > 0) { + const client = rng.pick(withSubs); + client.expectedErrors.push("INVALID_PACKET"); + await client.connection.handleMessage( + subscribeFrame({ + subscriptionId: rng.pick([...client.active]), + stream: { type: "host" }, + }), + ); + } + } else if (roll < 0.96) { + const client = rng.pick(aliveClients()); + client.expectedErrors.push("INVALID_PACKET"); + requestSerial += 1; + await client.connection.handleMessage( + unsubscribeFrame( + `ghost-${seed}-${requestSerial}`, + `req-ghost-${requestSerial}`, + ), + ); + } else { + // Back-pressure kill: the next send drops the connection with 1013. + const candidates = aliveClients(); + if (candidates.length > 1) { + const client = rng.pick(candidates); + client.socket.bufferedAmount = 9 * 1024 * 1024; + await client.connection.handleMessage( + JSON.stringify({ type: "ping", nonce: "doom" }), + ); + } + } + await settle(); + } + await settle(); + + // ---- Oracle: every session subscription saw an exact canonical suffix ---- + const canonicalCache = new Map(); + const canonicalFor = (sessionId: string): string[] => { + let cursors = canonicalCache.get(sessionId); + if (!cursors) { + const replay = runtime.sessionReplay(sessionId, null); + if (!replay.ok) throw new Error(`no canonical replay for ${sessionId}`); + cursors = replay.events.map((event) => event.cursor); + canonicalCache.set(sessionId, cursors); + } + return cursors; + }; + + let checkedSessionTrackers = 0; + for (const client of clients) { + for (const tracker of client.trackers.values()) { + if (tracker.phase === "awaiting_subscribed") { + throw new Error(`${tracker.subscriptionId} never got subscribed`); + } + if (tracker.stream !== "session" || tracker.sessionId === null) continue; + const expected = canonicalFor(tracker.sessionId).filter( + (cursor) => cursor > tracker.after, + ); + // Dead and unsubscribed trackers hold a prefix; a live tracker on a + // live connection must have received everything. + expect(tracker.cursors).toEqual( + expected.slice(0, tracker.cursors.length), + ); + if (client.alive && tracker.phase === "live") { + expect(tracker.cursors.length).toBe(expected.length); + } + checkedSessionTrackers += 1; + } + } + expect(checkedSessionTrackers).toBeGreaterThan(0); + + // ---- Oracle: host subscriptions converge on the runtime's truth ---- + const liveHostTrackers: SubscriptionTracker[] = []; + for (const client of aliveClients()) { + for (const tracker of client.trackers.values()) { + if (tracker.stream === "host" && tracker.phase === "live") { + liveHostTrackers.push(tracker); + } + } + } + if (liveHostTrackers.length > 0) { + const finalSerials = new Set( + liveHostTrackers.map((tracker) => + hostSerialOf(tracker.lastDelivered ?? ""), + ), + ); + expect(finalSerials.size).toBe(1); + + const data = runtime.hostSnapshotData(); + const scopeIds = data.sessions.map((session) => session.id).sort(); + expect(scopeIds).toEqual(SESSIONS.filter((id) => !archived.has(id)).sort()); + const pendingIds = data.pendingPermissions + .map((permission) => permission.id) + .sort(); + expect(pendingIds.length).toBe(outstandingPermissions.length); + for (const tracker of liveHostTrackers) { + if (tracker.membership) { + expect([...tracker.membership].sort()).toEqual(scopeIds); + } + if (tracker.pendingPermissions) { + expect([...tracker.pendingPermissions].sort()).toEqual(pendingIds); + } + } + } + + // ---- Shutdown: survivors close 1001, feeds detach ---- + const survivors = aliveClients(); + expect(survivors.length).toBeGreaterThan(0); + hub.dispose(); + for (const client of survivors) { + expect(client.socket.closed?.code).toBe(1001); + } + port.emitUpdate("session-a", chunk("after dispose")); + await flush(); + for (const client of clients) { + expect(client.socket.take()).toHaveLength(0); + } +} + +describe("sessions sync stress", () => { + for (const seed of SEEDS) { + test(`randomized churn (seed ${seed}): every subscription sees a gapless canonical suffix`, async () => { + await runChurn(seed); + }); + } + + test("a connection killed mid-subscribe never registers and leaves others untouched", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const healthy = await openClient(hub); + await healthy.client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-healthy", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + healthy.socket.take(); + + const doomed = await openClient(hub); + doomed.socket.bufferedAmount = 9 * 1024 * 1024; + await doomed.client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-doomed", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + // The very first send (subscribed) hits back-pressure: nothing was + // delivered, nothing registered, and the socket closed 1013. + expect(doomed.socket.take()).toHaveLength(0); + expect(doomed.socket.closed?.code).toBe(1013); + + port.emitUpdate("session-a", chunk("still flowing")); + expect(eventPackets(healthy.socket.take()).length).toBeGreaterThan(0); + expect(doomed.socket.take()).toHaveLength(0); + }); + + test("kill and resume: a reconnect from the last delivered cursor closes the gap exactly", async () => { + const { port, runtime, hub } = makeHub(); + seedLive(port, "session-a"); + + const first = await openClient(hub); + await first.client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-first", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + const setup = first.socket.take(); + const through = expectPacket(setup[0], "subscribed").replay.through; + + port.emitUpdate("session-a", chunk("one")); + port.emitUpdate("session-a", chunk("two")); + const beforeKill = eventPackets(first.socket.take()).map( + (packet) => packet.cursor, + ); + const lastDelivered = beforeKill[beforeKill.length - 1]; + if (!lastDelivered) throw new Error("expected deliveries before the kill"); + + // The client stops draining and dies mid-stream… + first.socket.bufferedAmount = 9 * 1024 * 1024; + port.emitUpdate("session-a", chunk("lost in flight")); + expect(first.socket.closed?.code).toBe(1013); + // …while the world keeps moving. + port.emitUpdate("session-a", chunk("while offline")); + + const second = await openClient(hub); + await second.client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-second", + stream: { type: "session", sessionId: "session-a" }, + after: lastDelivered, + }), + ); + const packets = second.socket.take(); + const resumed = expectPacket(packets[0], "subscribed"); + expect(resumed.replay.fromExclusive).toBe(lastDelivered); + const replayed = eventPackets(packets).map((packet) => packet.cursor); + expect(expectPacket(packets[packets.length - 1], "caughtUp").through).toBe( + resumed.replay.through, + ); + + // The two delivery windows tile the canonical log exactly: no gap at + // the kill, no duplicates across the reconnect. + const canonical = runtime.sessionReplay("session-a", null); + if (!canonical.ok) throw new Error("expected canonical replay"); + const expected = canonical.events + .map((event) => event.cursor) + .filter((cursor) => cursor > through); + expect([...beforeKill, ...replayed]).toEqual(expected); + + // And the resumed subscription is live. + port.emitUpdate("session-a", chunk("back online")); + const live = eventPackets(second.socket.take()); + expect(live.length).toBeGreaterThan(0); + for (const packet of live) { + expect(packet.cursor > resumed.replay.through).toBe(true); + } + }); + + test("subscribe/unsubscribe churn on one id stays grammar-clean and monotonic", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + + let previousThrough: string | null = null; + for (let round = 0; round < 30; round += 1) { + // Every round re-subscribes from scratch: the zero cursor replays the + // whole (growing) log, ending exactly at the advertised through. + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-churn", + stream: { type: "session", sessionId: "session-a" }, + requestId: `req-sub-${round}`, + }), + ); + const setup = socket.take(); + const subscribed = expectPacket(setup[0], "subscribed"); + const through = subscribed.replay.through; + const replayed = eventPackets(setup); + expect(setup).toHaveLength(replayed.length + 2); + expect(replayed[replayed.length - 1]?.cursor).toBe(through); + expect(expectPacket(setup[setup.length - 1], "caughtUp").through).toBe( + through, + ); + if (previousThrough !== null) { + // Events emitted between rounds land in the next replay window. + expect(through > previousThrough).toBe(true); + } + + port.emitUpdate("session-a", chunk(`round ${round}`)); + const live = eventPackets(socket.take()); + expect(live.length).toBeGreaterThan(0); + const lastCursor = live[live.length - 1]?.cursor ?? ""; + + await client.handleMessage( + unsubscribeFrame("sub-churn", `req-unsub-${round}`), + ); + const unsubscribed = expectPacket(socket.take()[0], "unsubscribed"); + expect(unsubscribed.through).toBe(lastCursor); + + port.emitUpdate("session-a", chunk(`between rounds ${round}`)); + expect(socket.take()).toHaveLength(0); + previousThrough = through; + } + }); + + test("garbage frames interleaved with live traffic do not disturb the stream", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-a", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + + port.emitUpdate("session-a", chunk("first")); + await client.handleMessage("not json at all"); + port.emitUpdate("session-a", chunk("second")); + + const packets = socket.take(); + const error = expectPacket( + packets.find((packet) => packet.type === "error"), + "error", + ); + expect(error.code).toBe("INVALID_PACKET"); + const cursors = eventPackets(packets).map((packet) => packet.cursor); + expect(cursors.length).toBeGreaterThanOrEqual(2); + expect([...cursors].sort()).toEqual(cursors); + expect(new Set(cursors).size).toBe(cursors.length); + expect(socket.closed).toBeNull(); + }); +}); diff --git a/packages/host-service/src/runtime/sessions/sync-hub.test.ts b/packages/host-service/src/runtime/sessions/sync-hub.test.ts new file mode 100644 index 00000000000..d844233733c --- /dev/null +++ b/packages/host-service/src/runtime/sessions/sync-hub.test.ts @@ -0,0 +1,750 @@ +import { describe, expect, test } from "bun:test"; +import { + chunk, + eventPackets, + expectPacket, + FakeSyncSocket, + flush, + helloFrame, + hostEvents, + makeHub, + openClient, + seedLive, + sessionEvents, + subscribeFrame, + unsubscribeFrame, + ZERO_HOST_CURSOR, + ZERO_SESSION_CURSOR, +} from "./testing/sync-harness"; +import { acpMainThreadId } from "./translate-acp"; + +describe("SessionsSyncHub", () => { + test("handshake: hello → helloAck, ping → pong, toolResolversChanged is silent", async () => { + const { hub } = makeHub({ + limits: { maxSubscriptions: 8, maxFrameBytes: 2_048 }, + }); + const socket = new FakeSyncSocket(); + const client = hub.connect(socket); + + await client.handleMessage(helloFrame({ requestId: "req-h1" })); + const ack = expectPacket(socket.take()[0], "helloAck"); + expect(ack.requestId).toBe("req-h1"); + expect(ack.protocolVersion).toBe(1); + expect(ack.hostId).toBe("host-test"); + expect(ack.connectionId.length).toBeGreaterThan(0); + expect(ack.limits).toEqual({ maxSubscriptions: 8, maxFrameBytes: 2_048 }); + + await client.handleMessage( + JSON.stringify({ type: "ping", nonce: "nonce-1" }), + ); + expect(expectPacket(socket.take()[0], "pong").nonce).toBe("nonce-1"); + + await client.handleMessage( + JSON.stringify({ + type: "toolResolversChanged", + requestId: "req-tools", + toolResolvers: [{ name: "openFile", version: 1 }], + }), + ); + expect(socket.take()).toHaveLength(0); + }); + + test("hello with an unsupported protocol version is refused and the socket closes", async () => { + const { hub } = makeHub(); + const socket = new FakeSyncSocket(); + const client = hub.connect(socket); + + await client.handleMessage( + helloFrame({ protocolVersion: 2, requestId: "req-v2" }), + ); + const error = expectPacket(socket.take()[0], "error"); + expect(error.code).toBe("UNSUPPORTED_PROTOCOL_VERSION"); + expect(error.requestId).toBe("req-v2"); + expect(error.retryable).toBe(false); + expect(socket.closed?.code).toBe(1008); + }); + + test("any packet before hello is rejected and the socket closes", async () => { + const { hub } = makeHub(); + const socket = new FakeSyncSocket(); + const client = hub.connect(socket); + + await client.handleMessage( + subscribeFrame({ subscriptionId: "sub-1", stream: { type: "host" } }), + ); + const error = expectPacket(socket.take()[0], "error"); + expect(error.code).toBe("INVALID_PACKET"); + expect(error.requestId).toBe("req-sub-1"); + expect(socket.closed?.code).toBe(1008); + + // The connection is gone: nothing else is processed. + await client.handleMessage(helloFrame()); + expect(socket.take()).toHaveLength(0); + }); + + test("a duplicate hello is an error but does not drop the connection", async () => { + const { hub } = makeHub(); + const { socket, client } = await openClient(hub); + + await client.handleMessage(helloFrame({ requestId: "req-again" })); + const error = expectPacket(socket.take()[0], "error"); + expect(error.code).toBe("INVALID_PACKET"); + expect(error.requestId).toBe("req-again"); + expect(socket.closed).toBeNull(); + + await client.handleMessage( + JSON.stringify({ type: "ping", nonce: "still-alive" }), + ); + expect(expectPacket(socket.take()[0], "pong").nonce).toBe("still-alive"); + }); + + test("malformed and oversized frames after hello answer INVALID_PACKET and keep the connection", async () => { + const { hub } = makeHub({ limits: { maxFrameBytes: 512 } }); + const { socket, client } = await openClient(hub); + + await client.handleMessage("this is not json"); + expect(expectPacket(socket.take()[0], "error").code).toBe("INVALID_PACKET"); + expect(socket.closed).toBeNull(); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-big", + stream: { type: "host" }, + requestId: "r".repeat(600), + }), + ); + const oversized = expectPacket(socket.take()[0], "error"); + expect(oversized.code).toBe("INVALID_PACKET"); + expect(oversized.requestId).toBeNull(); + expect(socket.closed).toBeNull(); + + await client.handleMessage( + JSON.stringify({ type: "ping", nonce: "still-alive" }), + ); + expect(expectPacket(socket.take()[0], "pong").nonce).toBe("still-alive"); + }); + + test("session subscribe from the zero cursor: subscribed → full replay → caughtUp, then live events", async () => { + const { port, runtime, hub } = makeHub(); + seedLive(port, "session-a"); + port.emitUpdate("session-a", chunk("before subscribe")); + const { socket, client } = await openClient(hub); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-a", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + const packets = socket.take(); + const subscribed = expectPacket(packets[0], "subscribed"); + expect(subscribed.stream).toBe("session"); + expect(subscribed.sessionId).toBe("session-a"); + expect(subscribed.replay.fromExclusive).toBe(ZERO_SESSION_CURSOR); + const replayed = sessionEvents(packets); + expect(replayed.length).toBeGreaterThan(0); + expect(packets).toHaveLength(replayed.length + 2); + for (const packet of replayed) { + expect(packet.sessionId).toBe("session-a"); + expect(packet.cursor <= subscribed.replay.through).toBe(true); + } + expect(replayed[replayed.length - 1]?.cursor).toBe( + subscribed.replay.through, + ); + const caughtUp = expectPacket(packets[packets.length - 1], "caughtUp"); + expect(caughtUp.through).toBe(subscribed.replay.through); + + // The snapshot rides tRPC now; its head is exactly the replay window's + // end, so a client that subscribed from the snapshot head misses nothing. + const snapshot = await runtime.getSession({ sessionId: "session-a" }); + expect(snapshot.head).toBe(subscribed.replay.through); + expect( + snapshot.threads.some( + (thread) => thread.id === acpMainThreadId("session-a"), + ), + ).toBe(true); + + port.emitUpdate("session-a", chunk("live one")); + const live = eventPackets(socket.take()); + expect(live.length).toBeGreaterThan(0); + for (const packet of live) { + expect(packet.stream).toBe("session"); + expect(packet.sessionId).toBe("session-a"); + expect(packet.subscriptionId).toBe("sub-a"); + expect(packet.cursor > subscribed.replay.through).toBe(true); + } + // Cursors are strictly increasing on the wire. + const cursors = live.map((packet) => packet.cursor); + expect([...cursors].sort()).toEqual(cursors); + expect(new Set(cursors).size).toBe(cursors.length); + }); + + test("two subscriptions to the same session each get the live feed", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-1", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-2", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + + port.emitUpdate("session-a", chunk("fan out")); + const live = eventPackets(socket.take()); + const bySubscription = new Set(live.map((packet) => packet.subscriptionId)); + expect(bySubscription).toEqual(new Set(["sub-1", "sub-2"])); + }); + + test("session subscribe with a cursor replays exactly the events after it", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const witness = await openClient(hub); + await witness.client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-w", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + witness.socket.take(); + port.emitUpdate("session-a", chunk("one")); + port.emitUpdate("session-a", chunk("two")); + const seen = eventPackets(witness.socket.take()); + expect(seen.length).toBeGreaterThan(1); + const cursors = seen.map((packet) => packet.cursor); + const resumeFrom = cursors[0]; + if (!resumeFrom) throw new Error("expected a resume cursor"); + + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-resume", + stream: { type: "session", sessionId: "session-a" }, + after: resumeFrom, + }), + ); + const packets = socket.take(); + const subscribed = expectPacket(packets[0], "subscribed"); + expect(subscribed.replay.fromExclusive).toBe(resumeFrom); + const replayed = sessionEvents(packets); + expect(replayed.map((packet) => packet.cursor)).toEqual(cursors.slice(1)); + expect(replayed.every((packet) => packet.event.id.length > 0)).toBe(true); + const caughtUp = expectPacket(packets[packets.length - 1], "caughtUp"); + expect(caughtUp.through).toBe(subscribed.replay.through); + + // Resuming from the head replays nothing. + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-at-head", + stream: { type: "session", sessionId: "session-a" }, + after: caughtUp.through, + }), + ); + const atHead = socket.take(); + expect(atHead.map((packet) => packet.type)).toEqual([ + "subscribed", + "caughtUp", + ]); + + // Both stay live after their replays. + port.emitUpdate("session-a", chunk("three")); + const live = eventPackets(socket.take()); + const bySubscription = new Set(live.map((packet) => packet.subscriptionId)); + expect(bySubscription).toEqual(new Set(["sub-resume", "sub-at-head"])); + }); + + test("session subscribe with a foreign-generation cursor answers reset and does not register", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + + // A legacy pre-generation cursor and a cursor minted by another log + // generation are both foreign — CURSOR_INVALID, like host cursors from + // a dead hub incarnation. + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-stale", + stream: { type: "session", sessionId: "session-a" }, + after: "c999999999999", + }), + ); + const reset = expectPacket(socket.take()[0], "reset"); + expect(reset.stream).toBe("session"); + expect(reset.sessionId).toBe("session-a"); + expect(reset.code).toBe("CURSOR_INVALID"); + expect(reset.recovery).toBe("refetchSnapshot"); + + port.emitUpdate("session-a", chunk("not delivered")); + expect(eventPackets(socket.take())).toHaveLength(0); + + // The subscriptionId was never registered, so recovery can reuse it. + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-stale", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + const packets = socket.take(); + expect(packets[0]?.type).toBe("subscribed"); + expect(packets[packets.length - 1]?.type).toBe("caughtUp"); + expect(sessionEvents(packets).length).toBe(packets.length - 2); + }); + + test("subscribing to an unknown session answers SESSION_NOT_FOUND", async () => { + const { hub } = makeHub(); + const { socket, client } = await openClient(hub); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-ghost", + stream: { type: "session", sessionId: "session-ghost" }, + }), + ); + const error = expectPacket(socket.take()[0], "error"); + expect(error.code).toBe("SESSION_NOT_FOUND"); + expect(error.subscriptionId).toBe("sub-ghost"); + expect(error.sessionId).toBe("session-ghost"); + expect(error.retryable).toBe(false); + }); + + test("duplicate subscriptionIds and the subscription limit are refused", async () => { + const { port, hub } = makeHub({ limits: { maxSubscriptions: 2 } }); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + + await client.handleMessage( + subscribeFrame({ subscriptionId: "sub-1", stream: { type: "host" } }), + ); + socket.take(); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-1", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + const duplicate = expectPacket(socket.take()[0], "error"); + expect(duplicate.code).toBe("INVALID_PACKET"); + expect(duplicate.subscriptionId).toBe("sub-1"); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-2", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-3", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + const limit = expectPacket(socket.take()[0], "error"); + expect(limit.code).toBe("SUBSCRIPTION_LIMIT"); + expect(limit.retryable).toBe(true); + + // Unsubscribing frees a slot. + await client.handleMessage(unsubscribeFrame("sub-2")); + expectPacket(socket.take()[0], "unsubscribed"); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-3", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + expect(expectPacket(socket.take()[0], "subscribed").subscriptionId).toBe( + "sub-3", + ); + }); + + test("unsubscribe confirms with the delivery cursor, stops the feed, and rejects unknown ids", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-a", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + port.emitUpdate("session-a", chunk("delivered")); + const live = eventPackets(socket.take()); + const lastDelivered = live[live.length - 1]?.cursor; + if (!lastDelivered) throw new Error("expected live events"); + + await client.handleMessage(unsubscribeFrame("sub-a", "req-unsub-1")); + const unsubscribed = expectPacket(socket.take()[0], "unsubscribed"); + expect(unsubscribed.requestId).toBe("req-unsub-1"); + expect(unsubscribed.stream).toBe("session"); + expect(unsubscribed.sessionId).toBe("session-a"); + expect(unsubscribed.through).toBe(lastDelivered); + + port.emitUpdate("session-a", chunk("after unsubscribe")); + expect(eventPackets(socket.take())).toHaveLength(0); + + // Idempotence is the client's job: a second unsubscribe is an error + // (`unsubscribed` needs a stream we no longer know). + await client.handleMessage(unsubscribeFrame("sub-a", "req-unsub-2")); + const error = expectPacket(socket.take()[0], "error"); + expect(error.code).toBe("INVALID_PACKET"); + expect(error.requestId).toBe("req-unsub-2"); + }); + + test("host subscribe from the zero cursor, then coalesced upserts, removals, permissions", async () => { + const { port, runtime, hub } = makeHub(); + seedLive(port, "session-a"); + seedLive(port, "session-b"); + const { socket, client } = await openClient(hub); + + await client.handleMessage( + subscribeFrame({ subscriptionId: "sub-host", stream: { type: "host" } }), + ); + const packets = socket.take(); + expect(packets.map((packet) => packet.type)).toEqual([ + "subscribed", + "caughtUp", + ]); + const subscribed = expectPacket(packets[0], "subscribed"); + expect(subscribed.replay.fromExclusive).toBe(ZERO_HOST_CURSOR); + expect(subscribed.replay.through).toBe(ZERO_HOST_CURSOR); + // Snapshot content rides tRPC now: both seeded rows are in host scope, + // and the hub's head is exactly where the subscription started. + expect(hub.hostHead()).toBe(ZERO_HOST_CURSOR); + const data = runtime.hostSnapshotData(); + expect(data.sessions.map((session) => session.id).sort()).toEqual([ + "session-a", + "session-b", + ]); + + // Tracking a session emits its first composed upsert. + await runtime.warmSession("session-a"); + await flush(); + socket.take(); + + // A burst of folds coalesces into one upsert per flush. + port.emitUpdate("session-a", chunk("one")); + port.emitUpdate("session-a", chunk("two")); + await flush(); + const upserts = hostEvents(socket.take()).filter( + (packet) => packet.event.type === "sessionUpsert", + ); + expect(upserts).toHaveLength(1); + const upsert = upserts[0]; + if (!upsert) throw new Error("expected an upsert"); + expect(upsert.stream).toBe("host"); + expect(upsert.sessionId).toBe("session-a"); + expect(upsert.threadId).toBeNull(); + + // Permission transitions ride the host stream with their thread. + await runtime.submitTurn({ + requestId: "req-turn-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "do the thing" }], + }); + await flush(); + socket.take(); + port.requestPermission("session-a", "perm-1", "toolu_1"); + const available = hostEvents(socket.take()).find( + (packet) => packet.event.type === "permissionAvailable", + ); + if (!available || available.event.type !== "permissionAvailable") { + throw new Error("expected a permissionAvailable host event"); + } + expect(available.sessionId).toBe("session-a"); + expect(available.threadId).toBe(available.event.permission.threadId); + const permissionId = available.event.permission.id; + + port.respondToPermission({ + sessionId: "session-a", + requestId: "perm-1", + outcome: { outcome: "selected", optionId: "allow" }, + }); + const resolved = hostEvents(socket.take()).find( + (packet) => packet.event.type === "permissionResolved", + ); + if (!resolved || resolved.event.type !== "permissionResolved") { + throw new Error("expected a permissionResolved host event"); + } + expect(resolved.event.permissionId).toBe(permissionId); + expect(resolved.threadId).toBeNull(); + + // Archiving removes the session from the host scope. + await runtime.updateSession({ + requestId: "req-archive", + sessionId: "session-a", + archived: true, + }); + const removed = hostEvents(socket.take()).find( + (packet) => packet.event.type === "sessionRemoved", + ); + if (!removed || removed.event.type !== "sessionRemoved") { + throw new Error("expected a sessionRemoved host event"); + } + expect(removed.sessionId).toBe("session-a"); + expect(removed.event.reason).toBe("archived"); + }); + + test("host subscribe with a cursor replays the retained tail; bad cursors reset", async () => { + const { port, runtime, hub } = makeHub(); + seedLive(port, "session-a"); + const witness = await openClient(hub); + await witness.client.handleMessage( + subscribeFrame({ subscriptionId: "sub-w", stream: { type: "host" } }), + ); + witness.socket.take(); + + await runtime.warmSession("session-a"); + await flush(); + port.emitUpdate("session-a", chunk("more")); + await flush(); + await runtime.submitTurn({ + requestId: "req-turn-1", + sessionId: "session-a", + threadId: acpMainThreadId("session-a"), + content: [{ type: "text", text: "go" }], + }); + port.requestPermission("session-a", "perm-1", "toolu_1"); + await flush(); + const seen = eventPackets(witness.socket.take()); + expect(seen.length).toBeGreaterThan(1); + const cursors = seen.map((packet) => packet.cursor); + const resumeFrom = cursors[0]; + if (!resumeFrom) throw new Error("expected host cursors"); + + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-resume", + stream: { type: "host" }, + after: resumeFrom, + }), + ); + const packets = socket.take(); + const subscribed = expectPacket(packets[0], "subscribed"); + expect(subscribed.replay.fromExclusive).toBe(resumeFrom); + expect(eventPackets(packets).map((packet) => packet.cursor)).toEqual( + cursors.slice(1), + ); + const caughtUp = expectPacket(packets[packets.length - 1], "caughtUp"); + expect(caughtUp.through).toBe(cursors[cursors.length - 1] ?? ""); + + // At the head: subscribed + caughtUp only. + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-at-head", + stream: { type: "host" }, + after: caughtUp.through, + }), + ); + expect(socket.take().map((packet) => packet.type)).toEqual([ + "subscribed", + "caughtUp", + ]); + + // A cursor from the future, another format, or another hub incarnation + // is invalid — never silently accepted into this hub's serial range. + for (const after of [ + "htest-999999999999", + "not-a-cursor", + "hother-000000000001", + "h000000000001", + ]) { + await client.handleMessage( + subscribeFrame({ + subscriptionId: `sub-bad-${after}`, + stream: { type: "host" }, + after, + }), + ); + const reset = expectPacket(socket.take()[0], "reset"); + expect(reset.stream).toBe("host"); + expect(reset.code).toBe("CURSOR_INVALID"); + expect(reset.recovery).toBe("refetchSnapshot"); + } + }); + + test("a host cursor older than the retained ring answers CURSOR_EXPIRED", async () => { + const { port, runtime, hub } = makeHub({ limits: { hostRingLimit: 1 } }); + seedLive(port, "session-a"); + await runtime.warmSession("session-a"); + await flush(); + port.emitUpdate("session-a", chunk("more")); + await flush(); + + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-expired", + stream: { type: "host" }, + after: "htest-000000000000", + }), + ); + const reset = expectPacket(socket.take()[0], "reset"); + expect(reset.code).toBe("CURSOR_EXPIRED"); + expect(reset.recovery).toBe("refetchSnapshot"); + }); + + test("a client that stops draining is dropped with a back-pressure close", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-a", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + + socket.bufferedAmount = 9 * 1024 * 1024; + port.emitUpdate("session-a", chunk("undeliverable")); + expect(socket.closed?.code).toBe(1013); + expect(eventPackets(socket.take())).toHaveLength(0); + + // The connection is fully detached: recovering the buffer changes nothing. + socket.bufferedAmount = 0; + port.emitUpdate("session-a", chunk("still gone")); + expect(socket.take()).toHaveLength(0); + }); + + test("hub dispose closes connections and detaches from the runtime feeds", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-a", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + + hub.dispose(); + expect(socket.closed?.code).toBe(1001); + port.emitUpdate("session-a", chunk("after dispose")); + await flush(); + expect(socket.take()).toHaveLength(0); + + // Frames arriving after dispose are ignored outright. + await client.handleMessage( + JSON.stringify({ type: "ping", nonce: "too-late" }), + ); + expect(socket.take()).toHaveLength(0); + }); + + test("a host cursor from a previous hub incarnation resets CURSOR_INVALID even when the serial range overlaps", async () => { + // Hub A (harness incarnation "test") hands a client a host cursor. + const first = makeHub(); + seedLive(first.port, "session-a"); + const witness = await openClient(first.hub); + await witness.client.handleMessage( + subscribeFrame({ subscriptionId: "sub-w", stream: { type: "host" } }), + ); + witness.socket.take(); + await first.runtime.warmSession("session-a"); + await flush(); + first.port.emitUpdate("session-a", chunk("a1")); + await flush(); + const staleCursor = eventPackets(witness.socket.take())[0]?.cursor; + if (!staleCursor) throw new Error("expected a hub-A host cursor"); + first.hub.dispose(); + + // The host restarts: a new hub with a new incarnation advances its + // serial PAST the stale cursor's number — the numeric overlap that + // would be silently accepted if cursors carried no incarnation. + const second = makeHub({ hostIncarnation: "resurrect" }); + seedLive(second.port, "session-b"); + await second.runtime.warmSession("session-b"); + await flush(); + second.port.emitUpdate("session-b", chunk("b1")); + await flush(); + second.port.emitUpdate("session-b", chunk("b2")); + await flush(); + + const { socket, client } = await openClient(second.hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-stale", + stream: { type: "host" }, + after: staleCursor, + }), + ); + const reset = expectPacket(socket.take()[0], "reset"); + expect(reset.stream).toBe("host"); + expect(reset.code).toBe("CURSOR_INVALID"); + expect(reset.recovery).toBe("refetchSnapshot"); + + // The prescribed recovery works: re-fetch the tRPC snapshot and + // resubscribe from the head it was taken at. + expect( + second.runtime.hostSnapshotData().sessions.map((session) => session.id), + ).toEqual(["session-b"]); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-fresh", + stream: { type: "host" }, + after: second.hub.hostHead(), + }), + ); + expect(socket.take().map((packet) => packet.type)).toEqual([ + "subscribed", + "caughtUp", + ]); + second.hub.dispose(); + }); + + test("a synchronous socket.send throw drops the connection instead of wedging it", async () => { + const { port, hub } = makeHub(); + seedLive(port, "session-a"); + const { socket, client } = await openClient(hub); + await client.handleMessage( + subscribeFrame({ + subscriptionId: "sub-a", + stream: { type: "session", sessionId: "session-a" }, + }), + ); + socket.take(); + + // The next live fan-out hits a socket whose send throws synchronously + // (torn TCP connection surfacing through ws). The throw must not + // escape into the runtime's listener loop, and the connection must not + // stay registered as if delivery had succeeded. + const realSend = socket.send; + socket.send = () => { + throw new Error("EPIPE"); + }; + port.emitUpdate("session-a", chunk("undeliverable")); + expect(socket.closed?.code).toBe(1011); + + // Fully detached: a healthy socket again changes nothing. + socket.send = realSend; + port.emitUpdate("session-a", chunk("still gone")); + expect(socket.take()).toHaveLength(0); + }); + + test("connect after dispose refuses the socket outright", async () => { + const { hub } = makeHub(); + hub.dispose(); + // Double dispose stays a no-op. + hub.dispose(); + + const socket = new FakeSyncSocket(); + const client = hub.connect(socket); + expect(socket.closed?.code).toBe(1001); + await client.handleMessage(helloFrame()); + expect(socket.take()).toHaveLength(0); + }); +}); diff --git a/packages/host-service/src/runtime/sessions/sync-hub.ts b/packages/host-service/src/runtime/sessions/sync-hub.ts new file mode 100644 index 00000000000..8a44f1daae7 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/sync-hub.ts @@ -0,0 +1,774 @@ +import type { + HelloPacket, + HostEvent, + SessionEvent, + SubscribePacket, + SyncErrorCode, + SyncServerPacket, + ToolResolverDescriptor, + UnsubscribePacket, +} from "@superset/host-service-sync/protocol"; +import { syncClientPacketSchema } from "@superset/host-service-sync/protocol"; +import { AcpSessionNotFoundError } from "../acp-sessions"; +import { + CanonicalSessionsError, + type CanonicalSessionsRuntime, + type HostChange, +} from "./canonical-sessions"; + +/** + * The runtime slice the hub consumes: live feeds plus the synchronous + * replay accessor. Snapshots ride tRPC, so the hub never composes one. + * Structural so tests can stub it, though the suite runs against the real + * runtime over a fake ACP port. + */ +export type SessionsSyncSource = Pick< + CanonicalSessionsRuntime, + "onSessionEvent" | "onHostChange" | "warmSession" | "sessionReplay" +>; + +// Structural slice of hono/ws's WSContext; `raw` is the underlying `ws` +// socket (present for node-ws), read for `bufferedAmount` back-pressure. +export type SyncSocket = { + send: (data: string) => void; + close: (code?: number, reason?: string) => void; + readyState: number; + raw?: { readonly bufferedAmount?: number }; +}; + +export interface SessionsSyncHubOptions { + runtime: SessionsSyncSource; + /** Stamped on every hostId-bearing packet; app.ts passes the org id. */ + hostId: string; + /** Injectable for deterministic tests; stamps hello_ack.serverTime only. */ + now?: () => number; + limits?: { + maxSubscriptions?: number; + maxFrameBytes?: number; + /** Host-stream retention override; tests shrink it to force expiry. */ + hostRingLimit?: number; + }; + /** Injectable for deterministic tests; mints hello_ack.connectionId. */ + mintConnectionId?: () => string; + /** + * Tag baked into every host-stream cursor this hub mints. Serials restart + * at zero with each hub, so without it a cursor from a previous host + * process would be silently accepted once the new serial range covers it, + * replaying an unrelated suffix. A per-incarnation tag makes every foreign + * cursor CURSOR_INVALID → deterministic snapshot rebuild. Injectable for + * deterministic tests; defaults to a random token per hub. + */ + hostIncarnation?: string; +} + +/** Route-facing handle for one accepted WebSocket. */ +export interface SessionsSyncConnection { + connectionId: string; + /** + * Feed one raw text frame. Processing is serialized per connection; the + * returned promise settles when this frame has been handled (tests await + * it, the route fires and forgets). + */ + handleMessage(raw: string): Promise; + /** The socket went away, or the route is tearing down. */ + dispose(): void; +} + +interface Subscription { + subscriptionId: string; + stream: "host" | "session"; + /** Null exactly when stream === "host". */ + sessionId: string | null; + /** + * Cursor of the last packet delivered on this subscription. Replay hands + * it off to the live feed; the fan-out skips anything at or below it, so + * a fold that lands mid-subscribe is never double-sent. + */ + lastCursor: string; +} + +interface Connection { + id: string; + socket: SyncSocket; + helloReceived: boolean; + closed: boolean; + queue: Promise; + subscriptions: Map; + toolResolvers: ToolResolverDescriptor[]; +} + +interface HostRingEntry { + serial: number; + cursor: string; + sessionId: string; + threadId: string | null; + event: HostEvent; +} + +const PROTOCOL_VERSION = 1; +const SOCKET_OPEN = 1; +const DEFAULT_MAX_SUBSCRIPTIONS = 32; +const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024; +// Same rationale as the ACP stream route: with no ACK flow control, a client +// that stops draining would grow the host's send buffer without bound. +// Blowing past this means the client is effectively gone — drop it; +// reconnect-with-cursor replays what it missed. +const WS_SEND_BUFFER_CAP_BYTES = 8 * 1024 * 1024; +/** + * Host-stream retention. Host events are coalesced row upserts and permission + * transitions, so this covers hours of activity; a client further behind + * rebuilds from the tRPC host snapshot via reset → refetchSnapshot. + */ +const HOST_RING_LIMIT = 10_000; + +function hostEventFor(change: HostChange): HostEvent { + switch (change.type) { + case "sessionUpsert": + return { type: "sessionUpsert", session: change.session }; + case "sessionRemoved": + return { type: "sessionRemoved", reason: change.reason }; + case "permissionAvailable": + return { type: "permissionAvailable", permission: change.permission }; + case "permissionResolved": + return { type: "permissionResolved", permissionId: change.permissionId }; + } +} + +/** Best-effort requestId echo for packets that failed schema validation. */ +function extractRequestId(raw: unknown): string | null { + if (typeof raw !== "object" || raw === null) return null; + const requestId = (raw as { requestId?: unknown }).requestId; + return typeof requestId === "string" && + requestId.length >= 1 && + requestId.length <= 512 + ? requestId + : null; +} + +/** + * The `/sessions/sync` WebSocket server (plans/host-sessions-sync.md). + * One hub per host process: it subscribes once to the canonical runtime's + * live feeds and fans packets out to every connection, replaying each + * subscription from its cursor first so the (fromExclusive, through] window + * in `subscribed` is exact — snapshot-or-replay, then `caught_up`, then live + * with no gap. Delivery is at-least-once across reconnects; clients dedupe + * by event id/cursor. + */ +export class SessionsSyncHub { + private readonly runtime: SessionsSyncSource; + private readonly hostId: string; + private readonly now: () => number; + private readonly maxSubscriptions: number; + private readonly maxFrameBytes: number; + private readonly hostRingLimit: number; + private readonly mintConnectionId: () => string; + private readonly hostIncarnation: string; + + private readonly connections = new Set(); + private readonly hostRing: HostRingEntry[] = []; + private hostSerial = 0; + private connectionSerial = 0; + private disposed = false; + private readonly stopSessionFeed: () => void; + private readonly stopHostFeed: () => void; + + constructor(options: SessionsSyncHubOptions) { + this.runtime = options.runtime; + this.hostId = options.hostId; + this.now = options.now ?? (() => Date.now()); + this.maxSubscriptions = + options.limits?.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS; + this.maxFrameBytes = + options.limits?.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES; + this.hostRingLimit = options.limits?.hostRingLimit ?? HOST_RING_LIMIT; + this.mintConnectionId = + options.mintConnectionId ?? + (() => { + this.connectionSerial += 1; + return `conn-${this.connectionSerial}`; + }); + this.hostIncarnation = + options.hostIncarnation ?? crypto.randomUUID().slice(0, 8); + this.stopSessionFeed = this.runtime.onSessionEvent((event) => { + this.fanOutSessionEvent(event); + }); + this.stopHostFeed = this.runtime.onHostChange((change) => { + this.fanOutHostChange(change); + }); + } + + connect(socket: SyncSocket): SessionsSyncConnection { + if (this.disposed) { + // The live feeds are already detached; a subscriber accepted now + // would snapshot, report caught_up, and never hear another event. + try { + socket.close(1001, "host shutting down"); + } catch { + // best-effort; close may race a socket already going away + } + return { + connectionId: this.mintConnectionId(), + handleMessage: async () => {}, + dispose: () => {}, + }; + } + const connection: Connection = { + id: this.mintConnectionId(), + socket, + helloReceived: false, + closed: false, + queue: Promise.resolve(), + subscriptions: new Map(), + toolResolvers: [], + }; + this.connections.add(connection); + return { + connectionId: connection.id, + handleMessage: (raw) => this.enqueue(connection, raw), + dispose: () => { + this.dropConnection(connection); + }, + }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.stopSessionFeed(); + this.stopHostFeed(); + for (const connection of [...this.connections]) { + this.dropConnection(connection); + try { + connection.socket.close(1001, "host shutting down"); + } catch { + // best-effort; close may race a socket already going away + } + } + } + + // ------------------------------------------------------------------------- + // Packet intake + // ------------------------------------------------------------------------- + + private enqueue(connection: Connection, raw: string): Promise { + // Serialize per connection so a subscribe's async warm-up can't be + // overtaken by a later unsubscribe for the same subscription. + const next = connection.queue.then(() => + this.handlePacket(connection, raw), + ); + connection.queue = next.catch((error) => { + // handlePacket reports protocol errors itself; anything reaching + // here is a hub bug, and it must not wedge the queue. + console.error("[sessions-sync] packet handling failed", error); + }); + return connection.queue; + } + + private async handlePacket( + connection: Connection, + raw: string, + ): Promise { + if (connection.closed) return; + // UTF-16 length never exceeds the UTF-8 byte count, so it rejects huge + // frames without a scan; the rest need the exact byte measurement. + if ( + raw.length > this.maxFrameBytes || + Buffer.byteLength(raw, "utf8") > this.maxFrameBytes + ) { + this.sendError(connection, { + requestId: null, + code: "INVALID_PACKET", + retryable: false, + }); + return; + } + let rawPacket: unknown; + try { + rawPacket = JSON.parse(raw); + } catch { + this.sendError(connection, { + requestId: null, + code: "INVALID_PACKET", + retryable: false, + }); + if (!connection.helloReceived) { + this.closeConnection(connection, 1008, "malformed packet before hello"); + } + return; + } + // Version-gate before schema validation: a future hello should learn + // "unsupported version", not "invalid packet". + const rawType = + typeof rawPacket === "object" && rawPacket !== null + ? (rawPacket as { type?: unknown }).type + : undefined; + if (rawType === "hello") { + const version = (rawPacket as { protocolVersion?: unknown }) + .protocolVersion; + if (version !== PROTOCOL_VERSION) { + this.sendError(connection, { + requestId: extractRequestId(rawPacket), + code: "UNSUPPORTED_PROTOCOL_VERSION", + retryable: false, + }); + this.closeConnection(connection, 1008, "unsupported protocol version"); + return; + } + } + const parsed = syncClientPacketSchema.safeParse(rawPacket); + if (!parsed.success) { + this.sendError(connection, { + requestId: extractRequestId(rawPacket), + code: "INVALID_PACKET", + retryable: false, + }); + if (!connection.helloReceived) { + this.closeConnection(connection, 1008, "invalid packet before hello"); + } + return; + } + const packet = parsed.data; + if (!connection.helloReceived && packet.type !== "hello") { + this.sendError(connection, { + requestId: packet.type === "ping" ? null : packet.requestId, + code: "INVALID_PACKET", + retryable: false, + }); + this.closeConnection(connection, 1008, "hello required"); + return; + } + switch (packet.type) { + case "hello": + this.handleHello(connection, packet); + return; + case "toolResolversChanged": + connection.toolResolvers = packet.toolResolvers; + return; + case "ping": + this.send(connection, { type: "pong", nonce: packet.nonce }); + return; + case "subscribe": + await this.handleSubscribe(connection, packet); + return; + case "unsubscribe": + this.handleUnsubscribe(connection, packet); + return; + } + } + + private handleHello(connection: Connection, packet: HelloPacket): void { + if (connection.helloReceived) { + // The original handshake still stands; refuse the re-negotiation + // without dropping live subscriptions. + this.sendError(connection, { + requestId: packet.requestId, + code: "INVALID_PACKET", + retryable: false, + }); + return; + } + connection.helloReceived = true; + connection.toolResolvers = packet.toolResolvers; + this.send(connection, { + type: "helloAck", + protocolVersion: PROTOCOL_VERSION, + requestId: packet.requestId, + hostId: this.hostId, + connectionId: connection.id, + serverTime: this.now(), + limits: { + maxSubscriptions: this.maxSubscriptions, + maxFrameBytes: this.maxFrameBytes, + }, + }); + } + + // ------------------------------------------------------------------------- + // Subscribe / unsubscribe + // ------------------------------------------------------------------------- + + private async handleSubscribe( + connection: Connection, + packet: SubscribePacket, + ): Promise { + const sessionId = + packet.stream.type === "session" ? packet.stream.sessionId : null; + if (connection.subscriptions.has(packet.subscriptionId)) { + this.sendError(connection, { + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + sessionId, + code: "INVALID_PACKET", + retryable: false, + }); + return; + } + if (connection.subscriptions.size >= this.maxSubscriptions) { + this.sendError(connection, { + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + sessionId, + code: "SUBSCRIPTION_LIMIT", + retryable: true, + }); + return; + } + if (packet.stream.type === "host") { + this.subscribeHost(connection, packet); + return; + } + try { + await this.runtime.warmSession(packet.stream.sessionId); + } catch (error) { + const notFound = + error instanceof AcpSessionNotFoundError || + (error instanceof CanonicalSessionsError && error.code === "NOT_FOUND"); + this.sendError(connection, { + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + sessionId, + code: notFound ? "SESSION_NOT_FOUND" : "INTERNAL_ERROR", + retryable: !notFound, + }); + return; + } + if (connection.closed) return; + this.subscribeSession(connection, packet, packet.stream.sessionId); + } + + /** + * Runs synchronously after the warm-up: capture head, send + * subscribed → replay → caughtUp, register at the head. No awaits, so a + * fold cannot land between the capture and the registration — that is the + * whole no-gap argument. `after` always exists (the client took it from a + * tRPC snapshot); an unservable one answers `reset` and the client re-runs + * the tRPC cold path. + */ + private subscribeSession( + connection: Connection, + packet: SubscribePacket, + sessionId: string, + ): void { + const replay = this.runtime.sessionReplay(sessionId, packet.after); + if (!replay.ok) { + if (replay.reason === "foreign_cursor") { + // Like a host cursor from a dead hub incarnation: a cursor from a + // dead log generation (a previous tracking, usually a previous + // host process) is irrecoverable per-cursor; the client rebuilds + // from the tRPC snapshot. + this.send(connection, { + type: "reset", + subscriptionId: packet.subscriptionId, + stream: "session", + sessionId, + code: "CURSOR_INVALID", + recovery: "refetchSnapshot", + }); + return; + } + // Untracked immediately after a successful warm: a dispose or + // port-side eviction raced the subscribe. + this.sendError(connection, { + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + sessionId, + code: "INTERNAL_ERROR", + retryable: true, + }); + return; + } + const sent = this.send(connection, { + type: "subscribed", + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + stream: "session", + sessionId, + replay: { fromExclusive: packet.after, through: replay.head }, + }); + if (!sent) return; + for (const event of replay.events) { + const ok = this.send(connection, { + type: "event", + hostId: this.hostId, + subscriptionId: packet.subscriptionId, + stream: "session", + sessionId, + threadId: event.threadId, + cursor: event.cursor, + event, + }); + if (!ok) return; + } + const caughtUp = this.send(connection, { + type: "caughtUp", + subscriptionId: packet.subscriptionId, + stream: "session", + sessionId, + through: replay.head, + }); + if (!caughtUp) return; + connection.subscriptions.set(packet.subscriptionId, { + subscriptionId: packet.subscriptionId, + stream: "session", + sessionId, + lastCursor: replay.head, + }); + } + + private hostCursorFor(serial: number): string { + return `h${this.hostIncarnation}-${String(serial).padStart(12, "0")}`; + } + + /** + * The current host-stream head. `sessions.list` stamps it on the tRPC + * host snapshot so clients subscribe from exactly where the snapshot was + * taken; replay overlap is harmless because host events are idempotent. + */ + hostHead(): string { + return this.hostCursorFor(this.hostSerial); + } + + /** Null for anything that is not a cursor this hub incarnation minted. */ + private parseHostCursor(cursor: string): number | null { + const prefix = `h${this.hostIncarnation}-`; + if (!cursor.startsWith(prefix)) return null; + const digits = cursor.slice(prefix.length); + if (!/^\d{12}$/.test(digits)) return null; + return Number(digits); + } + + private subscribeHost(connection: Connection, packet: SubscribePacket): void { + const head = this.hostCursorFor(this.hostSerial); + const afterSerial = this.parseHostCursor(packet.after); + if (afterSerial === null || afterSerial > this.hostSerial) { + this.send(connection, { + type: "reset", + subscriptionId: packet.subscriptionId, + stream: "host", + sessionId: null, + code: "CURSOR_INVALID", + recovery: "refetchSnapshot", + }); + return; + } + const oldestSerial = this.hostRing[0]?.serial ?? this.hostSerial + 1; + if (afterSerial < this.hostSerial && afterSerial < oldestSerial - 1) { + this.send(connection, { + type: "reset", + subscriptionId: packet.subscriptionId, + stream: "host", + sessionId: null, + code: "CURSOR_EXPIRED", + recovery: "refetchSnapshot", + }); + return; + } + const sent = this.send(connection, { + type: "subscribed", + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + stream: "host", + sessionId: null, + replay: { fromExclusive: packet.after, through: head }, + }); + if (!sent) return; + for (const entry of this.hostRing) { + if (entry.serial <= afterSerial) continue; + if (!this.sendHostEvent(connection, packet.subscriptionId, entry)) { + return; + } + } + const caughtUp = this.send(connection, { + type: "caughtUp", + subscriptionId: packet.subscriptionId, + stream: "host", + sessionId: null, + through: head, + }); + if (!caughtUp) return; + connection.subscriptions.set(packet.subscriptionId, { + subscriptionId: packet.subscriptionId, + stream: "host", + sessionId: null, + lastCursor: head, + }); + } + + private handleUnsubscribe( + connection: Connection, + packet: UnsubscribePacket, + ): void { + const subscription = connection.subscriptions.get(packet.subscriptionId); + if (!subscription) { + // `unsubscribed` requires a stream we don't know; an unknown id is a + // client-side bookkeeping bug, so answer with an explicit error. + this.sendError(connection, { + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + code: "INVALID_PACKET", + retryable: false, + }); + return; + } + connection.subscriptions.delete(packet.subscriptionId); + this.send(connection, { + type: "unsubscribed", + requestId: packet.requestId, + subscriptionId: packet.subscriptionId, + stream: subscription.stream, + sessionId: subscription.sessionId, + through: subscription.lastCursor, + }); + } + + // ------------------------------------------------------------------------- + // Live fan-out + // ------------------------------------------------------------------------- + + private fanOutSessionEvent(event: SessionEvent): void { + for (const connection of [...this.connections]) { + for (const subscription of [...connection.subscriptions.values()]) { + if (connection.closed) break; + if ( + subscription.stream !== "session" || + subscription.sessionId !== event.sessionId + ) { + continue; + } + // Padded cursors make the string compare a serial compare; at or + // below lastCursor means the replay window already carried it. + if (event.cursor <= subscription.lastCursor) continue; + const sent = this.send(connection, { + type: "event", + hostId: this.hostId, + subscriptionId: subscription.subscriptionId, + stream: "session", + sessionId: event.sessionId, + threadId: event.threadId, + cursor: event.cursor, + event, + }); + if (sent) subscription.lastCursor = event.cursor; + } + } + } + + private fanOutHostChange(change: HostChange): void { + this.hostSerial += 1; + const entry: HostRingEntry = { + serial: this.hostSerial, + cursor: this.hostCursorFor(this.hostSerial), + sessionId: change.sessionId, + threadId: change.type === "permissionAvailable" ? change.threadId : null, + event: hostEventFor(change), + }; + this.hostRing.push(entry); + if (this.hostRing.length > this.hostRingLimit) this.hostRing.shift(); + for (const connection of [...this.connections]) { + for (const subscription of [...connection.subscriptions.values()]) { + if (connection.closed) break; + if (subscription.stream !== "host") continue; + if (entry.cursor <= subscription.lastCursor) continue; + if ( + this.sendHostEvent(connection, subscription.subscriptionId, entry) + ) { + subscription.lastCursor = entry.cursor; + } + } + } + } + + private sendHostEvent( + connection: Connection, + subscriptionId: string, + entry: HostRingEntry, + ): boolean { + return this.send(connection, { + type: "event", + hostId: this.hostId, + subscriptionId, + stream: "host", + sessionId: entry.sessionId, + threadId: entry.threadId, + cursor: entry.cursor, + event: entry.event, + }); + } + + // ------------------------------------------------------------------------- + // Socket plumbing + // ------------------------------------------------------------------------- + + private send(connection: Connection, packet: SyncServerPacket): boolean { + if (connection.closed) return false; + const socket = connection.socket; + if (socket.readyState !== SOCKET_OPEN) { + this.dropConnection(connection); + return false; + } + if ((socket.raw?.bufferedAmount ?? 0) > WS_SEND_BUFFER_CAP_BYTES) { + this.dropConnection(connection); + try { + socket.close(1013, "sync back-pressure"); + } catch { + // best-effort; close may race an already-closing socket + } + return false; + } + try { + socket.send(JSON.stringify(packet)); + } catch { + // A synchronous send throw (torn socket, ws internals) must not leave + // the connection registered as if delivery succeeded — callers treat + // `false` as "stop this frame sequence", and the client re-syncs by + // cursor on reconnect. + this.dropConnection(connection); + try { + socket.close(1011, "sync send failed"); + } catch { + // best-effort; close may race an already-closing socket + } + return false; + } + return true; + } + + private sendError( + connection: Connection, + fields: { + requestId: string | null; + subscriptionId?: string | null; + sessionId?: string | null; + code: SyncErrorCode; + retryable: boolean; + }, + ): void { + this.send(connection, { + type: "error", + requestId: fields.requestId, + subscriptionId: fields.subscriptionId ?? null, + sessionId: fields.sessionId ?? null, + code: fields.code, + retryable: fields.retryable, + }); + } + + private closeConnection( + connection: Connection, + code: number, + reason: string, + ): void { + this.dropConnection(connection); + try { + connection.socket.close(code, reason); + } catch { + // best-effort; close may race an already-closing socket + } + } + + private dropConnection(connection: Connection): void { + connection.closed = true; + connection.subscriptions.clear(); + this.connections.delete(connection); + } +} diff --git a/packages/host-service/src/runtime/sessions/sync-route.ts b/packages/host-service/src/runtime/sessions/sync-route.ts new file mode 100644 index 00000000000..a086d2db035 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/sync-route.ts @@ -0,0 +1,56 @@ +import type { NodeWebSocket } from "@hono/node-ws"; +import { SESSIONS_SYNC_PATH } from "@superset/host-service-sync/protocol"; +import type { Hono } from "hono"; +import type { + SessionsSyncConnection, + SessionsSyncHub, + SyncSocket, +} from "./sync-hub"; + +export interface RegisterSessionsSyncRouteOptions { + app: Hono; + hub: SessionsSyncHub; + upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; + /** Test/embedding hook; production uses the protocol's canonical path. */ + path?: string; +} + +/** + * `/sessions/sync` — the Host Sessions synchronization WebSocket + * (plans/host-sessions-sync.md). All framing, handshake, and + * subscription logic lives in {@link SessionsSyncHub}; this route only + * adapts hono/node-ws sockets onto it. Auth is the same `wsAuth` guard + * app.ts applies to the other WS surfaces. + */ +export function registerSessionsSyncRoute({ + app, + hub, + upgradeWebSocket, + path = SESSIONS_SYNC_PATH, +}: RegisterSessionsSyncRouteOptions) { + app.get( + path, + upgradeWebSocket(() => { + let connection: SessionsSyncConnection | null = null; + return { + onOpen: (_event, ws) => { + connection = hub.connect(ws as SyncSocket); + }, + onMessage: (event) => { + // The protocol is JSON text frames; String() folds the rare + // Buffer delivery into the same parse path, where non-JSON is + // answered with INVALID_PACKET. + void connection?.handleMessage(String(event.data)); + }, + onClose: () => { + connection?.dispose(); + connection = null; + }, + onError: () => { + connection?.dispose(); + connection = null; + }, + }; + }), + ); +} diff --git a/packages/host-service/src/runtime/sessions/testing/fake-acp-port.ts b/packages/host-service/src/runtime/sessions/testing/fake-acp-port.ts new file mode 100644 index 00000000000..43113bc28a8 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/testing/fake-acp-port.ts @@ -0,0 +1,383 @@ +import type { + ContentBlock as AcpContentBlock, + StopReason as AcpStopReason, + RequestPermissionOutcome, + SessionConfigOption, + SessionScopedState, + SessionUpdate, + SessionUpdateEnvelope, + SessionUpdateFrame, +} from "@superset/session-protocol"; +import { AcpSessionNotFoundError } from "../../acp-sessions"; +import { + type AcpSessionsPort, + CanonicalSessionsRuntime, +} from "../canonical-sessions"; + +export const T0 = 1_784_000_000_000; +export const WORKSPACE = "workspace-test"; + +export const MODEL_OPTIONS = [ + { + id: "model", + name: "Model", + category: "model" as const, + type: "select" as const, + currentValue: "claude-sonnet-5", + options: [ + { value: "claude-sonnet-5", name: "Sonnet" }, + { value: "claude-fable-5", name: "Fable" }, + ], + }, +]; + +/** + * Deterministic in-memory stand-in for AcpSessionManager, reproducing the + * journal semantics the runtime depends on: synchronous fan-out, seq from 1, + * a state-frame seed on create, prompt journaling user chunks before it + * returns, permission park/settle, resurrection restarting seqs, and + * ring-buffer eviction answering stale subscribes with a reset frame. + */ +export class FakeAcpPort implements AcpSessionsPort { + private tick = 0; + readonly sessions = new Map< + string, + { + state: SessionScopedState; + journal: SessionUpdateEnvelope[]; + seq: number; + subscribers: Set<(envelope: SessionUpdateEnvelope) => void>; + pendingPermissionIds: Set; + /** Frames session/load would replay after a resurrection. */ + resurrectFrames: SessionUpdateFrame[]; + } + >(); + createCalls = 0; + promptCalls = 0; + cancelCalls = 0; + respondCalls: Array<{ + requestId: string; + outcome: RequestPermissionOutcome; + }> = []; + + private nextTs(): number { + this.tick += 1; + return T0 + this.tick; + } + + seed(sessionId: string, overrides: Partial = {}) { + const now = this.nextTs(); + const session = { + state: { + sessionId, + workspaceId: WORKSPACE, + harness: "claude-agent-acp" as const, + status: "idle" as const, + title: null, + currentMode: null, + configOptions: [], + pendingPermissions: [], + cwd: "/tmp/workspace", + lastSeq: 0, + lastStopReason: null, + lastError: null, + createdAt: now, + updatedAt: now, + ...overrides, + }, + journal: [], + seq: 0, + subscribers: new Set<(envelope: SessionUpdateEnvelope) => void>(), + pendingPermissionIds: new Set(), + resurrectFrames: [] as SessionUpdateFrame[], + }; + this.sessions.set(sessionId, session); + return session; + } + + private require(sessionId: string) { + const session = this.sessions.get(sessionId); + if (!session) { + throw new AcpSessionNotFoundError(`Unknown ACP session: ${sessionId}`); + } + return session; + } + + private requireLive(sessionId: string) { + const session = this.require(sessionId); + if (session.state.status === "offline" || session.state.status === "dead") { + throw new Error(`ACP session not live: ${sessionId}`); + } + return session; + } + + journalFrame(sessionId: string, frame: SessionUpdateFrame) { + const session = this.require(sessionId); + session.seq += 1; + const envelope: SessionUpdateEnvelope = { + seq: session.seq, + sessionId, + ts: this.nextTs(), + frame, + }; + session.journal.push(envelope); + for (const subscriber of [...session.subscribers]) { + subscriber(envelope); + } + return envelope; + } + + emitUpdate(sessionId: string, update: SessionUpdate) { + this.journalFrame(sessionId, { kind: "update", update }); + } + + emitState(sessionId: string, overrides: Partial = {}) { + const session = this.require(sessionId); + session.state = { + ...session.state, + ...overrides, + updatedAt: this.nextTs(), + }; + this.journalFrame(sessionId, { + kind: "state", + state: { ...session.state }, + }); + } + + requestPermission( + sessionId: string, + nativeRequestId: string, + toolCallId: string, + ) { + const session = this.require(sessionId); + session.pendingPermissionIds.add(nativeRequestId); + this.journalFrame(sessionId, { + kind: "permission_requested", + pending: { + requestId: nativeRequestId, + toolCall: { toolCallId }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + requestedAt: this.nextTs(), + }, + }); + } + + completeTurn(sessionId: string, stopReason: AcpStopReason) { + this.emitState(sessionId, { status: "idle", lastStopReason: stopReason }); + } + + /** + * Ring-buffer eviction: drop all but the newest `keepLast` envelopes. A + * later subscribe from an evicted seq gets one reset frame, like the real + * SessionJournal. + */ + evictJournal(sessionId: string, keepLast = 0) { + const session = this.require(sessionId); + session.journal = keepLast > 0 ? session.journal.slice(-keepLast) : []; + } + + // ---- AcpSessionsPort ------------------------------------------------ + + async create(input: { sessionId: string; workspaceId: string }) { + this.createCalls += 1; + const existing = this.sessions.get(input.sessionId); + if (existing) return { ...existing.state }; + const session = this.seed(input.sessionId, { + workspaceId: input.workspaceId, + }); + this.journalFrame(input.sessionId, { + kind: "state", + state: { ...session.state }, + }); + return { ...session.state }; + } + + get(sessionId: string): SessionScopedState { + return { ...this.require(sessionId).state }; + } + + async ensureLive(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || session.state.status !== "offline") return; + // A resurrection loads the transcript into a brand-new journal. + session.journal = []; + session.seq = 0; + session.subscribers.clear(); + session.state = { ...session.state, status: "idle" }; + this.journalFrame(sessionId, { + kind: "state", + state: { ...session.state }, + }); + for (const frame of session.resurrectFrames) { + this.journalFrame(sessionId, frame); + } + } + + list(input: { workspaceId?: string; cursor?: string; limit?: number }) { + const limit = input.limit ?? 50; + const states = [...this.sessions.values()] + .map((session) => ({ ...session.state })) + .filter( + (state) => + !input.workspaceId || state.workspaceId === input.workspaceId, + ) + .sort( + (a, b) => + b.createdAt - a.createdAt || a.sessionId.localeCompare(b.sessionId), + ); + let start = 0; + if (input.cursor) { + const separator = input.cursor.indexOf(":"); + const createdAt = Number(input.cursor.slice(0, separator)); + const sessionId = input.cursor.slice(separator + 1); + start = states.findIndex( + (state) => + state.createdAt < createdAt || + (state.createdAt === createdAt && + state.sessionId.localeCompare(sessionId) > 0), + ); + if (start === -1) start = states.length; + } + const page = states.slice(start, start + limit); + const last = page[page.length - 1]; + return { + items: page, + nextCursor: + last && start + limit < states.length + ? `${last.createdAt}:${last.sessionId}` + : null, + }; + } + + prompt(input: { sessionId: string; prompt: AcpContentBlock[] }) { + this.promptCalls += 1; + const session = this.requireLive(input.sessionId); + for (const block of input.prompt) { + this.journalFrame(input.sessionId, { + kind: "update", + update: { sessionUpdate: "user_message_chunk", content: block }, + }); + } + session.state.lastError = null; + this.emitState(input.sessionId, { status: "running" }); + return { + accepted: true as const, + turn: Promise.resolve({ stopReason: "end_turn" as const }), + }; + } + + respondToPermission(input: { + sessionId: string; + requestId: string; + outcome: RequestPermissionOutcome; + }) { + const session = this.requireLive(input.sessionId); + this.respondCalls.push({ + requestId: input.requestId, + outcome: input.outcome, + }); + if (!session.pendingPermissionIds.delete(input.requestId)) { + return { status: "already_resolved" as const }; + } + this.journalFrame(input.sessionId, { + kind: "permission_resolved", + requestId: input.requestId, + outcome: input.outcome, + }); + return { status: "resolved" as const }; + } + + async cancel(input: { sessionId: string }): Promise { + this.cancelCalls += 1; + const session = this.requireLive(input.sessionId); + for (const requestId of [...session.pendingPermissionIds]) { + this.respondToPermission({ + sessionId: input.sessionId, + requestId, + outcome: { outcome: "cancelled" }, + }); + } + this.emitState(input.sessionId, { + status: "idle", + lastStopReason: "cancelled", + }); + } + + async setMode(input: { sessionId: string; modeId: string }): Promise { + const session = this.requireLive(input.sessionId); + if (session.state.currentMode) { + session.state.currentMode = { + ...session.state.currentMode, + currentModeId: input.modeId, + }; + } + this.emitState(input.sessionId, {}); + } + + async setConfigOption(input: { + sessionId: string; + configId: string; + value: string | boolean; + }): Promise { + const session = this.requireLive(input.sessionId); + const options = session.state.configOptions.map((option) => + option.id === input.configId + ? ({ ...option, currentValue: input.value } as SessionConfigOption) + : option, + ); + if (!options.some((option) => option.id === input.configId)) { + throw new Error(`Unknown config option: ${input.configId}`); + } + session.state = { ...session.state, configOptions: options }; + this.emitState(input.sessionId, {}); + } + + subscribe(input: { + sessionId: string; + since?: number; + onEnvelope: (envelope: SessionUpdateEnvelope) => void; + }): () => void { + const session = this.require(input.sessionId); + const since = input.since ?? session.seq; + // Mirror SessionJournal.after: a since older than the retained tail OR + // ahead of the journal head (a cursor from before a resurrection reset + // seqs) is unservable — one reset frame, never attaches. + const oldest = session.journal[0]?.seq ?? session.seq + 1; + if (since > session.seq || (since < session.seq && since < oldest - 1)) { + input.onEnvelope({ + seq: 0, + sessionId: input.sessionId, + ts: this.nextTs(), + frame: { kind: "reset", reason: "journal evicted" }, + }); + return () => {}; + } + for (const envelope of session.journal) { + if (envelope.seq > since) input.onEnvelope(envelope); + } + session.subscribers.add(input.onEnvelope); + return () => { + session.subscribers.delete(input.onEnvelope); + }; + } +} + +/** Runtime with an injected deterministic clock and session-id mint. */ +export function makeRuntime(port: FakeAcpPort) { + let mintSerial = 0; + let clock = T0 + 500_000; + return new CanonicalSessionsRuntime({ + port, + now: () => { + clock += 1; + return clock; + }, + mintSessionId: () => { + mintSerial += 1; + return `session-minted-${mintSerial}`; + }, + }); +} diff --git a/packages/host-service/src/runtime/sessions/testing/sqlite-store.ts b/packages/host-service/src/runtime/sessions/testing/sqlite-store.ts new file mode 100644 index 00000000000..e9b3416d0b0 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/testing/sqlite-store.ts @@ -0,0 +1,24 @@ +import { Database as BunDatabase } from "bun:sqlite"; +import { resolve } from "node:path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../../../db"; +import * as schema from "../../../db/schema"; +import { SqliteSessionMetaStore } from "../session-meta-store"; + +const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../drizzle"); + +/** In-memory host DB + canonical session metadata store for unit tests. */ +export function makeMetaStoreDb(): { + metaStore: SqliteSessionMetaStore; + db: HostDb; +} { + const sqlite = new BunDatabase(":memory:"); + sqlite.exec("PRAGMA foreign_keys = ON"); + const bunDb = drizzle(sqlite, { schema }); + migrate(bunDb, { migrationsFolder: MIGRATIONS_FOLDER }); + // Same shim as createTestHost: bun:sqlite and better-sqlite3 back the + // same drizzle API; prod uses better-sqlite3 on bundled Node. + const db = bunDb as unknown as HostDb; + return { metaStore: new SqliteSessionMetaStore(db), db }; +} diff --git a/packages/host-service/src/runtime/sessions/testing/sync-harness.ts b/packages/host-service/src/runtime/sessions/testing/sync-harness.ts new file mode 100644 index 00000000000..17e79d0f34c --- /dev/null +++ b/packages/host-service/src/runtime/sessions/testing/sync-harness.ts @@ -0,0 +1,156 @@ +import { + type HostEventPacket, + type SessionEventPacket, + type SyncServerPacket, + syncServerPacketSchema, +} from "@superset/host-service-sync/protocol"; +import type { SessionUpdate } from "@superset/session-protocol"; +import { SessionsSyncHub, type SessionsSyncHubOptions } from "../sync-hub"; +import { FakeAcpPort, makeRuntime, T0 } from "./fake-acp-port"; + +/** + * A hono/node-ws stand-in that schema-validates every outbound frame, so + * every test built on it doubles as a wire-conformance test for the server + * side of the protocol. + */ +export class FakeSyncSocket { + readyState = 1; + bufferedAmount = 0; + closed: { code?: number; reason?: string } | null = null; + readonly packets: SyncServerPacket[] = []; + + send = (data: string) => { + this.packets.push(syncServerPacketSchema.parse(JSON.parse(data))); + }; + close = (code?: number, reason?: string) => { + this.closed = { code, reason }; + this.readyState = 3; + }; + get raw() { + return { bufferedAmount: this.bufferedAmount }; + } + + take(): SyncServerPacket[] { + return this.packets.splice(0, this.packets.length); + } +} + +/** Hub over a fresh runtime + fake port, with a deterministic clock. */ +export function makeHub(options: Partial = {}) { + const port = new FakeAcpPort(); + const runtime = makeRuntime(port); + let clock = T0 + 900_000; + const hub = new SessionsSyncHub({ + runtime, + hostId: "host-test", + hostIncarnation: "test", + now: () => { + clock += 1; + return clock; + }, + ...options, + }); + return { port, runtime, hub }; +} + +export function helloFrame(overrides: Record = {}) { + return JSON.stringify({ + type: "hello", + protocolVersion: 1, + requestId: "req-hello", + clientInstanceId: "client-test", + clientVersion: "0.0.0-test", + toolResolvers: [], + ...overrides, + }); +} + +export async function openClient(hub: SessionsSyncHub) { + const socket = new FakeSyncSocket(); + const client = hub.connect(socket); + await client.handleMessage(helloFrame()); + const ack = expectPacket(socket.take()[0], "helloAck"); + return { socket, client, ack }; +} + +/** + * Zero cursors: what a client that fetched an empty tRPC snapshot holds. + * Session logs start at c…0; makeHub pins hostIncarnation "test", so the + * host stream starts at htest-…0. Subscribing from them replays everything. + */ +export const ZERO_SESSION_CURSOR = "c000000000000"; +export const ZERO_HOST_CURSOR = "htest-000000000000"; + +export function subscribeFrame(input: { + subscriptionId: string; + stream: { type: "host" } | { type: "session"; sessionId: string }; + after?: string; + requestId?: string; +}) { + return JSON.stringify({ + type: "subscribe", + requestId: input.requestId ?? `req-${input.subscriptionId}`, + subscriptionId: input.subscriptionId, + stream: input.stream, + after: + input.after ?? + (input.stream.type === "host" ? ZERO_HOST_CURSOR : ZERO_SESSION_CURSOR), + }); +} + +export function unsubscribeFrame( + subscriptionId: string, + requestId = "req-unsub", +) { + return JSON.stringify({ type: "unsubscribe", requestId, subscriptionId }); +} + +export function expectPacket( + packet: SyncServerPacket | undefined, + type: T, +): Extract { + if (!packet || packet.type !== type) { + throw new Error( + `expected a ${type} packet, got ${packet ? packet.type : "nothing"}`, + ); + } + return packet as Extract; +} + +export function eventPackets(packets: SyncServerPacket[]) { + return packets.filter( + (packet): packet is HostEventPacket | SessionEventPacket => + packet.type === "event", + ); +} + +export function sessionEvents( + packets: SyncServerPacket[], +): SessionEventPacket[] { + return eventPackets(packets).filter( + (packet): packet is SessionEventPacket => packet.stream === "session", + ); +} + +export function hostEvents(packets: SyncServerPacket[]): HostEventPacket[] { + return eventPackets(packets).filter( + (packet): packet is HostEventPacket => packet.stream === "host", + ); +} + +export function chunk(text: string): SessionUpdate { + return { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }; +} + +/** Seed a session and journal a state frame — the shape any real session has. */ +export function seedLive(port: FakeAcpPort, sessionId: string) { + port.seed(sessionId); + port.emitState(sessionId, {}); +} + +/** Let markDirty's queueMicrotask coalescing flush run. */ +export const flush = () => + new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/host-service/src/runtime/sessions/translate-acp.test.ts b/packages/host-service/src/runtime/sessions/translate-acp.test.ts new file mode 100644 index 00000000000..bfe1a82436e --- /dev/null +++ b/packages/host-service/src/runtime/sessions/translate-acp.test.ts @@ -0,0 +1,1054 @@ +import { describe, expect, test } from "bun:test"; +import { + reduceProjection, + type SessionProjection, + sessionEventSchema, +} from "@superset/host-service-sync"; +import type { + SessionScopedState, + SessionUpdate, + SessionUpdateEnvelope, + SessionUpdateFrame, +} from "@superset/session-protocol"; +import { makeSelectedOutcome } from "@superset/session-protocol"; +import { + AcpSessionEventTranslator, + acpMainThreadId, + type SessionEventDraft, + settingOptionsFromScopedState, +} from "./translate-acp"; + +const SESSION_ID = "session-test"; +const MAIN_THREAD_ID = acpMainThreadId(SESSION_ID); +const T0 = 1_784_000_000_000; + +function makeTranslator() { + return new AcpSessionEventTranslator({ + sessionId: SESSION_ID, + idScope: "scope1", + }); +} + +/** Envelope factory with auto-incrementing seq/ts, mirroring the journal. */ +function makeJournal() { + let seq = 0; + return (frame: SessionUpdateFrame): SessionUpdateEnvelope => { + seq += 1; + return { seq, sessionId: SESSION_ID, ts: T0 + seq, frame }; + }; +} + +function update(u: SessionUpdate): SessionUpdateFrame { + return { kind: "update", update: u }; +} + +function stateFrame( + overrides: Partial, +): SessionUpdateFrame { + return { + kind: "state", + state: { + sessionId: SESSION_ID, + workspaceId: "workspace-test", + harness: "claude-agent-acp", + status: "idle", + title: null, + currentMode: null, + configOptions: [], + pendingPermissions: [], + cwd: "/tmp/workspace", + lastSeq: 0, + lastStopReason: null, + lastError: null, + createdAt: T0, + updatedAt: T0, + ...overrides, + }, + }; +} + +function baselineProjection(): SessionProjection { + return { + sessionId: SESSION_ID, + cursor: "cursor-0", + session: { + id: SESSION_ID, + workspaceId: "workspace-test", + title: null, + mainThreadId: MAIN_THREAD_ID, + agent: { id: "claude-code", displayName: "Claude Code" }, + runState: "idle", + capabilities: { + threadModel: "nested", + threadFidelity: "partial", + canResume: true, + supportsPermissions: true, + supportsModes: true, + supportsModels: true, + }, + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + settingOptions: [], + eventHead: null, + createdAt: T0, + updatedAt: T0, + lastActivityAt: T0, + archivedAt: null, + closedAt: null, + error: null, + }, + threadsById: { + [MAIN_THREAD_ID]: { + id: MAIN_THREAD_ID, + sessionId: SESSION_ID, + kind: "main", + parentThreadId: null, + origin: { type: "sessionCreated" }, + fidelity: "full", + title: null, + runState: "idle", + eventHead: null, + createdAt: T0, + updatedAt: T0, + lastActivityAt: T0, + }, + }, + activeTurnsById: {}, + pendingPermissionsById: {}, + activeToolCallsById: {}, + plan: [], + }; +} + +/** + * The contract every translated batch must honor: each draft is a + * schema-valid SessionEvent once a cursor is assigned, and the whole stream + * folds through the shared projection reducer without throwing. + */ +function validateAndFold(drafts: SessionEventDraft[]): SessionProjection { + let projection = baselineProjection(); + drafts.forEach((draft, index) => { + const event = sessionEventSchema.parse({ + ...draft, + cursor: `cursor-${index + 1}`, + }); + projection = reduceProjection(projection, { + type: "event", + cursor: event.cursor, + value: event, + }); + }); + return projection; +} + +function translateAll( + translator: AcpSessionEventTranslator, + envelopes: SessionUpdateEnvelope[], +): SessionEventDraft[] { + return envelopes.flatMap((envelope) => translator.translate(envelope)); +} + +function payloadTypes(drafts: SessionEventDraft[]): string[] { + return drafts.map((draft) => draft.payload.type); +} + +function only( + drafts: SessionEventDraft[], + type: T, +): Extract[] { + return drafts + .filter((draft) => draft.payload.type === type) + .map( + (draft) => + draft.payload as Extract, + ); +} + +function firstOf( + drafts: SessionEventDraft[], + type: T, +): Extract { + const found = only(drafts, type)[0]; + if (!found) throw new Error(`expected a ${type} event in the batch`); + return found; +} + +describe("AcpSessionEventTranslator", () => { + test("full turn lifecycle: prompt, thought, tool with permission, completion", () => { + const translator = makeTranslator(); + const env = makeJournal(); + translator.attributeNextTurn({ + requestId: "request-send-1", + clientInstanceId: "client-instance-1", + }); + + const promptPhase = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Write the config file" }, + }), + ), + env(stateFrame({ status: "running" })), + env( + update({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Planning the write..." }, + messageId: "acp-msg-1", + }), + ), + env( + update({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Writing config.json now." }, + messageId: "acp-msg-1", + }), + ), + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_write_1", + title: "Write config.json", + kind: "edit", + status: "pending", + rawInput: { path: "config.json" }, + _meta: { claudeCode: { toolName: "Write" } }, + }), + ), + ]); + + // Main thread created first, then the attributed turn, then messages. + expect(payloadTypes(promptPhase)).toEqual([ + "threadCreated", + "turnStarted", + "messageStarted", + "messageDelta", + "settingsUpdated", + "messageCompleted", // user message closes when the assistant speaks + "messageStarted", + "messageDelta", + "messageDelta", + "toolCallStarted", + ]); + const turn = firstOf(promptPhase, "turnStarted"); + expect(turn.turn.originatingClientInstanceId).toBe("client-instance-1"); + const turnStartDraft = promptPhase.find( + (draft) => draft.payload.type === "turnStarted", + ); + expect(turnStartDraft?.causationId).toBe("request-send-1"); + const userMessageStart = promptPhase.find( + (draft) => draft.payload.type === "messageStarted", + ); + expect(userMessageStart?.causationId).toBe("request-send-1"); + + // Thought and prose interleave in ONE assistant message (same messageId). + const assistantDeltas = only(promptPhase, "messageDelta").slice(1); + expect(assistantDeltas.map((delta) => delta.content.type)).toEqual([ + "thought", + "text", + ]); + const messageStarts = only(promptPhase, "messageStarted"); + expect(new Set(messageStarts.map((start) => start.message.id)).size).toBe( + 2, + ); + + const toolStart = firstOf(promptPhase, "toolCallStarted"); + expect(toolStart.toolCall.id).toBe("toolu_write_1"); + expect(toolStart.toolCall.tool.name).toBe("Write"); + expect(toolStart.toolCall.state).toBe("requested"); + expect(toolStart.toolCall.input).toEqual({ path: "config.json" }); + + const permissionPhase = translateAll(translator, [ + env({ + kind: "permission_requested", + pending: { + requestId: "42", + toolCall: { toolCallId: "toolu_write_1", title: "Write config.json" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + requestedAt: T0 + 6, + }, + }), + env(stateFrame({ status: "awaiting_permission" })), + ]); + expect(payloadTypes(permissionPhase)).toEqual([ + "toolCallUpdated", + "permissionRequested", + ]); + const awaiting = firstOf(permissionPhase, "toolCallUpdated"); + expect(awaiting.update.state).toBe("awaitingPermission"); + const permission = firstOf(permissionPhase, "permissionRequested"); + expect(permission.permission.toolCallId).toBe("toolu_write_1"); + expect(permission.permission.multiSelect).toBe(false); + expect(permission.permission.options.map((option) => option.kind)).toEqual([ + "allowOnce", + "rejectOnce", + ]); + expect(translator.publicPermissionId("42")).toBe(permission.permission.id); + + translator.attributePermissionResolution("42", "request-resolve-1"); + const finishPhase = translateAll(translator, [ + env({ + kind: "permission_resolved", + requestId: "42", + outcome: { outcome: "selected", optionId: "allow" }, + }), + env(stateFrame({ status: "running" })), + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_write_1", + status: "completed", + rawOutput: { ok: true }, + }), + ), + env(stateFrame({ status: "idle", lastStopReason: "end_turn" })), + ]); + expect(payloadTypes(finishPhase)).toEqual([ + "permissionResolved", + "toolCallUpdated", + "messageCompleted", + "turnCompleted", + ]); + const resolved = firstOf(finishPhase, "permissionResolved"); + expect(resolved.permissionId).toBe(permission.permission.id); + expect(resolved.outcome).toEqual({ + type: "selected", + optionIds: ["allow"], + }); + const resolvedDraft = finishPhase.find( + (draft) => draft.payload.type === "permissionResolved", + ); + expect(resolvedDraft?.causationId).toBe("request-resolve-1"); + expect(translator.publicPermissionId("42")).toBeNull(); + const completedTurn = firstOf(finishPhase, "turnCompleted"); + expect(completedTurn.turnId).toBe(turn.turn.id); + expect(completedTurn.stopReason).toBe("endTurn"); + + const projection = validateAndFold([ + ...promptPhase, + ...permissionPhase, + ...finishPhase, + ]); + expect(projection.session.runState).toBe("idle"); + expect(projection.session.error).toBeNull(); + expect(projection.activeTurnsById).toEqual({}); + expect(projection.pendingPermissionsById).toEqual({}); + expect(projection.activeToolCallsById).toEqual({}); + expect(projection.threadsById[MAIN_THREAD_ID]?.runState).toBe("completed"); + }); + + test("subagent Task activity lands in a partial-fidelity child thread", () => { + const translator = makeTranslator(); + const env = makeJournal(); + + const drafts = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Explore the repo" }, + }), + ), + env(stateFrame({ status: "running" })), + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_task_1", + title: "Explore packages", + kind: "other", + status: "in_progress", + rawInput: { description: "Explore packages" }, + _meta: { claudeCode: { toolName: "Task" } }, + }), + ), + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_child_grep", + title: "grep -r sessions", + kind: "search", + status: "in_progress", + _meta: { + claudeCode: { toolName: "Grep", parentToolUseId: "toolu_task_1" }, + }, + }), + ), + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_child_grep", + status: "completed", + _meta: { + claudeCode: { parentToolUseId: "toolu_task_1" }, + }, + }), + ), + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_task_1", + status: "completed", + rawOutput: { summary: "done" }, + }), + ), + env(stateFrame({ status: "idle", lastStopReason: "end_turn" })), + ]); + + const threadsCreated = only(drafts, "threadCreated"); + expect(threadsCreated).toHaveLength(2); + const subagentThread = threadsCreated[1]?.thread; + expect(subagentThread).toMatchObject({ + id: "thread-sub-toolu_task_1", + kind: "subagent", + parentThreadId: MAIN_THREAD_ID, + fidelity: "partial", + title: "Explore packages", + runState: "running", + }); + const taskStart = drafts.find( + (draft) => + draft.payload.type === "toolCallStarted" && + draft.payload.toolCall.id === "toolu_task_1", + ); + expect(subagentThread?.origin).toEqual({ + type: "subagent", + spawnedByEventId: taskStart?.id ?? "", + spawnedByToolCallId: "toolu_task_1", + }); + + // Child tool activity routes to the subagent thread, with parent linkage. + const childStart = drafts.find( + (draft) => + draft.payload.type === "toolCallStarted" && + draft.payload.toolCall.id === "toolu_child_grep", + ); + expect(childStart?.threadId).toBe("thread-sub-toolu_task_1"); + expect( + childStart?.payload.type === "toolCallStarted" + ? childStart.payload.toolCall.parentToolCallId + : null, + ).toBe("toolu_task_1"); + + // Task completion closes the child thread. + const threadUpdates = only(drafts, "threadUpdated"); + expect(threadUpdates).toHaveLength(1); + expect(threadUpdates[0]?.thread.runState).toBe("completed"); + + const projection = validateAndFold(drafts); + expect(projection.threadsById["thread-sub-toolu_task_1"]?.runState).toBe( + "completed", + ); + expect(projection.session.runState).toBe("idle"); + expect(projection.activeToolCallsById).toEqual({}); + }); + + test("synthetic elicitation card becomes a ui.question tool call", () => { + const translator = makeTranslator(); + const env = makeJournal(); + + const asked = translateAll(translator, [ + env(stateFrame({ status: "running" })), + env({ + kind: "permission_requested", + pending: { + requestId: "elicit-req-1", + toolCall: { + toolCallId: "elicitation-abc", + title: "Which storage backend?", + }, + options: [ + { optionId: "sqlite", name: "SQLite", kind: "allow_once" }, + { optionId: "postgres", name: "Postgres", kind: "allow_once" }, + { optionId: "reject", name: "Cancel", kind: "reject_once" }, + ], + requestedAt: T0 + 2, + multiSelect: true, + }, + }), + ]); + const toolStart = firstOf(asked, "toolCallStarted"); + expect(toolStart.toolCall.tool.name).toBe("ui.question"); + expect(toolStart.toolCall.title).toBe("Which storage backend?"); + expect(toolStart.toolCall.state).toBe("awaitingPermission"); + const permission = firstOf(asked, "permissionRequested"); + expect(permission.permission.multiSelect).toBe(true); + expect(permission.permission.toolCallId).toBe("elicitation-abc"); + + const answered = translateAll(translator, [ + env({ + kind: "permission_resolved", + requestId: "elicit-req-1", + outcome: makeSelectedOutcome(["sqlite", "postgres"]), + }), + // The manager journals a terminal update for the synthetic card. + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "elicitation-abc", + status: "completed", + }), + ), + env(stateFrame({ status: "idle", lastStopReason: "end_turn" })), + ]); + const resolved = firstOf(answered, "permissionResolved"); + expect(resolved.outcome).toEqual({ + type: "selected", + optionIds: ["sqlite", "postgres"], + }); + + const projection = validateAndFold([...asked, ...answered]); + expect(projection.pendingPermissionsById).toEqual({}); + expect(projection.activeToolCallsById).toEqual({}); + }); + + test("prompt_rejected fails the turn once; auth failures map to AUTH_REQUIRED", () => { + const translator = makeTranslator(); + const env = makeJournal(); + + const drafts = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "hello" }, + }), + ), + env(stateFrame({ status: "running" })), + env({ + kind: "prompt_rejected", + reason: "Authentication required: please run /login", + promptStartSeq: 1, + }), + // The manager's finally-block state frame lands after the rejection. + env(stateFrame({ status: "idle", lastError: "Authentication required" })), + ]); + + const failures = only(drafts, "turnFailed"); + expect(failures).toHaveLength(1); + expect(failures[0]?.error).toMatchObject({ + code: "AUTH_REQUIRED", + retryable: false, + recovery: "reauthenticate", + }); + // No second turn event from the trailing state frame. + expect(only(drafts, "turnCompleted")).toHaveLength(0); + expect(only(drafts, "turnCancelled")).toHaveLength(0); + + const projection = validateAndFold(drafts); + expect(projection.session.error?.code).toBe("AUTH_REQUIRED"); + expect(projection.session.runState).toBe("idle"); + expect(projection.threadsById[MAIN_THREAD_ID]?.runState).toBe("failed"); + }); + + test("cancel settles pending permissions then cancels the turn", () => { + const translator = makeTranslator(); + const env = makeJournal(); + + const drafts = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "run something risky" }, + }), + ), + env(stateFrame({ status: "running" })), + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_bash_1", + title: "rm -rf ./build", + kind: "execute", + status: "pending", + _meta: { claudeCode: { toolName: "Bash" } }, + }), + ), + env({ + kind: "permission_requested", + pending: { + requestId: "7", + toolCall: { toolCallId: "toolu_bash_1" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + requestedAt: T0 + 4, + }, + }), + env({ + kind: "permission_resolved", + requestId: "7", + outcome: { outcome: "cancelled" }, + }), + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash_1", + status: "failed", + }), + ), + env(stateFrame({ status: "idle", lastStopReason: "cancelled" })), + ]); + + const resolved = firstOf(drafts, "permissionResolved"); + expect(resolved.outcome).toEqual({ type: "cancelled" }); + expect(only(drafts, "turnCancelled")).toHaveLength(1); + expect(only(drafts, "turnFailed")).toHaveLength(0); + + const projection = validateAndFold(drafts); + expect(projection.session.runState).toBe("idle"); + expect(projection.threadsById[MAIN_THREAD_ID]?.runState).toBe("cancelled"); + }); + + test("settings derive from state frames, dedupe, and carry attribution", () => { + const translator = makeTranslator(); + const env = makeJournal(); + + const modelOption = { + id: "model", + name: "Model", + category: "model" as const, + type: "select" as const, + currentValue: "claude-sonnet-4-6", + options: [ + { value: "claude-sonnet-4-6", name: "Sonnet" }, + { value: "claude-opus-4-8", name: "Opus" }, + ], + }; + + const first = translateAll(translator, [ + env( + stateFrame({ + status: "idle", + currentMode: { + currentModeId: "default", + availableModes: [{ id: "default", name: "Default" }], + }, + configOptions: [modelOption], + }), + ), + ]); + const initial = firstOf(first, "settingsUpdated"); + expect(initial.settings).toEqual({ + activeModel: "claude-sonnet-4-6", + activeMode: "default", + effort: null, + configuration: { model: "claude-sonnet-4-6" }, + }); + + // Identical state frame: no duplicate settings event. + const repeat = translateAll(translator, [ + env( + stateFrame({ + status: "idle", + currentMode: { + currentModeId: "default", + availableModes: [{ id: "default", name: "Default" }], + }, + configOptions: [modelOption], + }), + ), + ]); + expect(only(repeat, "settingsUpdated")).toHaveLength(0); + + translator.attributeNextSettingsChange("request-model-1"); + const changed = translateAll(translator, [ + env( + stateFrame({ + status: "idle", + currentMode: { + currentModeId: "acceptEdits", + availableModes: [{ id: "acceptEdits", name: "Accept Edits" }], + }, + configOptions: [{ ...modelOption, currentValue: "claude-opus-4-8" }], + }), + ), + ]); + const changedDraft = changed.find( + (draft) => draft.payload.type === "settingsUpdated", + ); + expect(changedDraft?.causationId).toBe("request-model-1"); + const updated = firstOf(changed, "settingsUpdated"); + expect(updated.settings.activeModel).toBe("claude-opus-4-8"); + expect(updated.settings.activeMode).toBe("acceptEdits"); + + const projection = validateAndFold([...first, ...repeat, ...changed]); + expect(projection.session.settings.activeModel).toBe("claude-opus-4-8"); + }); + + test("setting catalogs map selects, flatten groups, synthesize mode, skip booleans", () => { + const state = stateFrame({ + currentMode: { + currentModeId: "default", + availableModes: [ + { id: "default", name: "Default", description: "Ask first" }, + { id: "acceptEdits", name: "Accept Edits" }, + ], + }, + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "sonnet", + options: [ + { + group: "recommended", + name: "Recommended", + options: [{ value: "sonnet", name: "Sonnet" }], + }, + { + group: "all", + name: "All", + options: [{ value: "opus", name: "Opus", description: "Deep" }], + }, + ], + }, + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "default", + options: [ + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + }, + { + id: "fast", + name: "Fast mode", + type: "boolean", + currentValue: false, + }, + ], + }); + if (state.kind !== "state") throw new Error("expected state frame"); + const catalogs = settingOptionsFromScopedState(state.state); + expect(catalogs).toEqual([ + { + id: "model", + name: "Model", + kind: "model", + currentValue: "sonnet", + options: [ + { value: "sonnet", name: "Sonnet", description: null }, + { value: "opus", name: "Opus", description: "Deep" }, + ], + }, + { + id: "effort", + name: "Effort", + kind: "effort", + currentValue: "default", + options: [ + { value: "default", name: "Default", description: null }, + { value: "high", name: "High", description: null }, + ], + }, + { + id: "mode", + name: "Mode", + kind: "mode", + currentValue: "default", + options: [ + { value: "default", name: "Default", description: "Ask first" }, + { value: "acceptEdits", name: "Accept Edits", description: null }, + ], + }, + ]); + }); + + test("a catalog-only change still appends a settings event", () => { + const translator = makeTranslator(); + const env = makeJournal(); + const modelOption = (models: string[]) => ({ + id: "model", + name: "Model", + category: "model" as const, + type: "select" as const, + currentValue: "sonnet", + options: models.map((value) => ({ value, name: value })), + }); + const first = translateAll(translator, [ + env(stateFrame({ configOptions: [modelOption(["sonnet"])] })), + ]); + expect(only(first, "settingsUpdated")).toHaveLength(1); + // Same values, wider catalog: the composed session (the catalogs' + // carrier) is only re-pushed when the log moves, so this must append. + const widened = translateAll(translator, [ + env(stateFrame({ configOptions: [modelOption(["sonnet", "opus"])] })), + ]); + expect(only(widened, "settingsUpdated")).toHaveLength(1); + validateAndFold([...first, ...widened]); + }); + + test("adapter death fails the active turn; between-turn death surfaces an error once", () => { + const midTurn = makeTranslator(); + const env = makeJournal(); + const drafts = translateAll(midTurn, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "long task" }, + }), + ), + env(stateFrame({ status: "running" })), + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_slow_1", + title: "Slow read", + kind: "read", + status: "in_progress", + _meta: { claudeCode: { toolName: "Read" } }, + }), + ), + // markDead terminalizes open tools before the dead state frame. + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_slow_1", + status: "failed", + }), + ), + env( + stateFrame({ status: "dead", lastError: "adapter exited with code 1" }), + ), + ]); + const failures = only(drafts, "turnFailed"); + expect(failures).toHaveLength(1); + expect(failures[0]?.error).toMatchObject({ + code: "ADAPTER_UNAVAILABLE", + recovery: "startNewSession", + }); + const projection = validateAndFold(drafts); + expect(projection.session.error?.code).toBe("ADAPTER_UNAVAILABLE"); + expect(projection.activeToolCallsById).toEqual({}); + + const betweenTurns = makeTranslator(); + const env2 = makeJournal(); + const quietDeath = translateAll(betweenTurns, [ + env2(stateFrame({ status: "idle" })), + env2(stateFrame({ status: "dead", lastError: "adapter exited" })), + // A repeated dead frame must not emit a second error. + env2(stateFrame({ status: "dead", lastError: "adapter exited" })), + ]); + expect(only(quietDeath, "error")).toHaveLength(1); + expect(only(quietDeath, "turnFailed")).toHaveLength(0); + const quietProjection = validateAndFold(quietDeath); + expect(quietProjection.session.error?.code).toBe("ADAPTER_UNAVAILABLE"); + }); + + test("replayed transcripts recover turn boundaries; output is deterministic", () => { + // session/load replays the transcript with no state frames at all. + const replayEnvelopes = (() => { + const env = makeJournal(); + return [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "first ask" }, + }), + ), + env( + update({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "first answer" }, + messageId: "replay-msg-1", + }), + ), + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "second ask" }, + }), + ), + env( + update({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "second answer" }, + messageId: "replay-msg-2", + }), + ), + ]; + })(); + + const first = translateAll(makeTranslator(), replayEnvelopes); + const second = translateAll(makeTranslator(), replayEnvelopes); + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + + // The second user message closes the first turn (replay segmentation). + const turnStarts = only(first, "turnStarted"); + expect(turnStarts).toHaveLength(2); + const segmented = firstOf(first, "turnCompleted"); + expect(segmented.turnId).toBe(firstOf(first, "turnStarted").turn.id); + expect(segmented.stopReason).toBe("other"); + + // Second turn stays open (replay ends mid-conversation state). + const projection = validateAndFold(first); + expect(Object.keys(projection.activeTurnsById)).toHaveLength(1); + expect(projection.session.runState).toBe("running"); + }); + + test("audio chunks and unknown tool ids degrade without breaking the fold", () => { + const translator = makeTranslator(); + const env = makeJournal(); + const drafts = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "audio", mimeType: "audio/wav", data: "UklGRg==" }, + }), + ), + // Update for a tool call this incarnation never saw: forwarded, and + // the reducer treats it as an idempotent no-op. + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_from_before_restart", + status: "failed", + }), + ), + env(stateFrame({ status: "idle", lastStopReason: "end_turn" })), + ]); + + // Audio has no canonical block: the message opens but carries no delta. + expect(only(drafts, "messageStarted")).toHaveLength(1); + expect(only(drafts, "messageDelta")).toHaveLength(0); + + const projection = validateAndFold(drafts); + expect(projection.activeToolCallsById).toEqual({}); + expect(projection.session.runState).toBe("idle"); + }); + + test("oversize rawOutput is dropped to null at the normalization boundary", () => { + const translator = makeTranslator(); + const env = makeJournal(); + const drafts = translateAll(translator, [ + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_big", + title: "Big output", + status: "in_progress", + rawInput: { command: "generate" }, + }), + ), + env( + update({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_big", + status: "completed", + rawOutput: { blob: "x".repeat(300 * 1024) }, + }), + ), + env(stateFrame({ status: "idle", lastStopReason: "end_turn" })), + ]); + + const terminal = only(drafts, "toolCallUpdated").find( + (payload) => payload.update.state === "succeeded", + ); + if (!terminal) throw new Error("expected a terminal tool_call_updated"); + // Dropped, not truncated (a JSON prefix is not JSON) and not forwarded + // (one giant event would force-close every sync subscriber's socket). + expect(terminal.update.output).toBeNull(); + // The small input survived untouched on the started event. + expect(firstOf(drafts, "toolCallStarted").toolCall.input).toEqual({ + command: "generate", + }); + validateAndFold(drafts); + }); + + test("lookup surface: turnIdFor, knowsTurn, nativePermissionRequestId, merged-prompt causation", () => { + const translator = makeTranslator(); + const env = makeJournal(); + + translator.attributeNextTurn({ + requestId: "request-send-1", + clientInstanceId: null, + }); + const first = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "first prompt" }, + }), + ), + env(stateFrame({ status: "running" })), + ]); + const firstTurn = firstOf(first, "turnStarted").turn.id; + expect(translator.turnIdFor("request-send-1")).toBe(firstTurn); + expect(translator.activeTurnId()).toBe(firstTurn); + expect(translator.knowsTurn(firstTurn)).toBe(true); + expect(translator.knowsTurn("turn-from-another-incarnation")).toBe(false); + expect(translator.turnIdFor("request-never-sent")).toBeNull(); + + // A second prompt admitted into the still-open turn: the request maps to + // the existing turn and the user message carries the new request id. + translator.attributeNextTurn({ + requestId: "request-send-2", + clientInstanceId: null, + }); + const merged = translateAll(translator, [ + env( + update({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "second prompt, same turn" }, + }), + ), + ]); + expect(only(merged, "turnStarted")).toHaveLength(0); + expect(translator.turnIdFor("request-send-2")).toBe(firstTurn); + const mergedStart = merged.find( + (draft) => draft.payload.type === "messageStarted", + ); + expect(mergedStart?.causationId).toBe("request-send-2"); + const mergedDelta = merged.find( + (draft) => draft.payload.type === "messageDelta", + ); + expect(mergedDelta?.causationId).toBe("request-send-2"); + + // Permission id round-trip: public id ↔ native JSON-RPC request id. + const permission = translateAll(translator, [ + env( + update({ + sessionUpdate: "tool_call", + toolCallId: "toolu_perm_1", + title: "Run tests", + kind: "execute", + status: "pending", + }), + ), + env({ + kind: "permission_requested", + pending: { + requestId: "7", + toolCall: { toolCallId: "toolu_perm_1" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + requestedAt: T0 + 100, + }, + }), + ]); + const publicId = firstOf(permission, "permissionRequested").permission.id; + expect(translator.nativePermissionRequestId(publicId)).toBe("7"); + expect(translator.nativePermissionRequestId("permission-unknown")).toBe( + null, + ); + + // Resolution clears the mapping. + translateAll(translator, [ + env({ + kind: "permission_resolved", + requestId: "7", + outcome: { outcome: "selected", optionId: "allow" }, + }), + ]); + expect(translator.nativePermissionRequestId(publicId)).toBeNull(); + }); +}); diff --git a/packages/host-service/src/runtime/sessions/translate-acp.ts b/packages/host-service/src/runtime/sessions/translate-acp.ts new file mode 100644 index 00000000000..2f53ae79496 --- /dev/null +++ b/packages/host-service/src/runtime/sessions/translate-acp.ts @@ -0,0 +1,1246 @@ +import type { + ContentBlock, + EventId, + JsonValue, + PermissionRequest, + PlanEntry, + SessionError, + SessionEventPayload, + SessionId, + SessionSettings, + SettingOption, + SettingOptionChoice, + StopReason, + Thread, + ThreadId, + ToolCallState, + TurnId, +} from "@superset/host-service-sync/protocol"; +import type { + ContentBlock as AcpContentBlock, + PermissionOptionKind as AcpPermissionOptionKind, + StopReason as AcpStopReason, + ToolCallStatus as AcpToolCallStatus, + PendingPermission, + RequestPermissionOutcome, + SessionConfigOption, + SessionConfigSelectOptions, + SessionScopedState, + SessionUpdate, + SessionUpdateEnvelope, +} from "@superset/session-protocol"; +import { selectedOptionIds } from "@superset/session-protocol"; + +/** + * A canonical session event minus its `cursor`: the durable log assigns + * cursors at append time; everything else — including the event id — is + * minted here so intra-batch references (a subagent thread's + * `spawnedByEventId`) stay consistent. + */ +export interface SessionEventDraft { + id: EventId; + sessionId: SessionId; + threadId: ThreadId; + occurredAt: number; + causationId: string | null; + payload: SessionEventPayload; +} + +/** The main thread id every session's canonical log hangs off. */ +export function acpMainThreadId(sessionId: string): ThreadId { + return `thread-${sessionId}-main`; +} + +const SHORT_TEXT_LIMIT = 4_096; +/** Same retention shape as the projection's collection caps. */ +const MAX_TRACKED_TOOL_CALLS = 10_000; +const MAX_TRACKED_SUBAGENT_THREADS = 1_000; +const MAX_TRACKED_TURNS = 10_000; + +const TERMINAL_TOOL_CALL_STATES: ReadonlySet = new Set([ + "succeeded", + "failed", + "cancelled", +]); + +interface TrackedToolCall { + threadId: ThreadId; + startedEventId: EventId; + title: string; + terminal: boolean; +} + +interface ActiveTurn { + id: TurnId; + /** requestId of the sendMessage mutation that started it, if attributed. */ + causationId: string | null; + /** Once the agent has produced output, a fresh top-level user message + * means a new turn (replayed transcripts have no state frames). */ + sawAgentActivity: boolean; +} + +interface OpenMessage { + id: string; + /** `${role}:${acp messageId | "anon"}` — a change closes the message. */ + groupKey: string; + role: "user" | "assistant"; + /** Carried by message_started and every delta of this message. */ + causationId: string | null; +} + +export interface AcpSessionEventTranslatorOptions { + sessionId: SessionId; + /** + * Uniqueness scope for minted ids (turns, messages, permissions, events). + * The adapter journal restarts seqs at 1 on every resurrection, so the + * caller must pass a value that differs per incarnation or minted ids + * would collide in the durable log across restarts. + */ + idScope: string; +} + +/** + * The private ACP adapter normalization layer: folds the ACP session journal + * (`SessionUpdateEnvelope`s, in seq order) into canonical `SessionEvent` + * drafts. Deterministic — every output is a pure function of the envelope + * stream, the id scope, and the explicit `attribute*` calls; no clocks, no + * randomness — so the same journal always produces the same canonical log. + * + * Semantics ACP does not express are synthesized here: + * - Turns: opened by the first activity that needs one, closed by the + * manager's idle/dead state frames (via lastStopReason/lastError) or an + * early `prompt_rejected`. + * - Subagent threads: `_meta.claudeCode.parentToolUseId` routes child + * activity into a partial-fidelity `subagent` thread per Task tool call + * (adapter data loss must not become protocol flattening). + * - Messages: chunk streams are grouped by ACP messageId (role-scoped) and + * closed on group change or turn end. + * + * Deliberate reductions (documented, not accidental): audio content blocks, + * usage/available-commands updates, and the UNSTABLE plan_update/plan_removed + * variants are skipped; tool-call content/location refinements are dropped in + * favor of rawInput/rawOutput. + */ +export class AcpSessionEventTranslator { + private readonly sessionId: SessionId; + private readonly idScope: string; + readonly mainThreadId: ThreadId; + + private eventSerial = 0; + private turnSerial = 0; + private messageSerial = 0; + private permissionSerial = 0; + + private emittedMainThread = false; + private activeTurn: ActiveTurn | null = null; + private lastStatus: SessionScopedState["status"] | null = null; + private lastSettingsKey: string | null = null; + + private readonly openMessages = new Map(); + private readonly toolCalls = new Map(); + private readonly subagentThreads = new Map(); + private readonly pendingPermissions = new Map< + string, + { publicId: string; threadId: ThreadId; toolCallId: string } + >(); + private readonly mintedTurnIds = new Set(); + private readonly turnIdsByRequest = new Map(); + + private nextTurnAttribution: { + requestId: string | null; + clientInstanceId: string | null; + } | null = null; + private nextSettingsCausation: string | null = null; + private readonly permissionResolutionCausations = new Map(); + + constructor(options: AcpSessionEventTranslatorOptions) { + this.sessionId = options.sessionId; + this.idScope = options.idScope; + this.mainThreadId = acpMainThreadId(options.sessionId); + } + + /** The next synthesized turn (and its user message) carries this origin. */ + attributeNextTurn(attribution: { + requestId: string | null; + clientInstanceId: string | null; + }): void { + this.nextTurnAttribution = attribution; + } + + /** The next settings_updated emission carries this requestId. */ + attributeNextSettingsChange(requestId: string): void { + this.nextSettingsCausation = requestId; + } + + /** Disarm a failed settings mutation so it cannot tag an unrelated event. */ + clearSettingsCausation(requestId: string): void { + if (this.nextSettingsCausation === requestId) { + this.nextSettingsCausation = null; + } + } + + /** The resolution of this native permission request carries this requestId. */ + attributePermissionResolution( + nativeRequestId: string, + requestId: string, + ): void { + this.permissionResolutionCausations.set(nativeRequestId, requestId); + } + + /** Disarm a failed permission resolution so a later one is not mislabeled. */ + clearPermissionResolutionCausation( + nativeRequestId: string, + requestId: string, + ): void { + if ( + this.permissionResolutionCausations.get(nativeRequestId) === requestId + ) { + this.permissionResolutionCausations.delete(nativeRequestId); + } + } + + /** Public permission id for a native request id, while it is pending. */ + publicPermissionId(nativeRequestId: string): string | null { + return this.pendingPermissions.get(nativeRequestId)?.publicId ?? null; + } + + /** Native request id for a public permission id, while it is pending. */ + nativePermissionRequestId(publicPermissionId: string): string | null { + for (const [nativeRequestId, entry] of this.pendingPermissions) { + if (entry.publicId === publicPermissionId) return nativeRequestId; + } + return null; + } + + /** The turn a submitTurn requestId's message landed in, if seen yet. */ + turnIdFor(requestId: string): TurnId | null { + return this.turnIdsByRequest.get(requestId) ?? null; + } + + activeTurnId(): TurnId | null { + return this.activeTurn?.id ?? null; + } + + /** Whether this incarnation ever minted the given turn id. */ + knowsTurn(turnId: string): boolean { + return this.mintedTurnIds.has(turnId); + } + + translate(envelope: SessionUpdateEnvelope): SessionEventDraft[] { + const events: SessionEventDraft[] = []; + const ts = envelope.ts; + this.ensureMainThread(events, ts); + const frame = envelope.frame; + switch (frame.kind) { + case "update": + this.translateUpdate(events, frame.update, ts); + break; + case "permission_requested": + this.translatePermissionRequested(events, frame.pending, ts); + break; + case "permission_resolved": + this.translatePermissionResolved( + events, + frame.requestId, + frame.outcome, + ts, + ); + break; + case "prompt_rejected": + this.translatePromptRejected(events, frame.reason, ts); + break; + case "state": + this.translateState(events, frame.state, ts); + break; + case "reset": + // A journal-eviction marker for shipping-stream subscribers; the + // canonical pipeline subscribes from seq 1 and never sees one. + break; + default: + frame satisfies never; + } + return events; + } + + // ------------------------------------------------------------------------- + // ACP session/update variants + // ------------------------------------------------------------------------- + + private translateUpdate( + events: SessionEventDraft[], + update: SessionUpdate, + ts: number, + ): void { + switch (update.sessionUpdate) { + case "user_message_chunk": + this.translateChunk(events, ts, { + role: "user", + thought: false, + content: update.content, + messageId: update.messageId ?? null, + parentToolUseId: claudeMeta(update._meta).parentToolUseId, + }); + break; + case "agent_message_chunk": + case "agent_thought_chunk": + this.translateChunk(events, ts, { + role: "assistant", + thought: update.sessionUpdate === "agent_thought_chunk", + content: update.content, + messageId: update.messageId ?? null, + parentToolUseId: claudeMeta(update._meta).parentToolUseId, + }); + break; + case "tool_call": + this.translateToolCall(events, update, ts); + break; + case "tool_call_update": + this.translateToolCallUpdate(events, update, ts); + break; + case "plan": + this.push(events, this.mainThreadId, ts, null, { + type: "planUpdated", + plan: update.entries.map( + (entry, index): PlanEntry => ({ + id: `step-${index + 1}`, + content: entry.content, + status: + entry.status === "in_progress" ? "inProgress" : entry.status, + priority: entry.priority, + }), + ), + }); + break; + case "session_info_update": + case "current_mode_update": + case "config_option_update": + // Session-level metadata. The manager applies these to scoped state + // and emits a state frame right after; settings_updated is derived + // there so client-initiated changes (which have no raw update) and + // adapter-initiated ones share one path. + break; + case "plan_update": + case "plan_removed": + // UNSTABLE multi-plan ACP extension the Claude adapter does not + // emit; the stable `plan` variant carries the whole plan. + break; + case "available_commands_update": + case "usage_update": + // No canonical counterpart (yet); deliberately skipped. + break; + default: + update satisfies never; + } + } + + private translateChunk( + events: SessionEventDraft[], + ts: number, + chunk: { + role: "user" | "assistant"; + thought: boolean; + content: AcpContentBlock; + messageId: string | null; + parentToolUseId: string | null; + }, + ): void { + const threadId = this.routeThread(events, chunk.parentToolUseId, ts); + let userCausation: string | null = null; + if (chunk.role === "user" && chunk.parentToolUseId === null) { + // Replayed transcripts carry no state frames, so turn boundaries are + // recovered here: a fresh top-level user message after agent output + // ends the previous turn. + if (this.activeTurn?.sawAgentActivity) { + this.endTurn(events, ts, { + type: "turnCompleted", + stopReason: "other", + }); + } + const hadTurn = this.activeTurn !== null; + const turn = this.ensureTurn(events, ts); + if (hadTurn) { + // A second prompt admitted into a turn that has produced no agent + // output yet: its message rides the already-open turn, but keeps + // its own request attribution and starts a fresh user message. + const attribution = this.nextTurnAttribution; + this.nextTurnAttribution = null; + if (attribution?.requestId) { + this.turnIdsByRequest.set(attribution.requestId, turn.id); + userCausation = attribution.requestId; + if (this.openMessages.get(threadId)?.role === "user") { + this.closeMessage(events, threadId, ts); + } + } + } else { + userCausation = turn.causationId; + } + } else { + const turn = this.ensureTurn(events, ts); + turn.sawAgentActivity = true; + } + const turn = this.activeTurn; + if (!turn) return; + + const groupKey = `${chunk.role}:${chunk.messageId ?? "anon"}`; + const open = this.openMessages.get(threadId); + if (open && open.groupKey !== groupKey) { + this.closeMessage(events, threadId, ts); + } + let message = this.openMessages.get(threadId); + if (!message) { + message = { + id: `message-${this.idScope}-${++this.messageSerial}`, + groupKey, + role: chunk.role, + causationId: chunk.role === "user" ? userCausation : null, + }; + this.openMessages.set(threadId, message); + this.push(events, threadId, ts, message.causationId, { + type: "messageStarted", + message: { + id: message.id, + sessionId: this.sessionId, + threadId, + turnId: turn.id, + role: chunk.role, + content: [], + createdAt: ts, + }, + }); + } + const content = mapContentBlock(chunk.content, chunk.thought); + if (content) { + this.push(events, threadId, ts, message.causationId, { + type: "messageDelta", + messageId: message.id, + content, + }); + } + } + + private translateToolCall( + events: SessionEventDraft[], + update: Extract, + ts: number, + ): void { + const meta = claudeMeta(update._meta); + const threadId = this.routeThread(events, meta.parentToolUseId, ts); + const turn = this.ensureTurn(events, ts); + turn.sawAgentActivity = true; + const known = this.toolCalls.get(update.toolCallId); + if (known && !known.terminal) { + // The adapter re-announces a tool call it already surfaced (its + // permission flow's ensureToolCallEmitted); treat as a refinement. + known.title = truncate(update.title); + this.push(events, known.threadId, ts, null, { + type: "toolCallUpdated", + toolCallId: update.toolCallId, + update: { + title: truncate(update.title), + ...(update.rawInput !== undefined + ? { input: toJsonValue(update.rawInput) } + : {}), + ...(update.status ? { state: mapToolCallStatus(update.status) } : {}), + updatedAt: ts, + }, + }); + return; + } + const startedEventId = this.mintEventId(); + const state = mapToolCallStatus(update.status ?? "pending"); + this.trackToolCall(update.toolCallId, { + threadId, + startedEventId, + title: truncate(update.title), + terminal: TERMINAL_TOOL_CALL_STATES.has(state), + }); + events.push({ + id: startedEventId, + sessionId: this.sessionId, + threadId, + occurredAt: ts, + causationId: null, + payload: { + type: "toolCallStarted", + toolCall: { + id: update.toolCallId, + sessionId: this.sessionId, + threadId, + turnId: turn.id, + parentToolCallId: meta.parentToolUseId, + tool: { + name: meta.toolName ?? `acp.${update.kind ?? "other"}`, + version: 1, + }, + title: truncate(update.title), + input: toJsonValue(update.rawInput) ?? null, + resolver: { type: "host" }, + state, + createdAt: ts, + updatedAt: ts, + expiresAt: null, + }, + }, + }); + } + + private translateToolCallUpdate( + events: SessionEventDraft[], + update: Extract, + ts: number, + ): void { + const meta = claudeMeta(update._meta); + const known = this.toolCalls.get(update.toolCallId); + const threadId = + known?.threadId ?? this.routeThread(events, meta.parentToolUseId, ts); + const state = + update.status !== null && update.status !== undefined + ? mapToolCallStatus(update.status) + : undefined; + this.push(events, threadId, ts, null, { + type: "toolCallUpdated", + toolCallId: update.toolCallId, + update: { + ...(update.title !== null && update.title !== undefined + ? { title: truncate(update.title) } + : {}), + ...(update.rawInput !== undefined + ? { input: toJsonValue(update.rawInput) } + : {}), + ...(update.rawOutput !== undefined + ? { output: toJsonValue(update.rawOutput) } + : {}), + ...(state ? { state } : {}), + updatedAt: ts, + }, + }); + if (known && state && TERMINAL_TOOL_CALL_STATES.has(state)) { + known.terminal = true; + this.finishSubagentThread(events, update.toolCallId, state, ts); + } + } + + // ------------------------------------------------------------------------- + // Permission frames + // ------------------------------------------------------------------------- + + private translatePermissionRequested( + events: SessionEventDraft[], + pending: PendingPermission, + ts: number, + ): void { + const turn = this.ensureTurn(events, ts); + turn.sawAgentActivity = true; + const toolCallId = pending.toolCall.toolCallId; + const known = this.toolCalls.get(toolCallId); + let threadId: ThreadId; + if (known) { + threadId = known.threadId; + if (!known.terminal) { + this.push(events, threadId, ts, null, { + type: "toolCallUpdated", + toolCallId, + update: { + state: "awaitingPermission", + ...(typeof pending.toolCall.title === "string" && + truncate(pending.toolCall.title) !== known.title + ? { title: truncate(pending.toolCall.title) } + : {}), + updatedAt: ts, + }, + }); + } + } else { + // No adapter tool call exists (a synthetic question card from a form + // elicitation): synthesize one so the question text has a home. + threadId = this.mainThreadId; + const startedEventId = this.mintEventId(); + const title = truncate( + typeof pending.toolCall.title === "string" + ? pending.toolCall.title + : "Question", + ); + this.trackToolCall(toolCallId, { + threadId, + startedEventId, + title, + terminal: false, + }); + events.push({ + id: startedEventId, + sessionId: this.sessionId, + threadId, + occurredAt: ts, + causationId: null, + payload: { + type: "toolCallStarted", + toolCall: { + id: toolCallId, + sessionId: this.sessionId, + threadId, + turnId: turn.id, + parentToolCallId: null, + tool: { name: "ui.question", version: 1 }, + title, + input: toJsonValue(pending.toolCall.rawInput) ?? null, + resolver: { type: "host" }, + state: "awaitingPermission", + createdAt: ts, + updatedAt: ts, + expiresAt: null, + }, + }, + }); + } + const publicId = `permission-${this.idScope}-${++this.permissionSerial}`; + this.pendingPermissions.set(pending.requestId, { + publicId, + threadId, + toolCallId, + }); + const permission: PermissionRequest = { + id: publicId, + sessionId: this.sessionId, + threadId, + toolCallId, + options: pending.options.map((option) => ({ + id: option.optionId, + name: option.name, + kind: mapPermissionOptionKind(option.kind), + })), + multiSelect: pending.multiSelect ?? false, + requestedAt: pending.requestedAt, + }; + this.push(events, threadId, ts, null, { + type: "permissionRequested", + permission, + }); + } + + private translatePermissionResolved( + events: SessionEventDraft[], + nativeRequestId: string, + outcome: RequestPermissionOutcome, + ts: number, + ): void { + const entry = this.pendingPermissions.get(nativeRequestId); + if (!entry) return; + this.pendingPermissions.delete(nativeRequestId); + const causationId = + this.permissionResolutionCausations.get(nativeRequestId) ?? null; + this.permissionResolutionCausations.delete(nativeRequestId); + this.push(events, entry.threadId, ts, causationId, { + type: "permissionResolved", + permissionId: entry.publicId, + outcome: + outcome.outcome === "selected" + ? { type: "selected", optionIds: selectedOptionIds(outcome) } + : { type: "cancelled" }, + }); + } + + // ------------------------------------------------------------------------- + // Turn lifecycle + // ------------------------------------------------------------------------- + + private translatePromptRejected( + events: SessionEventDraft[], + reason: string, + ts: number, + ): void { + const error = mapReasonToError(reason, ts); + if (this.activeTurn) { + this.endTurn(events, ts, { type: "turnFailed", error }); + } else { + this.push(events, this.mainThreadId, ts, null, { type: "error", error }); + } + } + + private translateState( + events: SessionEventDraft[], + state: SessionScopedState, + ts: number, + ): void { + const settings = settingsFromScopedState(state); + // The catalogs are part of the change key even though the event carries + // only values: a catalog-only rebuild (e.g. the effort list after a model + // switch) must still append an event, because the composed session — the + // catalogs' actual carrier — is only re-pushed when the log moves. + const settingsKey = JSON.stringify([ + settings, + settingOptionsFromScopedState(state), + ]); + if (settingsKey !== this.lastSettingsKey) { + this.lastSettingsKey = settingsKey; + const causationId = this.nextSettingsCausation; + this.nextSettingsCausation = null; + this.push(events, this.mainThreadId, ts, causationId, { + type: "settingsUpdated", + settings, + }); + } + + if (state.status === "running" && !this.activeTurn) { + this.startTurn(events, ts); + } else if ( + (state.status === "idle" || state.status === "dead") && + this.activeTurn + ) { + if (state.status === "dead") { + this.endTurn(events, ts, { + type: "turnFailed", + error: adapterDeadError(ts), + }); + } else if (state.lastError !== null) { + this.endTurn(events, ts, { + type: "turnFailed", + error: mapReasonToError(state.lastError, ts), + }); + } else if (state.lastStopReason === "cancelled") { + this.endTurn(events, ts, { type: "turnCancelled" }); + } else { + this.endTurn(events, ts, { + type: "turnCompleted", + stopReason: mapStopReason(state.lastStopReason), + }); + } + } else if ( + state.status === "dead" && + !this.activeTurn && + this.lastStatus !== "dead" + ) { + // The adapter died between turns — surface it even without a turn to + // fail; the projection carries it as session.error. + this.push(events, this.mainThreadId, ts, null, { + type: "error", + error: adapterDeadError(ts), + }); + } + this.lastStatus = state.status; + } + + private ensureTurn(events: SessionEventDraft[], ts: number): ActiveTurn { + return this.activeTurn ?? this.startTurn(events, ts); + } + + private startTurn(events: SessionEventDraft[], ts: number): ActiveTurn { + const attribution = this.nextTurnAttribution; + this.nextTurnAttribution = null; + const turn: ActiveTurn = { + id: `turn-${this.idScope}-${++this.turnSerial}`, + causationId: attribution?.requestId ?? null, + sawAgentActivity: false, + }; + this.activeTurn = turn; + this.mintedTurnIds.add(turn.id); + if (this.mintedTurnIds.size > MAX_TRACKED_TURNS) { + const oldest = this.mintedTurnIds.values().next().value; + if (oldest !== undefined) this.mintedTurnIds.delete(oldest); + } + if (attribution?.requestId) { + this.turnIdsByRequest.set(attribution.requestId, turn.id); + if (this.turnIdsByRequest.size > MAX_TRACKED_TURNS) { + const oldest = this.turnIdsByRequest.keys().next().value; + if (oldest !== undefined) this.turnIdsByRequest.delete(oldest); + } + } + this.push(events, this.mainThreadId, ts, turn.causationId, { + type: "turnStarted", + turn: { + id: turn.id, + sessionId: this.sessionId, + threadId: this.mainThreadId, + status: "running", + originatingClientInstanceId: attribution?.clientInstanceId ?? null, + createdAt: ts, + updatedAt: ts, + }, + }); + return turn; + } + + private endTurn( + events: SessionEventDraft[], + ts: number, + ending: + | { type: "turnCompleted"; stopReason: StopReason } + | { type: "turnFailed"; error: SessionError } + | { type: "turnCancelled" }, + ): void { + const turn = this.activeTurn; + if (!turn) return; + this.activeTurn = null; + for (const threadId of [...this.openMessages.keys()]) { + this.closeMessage(events, threadId, ts); + } + this.push(events, this.mainThreadId, ts, null, { + ...ending, + turnId: turn.id, + }); + } + + // ------------------------------------------------------------------------- + // Threads and messages + // ------------------------------------------------------------------------- + + private ensureMainThread(events: SessionEventDraft[], ts: number): void { + if (this.emittedMainThread) return; + this.emittedMainThread = true; + this.push(events, this.mainThreadId, ts, null, { + type: "threadCreated", + thread: { + id: this.mainThreadId, + sessionId: this.sessionId, + kind: "main", + parentThreadId: null, + origin: { type: "sessionCreated" }, + fidelity: "full", + title: null, + runState: "idle", + eventHead: null, + createdAt: ts, + updatedAt: ts, + lastActivityAt: ts, + }, + }); + } + + /** + * Thread for a `_meta.claudeCode.parentToolUseId`, creating the + * partial-fidelity subagent thread on first sight. When the parent Task + * tool call was never seen (journal eviction), the thread_created event + * anchors `spawnedByEventId` to itself — deterministic and schema-valid, + * if less precise. + */ + private routeThread( + events: SessionEventDraft[], + parentToolUseId: string | null, + ts: number, + ): ThreadId { + if (parentToolUseId === null) return this.mainThreadId; + const existing = this.subagentThreads.get(parentToolUseId); + if (existing) return existing.id; + const parent = this.toolCalls.get(parentToolUseId); + const threadId: ThreadId = `thread-sub-${parentToolUseId}`; + const createdEventId = this.mintEventId(); + const thread: Thread = { + id: threadId, + sessionId: this.sessionId, + kind: "subagent", + parentThreadId: this.mainThreadId, + origin: { + type: "subagent", + spawnedByEventId: parent?.startedEventId ?? createdEventId, + spawnedByToolCallId: parentToolUseId, + }, + // ACP drops subagent assistant text/thinking; only child tool + // activity survives, so this thread is declared partial. + fidelity: "partial", + title: parent?.title ?? null, + runState: "running", + eventHead: null, + createdAt: ts, + updatedAt: ts, + lastActivityAt: ts, + }; + this.trackSubagentThread(parentToolUseId, thread); + events.push({ + id: createdEventId, + sessionId: this.sessionId, + threadId, + occurredAt: ts, + causationId: null, + payload: { type: "threadCreated", thread }, + }); + return threadId; + } + + private finishSubagentThread( + events: SessionEventDraft[], + taskToolCallId: string, + state: ToolCallState, + ts: number, + ): void { + const thread = this.subagentThreads.get(taskToolCallId); + if (!thread || thread.runState !== "running") return; + this.closeMessage(events, thread.id, ts); + const finished: Thread = { + ...thread, + runState: + state === "succeeded" + ? "completed" + : state === "cancelled" + ? "cancelled" + : "failed", + updatedAt: ts, + lastActivityAt: ts, + }; + this.subagentThreads.set(taskToolCallId, finished); + this.push(events, thread.id, ts, null, { + type: "threadUpdated", + thread: finished, + }); + } + + private closeMessage( + events: SessionEventDraft[], + threadId: ThreadId, + ts: number, + ): void { + const open = this.openMessages.get(threadId); + if (!open) return; + this.openMessages.delete(threadId); + this.push(events, threadId, ts, null, { + type: "messageCompleted", + messageId: open.id, + }); + } + + // ------------------------------------------------------------------------- + // Bookkeeping + // ------------------------------------------------------------------------- + + private trackToolCall(toolCallId: string, tracked: TrackedToolCall): void { + this.toolCalls.set(toolCallId, tracked); + if (this.toolCalls.size > MAX_TRACKED_TOOL_CALLS) { + const oldest = this.toolCalls.keys().next().value; + if (oldest !== undefined) this.toolCalls.delete(oldest); + } + } + + private trackSubagentThread(taskToolCallId: string, thread: Thread): void { + this.subagentThreads.set(taskToolCallId, thread); + if (this.subagentThreads.size > MAX_TRACKED_SUBAGENT_THREADS) { + const oldest = this.subagentThreads.keys().next().value; + if (oldest !== undefined) this.subagentThreads.delete(oldest); + } + } + + private mintEventId(): EventId { + return `event-${this.idScope}-${++this.eventSerial}`; + } + + private push( + events: SessionEventDraft[], + threadId: ThreadId, + occurredAt: number, + causationId: string | null, + payload: SessionEventPayload, + ): void { + events.push({ + id: this.mintEventId(), + sessionId: this.sessionId, + threadId, + occurredAt, + causationId, + payload, + }); + } +} + +// --------------------------------------------------------------------------- +// Pure mapping helpers +// --------------------------------------------------------------------------- + +function claudeMeta(meta: unknown): { + toolName: string | null; + parentToolUseId: string | null; +} { + const claude = ( + meta as + | { claudeCode?: { toolName?: unknown; parentToolUseId?: unknown } } + | null + | undefined + )?.claudeCode; + return { + toolName: typeof claude?.toolName === "string" ? claude.toolName : null, + parentToolUseId: + typeof claude?.parentToolUseId === "string" + ? claude.parentToolUseId + : null, + }; +} + +function mapContentBlock( + block: AcpContentBlock, + thought: boolean, +): ContentBlock | null { + switch (block.type) { + case "text": + return thought + ? { type: "thought", text: block.text } + : { type: "text", text: block.text }; + case "image": + return { type: "image", mimeType: block.mimeType, data: block.data }; + case "resource_link": + return { + type: "resource", + uri: block.uri, + name: block.name ?? null, + mimeType: block.mimeType ?? null, + }; + case "resource": + return { + type: "resource", + uri: block.resource.uri, + name: null, + mimeType: block.resource.mimeType ?? null, + }; + case "audio": + // No canonical audio block; a deliberate reduction, not an accident. + return null; + default: + block satisfies never; + return null; + } +} + +function mapToolCallStatus(status: AcpToolCallStatus): ToolCallState { + switch (status) { + case "pending": + return "requested"; + case "in_progress": + return "running"; + case "completed": + return "succeeded"; + case "failed": + return "failed"; + default: + status satisfies never; + return "failed"; + } +} + +function mapStopReason(reason: AcpStopReason | null): StopReason { + switch (reason) { + case "end_turn": + return "endTurn"; + case "max_tokens": + return "maxTokens"; + case "refusal": + return "refusal"; + case "cancelled": + return "cancelled"; + case "max_turn_requests": + case null: + return "other"; + default: + reason satisfies never; + return "other"; + } +} + +function mapPermissionOptionKind( + kind: AcpPermissionOptionKind | undefined, +): "allowOnce" | "allowAlways" | "rejectOnce" | "rejectAlways" | "other" { + switch (kind) { + case "allow_once": + return "allowOnce"; + case "allow_always": + return "allowAlways"; + case "reject_once": + return "rejectOnce"; + case "reject_always": + return "rejectAlways"; + default: + return "other"; + } +} + +function mapReasonToError(reason: string, occurredAt: number): SessionError { + if (/auth|login|credential/i.test(reason)) { + return { + code: "AUTH_REQUIRED", + retryable: false, + recovery: "reauthenticate", + occurredAt, + }; + } + return { + code: "ADAPTER_PROTOCOL_ERROR", + retryable: true, + recovery: "retry", + occurredAt, + }; +} + +function adapterDeadError(occurredAt: number): SessionError { + return { + code: "ADAPTER_UNAVAILABLE", + retryable: false, + recovery: "startNewSession", + occurredAt, + }; +} + +export function settingsFromScopedState( + state: SessionScopedState, +): SessionSettings { + const configuration: Record = {}; + for (const option of state.configOptions) { + configuration[option.id] = option.currentValue; + } + return { + activeModel: pickConfigValue(state.configOptions, "model", "model"), + activeMode: + state.currentMode?.currentModeId ?? + pickConfigValue(state.configOptions, "mode", "mode"), + effort: pickConfigValue(state.configOptions, "thought_level", "effort"), + configuration, + }; +} + +function pickConfigValue( + options: SessionConfigOption[], + category: string, + id: string, +): string | null { + const match = + options.find((option) => option.category === category) ?? + options.find((option) => option.id === id); + return match && typeof match.currentValue === "string" + ? match.currentValue + : null; +} + +const MAX_SETTING_OPTIONS = 100; +const MAX_SETTING_OPTION_CHOICES = 100; +const MAX_CHOICE_NAME = 1_024; +const MAX_CHOICE_DESCRIPTION = 4_096; +const MAX_CHOICE_VALUE = 256; + +/** + * Settings catalogs for the composed session entity: the harness's select + * config options with grouped choices flattened, plus a mode catalog + * synthesized from `currentMode` when the adapter does not mirror it as a + * config option. Boolean toggles are skipped — a deliberate v1 reduction; + * `settings.configuration` still carries their current values. + */ +export function settingOptionsFromScopedState( + state: SessionScopedState, +): SettingOption[] { + const options: SettingOption[] = []; + for (const option of state.configOptions) { + if (option.type !== "select") continue; + options.push({ + id: option.id, + name: clamp(option.name || option.id, MAX_CHOICE_NAME), + kind: mapOptionKind(option), + currentValue: option.currentValue, + options: flattenSelectChoices(option.options), + }); + } + if ( + !options.some((option) => option.kind === "mode") && + state.currentMode && + state.currentMode.availableModes.length > 0 + ) { + const currentMode = state.currentMode; + options.push({ + id: "mode", + name: "Mode", + kind: "mode", + currentValue: currentMode.currentModeId, + options: currentMode.availableModes + .slice(0, MAX_SETTING_OPTION_CHOICES) + .map((mode) => ({ + value: mode.id, + name: clamp(mode.name || mode.id, MAX_CHOICE_NAME), + description: mode.description + ? clamp(mode.description, MAX_CHOICE_DESCRIPTION) + : null, + })), + }); + } + return options.slice(0, MAX_SETTING_OPTIONS); +} + +function mapOptionKind(option: SessionConfigOption): SettingOption["kind"] { + // Same category-first, id-fallback resolution the settings values and the + // updateSession config-option lookup use, so the three never disagree on + // which option backs which settings field. + switch (option.category) { + case "model": + return "model"; + case "mode": + return "mode"; + case "thought_level": + return "effort"; + default: + break; + } + switch (option.id) { + case "model": + return "model"; + case "mode": + return "mode"; + case "effort": + return "effort"; + default: + return "other"; + } +} + +function flattenSelectChoices( + choices: SessionConfigSelectOptions, +): SettingOptionChoice[] { + const flat = choices.flatMap((entry) => + "group" in entry ? entry.options.map(mapChoice) : [mapChoice(entry)], + ); + return flat + .filter( + (choice) => + choice.value.length > 0 && choice.value.length <= MAX_CHOICE_VALUE, + ) + .slice(0, MAX_SETTING_OPTION_CHOICES); +} + +function mapChoice(choice: { + value: string; + name: string; + description?: string | null; +}): SettingOptionChoice { + return { + value: choice.value, + name: clamp(choice.name || choice.value, MAX_CHOICE_NAME), + description: choice.description + ? clamp(choice.description, MAX_CHOICE_DESCRIPTION) + : null, + }; +} + +function clamp(text: string, limit: number): string { + return text.length > limit ? text.slice(0, limit) : text; +} + +function truncate(text: string): string { + return text.length > SHORT_TEXT_LIMIT + ? text.slice(0, SHORT_TEXT_LIMIT) + : text; +} + +/** + * One canonical event must stay far below the sync frame budget (1 MiB), or + * a single giant tool payload would force-close every subscriber's socket on + * delivery. Oversize values are dropped, not truncated: a prefix of a JSON + * document is not JSON. + */ +const MAX_RAW_JSON_BYTES = 256 * 1024; + +/** + * Adapter raw input/output is arbitrary; the canonical log stores JSON. + * Round-tripping through JSON strips functions/undefined/cycles — anything + * unserializable or beyond the size budget becomes null. + */ +function toJsonValue(raw: unknown): JsonValue { + if (raw === undefined) return null; + try { + const serialized = JSON.stringify(raw); + if (serialized === undefined || serialized.length > MAX_RAW_JSON_BYTES) { + return null; + } + return JSON.parse(serialized) ?? null; + } catch { + return null; + } +} diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index dd899df6f29..da7bc041932 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -119,5 +119,8 @@ export type { ProjectNotSetupCause, TeardownFailureCause, } from "./error-types"; -// INTERIM cross-runtime types via dist-types — see docs/interim-router-types.md +// INTERIM: AppRouter is re-exported from compiled dist-types declarations so +// non-Node clients (Expo/RN mobile) can consume it without typechecking this +// package's Node-flavored source. Goes away when the wire contract moves to a +// neutral package. export type { AppRouter } from "./router"; diff --git a/packages/host-service/src/trpc/router/acp-sessions/acp-sessions.ts b/packages/host-service/src/trpc/router/acp-sessions/acp-sessions.ts deleted file mode 100644 index fefd279786c..00000000000 --- a/packages/host-service/src/trpc/router/acp-sessions/acp-sessions.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { - cancelInput, - createSessionInput, - decodeMessagesCursor, - getMessagesInput, - getSessionInput, - listSessionsInput, - promptInput, - respondToPermissionInput, - setConfigOptionInput, - setModeInput, -} from "@superset/session-protocol"; -import { TRPCError } from "@trpc/server"; -import { - AcpSessionDeadError, - AcpSessionNotFoundError, - AcpWorkspaceMismatchError, -} from "../../../runtime/acp-sessions"; -import { protectedProcedure, router } from "../../index"; - -/** - * Every ACP procedure except `list` sits behind the pre-release feature gate - * (see HostServiceRuntime.acpSessionsEnabled) — a disabled host rejects the - * surface with PRECONDITION_FAILED instead of exposing half-shipped behavior. - * `list` stays ungated and answers `enabled: false` so clients can feature- - * detect from the call they already make, without an extra request or error. - */ -const gatedProcedure = protectedProcedure.use(({ ctx, next }) => { - if (!ctx.runtime.acpSessionsEnabled) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: - "ACP sessions are disabled on this host (requires a canary build of the desktop app)", - }); - } - return next(); -}); - -function rethrowMapped(error: unknown): never { - if (error instanceof AcpSessionNotFoundError) { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - if (error instanceof AcpSessionDeadError) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: error.message, - }); - } - if (error instanceof AcpWorkspaceMismatchError) { - throw new TRPCError({ code: "CONFLICT", message: error.message }); - } - throw error; -} - -/** - * ACP session surface (docs/acp-sessions.md). Thin passthrough to - * `ctx.runtime.acpSessions` — inputs come from `@superset/session-protocol` - * so mobile and host validate against the same schemas. Fully parallel to the - * mastra `chat` router, which stays untouched. - */ -export const acpSessionsRouter = router({ - list: protectedProcedure.input(listSessionsInput).query(({ ctx, input }) => { - if (!ctx.runtime.acpSessionsEnabled) { - return { items: [], nextCursor: null, enabled: false }; - } - return ctx.runtime.acpSessions.list(input); - }), - - create: gatedProcedure - .input(createSessionInput) - .mutation(async ({ ctx, input }) => { - try { - return await ctx.runtime.acpSessions.create(input); - } catch (error) { - rethrowMapped(error); - } - }), - - get: gatedProcedure.input(getSessionInput).query(({ ctx, input }) => { - try { - return ctx.runtime.acpSessions.get(input.sessionId); - } catch (error) { - rethrowMapped(error); - } - }), - - // Every live-path procedure below awaits ensureLive first: a session - // persisted before a host restart is `offline` until something needs its - // adapter, at which point the manager resurrects it via session/load. - // `list` and `get` stay passive so browsing sessions spawns nothing. - getMessages: gatedProcedure - .input(getMessagesInput) - .query(async ({ ctx, input }) => { - let beforeSeq: number | undefined; - if (input.cursor !== undefined) { - const decoded = decodeMessagesCursor(input.cursor); - if (decoded === null) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid messages cursor: ${input.cursor}`, - }); - } - beforeSeq = decoded; - } - try { - await ctx.runtime.acpSessions.ensureLive(input.sessionId); - return ctx.runtime.acpSessions.getMessages({ - sessionId: input.sessionId, - beforeSeq, - limit: input.limit, - }); - } catch (error) { - rethrowMapped(error); - } - }), - - // Acks admission only — turn progress and completion ride the WS stream. - // Never await the turn here: it can block on human permission decisions - // far beyond the relay's buffered-HTTP timeout. - prompt: gatedProcedure.input(promptInput).mutation(async ({ ctx, input }) => { - try { - await ctx.runtime.acpSessions.ensureLive(input.sessionId); - const { accepted } = ctx.runtime.acpSessions.prompt(input); - return { accepted }; - } catch (error) { - rethrowMapped(error); - } - }), - - respondToPermission: gatedProcedure - .input(respondToPermissionInput) - .mutation(async ({ ctx, input }) => { - try { - await ctx.runtime.acpSessions.ensureLive(input.sessionId); - return ctx.runtime.acpSessions.respondToPermission(input); - } catch (error) { - rethrowMapped(error); - } - }), - - cancel: gatedProcedure.input(cancelInput).mutation(async ({ ctx, input }) => { - try { - await ctx.runtime.acpSessions.ensureLive(input.sessionId); - await ctx.runtime.acpSessions.cancel(input); - } catch (error) { - rethrowMapped(error); - } - }), - - setMode: gatedProcedure - .input(setModeInput) - .mutation(async ({ ctx, input }) => { - try { - await ctx.runtime.acpSessions.ensureLive(input.sessionId); - await ctx.runtime.acpSessions.setMode(input); - } catch (error) { - rethrowMapped(error); - } - }), - - setConfigOption: gatedProcedure - .input(setConfigOptionInput) - .mutation(async ({ ctx, input }) => { - try { - await ctx.runtime.acpSessions.ensureLive(input.sessionId); - await ctx.runtime.acpSessions.setConfigOption(input); - } catch (error) { - rethrowMapped(error); - } - }), -}); diff --git a/packages/host-service/src/trpc/router/acp-sessions/index.ts b/packages/host-service/src/trpc/router/acp-sessions/index.ts deleted file mode 100644 index ca30088e4f9..00000000000 --- a/packages/host-service/src/trpc/router/acp-sessions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { acpSessionsRouter } from "./acp-sessions"; diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index d2f457c94ed..0a92254291b 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -1,5 +1,4 @@ import { router } from "../index"; -import { acpSessionsRouter } from "./acp-sessions"; import { agentsRouter } from "./agents"; import { attachmentsRouter } from "./attachments"; import { authRouter } from "./auth"; @@ -16,6 +15,7 @@ import { notificationsRouter } from "./notifications"; import { portsRouter } from "./ports"; import { projectRouter } from "./project"; import { pullRequestsRouter } from "./pull-requests"; +import { sessionsRouter } from "./sessions"; import { settingsRouter } from "./settings"; import { terminalRouter } from "./terminal"; import { terminalAgentsRouter } from "./terminal-agents"; @@ -25,7 +25,6 @@ import { workspaceCreationRouter } from "./workspace-creation"; import { workspacesRouter } from "./workspaces"; export const appRouter = router({ - acpSessions: acpSessionsRouter, agents: agentsRouter, attachments: attachmentsRouter, auth: authRouter, @@ -42,6 +41,7 @@ export const appRouter = router({ pullRequests: pullRequestsRouter, project: projectRouter, ports: portsRouter, + sessions: sessionsRouter, settings: settingsRouter, terminal: terminalRouter, terminalAgents: terminalAgentsRouter, diff --git a/packages/host-service/src/trpc/router/sessions/index.ts b/packages/host-service/src/trpc/router/sessions/index.ts new file mode 100644 index 00000000000..8cbdc685db3 --- /dev/null +++ b/packages/host-service/src/trpc/router/sessions/index.ts @@ -0,0 +1 @@ +export { sessionsRouter } from "./sessions"; diff --git a/packages/host-service/src/trpc/router/sessions/sessions.ts b/packages/host-service/src/trpc/router/sessions/sessions.ts new file mode 100644 index 00000000000..58d933ce68a --- /dev/null +++ b/packages/host-service/src/trpc/router/sessions/sessions.ts @@ -0,0 +1,206 @@ +import { + cancelTurnInputSchema, + cancelTurnReceiptSchema, + createSessionInputSchema, + createSessionResultSchema, + eventsWindowSchema, + getEventsInputSchema, + getSessionInputSchema, + hostSnapshotSchema, + resolvePermissionInputSchema, + resolvePermissionReceiptSchema, + resolveToolCallInputSchema, + resolveToolCallReceiptSchema, + sessionSnapshotSchema, + submitTurnInputSchema, + submitTurnReceiptSchema, + updateSessionInputSchema, + updateSessionReceiptSchema, +} from "@superset/host-service-sync/protocol"; +import { TRPCError } from "@trpc/server"; +import { + AcpSessionDeadError, + AcpSessionNotFoundError, + AcpWorkspaceMismatchError, +} from "../../../runtime/acp-sessions"; +import { + CanonicalSessionsError, + type CanonicalSessionsErrorCode, +} from "../../../runtime/sessions"; +import { protectedProcedure, router } from "../../index"; + +/** + * Same pre-release feature gate as the raw acpSessions router: the canonical + * surface is a projection over the same adapter processes, so it ships behind + * the same flag. `list` stays ungated and answers the empty host snapshot so + * clients can probe without erroring. + */ +const gatedProcedure = protectedProcedure.use(({ ctx, next }) => { + if (!ctx.runtime.acpSessionsEnabled) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Host sessions are disabled on this host (requires a canary build of the desktop app)", + }); + } + return next(); +}); + +type TrpcErrorCode = ConstructorParameters[0]["code"]; + +const CODE_MAP: Record = { + NOT_FOUND: "NOT_FOUND", + BAD_REQUEST: "BAD_REQUEST", + NOT_IMPLEMENTED: "METHOD_NOT_SUPPORTED", + PRECONDITION_FAILED: "PRECONDITION_FAILED", + CONFLICT: "CONFLICT", + INTERNAL: "INTERNAL_SERVER_ERROR", +}; + +function rethrowMapped(error: unknown): never { + if (error instanceof CanonicalSessionsError) { + throw new TRPCError({ code: CODE_MAP[error.code], message: error.message }); + } + // The runtime lets manager errors escape untranslated (unknown session, + // dead adapter, cross-workspace id reuse) — same mapping as acpSessions. + if (error instanceof AcpSessionNotFoundError) { + throw new TRPCError({ code: "NOT_FOUND", message: error.message }); + } + if (error instanceof AcpSessionDeadError) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: error.message, + }); + } + if (error instanceof AcpWorkspaceMismatchError) { + throw new TRPCError({ code: "CONFLICT", message: error.message }); + } + throw error; +} + +/** + * Canonical Host Sessions surface (plans/host-sessions-sync.md) — the + * agent-agnostic session/thread/event model that mobile and web sync against. + * Inputs AND outputs are validated against the shared protocol schemas from + * `@superset/host-service-sync`, so a host that drifts from the contract + * fails loudly at the boundary instead of corrupting client stores. + * + * `list` and `get` ARE the snapshots: they carry the `head` cursor the sync + * socket subscribes from. Mutations return admission receipts; completion + * arrives as events whose causationId echoes the requestId. + */ +export const sessionsRouter = router({ + list: protectedProcedure.output(hostSnapshotSchema).query(({ ctx }) => { + const hub = ctx.runtime.sessionsSyncHub; + if (!ctx.runtime.acpSessionsEnabled || !hub) { + return { + sessions: [], + pendingPermissions: [], + openClientToolCalls: [], + head: null, + }; + } + try { + // Head first: an event folding between the two reads then lands in + // both the snapshot and the replay, which host-event idempotence + // absorbs — the reverse order could lose it. + const head = hub.hostHead(); + return { ...ctx.runtime.sessions.hostSnapshotData(), head }; + } catch (error) { + rethrowMapped(error); + } + }), + + create: gatedProcedure + .input(createSessionInputSchema) + .output(createSessionResultSchema) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.createSession(input); + } catch (error) { + rethrowMapped(error); + } + }), + + get: gatedProcedure + .input(getSessionInputSchema) + .output(sessionSnapshotSchema) + .query(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.getSession(input); + } catch (error) { + rethrowMapped(error); + } + }), + + update: gatedProcedure + .input(updateSessionInputSchema) + .output(updateSessionReceiptSchema) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.updateSession(input); + } catch (error) { + rethrowMapped(error); + } + }), + + getEvents: gatedProcedure + .input(getEventsInputSchema) + .output(eventsWindowSchema) + .query(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.getEvents(input); + } catch (error) { + rethrowMapped(error); + } + }), + + // Acks admission only — turn progress rides /sessions/sync. Never await + // the turn here: it can block on human permission decisions far beyond + // the relay's buffered-HTTP timeout. + submitTurn: gatedProcedure + .input(submitTurnInputSchema) + .output(submitTurnReceiptSchema) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.submitTurn(input); + } catch (error) { + rethrowMapped(error); + } + }), + + cancelTurn: gatedProcedure + .input(cancelTurnInputSchema) + .output(cancelTurnReceiptSchema) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.cancelTurn(input); + } catch (error) { + rethrowMapped(error); + } + }), + + resolvePermission: gatedProcedure + .input(resolvePermissionInputSchema) + .output(resolvePermissionReceiptSchema) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.resolvePermission(input); + } catch (error) { + rethrowMapped(error); + } + }), + + // No claim step: any capable client answers; first write wins and later + // resolves surface the host's stale error. + resolveToolCall: gatedProcedure + .input(resolveToolCallInputSchema) + .output(resolveToolCallReceiptSchema) + .mutation(async ({ ctx, input }) => { + try { + return await ctx.runtime.sessions.resolveToolCall(input); + } catch (error) { + rethrowMapped(error); + } + }), +}); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 6cfb24b9e37..4b4bd994798 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -4,29 +4,43 @@ import type { AppRouter } from "@superset/trpc"; import type { TRPCClient } from "@trpc/client"; import type { HostDb } from "./db"; import type { EventBus } from "./events"; -import type { AcpSessionManager } from "./runtime/acp-sessions"; import type { ChatRuntimeManager } from "./runtime/chat"; import type { WorkspaceFilesystemManager } from "./runtime/filesystem"; import type { GitCredentialProvider, GitFactory } from "./runtime/git"; import type { PullRequestRuntimeManager } from "./runtime/pull-requests"; +import type { + CanonicalSessionsRuntime, + SessionsSyncHub, +} from "./runtime/sessions"; import type { TerminalAgentStore } from "./terminal-agents"; import type { ExecGh } from "./trpc/router/workspace-creation/utils/exec-gh"; export type ApiClient = TRPCClient; export interface HostServiceRuntime { - acpSessions: AcpSessionManager; /** - * Feature gate for the pre-release ACP session harness. Off by default; - * app.ts turns it on via SUPERSET_ACP_SESSIONS=1 (or a test-injected - * manager). When off, the acpSessions router rejects every call and the - * WS stream route is not registered. + * Feature gate for the pre-release canonical sessions surface. Off by + * default; app.ts turns it on via SUPERSET_ACP_SESSIONS=1 (or a + * test-injected manager). When off, the sessions router rejects every + * call and /sessions/sync is not registered. */ acpSessionsEnabled: boolean; auth: ChatService; chat: ChatRuntimeManager; filesystem: WorkspaceFilesystemManager; pullRequests: PullRequestRuntimeManager; + /** + * Canonical Host Sessions projection over the ACP session manager — + * serves the `sessions.*` router and /sessions/sync. Shares the + * acpSessionsEnabled feature gate. + */ + sessions: CanonicalSessionsRuntime; + /** + * The /sessions/sync hub, when the gate is on. `sessions.list` stamps its + * host-stream head onto the tRPC host snapshot so clients subscribe from + * exactly where the snapshot was taken. + */ + sessionsSyncHub: SessionsSyncHub | null; } export interface HostServiceContext { diff --git a/packages/host-service/test/helpers/createTestHost.ts b/packages/host-service/test/helpers/createTestHost.ts index d2f43ab1d6c..19a1bcb4576 100644 --- a/packages/host-service/test/helpers/createTestHost.ts +++ b/packages/host-service/test/helpers/createTestHost.ts @@ -1,7 +1,7 @@ import { Database as BunDatabase } from "bun:sqlite"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { createTRPCClient, httpBatchLink } from "@trpc/client"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; @@ -43,11 +43,20 @@ export interface TestHostOptions { chatService?: unknown; /** Injecting a manager also opens the acpSessions feature gate (app.ts). */ acpSessions?: unknown; + /** + * Reuse a specific SQLite file instead of a throwaway temp dir. Reboot + * tests point successive hosts at the same path so durable state + * (session registry, canonical session metadata) survives; dispose() + * then leaves the file in place and the caller owns cleanup. + */ + dbPath?: string; } export interface TestHost { app: CreateAppResult["app"]; api: CreateAppResult["api"]; + /** Attach the app's WebSocket routes to a real `node:http` server. */ + injectWebSocket: CreateAppResult["injectWebSocket"]; db: HostDb; dispose: () => Promise; psk: string; @@ -80,8 +89,15 @@ export async function createTestHost( options: TestHostOptions = {}, ): Promise { const psk = options.psk ?? "test-psk-secret"; - const dataDir = mkdtempSync(join(tmpdir(), "host-service-test-db-")); - const dbPath = join(dataDir, "host.db"); + // A caller-provided dbPath is durable across hosts (reboot tests); only a + // temp dir we minted ourselves is removed on dispose. + const dataDir = options.dbPath + ? null + : mkdtempSync(join(tmpdir(), "host-service-test-db-")); + const dbPath = options.dbPath ?? join(dataDir ?? "", "host.db"); + if (options.dbPath) { + mkdirSync(dirname(options.dbPath), { recursive: true }); + } const sqlite = new BunDatabase(dbPath, { create: true, readwrite: true }); sqlite.exec("PRAGMA journal_mode = WAL"); @@ -167,10 +183,12 @@ export async function createTestHost( } catch { // best-effort } - try { - rmSync(dataDir, { recursive: true, force: true }); - } catch { - // best-effort + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch { + // best-effort + } } } }; @@ -178,6 +196,7 @@ export async function createTestHost( return { app: result.app, api: fakeApi.client, + injectWebSocket: result.injectWebSocket, db: db as unknown as HostDb, dispose, psk, diff --git a/packages/host-service/test/helpers/gym.ts b/packages/host-service/test/helpers/gym.ts new file mode 100644 index 00000000000..9829bc947cb --- /dev/null +++ b/packages/host-service/test/helpers/gym.ts @@ -0,0 +1,178 @@ +/** + * The "gym" — a disposable fixture repository for real-Claude E2E runs. + * + * Layout and contents are pinned by the E2E User Story in + * plans/host-sessions-sync.md ("The gym project"): every file exists so a + * specific harness behavior can be exercised and asserted with exact + * sentinels. Keep the fixture ids and sentinels in sync with that doc and + * with acp-sessions.integration.test.ts (which uses the same workflow). + */ +import { execSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const GYM_NOTES_CONTENT = + "fixture_id=sonnet-workflow\nvalues=13,29\nexpected_sum=42\n"; + +export const GYM_WORKFLOW_NAME = "acp-e2e-dummy"; + +/** Printed by scripts/ok.sh; replies quoting the script output contain it. */ +export const GYM_SCRIPT_SENTINEL = "GYM_SCRIPT_OK"; + +/** The gym-check skill instructs the model to reply with exactly this. */ +export const GYM_SKILL_SENTINEL = "GYM_CHECK_OK 42"; + +export const GYM_WORKFLOW_SOURCE = `export const meta = { + name: "acp-e2e-dummy", + description: "Run a multi-agent inspect, analyze, audit, and verify workflow", + phases: [ + { title: "Inspect", detail: "Parse the fixture through a subagent" }, + { title: "Analyze", detail: "Derive an independent proof" }, + { title: "Audit", detail: "Cross-check the source and proof in parallel" }, + { title: "Verify", detail: "Converge on a structured verdict" }, + ], +} + +phase("Inspect") +const inspected = await agent( + "Read notes.txt. Return its fixture_id, the two integer values, and their computed sum. Do not modify files and do not use Bash.", + { + label: "inspect-fixture", + schema: { + type: "object", + additionalProperties: false, + required: ["fixtureId", "values", "sum"], + properties: { + fixtureId: { type: "string" }, + values: { type: "array", items: { type: "integer" }, minItems: 2, maxItems: 2 }, + sum: { type: "integer" }, + }, + }, + }, +) + +phase("Analyze") +const analyzed = await agent( + "Using this inspected data: " + JSON.stringify(inspected) + + ", independently read notes.txt, verify it matches, and derive the sum, product, and the exact equation 13 + 29 = 42. Do not modify files and do not use Bash.", + { + label: "derive-proof", + schema: { + type: "object", + additionalProperties: false, + required: ["fixtureId", "sum", "product", "equation"], + properties: { + fixtureId: { type: "string" }, + sum: { type: "integer" }, + product: { type: "integer" }, + equation: { type: "string" }, + }, + }, + }, +) + +phase("Audit") +const audits = await pipeline(["source", "arithmetic"], angle => + agent( + "Independently audit the " + angle + + " side of this fixture. Read notes.txt and cross-check inspected=" + + JSON.stringify(inspected) + ", analyzed=" + JSON.stringify(analyzed) + + ". Verify fixtureId sonnet-workflow, values 13 and 29, sum 42, product 377, and equation 13 + 29 = 42. Report concrete evidence. Do not modify files and do not use Bash.", + { + label: "audit-" + angle, + schema: { + type: "object", + additionalProperties: false, + required: ["angle", "passed", "evidence"], + properties: { + angle: { type: "string" }, + passed: { type: "boolean" }, + evidence: { type: "string" }, + }, + }, + }, + ), +) + +phase("Verify") +const verified = await agent( + "Act as the final verifier. Read notes.txt. Cross-check these prior results: inspected=" + + JSON.stringify(inspected) + ", analyzed=" + JSON.stringify(analyzed) + + ", audits=" + JSON.stringify(audits) + + ". Return valid only when both audits passed, fixtureId is sonnet-workflow, values are 13 and 29, sum is 42, product is 377, and the equation is exact. When valid, set marker exactly WORKFLOW_VERIFIED. Do not modify files and do not use Bash.", + { + label: "verify-proof", + schema: { + type: "object", + additionalProperties: false, + required: ["valid", "fixtureId", "sum", "auditCount", "marker"], + properties: { + valid: { type: "boolean" }, + fixtureId: { type: "string" }, + sum: { type: "integer" }, + auditCount: { type: "integer" }, + marker: { type: "string" }, + }, + }, + }, +) + +return { inspected, analyzed, audits, verified } +`; + +const GYM_SKILL_SOURCE = `--- +name: gym-check +description: Verify the gym fixture notes.txt and report its sentinel. Use when asked to run the gym check. +--- + +# Gym check + +1. Read \`notes.txt\` in the workspace root. +2. If it contains \`expected_sum=42\`, reply with exactly \`${GYM_SKILL_SENTINEL}\`. +3. If it does not, reply with exactly \`GYM_CHECK_BROKEN\`. +4. Do not modify any files. +`; + +const GYM_OK_SCRIPT = `#!/bin/sh +echo ${GYM_SCRIPT_SENTINEL} +`; + +const GYM_README = `# Gym fixture + +Disposable workspace for Superset harness E2E runs. Nothing here is real +product code; every file exists to exercise one harness behavior. +`; + +/** + * Provision a fresh gym repository in a temp directory and return its path. + * Callers own cleanup (rmSync recursive) after disposing the manager. + */ +export function provisionGym(prefix = "acp-gym-"): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)); + writeFileSync(path.join(dir, "notes.txt"), GYM_NOTES_CONTENT); + writeFileSync(path.join(dir, "README.md"), GYM_README); + + const scriptsDir = path.join(dir, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + const okScript = path.join(scriptsDir, "ok.sh"); + writeFileSync(okScript, GYM_OK_SCRIPT); + chmodSync(okScript, 0o755); + + const workflowsDir = path.join(dir, ".claude", "workflows"); + mkdirSync(workflowsDir, { recursive: true }); + writeFileSync( + path.join(workflowsDir, `${GYM_WORKFLOW_NAME}.js`), + GYM_WORKFLOW_SOURCE, + ); + + const skillDir = path.join(dir, ".claude", "skills", "gym-check"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(path.join(skillDir, "SKILL.md"), GYM_SKILL_SOURCE); + + execSync( + "git init -q && git add -A && git -c user.email=gym@superset.sh -c user.name=gym commit -qm init", + { cwd: dir }, + ); + return dir; +} diff --git a/packages/host-service/test/integration/acp-host-client.e2e.test.ts b/packages/host-service/test/integration/acp-host-client.e2e.test.ts deleted file mode 100644 index 22203391d3c..00000000000 --- a/packages/host-service/test/integration/acp-host-client.e2e.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Belt-and-suspenders host-service + @superset/host-client boundary E2E. A tiny - * local relay - * mount supplies the production `/hosts/:routingKey/*` prefix, while every - * command still crosses actual HTTP/SuperJSON/tRPC and every update crosses - * an actual WebSocket. The ACP model is the deterministic stdio adapter, so - * this broad always-run suite complements but does not replace the - * ACP_E2E-gated authenticated real-Claude acceptance lane. - */ -import { Database as BunDatabase } from "bun:sqlite"; -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import type { AddressInfo } from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { type ServerType, serve } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import { - createAcpHostClient, - createHostTransport, -} from "@superset/host-client"; -import { - emptyTimeline, - foldEnvelopes, - type SessionUpdateEnvelope, -} from "@superset/session-protocol"; -import { - type SessionSubscription, - subscribeToSession, -} from "@superset/session-protocol/client"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { Hono } from "hono"; -import type { HostDb } from "../../src/db"; -import * as schema from "../../src/db/schema"; -import { - AcpSessionManager, - registerAcpSessionStreamRoute, - SqliteAcpSessionPersistence, -} from "../../src/runtime/acp-sessions"; -import { createTestHost, type TestHost } from "../helpers/createTestHost"; - -const FAKE_ADAPTER = path.join( - import.meta.dir, - "../fixtures/fake-acp-adapter.ts", -); -const MIGRATIONS_FOLDER = path.resolve(import.meta.dir, "../../drizzle"); -const ROUTING_KEY = "acp-host-client-e2e"; -const WORKSPACE_ID = "acp-host-client-workspace"; -const PSK = "acp-host-client-test-psk"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitFor( - predicate: () => boolean | Promise, - timeoutMs: number, - label: string, -): Promise { - const deadline = Date.now() + timeoutMs; - while (!(await predicate())) { - if (Date.now() > deadline) { - throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); - } - await sleep(10); - } -} - -async function closeServer(server: ServerType): Promise { - ( - server as unknown as { closeAllConnections?: () => void } - ).closeAllConnections?.(); - await new Promise((resolve) => server.close(() => resolve())); -} - -interface HostGeneration { - host: TestHost; - server: ServerType; - manager: AcpSessionManager; - baseUrl: string; -} - -async function startHostGeneration( - manager: AcpSessionManager, -): Promise { - const host = await createTestHost({ acpSessions: manager, psk: PSK }); - // The real relay preserves everything after `/hosts/:routingKey`. Rewrite - // that prefix into the real host app without replacing either side's HTTP, - // auth, tRPC, or WebSocket implementation. - const relay = new Hono(); - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ - app: relay, - }); - relay.use("/hosts/:routingKey/acp-sessions/*", async (context, next) => { - if (context.req.query("token") !== PSK) { - return context.json({ error: "Unauthorized" }, 401); - } - return next(); - }); - registerAcpSessionStreamRoute({ - app: relay, - sessions: manager, - upgradeWebSocket, - path: "/hosts/:routingKey/acp-sessions/:sessionId/stream", - }); - relay.all("/hosts/:routingKey/*", (context) => { - const url = new URL(context.req.url); - const prefix = `/hosts/${context.req.param("routingKey")}`; - url.pathname = url.pathname.slice(prefix.length) || "/"; - return host.app.fetch(new Request(url, context.req.raw)); - }); - const server = await new Promise((resolve) => { - const instance = serve({ fetch: relay.fetch, port: 0 }, () => - resolve(instance), - ); - }); - injectWebSocket(server); - const { port } = server.address() as AddressInfo; - return { - host, - server, - manager, - baseUrl: `http://127.0.0.1:${port}`, - }; -} - -async function stopHostGeneration(generation: HostGeneration): Promise { - await closeServer(generation.server); - await generation.host.dispose(); -} - -describe("ACP real host + host-client boundary e2e", () => { - test("question, abort, concurrent permissions, reconnect, DB reopen, resume, and missing native transcript", async () => { - const workspaceDir = mkdtempSync( - path.join(os.tmpdir(), "acp-host-client-e2e-"), - ); - const registryPath = path.join(workspaceDir, "acp-registry.db"); - let sqlite: BunDatabase | null = null; - let generation: HostGeneration | null = null; - let subscription: SessionSubscription | null = null; - let relayUrl = ""; - const streamStatuses: string[] = []; - const streamResets: string[] = []; - const streamGaps: Array<{ expected: number; received: number }> = []; - - const openManager = () => { - sqlite = new BunDatabase(registryPath, { create: true, readwrite: true }); - sqlite.exec("PRAGMA journal_mode = WAL"); - const db = drizzle(sqlite, { schema }) as unknown as HostDb; - migrate(db as never, { migrationsFolder: MIGRATIONS_FOLDER }); - return new AcpSessionManager({ - resolveWorkspaceCwd: () => workspaceDir, - adapterEntry: FAKE_ADAPTER, - persistence: new SqliteAcpSessionPersistence(db), - }); - }; - - const restartHost = async () => { - if (subscription) { - subscription.close(); - subscription = null; - } - if (generation) { - await stopHostGeneration(generation); - generation = null; - } - if (sqlite) { - sqlite.close(); - sqlite = null; - } - const manager = openManager(); - generation = await startHostGeneration(manager); - relayUrl = generation.baseUrl; - return generation; - }; - - const transport = createHostTransport({ - getRelayUrl: () => relayUrl, - getToken: async () => PSK, - }); - const client = createAcpHostClient(transport); - const api = client.sessionsApi(ROUTING_KEY); - - try { - await restartHost(); - const sessionId = "host-client-main"; - const initialList = await client.listSessions(ROUTING_KEY, { - workspaceId: WORKSPACE_ID, - }); - expect(initialList).toEqual({ - items: [], - nextCursor: null, - enabled: true, - }); - const created = await client.createSession(ROUTING_KEY, { - sessionId, - workspaceId: WORKSPACE_ID, - }); - expect(created.status).toBe("idle"); - - const beforeDisconnect: SessionUpdateEnvelope[] = []; - subscription = subscribeToSession({ - streamUrl: client.streamUrl({ routingKey: ROUTING_KEY, sessionId }), - since: 0, - onEnvelope: (envelope) => beforeDisconnect.push(envelope), - onStatus: (status) => streamStatuses.push(status), - onReset: (reason) => streamResets.push(reason), - onGap: (gap) => streamGaps.push(gap), - }); - - // Drive an AskUserQuestion-shaped form through the public client. The - // question must arrive as a pending permission, accept the selected - // option, and finish through the live WebSocket stream. - expect( - await api.prompt({ - sessionId, - prompt: [ - { - type: "text", - text: "ask-single Pick a fixture color|red,blue", - }, - ], - }), - ).toEqual({ accepted: true }); - await waitFor( - async () => - (await api.get({ sessionId })).pendingPermissions.length === 1, - 10_000, - "the question through host-client", - ); - const question = (await api.get({ sessionId })).pendingPermissions[0]; - if (!question) throw new Error("question card disappeared"); - expect(question.toolCall.title).toBe("Pick a fixture color"); - const blue = question.options.find((option) => option.name === "blue"); - if (!blue) throw new Error("blue question option disappeared"); - expect( - await api.respondToPermission({ - sessionId, - requestId: question.requestId, - outcome: { outcome: "selected", optionId: blue.optionId }, - }), - ).toEqual({ status: "resolved" }); - await waitFor( - async () => (await api.get({ sessionId })).status === "idle", - 10_000, - "the answered question turn to finish", - ); - await waitFor( - () => - foldEnvelopes(emptyTimeline(), beforeDisconnect).items.some( - (item) => - item.kind === "message" && - item.role === "agent" && - item.blocks.some( - (block) => - block.type === "text" && block.text.includes("picked:blue"), - ), - ), - 10_000, - "the question answer on the relay WebSocket", - ); - - // Abort an in-flight tool after it has started. This crosses the same - // host-client command path the mobile Stop button uses and must leave no - // running tool or pending interaction behind. - expect( - await api.prompt({ - sessionId, - prompt: [{ type: "text", text: "hang" }], - }), - ).toEqual({ accepted: true }); - await waitFor( - async () => (await api.get({ sessionId })).status === "running", - 10_000, - "the cancellable turn to start", - ); - await api.cancel({ sessionId }); - await waitFor( - async () => { - const state = await api.get({ sessionId }); - return ( - state.status === "idle" && state.lastStopReason === "cancelled" - ); - }, - 10_000, - "the host-client cancellation to settle", - ); - const afterCancel = foldEnvelopes( - emptyTimeline(), - (await api.getMessages({ sessionId, limit: 200 })).items, - ); - const cancelledTool = afterCancel.items.find( - (item) => item.kind === "tool_call" && item.call.title === "hang", - ); - if (!cancelledTool || cancelledTool.kind !== "tool_call") { - throw new Error("cancelled tool call missing from timeline"); - } - expect(cancelledTool.call.status).toBe("failed"); - expect((await api.get({ sessionId })).pendingPermissions).toEqual([]); - - expect( - await api.prompt({ - sessionId, - prompt: [ - { type: "text", text: "permissions first-write,second-delete" }, - ], - }), - ).toEqual({ accepted: true }); - await waitFor( - async () => - (await api.get({ sessionId })).pendingPermissions.length === 2, - 10_000, - "two permissions through host-client", - ); - try { - await waitFor( - () => - beforeDisconnect.filter( - (envelope) => envelope.frame.kind === "permission_requested", - ).length === 3, - 10_000, - "the question and both permission cards on the relay WebSocket", - ); - } catch (cause) { - throw new Error( - `${cause instanceof Error ? cause.message : String(cause)}; statuses=${JSON.stringify(streamStatuses)} resets=${JSON.stringify(streamResets)} gaps=${JSON.stringify(streamGaps)} frames=${JSON.stringify(beforeDisconnect.map((envelope) => [envelope.seq, envelope.frame.kind]))}`, - ); - } - const pending = (await api.get({ sessionId })).pendingPermissions; - const [first, second] = pending; - if (!first || !second) throw new Error("permission cards disappeared"); - const disconnectSeq = beforeDisconnect.at(-1)?.seq; - if (!disconnectSeq) throw new Error("stream had no disconnect cursor"); - subscription.close(); - subscription = null; - - // Resolve while the client is disconnected; reconnect must catch up from - // the last seen cursor with no duplicate or missing envelope. - expect( - await api.respondToPermission({ - sessionId, - requestId: second.requestId, - outcome: { outcome: "selected", optionId: "reject" }, - }), - ).toEqual({ status: "resolved" }); - expect( - await api.respondToPermission({ - sessionId, - requestId: first.requestId, - outcome: { outcome: "selected", optionId: "allow" }, - }), - ).toEqual({ status: "resolved" }); - await waitFor( - async () => (await api.get({ sessionId })).status === "idle", - 10_000, - "the permission turn to finish while disconnected", - ); - - const afterReconnect: SessionUpdateEnvelope[] = []; - subscription = subscribeToSession({ - streamUrl: client.streamUrl({ routingKey: ROUTING_KEY, sessionId }), - since: disconnectSeq, - onEnvelope: (envelope) => afterReconnect.push(envelope), - }); - await waitFor( - () => - afterReconnect.some( - (envelope) => - envelope.frame.kind === "state" && - envelope.frame.state.status === "idle", - ), - 10_000, - "the disconnected tail after reconnect", - ); - expect(afterReconnect[0]?.seq).toBe(disconnectSeq + 1); - for (let index = 1; index < afterReconnect.length; index += 1) { - expect(afterReconnect[index]?.seq).toBe( - (afterReconnect[index - 1]?.seq ?? 0) + 1, - ); - } - expect( - afterReconnect.filter( - (envelope) => envelope.frame.kind === "permission_resolved", - ), - ).toHaveLength(2); - - // Close the app, server, adapter, and SQLite handle; then reopen the same - // file. The host-client sees the persisted row passively as offline and a - // normal history read resurrects it through session/load. - await restartHost(); - const afterRestart = await client.listSessions(ROUTING_KEY, { - workspaceId: WORKSPACE_ID, - }); - expect( - afterRestart.items.find((item) => item.sessionId === sessionId)?.status, - ).toBe("offline"); - const replayed = await api.getMessages({ sessionId, limit: 200 }); - const replayedTimeline = foldEnvelopes(emptyTimeline(), replayed.items); - const replayedText = replayedTimeline.items - .filter((item) => item.kind === "message") - .flatMap((item) => (item.kind === "message" ? item.blocks : [])) - .map((block) => (block.type === "text" ? block.text : "")) - .join("\n"); - expect(replayedText).toContain("permissions first-write,second-delete"); - expect((await api.get({ sessionId })).status).toBe("idle"); - - // Create a second durable row, then remove only its harness-owned native - // transcript before another full DB/app/server reopen. - const brokenSessionId = "host-client-missing-native"; - await client.createSession(ROUTING_KEY, { - sessionId: brokenSessionId, - workspaceId: WORKSPACE_ID, - }); - await api.prompt({ - sessionId: brokenSessionId, - prompt: [{ type: "text", text: "say doomed-native-session" }], - }); - await waitFor( - async () => - (await api.get({ sessionId: brokenSessionId })).lastStopReason === - "end_turn", - 10_000, - "the soon-to-be-missing native session to finish", - ); - if (!sqlite) throw new Error("registry SQLite unexpectedly closed"); - const currentDb = drizzle(sqlite, { schema }) as unknown as HostDb; - const brokenRecord = new SqliteAcpSessionPersistence(currentDb) - .loadAll() - .find((record) => record.sessionId === brokenSessionId); - if (!brokenRecord) throw new Error("broken session registry row missing"); - await restartHost(); - rmSync( - path.join( - workspaceDir, - ".fake-acp-store", - `${brokenRecord.acpSessionId}.jsonl`, - ), - ); - - await expect( - api.getMessages({ sessionId: brokenSessionId, limit: 50 }), - ).rejects.toThrow( - `No stored session to load: ${brokenRecord.acpSessionId}`, - ); - expect((await api.get({ sessionId: brokenSessionId })).status).toBe( - "offline", - ); - - const resets: string[] = []; - subscription = subscribeToSession({ - streamUrl: client.streamUrl({ - routingKey: ROUTING_KEY, - sessionId: brokenSessionId, - }), - since: 0, - onEnvelope: () => {}, - onReset: (reason) => resets.push(reason), - }); - await waitFor( - () => resets.includes("session_load_failed"), - 10_000, - "session_load_failed through the relay WebSocket", - ); - } finally { - subscription?.close(); - if (generation) await stopHostGeneration(generation); - if (sqlite) sqlite.close(); - rmSync(workspaceDir, { recursive: true, force: true }); - } - }, 60_000); -}); diff --git a/packages/host-service/test/integration/acp-sessions-persistence.e2e.test.ts b/packages/host-service/test/integration/acp-sessions-persistence.e2e.test.ts deleted file mode 100644 index 2e392313df2..00000000000 --- a/packages/host-service/test/integration/acp-sessions-persistence.e2e.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -/** - * ACP session persistence e2e (fake adapter): the SQLite session registry - * (SqliteAcpSessionPersistence over the real migrations) carried across - * manager "restarts" — same DB handle, fresh AcpSessionManager — exercising - * the offline status, ensureLive resurrection via the adapter's - * session/load transcript replay, re-issued create() idempotency across a - * restart, failed loads staying offline, the WS stream route resurrecting - * on attach, and the tRPC router resurrecting through getMessages/prompt. - */ -import { Database as BunDatabase } from "bun:sqlite"; -import { afterAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import type { AddressInfo } from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { type ServerType, serve } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import { - emptyTimeline, - foldEnvelopes, - type SessionUpdateEnvelope, - type Timeline, -} from "@superset/session-protocol"; -import { - type SessionSubscription, - subscribeToSession, -} from "@superset/session-protocol/client"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { Hono } from "hono"; -import type { HostDb } from "../../src/db"; -import * as schema from "../../src/db/schema"; -import { - AcpSessionManager, - AcpSessionNotFoundError, - AcpWorkspaceMismatchError, - registerAcpSessionStreamRoute, - SqliteAcpSessionPersistence, -} from "../../src/runtime/acp-sessions"; -import { createTestHost, type TestHost } from "../helpers/createTestHost"; - -const FAKE_ADAPTER = path.join( - import.meta.dir, - "../fixtures/fake-acp-adapter.ts", -); -const MIGRATIONS_FOLDER = path.resolve(import.meta.dir, "../../drizzle"); -const WORKSPACE_ID = "acp-persist-workspace"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitFor( - predicate: () => boolean, - timeoutMs: number, - label: string, -): Promise { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); - } - await sleep(10); - } -} - -function messageText(timeline: Timeline, role: "agent" | "user"): string { - return timeline.items - .filter((item) => item.kind === "message" && item.role === role) - .flatMap((item) => (item.kind === "message" ? item.blocks : [])) - .map((block) => (block.type === "text" ? block.text : "")) - .join("\n"); -} - -function expectGapless(envelopes: SessionUpdateEnvelope[]): void { - expect(envelopes.length).toBeGreaterThan(0); - expect(envelopes[0]?.seq).toBe(1); - for (let i = 1; i < envelopes.length; i += 1) { - expect(envelopes[i]?.seq).toBe((envelopes[i - 1]?.seq ?? 0) + 1); - } -} - -describe("acp-sessions persistence e2e (fake adapter)", () => { - const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "acp-persist-")); - // One bun:sqlite handle for the whole suite: a "restart" is a fresh - // AcpSessionManager over the same persistence — anything a new manager - // sees must have come from the DB rows, never from manager memory. - const sqlite = new BunDatabase(":memory:"); - const db = drizzle(sqlite, { schema }) as unknown as HostDb; - migrate(db as never, { migrationsFolder: MIGRATIONS_FOLDER }); - const persistence = new SqliteAcpSessionPersistence(db); - - const managers: AcpSessionManager[] = []; - const servers: ServerType[] = []; - const subscriptions: SessionSubscription[] = []; - const hosts: TestHost[] = []; - - function newManager(options?: { journalCapacity?: number }) { - const manager = new AcpSessionManager({ - resolveWorkspaceCwd: () => workspaceDir, - adapterEntry: FAKE_ADAPTER, - persistence, - journalCapacity: options?.journalCapacity, - }); - managers.push(manager); - return manager; - } - - async function startServer(manager: AcpSessionManager): Promise { - const app = new Hono(); - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); - registerAcpSessionStreamRoute({ - app, - sessions: manager, - upgradeWebSocket, - }); - const started = await new Promise((resolve) => { - const instance = serve({ fetch: app.fetch, port: 0 }, () => - resolve(instance), - ); - }); - injectWebSocket(started); - servers.push(started); - const { port } = started.address() as AddressInfo; - return `ws://127.0.0.1:${port}`; - } - - /** Run one scripted turn and wait for it to land. */ - async function runTurn( - manager: AcpSessionManager, - sessionId: string, - text: string, - ): Promise { - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text }], - }); - const { stopReason } = await turn; - expect(stopReason).toBe("end_turn"); - } - - function foldedMessages( - manager: AcpSessionManager, - sessionId: string, - ): Timeline { - const page = manager.getMessages({ sessionId, limit: 500 }); - return foldEnvelopes(emptyTimeline(), page.items); - } - - afterAll(async () => { - for (const subscription of subscriptions.splice(0)) { - subscription.close(); - } - for (const server of servers.splice(0)) { - ( - server as unknown as { closeAllConnections?: () => void } - ).closeAllConnections?.(); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - for (const host of hosts.splice(0)) { - await host.dispose(); - } - await Promise.all(managers.splice(0).map((manager) => manager.dispose())); - sqlite.close(); - try { - rmSync(workspaceDir, { recursive: true, force: true }); - } catch { - // best-effort - } - }); - - test("a session survives a manager restart: offline in list, resurrected by ensureLive with the replayed transcript", async () => { - const sessionId = "persist-restart"; - const before = newManager(); - await before.create({ sessionId, workspaceId: WORKSPACE_ID }); - await runTurn(before, sessionId, "say hello before restart"); - await runTurn(before, sessionId, "title Persisted Title"); - const stateBefore = before.get(sessionId); - expect(stateBefore.title).toBe("Persisted Title"); - await before.dispose(); - - const after = newManager(); - // Passive reads see the registry row, not a process. - const listed = after - .list({}) - .items.find((state) => state.sessionId === sessionId); - if (!listed) throw new Error("restarted manager lost the session"); - expect(listed.status).toBe("offline"); - expect(listed.title).toBe("Persisted Title"); - expect(listed.lastStopReason).toBe("end_turn"); - expect(listed.createdAt).toBe(stateBefore.createdAt); - expect(after.get(sessionId).status).toBe("offline"); - // The manager itself stays passive — resurrection is the caller's - // explicit ensureLive (the router/stream boundaries), not getMessages. - expect(() => after.getMessages({ sessionId })).toThrow( - AcpSessionNotFoundError, - ); - - // ensureLive is idempotent under concurrency (deduped like create) and - // a no-op for ids the registry has never seen. - await Promise.all([ - after.ensureLive(sessionId), - after.ensureLive(sessionId), - after.ensureLive("never-created"), - ]); - const resurrected = after.get(sessionId); - expect(resurrected.status).toBe("idle"); - expect(resurrected.title).toBe("Persisted Title"); - expect(resurrected.createdAt).toBe(stateBefore.createdAt); - // D14-c on load: the adapter comes back in bypassPermissions; the - // manager must force it out of bypass. - expect(resurrected.currentMode?.currentModeId).toBe("default"); - // ensureLive on an already-live session must not respawn the adapter. - const pid = after.adapterPid(sessionId); - await after.ensureLive(sessionId); - expect(after.adapterPid(sessionId)).toBe(pid); - - // session/load replayed the stored transcript — both sides of the - // pre-restart conversation fold out of the fresh journal… - const timeline = foldedMessages(after, sessionId); - expect(messageText(timeline, "user")).toContain("say hello before restart"); - expect(messageText(timeline, "agent")).toContain("hello before restart"); - // …and the journal is a fresh gapless incarnation from seq 1. - const replayed: SessionUpdateEnvelope[] = []; - const unsubscribe = after.subscribe({ - sessionId, - since: 0, - onEnvelope: (envelope) => replayed.push(envelope), - }); - expectGapless(replayed); - unsubscribe(); - - // The resurrected session takes new turns. - await runTurn(after, sessionId, "say hello after restart"); - expect(messageText(foldedMessages(after, sessionId), "agent")).toContain( - "hello after restart", - ); - }, 30_000); - - test("session/load bounds its pre-runtime replay to the configured catch-up window", async () => { - const sessionId = "persist-bounded-replay"; - const before = newManager(); - await before.create({ sessionId, workspaceId: WORKSPACE_ID }); - for (let turn = 1; turn <= 6; turn += 1) { - await runTurn(before, sessionId, `say replay-marker-${turn}`); - } - await before.dispose(); - - const after = newManager({ journalCapacity: 4 }); - await after.ensureLive(sessionId); - const page = after.getMessages({ sessionId, limit: 100 }); - expect(page.items.length).toBeLessThanOrEqual(4); - const text = messageText( - foldEnvelopes(emptyTimeline(), page.items), - "agent", - ); - expect(text).toContain("replay-marker-6"); - expect(text).not.toContain("replay-marker-1"); - }, 30_000); - - test("create() re-issued after a restart resurrects the same adapter session; mismatched workspace conflicts", async () => { - const sessionId = "persist-recreate"; - const before = newManager(); - await before.create({ sessionId, workspaceId: WORKSPACE_ID }); - await runTurn(before, sessionId, "say marker-one"); - await before.dispose(); - - const after = newManager(); - await expect( - after.create({ sessionId, workspaceId: "some-other-workspace" }), - ).rejects.toBeInstanceOf(AcpWorkspaceMismatchError); - - // The client's normal open flow — create, then read — needs no - // explicit ensureLive: create resurrects. - const created = await after.create({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - expect(created.status).toBe("idle"); - expect(messageText(foldedMessages(after, sessionId), "agent")).toContain( - "marker-one", - ); - }, 30_000); - - test("a failed session/load leaves the session offline and surfaces the error", async () => { - const sessionId = "persist-broken"; - const before = newManager(); - await before.create({ sessionId, workspaceId: WORKSPACE_ID }); - await runTurn(before, sessionId, "say doomed"); - await before.dispose(); - - // Break the harness-side store (the fake's stand-in for Claude Code's - // on-disk session files) for exactly this session. - const record = persistence - .loadAll() - .find((row) => row.sessionId === sessionId); - if (!record) throw new Error("registry row missing"); - rmSync( - path.join( - workspaceDir, - ".fake-acp-store", - `${record.acpSessionId}.jsonl`, - ), - ); - - const after = newManager(); - await expect(after.ensureLive(sessionId)).rejects.toThrow(/load/i); - // Still offline, still listed — the row is kept for a later retry. - expect(after.get(sessionId).status).toBe("offline"); - expect(after.list({}).items.map((state) => state.sessionId)).toContain( - sessionId, - ); - // And the failure is retryable, not a poisoned inflight entry. - await expect(after.ensureLive(sessionId)).rejects.toThrow(/load/i); - - // The stream route reports the failed load as a reset instead of - // hanging the socket. - const baseUrl = await startServer(after); - const resets: string[] = []; - subscriptions.push( - subscribeToSession({ - streamUrl: `${baseUrl}/acp-sessions/${sessionId}/stream`, - since: 0, - onEnvelope: () => {}, - onReset: (reason) => resets.push(reason), - }), - ); - await waitFor( - () => resets.includes("session_load_failed"), - 10_000, - "a session_load_failed reset", - ); - }, 30_000); - - test("a WS subscriber attaching to an offline session resurrects it and replays from seq 1", async () => { - const sessionId = "persist-stream"; - const before = newManager(); - await before.create({ sessionId, workspaceId: WORKSPACE_ID }); - await runTurn(before, sessionId, "say stream-marker"); - await before.dispose(); - - const after = newManager(); - expect(after.get(sessionId).status).toBe("offline"); - const baseUrl = await startServer(after); - const received: SessionUpdateEnvelope[] = []; - subscriptions.push( - subscribeToSession({ - streamUrl: `${baseUrl}/acp-sessions/${sessionId}/stream`, - since: 0, - onEnvelope: (envelope) => received.push(envelope), - }), - ); - await waitFor( - () => - received.some( - (envelope) => - envelope.frame.kind === "update" && - envelope.frame.update.sessionUpdate === "agent_message_chunk", - ), - 10_000, - "the replayed transcript over the stream", - ); - expectGapless(received); - const timeline = foldEnvelopes( - emptyTimeline(), - received.filter((envelope) => envelope.frame.kind !== "state"), - ); - expect(messageText(timeline, "agent")).toContain("stream-marker"); - expect(after.get(sessionId).status).toBe("idle"); - }, 30_000); - - test("router: getMessages and prompt resurrect through the tRPC boundary after a host restart", async () => { - const sessionId = "persist-router"; - const managerBefore = newManager(); - const hostBefore = await createTestHost({ acpSessions: managerBefore }); - await hostBefore.trpc.acpSessions.create.mutate({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - await hostBefore.trpc.acpSessions.prompt.mutate({ - sessionId, - prompt: [{ type: "text", text: "say router-marker" }], - }); - await waitFor( - () => { - const state = managerBefore.get(sessionId); - return state.status === "idle" && state.lastStopReason === "end_turn"; - }, - 10_000, - "the pre-restart turn to land", - ); - // Disposing the app kills the injected manager's adapter processes — - // the host-restart half of the scenario. - await hostBefore.dispose(); - - const managerAfter = newManager(); - const hostAfter = await createTestHost({ acpSessions: managerAfter }); - hosts.push(hostAfter); - - const listed = await hostAfter.trpc.acpSessions.list.query({}); - expect( - listed.items.find((state) => state.sessionId === sessionId)?.status, - ).toBe("offline"); - - // No explicit resurrect call anywhere: the router's ensureLive boundary - // must bring the session back for a plain getMessages… - const page = await hostAfter.trpc.acpSessions.getMessages.query({ - sessionId, - limit: 200, - }); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - expect(messageText(timeline, "agent")).toContain("router-marker"); - expect( - (await hostAfter.trpc.acpSessions.get.query({ sessionId })).status, - ).toBe("idle"); - - // …and the session keeps working. - const ack = await hostAfter.trpc.acpSessions.prompt.mutate({ - sessionId, - prompt: [{ type: "text", text: "say back from the dead" }], - }); - expect(ack).toEqual({ accepted: true }); - await waitFor( - () => { - const state = managerAfter.get(sessionId); - return state.status === "idle" && state.lastStopReason === "end_turn"; - }, - 10_000, - "the post-restart turn to land", - ); - }, 30_000); -}); diff --git a/packages/host-service/test/integration/acp-sessions-stream.integration.test.ts b/packages/host-service/test/integration/acp-sessions-stream.integration.test.ts deleted file mode 100644 index 398dce41cfe..00000000000 --- a/packages/host-service/test/integration/acp-sessions-stream.integration.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * PRIMARY ACP stream acceptance lane: the real WS route serves a real - * `claude-agent-acp` adapter and real Claude model, consumed through the real - * `subscribeToSession` client. - * - * Run this on an authenticated Mac whenever changing the ACP runtime, adapter - * bridge, stream route/client, reconnect, sequencing, or cancellation. It is - * gated only because CI does not have a Claude login and it spends real tokens; - * deterministic adapter tests are regression backup, not a substitute. - * - * ACP_E2E=1 ACP_E2E_MODEL=sonnet ACP_E2E_EFFORT=low \ - * bun test test/integration/acp-sessions-stream.integration.test.ts - */ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { execSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; -import type { AddressInfo } from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { type ServerType, serve } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import { - emptyTimeline, - foldEnvelopes, - type SessionUpdateEnvelope, -} from "@superset/session-protocol"; -import { - type SessionSubscription, - subscribeToSession, -} from "@superset/session-protocol/client"; -import { Hono } from "hono"; -import { - AcpSessionManager, - registerAcpSessionStreamRoute, -} from "../../src/runtime/acp-sessions"; - -const RUN = process.env.ACP_E2E === "1"; -const E2E_MODEL = process.env.ACP_E2E_MODEL ?? "sonnet"; -const E2E_EFFORT = process.env.ACP_E2E_EFFORT ?? "low"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitFor( - predicate: () => boolean, - timeoutMs: number, - label: string, -): Promise { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); - } - await sleep(100); - } -} - -function expectGapless(envelopes: SessionUpdateEnvelope[], fromSeq: number) { - expect(envelopes.length).toBeGreaterThan(0); - expect(envelopes[0]?.seq).toBe(fromSeq); - for (let i = 1; i < envelopes.length; i += 1) { - expect(envelopes[i]?.seq).toBe((envelopes[i - 1]?.seq ?? 0) + 1); - } -} - -describe.skipIf(!RUN)("acp-sessions WS stream (real adapter)", () => { - let workspaceDir: string; - let manager: AcpSessionManager; - let evictManager: AcpSessionManager; - const servers: ServerType[] = []; - const subscriptions: SessionSubscription[] = []; - const sessionId = "acp-m3-stream"; - const evictSessionId = "acp-m3-evict"; - const workspaceId = "acp-m3-workspace"; - - async function startServer(source: AcpSessionManager): Promise { - const app = new Hono(); - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); - registerAcpSessionStreamRoute({ app, sessions: source, upgradeWebSocket }); - const started = await new Promise((resolve) => { - const instance = serve({ fetch: app.fetch, port: 0 }, () => - resolve(instance), - ); - }); - injectWebSocket(started); - servers.push(started); - const { port } = started.address() as AddressInfo; - return `ws://127.0.0.1:${port}`; - } - - function connect(options: { - baseUrl: string; - sessionId: string; - since?: number; - onReset?: (reason: string) => void; - }): { subscription: SessionSubscription; received: SessionUpdateEnvelope[] } { - const received: SessionUpdateEnvelope[] = []; - const subscription = subscribeToSession({ - streamUrl: `${options.baseUrl}/acp-sessions/${options.sessionId}/stream`, - since: options.since, - onEnvelope: (envelope) => received.push(envelope), - onReset: options.onReset, - }); - subscriptions.push(subscription); - return { subscription, received }; - } - - async function configureRealModel( - source: AcpSessionManager, - id: string, - ): Promise { - const session = source.get(id); - const model = session.configOptions.find( - (option) => option.id === "model" && option.type === "select", - ); - if (!model || !model.options.some((option) => option.value === E2E_MODEL)) { - throw new Error( - `ACP_E2E_MODEL=${E2E_MODEL} is unavailable; adapter offered ${model?.options.map((option) => option.value).join(", ") ?? "no model catalog"}`, - ); - } - await source.setConfigOption({ - sessionId: id, - configId: "model", - value: E2E_MODEL, - }); - const effort = source - .get(id) - .configOptions.find( - (option) => option.id === "effort" && option.type === "select", - ); - if (effort?.options.some((option) => option.value === E2E_EFFORT)) { - await source.setConfigOption({ - sessionId: id, - configId: "effort", - value: E2E_EFFORT, - }); - } - } - - beforeAll(() => { - workspaceDir = mkdtempSync(path.join(os.tmpdir(), "acp-m3-")); - execSync( - "git init -q && git -c user.email=m3@superset.sh -c user.name=m3 commit -q --allow-empty -m init", - { cwd: workspaceDir }, - ); - manager = new AcpSessionManager({ - resolveWorkspaceCwd: () => workspaceDir, - }); - evictManager = new AcpSessionManager({ - resolveWorkspaceCwd: () => workspaceDir, - journalCapacity: 10, - }); - }); - - afterAll(async () => { - for (const subscription of subscriptions.splice(0)) { - subscription.close(); - } - await manager.dispose(); - await evictManager.dispose(); - for (const server of servers.splice(0)) { - ( - server as unknown as { closeAllConnections?: () => void } - ).closeAllConnections?.(); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - }); - - test("two concurrent WS subscribers see the identical gapless stream of a live turn", async () => { - await manager.create({ sessionId, workspaceId }); - await configureRealModel(manager, sessionId); - const baseUrl = await startServer(manager); - - const a = connect({ baseUrl, sessionId, since: 0 }); - const b = connect({ baseUrl, sessionId, since: 0 }); - - const { stopReason } = await manager.prompt({ - sessionId, - prompt: [ - { - type: "text", - text: "Reply with exactly the text M3_STREAM_OK and nothing else.", - }, - ], - }).turn; - expect(stopReason).toBe("end_turn"); - - // The adapter can keep emitting trailing frames after the prompt - // resolves, so subscribers may overshoot this snapshot — wait with >= - // and compare only the deterministic prefix up to the snapshot. - const finalSeq = manager.get(sessionId).lastSeq; - await waitFor( - () => - a.subscription.lastSeq >= finalSeq && - b.subscription.lastSeq >= finalSeq, - 30_000, - "both subscribers to reach the turn-end seq", - ); - - const upTo = (envelopes: SessionUpdateEnvelope[]) => - envelopes.filter((envelope) => envelope.seq <= finalSeq); - const aHead = upTo(a.received); - const bHead = upTo(b.received); - expectGapless(aHead, 1); - expect(aHead[aHead.length - 1]?.seq).toBe(finalSeq); - expect(JSON.stringify(aHead)).toBe(JSON.stringify(bHead)); - - // The stream folds into a timeline showing the agent's reply. - const timeline = foldEnvelopes(emptyTimeline(), aHead); - const agentText = timeline.items - .filter((item) => item.kind === "message" && item.role === "agent") - .flatMap((item) => (item.kind === "message" ? item.blocks : [])) - .map((block) => (block.type === "text" ? block.text : "")) - .join(""); - expect(agentText).toContain("M3_STREAM_OK"); - }, 300_000); - - test("a subscriber that drops mid-turn resumes from its cursor with no gaps and no duplicates", async () => { - const baseUrl = await startServer(manager); - const sinceSeq = manager.get(sessionId).lastSeq; - - const { turn } = manager.prompt({ - sessionId, - prompt: [ - { - type: "text", - text: "Write a haiku about websockets, then a haiku about sequence numbers.", - }, - ], - }); - - const first = connect({ baseUrl, sessionId, since: sinceSeq }); - await waitFor( - () => first.received.length >= 2, - 120_000, - "a few mid-turn envelopes", - ); - first.subscription.close(); - - const { stopReason } = await turn; - expect(stopReason).toBe("end_turn"); - - const second = connect({ - baseUrl, - sessionId, - since: first.subscription.lastSeq, - }); - const finalSeq = manager.get(sessionId).lastSeq; - await waitFor( - () => second.subscription.lastSeq >= finalSeq, - 30_000, - "the resumed subscriber to catch up", - ); - - const combined = [...first.received, ...second.received]; - expectGapless(combined, sinceSeq + 1); - // >= — trailing post-turn frames may land after the snapshot was read. - expect(combined[combined.length - 1]?.seq).toBeGreaterThanOrEqual(finalSeq); - }, 300_000); - - test("an evicted cursor yields a reset frame; resyncing from current state re-attaches cleanly", async () => { - await evictManager.create({ sessionId: evictSessionId, workspaceId }); - await configureRealModel(evictManager, evictSessionId); - const baseUrl = await startServer(evictManager); - - // Push the 10-slot ring past seq 1 (evicted once latestSeq ≥ 12). - while (evictManager.get(evictSessionId).lastSeq < 12) { - await evictManager.prompt({ - sessionId: evictSessionId, - prompt: [ - { - type: "text", - text: "Reply with exactly the text OK and nothing else.", - }, - ], - }).turn; - } - - const resets: string[] = []; - const stale = connect({ - baseUrl, - sessionId: evictSessionId, - since: 1, - onReset: (reason) => { - resets.push(reason); - }, - }); - await waitFor(() => resets.length > 0, 30_000, "the reset frame"); - expect(resets[0]).toBe("journal_evicted"); - expect(stale.received).toEqual([]); - - // Resync exactly like a client would: take the current snapshot, then - // subscribe from its lastSeq. The next turn streams gaplessly from there. - const state = evictManager.get(evictSessionId); - const resynced = connect({ - baseUrl, - sessionId: evictSessionId, - since: state.lastSeq, - }); - const { stopReason } = await evictManager.prompt({ - sessionId: evictSessionId, - prompt: [ - { - type: "text", - text: "Reply with exactly the text RESYNCED and nothing else.", - }, - ], - }).turn; - expect(stopReason).toBe("end_turn"); - const finalSeq = evictManager.get(evictSessionId).lastSeq; - await waitFor( - () => resynced.subscription.lastSeq >= finalSeq, - 30_000, - "the resynced subscriber to catch up", - ); - expectGapless(resynced.received, state.lastSeq + 1); - }, 300_000); -}); diff --git a/packages/host-service/test/integration/acp-sessions.e2e.test.ts b/packages/host-service/test/integration/acp-sessions.e2e.test.ts deleted file mode 100644 index f907d2681e1..00000000000 --- a/packages/host-service/test/integration/acp-sessions.e2e.test.ts +++ /dev/null @@ -1,1332 +0,0 @@ -/** - * Belt-and-suspenders ACP regression coverage against the deterministic fake - * adapter - * (test/fixtures/fake-acp-adapter.ts): real AcpSessionManager, real child - * processes over JSON-RPC/stdio, real Hono/node-ws stream route, real - * `subscribeToSession` WS client — but the model and Claude adapter are fake. - * This runs in every `bun test` with no tokens or network, but does not prove - * compatibility with real Claude behavior. The ACP_E2E-gated authenticated - * real-adapter suites are the primary acceptance lane and must be run on a Mac - * after relevant ACP/runtime changes. - * - * This deterministic backup covers broad and long-haul paths cheaply: a - * ~30-turn marathon with gapless streams and pagination folds, - * permission allow/deny (including concurrent requests), single- and - * multi-select elicitations, cancel mid-tool-call/question, adapter crash, - * replacement of a memory-only manager, - * stale-cursor eviction resyncs, non-fatal prompt rejection, create - * idempotency, mode/config round-trips, elicitation edge modes, credential - * scrubbing, concurrent turns, graveyard eviction, and list pagination. - */ -import { afterAll, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; -import type { AddressInfo } from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { type ServerType, serve } from "@hono/node-server"; -import { createNodeWebSocket } from "@hono/node-ws"; -import { - decodeMessagesCursor, - emptyTimeline, - foldEnvelopes, - makeSelectedOutcome, - type SessionScopedState, - type SessionUpdateEnvelope, - type Timeline, -} from "@superset/session-protocol"; -import { - type SessionSubscription, - subscribeToSession, -} from "@superset/session-protocol/client"; -import { Hono } from "hono"; -import { - AcpSessionManager, - AcpSessionNotFoundError, - AcpWorkspaceMismatchError, - registerAcpSessionStreamRoute, -} from "../../src/runtime/acp-sessions"; - -const FAKE_ADAPTER = path.join( - import.meta.dir, - "../fixtures/fake-acp-adapter.ts", -); -const WORKSPACE_ID = "acp-e2e-workspace"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitFor( - predicate: () => boolean, - timeoutMs: number, - label: string, -): Promise { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() > deadline) { - throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); - } - await sleep(10); - } -} - -function agentText(timeline: Timeline): string { - return timeline.items - .filter((item) => item.kind === "message" && item.role === "agent") - .flatMap((item) => (item.kind === "message" ? item.blocks : [])) - .map((block) => (block.type === "text" ? block.text : "")) - .join("\n"); -} - -function configValue( - state: SessionScopedState, - configId: string, -): string | boolean | undefined { - return state.configOptions.find((option) => option.id === configId) - ?.currentValue; -} - -function expectGapless(envelopes: SessionUpdateEnvelope[]): void { - expect(envelopes.length).toBeGreaterThan(0); - expect(envelopes[0]?.seq).toBe(1); - for (let i = 1; i < envelopes.length; i += 1) { - expect(envelopes[i]?.seq).toBe((envelopes[i - 1]?.seq ?? 0) + 1); - } -} - -describe("acp-sessions e2e (fake adapter)", () => { - const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "acp-e2e-")); - const managers: AcpSessionManager[] = []; - const servers: ServerType[] = []; - const subscriptions: SessionSubscription[] = []; - - function newManager(options?: { journalCapacity?: number }) { - const manager = new AcpSessionManager({ - resolveWorkspaceCwd: () => workspaceDir, - adapterEntry: FAKE_ADAPTER, - ...options, - }); - managers.push(manager); - return manager; - } - - async function startServer(manager: AcpSessionManager): Promise { - const app = new Hono(); - const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); - registerAcpSessionStreamRoute({ - app, - sessions: manager, - upgradeWebSocket, - }); - const started = await new Promise((resolve) => { - const instance = serve({ fetch: app.fetch, port: 0 }, () => - resolve(instance), - ); - }); - injectWebSocket(started); - servers.push(started); - const { port } = started.address() as AddressInfo; - return `ws://127.0.0.1:${port}`; - } - - function connect(options: { - baseUrl: string; - sessionId: string; - since?: number; - onReset?: (reason: string) => void; - }): { received: SessionUpdateEnvelope[]; resets: string[] } { - const received: SessionUpdateEnvelope[] = []; - const resets: string[] = []; - const subscription = subscribeToSession({ - streamUrl: `${options.baseUrl}/acp-sessions/${options.sessionId}/stream`, - since: options.since, - onEnvelope: (envelope) => received.push(envelope), - onReset: (reason) => { - resets.push(reason); - options.onReset?.(reason); - }, - }); - subscriptions.push(subscription); - return { received, resets }; - } - - afterAll(async () => { - for (const subscription of subscriptions.splice(0)) { - subscription.close(); - } - for (const server of servers.splice(0)) { - ( - server as unknown as { closeAllConnections?: () => void } - ).closeAllConnections?.(); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - await Promise.all(managers.splice(0).map((manager) => manager.dispose())); - }); - - test("30-turn marathon: gapless WS stream, folded timeline, pagination agrees", async () => { - const manager = newManager(); - const baseUrl = await startServer(manager); - const sessionId = "e2e-marathon"; - - const created = await manager.create({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - expect(created.status).toBe("idle"); - // D14-c: the fake starts sessions in bypassPermissions, like the real - // adapter — the manager must have switched it to default. - expect(created.currentMode?.currentModeId).toBe("default"); - - const stream = connect({ baseUrl, sessionId, since: 0 }); - - const TURNS = 30; - for (let i = 1; i <= TURNS; i += 1) { - const text = - i % 5 === 0 - ? `tool step-${i}` - : i % 7 === 0 - ? `title marathon-${i}` - : `say turn-${i}`; - const { accepted, turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text }], - }); - expect(accepted).toBe(true); - const { stopReason } = await turn; - expect(stopReason).toBe("end_turn"); - } - - const state = manager.get(sessionId); - expect(state.status).toBe("idle"); - expect(state.lastStopReason).toBe("end_turn"); - expect(state.lastError).toBeNull(); - // The last `title` turn's session_info_update landed in state. - expect(state.title).toBe("marathon-28"); - - // The WS subscriber saw every journaled envelope, gapless from seq 1. - await waitFor( - () => stream.received.at(-1)?.seq === state.lastSeq, - 10_000, - "the WS stream to catch up to lastSeq", - ); - expectGapless(stream.received); - expect(stream.resets).toEqual([]); - - // Every turn's output is present, in order, in the folded timeline. - const timeline = foldEnvelopes(emptyTimeline(), stream.received); - const text = agentText(timeline); - let lastIndex = -1; - for (let i = 1; i <= TURNS; i += 1) { - const marker = - i % 5 === 0 - ? `tool step-${i} done` - : i % 7 === 0 - ? `titled marathon-${i}` - : `turn-${i}`; - const index = text.indexOf(marker); - expect(index).toBeGreaterThan(lastIndex); - lastIndex = index; - } - // Six tool turns (5, 10, 15, 20, 25, 30), all completed. - const toolItems = timeline.items.filter( - (item) => item.kind === "tool_call", - ); - expect(toolItems).toHaveLength(6); - for (const item of toolItems) { - if (item.kind !== "tool_call") continue; - expect(item.call.status).toBe("completed"); - } - // One user message per turn made it into the timeline. - expect( - timeline.items.filter( - (item) => item.kind === "message" && item.role === "user", - ), - ).toHaveLength(TURNS); - - // Paging backwards through getMessages and re-folding reproduces the - // exact same timeline the live stream produced. - const pages: SessionUpdateEnvelope[][] = []; - let beforeSeq: number | undefined; - for (;;) { - const page = manager.getMessages({ sessionId, beforeSeq, limit: 17 }); - pages.push(page.items); - if (page.nextCursor === null) break; - const decoded = decodeMessagesCursor(page.nextCursor); - if (decoded === null) throw new Error("undecodable cursor from host"); - beforeSeq = decoded; - } - const paged = pages.reverse().flat(); - const pagedTimeline = foldEnvelopes(emptyTimeline(), paged); - expect(agentText(pagedTimeline)).toBe(text); - expect(pagedTimeline.items).toHaveLength(timeline.items.length); - }, 60_000); - - test("permission flow: allow completes the tool, deny fails it, dup answers are stale", async () => { - const manager = newManager(); - const sessionId = "e2e-permission"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - // Allow path. - const allowed = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "permission risky-write" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "a pending permission (allow path)", - ); - let state = manager.get(sessionId); - expect(state.status).toBe("awaiting_permission"); - const allowPending = state.pendingPermissions[0]; - if (!allowPending) throw new Error("pending permission disappeared"); - // The real adapter's option triple, exactly as real runs emit it. - expect(allowPending.options.map((option) => option.optionId)).toEqual([ - "allow_always", - "allow", - "reject", - ]); - const first = manager.respondToPermission({ - sessionId, - requestId: allowPending.requestId, - outcome: { outcome: "selected", optionId: "allow" }, - }); - const second = manager.respondToPermission({ - sessionId, - requestId: allowPending.requestId, - outcome: { outcome: "selected", optionId: "allow" }, - }); - expect(first.status).toBe("resolved"); - expect(second.status).toBe("already_resolved"); - expect((await allowed.turn).stopReason).toBe("end_turn"); - - // Deny path. - const denied = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "permission risky-delete" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "a pending permission (deny path)", - ); - const denyPending = manager.get(sessionId).pendingPermissions[0]; - if (!denyPending) throw new Error("pending permission disappeared"); - manager.respondToPermission({ - sessionId, - requestId: denyPending.requestId, - outcome: { outcome: "selected", optionId: "reject" }, - }); - expect((await denied.turn).stopReason).toBe("end_turn"); - - state = manager.get(sessionId); - expect(state.pendingPermissions).toEqual([]); - expect(state.status).toBe("idle"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const text = agentText(timeline); - expect(text).toContain("allowed risky-write"); - expect(text).toContain("denied risky-delete"); - const statuses = timeline.items - .filter((item) => item.kind === "tool_call") - .map((item) => (item.kind === "tool_call" ? item.call.status : "")); - expect(statuses).toEqual(["completed", "failed"]); - }, 30_000); - - test("two simultaneous permissions stay independently correlated and awaiting until both resolve", async () => { - const manager = newManager(); - const sessionId = "e2e-concurrent-permissions"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "permissions write-report,delete-cache" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length === 2, - 10_000, - "both permission cards", - ); - const [first, second] = manager.get(sessionId).pendingPermissions; - if (!first || !second) - throw new Error("concurrent permissions disappeared"); - expect(first.requestId).not.toBe(second.requestId); - expect(first.toolCall.toolCallId).not.toBe(second.toolCall.toolCallId); - expect(manager.get(sessionId).status).toBe("awaiting_permission"); - - // Resolve in reverse tool order to prove request ids, not array positions, - // correlate the answers. One unresolved card must keep the session blocked. - expect( - manager.respondToPermission({ - sessionId, - requestId: second.requestId, - outcome: { outcome: "selected", optionId: "reject" }, - }), - ).toEqual({ status: "resolved" }); - expect(manager.get(sessionId).pendingPermissions).toHaveLength(1); - expect(manager.get(sessionId).pendingPermissions[0]?.requestId).toBe( - first.requestId, - ); - expect(manager.get(sessionId).status).toBe("awaiting_permission"); - - expect( - manager.respondToPermission({ - sessionId, - requestId: first.requestId, - outcome: { outcome: "selected", optionId: "allow" }, - }), - ).toEqual({ status: "resolved" }); - expect((await turn).stopReason).toBe("end_turn"); - expect(manager.get(sessionId).pendingPermissions).toEqual([]); - expect(manager.get(sessionId).status).toBe("idle"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - expect( - page.items.filter( - (envelope) => envelope.frame.kind === "permission_requested", - ), - ).toHaveLength(2); - expect( - page.items.filter( - (envelope) => envelope.frame.kind === "permission_resolved", - ), - ).toHaveLength(2); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - expect(agentText(timeline)).toContain("allowed write-report"); - expect(agentText(timeline)).toContain("denied delete-cache"); - const tools = timeline.items.filter((item) => item.kind === "tool_call"); - expect(tools.map((item) => item.call.status)).toEqual([ - "completed", - "failed", - ]); - }, 30_000); - - test("elicitations: single-select answers by option, multi-select rides _meta", async () => { - const manager = newManager(); - const sessionId = "e2e-elicitation"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - // Single-select: pick the middle label. - const single = manager.prompt({ - sessionId, - prompt: [ - { type: "text", text: "ask-single pick a color|red, green, blue" }, - ], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "the single-select question card", - ); - const singleCard = manager.get(sessionId).pendingPermissions[0]; - if (!singleCard) throw new Error("question card disappeared"); - expect(singleCard.multiSelect).toBeUndefined(); - expect(singleCard.toolCall.title).toBe("pick a color"); - expect(singleCard.options.map((option) => option.name)).toEqual([ - "red", - "green", - "blue", - "Skip", - ]); - manager.respondToPermission({ - sessionId, - requestId: singleCard.requestId, - outcome: { outcome: "selected", optionId: "option-1" }, - }); - expect((await single.turn).stopReason).toBe("end_turn"); - - // Multi-select: pick the first and last labels in one outcome. - const multi = manager.prompt({ - sessionId, - prompt: [ - { type: "text", text: "ask-multi pick fruits|apple, banana, cherry" }, - ], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "the multi-select question card", - ); - const multiCard = manager.get(sessionId).pendingPermissions[0]; - if (!multiCard) throw new Error("question card disappeared"); - expect(multiCard.multiSelect).toBe(true); - manager.respondToPermission({ - sessionId, - requestId: multiCard.requestId, - outcome: makeSelectedOutcome(["option-0", "option-2"]), - }); - expect((await multi.turn).stopReason).toBe("end_turn"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - const text = agentText(foldEnvelopes(emptyTimeline(), page.items)); - expect(text).toContain("picked:green"); - expect(text).toContain("picked:apple+cherry"); - }, 30_000); - - test("cancel mid-tool-call: turn stops as cancelled, the open tool call terminalizes", async () => { - const manager = newManager(); - const sessionId = "e2e-cancel"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "hang" }], - }); - await waitFor( - () => manager.get(sessionId).status === "running", - 10_000, - "the hanging turn to start", - ); - // Give the in_progress tool_call time to journal before cancelling. - await waitFor( - () => - manager - .getMessages({ sessionId, limit: 200 }) - .items.some( - (envelope) => - envelope.frame.kind === "update" && - envelope.frame.update.sessionUpdate === "tool_call", - ), - 10_000, - "the hang tool call to journal", - ); - - await manager.cancel({ sessionId }); - expect((await turn).stopReason).toBe("cancelled"); - - const state = manager.get(sessionId); - expect(state.status).toBe("idle"); - expect(state.lastStopReason).toBe("cancelled"); - - // Nothing may render as running forever: the orphaned tool call was - // journaled to a terminal status when the turn ended. - const page = manager.getMessages({ sessionId, limit: 200 }); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const hangTool = timeline.items.find((item) => item.kind === "tool_call"); - if (!hangTool || hangTool.kind !== "tool_call") { - throw new Error("hang tool call missing from timeline"); - } - expect(hangTool.call.status).toBe("failed"); - }, 30_000); - - test("adapter crash: session reports dead but stays readable; siblings are untouched", async () => { - const manager = newManager(); - const sessionId = "e2e-survivor"; - const doomedId = "e2e-doomed"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - await manager.create({ sessionId: doomedId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId: doomedId, - prompt: [{ type: "text", text: "crash" }], - }); - await expect(turn).rejects.toThrow(); - await waitFor( - () => manager.get(doomedId).status === "dead", - 10_000, - "the doomed session to report dead", - ); - - // Dead sessions stay discoverable with a readable transcript. - const listed = manager.list({}).items.map((state) => state.sessionId); - expect(listed).toContain(doomedId); - expect(listed).toContain(sessionId); - const dead = manager.get(doomedId); - expect(dead.lastError).toContain("adapter"); - const page = manager.getMessages({ sessionId: doomedId, limit: 200 }); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - expect(agentText(timeline)).toContain("about to crash"); - // The tool call left open by the crash was terminalized. - const crashTool = timeline.items.find((item) => item.kind === "tool_call"); - if (!crashTool || crashTool.kind !== "tool_call") { - throw new Error("crash tool call missing from timeline"); - } - expect(crashTool.call.status).toBe("failed"); - - expect(() => - manager.prompt({ - sessionId: doomedId, - prompt: [{ type: "text", text: "say hello?" }], - }), - ).toThrow(/dead/); - - // The sibling session still takes turns. - const { turn: siblingTurn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "say still alive" }], - }); - expect((await siblingTurn).stopReason).toBe("end_turn"); - }, 30_000); - - test("a manager without persistence drops its runtime when replaced", async () => { - const manager = newManager(); - const sessionId = "e2e-restart"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "say before restart" }], - }); - await turn; - const preRestartSeq = manager.get(sessionId).lastSeq; - expect(preRestartSeq).toBeGreaterThan(0); - - // This manager deliberately has no persistence injection. Replacing it - // proves the optional memory-only mode does not fabricate recovery. - await manager.dispose(); - const restarted = newManager(); - const baseUrl = await startServer(restarted); - - // A client reconnecting with its old cursor learns the session is gone… - const staleStream = connect({ - baseUrl, - sessionId, - since: preRestartSeq, - }); - await waitFor( - () => staleStream.resets.length > 0, - 10_000, - "the stale subscriber's reset frame", - ); - expect(staleStream.resets).toEqual(["session_not_found"]); - - // The caller can create a different native session under the same public id - // and resync from scratch with a fresh journal from 1. - await restarted.create({ sessionId, workspaceId: WORKSPACE_ID }); - const fresh = connect({ baseUrl, sessionId, since: 0 }); - const { turn: freshTurn } = restarted.prompt({ - sessionId, - prompt: [{ type: "text", text: "say back online" }], - }); - expect((await freshTurn).stopReason).toBe("end_turn"); - await waitFor( - () => fresh.received.at(-1)?.seq === restarted.get(sessionId).lastSeq, - 10_000, - "the fresh stream to catch up", - ); - expectGapless(fresh.received); - const timeline = foldEnvelopes(emptyTimeline(), fresh.received); - const text = agentText(timeline); - expect(text).toContain("back online"); - expect(text).not.toContain("before restart"); - }, 30_000); - - test("stale cursor after eviction: reset frame, then reconnect from lastSeq goes live", async () => { - // A tiny ring guarantees seq 1 is evicted after a few turns. - const manager = newManager({ journalCapacity: 8 }); - const baseUrl = await startServer(manager); - const sessionId = "e2e-evicted"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - for (let i = 1; i <= 5; i += 1) { - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: `say filler-${i}` }], - }); - await turn; - } - - // since=1 is unservable now — the subscriber gets a reset and must - // resync out of band instead of silently missing frames. - const stale = connect({ baseUrl, sessionId, since: 1 }); - await waitFor( - () => stale.resets.length > 0, - 10_000, - "the evicted cursor's reset frame", - ); - expect(stale.resets).toEqual(["journal_evicted"]); - - // Resync: snapshot state, then subscribe from its lastSeq — only new - // envelopes flow, starting exactly at lastSeq + 1. - const resyncSeq = manager.get(sessionId).lastSeq; - const live = connect({ baseUrl, sessionId, since: resyncSeq }); - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "say after resync" }], - }); - await turn; - await waitFor( - () => live.received.at(-1)?.seq === manager.get(sessionId).lastSeq, - 10_000, - "live envelopes after resync", - ); - expect(live.resets).toEqual([]); - expect(live.received[0]?.seq).toBe(resyncSeq + 1); - const text = agentText(foldEnvelopes(emptyTimeline(), live.received)); - expect(text).toContain("after resync"); - expect(text).not.toContain("filler-1"); - }, 30_000); - - test("prompt rejection is non-fatal: journaled, bubble marked failed, session recovers", async () => { - const manager = newManager(); - const sessionId = "e2e-reject"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "reject boom" }], - }); - // The SDK maps a thrown handler error to a generic internal error on the - // requester's side (details ride error.data), so assert loosely here and - // precisely on the journaled frame below. - await expect(turn).rejects.toThrow(); - - const state = manager.get(sessionId); - expect(state.status).toBe("idle"); - expect(state.lastError).toBeTruthy(); - - const page = manager.getMessages({ sessionId, limit: 200 }); - const rejected = page.items.find( - (envelope) => envelope.frame.kind === "prompt_rejected", - ); - if (!rejected || rejected.frame.kind !== "prompt_rejected") { - throw new Error("prompt_rejected frame missing from journal"); - } - expect(rejected.frame.promptStartSeq).toBeGreaterThan(0); - expect(rejected.frame.reason.length).toBeGreaterThan(0); - // fold repaints the rejected prompt's user bubble as failed. - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const bubble = timeline.items.find( - (item) => item.kind === "message" && item.role === "user", - ); - if (!bubble || bubble.kind !== "message") { - throw new Error("user bubble missing from timeline"); - } - expect(bubble.failed).toBe(true); - - // The session is still alive and takes the next turn cleanly. - const { turn: recovery } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "say recovered" }], - }); - expect((await recovery).stopReason).toBe("end_turn"); - const after = manager.get(sessionId); - expect(after.lastError).toBeNull(); - expect(after.lastStopReason).toBe("end_turn"); - const text = agentText( - foldEnvelopes( - emptyTimeline(), - manager.getMessages({ sessionId, limit: 200 }).items, - ), - ); - expect(text).toContain("recovered"); - }, 30_000); - - test("create is idempotent per (sessionId, workspaceId); mismatches conflict", async () => { - const workspaceResolutions: string[] = []; - const manager = new AcpSessionManager({ - resolveWorkspaceCwd: (workspaceId) => { - workspaceResolutions.push(workspaceId); - return workspaceDir; - }, - adapterEntry: FAKE_ADAPTER, - }); - managers.push(manager); - const sessionId = "e2e-idempotent"; - - // Two concurrent creates coalesce onto one spawn. - const [first, second] = await Promise.all([ - manager.create({ sessionId, workspaceId: WORKSPACE_ID }), - manager.create({ sessionId, workspaceId: WORKSPACE_ID }), - ]); - expect(workspaceResolutions).toEqual([WORKSPACE_ID]); - expect(second?.createdAt).toBe(first?.createdAt ?? Number.NaN); - - // Replaying the create later returns the same session, no new spawn. - const replay = await manager.create({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - expect(replay.createdAt).toBe(first?.createdAt ?? Number.NaN); - expect(workspaceResolutions).toEqual([WORKSPACE_ID]); - expect(manager.list({}).items).toHaveLength(1); - - // Same id in a different workspace conflicts — against a live runtime… - await expect( - manager.create({ sessionId, workspaceId: "some-other-workspace" }), - ).rejects.toThrow(AcpWorkspaceMismatchError); - - // …and against a create still in flight. - const inflight = manager.create({ - sessionId: "e2e-idempotent-race", - workspaceId: WORKSPACE_ID, - }); - await expect( - manager.create({ - sessionId: "e2e-idempotent-race", - workspaceId: "some-other-workspace", - }), - ).rejects.toThrow(AcpWorkspaceMismatchError); - await inflight; - }, 30_000); - - test("setMode and setConfigOption round-trip through the adapter", async () => { - const manager = newManager(); - const sessionId = "e2e-config"; - const created = await manager.create({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - - // The real catalog rode session/new: mode + model + fast (fast as the - // two-value select fallback — our initialize never declares - // session.configOptions.boolean, so the boolean toggle shape can't occur - // against the real adapter either). - expect(created.configOptions.map((option) => option.id)).toEqual([ - "mode", - "model", - "fast", - ]); - expect(configValue(created, "model")).toBe("claude-opus-4-6"); - expect(configValue(created, "fast")).toBe("off"); - - await manager.setMode({ sessionId, modeId: "acceptEdits" }); - expect(manager.get(sessionId).currentMode?.currentModeId).toBe( - "acceptEdits", - ); - // The switch reached the adapter process, not just local state. - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "mode" }], - }); - await turn; - const text = agentText( - foldEnvelopes( - emptyTimeline(), - manager.getMessages({ sessionId, limit: 200 }).items, - ), - ); - expect(text).toContain("mode:acceptEdits"); - - // The refreshed catalog rides the set_config_option response into state. - await manager.setConfigOption({ - sessionId, - configId: "model", - value: "claude-sonnet-4-5", - }); - expect(configValue(manager.get(sessionId), "model")).toBe( - "claude-sonnet-4-5", - ); - await manager.setConfigOption({ sessionId, configId: "fast", value: "on" }); - expect(configValue(manager.get(sessionId), "fast")).toBe("on"); - }, 30_000); - - test("cancel with a pending permission card settles it cancelled", async () => { - const manager = newManager(); - const sessionId = "e2e-cancel-pending"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "permission risky-op" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "the permission card", - ); - await manager.cancel({ sessionId }); - expect((await turn).stopReason).toBe("cancelled"); - - const state = manager.get(sessionId); - expect(state.pendingPermissions).toEqual([]); - expect(state.status).toBe("idle"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - const resolved = page.items.find( - (envelope) => envelope.frame.kind === "permission_resolved", - ); - if (!resolved || resolved.frame.kind !== "permission_resolved") { - throw new Error("permission_resolved frame missing from journal"); - } - expect(resolved.frame.outcome).toEqual({ outcome: "cancelled" }); - // The tool call behind the card never reached a terminal status on its - // own, so the turn end terminalized it. - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const tool = timeline.items.find((item) => item.kind === "tool_call"); - if (!tool || tool.kind !== "tool_call") { - throw new Error("tool call missing from timeline"); - } - expect(tool.call.status).toBe("failed"); - }, 30_000); - - test("cancel while two permission requests are pending settles and terminalizes both", async () => { - const manager = newManager(); - const sessionId = "e2e-cancel-concurrent-permissions"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "permissions first-op,second-op" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length === 2, - 10_000, - "both permission cards before cancel", - ); - await manager.cancel({ sessionId }); - expect((await turn).stopReason).toBe("cancelled"); - expect(manager.get(sessionId).pendingPermissions).toEqual([]); - expect(manager.get(sessionId).status).toBe("idle"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - const resolved = page.items.filter( - (envelope) => envelope.frame.kind === "permission_resolved", - ); - expect(resolved).toHaveLength(2); - expect( - resolved.every( - (envelope) => - envelope.frame.kind === "permission_resolved" && - envelope.frame.outcome.outcome === "cancelled", - ), - ).toBe(true); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const tools = timeline.items.filter((item) => item.kind === "tool_call"); - expect(tools).toHaveLength(2); - expect(tools.every((item) => item.call.status === "failed")).toBe(true); - }, 30_000); - - test("cancel during AskUserQuestion clears the card and never opens the next question", async () => { - const manager = newManager(); - const sessionId = "e2e-cancel-question"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [ - { - type: "text", - text: "ask-two first question|yes, no;second question|left, right", - }, - ], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length === 1, - 10_000, - "the first question card", - ); - expect(manager.get(sessionId).pendingPermissions[0]?.toolCall.title).toBe( - "first question", - ); - await manager.cancel({ sessionId }); - expect((await turn).stopReason).toBe("cancelled"); - expect(manager.get(sessionId).pendingPermissions).toEqual([]); - - const page = manager.getMessages({ sessionId, limit: 200 }); - expect( - page.items.filter( - (envelope) => envelope.frame.kind === "permission_requested", - ), - ).toHaveLength(1); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const questionTool = timeline.items.find( - (item) => item.kind === "tool_call", - ); - if (!questionTool || questionTool.kind !== "tool_call") { - throw new Error("question tool call missing from timeline"); - } - expect(questionTool.call.status).toBe("failed"); - }, 30_000); - - test("adapter death with a pending card clears it; answering reports dead", async () => { - const manager = newManager(); - const sessionId = "e2e-dead-pending"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "permission doomed-op" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "the permission card", - ); - const requestId = manager.get(sessionId).pendingPermissions[0]?.requestId; - if (!requestId) throw new Error("pending permission disappeared"); - - const pid = manager.adapterPid(sessionId); - if (!pid) throw new Error("adapter pid unavailable"); - process.kill(pid, "SIGKILL"); - - await expect(turn).rejects.toThrow(); - await waitFor( - () => manager.get(sessionId).status === "dead", - 10_000, - "the session to report dead", - ); - expect(manager.get(sessionId).pendingPermissions).toEqual([]); - // A late answer errors loudly instead of pretending to be stale. - expect(() => - manager.respondToPermission({ - sessionId, - requestId, - outcome: { outcome: "selected", optionId: "allow" }, - }), - ).toThrow(/dead/); - }, 30_000); - - test("elicitation edge modes: url is cancelled, empty form is declined — no cards", async () => { - const manager = newManager(); - const sessionId = "e2e-elicit-edge"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const url = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "ask-url" }], - }); - expect((await url.turn).stopReason).toBe("end_turn"); - const empty = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "ask-empty" }], - }); - expect((await empty.turn).stopReason).toBe("end_turn"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - // Neither shape can render as a question card, so nothing may block. - expect( - page.items.some( - (envelope) => envelope.frame.kind === "permission_requested", - ), - ).toBe(false); - const text = agentText(foldEnvelopes(emptyTimeline(), page.items)); - expect(text).toContain("url-elicit:cancel"); - expect(text).toContain("empty-elicit:decline"); - }, 30_000); - - test("two-question form: sequential cards, Skip omits the answer, _custom fields ignored", async () => { - const manager = newManager(); - const sessionId = "e2e-two-questions"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [ - { - type: "text", - text: "ask-two pick color|red, blue;pick size|small, large", - }, - ], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "the first question card", - ); - const firstCard = manager.get(sessionId).pendingPermissions[0]; - if (!firstCard) throw new Error("first card disappeared"); - // Multi-question forms carry each question in its field description. - expect(firstCard.toolCall.title).toBe("pick color"); - expect(firstCard.options.map((option) => option.name)).toEqual([ - "red", - "blue", - "Skip", - ]); - manager.respondToPermission({ - sessionId, - requestId: firstCard.requestId, - outcome: { outcome: "selected", optionId: "option-0" }, - }); - - // The second card only appears once the first resolves. - await waitFor( - () => - manager.get(sessionId).pendingPermissions[0]?.toolCall.title === - "pick size", - 10_000, - "the second question card", - ); - const secondCard = manager.get(sessionId).pendingPermissions[0]; - if (!secondCard) throw new Error("second card disappeared"); - manager.respondToPermission({ - sessionId, - requestId: secondCard.requestId, - outcome: { outcome: "selected", optionId: "skip" }, - }); - expect((await turn).stopReason).toBe("end_turn"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - // A skipped question contributes no answer: the adapter sees the form - // content without its key at all. - const text = agentText(foldEnvelopes(emptyTimeline(), page.items)); - expect(text).toContain("picked:red&skipped"); - // Exactly two cards total: the question__custom "Other" fields the - // real adapter appends never become cards of their own. - expect( - page.items.filter( - (envelope) => envelope.frame.kind === "permission_requested", - ), - ).toHaveLength(2); - }, 30_000); - - test("adapter-owned elicitation tool call gets exactly one terminal update", async () => { - const manager = newManager(); - const sessionId = "e2e-adapter-owned"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "ask-tool deploy now?|yes, no" }], - }); - await waitFor( - () => manager.get(sessionId).pendingPermissions.length > 0, - 10_000, - "the adapter-owned question card", - ); - const card = manager.get(sessionId).pendingPermissions[0]; - if (!card) throw new Error("question card disappeared"); - // The card is bound to the adapter's real tool call, not a synthetic - // elicitation- stand-in. - expect(card.toolCall.toolCallId).toMatch(/^tool-\d+$/); - manager.respondToPermission({ - sessionId, - requestId: card.requestId, - outcome: { outcome: "selected", optionId: "option-0" }, - }); - expect((await turn).stopReason).toBe("end_turn"); - - const page = manager.getMessages({ sessionId, limit: 200 }); - expect(agentText(foldEnvelopes(emptyTimeline(), page.items))).toContain( - "picked:yes", - ); - // The adapter owns the terminal status; the host must not journal a - // second one of its own (it does for synthetic ids only). - const terminalUpdates = page.items.filter( - (envelope) => - envelope.frame.kind === "update" && - envelope.frame.update.sessionUpdate === "tool_call_update" && - (envelope.frame.update.status === "completed" || - envelope.frame.update.status === "failed"), - ); - expect(terminalUpdates).toHaveLength(1); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - const tool = timeline.items.find((item) => item.kind === "tool_call"); - if (!tool || tool.kind !== "tool_call") { - throw new Error("tool call missing from timeline"); - } - expect(tool.call.status).toBe("completed"); - }, 30_000); - - test("spawned adapters never see ambient Anthropic credentials", async () => { - const previousApiKey = process.env.ANTHROPIC_API_KEY; - const previousAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; - process.env.ANTHROPIC_API_KEY = "sk-ambient-secret"; - process.env.ANTHROPIC_AUTH_TOKEN = "ambient-token-secret"; - try { - // The manager scrubs at spawn time, so the session must be created - // while the ambient credentials are set. - const manager = newManager(); - const sessionId = "e2e-env-scrub"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]) { - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: `env ${name}` }], - }); - await turn; - } - const text = agentText( - foldEnvelopes( - emptyTimeline(), - manager.getMessages({ sessionId, limit: 200 }).items, - ), - ); - expect(text).toContain("env:ANTHROPIC_API_KEY="); - expect(text).toContain("env:ANTHROPIC_AUTH_TOKEN="); - expect(text).not.toContain("secret"); - } finally { - if (previousApiKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = previousApiKey; - } - if (previousAuthToken === undefined) { - delete process.env.ANTHROPIC_AUTH_TOKEN; - } else { - process.env.ANTHROPIC_AUTH_TOKEN = previousAuthToken; - } - } - }, 30_000); - - test("concurrent turns: one turn ending must not terminalize the other's open tool", async () => { - const manager = newManager(); - const sessionId = "e2e-concurrent"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - const hang = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "hang" }], - }); - await waitFor( - () => - manager - .getMessages({ sessionId, limit: 200 }) - .items.some( - (envelope) => - envelope.frame.kind === "update" && - envelope.frame.update.sessionUpdate === "tool_call", - ), - 10_000, - "the hang tool call to journal", - ); - - // A second prompt while the first still runs. - const say = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "say concurrent hello" }], - }); - expect((await say.turn).stopReason).toBe("end_turn"); - - // The say turn ended, but the hang turn is still active — its open tool - // call must NOT have been swept to a terminal status. - const midTimeline = foldEnvelopes( - emptyTimeline(), - manager.getMessages({ sessionId, limit: 200 }).items, - ); - const midTool = midTimeline.items.find((item) => item.kind === "tool_call"); - if (!midTool || midTool.kind !== "tool_call") { - throw new Error("hang tool call missing from timeline"); - } - expect(midTool.call.status).toBe("in_progress"); - expect(manager.get(sessionId).status).toBe("running"); - - // Only when the LAST active turn ends does the sweep run. - await manager.cancel({ sessionId }); - expect((await hang.turn).stopReason).toBe("cancelled"); - const endTimeline = foldEnvelopes( - emptyTimeline(), - manager.getMessages({ sessionId, limit: 200 }).items, - ); - const endTool = endTimeline.items.find((item) => item.kind === "tool_call"); - if (!endTool || endTool.kind !== "tool_call") { - throw new Error("hang tool call missing from timeline"); - } - expect(endTool.call.status).toBe("failed"); - expect(manager.get(sessionId).status).toBe("idle"); - }, 30_000); - - test("dead-session graveyard evicts the oldest beyond the cap", async () => { - const manager = newManager(); - const sessionIds = Array.from( - { length: 21 }, - (_, index) => `e2e-grave-${index}`, - ); - await Promise.all( - sessionIds.map((id) => - manager.create({ sessionId: id, workspaceId: WORKSPACE_ID }), - ), - ); - // Kill in order, waiting out each death so updatedAt (the eviction - // order) is strictly increasing. - for (const id of sessionIds) { - const pid = manager.adapterPid(id); - if (!pid) throw new Error(`adapter pid unavailable for ${id}`); - process.kill(pid, "SIGKILL"); - await waitFor( - () => manager.get(id).status === "dead", - 10_000, - `${id} to report dead`, - ); - await sleep(5); - } - - // 21 dead > the 20-session graveyard: the first death was evicted. - const listed = manager.list({ limit: 50 }).items; - expect(listed).toHaveLength(20); - expect(listed.every((state) => state.status === "dead")).toBe(true); - expect(listed.map((state) => state.sessionId)).not.toContain("e2e-grave-0"); - expect(() => manager.get("e2e-grave-0")).toThrow(AcpSessionNotFoundError); - expect(() => manager.get("e2e-grave-1")).not.toThrow(); - }, 60_000); - - test("list paginates by cursor and filters by workspace", async () => { - const manager = newManager(); - const older = ["e2e-page-a0", "e2e-page-a1", "e2e-page-a2"]; - const newer = ["e2e-page-b0", "e2e-page-b1"]; - for (const id of older) { - await manager.create({ sessionId: id, workspaceId: "ws-list-a" }); - await sleep(5); - } - for (const id of newer) { - await manager.create({ sessionId: id, workspaceId: "ws-list-b" }); - await sleep(5); - } - - // Newest first, two per page, cursors resuming exactly. - const pageOne = manager.list({ limit: 2 }); - expect(pageOne.enabled).toBe(true); - expect(pageOne.items.map((state) => state.sessionId)).toEqual([ - "e2e-page-b1", - "e2e-page-b0", - ]); - if (!pageOne.nextCursor) throw new Error("expected a second page"); - const pageTwo = manager.list({ limit: 2, cursor: pageOne.nextCursor }); - expect(pageTwo.items.map((state) => state.sessionId)).toEqual([ - "e2e-page-a2", - "e2e-page-a1", - ]); - if (!pageTwo.nextCursor) throw new Error("expected a third page"); - const pageThree = manager.list({ limit: 2, cursor: pageTwo.nextCursor }); - expect(pageThree.items.map((state) => state.sessionId)).toEqual([ - "e2e-page-a0", - ]); - expect(pageThree.nextCursor).toBeNull(); - - const filtered = manager.list({ workspaceId: "ws-list-b" }); - expect(filtered.items.map((state) => state.sessionId)).toEqual([ - "e2e-page-b1", - "e2e-page-b0", - ]); - }, 30_000); - - test("session_info_update with title: null clears the title", async () => { - const manager = newManager(); - const sessionId = "e2e-title-clear"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - await manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "title temp-title" }], - }).turn; - expect(manager.get(sessionId).title).toBe("temp-title"); - - await manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "title-clear" }], - }).turn; - expect(manager.get(sessionId).title).toBeNull(); - }, 30_000); - - test("a throwing subscriber does not break the turn or its siblings", async () => { - const manager = newManager(); - const sessionId = "e2e-subscriber-throw"; - await manager.create({ sessionId, workspaceId: WORKSPACE_ID }); - - // Live-only (no since): its throws hit the journal fan-out, which must - // isolate them. - const unsubscribeThrowing = manager.subscribe({ - sessionId, - onEnvelope: () => { - throw new Error("subscriber exploded"); - }, - }); - const received: SessionUpdateEnvelope[] = []; - const unsubscribe = manager.subscribe({ - sessionId, - since: 0, - onEnvelope: (envelope) => received.push(envelope), - }); - - const { turn } = manager.prompt({ - sessionId, - prompt: [{ type: "text", text: "say unbothered" }], - }); - expect((await turn).stopReason).toBe("end_turn"); - expect(agentText(foldEnvelopes(emptyTimeline(), received))).toContain( - "unbothered", - ); - expectGapless(received); - unsubscribeThrowing(); - unsubscribe(); - }, 30_000); -}); diff --git a/packages/host-service/test/integration/acp-sessions.router.integration.test.ts b/packages/host-service/test/integration/acp-sessions.router.integration.test.ts deleted file mode 100644 index 9a73b44f0bd..00000000000 --- a/packages/host-service/test/integration/acp-sessions.router.integration.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * ACP session router surface over the real host app: createTestHost boots - * createApp against bun:sqlite with the deterministic fake adapter injected, - * and every call round-trips through app.fetch as real tRPC (superjson, - * auth middleware, zod inputs). Covers the pre-release feature gate (closed - * by default; `list` feature-detects without erroring), the domain-error → - * TRPC-code mapping, and a full create → prompt → poll → fold round trip — - * the exact call sequence a mobile client makes over the relay. - */ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { - emptyTimeline, - foldEnvelopes, - type Timeline, -} from "@superset/session-protocol"; -import { TRPCClientError } from "@trpc/client"; -import { AcpSessionManager } from "../../src/runtime/acp-sessions"; -import { createTestHost, type TestHost } from "../helpers/createTestHost"; - -const FAKE_ADAPTER = path.join( - import.meta.dir, - "../fixtures/fake-acp-adapter.ts", -); -const WORKSPACE_ID = "acp-router-workspace"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** Await a call that must reject with a TRPCClientError; return its code. */ -async function trpcErrorCode(call: Promise): Promise { - try { - await call; - } catch (error) { - expect(error).toBeInstanceOf(TRPCClientError); - if (error instanceof TRPCClientError) { - return (error.data as { code?: string } | undefined)?.code ?? ""; - } - } - throw new Error("expected the call to reject"); -} - -function agentText(timeline: Timeline): string { - return timeline.items - .filter((item) => item.kind === "message" && item.role === "agent") - .flatMap((item) => (item.kind === "message" ? item.blocks : [])) - .map((block) => (block.type === "text" ? block.text : "")) - .join("\n"); -} - -describe("acp-sessions router: gate closed (default host)", () => { - test("list feature-detects enabled:false; gated procedures reject", async () => { - const previous = process.env.SUPERSET_ACP_SESSIONS; - delete process.env.SUPERSET_ACP_SESSIONS; - let host: TestHost | undefined; - try { - host = await createTestHost(); - // The one probe clients already make: no error, just enabled: false. - const page = await host.trpc.acpSessions.list.query({}); - expect(page).toEqual({ items: [], nextCursor: null, enabled: false }); - // Everything else is refused outright. - expect( - await trpcErrorCode( - host.trpc.acpSessions.create.mutate({ - sessionId: "gate-closed", - workspaceId: WORKSPACE_ID, - }), - ), - ).toBe("PRECONDITION_FAILED"); - expect( - await trpcErrorCode( - host.trpc.acpSessions.get.query({ sessionId: "gate-closed" }), - ), - ).toBe("PRECONDITION_FAILED"); - } finally { - if (previous === undefined) { - delete process.env.SUPERSET_ACP_SESSIONS; - } else { - process.env.SUPERSET_ACP_SESSIONS = previous; - } - await host?.dispose(); - } - }, 30_000); -}); - -describe("acp-sessions router: manager injected (gate open)", () => { - const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "acp-router-")); - // Injecting the manager opens the gate (app.ts) — no env flag needed. - const manager = new AcpSessionManager({ - resolveWorkspaceCwd: () => workspaceDir, - adapterEntry: FAKE_ADAPTER, - }); - let host: TestHost; - - beforeAll(async () => { - host = await createTestHost({ acpSessions: manager }); - }); - - afterAll(async () => { - // app dispose() also disposes the injected manager (and its children). - await host.dispose(); - try { - rmSync(workspaceDir, { recursive: true, force: true }); - } catch { - // best-effort - } - }); - - test("create → prompt → poll → getMessages round trip over real tRPC", async () => { - const sessionId = "router-roundtrip"; - const created = await host.trpc.acpSessions.create.mutate({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - expect(created.status).toBe("idle"); - expect(created.currentMode?.currentModeId).toBe("default"); - - const listed = await host.trpc.acpSessions.list.query({}); - expect(listed.enabled).toBe(true); - expect(listed.items.map((state) => state.sessionId)).toContain(sessionId); - - // prompt acks admission only; the turn's completion is never awaited on - // the HTTP request — remote clients poll state (or ride the WS stream). - const ack = await host.trpc.acpSessions.prompt.mutate({ - sessionId, - prompt: [{ type: "text", text: "say hello over trpc" }], - }); - expect(ack).toEqual({ accepted: true }); - - const deadline = Date.now() + 10_000; - let state = await host.trpc.acpSessions.get.query({ sessionId }); - while (!(state.status === "idle" && state.lastStopReason === "end_turn")) { - if (Date.now() > deadline) { - throw new Error("timed out waiting for the turn to land in state"); - } - await sleep(25); - state = await host.trpc.acpSessions.get.query({ sessionId }); - } - expect(state.lastError).toBeNull(); - - const page = await host.trpc.acpSessions.getMessages.query({ - sessionId, - limit: 200, - }); - const timeline = foldEnvelopes(emptyTimeline(), page.items); - expect(agentText(timeline)).toContain("hello over trpc"); - }, 30_000); - - test("domain errors map to TRPC codes", async () => { - // Unknown session → NOT_FOUND. - expect( - await trpcErrorCode( - host.trpc.acpSessions.get.query({ sessionId: "router-missing" }), - ), - ).toBe("NOT_FOUND"); - - // Undecodable cursor → BAD_REQUEST, before the manager is consulted. - expect( - await trpcErrorCode( - host.trpc.acpSessions.getMessages.query({ - sessionId: "router-missing", - cursor: "not-a-cursor", - }), - ), - ).toBe("BAD_REQUEST"); - - // Malformed list cursor → BAD_REQUEST from the shared input schema, - // instead of silently paginating to an empty page. - expect( - await trpcErrorCode( - host.trpc.acpSessions.list.query({ cursor: "not-a-cursor" }), - ), - ).toBe("BAD_REQUEST"); - - // Same session id in a different workspace → CONFLICT. - await host.trpc.acpSessions.create.mutate({ - sessionId: "router-conflict", - workspaceId: WORKSPACE_ID, - }); - expect( - await trpcErrorCode( - host.trpc.acpSessions.create.mutate({ - sessionId: "router-conflict", - workspaceId: "some-other-workspace", - }), - ), - ).toBe("CONFLICT"); - }, 30_000); - - test("prompting a dead session maps to PRECONDITION_FAILED", async () => { - const sessionId = "router-dead"; - await host.trpc.acpSessions.create.mutate({ - sessionId, - workspaceId: WORKSPACE_ID, - }); - const pid = manager.adapterPid(sessionId); - if (!pid) throw new Error("adapter pid unavailable"); - process.kill(pid, "SIGKILL"); - - const deadline = Date.now() + 10_000; - while ( - (await host.trpc.acpSessions.get.query({ sessionId })).status !== "dead" - ) { - if (Date.now() > deadline) { - throw new Error("timed out waiting for the session to report dead"); - } - await sleep(25); - } - expect( - await trpcErrorCode( - host.trpc.acpSessions.prompt.mutate({ - sessionId, - prompt: [{ type: "text", text: "say anyone home?" }], - }), - ), - ).toBe("PRECONDITION_FAILED"); - }, 30_000); - - test("the surface requires host auth, list included", async () => { - await expect( - host.unauthenticatedTrpc.acpSessions.list.query({}), - ).rejects.toBeInstanceOf(TRPCClientError); - }, 30_000); -}); diff --git a/packages/host-service/test/integration/acp-user-story.integration.test.ts b/packages/host-service/test/integration/acp-user-story.integration.test.ts new file mode 100644 index 00000000000..5dfccd2e648 --- /dev/null +++ b/packages/host-service/test/integration/acp-user-story.integration.test.ts @@ -0,0 +1,690 @@ +/** + * The E2E User Story validator, execution 1 of 4: the full gym story from + * plans/host-sessions-sync.md driven against the SHIPPING ACP surface + * (AcpSessionManager) with a real Claude instance. The same story must later + * pass unchanged over sessions.* + /sessions/sync, over the direct Claude SDK + * adapter, and finally from a real phone via Maestro. + * + * Steps 2-17 of the story run here as sequential tests sharing one session, + * exactly like a user driving one chat. Steps 18-20 (reconnect, host restart, + * phone) need surfaces that do not exist yet and are tracked in the rollout + * doc. Gated because it needs a Claude login and spends real tokens: + * + * ACP_E2E=1 bun test test/integration/acp-user-story.integration.test.ts + * + * The story pins opus for instruction-following; override with + * ACP_E2E_STORY_MODEL / ACP_E2E_STORY_EFFORT when iterating cheaply. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import { + emptyTimeline, + foldEnvelopes, + type SessionUpdateEnvelope, +} from "@superset/session-protocol"; +import { AcpSessionManager } from "../../src/runtime/acp-sessions"; +import { + GYM_SCRIPT_SENTINEL, + GYM_SKILL_SENTINEL, + GYM_WORKFLOW_NAME, + provisionGym, +} from "../helpers/gym"; + +const RUN = process.env.ACP_E2E === "1"; +const STORY_MODEL = process.env.ACP_E2E_STORY_MODEL ?? "opus"; +const STORY_EFFORT = + process.env.ACP_E2E_STORY_EFFORT ?? process.env.ACP_E2E_EFFORT ?? "low"; + +/** Verbatim from plans/host-sessions-sync.md, "The first message". */ +const FIRST_MESSAGE = `You are a real Claude instance running inside an automated end-to-end test of +Superset's session harness. This workspace is a disposable "gym" repo created +for this run; nothing in it is real product code. The test exercises the +harness around you: streaming, interrupts, permissions, questions, subagents, +workflows, skills. Follow every instruction in this conversation literally and +minimally, use exactly the tools named, and reply with the exact sentinels +requested. Start now: write a continuous ~600-word tour of this repository in +plain prose, using no tools, and end with the line TOUR_DONE.`; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor( + predicate: () => boolean, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); + } + await sleep(250); + } +} + +type Turn = Promise<{ stopReason: string }>; + +describe.skipIf(!RUN)("E2E user story over the shipping ACP surface", () => { + let manager: AcpSessionManager; + let gymDir: string; + const sessionId = "acp-story-main"; + const workspaceId = "acp-story-workspace"; + const envelopes: SessionUpdateEnvelope[] = []; + let unsubscribeMain: (() => void) | undefined; + + /** Streaming text accumulated from agent_message_chunk frames. */ + function chunkText(items: SessionUpdateEnvelope[]): string { + let text = ""; + for (const envelope of items) { + if (envelope.frame.kind !== "update") continue; + const update = envelope.frame.update as { + sessionUpdate?: string; + content?: { type?: string; text?: string }; + }; + if (update.sessionUpdate !== "agent_message_chunk") continue; + if (update.content?.type === "text" && update.content.text) { + text += update.content.text; + } + } + return text; + } + + /** Full agent text folded from the live since-0 subscription. */ + function agentText(): string { + const timeline = foldEnvelopes(emptyTimeline(), envelopes); + return timeline.items + .filter((item) => item.kind === "message" && item.role === "agent") + .flatMap((item) => (item.kind === "message" ? item.blocks : [])) + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + } + + const lastSeq = () => envelopes.at(-1)?.seq ?? 0; + + const permissionRequestsSince = (seq: number) => + envelopes.filter( + (envelope) => + envelope.seq > seq && envelope.frame.kind === "permission_requested", + ).length; + + /** Tool names of tool_call frames emitted after the given seq. */ + const toolCallNamesSince = (seq: number) => + envelopes.flatMap((envelope) => { + if (envelope.seq <= seq || envelope.frame.kind !== "update") return []; + if (envelope.frame.update.sessionUpdate !== "tool_call") return []; + const meta = envelope.frame.update._meta as + | { claudeCode?: { toolName?: string } } + | undefined; + return meta?.claudeCode?.toolName ? [meta.claudeCode.toolName] : []; + }); + + /** Await the turn; fail fast if any permission card appears. */ + async function expectNoCardTurn( + turn: Turn, + timeoutMs = 240_000, + ): Promise<{ stopReason: string }> { + const deadline = Date.now() + timeoutMs; + while (true) { + const raced = await Promise.race([ + turn.then((result) => ({ done: true as const, result })), + sleep(300).then(() => ({ done: false as const })), + ]); + if (raced.done) return raced.result; + const pending = manager.get(sessionId).pendingPermissions; + if (pending.length > 0) { + throw new Error( + `unexpected permission card during a no-card turn: ${pending[0]?.toolCall.title}`, + ); + } + if (Date.now() > deadline) { + throw new Error(`no-card turn did not finish within ${timeoutMs}ms`); + } + } + } + + /** Await the turn, answering every permission card with allow-once. */ + async function settleTurnAllowingCards( + turn: Turn, + timeoutMs = 240_000, + ): Promise<{ stopReason: string }> { + const answered = new Set(); + const deadline = Date.now() + timeoutMs; + while (true) { + const raced = await Promise.race([ + turn.then((result) => ({ done: true as const, result })), + sleep(300).then(() => ({ done: false as const })), + ]); + if (raced.done) return raced.result; + for (const pending of manager.get(sessionId).pendingPermissions) { + if (answered.has(pending.requestId)) continue; + const allow = + pending.options.find((option) => option.kind === "allow_once") ?? + pending.options[0]; + if (!allow) throw new Error("permission card offered no options"); + manager.respondToPermission({ + sessionId, + requestId: pending.requestId, + outcome: { outcome: "selected", optionId: allow.optionId }, + }); + answered.add(pending.requestId); + } + if (Date.now() > deadline) { + throw new Error(`turn did not settle within ${timeoutMs}ms`); + } + } + } + + async function pinModel(targetSessionId: string): Promise { + const model = manager + .get(targetSessionId) + .configOptions.find( + (option) => option.id === "model" && option.type === "select", + ); + if ( + !model || + !model.options.some((option) => option.value === STORY_MODEL) + ) { + throw new Error( + `story model ${STORY_MODEL} is unavailable; adapter offered ${model?.options.map((option) => option.value).join(", ") ?? "no model catalog"}`, + ); + } + await manager.setConfigOption({ + sessionId: targetSessionId, + configId: "model", + value: STORY_MODEL, + }); + const effort = manager + .get(targetSessionId) + .configOptions.find( + (option) => option.id === "effort" && option.type === "select", + ); + if (effort?.options.some((option) => option.value === STORY_EFFORT)) { + await manager.setConfigOption({ + sessionId: targetSessionId, + configId: "effort", + value: STORY_EFFORT, + }); + } + } + + beforeAll(async () => { + gymDir = provisionGym("acp-story-"); + manager = new AcpSessionManager({ + resolveWorkspaceCwd: () => gymDir, + }); + // Story step 2: the session starts in default mode (never bypass) with + // the model pinned before the first message. + await manager.create({ sessionId, workspaceId }); + expect(manager.get(sessionId).currentMode?.currentModeId).toBe("default"); + await pinModel(sessionId); + unsubscribeMain = manager.subscribe({ + sessionId, + since: 0, + onEnvelope: (envelope) => envelopes.push(envelope), + }); + }); + + afterAll(async () => { + unsubscribeMain?.(); + await manager?.dispose(); + if (gymDir) { + try { + rmSync(gymDir, { recursive: true, force: true }); + } catch { + // best-effort + } + } + }); + + test("steps 2-5: first message streams, Stop interrupts mid-tour, continue reaches TOUR_DONE", async () => { + const { accepted, turn } = manager.prompt({ + sessionId, + prompt: [{ type: "text", text: FIRST_MESSAGE }], + }); + expect(accepted).toBe(true); + + // Step 3: the tour streams live. Step 4: Stop while text is still + // arriving, well before a ~600-word essay can complete. + await waitFor( + () => chunkText(envelopes).length > 300, + 120_000, + "the tour to start streaming", + ); + expect(chunkText(envelopes)).not.toContain("TOUR_DONE"); + await manager.cancel({ sessionId }); + expect((await turn).stopReason).toBe("cancelled"); + + const interrupted = manager.get(sessionId); + expect(interrupted.status).toBe("idle"); + expect(interrupted.lastStopReason).toBe("cancelled"); + expect(interrupted.pendingPermissions).toEqual([]); + const partial = agentText(); + expect(partial.length).toBeGreaterThan(0); + expect(partial).not.toContain("TOUR_DONE"); + + // Step 5: a bare "continue" resumes with context intact. + const resume = manager.prompt({ + sessionId, + prompt: [{ type: "text", text: "continue" }], + }); + expect((await expectNoCardTurn(resume.turn)).stopReason).toBe("end_turn"); + expect(agentText()).toContain("TOUR_DONE"); + }, 300_000); + + test("steps 6-7: Bash raises a card, allow-once runs it, double answer is already_resolved", async () => { + const before = agentText().length; + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Run exactly `sh scripts/ok.sh` with the Bash tool exactly once, then reply with exactly RUN_OK followed by the script's output. Do not use any other tool.", + }, + ], + }); + await waitFor( + () => manager.get(sessionId).pendingPermissions.length > 0, + 120_000, + "the Bash permission card", + ); + expect(manager.get(sessionId).status).toBe("awaiting_permission"); + const pending = manager.get(sessionId).pendingPermissions[0]; + if (!pending) throw new Error("Bash permission disappeared"); + // A normal tool card offers allow-once, always-allow, and reject. + const kinds = pending.options.map((option) => option.kind); + expect(kinds).toContain("allow_once"); + expect(kinds).toContain("allow_always"); + expect(kinds).toContain("reject_once"); + + const allow = pending.options.find( + (option) => option.kind === "allow_once", + ); + if (!allow) throw new Error("no allow_once option"); + const first = manager.respondToPermission({ + sessionId, + requestId: pending.requestId, + outcome: { outcome: "selected", optionId: allow.optionId }, + }); + // Step 7: tapping Allow twice — the second tap is a no-op. + const second = manager.respondToPermission({ + sessionId, + requestId: pending.requestId, + outcome: { outcome: "selected", optionId: allow.optionId }, + }); + expect(first.status).toBe("resolved"); + expect(second.status).toBe("already_resolved"); + + expect((await turn).stopReason).toBe("end_turn"); + const reply = agentText().slice(before); + expect(reply).toContain("RUN_OK"); + expect(reply).toContain(GYM_SCRIPT_SENTINEL); + }, 300_000); + + test("steps 8-9: always-allow persists — the same command reruns with zero new cards", async () => { + // Step 8: run again, answer with always-allow this time. + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Run exactly `sh scripts/ok.sh` with the Bash tool exactly once more, then reply with exactly RUN_OK followed by the script's output. Do not use any other tool.", + }, + ], + }); + await waitFor( + () => manager.get(sessionId).pendingPermissions.length > 0, + 120_000, + "the second Bash permission card", + ); + const pending = manager.get(sessionId).pendingPermissions[0]; + if (!pending) throw new Error("second Bash permission disappeared"); + const always = pending.options.find( + (option) => option.kind === "allow_always", + ); + if (!always) throw new Error("no allow_always option"); + manager.respondToPermission({ + sessionId, + requestId: pending.requestId, + outcome: { outcome: "selected", optionId: always.optionId }, + }); + expect((await turn).stopReason).toBe("end_turn"); + + // Step 9: the rule holds for the rest of the session — two more runs, + // no card ever appears. + const marker = lastSeq(); + const before = agentText().length; + const rerun = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Run exactly `sh scripts/ok.sh` with the Bash tool exactly twice, one call after the other, then reply with exactly RUN_TWICE_OK. Do not use any other tool.", + }, + ], + }); + expect((await expectNoCardTurn(rerun.turn)).stopReason).toBe("end_turn"); + expect(permissionRequestsSince(marker)).toBe(0); + expect(agentText().slice(before)).toContain("RUN_TWICE_OK"); + }, 300_000); + + test("steps 10-11: an edit raises a fresh card; Stop + acceptEdits + continue finishes without one", async () => { + // Step 10: always-allow was per-tool — a file edit still cards. + const marker = lastSeq(); + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: 'Change the title of README.md to "Gym" using a file editing tool, then reply with exactly EDIT_DONE. Do not use the Bash tool.', + }, + ], + }); + await waitFor( + () => manager.get(sessionId).pendingPermissions.length > 0, + 120_000, + "the edit permission card", + ); + const editToolNames = toolCallNamesSince(marker); + expect(editToolNames.some((name) => ["Edit", "Write"].includes(name))).toBe( + true, + ); + expect(editToolNames).not.toContain("Bash"); + + // Step 11: Stop instead of answering, switch to an auto-accepting mode, + // then continue — the edit completes with no card. + await manager.cancel({ sessionId }); + expect((await turn).stopReason).toBe("cancelled"); + expect(manager.get(sessionId).pendingPermissions).toEqual([]); + + await manager.setMode({ sessionId, modeId: "acceptEdits" }); + expect(manager.get(sessionId).currentMode?.currentModeId).toBe( + "acceptEdits", + ); + + const resumeMarker = lastSeq(); + const before = agentText().length; + // A bare "continue" is ambiguous after a rejected edit — real Claude + // sometimes waits for direction instead of retrying. Be explicit that the + // mode change means the edit should be applied now. + const resume = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: 'Continue: I switched this session to acceptEdits mode, so apply that README.md title change to "Gym" now, then reply with exactly EDIT_DONE. Do not use the Bash tool.', + }, + ], + }); + expect((await expectNoCardTurn(resume.turn)).stopReason).toBe("end_turn"); + expect(permissionRequestsSince(resumeMarker)).toBe(0); + expect(agentText().slice(before)).toContain("EDIT_DONE"); + + // The end state is on disk, not just in the transcript. + const readme = readFileSync(path.join(gymDir, "README.md"), "utf8"); + expect(readme).toMatch(/^# Gym\s*$/m); + expect(readme).not.toContain("Gym fixture"); + }, 300_000); + + test("steps 12-13: two questions arrive as sequential cards; answer one, skip the other", async () => { + const marker = lastSeq(); + const before = agentText().length; + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: 'Use the AskUserQuestion tool exactly once with two questions. Question one: "Pick a color" with exactly two options, red and blue. Question two: "Pick a number" with exactly two options, 1 and 2. Do not answer them yourself. After receiving the answers, reply with exactly ANSWERS followed by the answers you received.', + }, + ], + }); + + // The first question surfaces alone; the second only after it resolves. + await waitFor( + () => manager.get(sessionId).pendingPermissions.length > 0, + 120_000, + "the first question card", + ); + const colorCard = manager.get(sessionId).pendingPermissions[0]; + if (!colorCard) throw new Error("color question disappeared"); + expect(colorCard.toolCall.title).toContain("Pick a color"); + const optionNames = colorCard.options.map((option) => option.name); + expect(optionNames).toContain("red"); + expect(optionNames).toContain("blue"); + expect(optionNames).toContain("Skip"); + const blue = colorCard.options.find((option) => option.name === "blue"); + if (!blue) throw new Error("blue option missing"); + manager.respondToPermission({ + sessionId, + requestId: colorCard.requestId, + outcome: { outcome: "selected", optionId: blue.optionId }, + }); + + await waitFor( + () => { + const pending = manager.get(sessionId).pendingPermissions; + return ( + pending.length > 0 && pending[0]?.requestId !== colorCard.requestId + ); + }, + 120_000, + "the second question card", + ); + const numberCard = manager.get(sessionId).pendingPermissions[0]; + if (!numberCard) throw new Error("number question disappeared"); + expect(numberCard.toolCall.title).toContain("Pick a number"); + const skip = + numberCard.options.find((option) => option.name === "Skip") ?? + numberCard.options.find((option) => option.optionId === "skip"); + if (!skip) throw new Error("Skip option missing"); + manager.respondToPermission({ + sessionId, + requestId: numberCard.requestId, + outcome: { outcome: "selected", optionId: skip.optionId }, + }); + + expect((await turn).stopReason).toBe("end_turn"); + const reply = agentText().slice(before); + expect(reply).toContain("ANSWERS"); + expect(reply).toContain("blue"); + // Exactly one card per question crossed the wire — no dupes, no extras. + expect(permissionRequestsSince(marker)).toBe(2); + }, 300_000); + + test("step 14: a Task subagent reads the fixture and reports through the main thread", async () => { + const marker = lastSeq(); + const before = agentText().length; + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Launch exactly one subagent that reads notes.txt and reports the expected_sum value. Then reply with exactly SUB_OK followed by that value.", + }, + ], + }); + expect((await settleTurnAllowingCards(turn, 300_000)).stopReason).toBe( + "end_turn", + ); + // The subagent tool is named Task or Agent depending on SDK version. + const subagentCalls = toolCallNamesSince(marker).filter((name) => + ["Task", "Agent"].includes(name), + ); + expect(subagentCalls.length).toBeGreaterThan(0); + expect(agentText().slice(before)).toContain("SUB_OK 42"); + }, 300_000); + + test("step 15: the saved gym workflow launches, runs its five agents, and verifies", async () => { + const before = agentText().length; + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: `Launch the named workflow ${GYM_WORKFLOW_NAME} exactly once with the Workflow tool. Do not perform its work yourself and do not use another tool. The Workflow tool runs asynchronously; after it confirms the background launch, reply with exactly WORKFLOW_LAUNCHED and do not cancel it.`, + }, + ], + }); + expect((await settleTurnAllowingCards(turn, 300_000)).stopReason).toBe( + "end_turn", + ); + expect(agentText().slice(before)).toContain("WORKFLOW_LAUNCHED"); + + type WorkflowLaunch = { + status?: string; + runId?: string; + transcriptDir?: string; + workflowName?: string; + }; + const launch = envelopes + .flatMap((envelope): WorkflowLaunch[] => { + if ( + envelope.frame.kind !== "update" || + envelope.frame.update.sessionUpdate !== "tool_call_update" + ) { + return []; + } + const meta = envelope.frame.update._meta as + | { claudeCode?: { toolResponse?: WorkflowLaunch } } + | undefined; + const response = meta?.claudeCode?.toolResponse; + return response?.status === "async_launched" ? [response] : []; + }) + .at(-1); + if (!launch?.runId || !launch.transcriptDir) { + throw new Error("Workflow launch metadata did not cross ACP"); + } + expect(launch.workflowName).toBe(GYM_WORKFLOW_NAME); + + type WorkflowRunState = { + status?: string; + agentCount?: number; + result?: { verified?: { valid?: boolean; marker?: string } }; + }; + const runStatePath = path.resolve( + launch.transcriptDir, + "..", + "..", + "..", + "workflows", + `${launch.runId}.json`, + ); + let runState: WorkflowRunState | null = null; + await waitFor( + () => { + if (!existsSync(runStatePath)) return false; + try { + runState = JSON.parse( + readFileSync(runStatePath, "utf8"), + ) as WorkflowRunState; + } catch { + return false; + } + if (["failed", "killed", "cancelled"].includes(runState.status ?? "")) { + throw new Error( + `Workflow run did not complete: ${readFileSync(runStatePath, "utf8")}`, + ); + } + return runState.status === "completed"; + }, + 240_000, + "the gym workflow agents to complete", + ); + if (!runState) throw new Error("Workflow completed without run state"); + const finished = runState as WorkflowRunState; + expect(finished.agentCount).toBe(5); + expect(finished.result?.verified?.valid).toBe(true); + expect(finished.result?.verified?.marker).toBe("WORKFLOW_VERIFIED"); + }, 600_000); + + test("step 16: the project gym-check skill runs and reports its sentinel", async () => { + const marker = lastSeq(); + const before = agentText().length; + const { turn } = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Use the Skill tool to run the gym-check skill from this repository, then follow the skill's instructions exactly.", + }, + ], + }); + expect((await settleTurnAllowingCards(turn, 300_000)).stopReason).toBe( + "end_turn", + ); + expect(toolCallNamesSince(marker)).toContain("Skill"); + expect(agentText().slice(before)).toContain(GYM_SKILL_SENTINEL); + }, 300_000); + + test("step 17: a second session streams in parallel with no envelope bleed", async () => { + // Keep the main session busy with a long no-tools turn... + const essayStart = chunkText(envelopes).length; + const essay = manager.prompt({ + sessionId, + prompt: [ + { + type: "text", + text: "Write a continuous ~500-word essay about training gyms in plain prose, using no tools, and end with the line ESSAY_DONE.", + }, + ], + }); + await waitFor( + () => chunkText(envelopes).length > essayStart + 100, + 120_000, + "the essay to start streaming", + ); + + // ...then run a whole second session start-to-finish while it streams. + const secondId = "acp-story-second"; + await manager.create({ sessionId: secondId, workspaceId }); + await pinModel(secondId); + const secondEnvelopes: SessionUpdateEnvelope[] = []; + const unsubscribeSecond = manager.subscribe({ + sessionId: secondId, + since: 0, + onEnvelope: (envelope) => secondEnvelopes.push(envelope), + }); + + let overlapped = false; + let secondDone = false; + const overlapWatch = (async () => { + while (!secondDone) { + if (manager.get(sessionId).status === "running") overlapped = true; + await sleep(200); + } + })(); + const second = manager.prompt({ + sessionId: secondId, + prompt: [ + { + type: "text", + text: "Reply with exactly PARALLEL_OK and nothing else.", + }, + ], + }); + expect((await second.turn).stopReason).toBe("end_turn"); + secondDone = true; + await overlapWatch; + expect(overlapped).toBe(true); + + // Each subscription carries only its own session's story. + expect(chunkText(secondEnvelopes)).toContain("PARALLEL_OK"); + expect(chunkText(secondEnvelopes)).not.toContain("ESSAY_DONE"); + expect(secondEnvelopes[0]?.seq).toBe(1); + unsubscribeSecond(); + + expect((await expectNoCardTurn(essay.turn)).stopReason).toBe("end_turn"); + expect(chunkText(envelopes)).toContain("ESSAY_DONE"); + expect(chunkText(envelopes)).not.toContain("PARALLEL_OK"); + }, 300_000); + + test("the whole story streamed as one gapless envelope sequence", () => { + expect(envelopes.length).toBeGreaterThan(0); + expect(envelopes[0]?.seq).toBe(1); + for (let i = 1; i < envelopes.length; i += 1) { + expect(envelopes[i]?.seq).toBe((envelopes[i - 1]?.seq ?? 0) + 1); + } + const state = manager.get(sessionId); + expect(state.status).toBe("idle"); + expect(state.pendingPermissions).toEqual([]); + }); +}); diff --git a/packages/host-service/test/integration/sessions-sync-client.integration.test.ts b/packages/host-service/test/integration/sessions-sync-client.integration.test.ts new file mode 100644 index 00000000000..6dd9db80d5a --- /dev/null +++ b/packages/host-service/test/integration/sessions-sync-client.integration.test.ts @@ -0,0 +1,691 @@ +/** + * End-to-end wiring test for the Host Sessions client: a real + * `createSessionsSyncClient` store talking to a real `createApp` host over a + * real WebSocket (`/sessions/sync`) and real tRPC (`sessions.*`), with only + * the ACP adapter faked. Deterministic and ungated — this is the CI-grade + * proof that the client store, wire protocol, hub, tRPC router, and canonical + * runtime compose into one consistent system. + * + * The oracle throughout is PARITY: after every scenario the client's folded + * zustand store must deep-equal the host's own truth as served over tRPC + * (`sessions.getEvents` / `sessions.get` / `sessions.list`). No hand-computed + * expectations — if either side drifts from the contract, they disagree. + * + * A second oracle rides along implicitly: the client hard-validates every + * inbound frame (schema + connection context) and force-closes the socket on + * any mismatch, so the log capture asserting "no socket drops, only the one + * expected reset" makes every test here a wire-conformance test of the + * server. The tail of the suite is the Layer-3 scenario from + * plans/host-sessions-sync.md: scrollback paging, session switching + * under streaming load, and a full host reboot with recovery. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type ServerType, serve } from "@hono/node-server"; +import { + createSessionsSyncClient, + type SessionsSyncClient, + type SessionsSyncLogEvent, +} from "@superset/host-service-sync/client"; +import { + SESSIONS_SYNC_PATH, + type Session, + type SessionEvent, + type Thread, +} from "@superset/host-service-sync/protocol"; +import { FakeAcpPort } from "../../src/runtime/sessions/testing/fake-acp-port"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor( + predicate: () => boolean, + label: string, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); + } + await sleep(10); + } +} + +/** app.ts awaits `acpSessions.dispose()` on teardown; the fake needs one. */ +class DisposableFakeAcpPort extends FakeAcpPort { + async dispose(): Promise {} +} + +const WORKSPACE_ID = "workspace-parity"; + +describe("sessions sync client against a real host", () => { + let host: TestHost; + let port: DisposableFakeAcpPort; + let server: ServerType; + let syncUrl: string; + let client: SessionsSyncClient; + const logs: SessionsSyncLogEvent[] = []; + const extraClients: SessionsSyncClient[] = []; + // One durable dir for the whole suite: reboots reuse the same SQLite + // file, so persisted top-level session metadata (titles, archive state) + // survives across hosts like it does across real restarts. + let durableDir: string; + + let sessionA: Session; + let mainThreadA: Thread; + let sessionB: Session; + + function makeClient(instanceId: string): SessionsSyncClient { + return createSessionsSyncClient({ + clientInstanceId: instanceId, + clientVersion: "0.0.0-test", + // A function so the host-reboot scenario can repoint the client at + // the new server between reconnect attempts, like a relay would. + syncUrl: () => syncUrl, + api: { + list: () => host.trpc.sessions.list.query(), + get: (input) => host.trpc.sessions.get.query(input), + getEvents: (input) => host.trpc.sessions.getEvents.query(input), + resolveToolCall: async (input) => { + await host.trpc.sessions.resolveToolCall.mutate(input); + }, + }, + logger: { log: (event) => logs.push(event) }, + reconnectDelayMs: 10, + }); + } + + async function bootServer(): Promise { + host = await createTestHost({ + acpSessions: port, + dbPath: join(durableDir, "host.db"), + }); + server = await new Promise((resolve) => { + const instance = serve({ fetch: host.app.fetch, port: 0 }, () => + resolve(instance), + ); + }); + host.injectWebSocket(server); + const { port: httpPort } = server.address() as AddressInfo; + syncUrl = `ws://127.0.0.1:${httpPort}${SESSIONS_SYNC_PATH}?token=${host.psk}`; + } + + beforeAll(async () => { + durableDir = mkdtempSync(join(tmpdir(), "sessions-sync-durable-")); + port = new DisposableFakeAcpPort(); + await bootServer(); + client = makeClient("client-parity-1"); + }); + + afterAll(async () => { + for (const extra of extraClients) extra.disconnect(); + client?.disconnect(); + await host?.dispose(); + // Not awaited: Bun's node:http shim never fires the close callback, + // even after every TCP connection is gone (they all close within + // ~10ms of the clients disconnecting). + server?.close(); + rmSync(durableDir, { recursive: true, force: true }); + }); + + /** The client's ordered event list for one session. */ + function clientEvents( + target: SessionsSyncClient, + sessionId: string, + ): SessionEvent[] { + const stream = target.store.getState().streamsBySessionId[sessionId]; + if (!stream) throw new Error(`no client stream for ${sessionId}`); + return stream.eventIds.map((id) => { + const event = stream.eventsById[id]; + if (!event) throw new Error(`client stream lost event ${id}`); + return event; + }); + } + + const byId = (items: T[]) => + [...items].sort((left, right) => left.id.localeCompare(right.id)); + + /** The host's full log for one session, stitched backwards through paging. */ + async function hostFullLog(sessionId: string): Promise { + const pages: SessionEvent[][] = []; + let beforeCursor: string | undefined; + for (;;) { + const window = await host.trpc.sessions.getEvents.query({ + sessionId, + ...(beforeCursor === undefined ? {} : { beforeCursor }), + limit: 100, + }); + pages.unshift(window.items); + if (!window.range.hasMoreBefore) { + expect(window.range.truncatedBefore).toBe(false); + break; + } + const oldest = window.range.oldest; + if (!oldest) throw new Error("hasMoreBefore without an oldest boundary"); + beforeCursor = oldest.cursor; + } + return pages.flat(); + } + + /** + * The parity oracle: the client's folded store must deep-equal the host's + * own answers over tRPC — the client window tiles the exact tail of the + * host log, the entity matches, and pending permissions agree. + */ + async function expectSessionParity( + target: SessionsSyncClient, + sessionId: string, + ): Promise { + const log = await hostFullLog(sessionId); + const window = clientEvents(target, sessionId); + expect(window).toEqual(log.slice(log.length - window.length)); + const stream = target.store.getState().streamsBySessionId[sessionId]; + expect(stream?.latestCursor).toBe( + log[log.length - 1]?.cursor ?? stream?.latestCursor ?? null, + ); + + const snapshot = await host.trpc.sessions.get.query({ sessionId }); + const state = target.store.getState(); + expect(state.sessionsById[sessionId]).toEqual(snapshot.session); + const clientPendings = Object.values(state.pendingPermissionsById).filter( + (pending) => pending.sessionId === sessionId, + ); + expect(byId(clientPendings)).toEqual(byId(snapshot.pendingPermissions)); + } + + /** `sessions.list` IS the host snapshot; the client mirrors it exactly. */ + async function expectListParity(target: SessionsSyncClient): Promise { + const snapshot = await host.trpc.sessions.list.query(); + const state = target.store.getState(); + expect(byId(Object.values(state.sessionsById))).toEqual( + byId(snapshot.sessions), + ); + } + + test("connects, goes live on the host stream, and folds created sessions", async () => { + client.connect(); + await waitFor( + () => client.store.getState().connection.status === "connected", + "the sync socket to connect", + ); + await waitFor( + () => client.store.getState().hostSubscription.status === "live", + "the host stream to go live", + ); + expect(client.store.getState().connection.hostId).toBe( + "00000000-0000-0000-0000-000000000001", + ); + + const createdA = await host.trpc.sessions.create.mutate({ + requestId: "req-create-a", + workspaceId: WORKSPACE_ID, + agentId: "claude-code", + title: "Parity A", + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }); + sessionA = createdA.session; + mainThreadA = createdA.mainThread; + const createdB = await host.trpc.sessions.create.mutate({ + requestId: "req-create-b", + workspaceId: WORKSPACE_ID, + agentId: "claude-code", + title: "Parity B", + settings: { + activeModel: null, + activeMode: null, + effort: null, + configuration: {}, + }, + }); + sessionB = createdB.session; + + await waitFor(() => { + const { sessionsById } = client.store.getState(); + return sessionA.id in sessionsById && sessionB.id in sessionsById; + }, "both created sessions to reach the client via the host stream"); + await expectListParity(client); + }); + + test("a retained session seeds from the tRPC snapshot and folds live chunks", async () => { + client.retainSession(sessionA.id, "focused"); + await waitFor( + () => + client.store.getState().streamsBySessionId[sessionA.id]?.status === + "live", + "session A to go live", + ); + await expectSessionParity(client, sessionA.id); + + for (const text of ["alpha ", "beta ", "gamma"]) { + port.emitUpdate(sessionA.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); + } + await waitFor( + () => + clientEvents(client, sessionA.id).some( + (event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" && + event.payload.content.text === "gamma", + ), + "the streamed chunks to reach the client store", + ); + await expectSessionParity(client, sessionA.id); + }); + + test("submitTurn is admitted over tRPC and completion arrives as events", async () => { + const receipt = await host.trpc.sessions.submitTurn.mutate({ + requestId: "req-turn-1", + sessionId: sessionA.id, + threadId: mainThreadA.id, + content: [{ type: "text", text: "hello gym" }], + }); + expect(receipt.status).toBe("accepted"); + + port.completeTurn(sessionA.id, "end_turn"); + await waitFor( + () => + clientEvents(client, sessionA.id).some( + (event) => + event.payload.type === "turnCompleted" && + event.payload.turnId === receipt.turnId && + event.payload.stopReason === "endTurn", + ), + "the turn to complete in the client store", + ); + + const events = clientEvents(client, sessionA.id); + const started = events.find( + (event) => + event.payload.type === "turnStarted" && + event.payload.turn.id === receipt.turnId, + ); + if (!started) throw new Error("turnStarted never reached the client"); + // The receipt's requestId is the causation chain into the event log. + expect(started.causationId).toBe("req-turn-1"); + expect(client.store.getState().sessionsById[sessionA.id]?.runState).toBe( + "idle", + ); + await expectSessionParity(client, sessionA.id); + }); + + test("a permission card folds in and resolves through tRPC", async () => { + port.requestPermission(sessionA.id, "perm-native-1", "toolu-1"); + await waitFor( + () => + Object.values(client.store.getState().pendingPermissionsById).some( + (pending) => pending.sessionId === sessionA.id, + ), + "the permission card to reach the client store", + ); + await expectSessionParity(client, sessionA.id); + + const pending = Object.values( + client.store.getState().pendingPermissionsById, + ).find((entry) => entry.sessionId === sessionA.id); + if (!pending) throw new Error("pending permission disappeared"); + expect(pending.toolCallId).toBe("toolu-1"); + const allow = pending.options.find((option) => option.kind === "allowOnce"); + if (!allow) throw new Error("no allowOnce option on the card"); + + const receipt = await host.trpc.sessions.resolvePermission.mutate({ + requestId: "req-perm-1", + sessionId: sessionA.id, + permissionId: pending.id, + outcome: { type: "selected", optionIds: [allow.id] }, + }); + expect(receipt.status).toBe("accepted"); + await waitFor( + () => + !Object.values(client.store.getState().pendingPermissionsById).some( + (entry) => entry.sessionId === sessionA.id, + ), + "the permission card to drain from the client store", + ); + // The decision reached the adapter, not just the projection. + expect(port.respondCalls.at(-1)).toEqual({ + requestId: "perm-native-1", + outcome: { outcome: "selected", optionId: allow.id }, + }); + await expectSessionParity(client, sessionA.id); + }); + + test("offline activity replays gaplessly across a reconnect", async () => { + client.disconnect(); + await waitFor( + () => client.store.getState().connection.status === "disconnected", + "the client to disconnect", + ); + + // While the socket is down the host keeps moving — and tRPC keeps + // working; the WS is a read path, never a write dependency. + port.emitUpdate(sessionA.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "offline delta" }, + }); + port.requestPermission(sessionA.id, "perm-native-2", "toolu-2"); + const snapshot = await host.trpc.sessions.get.query({ + sessionId: sessionA.id, + }); + const offlinePending = snapshot.pendingPermissions.find( + (pending) => pending.toolCallId === "toolu-2", + ); + if (!offlinePending) throw new Error("offline permission not pending"); + await host.trpc.sessions.resolvePermission.mutate({ + requestId: "req-perm-2", + sessionId: sessionA.id, + permissionId: offlinePending.id, + outcome: { type: "cancelled" }, + }); + + client.connect(); + await waitFor( + () => + client.store.getState().streamsBySessionId[sessionA.id]?.status === + "live", + "session A to resume live after reconnect", + ); + await waitFor( + () => client.store.getState().hostSubscription.status === "live", + "the host stream to resume live after reconnect", + ); + await expectSessionParity(client, sessionA.id); + await expectListParity(client); + + // The replay tiled exactly: no event id appears twice in the fold. + const ids = clientEvents(client, sessionA.id).map((event) => event.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("a fresh client converges to the same state", async () => { + const second = makeClient("client-parity-2"); + extraClients.push(second); + second.connect(); + second.retainSession(sessionA.id, "focused"); + await waitFor( + () => + second.store.getState().streamsBySessionId[sessionA.id]?.status === + "live", + "the second client to go live on session A", + ); + await expectSessionParity(second, sessionA.id); + await expectListParity(second); + expect(second.store.getState().sessionsById).toEqual( + client.store.getState().sessionsById, + ); + second.disconnect(); + }); + + test("archiving removes the session from the list and the client store", async () => { + const receipt = await host.trpc.sessions.update.mutate({ + requestId: "req-archive-b", + sessionId: sessionB.id, + archived: true, + }); + expect(receipt.status).toBe("accepted"); + await waitFor( + () => !(sessionB.id in client.store.getState().sessionsById), + "the archived session to leave the client store", + ); + // list (the host snapshot) excludes archived rows; the registry row + // still exists and `get` still serves it. + const listed = await host.trpc.sessions.list.query(); + expect(listed.sessions.some((item) => item.id === sessionB.id)).toBe(false); + const archived = await host.trpc.sessions.get.query({ + sessionId: sessionB.id, + }); + expect(archived.session.archivedAt).toBeGreaterThan(0); + await expectListParity(client); + expect(sessionA.id in client.store.getState().sessionsById).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Layer-3 realistic scenario (plans/host-sessions-sync.md) + // ------------------------------------------------------------------------- + + test("scrollback: backwards paging stitches the client window onto the full host log", async () => { + // Push well past the snapshot tail (50) so the seed is a strict suffix. + for (let index = 0; index < 80; index += 1) { + port.emitUpdate(sessionA.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `bulk-${index} ` }, + }); + } + await waitFor( + () => + clientEvents(client, sessionA.id).some( + (event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" && + event.payload.content.text === "bulk-79 ", + ), + "the bulk chunks to reach the client store", + ); + + // A third client seeds fresh: its window is the snapshot tail only, + // with hasOlder signalling scrollback exists. + const pager = makeClient("client-parity-pager"); + extraClients.push(pager); + pager.connect(); + pager.retainSession(sessionA.id, "focused"); + await waitFor( + () => + pager.store.getState().streamsBySessionId[sessionA.id]?.status === + "live", + "the paging client to go live on session A", + ); + const seeded = pager.store.getState().streamsBySessionId[sessionA.id]; + if (!seeded) throw new Error("paging client has no stream"); + expect(seeded.hasOlder).toBe(true); + const fullLog = await hostFullLog(sessionA.id); + expect(seeded.eventIds.length).toBeLessThan(fullLog.length); + + // Drag up until the log is exhausted; the fold must equal the host's + // full log exactly — order, cursors, and all. + for (let guard = 0; guard < 20; guard += 1) { + const stream = pager.store.getState().streamsBySessionId[sessionA.id]; + if (!stream?.hasOlder) break; + await pager.fetchOlderEvents(sessionA.id, { limit: 40 }); + } + expect(clientEvents(pager, sessionA.id)).toEqual(fullLog); + expect( + pager.store.getState().streamsBySessionId[sessionA.id]?.hasOlder, + ).toBe(false); + pager.disconnect(); + }); + + test("session switching under load keeps both streams consistent", async () => { + // The phone taps into B while A keeps streaming in the background. + const releaseB = client.retainSession(sessionB.id, "focused"); + // B was archived earlier; un-archive so it is back in scope. + await host.trpc.sessions.update.mutate({ + requestId: "req-unarchive-b", + sessionId: sessionB.id, + archived: false, + }); + for (let index = 0; index < 10; index += 1) { + port.emitUpdate(sessionA.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `switch-a-${index} ` }, + }); + port.emitUpdate(sessionB.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `switch-b-${index} ` }, + }); + } + await waitFor( + () => + client.store.getState().streamsBySessionId[sessionB.id]?.status === + "live" && + clientEvents(client, sessionB.id).some( + (event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" && + event.payload.content.text === "switch-b-9 ", + ), + "session B to go live and stream while A is retained", + ); + await waitFor( + () => + clientEvents(client, sessionA.id).some( + (event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" && + event.payload.content.text === "switch-a-9 ", + ), + "session A to keep streaming during the switch", + ); + await expectSessionParity(client, sessionA.id); + await expectSessionParity(client, sessionB.id); + releaseB(); + }); + + test("host reboot: session metadata survives, rebuilt logs are a new generation, cold path recovers", async () => { + // Pin B as an explicitly retained second stream so the reboot's blast + // radius is deterministic: every session subscription resets, once. + client.retainSession(sessionB.id, "focused"); + await waitFor( + () => + client.store.getState().streamsBySessionId[sessionB.id]?.status === + "live", + "session B to go live before the reboot", + ); + const eventsBefore = clientEvents(client, sessionA.id); + expect(eventsBefore.length).toBeGreaterThan(0); + const hostCursorBefore = + client.store.getState().hostSubscription.latestCursor; + expect(hostCursorBefore).not.toBeNull(); + const sessionCursorBefore = + client.store.getState().streamsBySessionId[sessionA.id]?.latestCursor; + expect(sessionCursorBefore).not.toBeNull(); + + // Kill the host process (dispose the app + server) and boot a fresh + // one over the SAME SQLite file — a new hub incarnation. The fake + // port's journal stands in for the vendor transcript, the only place + // conversation content survives. + const oldHost = host; + const oldServer = server; + client.disconnect(); + // Disconnect downgraded every stream: "live" must always mean "caught + // up on an open socket", or recovery waits pass on stale state. + expect(client.store.getState().hostSubscription.status).toBe("idle"); + expect( + client.store.getState().streamsBySessionId[sessionA.id]?.status, + ).toBe("idle"); + await oldHost.dispose(); + oldServer.close(); + await bootServer(); + + // The client reconnects: the host cursor belongs to the dead boot, and + // each session cursor belongs to a dead log generation (the log is + // rebuilt from vendor replay — content is deliberately not persisted). + // Every stream resets CURSOR_INVALID and re-seeds via the cold path. + client.connect(); + await waitFor( + () => + client.store.getState().hostSubscription.status === "live" && + client.store.getState().hostSubscription.latestCursor !== + hostCursorBefore, + "the host stream to recover on the new hub incarnation", + 15_000, + ); + await waitFor( + () => + client.store.getState().streamsBySessionId[sessionA.id]?.status === + "live" && + client.store.getState().streamsBySessionId[sessionB.id]?.status === + "live", + "both session streams to recover after the reboot", + ); + + const resets = logs.filter( + (event) => event.event === "sessions_sync.stream_reset", + ); + expect(resets.filter((reset) => reset.sessionId === null)).toEqual([ + { + event: "sessions_sync.stream_reset", + sessionId: null, + code: "CURSOR_INVALID", + recovery: "refetchSnapshot", + }, + ]); + const sessionResets = resets.filter((reset) => reset.sessionId !== null); + expect(sessionResets.map((reset) => reset.sessionId).sort()).toEqual( + [sessionA.id, sessionB.id].sort(), + ); + for (const reset of sessionResets) { + expect(reset.code).toBe("CURSOR_INVALID"); + expect(reset.recovery).toBe("refetchSnapshot"); + } + + // Top-level session metadata survived the reboot: the canonical titles + // live in the host DB, not in the dead process. + const listed = await host.trpc.sessions.list.query(); + expect( + listed.sessions.find((session) => session.id === sessionA.id)?.title, + ).toBe("Parity A"); + expect( + listed.sessions.find((session) => session.id === sessionB.id)?.title, + ).toBe("Parity B"); + + // The re-seeded replica converges on the rebuilt log: fresh cursors, + // no duplicated ids, and scrollback stitches it end to end. + expect( + client.store.getState().streamsBySessionId[sessionA.id]?.latestCursor, + ).not.toBe(sessionCursorBefore ?? ""); + const ids = clientEvents(client, sessionA.id).map((event) => event.id); + expect(new Set(ids).size).toBe(ids.length); + await expectSessionParity(client, sessionA.id); + await expectListParity(client); + // The rebuild really did lose attribution — content is re-derived from + // the vendor journal, which never carried request ids. Pinned as the + // deliberate for-now trade-off of keeping messages out of SQLite + // (plans/host-sessions-sync.md). + const rebuilt = await hostFullLog(sessionA.id); + expect(rebuilt.every((event) => event.causationId === null)).toBe(true); + expect(rebuilt.length).toBeGreaterThanOrEqual(eventsBefore.length); + + // The host keeps streaming on the new boot. + port.emitUpdate(sessionA.id, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "post-reboot" }, + }); + await waitFor( + () => + clientEvents(client, sessionA.id).some( + (event) => + event.payload.type === "messageDelta" && + event.payload.content.type === "text" && + event.payload.content.text === "post-reboot", + ), + "post-reboot activity to stream to the client", + ); + await expectSessionParity(client, sessionA.id); + }); + + test("the wire stayed clean: no drops, and only the reboots' expected resets", () => { + const drops = logs.filter( + (event) => event.event === "sessions_sync.socket_dropped", + ); + expect(drops).toEqual([]); + // The reboot's one host reset (new hub incarnation) plus one reset per + // session stream (new log generation) — nothing else ever reset. + const resets = logs.filter( + (event) => event.event === "sessions_sync.stream_reset", + ); + expect(resets).toHaveLength(3); + expect(resets.filter((reset) => reset.sessionId === null)).toHaveLength(1); + expect(client.store.getState().connection.error).toBeNull(); + }); +}); diff --git a/packages/host-service/test/integration/sessions-user-story.integration.test.ts b/packages/host-service/test/integration/sessions-user-story.integration.test.ts new file mode 100644 index 00000000000..c03ce429543 --- /dev/null +++ b/packages/host-service/test/integration/sessions-user-story.integration.test.ts @@ -0,0 +1,907 @@ +/** + * The E2E User Story validator, execution 2 of 4: the same gym story from + * plans/host-sessions-sync.md (steps 2-17), driven UNCHANGED against the + * TARGET canonical surface — `sessions.*` tRPC on a real `createApp` host and + * a real `createSessionsSyncClient` store over a real `/sessions/sync` + * WebSocket — with a real Claude instance behind the ACP adapter. Execution 1 + * (the shipping ACP surface) is acp-user-story.integration.test.ts; execution + * 3 re-runs this file over the direct SDK adapter, execution 4 replays it + * from a phone via Maestro. + * + * Because every inbound frame is schema-and-context validated by the client + * (mismatch force-closes the socket) and the final test asserts zero drops + * and store/tRPC parity, the whole story doubles as a wire-conformance run. + * + * Gated because it needs a Claude login and spends real tokens: + * + * ACP_E2E=1 bun test test/integration/sessions-user-story.integration.test.ts + * + * The story pins opus for instruction-following; override with + * ACP_E2E_STORY_MODEL / ACP_E2E_STORY_EFFORT when iterating cheaply. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { type ServerType, serve } from "@hono/node-server"; +import { + createSessionsSyncClient, + type SessionsSyncClient, + type SessionsSyncLogEvent, +} from "@superset/host-service-sync/client"; +import { + SESSIONS_SYNC_PATH, + type Session, + type SessionEvent, + type Thread, +} from "@superset/host-service-sync/protocol"; +import { AcpSessionManager } from "../../src/runtime/acp-sessions"; +import { createTestHost, type TestHost } from "../helpers/createTestHost"; +import { + GYM_SCRIPT_SENTINEL, + GYM_SKILL_SENTINEL, + GYM_WORKFLOW_NAME, + provisionGym, +} from "../helpers/gym"; + +const RUN = process.env.ACP_E2E === "1"; +const STORY_MODEL = process.env.ACP_E2E_STORY_MODEL ?? "opus"; +const STORY_EFFORT = + process.env.ACP_E2E_STORY_EFFORT ?? process.env.ACP_E2E_EFFORT ?? "low"; + +/** Verbatim from plans/host-sessions-sync.md, "The first message". */ +const FIRST_MESSAGE = `You are a real Claude instance running inside an automated end-to-end test of +Superset's session harness. This workspace is a disposable "gym" repo created +for this run; nothing in it is real product code. The test exercises the +harness around you: streaming, interrupts, permissions, questions, subagents, +workflows, skills. Follow every instruction in this conversation literally and +minimally, use exactly the tools named, and reply with the exact sentinels +requested. Start now: write a continuous ~600-word tour of this repository in +plain prose, using no tools, and end with the line TOUR_DONE.`; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor( + predicate: () => boolean, + timeoutMs: number, + label: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`); + } + await sleep(250); + } +} + +describe.skipIf(!RUN)("E2E user story over sessions.* + /sessions/sync", () => { + let gymDir: string; + let manager: AcpSessionManager; + let host: TestHost; + let server: ServerType; + let client: SessionsSyncClient; + const logs: SessionsSyncLogEvent[] = []; + + let sessionA: Session; + let mainThreadA: Thread; + let requestSerial = 0; + const nextRequestId = () => `req-story-${++requestSerial}`; + + // ------------------------------------------------------------------------- + // Store lenses — everything below reads ONLY what a product client can + // read: the sync client's folded store and the public tRPC surface. + // ------------------------------------------------------------------------- + + /** Session A's ordered canonical event list, from the client store. */ + function events(): SessionEvent[] { + const stream = client.store.getState().streamsBySessionId[sessionA.id]; + if (!stream) throw new Error("no client stream for the story session"); + return stream.eventIds.map((id) => { + const event = stream.eventsById[id]; + if (!event) throw new Error(`client stream lost event ${id}`); + return event; + }); + } + + /** Count of events currently folded; used as a progress marker. */ + const marker = () => events().length; + + function sessionEntity(): Session { + const session = client.store.getState().sessionsById[sessionA.id]; + if (!session) throw new Error("story session missing from client store"); + return session; + } + + function pendingPermissions(sessionId = sessionA.id) { + return Object.values(client.store.getState().pendingPermissionsById) + .filter((pending) => pending.sessionId === sessionId) + .sort((left, right) => left.requestedAt - right.requestedAt); + } + + /** + * Assistant prose streamed on the MAIN thread: text deltas of assistant + * messages, in fold order. Thought blocks and subagent threads excluded. + */ + function agentText(items = events()): string { + const assistantMessages = new Set(); + for (const event of items) { + if ( + event.payload.type === "messageStarted" && + event.payload.message.role === "assistant" && + event.threadId === mainThreadA.id + ) { + assistantMessages.add(event.payload.message.id); + } + } + let text = ""; + for (const event of items) { + if (event.payload.type !== "messageDelta") continue; + if (!assistantMessages.has(event.payload.messageId)) continue; + if (event.payload.content.type !== "text") continue; + text += event.payload.content.text; + } + return text; + } + + /** Tool names of toolCallStarted events at or after an event marker. */ + const toolNamesSince = (since: number) => + events() + .slice(since) + .flatMap((event) => + event.payload.type === "toolCallStarted" + ? [event.payload.toolCall.tool.name] + : [], + ); + + const permissionRequestsSince = (since: number) => + events() + .slice(since) + .filter((event) => event.payload.type === "permissionRequested").length; + + /** Every title this tool call ever carried (started + refinements). */ + function toolCallTitles(toolCallId: string): string[] { + return events().flatMap((event) => { + if ( + event.payload.type === "toolCallStarted" && + event.payload.toolCall.id === toolCallId + ) { + return [event.payload.toolCall.title]; + } + if ( + event.payload.type === "toolCallUpdated" && + event.payload.toolCallId === toolCallId && + event.payload.update.title !== undefined + ) { + return [event.payload.update.title]; + } + return []; + }); + } + + function turnEnd( + turnId: string, + ): { kind: "completed"; stopReason: string } | { kind: "cancelled" } | null { + for (const event of events()) { + const payload = event.payload; + if (payload.type === "turnCompleted" && payload.turnId === turnId) { + return { kind: "completed", stopReason: payload.stopReason }; + } + if (payload.type === "turnCancelled" && payload.turnId === turnId) { + return { kind: "cancelled" }; + } + if (payload.type === "turnFailed" && payload.turnId === turnId) { + throw new Error(`turn ${turnId} failed: ${payload.error.code}`); + } + } + return null; + } + + // ------------------------------------------------------------------------- + // Command helpers — all writes go through the public tRPC surface. + // ------------------------------------------------------------------------- + + async function submitTurn(text: string) { + const requestId = nextRequestId(); + const receipt = await host.trpc.sessions.submitTurn.mutate({ + requestId, + sessionId: sessionA.id, + threadId: mainThreadA.id, + content: [{ type: "text", text }], + }); + expect(receipt.status).toBe("accepted"); + return receipt; + } + + async function allowPermission(permissionId: string, kind: string) { + const pending = pendingPermissions().find( + (entry) => entry.id === permissionId, + ); + if (!pending) throw new Error(`permission ${permissionId} not pending`); + const option = + pending.options.find((entry) => entry.kind === kind) ?? + pending.options[0]; + if (!option) throw new Error("permission card offered no options"); + const receipt = await host.trpc.sessions.resolvePermission.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + permissionId, + outcome: { type: "selected", optionIds: [option.id] }, + }); + expect(receipt.status).toBe("accepted"); + } + + /** + * Answer a card, then wait for its permissionResolved to fold back into + * the store. The tRPC ack races the WebSocket event by design (admission + * vs completion), so a caller that immediately asserts "no cards pending" + * would otherwise trip over the card it just answered. + */ + async function allowPermissionAndDrain(permissionId: string, kind: string) { + await allowPermission(permissionId, kind); + await waitFor( + () => !pendingPermissions().some((entry) => entry.id === permissionId), + 30_000, + `the answered card ${permissionId} to drain from the store`, + ); + } + + /** Await the turn's end; fail fast if any permission card appears. */ + async function expectNoCardTurn( + turnId: string, + timeoutMs = 240_000, + ): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + const end = turnEnd(turnId); + if (end) { + if (end.kind !== "completed") { + throw new Error(`expected completion, turn was ${end.kind}`); + } + return end.stopReason; + } + const pending = pendingPermissions(); + if (pending.length > 0) { + throw new Error( + `unexpected permission card during a no-card turn: ${toolCallTitles(pending[0]?.toolCallId ?? "").join(" / ")}`, + ); + } + if (Date.now() > deadline) { + throw new Error(`no-card turn did not finish within ${timeoutMs}ms`); + } + await sleep(250); + } + } + + /** Await the turn's end, answering every card with allow-once. */ + async function settleTurnAllowingCards( + turnId: string, + timeoutMs = 300_000, + ): Promise { + const answered = new Set(); + const deadline = Date.now() + timeoutMs; + while (true) { + const end = turnEnd(turnId); + if (end) { + if (end.kind !== "completed") { + throw new Error(`expected completion, turn was ${end.kind}`); + } + return end.stopReason; + } + for (const pending of pendingPermissions()) { + if (answered.has(pending.id)) continue; + answered.add(pending.id); + await allowPermission(pending.id, "allowOnce"); + } + if (Date.now() > deadline) { + throw new Error(`turn did not settle within ${timeoutMs}ms`); + } + await sleep(250); + } + } + + beforeAll(async () => { + gymDir = provisionGym("sessions-story-"); + manager = new AcpSessionManager({ + resolveWorkspaceCwd: () => gymDir, + }); + host = await createTestHost({ acpSessions: manager }); + server = await new Promise((resolve) => { + const instance = serve({ fetch: host.app.fetch, port: 0 }, () => + resolve(instance), + ); + }); + host.injectWebSocket(server); + const { port: httpPort } = server.address() as AddressInfo; + client = createSessionsSyncClient({ + clientInstanceId: "story-client-1", + clientVersion: "0.0.0-test", + syncUrl: `ws://127.0.0.1:${httpPort}${SESSIONS_SYNC_PATH}?token=${host.psk}`, + api: { + list: () => host.trpc.sessions.list.query(), + get: (input) => host.trpc.sessions.get.query(input), + getEvents: (input) => host.trpc.sessions.getEvents.query(input), + resolveToolCall: async (input) => { + await host.trpc.sessions.resolveToolCall.mutate(input); + }, + }, + logger: { log: (event) => logs.push(event) }, + }); + client.connect(); + await waitFor( + () => client.store.getState().hostSubscription.status === "live", + 15_000, + "the host stream to go live", + ); + + // Story step 2: create the session with the model pinned and the mode + // left at the harness default (never bypass). + const created = await host.trpc.sessions.create.mutate({ + requestId: nextRequestId(), + workspaceId: "sessions-story-workspace", + agentId: "claude-code", + title: "Gym story", + settings: { + activeModel: STORY_MODEL, + activeMode: null, + effort: STORY_EFFORT, + configuration: {}, + }, + }); + sessionA = created.session; + mainThreadA = created.mainThread; + // Settings application is best-effort on create; the story depends on + // the pin actually taking, so fail loudly here rather than mid-story. + // The adapter may canonicalize to an account variant (opus → opus[1m]), + // and the entity reports the adapter's truth, not the requested value. + expect(created.session.settings.activeModel ?? "").toContain(STORY_MODEL); + expect(created.session.settings.activeMode).toBe("default"); + + client.retainSession(sessionA.id, "focused"); + await waitFor( + () => + client.store.getState().streamsBySessionId[sessionA.id]?.status === + "live", + 15_000, + "the story session stream to go live", + ); + }, 120_000); + + afterAll(async () => { + client?.disconnect(); + // host.dispose() also disposes the injected AcpSessionManager (app.ts + // owns adapter teardown). + await host?.dispose(); + // Not awaited: Bun's node:http shim never fires the close callback. + server?.close(); + if (gymDir) { + try { + rmSync(gymDir, { recursive: true, force: true }); + } catch { + // best-effort + } + } + }); + + test("steps 2-5: first message streams, Stop interrupts mid-tour, continue reaches TOUR_DONE", async () => { + const receipt = await submitTurn(FIRST_MESSAGE); + + // Step 3: the tour streams into the client store live. + await waitFor( + () => agentText().length > 300, + 120_000, + "the tour to start streaming", + ); + expect(agentText()).not.toContain("TOUR_DONE"); + + // The admission chain is intact: the turn this receipt opened carries + // the mutation's requestId as its causation. + const started = events().find( + (event) => + event.payload.type === "turnStarted" && + event.payload.turn.id === receipt.turnId, + ); + if (!started) throw new Error("turnStarted never reached the client"); + expect(started.causationId).toBe(receipt.requestId); + + // Step 4: Stop while text is still arriving. + const cancel = await host.trpc.sessions.cancelTurn.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + turnId: receipt.turnId, + }); + expect(cancel.status).toBe("accepted"); + await waitFor( + () => turnEnd(receipt.turnId) !== null, + 60_000, + "the interrupted turn to settle", + ); + expect(turnEnd(receipt.turnId)).toEqual({ kind: "cancelled" }); + await waitFor( + () => sessionEntity().runState === "idle", + 30_000, + "the session to return to idle", + ); + expect(pendingPermissions()).toEqual([]); + const partial = agentText(); + expect(partial.length).toBeGreaterThan(0); + expect(partial).not.toContain("TOUR_DONE"); + + // Step 5: a bare "continue" resumes the same conversation. + const resume = await submitTurn("continue"); + expect(await expectNoCardTurn(resume.turnId)).toBe("endTurn"); + expect(agentText()).toContain("TOUR_DONE"); + }, 300_000); + + test("steps 6-7: Bash raises a card, allow-once runs it, double answer is rejected as stale", async () => { + const before = agentText().length; + const { turnId } = await submitTurn( + "Run exactly `sh scripts/ok.sh` with the Bash tool exactly once, then reply with exactly RUN_OK followed by the script's output. Do not use any other tool.", + ); + await waitFor( + () => pendingPermissions().length > 0, + 120_000, + "the Bash permission card", + ); + // "Needs attention", not "stuck": the card sits in pendingPermissions + // while the turn stays formally running — attention badges are a + // client-side derivation over this, not protocol state. + expect(sessionEntity().runState).toBe("running"); + const card = pendingPermissions()[0]; + if (!card) throw new Error("Bash permission disappeared"); + + const kinds = card.options.map((option) => option.kind); + expect(kinds).toContain("allowOnce"); + expect(kinds).toContain("allowAlways"); + expect(kinds).toContain("rejectOnce"); + + await allowPermissionAndDrain(card.id, "allowOnce"); + // Step 7: tapping Allow twice — the second answer targets a permission + // that no longer exists and is rejected without side effects. + await expect( + host.trpc.sessions.resolvePermission.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + permissionId: card.id, + outcome: { + type: "selected", + optionIds: [card.options[0]?.id ?? "allow"], + }, + }), + ).rejects.toThrow(/No pending permission/); + + expect(await expectNoCardTurn(turnId)).toBe("endTurn"); + const reply = agentText().slice(before); + expect(reply).toContain("RUN_OK"); + expect(reply).toContain(GYM_SCRIPT_SENTINEL); + }, 300_000); + + test("steps 8-9: always-allow persists — the same command reruns with zero new cards", async () => { + // Step 8: run again, answer with always-allow this time. + const { turnId } = await submitTurn( + "Run exactly `sh scripts/ok.sh` with the Bash tool exactly once more, then reply with exactly RUN_OK followed by the script's output. Do not use any other tool.", + ); + await waitFor( + () => pendingPermissions().length > 0, + 120_000, + "the second Bash permission card", + ); + const card = pendingPermissions()[0]; + if (!card) throw new Error("second Bash permission disappeared"); + await allowPermissionAndDrain(card.id, "allowAlways"); + expect(await expectNoCardTurn(turnId)).toBe("endTurn"); + + // Step 9: the rule holds — two more runs, no card ever appears. + const since = marker(); + const before = agentText().length; + const rerun = await submitTurn( + "Run exactly `sh scripts/ok.sh` with the Bash tool exactly twice, one call after the other, then reply with exactly RUN_TWICE_OK. Do not use any other tool.", + ); + expect(await expectNoCardTurn(rerun.turnId)).toBe("endTurn"); + expect(permissionRequestsSince(since)).toBe(0); + expect(agentText().slice(before)).toContain("RUN_TWICE_OK"); + }, 300_000); + + test("steps 10-11: an edit raises a fresh card; Stop + acceptEdits + continue finishes without one", async () => { + // Step 10: always-allow was per-tool — a file edit still cards. + const since = marker(); + const { turnId } = await submitTurn( + 'Change the title of README.md to "Gym" using a file editing tool, then reply with exactly EDIT_DONE. Do not use the Bash tool.', + ); + await waitFor( + () => pendingPermissions().length > 0, + 120_000, + "the edit permission card", + ); + const editTools = toolNamesSince(since); + expect(editTools.some((name) => ["Edit", "Write"].includes(name))).toBe( + true, + ); + expect(editTools).not.toContain("Bash"); + + // Step 11: Stop instead of answering, switch the session to an + // auto-accepting mode over tRPC, then continue. + await host.trpc.sessions.cancelTurn.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + turnId, + }); + await waitFor( + () => turnEnd(turnId) !== null, + 60_000, + "the cancelled edit turn to settle", + ); + await waitFor( + () => pendingPermissions().length === 0, + 30_000, + "the abandoned card to drain", + ); + + const settings = await host.trpc.sessions.update.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + settings: { activeMode: "acceptEdits" }, + }); + expect(settings.status).toBe("accepted"); + await waitFor( + () => sessionEntity().settings.activeMode === "acceptEdits", + 30_000, + "the mode change to reach the client store", + ); + + const resumeSince = marker(); + const before = agentText().length; + // A bare "continue" is ambiguous after a rejected edit — be explicit + // that the mode change means the edit should be applied now. + const resume = await submitTurn( + 'Continue: I switched this session to acceptEdits mode, so apply that README.md title change to "Gym" now, then reply with exactly EDIT_DONE. Do not use the Bash tool.', + ); + expect(await expectNoCardTurn(resume.turnId)).toBe("endTurn"); + expect(permissionRequestsSince(resumeSince)).toBe(0); + expect(agentText().slice(before)).toContain("EDIT_DONE"); + + // The end state is on disk, not just in the transcript. + const readme = readFileSync(path.join(gymDir, "README.md"), "utf8"); + expect(readme).toMatch(/^# Gym\s*$/m); + expect(readme).not.toContain("Gym fixture"); + }, 300_000); + + test("steps 12-13: two questions arrive as sequential cards; answer one, skip the other", async () => { + const since = marker(); + const before = agentText().length; + const { turnId } = await submitTurn( + 'Use the AskUserQuestion tool exactly once with two questions. Question one: "Pick a color" with exactly two options, red and blue. Question two: "Pick a number" with exactly two options, 1 and 2. Do not answer them yourself. After receiving the answers, reply with exactly ANSWERS followed by the answers you received.', + ); + + // The first question surfaces alone; the second only after it resolves. + await waitFor( + () => pendingPermissions().length > 0, + 120_000, + "the first question card", + ); + const colorCard = pendingPermissions()[0]; + if (!colorCard) throw new Error("color question disappeared"); + expect(toolCallTitles(colorCard.toolCallId).join(" ")).toContain( + "Pick a color", + ); + const optionNames = colorCard.options.map((option) => option.name); + expect(optionNames).toContain("red"); + expect(optionNames).toContain("blue"); + expect(optionNames).toContain("Skip"); + const blue = colorCard.options.find((option) => option.name === "blue"); + if (!blue) throw new Error("blue option missing"); + await host.trpc.sessions.resolvePermission.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + permissionId: colorCard.id, + outcome: { type: "selected", optionIds: [blue.id] }, + }); + await waitFor( + () => !pendingPermissions().some((entry) => entry.id === colorCard.id), + 30_000, + "the answered color card to drain", + ); + + await waitFor( + () => { + const pending = pendingPermissions(); + return pending.length > 0 && pending[0]?.id !== colorCard.id; + }, + 120_000, + "the second question card", + ); + const numberCard = pendingPermissions()[0]; + if (!numberCard) throw new Error("number question disappeared"); + expect(toolCallTitles(numberCard.toolCallId).join(" ")).toContain( + "Pick a number", + ); + const skip = + numberCard.options.find((option) => option.name === "Skip") ?? + numberCard.options.find((option) => option.id === "skip"); + if (!skip) throw new Error("Skip option missing"); + await host.trpc.sessions.resolvePermission.mutate({ + requestId: nextRequestId(), + sessionId: sessionA.id, + permissionId: numberCard.id, + outcome: { type: "selected", optionIds: [skip.id] }, + }); + await waitFor( + () => !pendingPermissions().some((entry) => entry.id === numberCard.id), + 30_000, + "the skipped number card to drain", + ); + + expect(await expectNoCardTurn(turnId)).toBe("endTurn"); + const reply = agentText().slice(before); + expect(reply).toContain("ANSWERS"); + expect(reply).toContain("blue"); + // Exactly one canonical card per question — no dupes, no extras. + expect(permissionRequestsSince(since)).toBe(2); + }, 300_000); + + test("step 14: a Task subagent surfaces as its own thread and reports through the main one", async () => { + const since = marker(); + const before = agentText().length; + const { turnId } = await submitTurn( + "Launch exactly one subagent that reads notes.txt and reports the expected_sum value. Then reply with exactly SUB_OK followed by that value.", + ); + expect(await settleTurnAllowingCards(turnId)).toBe("endTurn"); + + // The subagent tool is named Task or Agent depending on SDK version. + const subagentCalls = toolNamesSince(since).filter((name) => + ["Task", "Agent"].includes(name), + ); + expect(subagentCalls.length).toBeGreaterThan(0); + // The canonical surface's target contract: child activity lands in a + // partial-fidelity subagent thread, never flattened away. + const subThreads = events() + .slice(since) + .flatMap((event) => + event.payload.type === "threadCreated" && + event.payload.thread.kind === "subagent" + ? [event.payload.thread] + : [], + ); + expect(subThreads.length).toBeGreaterThan(0); + expect(subThreads[0]?.fidelity).toBe("partial"); + expect(subThreads[0]?.parentThreadId).toBe(mainThreadA.id); + expect(agentText().slice(before)).toContain("SUB_OK 42"); + }, 300_000); + + test("step 15: the saved gym workflow launches, runs its five agents, and verifies", async () => { + const since = marker(); + const before = agentText().length; + const { turnId } = await submitTurn( + `Launch the named workflow ${GYM_WORKFLOW_NAME} exactly once with the Workflow tool. Do not perform its work yourself and do not use another tool. The Workflow tool runs asynchronously; after it confirms the background launch, reply with exactly WORKFLOW_LAUNCHED and do not cancel it.`, + ); + expect(await settleTurnAllowingCards(turnId)).toBe("endTurn"); + expect(agentText().slice(before)).toContain("WORKFLOW_LAUNCHED"); + expect(toolNamesSince(since)).toContain("Workflow"); + + // The ACP bridge exposes the structured launch payload (runId, + // transcriptDir) only via private _meta, which the canonical surface + // deliberately does not forward — a typed workflow run model is an + // explicit rollout backlog item. Until then, find this run's state + // file on disk under the gym's Claude project directory. + type WorkflowRunState = { + workflowName?: string; + status?: string; + agentCount?: number; + result?: { verified?: { valid?: boolean; marker?: string } }; + }; + const projectsDir = path.join(os.homedir(), ".claude", "projects"); + const gymKey = path.basename(gymDir); + const readRunStates = (workflowsDir: string): WorkflowRunState[] => { + if (!existsSync(workflowsDir)) return []; + return readdirSync(workflowsDir) + .filter((file) => file.endsWith(".json")) + .flatMap((file): WorkflowRunState[] => { + try { + return [ + JSON.parse( + readFileSync(path.join(workflowsDir, file), "utf8"), + ) as WorkflowRunState, + ]; + } catch { + return []; + } + }); + }; + // Run state lands at //workflows/.json; + // scan one directory level under the gym's project dir (and the project + // dir itself, in case the layout flattens in a future harness version). + const findRunStates = (): WorkflowRunState[] => { + if (!existsSync(projectsDir)) return []; + return readdirSync(projectsDir) + .filter((name) => name.includes(gymKey)) + .flatMap((name) => { + const projectDir = path.join(projectsDir, name); + const nested = readdirSync(projectDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(projectDir, entry.name, "workflows")); + return [path.join(projectDir, "workflows"), ...nested].flatMap( + readRunStates, + ); + }); + }; + let finished: WorkflowRunState | null = null; + await waitFor( + () => { + const runs = findRunStates().filter( + (run) => run.workflowName === GYM_WORKFLOW_NAME, + ); + const dead = runs.find((run) => + ["failed", "killed", "cancelled"].includes(run.status ?? ""), + ); + if (dead) { + throw new Error( + `Workflow run did not complete: ${JSON.stringify(dead)}`, + ); + } + finished = runs.find((run) => run.status === "completed") ?? null; + return finished !== null; + }, + 240_000, + "the gym workflow agents to complete", + ); + if (!finished) throw new Error("Workflow completed without run state"); + const runState = finished as WorkflowRunState; + expect(runState.agentCount).toBe(5); + expect(runState.result?.verified?.valid).toBe(true); + expect(runState.result?.verified?.marker).toBe("WORKFLOW_VERIFIED"); + }, 600_000); + + test("step 16: the project gym-check skill runs and reports its sentinel", async () => { + const since = marker(); + const before = agentText().length; + const { turnId } = await submitTurn( + "Use the Skill tool to run the gym-check skill from this repository, then follow the skill's instructions exactly.", + ); + expect(await settleTurnAllowingCards(turnId)).toBe("endTurn"); + expect(toolNamesSince(since)).toContain("Skill"); + expect(agentText().slice(before)).toContain(GYM_SKILL_SENTINEL); + }, 300_000); + + test("step 17: a second session streams in parallel on the same socket with no bleed", async () => { + // Keep the main session busy with a long no-tools turn... + const essayBefore = agentText().length; + const essay = await submitTurn( + "Write a continuous ~500-word essay about training gyms in plain prose, using no tools, and end with the line ESSAY_DONE.", + ); + await waitFor( + () => agentText().length > essayBefore + 100, + 120_000, + "the essay to start streaming", + ); + + // ...then run a whole second session start-to-finish while it streams, + // over the SAME client store and physical socket. + const createdB = await host.trpc.sessions.create.mutate({ + requestId: nextRequestId(), + workspaceId: "sessions-story-workspace", + agentId: "claude-code", + title: "Gym story parallel", + settings: { + activeModel: STORY_MODEL, + activeMode: null, + effort: STORY_EFFORT, + configuration: {}, + }, + }); + expect(createdB.session.settings.activeModel ?? "").toContain(STORY_MODEL); + client.retainSession(createdB.session.id, "focused"); + await waitFor( + () => + client.store.getState().streamsBySessionId[createdB.session.id] + ?.status === "live", + 30_000, + "session B to go live on the shared socket", + ); + + let overlapped = false; + let secondDone = false; + const overlapWatch = (async () => { + while (!secondDone) { + if (sessionEntity().runState === "running") overlapped = true; + await sleep(200); + } + })(); + + const receiptB = await host.trpc.sessions.submitTurn.mutate({ + requestId: nextRequestId(), + sessionId: createdB.session.id, + threadId: createdB.mainThread.id, + content: [ + { + type: "text", + text: "Reply with exactly PARALLEL_OK and nothing else.", + }, + ], + }); + await waitFor( + () => + ( + client.store.getState().streamsBySessionId[createdB.session.id] + ?.eventIds ?? [] + ).some((id) => { + const stream = + client.store.getState().streamsBySessionId[createdB.session.id]; + const event = stream?.eventsById[id]; + return ( + event?.payload.type === "turnCompleted" && + event.payload.turnId === receiptB.turnId + ); + }), + 240_000, + "session B's turn to complete", + ); + secondDone = true; + await overlapWatch; + expect(overlapped).toBe(true); + + // Session B's stream carries only its own story. + const streamB = + client.store.getState().streamsBySessionId[createdB.session.id]; + if (!streamB) throw new Error("session B stream missing"); + const textB = streamB.eventIds + .map((id) => streamB.eventsById[id]) + .flatMap((event) => + event?.payload.type === "messageDelta" && + event.payload.content.type === "text" + ? [event.payload.content.text] + : [], + ) + .join(""); + expect(textB).toContain("PARALLEL_OK"); + expect(textB).not.toContain("ESSAY_DONE"); + + expect(await expectNoCardTurn(essay.turnId)).toBe("endTurn"); + expect(agentText()).toContain("ESSAY_DONE"); + expect(agentText()).not.toContain("PARALLEL_OK"); + }, 300_000); + + test("the wire stayed clean and the store matches the host's own answers", async () => { + // The client hard-validates every frame and force-closes on mismatch, + // so zero drops/resets makes the whole story a wire-conformance run. + const drops = logs.filter( + (event) => + event.event === "sessions_sync.socket_dropped" || + event.event === "sessions_sync.stream_reset", + ); + expect(drops).toEqual([]); + expect(client.store.getState().connection.error).toBeNull(); + + // Parity oracle: page the whole log backwards over tRPC and compare it + // to the client store's fold, event for event. The client retained the + // session at birth (the seed tail held the entire young log), so its + // fold must equal the host's full journal, and the entities must agree. + const full: SessionEvent[] = []; + let beforeCursor: string | undefined; + while (true) { + const window = await host.trpc.sessions.getEvents.query({ + sessionId: sessionA.id, + ...(beforeCursor === undefined ? {} : { beforeCursor }), + limit: 100, + }); + full.unshift(...window.items); + if (!window.range.hasMoreBefore) break; + const oldest = window.range.oldest; + if (!oldest) throw new Error("hasMoreBefore with no oldest boundary"); + beforeCursor = oldest.cursor; + } + expect(full.length).toBeGreaterThan(0); + expect(events()).toEqual(full); + + const snapshot = await host.trpc.sessions.get.query({ + sessionId: sessionA.id, + }); + expect(sessionEntity()).toEqual(snapshot.session); + expect(snapshot.session.runState).toBe("idle"); + expect(snapshot.pendingPermissions).toEqual([]); + expect(pendingPermissions()).toEqual([]); + // The story is quiescent, so the snapshot head is the fold's head. + const streamA = client.store.getState().streamsBySessionId[sessionA.id]; + expect(streamA?.latestCursor).toBe(snapshot.head); + }); +}); diff --git a/packages/session-protocol/package.json b/packages/session-protocol/package.json index 3d3e1abc7fe..4d23a44ccac 100644 --- a/packages/session-protocol/package.json +++ b/packages/session-protocol/package.json @@ -7,14 +7,6 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" - }, - "./client": { - "types": "./src/client/index.ts", - "default": "./src/client/index.ts" - }, - "./react": { - "types": "./src/react/index.ts", - "default": "./src/react/index.ts" } }, "scripts": { @@ -25,19 +17,10 @@ "@agentclientprotocol/sdk": "1.2.0", "zod": "4.3.6" }, - "peerDependencies": { - "react": "19.2.3" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - }, "devDependencies": { "@superset/typescript": "workspace:*", "@types/bun": "1.3.14", "@types/node": "24.12.0", - "@types/react": "19.2.14", "typescript": "6.0.3" } } diff --git a/packages/session-protocol/src/acp.ts b/packages/session-protocol/src/acp.ts index 2d9692007c3..f4ea6d6c493 100644 --- a/packages/session-protocol/src/acp.ts +++ b/packages/session-protocol/src/acp.ts @@ -1,7 +1,7 @@ /** - * The single source of ACP typing for both sides of the wire (Decision D7 in - * packages/host-service/docs/acp-sessions.md): host-service and every client import ACP - * types ONLY through this file — the ENTIRE generated surface, never a + * The single source of ACP typing for both sides of the wire (legacy ACP + * surface; being replaced by plans/host-sessions-sync.md): host-service and + * every client import ACP types ONLY through this file — the ENTIRE generated surface, never a * hand-picked subset. * * Type-only on purpose. Runtime connection machinery (client(), ndJsonStream) diff --git a/packages/session-protocol/src/client/index.ts b/packages/session-protocol/src/client/index.ts deleted file mode 100644 index b5dc7a401d3..00000000000 --- a/packages/session-protocol/src/client/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./subscribeToSession"; diff --git a/packages/session-protocol/src/client/subscribeToSession/index.ts b/packages/session-protocol/src/client/subscribeToSession/index.ts deleted file mode 100644 index b5dc7a401d3..00000000000 --- a/packages/session-protocol/src/client/subscribeToSession/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./subscribeToSession"; diff --git a/packages/session-protocol/src/client/subscribeToSession/subscribeToSession.test.ts b/packages/session-protocol/src/client/subscribeToSession/subscribeToSession.test.ts deleted file mode 100644 index de099e3db46..00000000000 --- a/packages/session-protocol/src/client/subscribeToSession/subscribeToSession.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { SessionUpdateEnvelope } from "../../envelope"; -import { type StreamStatus, subscribeToSession } from "./subscribeToSession"; - -class FakeWebSocket { - url: string; - onopen: (() => void) | null = null; - onmessage: ((event: { data: unknown }) => void) | null = null; - onclose: ((event: { code?: number; reason?: string }) => void) | null = null; - onerror: ((event: unknown) => void) | null = null; - closedByClient = false; - - constructor(url: string) { - this.url = url; - } - - close(): void { - this.closedByClient = true; - } - - open(): void { - this.onopen?.(); - } - - message(envelope: SessionUpdateEnvelope): void { - this.onmessage?.({ data: JSON.stringify(envelope) }); - } - - serverClose(): void { - this.onclose?.({ code: 1006 }); - } -} - -function harness(options?: { since?: number; url?: string }) { - const sockets: FakeWebSocket[] = []; - const delivered: SessionUpdateEnvelope[] = []; - const statuses: StreamStatus[] = []; - const gaps: Array<{ expected: number; received: number }> = []; - const resets: string[] = []; - const subscription = subscribeToSession({ - streamUrl: options?.url ?? "ws://test/stream", - since: options?.since, - onEnvelope: (e) => delivered.push(e), - onStatus: (s) => statuses.push(s), - onGap: (g) => gaps.push(g), - onReset: (r) => resets.push(r), - createWebSocket: (url) => { - const ws = new FakeWebSocket(url); - sockets.push(ws); - return ws; - }, - reconnectDelayMs: 1, - }); - return { sockets, delivered, statuses, gaps, resets, subscription }; -} - -function env(seq: number): SessionUpdateEnvelope { - return { - seq, - sessionId: "sess-1", - ts: seq, - frame: { - kind: "update", - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: `#${seq}` }, - }, - }, - }; -} - -function resetEnv(seq: number, reason: string): SessionUpdateEnvelope { - return { - seq, - sessionId: "sess-1", - ts: seq, - frame: { kind: "reset", reason }, - }; -} - -const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms)); - -describe("subscribeToSession", () => { - test("appends since to the url and delivers envelopes in order", () => { - const h = harness({ since: 10 }); - expect(h.sockets).toHaveLength(1); - expect(h.sockets[0]?.url).toBe("ws://test/stream?since=10"); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(11)); - h.sockets[0]?.message(env(12)); - expect(h.delivered.map((e) => e.seq)).toEqual([11, 12]); - expect(h.subscription.lastSeq).toBe(12); - h.subscription.close(); - }); - - test("uses & when the stream url already has a query string", () => { - const h = harness({ since: 3, url: "ws://test/stream?token=abc" }); - expect(h.sockets[0]?.url).toBe("ws://test/stream?token=abc&since=3"); - h.subscription.close(); - }); - - test("drops duplicate envelopes (seq <= lastSeq)", () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(1)); - h.sockets[0]?.message(env(1)); - h.sockets[0]?.message(env(2)); - expect(h.delivered.map((e) => e.seq)).toEqual([1, 2]); - h.subscription.close(); - }); - - test("without since, accepts the first envelope at any seq, then enforces continuity", async () => { - const h = harness(); - expect(h.sockets[0]?.url).toBe("ws://test/stream"); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(41)); - expect(h.delivered.map((e) => e.seq)).toEqual([41]); - h.sockets[0]?.message(env(43)); // now a real gap - expect(h.gaps).toEqual([{ expected: 42, received: 43 }]); - h.subscription.close(); - }); - - test("gap closes the socket and reconnects with since=lastSeq", async () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(1)); - h.sockets[0]?.message(env(5)); // gap: expected 2 - expect(h.gaps).toEqual([{ expected: 2, received: 5 }]); - expect(h.sockets[0]?.closedByClient).toBe(true); - expect(h.delivered.map((e) => e.seq)).toEqual([1]); - await tick(); - expect(h.sockets).toHaveLength(2); - expect(h.sockets[1]?.url).toBe("ws://test/stream?since=1"); - h.sockets[1]?.open(); - h.sockets[1]?.message(env(2)); - expect(h.delivered.map((e) => e.seq)).toEqual([1, 2]); - h.subscription.close(); - }); - - test("server close triggers reconnect from the last delivered seq", async () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(1)); - h.sockets[0]?.serverClose(); - await tick(); - expect(h.sockets).toHaveLength(2); - expect(h.sockets[1]?.url).toBe("ws://test/stream?since=1"); - expect(h.statuses).toContain("reconnecting"); - h.subscription.close(); - }); - - test("reset frame stops the stream and reports the reason; no reconnect", async () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(1)); - h.sockets[0]?.message(resetEnv(2, "journal_evicted")); - expect(h.resets).toEqual(["journal_evicted"]); - expect(h.statuses[h.statuses.length - 1]).toBe("stopped"); - await tick(); - expect(h.sockets).toHaveLength(1); - h.subscription.close(); - }); - - test("user close stops reconnection", async () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.subscription.close(); - expect(h.sockets[0]?.closedByClient).toBe(true); - await tick(); - expect(h.sockets).toHaveLength(1); - expect(h.statuses[h.statuses.length - 1]).toBe("stopped"); - }); - - test("async streamUrl factory mints a fresh url on every (re)connect", async () => { - let minted = 0; - const sockets: FakeWebSocket[] = []; - const subscription = subscribeToSession({ - streamUrl: async () => { - minted += 1; - return `ws://test/stream?token=t${minted}`; - }, - since: 0, - onEnvelope: () => {}, - createWebSocket: (url) => { - const ws = new FakeWebSocket(url); - sockets.push(ws); - return ws; - }, - reconnectDelayMs: 1, - }); - await tick(); - expect(sockets[0]?.url).toBe("ws://test/stream?token=t1&since=0"); - sockets[0]?.open(); - sockets[0]?.message(env(1)); - sockets[0]?.serverClose(); - await tick(); - // The reconnect re-invoked the factory (fresh token), not the stale URL. - expect(sockets[1]?.url).toBe("ws://test/stream?token=t2&since=1"); - subscription.close(); - }); - - test("streamUrl factory rejection retries with backoff instead of dying", async () => { - let calls = 0; - const sockets: FakeWebSocket[] = []; - const subscription = subscribeToSession({ - streamUrl: async () => { - calls += 1; - if (calls === 1) throw new Error("token mint failed"); - return "ws://test/stream"; - }, - since: 0, - onEnvelope: () => {}, - createWebSocket: (url) => { - const ws = new FakeWebSocket(url); - sockets.push(ws); - return ws; - }, - reconnectDelayMs: 1, - }); - await tick(); - expect(calls).toBeGreaterThanOrEqual(2); - expect(sockets).toHaveLength(1); - subscription.close(); - }); - - test("malformed frame forces a reconnect from the last good cursor", async () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(1)); - h.sockets[0]?.onmessage?.({ data: "not json{{{" }); - expect(h.sockets[0]?.closedByClient).toBe(true); - await tick(); - expect(h.sockets).toHaveLength(2); - expect(h.sockets[1]?.url).toBe("ws://test/stream?since=1"); - h.subscription.close(); - }); - - test("valid JSON that is not an envelope resyncs instead of crashing", async () => { - const h = harness({ since: 0 }); - h.sockets[0]?.open(); - h.sockets[0]?.message(env(1)); - // A relay/proxy error payload: parses fine, has no seq/frame shape. - h.sockets[0]?.onmessage?.({ data: JSON.stringify({ error: "bad" }) }); - expect(h.sockets[0]?.closedByClient).toBe(true); - expect(h.delivered.map((e) => e.seq)).toEqual([1]); - await tick(); - expect(h.sockets).toHaveLength(2); - expect(h.sockets[1]?.url).toBe("ws://test/stream?since=1"); - h.subscription.close(); - }); - - test("a throwing createWebSocket during reconnect keeps the retry loop alive", async () => { - let calls = 0; - const sockets: FakeWebSocket[] = []; - const subscription = subscribeToSession({ - streamUrl: "ws://test/stream", - since: 0, - onEnvelope: () => {}, - createWebSocket: (url) => { - calls += 1; - if (calls === 2) throw new Error("socket construction failed"); - const ws = new FakeWebSocket(url); - sockets.push(ws); - return ws; - }, - reconnectDelayMs: 1, - }); - sockets[0]?.open(); - sockets[0]?.serverClose(); // attempt 2 throws in the reconnect timer - await tick(20); - // Attempt 3 recovered instead of the loop dying silently. - expect(calls).toBeGreaterThanOrEqual(3); - expect(sockets.length).toBeGreaterThanOrEqual(2); - subscription.close(); - }); -}); diff --git a/packages/session-protocol/src/client/subscribeToSession/subscribeToSession.ts b/packages/session-protocol/src/client/subscribeToSession/subscribeToSession.ts deleted file mode 100644 index dbfe125f55e..00000000000 --- a/packages/session-protocol/src/client/subscribeToSession/subscribeToSession.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { SessionUpdateEnvelope } from "../../envelope"; - -/** - * The subset of the WebSocket API the stream client uses. Matches browser, - * React Native, and bun WebSockets; tests inject a fake. - */ -export interface WebSocketLike { - onopen: (() => void) | null; - onmessage: ((event: { data: unknown }) => void) | null; - onclose: ((event: { code?: number; reason?: string }) => void) | null; - onerror: ((event: unknown) => void) | null; - close(code?: number, reason?: string): void; -} - -export type StreamStatus = "connecting" | "open" | "reconnecting" | "stopped"; - -export interface SubscribeToSessionOptions { - /** - * WS endpoint, e.g. wss://host/acp-sessions//stream. Pass a function - * when the URL embeds short-lived credentials (a JWT query param): it is - * called before every connection attempt, so reconnects after token expiry - * mint a fresh URL instead of replaying a dead one forever. - */ - streamUrl: string | (() => string | Promise); - /** - * Replay from this seq (exclusive): server sends journaled envelopes with - * seq > since, then goes live. Omit to accept the live stream from wherever - * it currently is. - */ - since?: number; - onEnvelope: (envelope: SessionUpdateEnvelope) => void; - /** - * A reset frame means the server cannot serve our cursor (journal evicted, - * session restarted). The subscription stops; the caller must resync - * (get + getMessages) and subscribe again. - */ - onReset?: (reason: string) => void; - onStatus?: (status: StreamStatus) => void; - /** Fired when a seq gap is detected, right before auto-reconnect. */ - onGap?: (info: { expected: number; received: number }) => void; - /** Injectable for tests / non-global WebSocket environments. */ - createWebSocket?: (url: string) => WebSocketLike; - /** Base reconnect delay; doubles per consecutive failure, capped at 10s. */ - reconnectDelayMs?: number; -} - -export interface SessionSubscription { - close(): void; - /** Seq of the last envelope delivered to onEnvelope. */ - readonly lastSeq: number; -} - -const MAX_RECONNECT_DELAY_MS = 10_000; - -/** - * Structural check on a parsed frame. JSON.parse only proves syntax; a - * proxy/relay error payload is valid JSON but would crash the fold — treat it - * like a corrupt frame (resync from cursor) instead. - */ -function isEnvelope(value: unknown): value is SessionUpdateEnvelope { - if (typeof value !== "object" || value === null) return false; - const candidate = value as { seq?: unknown; frame?: unknown }; - return ( - typeof candidate.seq === "number" && - typeof candidate.frame === "object" && - candidate.frame !== null && - typeof (candidate.frame as { kind?: unknown }).kind === "string" - ); -} - -export function subscribeToSession( - options: SubscribeToSessionOptions, -): SessionSubscription { - const { - streamUrl, - onEnvelope, - onReset, - onStatus, - onGap, - reconnectDelayMs = 250, - } = options; - const createWebSocket = - options.createWebSocket ?? - ((url: string) => new WebSocket(url) as unknown as WebSocketLike); - - // lastSeq doubles as the dedup floor and the reconnect cursor. When the - // caller gave no `since`, we accept the first live envelope at any seq. - let lastSeq = options.since ?? 0; - let hasCursor = options.since !== undefined; - let stopped = false; - let attempts = 0; - let socket: WebSocketLike | null = null; - let reconnectTimer: ReturnType | null = null; - - function withCursor(base: string): string { - if (!hasCursor) return base; - const separator = base.includes("?") ? "&" : "?"; - return `${base}${separator}since=${lastSeq}`; - } - - function stop(status: StreamStatus = "stopped"): void { - if (stopped) return; - stopped = true; - if (reconnectTimer !== null) clearTimeout(reconnectTimer); - const current = socket; - socket = null; - if (current) { - // Detach first so the close event can't schedule a reconnect. - current.onclose = null; - current.onmessage = null; - current.onerror = null; - current.onopen = null; - current.close(); - } - onStatus?.(status); - } - - function scheduleReconnect(): void { - if (stopped) return; - onStatus?.("reconnecting"); - const delay = Math.min( - reconnectDelayMs * 2 ** attempts, - MAX_RECONNECT_DELAY_MS, - ); - attempts += 1; - reconnectTimer = setTimeout(connect, delay); - } - - function handleEnvelope(envelope: SessionUpdateEnvelope): void { - if (envelope.frame.kind === "reset") { - const reason = envelope.frame.reason; - stop(); - onReset?.(reason); - return; - } - if (hasCursor) { - if (envelope.seq <= lastSeq) return; // at-least-once dedup - if (envelope.seq > lastSeq + 1) { - onGap?.({ expected: lastSeq + 1, received: envelope.seq }); - reconnectCurrentSocket(); - return; - } - } - lastSeq = envelope.seq; - hasCursor = true; - onEnvelope(envelope); - } - - function reconnectCurrentSocket(): void { - const current = socket; - socket = null; - if (current) { - current.onclose = null; - current.onmessage = null; - current.onerror = null; - current.onopen = null; - current.close(); - } - scheduleReconnect(); - } - - function connect(): void { - if (stopped) return; - reconnectTimer = null; - onStatus?.(attempts === 0 ? "connecting" : "reconnecting"); - // Static URLs (and sync factories) connect synchronously — only a - // promise-returning factory (token minting) defers the socket. - let base: string | Promise; - try { - base = typeof streamUrl === "function" ? streamUrl() : streamUrl; - } catch { - scheduleReconnect(); - return; - } - if (typeof base === "string") { - openSocket(withCursor(base)); - return; - } - base.then( - (url) => { - if (stopped) return; - openSocket(withCursor(url)); - }, - () => { - // URL/token minting failed (offline, auth hiccup) — retry with the - // same backoff as a dropped socket. - scheduleReconnect(); - }, - ); - } - - function openSocket(url: string): void { - if (stopped) return; - let ws: WebSocketLike; - try { - ws = createWebSocket(url); - } catch { - // A throwing constructor (malformed URL, platform hiccup) inside the - // reconnect timer must not kill the retry loop — same backoff as a - // dropped socket. - scheduleReconnect(); - return; - } - socket = ws; - ws.onopen = () => { - attempts = 0; - onStatus?.("open"); - }; - ws.onmessage = (event) => { - let parsed: unknown; - try { - parsed = JSON.parse(String(event.data)); - } catch { - // Corrupt frame: resync from the last good cursor. - reconnectCurrentSocket(); - return; - } - if (!isEnvelope(parsed)) { - reconnectCurrentSocket(); - return; - } - handleEnvelope(parsed); - }; - ws.onerror = () => { - // The close event follows; reconnect is handled there. - }; - ws.onclose = () => { - if (socket !== ws) return; - socket = null; - scheduleReconnect(); - }; - } - - connect(); - - return { - close: () => stop(), - get lastSeq() { - return lastSeq; - }, - }; -} diff --git a/packages/session-protocol/src/react/index.ts b/packages/session-protocol/src/react/index.ts deleted file mode 100644 index 8d16eff0b85..00000000000 --- a/packages/session-protocol/src/react/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./useAcpPermissions"; -export * from "./useAcpSession"; diff --git a/packages/session-protocol/src/react/useAcpPermissions/index.ts b/packages/session-protocol/src/react/useAcpPermissions/index.ts deleted file mode 100644 index 5567608e94b..00000000000 --- a/packages/session-protocol/src/react/useAcpPermissions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useAcpPermissions"; diff --git a/packages/session-protocol/src/react/useAcpPermissions/useAcpPermissions.ts b/packages/session-protocol/src/react/useAcpPermissions/useAcpPermissions.ts deleted file mode 100644 index 31163beebd3..00000000000 --- a/packages/session-protocol/src/react/useAcpPermissions/useAcpPermissions.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useMemo } from "react"; -import type { RequestPermissionOutcome } from "../../acp"; -import type { RespondToPermissionResult } from "../../api"; -import type { PendingPermission } from "../../state"; -import type { UseAcpSessionResult } from "../useAcpSession"; - -export interface UseAcpPermissionsResult { - /** Permission requests currently blocking the agent, oldest first. */ - pending: PendingPermission[]; - respond( - requestId: string, - outcome: RequestPermissionOutcome, - ): Promise; - /** Answers with the request's first allow_once option. */ - allowOnce(requestId: string): Promise; - /** Answers with the request's first reject_once option. */ - rejectOnce(requestId: string): Promise; -} - -/** - * Convenience selector over useAcpSession for permission UI: the blocking - * requests plus answer helpers. For anything beyond one-shot allow/reject - * (e.g. allow_always), render the request's `options` and call `respond`. - */ -export function useAcpPermissions( - session: UseAcpSessionResult, -): UseAcpPermissionsResult { - const pending = session.state?.pendingPermissions; - const { respondToPermission } = session.actions; - return useMemo(() => { - const requests = pending ?? []; - const respond = (requestId: string, outcome: RequestPermissionOutcome) => - respondToPermission(requestId, outcome); - const respondByKind = ( - requestId: string, - kind: "allow_once" | "reject_once", - ): Promise => { - const request = requests.find((p) => p.requestId === requestId); - const option = request?.options.find((o) => o.kind === kind); - if (!option) { - return Promise.reject( - new Error(`no ${kind} option on permission request ${requestId}`), - ); - } - return respond(requestId, { - outcome: "selected", - optionId: option.optionId, - }); - }; - return { - pending: requests, - respond, - allowOnce: (requestId) => respondByKind(requestId, "allow_once"), - rejectOnce: (requestId) => respondByKind(requestId, "reject_once"), - }; - }, [pending, respondToPermission]); -} diff --git a/packages/session-protocol/src/react/useAcpSession/index.ts b/packages/session-protocol/src/react/useAcpSession/index.ts deleted file mode 100644 index 5fa58528a0b..00000000000 --- a/packages/session-protocol/src/react/useAcpSession/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useAcpSession"; diff --git a/packages/session-protocol/src/react/useAcpSession/useAcpSession.ts b/packages/session-protocol/src/react/useAcpSession/useAcpSession.ts deleted file mode 100644 index 621a4348aec..00000000000 --- a/packages/session-protocol/src/react/useAcpSession/useAcpSession.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ContentBlock, RequestPermissionOutcome } from "../../acp"; -import type { - AcpSessionsApi, - PromptAccepted, - RespondToPermissionResult, -} from "../../api"; -import { - type SessionSubscription, - type StreamStatus, - subscribeToSession, - type WebSocketLike, -} from "../../client"; -import type { SessionUpdateEnvelope } from "../../envelope"; -import { - emptyTimeline, - type FoldedTimeline, - foldEnvelope, - foldEnvelopes, -} from "../../fold"; -import type { SessionScopedState } from "../../state"; - -export interface UseAcpSessionOptions { - sessionId: string; - /** Transport for commands + catch-up reads (a tRPC client fits). */ - api: AcpSessionsApi; - /** - * WS endpoint for this session's update stream. Pass a function when the - * URL embeds a short-lived token — it is re-invoked on every reconnect. - */ - streamUrl: string | (() => string | Promise); - /** Injectable for tests / non-global WebSocket environments. */ - createWebSocket?: (url: string) => WebSocketLike; - /** Page size for getMessages catch-up and loadOlder pages (default 50). */ - pageSize?: number; -} - -export interface AcpSessionActions { - /** Acks admission; turn completion arrives via state frames (see api.ts). */ - prompt(blocks: ContentBlock[]): Promise; - cancel(): Promise; - respondToPermission( - requestId: string, - outcome: RequestPermissionOutcome, - ): Promise; - setMode(modeId: string): Promise; - setConfigOption(configId: string, value: string | boolean): Promise; - /** Full resync: re-fetch state + newest messages, resubscribe. */ - refresh(): Promise; -} - -export interface UseAcpSessionResult { - /** Live session-scoped state (status, pending permissions, modes...). */ - state: SessionScopedState | null; - /** Folded, render-ready timeline of the loaded pages + live updates. */ - timeline: FoldedTimeline; - streamStatus: StreamStatus; - /** True during the initial (or refresh) resync round-trip. */ - isLoading: boolean; - /** True while the journal holds older pages loadOlder hasn't fetched. */ - hasOlder: boolean; - /** True while a loadOlder page is in flight. */ - isLoadingOlder: boolean; - error: Error | null; - actions: AcpSessionActions; - /** Prepend the next older history page (no-op when none/in flight). */ - loadOlder: () => void; -} - -/** - * Attach to a host-service ACP session: seed from get + getMessages, fold the - * WS stream on top, and resync automatically when the server signals reset. - * All folding/gap/dedup logic lives in the pure `fold` and `client` modules — - * this hook only orchestrates them. - */ -export function useAcpSession( - options: UseAcpSessionOptions, -): UseAcpSessionResult { - const { sessionId, pageSize } = options; - - // Latest transport without making it an effect dependency: callers often - // build `api`/`streamUrl`/`createWebSocket` inline, and identity churn - // must not tear down the socket. - const apiRef = useRef(options.api); - apiRef.current = options.api; - const streamUrlRef = useRef(options.streamUrl); - streamUrlRef.current = options.streamUrl; - const createWebSocketRef = useRef(options.createWebSocket); - createWebSocketRef.current = options.createWebSocket; - - const [fetchedState, setFetchedState] = useState( - null, - ); - const [timeline, setTimeline] = useState(emptyTimeline); - const [streamStatus, setStreamStatus] = useState("connecting"); - const [isLoading, setIsLoading] = useState(true); - const [hasOlder, setHasOlder] = useState(false); - const [isLoadingOlder, setIsLoadingOlder] = useState(false); - const [error, setError] = useState(null); - - // Fold target between renders; epoch guards resync races (a stale resync - // or a stale subscription's callbacks must not clobber a newer one). - const timelineRef = useRef(timeline); - // Every envelope folded so far, in seq order — loadOlder prepends a page - // and refolds from scratch (folding is pure and cheap at journal scale). - const envelopesRef = useRef([]); - // getMessages cursor for the next OLDER page; null = fully paged back. - const olderCursorRef = useRef(null); - const loadingOlderRef = useRef(false); - const epochRef = useRef(0); - const subscriptionRef = useRef(null); - - const resync = useCallback(async (): Promise => { - const epoch = ++epochRef.current; - subscriptionRef.current?.close(); - subscriptionRef.current = null; - // Invalidate any older-page request from the previous epoch immediately. - // Its stale finally block is intentionally ignored, so leaving these set - // would permanently block loadOlder when this resync fails. - loadingOlderRef.current = false; - setIsLoadingOlder(false); - setIsLoading(true); - setError(null); - try { - const api = apiRef.current; - const state = await api.get({ sessionId }); - if (epoch !== epochRef.current) return; - // Publish passive `offline` state before the live history read tries to - // resurrect it. If session/load fails, the UI can explain that this is a - // resumable registry row (and keep its composer disabled) alongside the - // actual load error instead of looking like a brand-new empty thread. - setFetchedState(state); - const page = await api.getMessages({ sessionId, limit: pageSize }); - if (epoch !== epochRef.current) return; - - envelopesRef.current = [...page.items]; - olderCursorRef.current = page.nextCursor; - loadingOlderRef.current = false; - const seeded = foldEnvelopes(emptyTimeline(), page.items); - timelineRef.current = seeded; - setTimeline(seeded); - setHasOlder(page.nextCursor !== null); - setIsLoadingOlder(false); - setIsLoading(false); - - // Empty journal page but a non-zero server cursor (e.g. evicted - // journal): subscribe from the server's seq to avoid a reset loop. - const since = seeded.lastSeq > 0 ? seeded.lastSeq : state.lastSeq; - subscriptionRef.current = subscribeToSession({ - streamUrl: () => { - const current = streamUrlRef.current; - return typeof current === "function" ? current() : current; - }, - since, - createWebSocket: createWebSocketRef.current, - onEnvelope: (envelope) => { - if (epoch !== epochRef.current) return; - if (envelope.frame.kind === "state") { - // State frames are full snapshots and last-wins in fold — - // superseded ones only bloat this refold buffer (they arrive on - // every status/permission transition for the lifetime of the - // mount), so keep just the newest. - envelopesRef.current = envelopesRef.current.filter( - (buffered) => buffered.frame.kind !== "state", - ); - } - envelopesRef.current.push(envelope); - timelineRef.current = foldEnvelope(timelineRef.current, envelope); - setTimeline(timelineRef.current); - }, - onStatus: (status) => { - if (epoch !== epochRef.current) return; - setStreamStatus(status); - }, - onReset: () => { - if (epoch !== epochRef.current) return; - void resync(); - }, - }); - } catch (cause) { - if (epoch !== epochRef.current) return; - setIsLoading(false); - setError(cause instanceof Error ? cause : new Error(String(cause))); - } - }, [sessionId, pageSize]); - - const loadOlder = useCallback(() => { - const cursor = olderCursorRef.current; - if (cursor === null || loadingOlderRef.current) return; - const epoch = epochRef.current; - loadingOlderRef.current = true; - setIsLoadingOlder(true); - apiRef.current - .getMessages({ sessionId, cursor, limit: pageSize }) - .then((page) => { - if (epoch !== epochRef.current) return; - envelopesRef.current = [...page.items, ...envelopesRef.current]; - olderCursorRef.current = page.nextCursor; - timelineRef.current = foldEnvelopes( - emptyTimeline(), - envelopesRef.current, - ); - setTimeline(timelineRef.current); - setHasOlder(page.nextCursor !== null); - }) - .catch((cause) => { - // Older history stays available for the next scroll attempt; the - // live thread is unaffected, so don't surface a blocking error — - // but leave a trace so a dead scrollback is diagnosable. - console.warn(`[acp-session] loadOlder failed (${sessionId})`, cause); - }) - .finally(() => { - if (epoch !== epochRef.current) return; - loadingOlderRef.current = false; - setIsLoadingOlder(false); - }); - }, [sessionId, pageSize]); - - // The session currently reflected by the rendered state/timeline. When the - // route swaps sessionIds in place, the old session's thread (and its still- - // answerable permissions) must not stay visible while the new one loads — - // clear before resyncing. Same-session resyncs (refresh, reset) keep the - // existing data rendered during the round trip. - const renderedSessionIdRef = useRef(sessionId); - - useEffect(() => { - if (renderedSessionIdRef.current !== sessionId) { - renderedSessionIdRef.current = sessionId; - envelopesRef.current = []; - olderCursorRef.current = null; - loadingOlderRef.current = false; - timelineRef.current = emptyTimeline(); - setFetchedState(null); - setTimeline(timelineRef.current); - setHasOlder(false); - setIsLoadingOlder(false); - setStreamStatus("connecting"); - } - void resync(); - return () => { - epochRef.current += 1; - subscriptionRef.current?.close(); - subscriptionRef.current = null; - }; - }, [sessionId, resync]); - - const actions = useMemo( - () => ({ - prompt: (blocks) => apiRef.current.prompt({ sessionId, prompt: blocks }), - cancel: () => apiRef.current.cancel({ sessionId }), - respondToPermission: (requestId, outcome) => - apiRef.current.respondToPermission({ sessionId, requestId, outcome }), - setMode: (modeId) => apiRef.current.setMode({ sessionId, modeId }), - setConfigOption: (configId, value) => - apiRef.current.setConfigOption({ sessionId, configId, value }), - refresh: () => resync(), - }), - [sessionId, resync], - ); - - return { - // State frames folded from the stream supersede the initial fetch. - state: timeline.state ?? fetchedState, - timeline, - streamStatus, - isLoading, - hasOlder, - isLoadingOlder, - error, - actions, - loadOlder, - }; -} diff --git a/plans/acp-session-follow-ups.md b/plans/acp-session-follow-ups.md deleted file mode 100644 index fab308a8523..00000000000 --- a/plans/acp-session-follow-ups.md +++ /dev/null @@ -1,305 +0,0 @@ -# ACP Session Follow-ups - -Status: **active**. Current implementation behavior is documented in -`packages/host-service/docs/acp-sessions.md`. This plan contains only remaining -work and completed prerequisites that explain the next boundaries. - -## Decisions - -1. The in-memory journal is a recent delivery/catch-up buffer, not the long-term - history source. -2. Older history pages must come from disk. Do not increase the ring to make - long sessions appear durable. -3. Keep `@superset/host-client` as the platform-neutral relay/direct transport. -4. Replace the mixed `@superset/session-protocol` package with two packages: - `@superset/host-service-sync` and `@superset/host-service-react`. Do not add a - third `host-service-protocol` package. -5. Runtime data crossing a process, network, persistence, or untyped JSON - boundary must be parsed. TypeScript assertions are not validation. -6. Authenticated real-Claude tests are the primary acceptance evidence for the - model/adapter boundary and must run on a Mac after relevant changes. The - deterministic fake adapter stays always-run as belt-and-suspenders breadth; - it does not replace the real adapter/model or real host/client boundaries. - -## Current Gaps - -### History And Memory - -The host ring is bounded at 5,000 envelopes, but `getMessages` currently pages -that same ring. `session/load` can rebuild it after a restart by replaying the -whole native transcript, but ACP exposes no paginated native-history method. -Once the transcript is larger than the ring, the oldest frames are not -available through Superset. - -The React hook also keeps every fetched page and live envelope in -`envelopesRef` so it can refold from scratch. It removes superseded state frames, -but there is no general live-buffer cap. This is bounded by what the user has -loaded plus the current mount's live stream, not by the full on-disk transcript. - -### Validation - -The code is strongly typed at compile time but not fully runtime validated: - -- ACP `ContentBlock` and permission outcomes use shallow `z.custom` checks. -- the generic host transport lets the caller choose `TOutput` and asserts the - deserialized relay response; -- the WebSocket guard checks only `seq` and `frame.kind`; -- adapter response payloads and persisted registry rows are trusted; -- tRPC procedures do not declare output schemas; -- test fixture files are outside the host-service package typecheck. - -### Cursor Incarnations - -Sequence numbers restart after host resurrection. There is no persisted -journal incarnation in the cursor, so an old numeric cursor can overlap the new -range and be mistaken for a current cursor. - -## Workstreams - -### P0: Disk-backed history, bounded catch-up - -- [ ] Define `SessionHistoryStore` with append, replace-from-native-replay, and - newest-first page operations. It must be adapter-neutral at the manager - boundary. -- [ ] Choose the on-disk representation after measuring payload size. Prefer an - append-only host-local store with indexed session/order columns; do not store - folded UI state. -- [ ] Document retention, permissions, deletion, and corruption recovery before - storing message/tool payloads in host SQLite or another host-owned file. -- [ ] During `session/load`, write the full replay to a temporary generation and - atomically replace that session's history only after load succeeds. A failed - load must preserve the previous readable generation. -- [ ] Keep only the newest catch-up window in `SessionJournal`. Set the cap from - an explicit event/byte policy after measurement; 5,000 is not a product - requirement. -- [ ] Make `getMessages` page `SessionHistoryStore`, never `SessionJournal`. -- [ ] Keep state/reset frames out of history pages while retaining all timeline - frame kinds required to reconstruct permissions, plans, tool calls, and prompt - failures. -- [ ] Add a journal incarnation id to state, stream cursors, and history page - cursors. A cursor from another incarnation must deterministically reset. -- [ ] Bound `earlyUpdates` during `session/load`; write replay rows incrementally - instead of accumulating the full replay array before runtime creation. -- [ ] In `host-service-sync`, retain loaded history pages and a bounded live - overlay. Do not retain evicted catch-up envelopes merely to support refolding. -- [ ] Prove a transcript larger than every in-memory cap can page to its first - message after host restart while host and client memory remain bounded. - -Acceptance: - -- `getMessages` can read old pages with no live adapter process; -- shrinking the catch-up ring does not shrink available history; -- restart/load never exposes a partially rebuilt history generation; -- stale cursors always reset by incarnation, not by numeric coincidence; -- no message content is uploaded to cloud storage. - -### P1: Strict runtime contracts - -- [ ] Put canonical Zod schemas for every Superset-authored state, frame, - envelope, cursor, and page in `@superset/host-service-sync`. -- [ ] Replace shallow `z.custom` schemas with explicit supported ACP content and - outcome schemas. Preserve official extension points as `unknown` or JSON-safe - records only where the protocol requires them. -- [ ] Decide how to validate full ACP payloads: compile validators from the - SDK-exported JSON Schema, vendor generated validators, or validate the exact - supported union locally. Record the version-skew policy. -- [ ] Parse all tRPC outputs in `@superset/host-client`; remove caller-selected - unchecked `TOutput` from named ACP operations. -- [ ] Parse complete WebSocket envelopes, enforce expected `sessionId`, safe - integer sequences/timestamps, and known discriminants before folding. -- [ ] Parse adapter initialize/new/load/prompt/config responses before use. -- [ ] Derive the registry row type from Drizzle and parse rows read from SQLite. -- [ ] Add database constraints for harness/status-like fields and workspace - ownership where compatible with host cleanup semantics. -- [ ] Include host-service test fixtures in a strict test tsconfig. -- [ ] Add malformed input/output/frame/row tests for every trust boundary. - -Acceptance: no network, child-process, JSON, or SQLite payload reaches business -logic through an unchecked assertion. - -### P2: Real host plus real host client E2E - -Completed: - -- [x] Extract generic transport to `@superset/host-client` and consume it from - mobile. -- [x] Add a deterministic fake ACP adapter that speaks real ACP JSON-RPC over - stdio. -- [x] Cover manager, router, and WebSocket behavior directly. -- [x] Start the real `createApp` host on an ephemeral port with the production - auth middleware, a temporary on-disk registry, and the fake adapter. -- [x] Drive named operations through `@superset/host-client`, including - SuperJSON, bearer auth, tRPC routing, WebSocket URL construction, concurrent - permissions, an `AskUserQuestion` answer, an in-flight cancellation, cursor - reconnect, and load-error propagation. The transport unit suite separately - pins 401 refresh-once. -- [x] Close and rebuild the app, HTTP/WS server, adapter children, and SQLite - handle against the same DB path; prove offline listing, resurrection, replay, - and missing-native-transcript behavior. -- [x] Add an authenticated real-adapter lane using a throwaway workspace, - Sonnet/low by default, and the machine's Claude login. Captured cases cover a - completed five-agent Workflow, `AskUserQuestion`, serialized parallel-tool - permissions, cancel mid-turn, real WebSocket fan-out, reconnect, and reset. - This is the primary compatibility lane; it is skipped in ordinary CI only - because CI lacks Claude credentials and the run spends real tokens. - -Remaining: - -- [ ] Move restart into a separate OS host process and prove kill/respawn, not - only fresh app/server/manager instances inside one Bun test process. -- [ ] Run a Node lane with production `better-sqlite3`, not only `bun:sqlite`. -- [ ] Keep the suite package-boundary-safe: tests should not import manager - internals to make assertions that a real client cannot make. - -The focused test design remains in `plans/host-integration-test.md`. - -### P3: Package split - -Target ownership: - -```text -@superset/host-client - fetch/SuperJSON transport, auth retry, direct/relay URL construction, - named host-service clients - -@superset/host-service-sync - ACP/Superset wire types, Zod validators, cursors, pure fold/reducer, - WebSocket reconnect/dedup/reset logic, framework-free session store - -@superset/host-service-react - useHostServiceSession, permission selectors, lifecycle/GC bindings; - depends on host-service-sync and React, contains no transport implementation -``` - -- [ ] Create `host-service-sync` from the non-React parts of - `session-protocol`. -- [ ] Create `host-service-react` from `session-protocol/src/react`. -- [ ] Move the per-session state owner out of one component hook and expose a - vanilla store factory so multiple React consumers share one subscription. -- [ ] Keep app-specific auth/routing injection in mobile/desktop bindings. -- [ ] Migrate host-service, host-client, and mobile imports. -- [ ] Delete `@superset/session-protocol` after the migration; do not leave - compatibility re-exports indefinitely. -- [ ] Add package boundary tests proving `host-service-sync` has no React, Node, - Expo, or host-service runtime dependency. - -### P4: Client state management - -Do not copy the current desktop chat implementation exactly. The current v2 -desktop chat hook polls a full `getSnapshot` at 4 fps through TanStack Query and -keeps full message history in the query cache with a 60-second inactive GC. It -is not a reusable streaming session store. The Zustand event-store design in -`plans/v2-chat-greenfield-architecture.md` is proposed, not implemented. - -Reuse the useful desktop conventions instead: - -- one vanilla store instance per open session; -- explicit acquire/release ownership and short inactive GC; -- serializable state separated from sockets, promises, and callbacks; -- selectors so a status chip does not rerender the whole timeline; -- optimistic command state reconciled by authoritative stream events; -- app-owned pane/tab persistence, not session-sync-owned navigation state. - -- [ ] Prototype the store in `host-service-sync` and React bindings in - `host-service-react`. -- [ ] Compare memory and render counts against the current hook with a long - synthetic transcript. -- [ ] Prove two mounted consumers of one session use one socket and converge. -- [ ] Prove unmount/remount inside the GC window reuses state; after GC it cold - loads only the newest page. -- [ ] Keep desktop pane layout and mobile navigation out of the shared store. - -### P5: Registry lifecycle - -Completed in the current implementation: - -- [x] Add a host DB table mapping public session id to workspace id, native ACP - session id, harness, cwd, title, stop reason, and timestamps. -- [x] Load rows as passive offline sessions and resume through `session/load`. -- [x] Keep transcript content out of the registry row. - -Remaining: - -- [ ] Add explicit delete/forget semantics, including native-session deletion - behavior and history-store cleanup. -- [ ] Remove or quarantine rows whose workspace no longer exists. -- [ ] Define retention/GC for abandoned, load-failed, and dead sessions. -- [ ] Support additional harness values only with an adapter registry and - harness-specific resume/history capabilities. -- [ ] Validate cwd/workspace ownership on every resurrection; do not trust a - stale stored path if the workspace moved. - -## E2E Edge-case Matrix - -Every row needs a deterministic fake-adapter case. Rows marked `boundary` also -need the host plus `@superset/host-client` suite. User-visible rows need an iOS -Maestro scenario. - -### Session lifecycle - -- [ ] create idempotency under concurrent calls (`boundary`) -- [ ] same public id with another workspace is rejected (`boundary`) -- [ ] adapter fails before initialize, during new, and immediately after new -- [ ] adapter exits idle, streaming, awaiting permission, and during load -- [ ] host exits idle, streaming, awaiting permission, and while persisting -- [ ] on-disk DB close/reopen plus host process respawn (`boundary`, Maestro) -- [x] native transcript missing (`boundary`); corrupt, truncated, and access - denied remain -- [ ] workspace deleted or moved before resurrection -- [ ] delete/forget races with attach and prompt - -### Stream and pagination - -- [ ] disconnect before first frame, mid-frame, mid-turn, and after turn end -- [ ] duplicate, missing, out-of-order, malformed, wrong-session, and unknown - frame variants (`boundary`) -- [ ] stale cursor from another incarnation (`boundary`) -- [ ] journal eviction before attach and while a subscriber is slow -- [ ] WebSocket back-pressure close followed by cursor recovery -- [ ] JWT expires before connect and during reconnect (`boundary`) -- [ ] relay unavailable, reconnect backoff, and host tunnel replacement -- [ ] history larger than ring, page size, and client memory cap -- [ ] concurrent `loadOlder`, reset, refresh, and route/session switch -- [ ] empty history, exact page boundary, final partial page, invalid cursor - -### Commands and interactions - -- [ ] two prompts admitted concurrently and prompt queued mid-turn -- [ ] prompt request rejected before and after user-message persistence -- [ ] permission allow/deny racing cancel, adapter abort, and another client -- [ ] invalid request id, invalid option id, invalid multi-select cardinality -- [x] multiple simultaneous permissions and ordered question cards -- [x] single-select, multi-select, skip, arbitrary unsupported form, URL mode -- [ ] set mode/config idle, mid-turn, during load, and from two clients -- [x] cancel running and awaiting permission -- [ ] cancel idle, twice, and after death -- [ ] tool calls left open by cancel/crash/restart terminalize once - -### Security and privacy - -- [ ] feature gate off mounts no stream route and spawns no adapter (`boundary`) -- [ ] cross-user/org/host/workspace access is denied (`boundary`) -- [x] ambient Anthropic credentials never enter the adapter child -- [ ] auth tokens never appear in logs, errors, persisted rows, or snapshots -- [ ] registry contains no message/tool/permission bodies -- [ ] history retention/delete inspection proves removed content is absent - -### Mobile - -- [ ] first load, empty, disabled, offline, dead, and load-failed list states -- [ ] background/foreground longer than token lifetime (`Maestro`) -- [ ] host restart while thread is open (`Maestro`) -- [ ] scroll to first on-disk page while live updates continue (`Maestro`) -- [ ] two devices answer the same permission; loser reconciles correctly -- [ ] network loss during prompt, permission response, and pagination -- [ ] long text/tool output, large plans, and all supported content blocks - -## Completion Gates - -- `bun run lint` exits 0 with no warnings. -- `bun run typecheck` exits 0, including test fixtures. -- Focused package tests pass under Bun and the Node/`better-sqlite3` lane. -- The real host plus real host-client suite passes with an on-disk restart. -- The documented memory caps are measured with a transcript beyond those caps. -- Current implementation docs and the PR description match the code without - historical behavior mixed into the current-state section. diff --git a/plans/chat-mastra-rebuild-execplan.md b/plans/chat-mastra-rebuild-execplan.md deleted file mode 100644 index e755cb4ea41..00000000000 --- a/plans/chat-mastra-rebuild-execplan.md +++ /dev/null @@ -1,198 +0,0 @@ -# Chat-Mastra Rebuild Exec Plan - -This is the source-of-truth plan for rebuilding chat as a separate `chat` stack. - -## Status -- Owner: platform/chat -- Scope: desktop-first rollout, server/client split preserved -- Last updated: 2026-02-24 - -## Goals -- Build a new chat architecture with no runtime code sharing with `@superset/chat`. -- Keep write/control APIs over tRPC. -- Keep read/replay APIs over durable streams (SSE) for all clients. -- Preserve session resumability using Mastra storage. -- Keep server as a hostable service core that can be wrapped by tRPC/Hono. - -## Non-Goals (v1) -- No command-log-first write path (can be added later). -- No legacy chat migration in place. -- No attempt to make harness events stable across arbitrary Mastra versions. - -## Locked Decisions -- New package: `packages/chat` (single package with strict internal boundaries). -- New session metadata table: `chat_mastra_sessions`. -- Event protocol: raw Mastra harness events for runtime output. -- Slash commands served by our service layer, not by harness. -- Rollout: desktop first. - -## Internal Package Layout -- `src/client` -- `src/schema` -- `src/events` -- `src/server/core` -- `src/server/trpc` -- `src/server/hono` -- `src/server/file-search` -- `src/server/slash-commands` - -## Mastra Runtime Facts (Validated) - -### `initialState` vs `storage` -- `initialState` is constructor-time seed/default state only. -- Real resume/history is storage-backed (`listThreads`, `listMessages` paths require storage). -- For our use-case, keep storage enabled for true resume. - -### Harness Event Behavior -- `sendMessage()` emits `agent_start` first. -- `message_start` is for assistant stream construction (not user submit). -- `message_start` payload is assistant role. -- `agent_end.reason` gives terminal state (`complete`, `aborted`, `error`). -- `abort()` itself does not emit a dedicated abort event. - -### Gap We Must Fill -- Harness does not emit explicit `user_message_submitted`. -- Service must emit durable submit events before calling harness methods. - -### Slash Commands in MastraCode -- Slash commands are handled in MastraCode TUI layer (`dispatchSlashCommand`, loaders), not in harness. -- `createMastraCode()` does not expose a slash-command API for our transport stack. -- We must provide slash discovery/resolve/execute APIs in our service. - -## High-Level Data Flow -1. Client calls tRPC mutation (`sendMessage`, control, approval, etc.). -2. Service appends durable submit event (`*_submitted`) via per-session ordered append queue. -3. Service calls harness API. -4. Harness emits runtime events. -5. Service appends raw harness events to same durable stream (same queue). -6. Clients consume durable stream only for chat timeline. -7. Non-stream features (file search, slash discovery/preview/resolve) use tRPC. - -## Durable Event Envelope (v1) - -```ts -kind: 'submit' | 'harness' -sessionId: string -timestamp: string -sequenceHint: number -payload: unknown -``` - -Submit event types: -- `user_message_submitted` -- `control_submitted` -- `tool_output_submitted` -- `slash_command_invoked` -- `slash_command_failed` (optional) - -Harness payload: -- raw `HarnessEvent` object - -## Ordering and Idempotency -- One append queue per session (single writer). -- Queue serializes all durable writes for deterministic order. -- Optional `clientMessageId` dedupe map in service (TTL cache) for retries. - -## tRPC Contract (Chat-Mastra) -- `start({ organizationId })` -- `stop()` -- `session.ensureRuntime({ sessionId, cwd? })` -- `session.sendMessage({ sessionId, content, files?, metadata?, clientMessageId? })` -- `session.control({ sessionId, action })` -- `session.toolOutput({ sessionId, tool, toolCallId, output|error })` -- `session.approval.respond({ sessionId, decision, toolCallId? })` -- `session.question.respond({ sessionId, questionId, answer })` -- `session.plan.respond({ sessionId, planId, action, feedback? })` -- `workspace.searchFiles(...)` -- `workspace.getSlashCommands(...)` -- `workspace.resolveSlashCommand(...)` -- `workspace.previewSlashCommand(...)` - -## Slash Command Model - -### Registry -- Keep markdown command files in `.claude/.agents` command directories. -- Build registry with project overrides over global. - -### Resolution -- If command has `action`: execute mapped service action handler. -- Else render template to prompt text and send to harness. - -### Prompt Command Payload -Wrap rendered prompt before sending to harness: - -```xml - -...rendered command text... - -``` - -### Action Definitions -`action` is an enum in service layer (examples): -- `new_session` -- `stop_stream` -- `set_model` -- `switch_mode` - -`passArguments` determines whether raw args are forwarded to action handler. - -## Session Metadata Strategy -Create `chat_mastra_sessions` with: -- `id` -- `organization_id` -- `created_by` -- `workspace_id` -- `title` -- `last_active_at` -- `created_at` -- `updated_at` - -Notes: -- Needed for efficient listing/filtering/title UX. -- Avoid stream-scanning for index UX. - -## Desktop Integration Plan -- Add new pane type: `chat`. -- Add new router namespace: `chatRuntimeService`. -- Add new renderer pane component tree for chat. -- Keep old `chat` pane untouched during rollout. -- Feature-flag initial enablement. - -## API/Hono Integration Plan -- Keep Hono as wrapper. -- Mount tRPC handler in Hono. -- Mount durable stream proxy endpoints in Hono. -- Keep write path canonical through tRPC. - -## Risks and Mitigations -- Risk: async listener reordering -> Mitigation: per-session append queue. -- Risk: no user submit event in harness -> Mitigation: explicit submit events. -- Risk: Mastra internal TUI slash APIs change -> Mitigation: own slash layer. -- Risk: raw harness schema shifts -> Mitigation: pin Mastra version + contract snapshots. - -## Test Matrix -- Unit: queue ordering and submit-before-runtime ordering. -- Unit: slash resolve (action vs prompt) and arg passing. -- Integration: send message -> ordered stream -> replay renders complete timeline. -- Integration: approval/question/plan roundtrip. -- Integration: abort produces terminal state via `agent_end.reason`. -- Integration: restart + `ensureRuntime` resume behavior. -- Contract: snapshot raw harness event discriminants and key fields. - -## Implementation Checklist -- [ ] Create `packages/chat` exports + tsconfig + deps. -- [ ] Implement `src/schema` event/state schema. -- [ ] Implement `src/events` queue + durable append helpers. -- [ ] Implement `src/server/core` runtime manager and session runtime lifecycle. -- [ ] Implement `src/server/slash-commands` parser/registry/resolver with action support. -- [ ] Implement `src/server/trpc` router over core. -- [ ] Implement `src/server/hono` wrapper mounting tRPC + stream routes. -- [ ] Add `chat_mastra_sessions` DB schema (and relations if needed). -- [ ] Add desktop main-process router namespace `chatRuntimeService`. -- [ ] Add desktop pane type `chat` and initial pane surface. -- [ ] Add tests for ordering, resume, slash behaviors, and event contracts. - -## Open Questions (to resolve during implementation) -- Whether to include `chat_mastra_session_hosts` in v1. -- Exact migration strategy for users with existing `chat_sessions` tabs. -- Whether custom slash command `action` should be allowed in markdown frontmatter in v1, or builtins-only actions first. diff --git a/plans/chat-service-at-linking.md b/plans/chat-service-at-linking.md deleted file mode 100644 index a664176a32b..00000000000 --- a/plans/chat-service-at-linking.md +++ /dev/null @@ -1,387 +0,0 @@ -# Re-implement @ Linking + Chat Service (SUPER-288) - -## Context - -The old chat had @ linking for files, removed during the durable stream rewrite. The current `FileMentionPopover` exists but doesn't work — `useFileSearch` depends on `electronTrpc.filesystem.searchFiles`, which is desktop-only. - -Chat needs workspace-level features requiring local filesystem access (file search, slash commands). These can't live in `apps/api` — they need a process where the files are (desktop or sandbox). Meanwhile, agent execution (`AgentManager`, `StreamWatcher`, `runAgent`) lives in desktop with **zero Electron deps** — fully portable. - -### Goals -1. Create `packages/chat-service` — shared package with core logic + tRPC router contract -2. Any host (desktop, sandbox) serves the router through its transport (IPC, HTTP) -3. Any client (desktop, mobile, web) consumes via `createTRPCReact()` — no ternaries -4. Fix session resumption brittleness (currently depends on Electric SQL propagation) -5. Add @file and @task mention support - ---- - -## Architecture - -``` -Durable Stream (unchanged): - Client ←→ apps/api ←→ DurableStreams messages, approvals, tool results, abort, config - useDurableChat → apps/api stays as-is - -Chat Service (new — host-dependent: needs local filesystem): - packages/chat-service/ - ├── Core logic file search, slash commands, agent execution - ├── tRPC router THE contract — workspace queries + session activation - └── React client createTRPCReact() + Provider - - Hosts serve the router: - Desktop: mount into electron tRPC root → served via IPC - Sandbox: @trpc/server/adapters/fetch → served via HTTP (future) - - Clients consume via Provider: - Desktop: - Web/Mobile: - Components: chatService.workspace.searchFiles.useQuery(...) — zero ternaries - -Task Search (client-side — no host needed): - Tasks are org-level, already loaded on every client for task views. - MentionPopover searches them in-memory with fuse.js — no network round-trip. - Sandboxes don't need task data at all. -``` - -### What lives where - -| Concern | Where | Why | -|---------|-------|-----| -| Messages, approvals, tool results, abort | Durable stream | Need durability + multi-client visibility | -| Session config (model, permissionMode, etc.) | Durable stream | Agent reads in real-time, other clients see it | -| File search, slash commands | Chat-service router | Need local filesystem access | -| Session activate/isActive | Chat-service router | Direct to host, eliminates Electric SQL lag | -| Task search (for @ mentions) | Client-side (fuse.js) | Org-level data already loaded on client, avoids syncing tasks to 100s of sandboxes | -| Task context (for agent) | Server-side DB query in runAgent | One-off fetch when agent processes `@task:SLUG`, not a search | -| Available models | API (existing or new endpoint) | Org-level, not host-specific | -| Agent lifecycle (start/stop AgentManager) | Host-specific (not in shared router) | Desktop: renderer tells main. Sandbox: auto-starts | - ---- - -## tRPC Router Contract - -```ts -// packages/chat-service/src/router/index.ts -import { initTRPC } from "@trpc/server"; -import { z } from "zod"; - -const t = initTRPC.create(); - -export const searchFilesInput = z.object({ - workspaceId: z.string().uuid(), - query: z.string(), - includeHidden: z.boolean().default(false), - limit: z.number().default(20), -}); - -export const chatServiceRouter = t.router({ - workspace: t.router({ - // File search for @ mentions — fast-glob + fuse.js on host filesystem - searchFiles: t.procedure - .input(searchFilesInput) - .query(async ({ input }): Promise => { - const rootPath = await resolveWorkspaceRootPath(input.workspaceId); - return searchFiles({ - rootPath, - query: input.query, - includeHidden: input.includeHidden, - limit: input.limit, - }); - }), - - // Slash commands available in this workspace - getSlashCommands: t.procedure - .input(z.object({ workspaceId: z.string().uuid() })) - .query(async ({ input }) => { - return getSlashCommands(input.workspaceId); - }), - }), - - session: t.router({ - // Is the host watching this session? (UI status indicator) - isActive: t.procedure - .input(z.object({ sessionId: z.string().uuid() })) - .query(({ input }) => { - return { active: agentManager.hasWatcher(input.sessionId) }; - }), - - // Ensure host is watching this session — idempotent - // Called on first message send (NOT on mount — avoids delay viewing old chats) - activate: t.procedure - .input(z.object({ sessionId: z.string().uuid() })) - .mutation(({ input }) => { - agentManager.ensureWatcher(input.sessionId); - return { active: true }; - }), - }), -}); - -export type ChatServiceRouter = typeof chatServiceRouter; -``` - -### Client consumption - -```ts -// packages/chat-service/src/client/index.ts -import { createTRPCReact } from "@trpc/react-query"; -import type { ChatServiceRouter } from "../router"; - -export const chatService = createTRPCReact(); -``` - -Each app wraps once at the top level: -```tsx -// Desktop — IPC - - -// Web/Mobile — HTTP (future) - -``` - -### Task search — client-side, not in router - -Tasks are org-level data already loaded on every client (for task boards, lists, etc.). The MentionPopover searches them in-memory — no network round-trip, no syncing to sandboxes: - -```tsx -// MentionPopover — task search is local -const { data: orgTasks } = trpc.task.byOrganization.useQuery(organizationId); - -const taskResults = useMemo(() => { - if (!taskQuery || !orgTasks) return []; - const fuse = new Fuse(orgTasks, { - keys: [{ name: "slug", weight: 3 }, { name: "title", weight: 2 }], - threshold: 0.4, - ignoreLocation: true, - }); - return fuse.search(taskQuery, { limit: 20 }); -}, [orgTasks, taskQuery]); -``` - -The agent still fetches task details server-side when it encounters `@task:SLUG` in a message — that's a one-off DB query in `runAgent()`, not a search. - -### Desktop gateway routing - -Desktop main process routes `workspace.searchFiles` based on workspace type: -```ts -// Local workspace → run fast-glob locally -// Sandbox workspace → forward to sandbox's HTTP chat-service endpoint -``` - ---- - -## Package Structure - -``` -packages/chat-service/ - package.json deps: fast-glob, fuse.js, @electric-sql/client, - tsconfig.json @superset/db, @superset/durable-session - src/ peerDeps: react, @trpc/react-query - index.ts barrel: server-side exports - router/ - index.ts tRPC router + exported type + Zod schemas - workspace/ - file-search/ - file-search.ts fast-glob + fuse.js, 30s TTL cache - index.ts - slash-commands/ - slash-commands.ts workspace command discovery - index.ts - agent/ - index.ts barrel - agent-manager.ts Electric SQL watcher → StreamWatcher lifecycle - stream-watcher.ts SessionHost → runAgent bridge - run-agent.ts runAgent, resumeAgent, @file + @task parsing - anthropic-auth/ - anthropic-auth.ts Claude credential reading - index.ts - models.ts available model list - client/ - index.ts createTRPCReact() + re-exports -``` - ---- - -## Implementation Steps - -### Step 1: Create package + core workspace logic - -**New:** `packages/chat-service/package.json`, `tsconfig.json` - -**New:** `packages/chat-service/src/workspace/file-search/file-search.ts` -Migrate from: `apps/desktop/src/lib/trpc/routers/filesystem/index.ts` (lines 10-143) -- `buildSearchIndex()`, `getSearchIndex()`, 30s TTL cache, `DEFAULT_IGNORE_PATTERNS` -- Fuse config: `keys=[{name:"name",weight:2},{name:"relativePath",weight:1}], threshold:0.4` - -**New:** `packages/chat-service/src/router/index.ts` — tRPC router, type export, Zod schemas - -**New:** `packages/chat-service/src/client/index.ts` — `createTRPCReact()` - -### Step 2: Migrate agent execution - -**New:** `packages/chat-service/src/agent/agent-manager.ts` -Migrate from: `apps/desktop/src/main/lib/agent-manager/agent-manager.ts` -- Accept `electricUrl`, `apiUrl` as constructor params (remove `env.main` import) -- Add `hasWatcher(sessionId)` and `ensureWatcher(sessionId)` methods - -**New:** `packages/chat-service/src/agent/stream-watcher.ts` -Migrate from: `apps/desktop/src/main/lib/agent-manager/utils/stream-watcher.ts` -- Accept `apiUrl` as constructor param (remove `env.NEXT_PUBLIC_API_URL` import) - -**New:** `packages/chat-service/src/agent/run-agent.ts` -Migrate from: `apps/desktop/src/main/lib/agent-manager/utils/run-agent.ts` -- Add `parseTaskMentions()` + `buildTaskMentionContext()` for @task mentions -- `buildTaskMentionContext` does a one-off DB query: `select from tasks where slug in (...)` - -**New:** `packages/chat-service/src/agent/anthropic-auth/anthropic-auth.ts` -Migrate from: `apps/desktop/src/main/lib/agent-manager/utils/anthropic/auth/auth.ts` - -**New:** `packages/chat-service/src/agent/models.ts` -Migrate from: `apps/desktop/src/main/lib/agent-manager/utils/models.ts` - -### Step 3: Desktop integration - -**Modify:** `apps/desktop/src/lib/trpc/routers/agent-manager/` → rename to `chat-service/` -- Import `searchFiles`, `AgentManager` from `@superset/chat-service` -- Import Zod schemas from `@superset/chat-service/router` -- Add `workspace.searchFiles`, `workspace.getSlashCommands` procedures -- Add `session.isActive`, `session.activate` procedures -- Keep agent lifecycle as host-specific procedures (start/stop AgentManager) -- For `workspace.searchFiles`: route to local or sandbox based on workspace type - -**Modify:** `apps/desktop/src/lib/trpc/routers/index.ts` — rename `agentManager` → `chatService` - -**Delete:** `apps/desktop/src/main/lib/agent-manager/` — migrated to package - -**Modify:** Renderer — wrap chat UI with `chatService.Provider`: -```tsx -import { chatService } from "@superset/chat-service/client"; -// Create IPC client, wrap ChatInterface with chatService.Provider -``` - -### Step 4: Add `workspaceId` to `chatSessions` - -**Modify:** `packages/db/src/schema/schema.ts` — add to `chatSessions`: -```ts -workspaceId: uuid("workspace_id").references(() => workspaces.id, { onDelete: "set null" }), -``` - -**Modify:** `packages/db/src/schema/relations.ts` — add workspace ↔ chatSessions relation - -**Modify:** `apps/api/src/app/api/streams/[...path]/route.ts` PUT handler — accept `workspaceId` in body - -**Modify:** `ChatInterface.tsx` — pass `workspaceId` when creating sessions - -**Migration:** `bunx drizzle-kit generate --name="add_workspace_id_to_chat_sessions"` (on neon branch) - -### Step 5: Refactor MentionPopover + activate on send - -**Rename:** `FileMentionPopover/` → `MentionPopover/` -Path: `apps/desktop/src/renderer/.../ChatInterface/components/` - -Key changes: -- Remove `useFileSearch` import (renderer-local hook → electronTrpc.filesystem) -- File search: `chatService.workspace.searchFiles.useQuery({ workspaceId, query })` -- Task search: client-side fuse.js over `trpc.task.byOrganization.useQuery(organizationId)` (already loaded) -- Two ``s: "Files" and "Tasks" -- Detect `@task:` prefix → switch to task search mode -- On file select: insert `@relative/path` -- On task select: insert `@task:SLUG` -- Rename exports: `FileMentionProvider` → `MentionProvider`, etc. - -**Modify:** `ChatInputFooter.tsx` -- Updated imports (MentionProvider, MentionAnchor, MentionTrigger) -- Placeholder: `"Ask to make changes, @mention files, run /commands"` - -**Modify:** `ChatInterface.tsx` — activate on first message send: -```tsx -const handleSend = useCallback(async (message: { text: string }) => { - const text = message.text.trim(); - if (!text) return; - chatService.session.activate.mutate({ sessionId }); - sendMessage(text); -}, [sessionId, sendMessage]); -``` - -### Step 6: Agent-side task mention parsing - -**In:** `packages/chat-service/src/agent/run-agent.ts` - -Add alongside existing `parseFileMentions`: -```ts -const TASK_MENTION_REGEX = /@task:([\w-]+)/g; - -function parseTaskMentions(text: string): string[] { - return [...new Set([...text.matchAll(TASK_MENTION_REGEX)].map(m => m[1]))]; -} - -// One-off DB query — NOT a search index. Only called when agent processes a message. -async function buildTaskMentionContext(slugs: string[], orgId: string): Promise { - const rows = await db.select().from(tasks) - .where(and(inArray(tasks.slug, slugs), eq(tasks.organizationId, orgId), isNull(tasks.deletedAt))); - return rows.map(t => - `${t.description ?? ""}` - ).join("\n"); -} -``` - -Update `runAgent()` to call both parsers and inject context. - ---- - -## Mention Format - -| Resource | Inserted text | Agent regex | Example | -|----------|--------------|-------------|---------| -| File | `@relative/path` | `/@([\w./-]+(?:\/[\w./-]+\|\.[\w]+))/g` (existing) | `@src/lib/auth.ts` | -| Task | `@task:SLUG` | `/@task:([\w-]+)/g` | `@task:SUPER-288` | - ---- - -## Files Summary - -### New files (packages/chat-service/) -1. `package.json`, `tsconfig.json` -2. `src/index.ts` — server-side barrel -3. `src/router/index.ts` — tRPC router, `ChatServiceRouter` type, Zod schemas -4. `src/client/index.ts` — `createTRPCReact()` -5. `src/workspace/file-search/` — `file-search.ts`, `index.ts` -6. `src/workspace/slash-commands/` — `slash-commands.ts`, `index.ts` -7. `src/agent/` — `index.ts`, `agent-manager.ts`, `stream-watcher.ts`, `run-agent.ts`, `models.ts` -8. `src/agent/anthropic-auth/` — `anthropic-auth.ts`, `index.ts` - -### Modified files -9. `packages/db/src/schema/schema.ts` — `workspaceId` on `chatSessions` -10. `packages/db/src/schema/relations.ts` — workspace ↔ chatSessions relation -11. `apps/api/src/app/api/streams/[...path]/route.ts` — accept `workspaceId` in PUT body -12. `apps/desktop/src/lib/trpc/routers/agent-manager/` → renamed to `chat-service/`, adds workspace + session procedures -13. `apps/desktop/src/lib/trpc/routers/index.ts` — `agentManager` → `chatService` -14. `ChatInterface.tsx` — pass `workspaceId`, activate on first send, wrap with Provider -15. `FileMentionPopover/` → `MentionPopover/` — files via chatService, tasks via client-side fuse.js -16. `ChatInputFooter.tsx` — updated imports + placeholder text - -### Deleted -17. `apps/desktop/src/main/lib/agent-manager/` — migrated to package - -### Untouched -18. `apps/api/src/app/api/streams/[...path]/route.ts` — stream routes unchanged (except small workspaceId addition) -19. `packages/durable-session/` — `useDurableChat` stays pointed at apps/api -20. `apps/desktop/src/lib/trpc/routers/filesystem/` — kept for sidebar file browser - ---- - -## Verification - -1. `bun install` from root — no resolution errors -2. `bun run typecheck` — no type errors -3. `bun run lint:fix` — no lint issues -4. Start desktop app → AgentManager starts, no console errors -5. Open workspace, start new chat → session created with `workspaceId` -6. Send a message → `activate` fires, agent responds -7. Type `@` → MentionPopover opens with file results from chatService router -8. Fuzzy search → results appear, debounced -9. Select file → `@path/to/file` inserted -10. Type `@task:` → task results appear (client-side, instant) -11. Select task → `@task:SUPER-288` inserted -12. Send `fix @src/index.ts per @task:SUPER-288` → agent receives file content + task details -13. Close and reopen old chat → messages load instantly (no agent spin-up) -14. Send message in old chat → `activate` fires, agent resumes watching, processes message diff --git a/plans/cross-client-session-tab-sync.md b/plans/cross-client-session-tab-sync.md deleted file mode 100644 index 67f8d12a8a2..00000000000 --- a/plans/cross-client-session-tab-sync.md +++ /dev/null @@ -1,68 +0,0 @@ -# [mobile] Cross-client session and tab sync - -Status: design proposal. Companion to `plans/mobile-chat-runtime.md`. - -## Problem - -Workspace session state is split across storage layers, so desktop and mobile do -not present the same workspace contents. - -| State | Lives in | Shared today? | Result | -|---|---|---|---| -| Chat session metadata | Cloud Postgres | Yes, via Electric | Chat list is shared | -| Chat message content | Host mastracode memory | No | Mobile needs online host relay | -| Terminal sessions/status | Host SQLite | No | Mobile uses interim host query | -| Open panes/tabs | Desktop localStorage | No | Tabs are per device | - -## Goal - -Give all clients the same per-workspace session list without making desktop tab -layout global across devices. - -## Recommendation - -### 1. Sync terminal sessions and agent status. - -Add a cloud `terminal_sessions` style table and dual-write from host-service -when terminal sessions are created/exited and when terminal-agent lifecycle -events arrive. Add the table to Electric and expose client collections. - -Sketch: - -`terminal_sessions { id, organization_id, v2_workspace_id, host_id, agent_id, status, last_event_type, last_event_at, created_at, ended_at }` - -This removes mobile's per-host `terminalAgents.listByWorkspace` workaround and -lets offline clients show last-known terminal state. - -### 2. Render one workspace sessions list. - -Merge `chatSessions` and synced `terminalSessions` client-side, sorted by last -activity. - -- Mobile already uses this shape; terminal rows are tagged and not clickable. -- Desktop can add a launcher/list beside chat sessions. Opening a chat row should - reuse the existing focus-or-add-chat-pane path. Terminal rows can remain - status-only. - -### 3. Keep pane layout local-first. - -Do not sync full desktop pane geometry or opened tab layout. Phones and desktops -want different layouts, and full sync creates unnecessary conflict handling. - -If cross-device resume becomes important later, sync only an advisory pinned or -recent session set per user/workspace; keep geometry local. - -## Deferred - -- Persisting chat messages to cloud. This is larger than session-list parity - because mastracode owns the host memory store. -- Host push/subscriptions for mobile chat snapshots. This would reduce polling - volume but does not solve offline history. - -## Open Questions - -- Exact reduced terminal status model: `active`, `working`, `needs_input`, - `exited`, or a smaller set? -- Retention policy for exited terminal sessions. -- Whether terminal command titles are safe to sync to cloud. -- Whether CLI should read the same synced collection or stay host-direct. diff --git a/apps/mobile/plans/20260709-1438-unified-home-workspaces-sessions.md b/plans/done/20260709-1438-unified-home-workspaces-sessions.md similarity index 99% rename from apps/mobile/plans/20260709-1438-unified-home-workspaces-sessions.md rename to plans/done/20260709-1438-unified-home-workspaces-sessions.md index bb254fa80dd..2703a804ff6 100644 --- a/apps/mobile/plans/20260709-1438-unified-home-workspaces-sessions.md +++ b/plans/done/20260709-1438-unified-home-workspaces-sessions.md @@ -44,7 +44,7 @@ To see it working: run the app (`cd apps/mobile && bun dev`, or an existing dev Evidence: `useCreateChatWorkspace.ts` only calls `client.workspaces.create.mutate`; `grep agents.run apps/mobile` has no hits. - Observation: The native `modules/tab-bar` module exists but is unused; the app has no bottom tab bar. Home is a plain stack, so this redesign does not fight any tab structure. Evidence: `grep -rn "tab-bar" apps/mobile/app apps/mobile/screens` returns nothing. -- Observation: `chat_sessions` has no status or agent column (only id/workspace/title/timestamps), so chat rows cannot show a live status dot yet — dots appear on terminal rows only, and chat rows use a fixed Claude logo. Syncing session status via Electric (see plans/cross-client-session-tab-sync.md) would light them up. +- Observation: `chat_sessions` has no status or agent column (only id/workspace/title/timestamps), so chat rows cannot show a live status dot yet. Canonical live status now belongs to the host session sync tracked in `plans/host-sessions-rollout.md`, not a separate Electric terminal/session proposal. Evidence: `packages/db/src/schema/schema.ts:670`. - Observation: Mobile typecheck in a fresh worktree fails on every `HostWorkspaceRow` property because `@superset/host-service/router` types resolve from generated `dist-types/` that don't exist until `bun run build:types` runs in `packages/host-service`. Separately, mobile's tsc reports pre-existing errors in `packages/port-scanner`/`pty-daemon`/`workspace-fs` (bun-flavored `.ts` imports) — identical on clean HEAD, not caused by this change. Evidence: stash-and-typecheck comparison, 2026-07-09. diff --git a/plans/done/20260710-session-harness-acp.md b/plans/done/20260710-session-harness-acp.md index 6572bc5d531..babe2980d6f 100644 --- a/plans/done/20260710-session-harness-acp.md +++ b/plans/done/20260710-session-harness-acp.md @@ -2,7 +2,7 @@ Status: **implemented**. Current runtime behavior is maintained in `packages/host-service/docs/acp-sessions.md`. Remaining hardening and -architecture work is tracked in `plans/acp-session-follow-ups.md`. +architecture work is tracked in `plans/host-sessions-rollout.md`. ## Outcome @@ -126,4 +126,4 @@ suite. - Rich adapter-specific semantics such as native task graphs, workflows, subagent prose, and goals are flattened or absent on the ACP wire. -All of these are explicit workstreams in `plans/acp-session-follow-ups.md`. \ No newline at end of file +All of these are explicit workstreams in `plans/host-sessions-rollout.md`. diff --git a/plans/host-integration-test.md b/plans/host-integration-test.md deleted file mode 100644 index ee890e5bf3a..00000000000 --- a/plans/host-integration-test.md +++ /dev/null @@ -1,182 +0,0 @@ -# Host Integration Test - -Status: **active; first package-boundary suite shipped, process/Node/mobile lanes remain**. - -This plan is the focused test workstream for ACP sessions. The complete runtime, -packaging, persistence, state, and mobile backlog is in -`plans/acp-session-follow-ups.md`. Current behavior is documented in -`packages/host-service/docs/acp-sessions.md`. - -## Goal - -Test the same boundary a product client uses: - -```text -@superset/host-client - -> authenticated host-service HTTP and WebSocket server - -> acpSessions router and stream route - -> AcpSessionManager - -> deterministic fake ACP adapter child - -> temporary on-disk native transcript fixture -``` - -The always-run boundary suite at -`packages/host-service/test/integration/acp-host-client.e2e.test.ts` now proves -that the extracted host client and a real host server agree on procedure names, -SuperJSON envelopes, auth, output shapes, WebSocket cursors, and in-process host -restart behavior. The remaining work is a separate OS-process/Node lane and the -iOS product lane. - -## Completed Prerequisites - -- [x] `@superset/host-client` is extracted from mobile and owns generic - fetch/SuperJSON transport, one-time 401 refresh, relay URL construction, and - stream URL construction. -- [x] Mobile consumes `@superset/host-client` through - `apps/mobile/lib/host/client.ts`. -- [x] The fake adapter is a real child process speaking ACP JSON-RPC over stdio - through the official SDK. -- [x] Manager-level deterministic tests cover turns, tools, permissions, - elicitations, cancel, crash, journal eviction, and session/load replay. -- [x] Router and WebSocket route tests cover feature gating, error mapping, - fan-out, reconnect, and malformed `since` cursors. -- [x] A host-local SQLite registry maps the public session id to the native ACP - session id and harness for restart resurrection. -- [x] The real `createApp` HTTP/tRPC host runs behind a relay-shaped prefix and - is driven through `@superset/host-client` plus the real WebSocket sync client. -- [x] The boundary suite closes and reopens an on-disk registry, resurrects via - `session/load`, and verifies the client-visible error/reset after deleting the - harness-owned native transcript. - -## Shipped Always-run Suite - -The suite stays under `packages/host-service/test/integration/`. Keeping it with -host-service avoids a dev-dependency cycle from host-client back to the server -package. - -It currently: - -1. Create a temporary workspace and an on-disk host database. -2. Run host migrations through the normal test helper. -3. Start the real `createApp` server on an ephemeral port with the ACP feature - enabled, the fake adapter injected, and production auth middleware active. -4. Construct the real `@superset/host-client` against that endpoint. The - transport needs a direct-host URL mode or an in-process relay-shaped proxy; - do not duplicate its serialization logic in the test. -5. Drive named client methods only. Assertions may inspect the temporary - filesystem/database after a step, but must not invoke manager methods to - advance the scenario. The shipped flow includes a question answer, an - in-flight cancellation, simultaneous permissions, and cursor reconnect. -6. Shut down the whole server, close the database, and terminate adapter - children. -7. Starts a fresh app/server/manager generation against the same DB/workspace - paths and proves offline listing, on-demand `session/load`, history, stream - attach, and client-visible load failure. - -Still missing here: a separate OS process for the restarted host, the packaged -Node entrypoint with `better-sqlite3`, canonical output parsers, and the iOS -Maestro lane. - -## Canonical Flow - -1. `listSessions` returns `enabled: true` and no rows. -2. `createSession` returns idle/default-mode state and one registry row. -3. Two WebSocket clients attach to the same session. -4. A question prompt parks an interaction card; the client selects an answer - and observes the completed turn on the stream. -5. A running tool is cancelled through the client and terminalizes once. -6. A prompt requests permission; both clients receive identical gapless frames. -7. One client answers. The other sees the same resolution and the turn ends. -8. `getMessages` pagination folds to the same timeline as the live stream. -9. One socket disconnects, more frames arrive, and cursor reconnect catches up - without duplicates. -10. A tiny catch-up ring forces `journal_evicted`; full resync succeeds from the - disk-backed history source once that workstream lands. -11. A 401 causes exactly one token refresh and retry. A second 401 surfaces. -12. The host is fully stopped and restarted from the same on-disk DB. -13. `listSessions` shows the session as offline without spawning an adapter. -14. Opening history or the stream resurrects the same native session. -15. A stale pre-restart cursor resets by journal incarnation. -16. A post-restart prompt completes and appends to the same history. - -## Negative Cases - -- feature gate disabled: `list` is disabled/empty, commands fail, stream route - is absent, no child is spawned; -- invalid/expired auth over HTTP and WebSocket; -- session id bound to a different workspace; -- unknown session and malformed list/history/stream cursors; -- adapter fails initialize/new/load and exits during a turn; -- transcript missing or corrupt after registry lookup; -- host restart during a pending permission; -- wrong-session or malformed server output rejected by the real client; -- server close leaves no adapter process, socket, DB handle, or temp directory. - -## Runtime Lanes - -### Authenticated real-Claude lane (primary) - -Use the real pinned `claude-agent-acp`, a real Sonnet model, and the machine's -existing Claude login in a throwaway git workspace. This is the primary -acceptance evidence for model/adapter behavior, not an optional smoke. Run it on -an authenticated Mac whenever ACP runtime, adapter/SDK, Workflow, permission, -question, cancellation, stream, reconnect, or resurrection code changes: - -```bash -cd packages/host-service -ACP_E2E=1 ACP_E2E_MODEL=sonnet ACP_E2E_EFFORT=low \ - bun test \ - test/integration/acp-sessions.integration.test.ts \ - test/integration/acp-sessions-stream.integration.test.ts -``` - -The lane is not in ordinary CI yet because it needs a Claude login and spends -real tokens. Until a safe authenticated runner exists, the coding agent making -a relevant change is responsible for running it locally and reporting the -actual result. - -### Always-run deterministic lane (belt and suspenders) - -- fake adapter; -- no model, tokens, external network, or user credentials; -- Bun test runner for the broad matrix; -- a Node lane for the production `better-sqlite3` driver and packaged host - entrypoint. - -This lane is valuable for broad matrices, exact fault injection, and fast CI -feedback. It is not evidence that the real Claude adapter/model still behaves -the same way, and it never substitutes for the authenticated lane. - -### iOS product lane - -Maestro starts from the mobile session list and talks through the relay to the -same test host. Cover create, permission, reconnect/background, host restart, -offline row, resume, older-page loading, and load failure. Relaunch the app -during restart scenarios so the result cannot pass through retained React -state. - -## Contract Ownership - -The current `@superset/session-protocol` package mixes contracts, sync logic, -and React hooks. The planned split is: - -- `@superset/host-service-sync`: schemas, types, fold, cursors, WebSocket sync, - framework-free store; -- `@superset/host-service-react`: React bindings; -- `@superset/host-client`: transport and named host clients. - -The integration suite should consume named operations with output parsers from -`host-service-sync`; it must not import the host's full `AppRouter` into mobile -or hand-maintain a second response facade. - -## Acceptance - -- The complete canonical flow passes through a real server and real - `@superset/host-client`. -- Restart closes and reopens an on-disk DB in a new host process. -- Bun and Node/`better-sqlite3` lanes pass. -- The authenticated real-Claude suites pass on a Mac after every relevant - ACP/runtime change. -- Malformed outputs fail at the client parser, not later in the fold/UI. -- Teardown leaves no process, socket, file handle, or temporary directory. -- The deterministic backup remains always-run and requires no cloud services. diff --git a/plans/host-service-chat-architecture.md b/plans/host-service-chat-architecture.md deleted file mode 100644 index 0e026b2309f..00000000000 --- a/plans/host-service-chat-architecture.md +++ /dev/null @@ -1,733 +0,0 @@ -# Host-Service Chat Architecture - -## Summary - -This doc defines how chat should work in the host-service architecture. - -The goal is not to port the current desktop chat stack 1:1. The goal is to make -chat a first-class host capability in the same way terminal, pull requests, and -workspace git state are host capabilities. - -The core idea: - -- cloud remains the source of truth for shared chat session metadata -- host-service owns local runtime execution for workspace-bound chat sessions -- renderer talks to host-service for chat lifecycle and runtime interaction -- Mastracode still powers the local agent runtime, but no longer lives behind - desktop-only Electron tRPC - -## Status - -- Owner: platform/chat + platform/workspaces -- Scope: local desktop host first -- Last updated: 2026-03-17 - -## What Exists Today - -Current chat is split across three different runtime boundaries. - -### 1. Desktop main process hosts the live Mastracode runtime - -The active chat runtime is created in: - -- `packages/chat/src/server/trpc/service.ts` - -That service: - -- creates Mastracode runtimes via `createMastraCode(...)` -- keeps a `RuntimeSession` map in memory -- exposes session methods such as: - - `getDisplayState` - - `listMessages` - - `sendMessage` - - `restartFromMessage` - - `stop` - - approvals/questions/plans - -Desktop exposes this through Electron tRPC in: - -- `apps/desktop/src/lib/trpc/routers/chat-runtime-service/index.ts` - -### 2. Desktop main process separately hosts chat support services - -Desktop also exposes a separate "chat service" for: - -- auth status and credential management -- slash command discovery/resolution -- file search -- MCP overview - -Main files: - -- `packages/chat/src/server/desktop/chat-service/chat-service.ts` -- `packages/chat/src/server/desktop/router/router.ts` -- `apps/desktop/src/lib/trpc/routers/chat-service/index.ts` - -### 3. Session bootstrap still uses API routes - -Desktop still creates and deletes chat session records through API routes: - -- `apps/api/src/app/api/chat/[sessionId]/route.ts` -- `apps/api/src/app/api/chat/[sessionId]/stream/route.ts` - -And the active chat pane still calls those routes from: - -- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts` - -These API routes currently do two things: - -- manage canonical `chat_sessions` rows in Postgres -- create/delete durable streams for those sessions - -### 4. Host-service does not own chat yet - -Host-service currently owns: - -- project/workspace creation and deletion -- git status -- pull request runtime -- terminal websocket sessions - -Main entrypoints: - -- `packages/host-service/src/app.ts` -- `packages/host-service/src/trpc/router/router.ts` - -There is no chat runtime or chat router mounted in host-service today. - -## Goals - -- make chat a host-service runtime capability -- bind chat sessions to local workspaces hosted by host-service -- preserve Mastracode as the local agent runtime -- keep cloud-backed `chat_sessions` as the canonical shared session record in v1 -- remove the requirement that desktop main process own live chat runtimes -- allow the same chat contract to eventually work for: - - local desktop hosts - - cloud hosts - - remote device hosts -- minimize drift from the current working chat behavior while validating the new - host-service path - -## Non-Goals (v1) - -- do not redesign the renderer chat experience in this doc -- do not move canonical session metadata from cloud Postgres into host-service -- do not solve remote/cloud host execution in the first implementation -- do not migrate old durable-stream semantics in this doc -- do not commit to final event transport yet if polling/request-response is - enough for the first host-service version - -## Locked Constraints - -- workspace-bound chat must execute in the workspace's local checkout -- canonical session metadata is already represented by `chat_sessions` in - `packages/db/src/schema/schema.ts` -- desktop already relies on Electric replication of `chat_sessions` -- current Mastracode auth resolution is desktop-local and must be adapted - deliberately for host-service -- host-service already has a runtime object on context and should own more - runtime subsystems over time -- the first host-service version should be built in parallel to the current chat - stack, not by progressively mutating the current desktop runtime in place - -## Desired End State - -### High-level ownership - -Cloud owns: - -- `chat_sessions` -- session titles and metadata visible across devices -- auth-protected shared APIs - -Host-service owns: - -- local Mastracode runtime instances -- workspace cwd resolution -- local provider credential resolution through a shared provider/settings layer -- local file search / slash commands / MCP overview -- runtime lifecycle and ephemeral state - -Renderer owns: - -- pane/session selection UI -- optimistic UI state -- calling host-service chat routes - -### Runtime model - -For a local workspace chat session: - -1. renderer selects or creates a cloud `chat_session` -2. renderer calls host-service chat routes for that session + workspace -3. host-service resolves the local workspace checkout -4. host-service creates or reuses a Mastracode runtime for that session -5. host-service performs normal chat operations against that runtime - -## Proposed Host-Service Design - -## 1. Add `runtime.chat` to host-service - -Host-service should gain a chat runtime manager, similar in role to the pull -request runtime manager. - -It should be created in: - -- `packages/host-service/src/app.ts` - -And mounted on context as: - -- `ctx.runtime.chat` - -Suggested shape: - -```ts -interface HostChatRuntime { - getDisplayState(input): Promise<...> - listMessages(input): Promise<...> - sendMessage(input): Promise<...> - restartFromMessage(input): Promise<...> - stop(input): Promise - respondToApproval(input): Promise<...> - respondToQuestion(input): Promise<...> - respondToPlan(input): Promise<...> - getMcpOverview(input): Promise<...> - authenticateMcpServer(input): Promise<...> - searchFiles(input): Promise<...> - ensureSessionRuntime(input): Promise<...> -} -``` - -Internally this should manage: - -- `Map` -- in-flight runtime creations -- runtime lifecycle event forwarding -- workspace resolution -- title generation - -## 2. Keep workspace as the execution boundary - -The host runtime should not accept arbitrary cwd as the primary model forever. - -The real execution boundary should be: - -- `workspaceId` - -The runtime manager should resolve: - -- `workspaceId -> local workspace row -> worktreePath` - -From host-service local DB: - -- `packages/host-service/src/db/schema.ts` - -Passing raw `cwd` should be treated as transitional compatibility only if we -need it during migration. - -## 3. Keep cloud `chat_sessions` as canonical metadata in v1 - -The canonical session record should remain in shared Postgres for now: - -- `packages/db/src/schema/schema.ts` - -This is important because desktop already consumes: - -- `chat_sessions` -- `session_hosts` - -via Electric collections. - -So the host-service chat runtime should not try to replace the session table in -v1. It should treat cloud `chat_sessions` as the source of truth for: - -- session identity -- organization scope -- workspace link -- title -- listing/order metadata - -## 4. Add a host-service chat router - -Host-service should expose chat through its own tRPC router namespace. - -Suggested namespace: - -- `chat` - -- `getDisplayState` -- `listMessages` -- `sendMessage` -- `restartFromMessage` -- `stop` -- `respondToApproval` -- `respondToQuestion` -- `respondToPlan` -- `getSlashCommands` -- `resolveSlashCommand` -- `previewSlashCommand` -- `getMcpOverview` - -Chat should not own provider credential CRUD. - -Those flows should move under a dedicated model-provider credential layer rather -than staying chat-owned. - -In host-service terms, the direction we discussed is: - -- `providers/git/...` for git credentials -- `providers/model-providers/...` for Anthropic/OpenAI credential - resolution - -The model-provider layer should own: - -- provider auth status -- API key CRUD -- OAuth start/complete/disconnect -- env config overrides - -Chat should only consume the resolved provider/runtime config through an -internal adapter. - -## Provider Credential Adapter Requirement - -This is the most important non-trivial design problem. - -Current auth resolution is not "cloud auth." It is a mix of: - -- Mastracode auth storage -- Claude config files -- macOS keychain lookup -- managed env config -- API key management -- OAuth session state - -Relevant current files: - -- `packages/chat/src/server/desktop/chat-service/chat-service.ts` -- `packages/chat/src/server/desktop/auth/anthropic/anthropic.ts` -- `packages/chat/src/server/desktop/auth/openai/openai.ts` - -Current UI ownership is already drifting this way: - -- the settings page uses `chatServiceTrpc.auth.*` for the actual writes -- `electronTrpc.modelProviders.getStatuses` already derives higher-level provider - status for settings UI - -So the current code already suggests that provider auth/config should stop being -chat-owned. - -Host-service cannot just reuse that implicitly. It needs an explicit credential -resolution abstraction. - -### Proposed interfaces - -Provider/settings-owned code should resolve generic provider credentials, not -Mastracode-specific runtime config. - -```ts -type AnthropicCredential = - | { - kind: "api_key"; - apiKey: string; - source: "auth-storage" | "config" | "keychain" | "env"; - } - | { - kind: "oauth"; - accessToken: string; - headers: { - "anthropic-beta": string; - "user-agent": string; - "x-app": string; - }; - source: "auth-storage" | "config"; - expiresAt?: number; - }; - -type OpenAICredential = - | { - kind: "api_key"; - apiKey: string; - source: "auth-storage"; - } - | { - kind: "oauth"; - accessToken: string; - source: "auth-storage"; - accountId?: string; - expiresAt?: number; - }; - -interface ModelProviderCredentialResolver { - getAnthropicCredential(): Promise; - getOpenAICredential(): Promise; -} -``` - -Host-service chat should then adapt those generic credentials into the exact -inputs Mastracode needs when creating a runtime. - -```ts -interface MastraCodeProviderOptionsAdapter { - toRuntimeProviderOptions(input: { - anthropic: AnthropicCredential | null; - openai: OpenAICredential | null; - }): { - env?: Record; - // other Mastracode/provider-specific options as needed - }; -} -``` - -This keeps provider resolution reusable for other AI features, such as: - -- naming workspaces -- generating summaries -- future host-owned automation outside chat - -### Initial implementation - -For local desktop host-service, the adapter can still use the same local -mechanisms as desktop main does today: - -- `createAuthStorage()` -- Claude config files -- keychain -- managed env config - -But it should be owned by a shared model-provider/settings subsystem, then -consumed by host-service chat through an adapter, not owned by chat itself. - -That keeps the behavior stable while moving the boundary. - -### Why not pass auth state from the renderer? - -Renderer should not become the credential transport layer for chat. - -The better boundary is: - -1. settings/provider flows persist credentials once -2. host-service resolves effective provider credentials locally -3. host-service chat adapts those credentials into Mastracode inputs at runtime - creation time - -That keeps chat focused on runtime execution rather than credential management. - -### Existing code we can reuse - -Most of the provider-resolution logic already exists today in desktop-owned -chat code and should be extracted rather than rewritten from scratch. - -Relevant files: - -- `packages/chat/src/server/desktop/chat-service/chat-service.ts` -- `packages/chat/src/server/desktop/chat-service/auth-storage-utils.ts` -- `packages/chat/src/server/desktop/auth/anthropic/anthropic.ts` -- `packages/chat/src/server/desktop/auth/openai/openai.ts` -- `apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx` - -The settings page is already a UI over these flows. The architectural change is -to move ownership of that credential resolution out of chat and let host-service -consume it through the new resolver interface. - -## Session Lifecycle In The Host Model - -### Session create - -In v1, session creation should likely remain cloud-first: - -1. create `chat_sessions` row through API/trpc -2. persist/update `session_hosts` if needed -3. renderer opens pane for that session -4. host-service lazily creates runtime on first real interaction - -This avoids moving too many concerns at once. - -### Session runtime creation - -When renderer first calls a host chat method for a session: - -1. host-service validates the workspace exists locally -2. host-service resolves session -> workspace binding -3. host-service resolves credentials -4. host-service creates Mastracode runtime in that workspace cwd -5. host-service stores runtime in local in-memory map - -### Session delete - -Deletion should remain cloud-first in v1: - -1. delete `chat_sessions` row and any cloud durable-stream state if it still - exists -2. host-service destroys any in-memory runtime for that session - -## Transport Decision - -For local desktop host-service, keep this simple: - -- renderer -> host-service tRPC over local HTTP - -This mirrors the pattern already used for pull requests and workspace state. - -For now, chat does not need a new special transport just because the old stack -used a different one. - -### Reads and updates - -The minimal host-service chat can work over request/response for: - -- display state -- messages -- send/restart/stop/respond - -### Streaming - -We should not assume the final event transport in this doc yet. - -Reason: - -- current chat UI already knows how to poll/requery display state/messages -- the host-service architecture may eventually want SSE or WebSocket for chat -- but we do not need to decide the final stream protocol to move runtime - ownership into host-service - -So v1 can begin with request/response and add event streaming once the host -runtime boundary is correct. - -## Renderer Migration Plan - -Current renderer wiring: - -- `ChatPane` mounts providers from `@superset/chat/client` -- runtime calls go to Electron IPC client for `chatRuntimeService` -- support calls go to Electron IPC client for `chatService` - -Main files: - -- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx` -- `apps/desktop/src/renderer/components/Chat/utils/chat-service-client.ts` -- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/utils/chat-runtime-service-client.ts` - -Target migration: - -1. keep renderer view model and pane UX mostly intact -2. swap chat clients from Electron IPC to host-service clients -3. keep API/session bootstrap logic temporarily if needed -4. remove Electron main chat routers after host-service parity - -## V2 Workspace Rollout - -The first host-service-backed chat experience should live in the new v2 -workspace surface. - -### Initial UI shape - -Use a top-level tab group in the v2 workspace page to switch between: - -- the current v2 workspace layout -- a new chat surface for that workspace - -This is explicitly an iteration path, not the final pane architecture. - -### Important constraints - -- do not redesign pane persistence around this first rollout -- do not block on the future pane-storage model -- keep the chat UI visually aligned with the current chat experience so we can - validate the transport/runtime migration independently - -That means the first v2 workspace chat should preserve the existing chat UI -patterns as closely as possible: - -- session switcher in the top bar -- same message list / composer UX -- same slash-command behavior -- same model picker behavior -- same approval/question/plan UX - -Only the transport/runtime boundary should change: - -- old path: renderer -> Electron chat routers / legacy API session plumbing -- new path: renderer -> workspace host-service chat router - -### Why this is the right rollout - -- fast iteration without rewriting pane storage -- validates host-service chat in the place where workspace-native runtime - features are already moving -- reduces surface area compared to replacing the entire old chat pane path at - once -- lets us preserve familiar UX while changing the architecture underneath - -## Suggested Phases - -## Phase 1: establish host-service chat core - -- create host-service chat runtime manager -- move reusable runtime logic behind host-service -- move slash-command/file-search/MCP helpers into host-service-owned chat - modules -- move provider auth/config ownership toward a shared provider/settings layer -- let host-service chat consume provider runtime config through an adapter -- expose host-service `chat.*` router - -Goal: - -- host-service can create and drive a local Mastracode runtime for a workspace - -## Phase 2: move renderer transport - -- add renderer host-service chat clients -- add host-service-backed chat surface to v2 workspaces -- preserve the existing chat UI structure while swapping transport/runtime - ownership -- keep cloud `chat_sessions` as-is -- keep compatibility session bootstrap paths if needed - -Goal: - -- first real chat UX no longer depends on Electron main chat routers - -## Phase 3: remove desktop-main chat runtime ownership - -- delete `chatRuntimeService` Electron router -- delete `chatService` Electron router -- remove desktop main process runtime ownership for chat - -Goal: - -- host-service is the only local chat runtime owner - -## Phase 4: decide final session/bootstrap cleanup - -- decide whether `/api/chat/[sessionId]` compatibility routes remain necessary -- decide whether durable streams remain part of the architecture -- decide whether session-host binding belongs in API, host-service, or both - -## Open Questions - -### 1. Where should title generation live? - -Current title generation updates cloud `chat_sessions.title`. - -Options: - -- keep it in host-service and call shared API/tRPC mutation -- move it to cloud after first message write - -Likely answer: - -- keep it in host-service for now, because it is coupled to the local runtime - prompt lifecycle - -### 2. Should host-service own a local chat cache or DB tables? - -Possibly useful later for: - -- runtime snapshots -- local message cache -- local indexing - -But likely not needed for the first migration, because canonical session metadata -and messages already have existing paths. - -### 3. What replaces durable streams? - -Current old API chat routes still manage durable streams. - -Open question: - -- do we still need durable stream replay once host-service is the runtime owner? - -Likely answer: - -- not for the first migration decision -- defer until the host runtime boundary is in place - -### 4. How should session-host affinity work? - -There is already a shared `session_hosts` table. - -We need to decide whether host-service should: - -- register itself as the active host for a session -- claim/reclaim sessions explicitly -- remain stateless about host ownership in v1 - -### 5. Do we keep direct Mastracode auth storage forever? - -Probably not as the long-term abstraction. - -But for local desktop host-service, reusing the current mechanisms is likely the -fastest way to preserve behavior while relocating ownership. - -### 6. Should MCP management follow the same ownership model? - -Probably yes. - -There is a strong parallel between: - -- provider auth/config used by chat -- user-managed MCP configuration used by chat/runtime features - -Longer term, both likely belong in shared host-owned settings/config layers that -runtime subsystems consume through adapters. - -But MCP settings migration is out of scope for the first host-service chat -implementation. - -## Recommended First Implementation Slice - -The first real build slice should be: - -1. create host-service `chat.*` routes and runtime manager -2. create host-service runtime manager capable of: - - ensure/create runtime for `{ sessionId, workspaceId }` - - `getDisplayState` - - `listMessages` - - `sendMessage` - - `stop` -3. define a provider runtime-config resolver that host-service chat can consume -4. keep cloud session creation as-is -5. point one desktop chat pane path at host-service behind a feature flag - -This would validate the architecture without forcing session/bootstrap or stream -decisions too early. - -## Implementation Approach - -The first host-service version should be a parallel reimplementation. - -That means: - -- do not heavily rewrite the current desktop chat runtime in place -- do not force the existing chat stack to become host-service-aware piece by - piece -- build the host-service chat runtime as a separate path with the same external - behavior where possible -- reuse proven runtime logic selectively, but keep the new ownership boundary - explicit - -This minimizes risk and keeps the cutover reversible until the host-service -path has real parity. - -## Implementation Checklist - -- [ ] Add `runtime.chat` to host-service app/context -- [ ] Define host-service chat router contract -- [ ] Reimplement host-service chat runtime alongside the current desktop chat - runtime -- [ ] Reuse proven logic where appropriate without tightly coupling the new - host-service runtime to the existing desktop runtime -- [ ] Extract provider credential resolution into a shared provider/settings-owned - module -- [ ] Define generic provider credential shapes -- [ ] Define Mastracode adapter from provider credentials to runtime creation - inputs -- [ ] Resolve `workspaceId -> worktreePath` as the primary runtime boundary -- [ ] Add renderer host-service chat client -- [ ] Feature-flag host-service-backed chat pane path -- [ ] Cut over active chat pane from Electron main tRPC to host-service -- [ ] Remove old Electron chat runtime routers -- [ ] Revisit old API chat session/bootstrap/stream routes after cutover diff --git a/plans/host-sessions-sync.md b/plans/host-sessions-sync.md new file mode 100644 index 00000000000..069f7ceae34 --- /dev/null +++ b/plans/host-sessions-sync.md @@ -0,0 +1,769 @@ +# Host Sessions Sync + +Status: **v1 — implemented on `feat/sessions-v1-simplified-api`, in team +review.** This is the single source of truth for the host sessions surface: +the protocol spec (three review layers), rollout state, and the E2E user +story the suites assert against. It supersedes the previous wider protocol +draft and the separate rollout doc; everything cut was implemented and +tested first, so the cuts are deliberate removals of speculation, not +unbuilt wishes. + +The spec is structured as three review layers. Layer 1 is the pitch — read +it alone to judge the approach. Layer 2 is the design — state ownership, the +exact surface, recovery, testing. Layer 3 is one realistic end-to-end +scenario with exact payloads. Rollout and the user story close the doc. + +--- + +## Layer 1 — Principles and approach + +One host process per machine runs coding-agent sessions through vendor +harnesses (Claude Code today, more later). Phones and desktops are thin +replicas: they render state the host owns and submit intents. The protocol +between them is deliberately small: + +**Budgets (hard):** 9 tRPC procedures · 12 socket packet types · 2 cursor +kinds · 1 automatic recovery path + 1 human hammer. + +1. **The event log is the only truth in motion; everything else is a + projection.** The host folds vendor output into canonical events; host + and client run the *same* pure reducer over them. No second state + machine to drift. +2. **Mutations acknowledge admission, not completion.** Every mutation + carries an idempotent `requestId` and returns a receipt; the outcome + arrives later as events whose `causationId` echoes the request. Retries, + double-taps, and races between devices are absorbed by receipts — + first write wins, losers get a clean stale error. +3. **Big payloads ride tRPC; the socket carries only small frames.** + Snapshots (session list, session state) are tRPC query responses. The + WebSocket carries individual events, acks, and control frames — nothing + that grows with session size. +4. **Cursors are opaque and incarnation-tagged.** Clients store and return + them verbatim, never parse or compare. A cursor from a dead log + generation fails loudly (`reset`) instead of silently misaligning. +5. **Recovery is the cold path, not a special mode.** `reset` on a stream + triggers exactly what first-connect does: tRPC snapshot → subscribe from + its head. There is no separate resync state machine. When the host + itself is wedged, the existing desktop "restart host" button is the + answer — sessions re-hydrate from durable state; we add no intermediate + per-session recovery procedures. +6. **Harness-agnostic entities.** Canonical `Message`/`ContentBlock`/ + `ToolCall`/`Thread` shapes — not AI-SDK or ACP types — so the same wire + serves the ACP adapter today and a direct Claude SDK adapter next + without client changes. + +### Trajectory: one host control plane, terminals stay separate + +This is **host-level sync, not session-level sync** — sessions are just the +first domain riding it. + +- **One control socket per host.** The host stream on `/sessions/sync` is + the host's control plane: small, ordered, cursor-replayable events for + everything the host owns. Today that is session/permission lifecycle; + workspace lifecycle (created/deleted/renamed), host status, and agent + availability join as new host-stream event types plus matching tRPC + snapshot procedures — never as new sockets or a second sync machine. +- **Terminal sync is a different animal and stays out.** Terminals are a + throughput/data plane — raw PTY bytes, latency-sensitive, tolerant of + loss on reconnect — already served by the dedicated `/terminal/*` path. + Control and data planes never share a socket: a busy terminal must not + head-of-line-block a permission card, and PTY bytes must never pay the + event-log/cursor/replay overhead built for control events. +- **The client store extends the same way.** The vanilla zustand store is + already host-scoped (one replica per host: connection, host stream, + `sessionsById`, per-session streams). New domains land as sibling slices + folded from the same host stream by the same reducer pattern — one + socket, one cursor discipline, one store per host. + +### Comparison at a glance + +| Boundary choice | Strong at | Superset would still own | Best role | +|---|---|---|---| +| ACP | Agent ↔ editor interoperability, prompts, tools, permissions, capabilities, and agent-session lifecycle | Multi-device subscriptions, host-wide state, nested threads, workflow and subagent fidelity, cursor recovery, and non-chat domains | Host ↔ vendor harness | +| AI SDK UI | Streamed message parts, tool and approval state, typed UI state, chat hooks, and reusable components | Host multiplexing, persistence, multi-client arbitration, pagination and search, watcher events, and recovery | Canonical projection ↔ product UI | +| Superset-owned contract | Exact product entities, one host control plane, deterministic convergence, and additive product evolution | Schemas, reducer, client store, compatibility policy, and regression tests | Public host ↔ client boundary | + +### Why the host contract is product-owned + +ACP and AI SDK solve valuable but narrower problems than this contract. + +ACP standardizes communication between an editor/client and an agent. AI SDK +UI standardizes how one chat consumes and renders a model response stream. +Superset must synchronize an entire host across multiple clients: session +directory and metadata, nested threads, permissions, client tools, workflows, +host status, workspace lifecycle, and future host-owned domains. + +Using ACP or AI SDK as the public host wire would therefore not remove a +Superset protocol. Superset-specific semantics would move into `_meta`, custom +data parts, extension methods, or a second side channel. The result would have +two nested envelopes, two versioning surfaces, and more intermediate state, +while the product contract would still be ours in practice. + +The product-owned boundary gives us: + +- explicit `subscribe` / `unsubscribe` semantics for many streams over one + host connection; +- multi-device arbitration, including idempotent intents and first-resolution + wins; +- first-class `Thread`, workflow, subagent, and future side-chat semantics even + when the active harness does not expose them; +- one cursor, replay, reset, and cold-load discipline for every host control + domain; +- room for search, pagination, file-watcher invalidations, workspace updates, + and other host capabilities without pretending they are chat messages; +- one canonical reducer and one client replica instead of an upstream chat + state machine wrapped inside a second product state machine. + +The transport split follows the data: + +- tRPC carries snapshots, queries, and idempotent commands; +- `/sessions/sync` carries small ordered control events and replay; +- `/terminal/*` remains a separate throughput-oriented data plane for raw PTY + bytes. + +This is not a rejection of OSS. Upstream implementations are inputs, not the +source of product truth. We can vendor AI Elements, reuse AI SDK interaction +patterns, and align canonical shapes with ACP where the semantics match. +Upstream types terminate at adapters rather than leaking into the public +contract. + +ACP remains useful at the vendor boundary today because the Claude and Codex +adapters reduce integration work and are already proven. Moving an adapter +in-process or driving a vendor SDK directly is a reversible optimization when +adapter lag, lost semantic fidelity, process complexity, or debugging cost +justifies it. SDK and adapter bumps run through the same real-model gym, +deterministic protocol suites, and Maestro regression story before shipping. + +### Testing approach + +- **Validation spine:** one scripted "gym" user story (streaming, interrupt, + permission cards, always-allow, multi-select questions, subagent, + workflow, parallel sessions) replayed over every execution surface: + (1) the shipping ACP surface, (2) this protocol with a real model, + (3) the direct SDK adapter, (4) a phone via Maestro. Same story, four + wires — divergence is a bug by definition. +- **Deterministic fakes for the nasty parts:** a scripted fake ACP adapter + (real JSON-RPC over stdio, no model) drives restart/crash/eviction + scenarios in milliseconds inside `bun test`. +- **Stress oracles:** the sync hub runs a randomized multi-client + drop/reconnect/eviction storm; an oracle asserts byte-parity between what + the host holds and what every client converged to. +- **Adversarial review loop:** every protocol invariant that survives review + (internal + external AI reviewers + second-opinion codex runs) becomes a + Zod `superRefine` the host enforces at runtime on inputs *and* outputs — + the contract is executable, not prose. + +--- + +## Layer 2 — Design + +### Where real state lives + +``` + clients (N per user) host (1 per machine) vendor harness +┌─────────────────────┐ tRPC ┌────────────────────────────┐ ACP ┌──────────────────┐ +│ sync client store │───────► │ sessions.* router │─────► │ adapter child │ +│ (zustand replica, │ muts + │ CanonicalSessionsRuntime │ │ (1 per live │ +│ DISPOSABLE) │ snaps │ · event log (RAM ring) │ ◄──── │ session) │ +│ │ │ · projection │replay │ │ +│ │ ◄────── │ SyncHub (cursors, streams) │ │ transcript JSONL │ +└─────────────────────┘ WS │ SQLite registry (DURABLE) │ │ (DURABLE, vendor │ + /sessions/sync events └────────────────────────────┘ │ owns it) │ + └──────────────────┘ +``` + +| Store | Durability | Owns | Rebuilt from | +|---|---|---|---| +| SQLite registry (host) | survives host death | which sessions exist: id → workspace, native session id, harness | — (truth) | +| SQLite canonical session meta (host) | survives host death | top-level host-side session edits: title override, archivedAt, closedAt | — (truth) | +| Vendor transcript (`~/.claude/...` JSONL) | survives host death | conversation content — deliberately the ONLY place messages persist | — (truth) | +| Host RAM: canonical event log + projection | dies with host | live event stream, cursors, pending cards | registry + vendor replay (`session/load`), under a fresh log generation | +| Client store | disposable | rendered replica | tRPC snapshot + WS replay | + +The invariant that keeps recovery simple: **every layer is rebuildable from +the layers to its right.** Nothing a client holds is ever authoritative; +nothing in host RAM is unrecoverable. + +### Control flow (high level) + +``` +COLD PATH (= first connect, = reconnect after reset, same code): + tRPC sessions.list() ──► sidebar state + host-stream head cursor + tRPC sessions.get(id) ──► session snapshot + session-stream head cursor + WS subscribe(after: head) ──► subscribed → replay gap → caughtUp → LIVE + +LIVE: + user intent ──► tRPC mutation (requestId) ──► receipt (admission) + outcome ──► WS events (causationId = requestId) ──► reducer ──► UI + +RECOVERY: + stream reset ──► re-run cold path for that one stream (others untouched) + reset loop ──► circuit breaker: stream status = error, stop, surface to UI + host wedged ──► human presses "restart host" (existing desktop feature) + registry survives → sessions listed as offline + first live mutation → spawn adapter → session/load replays + transcript from vendor disk → canonical log rebuilt +``` + +### tRPC surface — 9 procedures + +All wire identifiers (packet types, event types, enum values) are +**camelCase**. Every mutation input carries `requestId`; receipts are +admission-acks. + +| Procedure | Shape | +|---|---| +| `list` → **host snapshot** | `{} → { sessions: Session[], pendingPermissions: PermissionRequest[], openClientToolCalls: ToolCall[], head: Cursor \| null }` — all non-archived sessions, **no paging**. Ungated capability probe: gated-off hosts return empty arrays, `head: null`. | +| `get` → **session snapshot** | `{sessionId} → { session, threads[], activeTurns[], pendingPermissions[], openToolCalls[], recentEvents[], hasOlderEvents, head }` — `recentEvents` is a ≤ 50 tail ending exactly at `head`; `hasOlderEvents` tells the client whether `getEvents` paging has anything to fetch | +| `create` | `{requestId, workspaceId, agentId, title, settings} → {session, mainThread}` | +| `update` | `{requestId, sessionId, title?, archived?, settings?}` — one patch mutation for title/archive/model/mode/effort (absent = unchanged) | +| `getEvents` | `{sessionId, threadId?, beforeCursor?, limit ≤ 100} → EventsWindow` — **backwards-only** scrollback; no cursor = newest window | +| `submitTurn` | `{requestId, sessionId, threadId, content: ContentBlock[]} → {status: "accepted", turnId}` | +| `cancelTurn` | `{requestId, sessionId, turnId} → receipt` | +| `resolvePermission` | `{requestId, sessionId, permissionId, outcome}` — first write wins | +| `resolveToolCall` | `{requestId, sessionId, toolCallId, outcome}` — client tools (`ask_user`); **no claims**: the card renders on every device, first resolve wins, others drain via events | + +`EventsWindow`: `{ items ≤ 100 ascending, range: { oldest, newest, +hasMoreBefore, truncatedBefore }, head }`. Boundaries are full +`{eventId, cursor, occurredAt}` triples, schema-verified against the actual +first/last items. Pages come from the in-memory log (messages are +deliberately not persisted on the host); `truncatedBefore: true` = older +history existed but the source journal evicted it before the host observed +it. + +### Sync socket — 12 packet types, all small + +client → `hello` (protocolVersion, clientInstanceId, toolResolvers), +`subscribe` (subscriptionId, stream, **after: Cursor — required**), +`unsubscribe`, `toolResolversChanged`, `ping` + +server → `helloAck` (hostId, connectionId), `subscribed`, `event`, +`caughtUp`, `unsubscribed`, `reset` (code, recovery), `error`, `pong` + +There are **no snapshot packets**. `subscribe.after` always comes from a +tRPC response (`list`/`get`/`getEvents` all return `head`). Subscribing with +an unservable cursor yields `reset`, and the client re-runs the cold path. + +The hot-path frame is `event`: ~250–600 bytes typical. The only bounded +exception: tool-call `rawInput`/`rawOutput` up to the 256 KB cap (oversize +values are dropped to `null` at translation, never truncated mid-JSON). + +Events: +- **host stream (6):** `sessionUpsert`, `sessionRemoved`, + `permissionAvailable`, `permissionResolved`, `clientToolCallAvailable`, + `clientToolCallResolved`. All are idempotent upserts/removals keyed by + entity id — replay overlap after a tRPC snapshot is harmless by + construction. +- **session stream (16):** `threadCreated/Updated`, + `turnStarted/Completed/Failed/Cancelled`, + `messageStarted/Delta/Completed`, `toolCallStarted/Updated`, + `permissionRequested/Resolved`, `planUpdated`, `settingsUpdated`, + `error`. Envelope: `{id, sessionId, threadId, cursor, occurredAt, + causationId, payload}`; dedup by `id`. + +### Cursors and incarnations — 2 kinds + +| Cursor | Format | Minted by | +|---|---|---| +| host stream | `h-` | SyncHub, per host boot | +| session events | `c-` per session; same value in live stream, snapshot `head`, `getEvents` boundaries | CanonicalSessionsRuntime | + +An *incarnation* is one lifetime of a log's owner. Host cursors embed the +boot id, so a restarted host deterministically rejects stale cursors +(`CURSOR_INVALID`). Session cursors embed the log *generation*, minted +fresh on every tracking: the canonical log is rebuilt from vendor replay +each time (content is deliberately not persisted host-side), so a cursor +from a previous tracking — usually a dead host process — fails the same +deterministic way (`CURSOR_INVALID` → snapshot rebuild) instead of +silently aliasing a serial in the new log. The bare zero cursor +`c000000000000` stays generation-less: it is the head of a never-tracked +session's snapshot and means "from the very beginning". Session-list +paging cursors were **deleted** along with list pagination. + +### Recovery model — two paths, one guard + +1. **Cold-load (automatic, per-stream, cheap).** Triggers: ring eviction + (client slept), host restart, session resurrection, upstream adapter + resync. `reset` names one stream; only it re-fetches. Handler = the + first-connect code path, literally. +2. **Host restart (human hammer).** Already a desktop button. The registry, + the canonical session meta (titles, archive/close state), and the vendor + transcripts survive; sessions come back `offline`; the first live + mutation resurrects (spawn adapter → `session/load` → vendor replays its + own disk → canonical log rebuilt under a fresh generation). Old session + cursors reset `CURSOR_INVALID` into the snapshot cold path. Reads never + resurrect — listing 50 dead sessions spawns 0 children. + +Guard: **reset circuit breaker** — N resets on one stream within a window → +stream status `error`, stop auto-resubscribing, surface to UI. A reset loop +is by definition a host bug; clients must stop, not spin. + +### Read-consumption reality (retention design note) + +Humans re-read user messages, attachments, and each turn's final assistant +response. Old turns' tool calls and deltas are write-only in practice. +Consequences: client eviction drops old-turn tool events/deltas first, +keeping a meaningful "skeleton"; tool payloads are already separable +(nullable, capped); a future condensed/skeleton `getEvents` mode is an +additive parameter. **Not in v1** — plain backwards paging at 100 +events/page is enough. + +Decision (2026-07-12): the host SQLite persists **no messages or events** — +only top-level session metadata (title/archive/close, in +`session_meta`) plus the existing registry. Conversation content has +exactly one durable home, the vendor's own transcript, resumed via the +native session id. Delete semantics follow: metadata rows whose session +left the registry are swept at startup; archive/close never delete +anything — sessions reopen. + +### What was cut from the previous draft (and why) + +| Cut | Why | +|---|---| +| Claim/lease machinery (`claimToolCall`, claims, `claimed` state, `claimId`) | `askUser` pops on all devices by design; first resolve wins; receipts already make the loser's submit a clean stale error | +| `Session.attention[]` | client-derived; the low-level `pendingPermissions`/`openClientToolCalls` in the host snapshot are the primitives | +| WS snapshot packets (`hostSnapshot`, `sessionSnapshot`) | duplicated the tRPC `list`/`get` outputs; snapshots are big and belong on tRPC | +| `search` | no consumer | +| `createSideChat` + `sideChat` thread kind | no UI plans it; re-adding is additive | +| `getThread`, `listThreads` | snapshots + thread events already deliver threads | +| `list` pagination | session lists are small; deletes a whole cursor kind | +| `getEvents` anchors (`time`/`event`) + forward paging | only backwards-from-latest is real usage | +| separate `updateSettings` | one patch mutation | + +### Test evidence and work needed + +Already proven on THIS surface (all automated, `bun test`, green on +`feat/sessions-v1-simplified-api` + `feat/sessions-durable-store`): +- **Realistic-scenario suite** (`sessions-sync-client.integration.test.ts`, + 11 tests, real host + real WebSocket, fake adapter): cold connect via + tRPC snapshots, streaming turns with causation receipts, permission + cards, offline replay across a mid-stream disconnect, a fresh client + converging from nothing, archiving, scrollback paging until the store + equals the host's full journal, session switching under load, and a + **host reboot** over the same host DB — canonical titles survive via the + metadata table, every cursor (host boot id, session log generation) is + rejected with exactly one `reset(CURSOR_INVALID, refetchSnapshot)` per + stream, the cold path re-seeds each replica from the rebuilt log, no + duplicated event ids, streaming resumes. The reboot test also pins the + causationId-loss trade-off of the no-messages-in-SQLite decision. +- **Session metadata store** (`canonical-sessions.durable.test.ts`): + title/archive/close overrides survive host reboots (archived rows stay + out of the host snapshot scope), createSession titles persist, rebuilt + logs mint a new generation whose foreign cursors reject + deterministically while the zero cursor stays servable, and the startup + orphan sweep drops metadata for sessions the registry no longer knows. +- Circuit breaker: >3 resets in 30 s parks the stream in `error` + (`RESET_LOOP`) and stops auto-resubscribe; cleared on reconnect. +- Hub: foreign/evicted cursor → reset → cold path; two-hub restart + collision; send-failure drop; dispose idempotence; randomized stress + storm with a byte-parity oracle (required-`after` grammar). +- Client store: reset handling, snapshot re-seed, paging never resurrects + resolved cards, mid-flight fetch races, never-subscribed packet drops, + reconnect backoff, socket loss downgrades `live` → `idle` (data kept). +- Runtime: offline sessions — passive reads synthesize (`head` = zero + cursor, empty tail), live paths resurrect (fake adapter); backwards + paging with `hasMoreBefore`/`truncatedBefore`; merged `update` receipts. +- Gym user story **10/10 with a real Opus instance** over sessions.* + + `/sessions/sync` on this exact surface (tRPC-seeded client, claimless + question cards, merged `update` for the acceptEdits switch, final parity + oracle that pages the whole journal backwards and deep-equals it against + the client fold; zero drops, zero resets). + +Work needed (details in the Rollout section below): +- React bindings package; mobile rewire (keep chat UI, kill + `session-protocol` data plane); Maestro phone replay (execution 4). +- Real OS-process host kill/respawn journey test. + +--- + +## Layer 3 — One realistic scenario, exact payloads + +Phone opens the app, drives a turn with a permission card, loses the +network mid-stream, recovers, then the host is restarted and the session +resumes. (This is the shape of the automated scenario test; the gym E2E +runs the same surface with a real model.) + +**1 — Cold connect.** Phone calls `sessions.list`: + +```jsonc +// → response (host snapshot; also the capability probe) +{ + "sessions": [ { + "id": "ses_01", "workspaceId": "ws_gym", "title": "Fix relay test", + "mainThreadId": "thr_main", "agent": {"id": "claude-code", "displayName": "Claude Code"}, + "runState": "idle", + "capabilities": { "threadModel": "nested", "threadFidelity": "partial", + "canResume": true, "supportsPermissions": true, + "supportsModes": true, "supportsModels": true }, + "settings": { "activeModel": "opus[1m]", "activeMode": "default", "effort": null, + "configuration": {} }, + "eventHead": "c000000000512", + "createdAt": 1783532100000, "updatedAt": 1783532400000, + "lastActivityAt": 1783532400000, "archivedAt": null, "closedAt": null, + "error": null } ], + "pendingPermissions": [], + "openClientToolCalls": [], + "head": "h1734-000000000381" +} +``` + +Phone opens the socket and subscribes to the host stream from `head`; user +taps the session, phone calls `sessions.get("ses_01")` (snapshot above) and +subscribes the session stream from its `head`: + +```jsonc +{ "type": "hello", "protocolVersion": 1, "requestId": "req_h1", + "clientInstanceId": "cli_phone", "clientVersion": "1.15.0", + "toolResolvers": [ { "toolName": "ui.ask_user", "version": 1 } ] } +{ "type": "helloAck", "requestId": "req_h1", "hostId": "org_7", "connectionId": "conn_a" } +{ "type": "subscribe", "requestId": "req_s1", "subscriptionId": "sub_host", + "stream": { "type": "host" }, "after": "h1734-000000000381" } +{ "type": "subscribed", "subscriptionId": "sub_host", "cursor": "h1734-000000000381" } +{ "type": "caughtUp", "subscriptionId": "sub_host", "cursor": "h1734-000000000381" } +{ "type": "subscribe", "requestId": "req_s2", "subscriptionId": "sub_ses1", + "stream": { "type": "session", "sessionId": "ses_01" }, "after": "c000000000512" } +{ "type": "subscribed", "subscriptionId": "sub_ses1", "cursor": "c000000000512" } +{ "type": "caughtUp", "subscriptionId": "sub_ses1", "cursor": "c000000000512" } +``` + +**2 — A turn with a permission card.** User sends a message: + +```jsonc +// tRPC sessions.submitTurn +{ "requestId": "req_t9", "sessionId": "ses_01", "threadId": "thr_main", + "content": [ { "type": "text", "text": "run the failing test and fix it" } ] } +// receipt — admission only +{ "status": "accepted", "turnId": "trn_10" } +``` + +Events stream on `sub_ses1` (envelope shown once, then payloads only): + +```jsonc +{ "type": "event", "subscriptionId": "sub_ses1", "stream": "session", + "cursor": "c000000000513", + "event": { "id": "evt_513", "sessionId": "ses_01", "threadId": "thr_main", + "cursor": "c000000000513", "occurredAt": 1783532410000, + "causationId": "req_t9", + "payload": { "type": "turnStarted", "turn": { "id": "trn_10", + "sessionId": "ses_01", "threadId": "thr_main", "status": "accepted", + "originatingClientInstanceId": "cli_phone", + "createdAt": 1783532410000, "updatedAt": 1783532410000 } } } } +// … subsequent payloads: +{ "type": "messageStarted", "message": { "id": "msg_u1", "role": "user", "content": [ … ], … } } +{ "type": "messageStarted", "message": { "id": "msg_a1", "role": "assistant", "content": [], … } } +{ "type": "messageDelta", "messageId": "msg_a1", "content": { "type": "text", "text": "Running the test" } } +{ "type": "toolCallStarted", "toolCall": { "id": "tc_bash1", "tool": { "name": "bash", "version": 1 }, + "title": "bun test relay.test.ts", "input": { "command": "bun test relay.test.ts" }, + "resolver": { "type": "host" }, "state": "awaitingPermission", + "parentToolCallId": null, "turnId": "trn_10", … } } +{ "type": "permissionRequested", "permission": { "id": "perm_1", "toolCallId": "tc_bash1", + "multiSelect": false, "options": [ + { "id": "allow_once", "name": "Allow once", "kind": "allowOnce" }, + { "id": "allow_always", "name": "Always allow", "kind": "allowAlways" }, + { "id": "reject_once", "name": "Reject", "kind": "rejectOnce" } ], + "requestedAt": 1783532412000 } } +``` + +The card shows on the phone AND the desktop (no claims). Phone answers +first: + +```jsonc +// tRPC sessions.resolvePermission (desktop's later attempt → NOT_FOUND, its card drains) +{ "requestId": "req_p1", "sessionId": "ses_01", "permissionId": "perm_1", + "outcome": { "type": "selected", "optionIds": ["allow_once"] } } +``` + +```jsonc +{ "type": "permissionResolved", "permissionId": "perm_1", + "outcome": { "type": "selected", "optionIds": ["allow_once"] } } +{ "type": "toolCallUpdated", "toolCallId": "tc_bash1", + "update": { "state": "running", "updatedAt": 1783532413000 } } +{ "type": "toolCallUpdated", "toolCallId": "tc_bash1", + "update": { "state": "succeeded", "output": { "exitCode": 0 }, "updatedAt": … } } +{ "type": "messageDelta", "messageId": "msg_a1", "content": { "type": "text", "text": " — passes now." } } +{ "type": "messageCompleted", "messageId": "msg_a1" } +{ "type": "turnCompleted", "turnId": "trn_10", "stopReason": "endTurn" } +``` + +**3 — Mid-stream disconnect.** Phone loses network after `c…525`; the agent +keeps streaming on the host. Phone reconnects (exponential backoff), replays +the gap, converges: + +```jsonc +{ "type": "subscribe", "requestId": "req_s3", "subscriptionId": "sub_ses1b", + "stream": { "type": "session", "sessionId": "ses_01" }, "after": "c000000000525" } +{ "type": "subscribed", "subscriptionId": "sub_ses1b", "cursor": "c000000000525" } +// events c…526 … c…541 replay here; dedup by event.id absorbs any overlap +{ "type": "caughtUp", "subscriptionId": "sub_ses1b", "cursor": "c000000000541" } +``` + +**4 — Long sleep → reset → cold path.** Phone returns after hours; the ring +(5k events) evicted its cursor: + +```jsonc +{ "type": "subscribe", "requestId": "req_s4", "subscriptionId": "sub_ses1c", + "stream": { "type": "session", "sessionId": "ses_01" }, "after": "c000000000525" } +{ "type": "reset", "subscriptionId": "sub_ses1c", "sessionId": "ses_01", + "code": "CURSOR_INVALID", "recovery": "refetchSnapshot" } +// phone re-runs the cold path: tRPC get → subscribe(after: new head). One stream, +// one refetch; every other stream on the socket is untouched. +``` + +If the host answered every such subscribe with another `reset` (a host bug), +the circuit breaker trips after N attempts: the stream goes `error`, the UI +shows a retry affordance, nothing spins. + +**5 — Host reboot.** User restarts the host (desktop button). The socket +closes; the phone's store keeps rendering its replica. On reconnect, +`sessions.list` still shows `ses_01` — the SQLite registry survived, and +its canonical title/archive state survived in `session_meta` — with +`runState: "offline"`. The phone's cursors all belong to dead incarnations: +the host-stream cursor `h1734-…` fails parsing under boot `h1802`, and the +session cursor's log generation died with the old process (the canonical +log is rebuilt from the vendor's `session/load` replay, never from host +disk) → `reset(CURSOR_INVALID)` → cold path per stream. User types a new +message → `submitTurn` → the host spawns a fresh adapter child, issues +`session/load` with the stored native session id, the vendor replays its +own on-disk transcript, the canonical log is rebuilt (new generation, new +event ids, fresh cursors), and events flow again. A turn that was +mid-flight when the host died is terminalized (`turnFailed`), not silently +resumed — the user resubmits. + +Known, deliberate limitation (pinned by the reboot test): the rebuilt +canonical log loses `causationId` attribution. Request→event linkage is +armed in the serving process; the vendor journal doesn't record it, so +every rebuilt event carries `causationId: null`. Receipts and UI still +work (they key on entities, not causation) — the trade for keeping +messages out of host SQLite entirely. + +--- + +## Open questions for review + +1. Snapshot `recentEvents` tail size (trade first-paint completeness vs + response size over the relay) — shipped default: 50. +2. Circuit-breaker N and window — shipped default: 3 resets / 30 s + (`maxStreamResetsPerWindow` / `streamResetWindowMs`, overridable per + client). +3. ~~Session-cursor incarnation tag ships with the durable store (#10), or + block v1 on it?~~ Resolved: shipped as the log generation tag + (`c-`), minted per tracking — no persistence + needed, since the log itself is never persisted. +4. Condensed/skeleton history mode: v1.1+ (taxonomy already supports it). +5. Should a durable event log ever ship (restoring causationId across + reboots, scrollback beyond the vendor replay window)? Decided against + for now — no messages in host SQLite; revisit alongside the direct SDK + adapter if its native store changes the calculus. + +--- + +## Rollout + +Where this stands (2026-07-12): + +**Done** (on `feat/sessions-v1-simplified-api`, all suites green plus a +real-model E2E): the protocol package, the `sessions.*` router and +`/sessions/sync` hub, the shared projection reducer, the framework-free +client store with reset circuit breaker, the 11-test realistic-scenario +suite (disconnect, scrollback parity, session switching, host reboot), and +the gym story 10/10 with a real Opus instance. + +**Done** (on `feat/sessions-durable-store`, stacked): rollout item 1, +rescoped by decision (2026-07-12) to **durable session metadata, not a +durable event log** — messages/events are deliberately kept OUT of host +SQLite; the vendor transcript stays the only content store, resumed via +the native session id. Shipped: the `session_meta` metadata table +(title/archive/close overrides survive host restarts, write-through, +loaded at construction, orphan-swept at startup) and generation-tagged +session cursors (`c-`, minted per tracking) so a +rebooted host deterministically rejects stale session cursors with +`CURSOR_INVALID` → cold path, exactly like host cursors. Deferred with +the event log: causationId parity across reboots and scrollback beyond +the vendor replay window (both pinned as for-now limitations in the +reboot test). + +**Direction:** ACP and the Mastra chat stack are both being retired from +this path. The host serves this surface; the ACP bridge behind it gets +replaced by a direct Claude SDK adapter with no client-visible change, then +removed. Mastra chat stays in the tree only so existing desktop users keep +working — frozen, no new features, and never used by mobile. + +**Next, in order:** + +1. ~~Durable history store~~ — done as durable session metadata + + cursor generations, see above. +2. ~~React bindings and the mobile rewire~~ — done (2026-07-12). Shipped: + `packages/host-service-react` (provider + zustand-backed hooks + + `useSessionTimeline` with incremental fold), the framework-free + transcript fold at `host-service-sync/timeline` (canonical events → + message/tool-call/plan items, thought-splitting, subagent nesting, + pending-permission cards), and `packages/host-client`'s `sessions` + module (command plane + `syncApi`/`syncUrl` facades). Mobile's chat + screens keep their shipped UI but ride `sessions.*` + `/sessions/sync` + over the relay (verified live end-to-end against a dev host through the + relay tunnel — the relay WS proxy doesn't negotiate subprotocols, so + the mobile socket factory requests none). DELETED with the rewire: the + `acpSessions.*` router, the per-session `/acp-sessions/:id/stream` + route, `host-client/acp`, session-protocol's `client`/`react` subpaths, + and their surface tests. `packages/session-protocol` itself shrinks to + the ACP-bridge contract (acp/api/envelope/fold/state) still imported by + the AcpSessionManager + canonical translator — it goes with item 3, not + before. + + **Follow-up shipped (2026-07-13): settings catalogs, pickers, and the + full mobile plane kill.** The session entity gained `settingOptions` — + harness-reported catalogs (`{id, name, kind: model|mode|effort|other, + currentValue, options[{value,name,description}]}`) composed live from + the adapter's config options (grouped selects flattened, mode + synthesized from `currentMode` when not mirrored, booleans skipped as a + v1 reduction). Catalogs ride the composed session (snapshots + + `session_upsert`), never the event log; the translator's settings + change-key includes the catalogs so a catalog-only rebuild still moves + the log and re-pushes the entity. The mobile composer's mode/model/ + effort chips are back (generic `OptionPicker` over `settingOptions`, + writes through `sessions.update`), verified on-sim: chip → native sheet + → live model switch reflected in the chip. The NewChatWidget's + new-workspace flow moved off the legacy `workspaces.create` agents + sugar onto `workspaces.create` (no agents) + `sessions.create` + + `submitTurn` (image attachments ride the first turn as canonical image + blocks), with the widget's model pick passed as adapter aliases (the + shared "claude" preset: fable/opus/sonnet/haiku — `SUPERSET_CHAT_MODELS` + Mastra ids are gone from mobile). And the old planes are now fully + deleted from the phone: the Mastra chat thread screen + (`chat/[sessionId]` UI + `useChatThread` + cloud `chat_sessions` + Electric collection + the `HostChatClient` chat facade) is gone, the + canonical thread now owns the `chat/[sessionId]` route (the `chat/acp/` + path segment is dead), and the session-row menu's rename/archive go + through `sessions.update`. Verified end-to-end on-sim: widget → new + workspace → canonical thread → real agent reply, with the picked model + active. + + **Follow-up shipped (2026-07-13): chat scroll discipline + thread + polish.** The `Conversation` component now implements the ChatGPT-class + scroll contract — the list moves only on user intent: drag-begin kills + following instantly; re-pinning requires a real gesture resting at the + true end (programmatic scrolls also emit momentum-end and must never + re-pin); send anchors the message to the top with whitespace for the + reply (bottom-relative target + corrective settle legs, exact even when + sent from deep in history); streaming, content re-measure dips, the + permission stack appearing/resolving, and the keyboard hiding never + shift an unpinned reader — trailing scroll range is pre-banked as a + native `contentInset.bottom` sized against the largest viewport seen + plus dip slack (insets fire no layout events, which kills the + spacer-view feedback loop), and every scroll target derives from the + native content height, never LegendList's estimates + (`initialScrollAtEnd`/`maintainScrollAtEnd`/`scrollToEnd` are banned — + they chase estimated totals into blank space). Invariants are documented + in `apps/mobile/components/ai-elements/AGENTS.md` and enforced by a + hidden regression lab (`superset://debug/conversation-lab` + + `.maestro/flows/conversation-lab.yml`) whose five scripted scenarios + (pin-follow, no-move-unpinned with dips, overlay resolve, send anchor, + send anchor from history) self-assert PASS/FAIL on-device. Thread + polish riding along: tool rows title with the model-written command + `description` (raw command stays in the detail sheet), GitHub-style + +added/−removed line badges on file-edit tool rows (input-derived, + tested), the running tool title breathes with a seam-free sine pulse, + the working indicator is a caret-sized boids flock (six dots milling in + an elliptical pen, speed-fade alpha, UI-thread `useFrameCallback` port + of the web caret), and the glass header title widens to 70% of the + screen before truncating. Cold thread load paints the FULL last page in + one frame: `sessions.get` now resurrects on read so the snapshot always + carries the recent tail + real head (the old zero-head fallback made the + hub stream the entire replayed transcript event-by-event — the thread + visibly built up from the first message), the thread holds a spinner + until that one-shot page lands, and the list mounts invisible until the + page is measured and parked at the bottom (quiet-gate + hard cap) — + older history stays `getEvents` scrollback territory. +3. Direct Claude SDK adapter behind the same surface (prototype on the + `claude-sdk-session-harness` branch, PR #5582): run both adapters + through the same real-model story, switch, then remove the ACP bridge + (AcpSessionManager, translate-acp, and the rest of + `packages/session-protocol`). +4. Maestro phone replay of the story (execution 4) as the final gate. + +**Gates:** `bun run lint` and typecheck exit 0; the story passes on every +lane it applies to; teardown leaves no process, socket, DB handle, or temp +directory; the documented memory caps are measured with a transcript beyond +those caps. + +## E2E user story (the validation spine) + +One realistic session, in user terms. It must pass with a real Claude +instance on each surface it migrates across: (1) the shipping ACP surface — +`acp-user-story.integration.test.ts`; (2) `sessions.*` + `/sessions/sync` — +`sessions-user-story.integration.test.ts`; both pass 10/10 with real Opus +today; then (3) the direct SDK adapter, and finally (4) replayed from a real +phone via Maestro. + +### The gym project + +The story runs in a disposable "gym" repo the harness provisions per run +(`packages/host-service/test/helpers/gym.ts`): + +- `notes.txt` — known data (`fixture_id`, `values=13,29`, `expected_sum=42`) + that the subagent and workflow steps read, so results are assertable. +- `.claude/workflows/acp-e2e-dummy.js` — the saved five-agent workflow + (inspect → analyze → audit ×2 → verify) over `notes.txt`. +- `.claude/skills/gym-check/SKILL.md` — a trivial skill that reads + `notes.txt` and prints a sentinel. +- `scripts/ok.sh` and `README.md` — a runnable script and an editable file, + so two *different* tools (Bash vs file edit) can each trigger a + permission card. + +Run the story with the model pinned to `opus`: the scripted prompts depend +on precise instruction-following. + +### The first message + +Sent verbatim as the session's initial prompt (the story tests quote this +block): + +```text +You are a real Claude instance running inside an automated end-to-end test of +Superset's session harness. This workspace is a disposable "gym" repo created +for this run; nothing in it is real product code. The test exercises the +harness around you: streaming, interrupts, permissions, questions, subagents, +workflows, skills. Follow every instruction in this conversation literally and +minimally, use exactly the tools named, and reply with the exact sentinels +requested. Start now: write a continuous ~600-word tour of this repository in +plain prose, using no tools, and end with the line TOUR_DONE. +``` + +### The steps + +1. Create the gym workspace — worktree provisioned, no agent process yet. +2. Start a session with the first message, model pinned to `opus`, + permission mode `default` (never bypass). +3. The ~600-word tour streams incrementally — no gaps, no duplicates; other + connected devices see the same stream live. +4. Stop mid-stream — the turn is cancelled, partial text stays, session + returns to idle, nothing left pending. +5. `continue` resumes the same conversation and reaches `TOUR_DONE`. +6. A Bash run raises a permission card (allow once / always allow / + reject); the session reads "needs attention", not "stuck". +7. Allow once — the card resolves on all devices at once, the script runs, + `RUN_OK`; a second tap gets "already answered", never a double + execution. +8. Always-allow on a rerun — the choice sticks for this session. +9. Two more runs auto-approve with zero new cards. +10. A README edit raises a fresh card — always-allow was scoped per tool. +11. Stop, switch the session to acceptEdits, `continue` — the edit applies + with no card; every tool call still lands in the transcript. +12. AskUserQuestion with two questions — cards arrive one at a time, and + the turn shows "waiting for you", not running. +13. Answer blue on the first, Skip the second — each answer registers + exactly once; the agent replies `ANSWERS blue`. +14. A Task subagent reads `notes.txt` — its own partial-fidelity + `subagent` thread (never flattened), result flows back as `SUB_OK 42`. +15. The saved workflow launches exactly once, runs its five agents in the + background, and completes verified (`WORKFLOW_VERIFIED`). +16. The gym-check skill runs and reports its sentinel. +17. A second session streams in parallel over the same socket — no bleed + between streams, cards, or attention. +18. Drop and reconnect — both streams catch up exactly; a too-old cursor + resyncs transparently via the cold path. +19. Host restart — sessions show offline, not gone; opening one loads its + transcript; a new message resumes the same native conversation. +20. Phone opens both sessions — identical transcripts, cards, and chips; + the first answer wins everywhere. Final gate: Maestro replays steps + 2–19 from the phone against a real host. diff --git a/plans/session-harness-claude-agent-sdk.md b/plans/session-harness-claude-agent-sdk.md deleted file mode 100644 index 459e420abaa..00000000000 --- a/plans/session-harness-claude-agent-sdk.md +++ /dev/null @@ -1,357 +0,0 @@ -# Replace the mastra chat harness with a Claude Agent SDK session runtime - -> **Status: archived alternative, not current implementation documentation.** The selected ACP harness is implemented; see [`packages/host-service/docs/acp-sessions.md`](../packages/host-service/docs/acp-sessions.md) for current behavior, [`plans/done/20260710-session-harness-acp.md`](./done/20260710-session-harness-acp.md) for the shipped outcome, and [`plans/acp-session-follow-ups.md`](./acp-session-follow-ups.md) for remaining work. This file is retained only as the direct-Claude-SDK fallback design. -> -> If this path is ever revived, note that four scope decisions were made *after* this draft and would apply here too: (1) ship **parallel to mastra**, not as a replacement — mastra is in production on desktop; the excision (Milestone 6) and desktop migration (Milestone 4) move to a later hard-swap plan; (2) **keep session processes alive forever** — no idle disposal (Q3 resolved); (3) list **live sessions only** — dead sessions just disappear, no resume-after-restart requirement (which also removes the need for the `agent_sessions` SQLite registry); (4) auth = the host machine's logged-in Claude account (Q1 resolved to option a). - -This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. - -Reference: This plan follows conventions from `AGENTS.md` and the ExecPlan template in `.agents/`. - -## Purpose / Big Picture - -Today every AI chat session in Superset is executed by `mastracode` (the "mastra harness"), wrapped twice — once in the desktop Electron main process and once in the host-service — and every client learns about session progress by polling snapshots at 250ms. Message history lives inside mastra's private memory store, the host cannot report which model a session is running, and mobile talks to the host through a hand-written, lossy type facade that nothing enforces. - -After this change: - -- The **host-service is the single owner** of AI session OS processes. It runs sessions with the **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`), which spawns one Claude Code subprocess per active session and persists transcripts as JSONL on the host's disk. -- A new **`packages/session-protocol`** package is the shared contract: Claude SDK types re-exported type-only, plus our own session-scoped state, event envelope, and paginated API types with zod schemas. Desktop and mobile both import it; mastra/claude types never need hand-copied facades again. -- Clients (desktop v2 workspace UI, mobile, any future client) attach to a session at any time via the host's tRPC API for state/history/actions, and a **resumable, sequence-numbered event stream** (SSE for direct connections, WebSocket through the relay) for real-time updates. Any attached client can drive the session: send messages, answer permission requests, switch model, interrupt. -- **mastracode and all `@mastra/*` dependencies are deleted.** Zero backwards compatibility: old mastra threads are not migrated. The design leaves an explicit seam for a future Codex harness (a `harness` discriminator on sessions and events). - -Demonstrable outcome: run the desktop app, open a v2 workspace, start a chat that is executed by Claude Code via the host-service; open the same session on mobile through the relay and watch the same tokens stream in live on both screens; answer a permission prompt from either device; kill the host-service mid-session, restart it, and continue the conversation with full history intact. - -## Assumptions - -- The Claude Agent SDK version pinned is `@anthropic-ai/claude-agent-sdk@0.3.205` or newer. Its API surface used here (verified against the published `sdk.d.ts` of 0.3.205): `query()` streaming-input mode, `canUseTool`, `Options.resume`, `Options.permissionMode`, `Options.env`, `Options.cwd`, `Options.includePartialMessages`, `Query.setModel/setPermissionMode/interrupt/initializationResult`, `listSessions`, `getSessionMessages` (paginated), `getSessionInfo`, `forkSession`, and the types-only export `@anthropic-ai/claude-agent-sdk/sdk-tools`. -- Sessions authenticate to Anthropic on the host machine. See Open Question Q1 for which credential is injected; the spike (Milestone 0) resolves it before any production code depends on it. -- The cloud metadata flow is unchanged: `chat_sessions` rows in cloud Postgres (created via `packages/trpc` `chat.createSession`, synced to clients by Electric) remain the cross-device session *directory*. This plan only replaces the *runtime* and message-content layer on the host. -- Desktop already spawns the host-service as a child process for local use (`apps/desktop/src/main/host-service/index.ts`), so "desktop attaches to host-service" requires no new process infrastructure. - -## Open Questions - -- **Q1 — Anthropic credentials for host-spawned sessions.** Options: (a) rely on the host machine's existing Claude Code login (`~/.claude` OAuth), (b) inject `ANTHROPIC_API_KEY` via `Options.env` from Superset-provisioned credentials (analogous to today's `runtimeResolver.prepareRuntimeEnv()`). Impacts Milestone 0 and 2. → Decision Log D9 placeholder. *(Resolved later, outside this draft: option a.)* -- **Q2 — Web app (`apps/web`) migration.** It talks to the host with hand-rolled fetch calls today. Out of scope here unless trivially cheap after Milestone 3; confirm. → Decision Log D10 placeholder. *(Resolved later: out of scope.)* -- **Q3 — Idle process policy.** One Claude subprocess per active session; propose dispose after 30 minutes idle and resume on demand via `Options.resume` (cheap because transcripts persist as JSONL). Needs product sign-off on the resume latency tradeoff (~1–3s to respawn). → Decision Log D11 placeholder. *(Resolved later: no disposal — keep alive forever.)* -- **Q4 — Retention/GC of JSONL transcripts** on the host disk. Not blocking; decide before GA. → Decision Log D12 placeholder. *(Resolved later: out of scope.)* - -## Progress - -- [x] (2026-07-09 18:00Z) Discovery: mapped mastra harness call sites, host-service transport, relay streaming constraints, SDK 0.3.205 surface. -- [x] (2026-07-09 18:30Z) Clarified scope, transport, type-lift, and migration strategy with Kirill (see Decision Log D1–D8). -- [ ] Milestone 0: SDK spike inside host-service context. -- [ ] Milestone 1: `packages/session-protocol`. -- [ ] Milestone 2: host-service `SessionManager` + `sessions` tRPC router. -- [ ] Milestone 3: resumable event stream (SSE direct + WS via relay) + client subscribe helper. -- [ ] Milestone 4: desktop v2 workspace chat on the new stack. -- [ ] Milestone 5: mobile on the new stack. -- [ ] Milestone 6: mastra excision (delete both old runtimes, drop deps, repoint v1 desktop panes). - -## Surprises & Discoveries - -- Observation: the relay cannot stream HTTP responses — `sendHttpRequest` in `apps/relay/src/tunnel.ts` buffers one complete `TunnelHttpResponse` per request, so SSE cannot traverse the relay. The relay *does* proxy arbitrary WebSocket channels (`openWsChannel`/`sendWsFrame`, used by `/terminal/*` today). - Evidence: `apps/relay/src/tunnel.ts:343-404`. -- Observation: `packages/chat-protocol` and `packages/durable-session` are referenced in `AGENTS.md` but do not exist as directories; the earlier "SCP v1" normalized-envelope design was never built. This plan supersedes it (Decision D2). -- Observation: the Claude SDK surfaces AskUserQuestion, plan approval (ExitPlanMode), and ordinary tool approvals all through the single `canUseTool` callback, and ships typed tool input/output schemas at `@anthropic-ai/claude-agent-sdk/sdk-tools` (types-only export). Today's three parallel pending flows (approval / question / plan) collapse into one `PendingPermissionRequest` model. -- Observation: `SessionMessage` objects returned by `getSessionMessages` carry a runtime `timestamp` field that is absent from the declared type; and `getSessionMessages` omits system messages unless `includeSystemMessages: true` (needed to render compaction boundaries). - -## Decision Log - -- Decision D1: The new runtime lives **only in host-service**; desktop is just another attached client (via its locally spawned host-service). - Rationale: single process owner, single codepath, matches the multi-client requirement. - Date/Author: 2026-07-09 / Kirill. -- Decision D2: The wire protocol carries **Claude SDK events verbatim** inside a thin envelope, and `packages/session-protocol` **re-exports SDK types** (type-only) rather than normalizing them. - Rationale: zero drift, zero translation bugs; the mobile facade's manual-sync failure mode is exactly what we're eliminating. Supersedes the unbuilt SCP v1 normalization design. - Date/Author: 2026-07-09 / Kirill. -- Decision D3: **No migration, no backwards compatibility.** mastracode, `@mastra/core`, `@mastra/memory`, `@mastra/mcp` are removed from the repo. Old threads in `~/.mastracode` are abandoned. - Date/Author: 2026-07-09 / Kirill. -- Decision D4: Future-proofing for Codex is limited to a `harness` discriminator (`'claude'` now, `'codex'` later) on session state and on `sdk`-kind stream events. No abstraction layers are built for a harness that doesn't exist yet. - Rationale: YAGNI; the envelope makes room without cost. - Date/Author: 2026-07-09 / Kirill + Claude. -- Decision D5: Real-time transport is **SSE for direct connections and WebSocket through the relay**, both carrying identical sequence-numbered envelope frames. Delivery is at-least-once with client-side dedup by `seq`; gaps heal via snapshot re-sync (see "Stream protocol" below). No relay protocol changes. - Rationale: neither SSE nor WS guarantees delivery by itself; the seq/cursor design makes the transport interchangeable and reconnects lossless within the journal window. The relay already proxies WS channels; extending it to stream HTTP is avoidable work. - Date/Author: 2026-07-09 / Kirill (directional) + Claude (mechanism). -- Decision D6: **Transcript truth is the Claude SDK's native JSONL persistence** on the host (`~/.claude/projects/...`), read through `getSessionMessages` (paginated). Host SQLite gains only a small `agent_sessions` registry table (superset-session-id ↔ claude-session-id ↔ workspace ↔ last state snapshot). The SDK's alpha `SessionStore` interface is not used. - Rationale: pagination, resume, and fork come for free and stay correct across SDK upgrades; the alpha store API is a stability risk with no payoff for v1. - Date/Author: 2026-07-09 / Claude, open to challenge. -- Decision D7: `packages/session-protocol` depends on `@anthropic-ai/claude-agent-sdk` as a regular dependency but only ever `import type`s it (plus the pure-types `/sdk-tools` subpath). Its shipped runtime code is zod schemas and the small stream-client helper — all React-Native-safe. Enforcement: a lint-greppable rule, `import { ... } from '@anthropic-ai/claude-agent-sdk'` (non-type) is forbidden in this package. - Rationale: type-only imports are erased at compile time, so Metro never bundles the SDK; bun workspace hoisting means the SDK is installed once at the repo root anyway (host-service needs it for real). - Date/Author: 2026-07-09 / Kirill. -- Decision D8: Pending interactions (tool approvals, AskUserQuestion, plan approval) are one unified `PendingPermissionRequest` list in session state, resolved by one `sessions.respondToPermission` procedure. Clients render by `toolName`. - Rationale: mirrors how the SDK actually delivers them (`canUseTool`); removes three parallel respond paths. - Date/Author: 2026-07-09 / Claude. -- Decision D9 (placeholder): Anthropic credential source — resolved by Milestone 0. -- Decision D10 (placeholder): web app scope. -- Decision D11 (placeholder): idle disposal policy. -- Decision D12 (placeholder): transcript retention. - -## Outcomes & Retrospective - -(To be written at completion.) - -## Context and Orientation - -Definitions used throughout: - -- **Harness**: the library that actually runs an AI coding agent turn loop. Today it is the object returned by `createMastraCode(...).harness` (from the `mastracode` npm package). After this plan it is the Claude Agent SDK's `query()`. -- **Spike** (not an acronym): a time-boxed exploratory prototype used to answer uncertain technical questions before production implementation. Milestone 0 would run the real SDK against a throwaway workspace to verify authentication, streaming input, permissions, structured questions, interruption, and resume behavior; the script is evidence tooling, not production runtime. -- **Host-service** (`packages/host-service`): a Hono HTTP server that runs on a user's machine. Desktop spawns it as a child process on `127.0.0.1` (`apps/desktop/src/main/host-service/index.ts`); it also dials out a persistent WebSocket tunnel to the **relay** so remote clients can reach it. -- **Relay** (`apps/relay`, deployed on Fly.io): forwards `https://relay/hosts/:hostId/trpc/*` requests and `wss://relay/hosts/:hostId/*` WebSocket upgrades over the host's tunnel. HTTP forwarding is buffered (no streaming); WebSocket forwarding is frame-by-frame (streaming works). -- **tRPC**: typed RPC. The host-service mounts its router at `/trpc/*` via `@hono/trpc-server` with `superjson` (`packages/host-service/src/app.ts`, router at `src/trpc/router/router.ts`). -- **SSE (Server-Sent Events)**: a one-way HTTP response that stays open and emits `id:`/`data:` frames; browsers reconnect automatically and send the last received `id` back as the `Last-Event-ID` header. -- **Electric / `chat_sessions`**: cloud Postgres table (in `packages/db`) synced read-only to clients; it is the cross-device *list* of chat sessions (title, workspace, timestamps). Message *content* never lives there. - -What exists today and is being replaced: - -- `packages/chat/src/server/trpc/service.ts` — `ChatRuntimeService`, the desktop-local mastra runtime + tRPC router, mounted over Electron IPC at `apps/desktop/src/lib/trpc/routers/chat-runtime-service/index.ts`, consumed by v1 chat panes through `packages/chat/src/client/hooks/use-chat-display/use-chat-display.ts` (250ms polling). -- `packages/host-service/src/runtime/chat/chat.ts` — `ChatRuntimeManager`, the host-service mastra runtime, exposed by `packages/host-service/src/trpc/router/chat/chat.ts` (`getSnapshot`, `sendMessage`, `respondToApproval/Question/Plan`, …), consumed by desktop v2 (`apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/**/useWorkspaceChatDisplay`) and by mobile over the relay (`apps/mobile/lib/trpc/host-chat-types.ts`, a hand-written lossy facade; polling at 250ms). -- mastra deps appear in exactly three `package.json`s: `packages/chat`, `packages/host-service`, `apps/desktop`. -- Message persistence: mastra memory store under `~/.mastracode` (opaque). Host SQLite (`packages/host-service/src/db/schema.ts`, better-sqlite3 + drizzle, path `env.HOST_DB_PATH`) holds workspaces/terminals but no chat content. - -Claude Agent SDK knowledge needed to implement this plan (verified against `sdk.d.ts` 0.3.205 — no external docs required): - -- `query({ prompt, options })` returns a `Query` — an `AsyncGenerator`. Passing an **`AsyncIterable` as `prompt`** ("streaming input mode") keeps the underlying Claude Code subprocess alive across turns and unlocks control methods on the handle: `interrupt()`, `setModel(model?)`, `setPermissionMode(mode)`, `initializationResult()` (models list, slash commands, account info). -- Key `Options`: `cwd` (the workspace worktree), `resume` (Claude session UUID to reload history and continue), `forkSession`, `permissionMode` (`'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'` etc.), `model`, `effort` (`'low'|'medium'|'high'|'xhigh'|'max'`), `env` (subprocess environment — credential injection point), `includePartialMessages` (emit `SDKPartialAssistantMessage` streaming deltas), `canUseTool`, `abortController`, `maxTurns`. -- `canUseTool(toolName, input, { requestId, toolUseID, title, displayName, description, suggestions, blockedPath, decisionReason, agentID, signal })` is called whenever the agent needs a human decision. It resolves with `PermissionResult`: `{ behavior: 'allow', updatedInput?, updatedPermissions? }` or `{ behavior: 'deny', message, interrupt? }`. **AskUserQuestion arrives here too** — the answer is returned as `updatedInput` (the questions with chosen options); **plan approval arrives as ExitPlanMode** through the same callback. `updatedPermissions` (echoing `suggestions`) implements "always allow". -- The event stream (`for await (const msg of query)`) yields the 39-variant `SDKMessage` union: `assistant`/`user` message envelopes wrapping Anthropic API content blocks, `system` subtypes (`init` — carries the claude `session_id`, model, tools; `status`; **`session_state_changed`** with `state: 'idle' | 'running' | 'requires_action'`), `result` (turn end, cost/usage), `stream_event` partial deltas, compaction boundaries, etc. All JSON-serializable. -- History: `getSessionMessages(claudeSessionId, { limit, offset, includeSystemMessages })` reads the JSONL transcript (chronological, parentUuid-chain resolved). `listSessions({ dir, limit, offset })` enumerates transcripts; `getSessionInfo(id)` reads one. Transcripts live under the host's `~/.claude/projects//`. -- Types-only tool schemas: `@anthropic-ai/claude-agent-sdk/sdk-tools` exports `AskUserQuestionInput/Output`, `ExitPlanModeInput`, `FileEditInput`, `BashInput`, … — used by clients to render tool cards and question forms with real types. - -### Relationship to the selected ACP implementation - -The current comparison lives in -[`apps/mobile/plans/acp-vs-claude-sdk.html`](../apps/mobile/plans/acp-vs-claude-sdk.html). -The direct design preserves the same user-facing topology; it changes the -host-local ownership boundary: - -| Dimension | Selected ACP implementation | This direct-SDK fallback | -|---|---|---| -| Agent owner | Adapter child owns the SDK Query; host speaks ACP over stdio. | Host owns the SDK Query and input queue directly. | -| Client delivery | ACP updates are wrapped in Superset seq envelopes. | SDK messages must be normalized into equivalent Superset envelopes. | -| Human interaction | Tool/plan approvals work today; structured questions require ACP form elicitation to be wired. | Tool approvals, plan approval, and `AskUserQuestion` all park in `canUseTool` callbacks directly. | -| Settings/control | Adapter-reported config and mode operations. | Direct Query methods and host-authored state projection. | -| Reconnect | Host journal + cursor replay/reset. | Same host journal + cursor replay/reset; no SDK resume is involved in a client reconnect. | -| Process death/restart | Dead sessions disappear; host restart loses them. | Same final v1 behavior despite the SDK's optional persisted resume capabilities. | -| Cost/risk | Extra process/translation layer and adapter/integration capability lag; less Superset-owned protocol code. | Fewer runtime layers and fastest SDK feature access; more custom lifecycle, normalization, and vendor coupling. | - -The direct path becomes preferable only if adapter or integration lag—especially leaving structured elicitation unwired—costs more than owning this additional runtime and protocol surface. Until then it remains a fallback, not parallel implementation work. - -## Plan of Work - -### The protocol package (`packages/session-protocol`) - -Create `packages/session-protocol` (`@superset/session-protocol`), following the repo's package conventions (see `packages/shared` for tsconfig/package.json shape). Dependencies: `@anthropic-ai/claude-agent-sdk` (types only — Decision D7), `zod`. Source layout: - -``` -packages/session-protocol/ - package.json # exports ".", "./client" - tsconfig.json - src/ - index.ts # barrel - sdk-types.ts # export type { SDKMessage, SDKUserMessage, SDKAssistantMessage, - # SDKResultMessage, SDKSystemMessage, SDKPartialAssistantMessage, - # SDKCompactBoundaryMessage, SDKSessionStateChangedMessage, - # SessionMessage, SDKSessionInfo, PermissionResult, PermissionUpdate, - # PermissionMode, ModelInfo, SlashCommand, EffortLevel, ... } - # from "@anthropic-ai/claude-agent-sdk"; - # export type { AskUserQuestionInput, ExitPlanModeInput, ... } - # from "@anthropic-ai/claude-agent-sdk/sdk-tools"; - state.ts # SessionScopedState, PendingPermissionRequest, SessionStatus - events.ts # SessionEventEnvelope, SessionEventPayload - api.ts # zod schemas + inferred types for every router procedure, pagination - client/ - stream-client.ts # transport-agnostic subscribe helper (see Milestone 3) -``` - -Core own types (final shapes to be refined during Milestone 1, but this is the contract): - -```ts -export type HarnessKind = "claude"; // future: | "codex" - -export type SessionStatus = - | "starting" // process spawning / init not yet received - | "idle" // alive, waiting for input - | "running" // agent turn in progress - | "requires_action" // blocked on pendingPermissions - | "exited" // process disposed (resumable) - | "errored"; - -export interface PendingPermissionRequest { - requestId: string; // canUseTool options.requestId — the resolution key - toolUseID: string; - toolName: string; // "AskUserQuestion" | "ExitPlanMode" | "Bash" | ... - input: Record; // narrow client-side via sdk-tools types - title?: string; // pre-rendered prompt sentence from the SDK - displayName?: string; - description?: string; - suggestions?: PermissionUpdate[]; // for "always allow" - blockedPath?: string; - decisionReason?: string; - agentID?: string; - requestedAt: number; -} - -export interface SessionScopedState { - sessionId: string; // Superset session id (= cloud chat_sessions.id) - claudeSessionId: string | null; // from system:init; null until first init - workspaceId: string; - harness: HarnessKind; - status: SessionStatus; - model: string | null; // authoritative current model - permissionMode: PermissionMode; - effort: EffortLevel | null; - pendingPermissions: PendingPermissionRequest[]; - cwd: string; - lastSeq: number; // highest journal seq emitted for this session - lastError: string | null; - createdAt: number; - updatedAt: number; -} -``` - -Stream protocol (`events.ts`): - -```ts -export interface SessionEventEnvelope { - seq: number; // per-session, monotonic, starts at 1, never reused - sessionId: string; - ts: number; // host epoch ms - event: SessionEventPayload; -} - -export type SessionEventPayload = - | { kind: "sdk"; harness: "claude"; message: SDKMessage } // verbatim - | { kind: "permission_request"; request: PendingPermissionRequest } - | { kind: "permission_resolved"; requestId: string; behavior: "allow" | "deny" } - | { kind: "state"; state: SessionScopedState } // full snapshot on any change - | { kind: "stream_reset"; reason: string }; // cursor unservable → resync -``` - -Delivery semantics (this answers "what if we miss stuff"): the host journals every envelope per session in a bounded in-memory ring buffer (default: last 2,000 envelopes per session). A subscriber passes `since=`; the host replays `(since, latest]` from the journal, then goes live. Because `seq` is monotonic and gapless, a client detects loss by observing a jump; because SSE reconnects send `Last-Event-ID` automatically (and our WS client sends `since` explicitly on reconnect), brief disconnects heal losslessly within the buffer window. If `since` has fallen out of the buffer, the host sends `stream_reset` and the client performs a full re-sync: `sessions.get` (state) + `sessions.getMessages` (history tail) + resubscribe from the fresh `state.lastSeq`. Net guarantee: **at-least-once delivery with deterministic gap detection and an always-available repair path** — neither raw SSE nor raw WS gives you this; the cursor design does, on both. - -Pagination convention (`api.ts`): every list procedure takes `{ limit: number (1..200, default 50), cursor?: string }` and returns `{ items: T[], nextCursor: string | null }`. For `getMessages`, the cursor encodes a message offset from the end so "first page = latest N, walk backwards" (the UI's natural order); internally it maps onto `getSessionMessages`'s `offset/limit`. - -### The host runtime (`packages/host-service/src/runtime/sessions/`) - -New `SessionManager` class replacing `ChatRuntimeManager`: - -- Holds `Map`. A `ManagedSession` owns: a push-driven `AsyncIterable` input queue; the `Query` handle; an `AbortController`; the ring-buffer journal + `seq` counter; the `SessionScopedState`; a `Map` for in-flight permissions; and a set of attached stream subscribers (callback fan-out). -- `create({ sessionId, workspaceId, model?, permissionMode? })`: resolves `cwd` from host SQLite `workspaces.worktreePath` (same lookup `ChatRuntimeManager` uses today), builds `query()` with streaming input, `canUseTool` wired to the pending-permission machinery, `includePartialMessages: true`, `env` per Decision D9, and starts the event pump: `for await (msg of query)` → journal + broadcast as `{kind:'sdk'}`; on `system:init` capture `claudeSessionId`/`model`; on `session_state_changed` update `status`; on `result` update `status`/cost. -- `attach`-time reads never touch the process: `getState()` returns the in-memory state; `getMessages()` calls the SDK's `getSessionMessages(claudeSessionId, …)` against the JSONL transcript (works even when the process is disposed). -- `sendMessage` pushes into the input queue (creating/resuming the process first if `status === 'exited'`, via `Options.resume: claudeSessionId`). Model/effort changes ride either the dedicated procedures (`setModel` → `query.setModel`) or per-message metadata for parity with today's UX; both end with a `state` broadcast so **every attached client sees the authoritative current model** (fixes the gap called out in PR #5536). -- `respondToPermission({ requestId, response })` resolves the deferred exactly once; late duplicates from other clients get a typed `ALREADY_RESOLVED` error; resolution broadcasts `permission_resolved` + `state`. -- Registry: new host SQLite table `agent_sessions { id (pk, superset session id), workspace_id, claude_session_id, harness, status_snapshot (json), created_at, last_active_at }`, written through the existing drizzle setup in `packages/host-service/src/db/schema.ts` + a migration in the host-service migrations folder (this is host-local SQLite — the Neon/cloud migration rules in `AGENTS.md` do not apply, but never hand-edit generated migration files). The registry is what makes `sessions.list` and resume-after-host-restart work. - -### The API (`packages/host-service/src/trpc/router/sessions/`) - -New `sessions` router (replaces the `chat` router), every input/output validated by `@superset/session-protocol` zod schemas: - -``` -sessions.list({ workspaceId?, cursor?, limit }) → { items: SessionScopedState[], nextCursor } -sessions.get({ sessionId }) → SessionScopedState -sessions.getMessages({ sessionId, cursor?, limit }) → { items: SessionMessage[], nextCursor } -sessions.create({ sessionId, workspaceId, model?, permissionMode? }) → SessionScopedState -sessions.sendMessage({ sessionId, payload: { content, files? }, metadata?: { model?, effort? } }) -sessions.respondToPermission({ sessionId, requestId, response }) // allow/deny/updatedInput/updatedPermissions -sessions.interrupt({ sessionId }) -sessions.setModel({ sessionId, model }) -sessions.setPermissionMode({ sessionId, mode }) -sessions.end({ sessionId }) // dispose process; state → exited -sessions.getCatalog({ sessionId }) → { models, commands } // from initializationResult() -``` - -### The stream endpoints (`packages/host-service/src/app.ts`) - -- `GET /sessions/:sessionId/stream?since=` — SSE. Each frame: `id: `, `data: `. Honors `Last-Event-ID` as an alias for `since`. For desktop (direct `127.0.0.1`) and web-direct. -- WS route `/sessions/:sessionId/stream` (registered alongside `/events` and `/terminal/*`, `wsAuth`-guarded) — same JSON frames; client sends `{ since }` as its first message. This is the relay path: the relay already proxies WS channels verbatim, so mobile gets live push with **zero relay changes**. - -### The clients - -- `@superset/session-protocol/client` ships `subscribeToSession({ transport: 'sse' | 'ws', url, since, onEnvelope, onReset, signal })` — a small dependency-free helper implementing reconnect + cursor + gap-detection + reset-resync callbacks. RN-safe (WS is native in RN; SSE path uses `fetch` streaming and is used only where available). -- Desktop v2: replace `useWorkspaceChatDisplay` polling with a new hook (co-located per repo structure rules) that does `sessions.get` + first page of `sessions.getMessages` + `subscribeToSession`, folding envelopes into local state. A shared view-model adapter (`toUiMessages(items, liveEnvelopes)`) maps Anthropic content blocks (`text`, `thinking`, `tool_use`, `tool_result`) onto the existing chat UI part renderers; pending permissions render from `state.pendingPermissions` by `toolName` (`AskUserQuestion` → question card with typed `AskUserQuestionInput`; `ExitPlanMode` → plan approval card; everything else → tool approval card with `title`/`description` from the request). -- Mobile: delete `apps/mobile/lib/trpc/host-chat-types.ts`; import `@superset/session-protocol`; replace `useChatThread`'s 250ms `getSnapshot` polling with the WS subscribe + snapshot flow. The model chip binds to `state.model` — cross-client switches finally reflect. - -### The excision - -Delete: `packages/host-service/src/runtime/chat/` + `src/trpc/router/chat/`; `packages/chat/src/server/trpc/` (service, zod, runtime utils) + `src/client/hooks/use-chat-display/`; `apps/desktop/src/lib/trpc/routers/chat-runtime-service/`; `apps/desktop/src/main/lib/agent-setup/agent-wrappers-mastra.ts`. Remove `mastracode`, `@mastra/core`, `@mastra/memory`, `@mastra/mcp` from all three package.jsons. Repoint the v1 desktop chat panes (`apps/desktop/src/renderer/components/Chat/**`, `ChatPane`) at the local host-service using the same new hook as v2 — desktop main already knows the host-service port via the coordinator. Audit what survives in `packages/chat`: slash-command tokenizers (`/shared`) and title generation stay if mastra-free; provider-credential machinery in `ChatService` shrinks to whatever Decision D9 requires. Grep gate: `grep -ri "mastra" --include="*.ts" -l` returns only historical plans/docs. - -## Milestones - -### Milestone 0 — Spike: SDK under host-service conditions (timebox: half a day) - -Additive and isolated (a script under `packages/host-service/scripts/`, not production code). Prove on a real workspace worktree: spawn `query()` streaming-input with `cwd` set; observe `system:init` (session id, model); send two messages across one process; trigger `canUseTool` (a Bash command) and resolve it programmatically; trigger AskUserQuestion and answer via `updatedInput`; kill the script and `resume` the session with history intact via `getSessionMessages`. Resolve Q1 (credential source) by testing both auth paths. Outcome recorded in Surprises & Discoveries + Decision D9. - -### Milestone 1 — `packages/session-protocol` - -The package as specified above, plus unit tests for zod schemas and cursor encoding (`bun test packages/session-protocol`). Register in workspaces, `bun run typecheck` green. Acceptance: `apps/mobile` can add it as a dependency and `bun run --cwd apps/mobile typecheck` stays green (proves the RN type-only story). - -### Milestone 2 — SessionManager + `sessions` router - -Host-service runs Claude sessions end-to-end, queryable without any streaming: `sessions.create` → `sendMessage` → poll `get`/`getMessages` shows the turn; `respondToPermission` unblocks a Bash approval; `list` paginates; host-service restart + `sendMessage` resumes the session. Includes the `agent_sessions` migration. Acceptance: an integration test against a temp workspace + manual curl transcript (see Concrete Steps). The old `chat` router still exists and is untouched — both routers coexist until Milestone 6. - -### Milestone 3 — Event stream + subscribe helper - -SSE + WS endpoints, journal, `since` replay, `stream_reset` resync, and `subscribeToSession` in the protocol package. Acceptance: two concurrent subscribers (one SSE-direct, one WS-through-relay in the local relay dev setup from the mobile plan docs) both render the same ordered seq stream; killing one subscriber's connection for 10s and reconnecting with its last seq yields no gaps; reconnecting with `since=1` after journal eviction yields `stream_reset`. - -### Milestone 4 — Desktop v2 on the new stack - -Replace the v2 workspace chat data layer + permission cards. Acceptance: full manual flow in `bun dev` desktop — create session, streamed tokens visible token-by-token (not 250ms chunks), answer an AskUserQuestion and a Bash approval, switch model and see the chip update, interrupt a turn. - -### Milestone 5 — Mobile on the new stack - -Replace facade + polling. Acceptance: the live E2E from `apps/mobile/plans/mobile-chat-runtime.md` §Verification, but with push instead of polling, plus: switch model on desktop, see the mobile chip update without sending a message. - -### Milestone 6 — Excision - -The deletion list above. Acceptance: mastra grep gate; `bun run lint` exits 0 (CI treats warnings as errors); `bun run typecheck` green across all 28 packages; desktop v1 chat panes still function against local host-service; `bun test` green. - -## Concrete Steps - -Milestone-2 smoke transcript (run from repo root; host-service dev on port 4879): - -```bash -bun run --cwd packages/host-service dev -# in another shell — create and drive a session: -curl -s localhost:4879/trpc/sessions.create -H 'content-type: application/json' \ - -d '{"json":{"sessionId":"","workspaceId":""}}' -# Expected: {"result":{"data":{"json":{"sessionId":"...","status":"idle","model":"claude-...", ...}}}} -curl -s localhost:4879/trpc/sessions.sendMessage ... -d '...content:"list the files in this repo"...' -curl -s "localhost:4879/trpc/sessions.getMessages?input=..." -# Expected: items[] containing the user message and assistant tool_use/text blocks -``` - -Milestone-3 stream check: - -```bash -curl -N "localhost:4879/sessions//stream?since=0" -# Expected: frames "id: 1\ndata: {...\"kind\":\"state\"...}", then live sdk events during a turn -``` - -Validation at every milestone: - -```bash -bun run typecheck # all packages, no errors -bun run lint # exit 0, zero warnings (CI fails on warnings) -bun test # all tests pass -``` - -## Validation and Acceptance - -The end-to-end acceptance for the whole plan is the Purpose scenario: desktop + mobile attached to one session, both driving it, live streams on both, host-service restart mid-conversation recovers with history. Each milestone above carries its own narrower acceptance; do not proceed past a milestone whose acceptance has not been demonstrated. - -## Idempotence and Recovery - -All milestones are additive until Milestone 6; the old mastra path keeps working alongside the new router the whole time, so rollback before M6 is "stop routing to `sessions.*`". Within the runtime: `create` is idempotent per sessionId (returns existing state); `respondToPermission` is exactly-once with typed duplicate errors; stream reconnects are safe at any cursor. The `agent_sessions` migration is forward-only but additive (a new table). Milestone 6 is a deletion PR — keep it separate and revertible. - -## Interfaces and Dependencies - -- `@anthropic-ai/claude-agent-sdk` `^0.3.205` — runtime dep of `packages/host-service`; types-only dep of `packages/session-protocol` (Decision D7). -- `packages/session-protocol` — the only chat/session contract import allowed in clients from Milestone 4 on. -- Host-service Hono app gains two routes (`/sessions/:id/stream` SSE + WS); tRPC router swaps `chat` → `sessions` at Milestone 6. -- Nothing in `packages/trpc` (cloud) or `apps/relay` changes. - ---- - -Revision note (2026-07-09, initial): drafted from discovery of the mastra harness map, host-service/relay transport constraints, SDK 0.3.205 type audit, and Kirill's four scoping decisions (D1–D5 directional answers). Stream delivery mechanism (seq/cursor/journal) authored in response to the open "what if we miss stuff" question. - -Revision note (2026-07-09, restored): this file briefly ceased to exist when the working plan was rewritten in place around the ACP direction. Restored verbatim from the session transcript as the competing "direct Claude Agent SDK" alternative, renamed from its timestamped filename to `session-harness-claude-agent-sdk.md`, code blocks converted to fenced style, and the status banner at the top added. See `packages/host-service/docs/acp-sessions.md` for the selected implementation. diff --git a/plans/v1-to-v2-fast-migration.md b/plans/v1-to-v2-fast-migration.md index e6bbfaeb644..d196ea7982b 100644 --- a/plans/v1-to-v2-fast-migration.md +++ b/plans/v1-to-v2-fast-migration.md @@ -1,5 +1,10 @@ # V1 -> V2 Fast Migration Plan +> **Status: historical (Mastra-era chat).** The greenfield/event-log docs it +> references were deleted; local session sync is now specified in +> `plans/host-sessions-sync.md`. The Mastra chat stack is frozen for existing +> users and is not the path forward. + A pragmatic plan to ship the v1 chat UX on top of the existing v2 host-service chat architecture. This is **not** the full event-log rearchitect from `v2-chat-greenfield-architecture.md`, and it should **not replace** the host-service chat work that already exists. The current host-service implementation is the foundation; this plan updates the remaining migration work around it. ## TL;DR diff --git a/plans/v2-chat-greenfield-architecture.md b/plans/v2-chat-greenfield-architecture.md deleted file mode 100644 index 24d4c1dfa72..00000000000 --- a/plans/v2-chat-greenfield-architecture.md +++ /dev/null @@ -1,712 +0,0 @@ -# V2 Chat — Greenfield Architecture Proposal - -Proposed transport + state architecture for v2 chat. Builds on `host-service-chat-architecture.md` and `chat-mastra-rebuild-execplan.md`, and takes specific patterns from `t3code-chat-architecture-reference.md`, `opencode-electron-chat-architecture-reference.md`, and `background-agents-chat-architecture-reference.md`. Starting point is the current v2 chat in `packages/chat` + `packages/host-service`, which polls `getDisplayState()` and `listMessages()` at 4 fps from two independent harness sources — that's the thing this proposal replaces. - -## Goals - -1. **Kill the polling race.** Single server-side source of truth per session; client reducer applies events in order. -2. **Keep the wire protocol stable across runtime locations.** Same `ChatEvent` shape whether the runtime is the user's laptop host-service or a cloud worker spun up for handoff. -3. **Support multi-device + multi-client on the same session.** A session opened on desktop, web, and mobile simultaneously must converge to identical state. -4. **Enable device handoff at turn boundaries.** Close laptop → continue on phone → return to laptop, with a cloud worker picking up in between. Mid-turn handoff is explicitly out of scope (see §P7). -5. **Host-service keeps owning the agent runtime and filesystem.** No change to the `host-service-chat-architecture.md` direction of travel — this is the *transport + state* layer that sits above it. (In the P5b DO path, host-service's authority scopes *down* slightly, see §Cloud-backed EventLog.) -6. **Reuse what we have.** tRPC everywhere, `@hono/node-ws` already in host-service, Zustand already in the dep tree. - -## Non-goals - -- Not migrating off tRPC. t3code's Effect-RPC is nice but the wire shape is what matters; we can get 90% of the value with tRPC subscriptions. -- Not replacing Mastracode. The harness stays — we wrap its event subscription, we don't rewrite the agent loop. -- Not event-sourcing the whole database. The *transport* is event-driven; persistence strategy is a separate decision (see §Persistence). -- Not inventing a new message shape. We already use Vercel AI SDK v6's `UIMessage` and its part types (`TextUIPart`, `ReasoningUIPart`, `ToolUIPart`, `FileUIPart`) throughout the chat UI — `ai-elements` renders them directly. We keep that as the canonical message shape. -- Not adopting `useChat` from `@ai-sdk/react`. It's built for client-initiated single-subscriber request/response. Our model is multi-subscriber, event-driven, with replay and approvals — outside its vocabulary. - -## Recommended architecture - -```mermaid -flowchart LR - subgraph Client["Client (desktop renderer · web · mobile)"] - direction TB - UI["React components"] - Store["Zustand session store
Record<id, UIMessage> · currentTurn ·
pendingApprovals · status"] - Reducer["applyEvent(state, event)"] - Recovery["gap detector
latestSeq · highestSeen"] - UI --> Store - Reducer --> Store - Recovery -.-> Store - end - - subgraph Transport["tRPC over WebSocket (@hono/node-ws)"] - direction TB - Cmd["mutations:
sendMessage · answerApproval ·
answerQuestion · interrupt"] - Replay["query: replayEvents(sessionId, fromSeq, toSeq)"] - Shell["subscription: workspace.watch"] - Detail["subscription: session.watch(sessionId)"] - end - - subgraph HostService["Host service (local) OR Cloud worker"] - direction TB - Router["chatRouter"] - Bridge["EventBridge
sequences harness events +
user commands, synthesizes
user_message_submitted"] - Dedup["CommandReceipts
(idempotent by commandId)"] - Log[("EventLog abstraction
append · readFrom · subscribe
seq-numbered · per-session")] - Shim["LocalEventLog (SQLite) today
— OR —
PostgresEventLog (P5a)
OR DO-native (P5b)"] - Harness["Mastracode harness
(unchanged)"] - Router --> Dedup - Router --> Bridge - Bridge --> Log - Log --> Shim - Bridge --> Harness - Harness --> Bridge - end - - Store -->|mutations| Cmd - Cmd --> Router - Recovery -->|on gap| Replay - Replay --> Log - Shell --> Store - Detail --> Store - Log -.->|fan-out| Shell - Log -.->|fan-out| Detail -``` - -The five load-bearing ideas, each earned from the reference docs: - -1. **Event log as the single source of truth.** Per-session, append-only, monotonically numbered. Both `getDisplayState()` and `listMessages()` become *projections of the log*, not independent queries. — from t3code. -2. **Append-style streaming deltas.** `{ messageId, partIndex, field, delta }` → client applies `messages[id].parts[i][field] += delta`. No unified diffs, no token objects. — from opencode. -3. **Dual subscription scope.** One workspace-wide stream for session summaries (sidebar), one per-session stream for message content. Each region of client state is written by exactly one stream. — from t3code. -4. **Command IDs + server dedup.** Mutations are idempotent by `commandId`; retries on reconnect don't duplicate effects. — from t3code. -5. **Gap detection + `replayEvents` RPC.** Client tracks `latestSeq` / `highestSeen`; on gap, fetch the slice. Non-negotiable for multi-device. — from t3code. - -And the four load-bearing things *we* add: - -6. **`EventLog` as an abstract interface** — swappable backend: local ring buffer + SQLite snapshot today, `s2.dev` durable stream tomorrow, without changing a line of client or router code. -7. **Writes stay as tRPC mutations.** No subscription-based commands. Everything the user does is a regular typed mutation that returns fast; its effect shows up as events on the subscription. -8. **Single session reducer per open session** (not a monolithic global store). Multiple sessions = multiple stores — keeps memory bounded and reducers small. -9. **`UIMessage` on the wire, our reducer on top.** Event payloads carry `UIMessage` and AI SDK part types verbatim. The reducer is ~150 lines of Zustand over a `Record`. `ai-elements` renders the result unchanged. No translation layer between wire and render. - -## Wire protocol - -### Events (server → client) - -Every event conforms to a base envelope, then a discriminated payload. Message and part shapes are AI SDK v6's `UIMessage` and its part union (`TextUIPart | ReasoningUIPart | ToolUIPart | FileUIPart | ...`) — not a custom type. - -```ts -import type { UIMessage } from "ai" -// UIMessage["parts"][number] is the canonical part union from AI SDK v6. -type UIPart = UIMessage["parts"][number] - -type ChatEvent = { - seq: number // monotonic per sessionId, gaps possible after replay - eventId: string - sessionId: SessionId - workspaceId: WorkspaceId - occurredAt: string // ISO - commandId: CommandId | null // null for harness-internal events - causationId: string | null // event that caused this, for tracing -} & EventPayload - -type EventPayload = - // Message lifecycle — uses UIMessage verbatim - | { type: "message.appended"; message: UIMessage } // full message added (user msg, assistant msg shell) - | { type: "message.part.appended"; messageId: UIMessage["id"]; partIndex: number; part: UIPart } // new part on an existing message - | { type: "message.part.delta"; messageId: UIMessage["id"]; partIndex: number; field: "text" | "reasoning"; delta: string } // append into part[field] - | { type: "message.part.updated"; messageId: UIMessage["id"]; partIndex: number; part: UIPart } // replace a part wholesale (tool state transitions, final snapshots) - | { type: "message.completed"; messageId: UIMessage["id"] } - // Turn lifecycle - | { type: "turn.started"; turnId: TurnId } - | { type: "turn.completed"; turnId: TurnId; status: "ok" | "error" | "cancelled" } - // Approvals / questions (out-of-band requests from the agent) - | { type: "approval.requested"; requestId: ApprovalId; tool: string; args: unknown } - | { type: "question.requested"; requestId: QuestionId; prompt: string } - // Session status — projection of turn/approval state, exposed for convenience - | { type: "status.changed"; status: "idle" | "running" | "waiting" | "error" } - | { type: "error"; error: ChatError } -``` - -Notes on the AI-SDK-aligned choices: - -- **`messageId` + `partIndex`** — `UIMessage.parts` is an ordered array, so parts are identified by position within a message, matching how `ai-elements` renders them. If we later need a stable `PartId` we can add it in `UIPart.metadata`, but it's not needed for the reducer to work. -- **`message.part.delta` uses `field: "text" | "reasoning"`** — both `TextUIPart` and `ReasoningUIPart` expose a string body that deltas append to. Tool parts (`ToolUIPart`) don't stream via delta — they emit `message.part.updated` events as their `state` transitions (`input-streaming → input-available → output-available | output-error`), which matches AI SDK v6's own tool state machine. -- **`message.appended` carries the full `UIMessage`** — cheap and unambiguous for initial user message insertion or for snapshot replay. Subsequent streaming uses `part.appended` / `part.delta` / `part.updated` to avoid re-sending the whole message. - -`seq` is per-session. Using per-session (not global) because: -- The harness emits per-session already; adding a global counter introduces a cross-session lock. -- Multi-device replay is always scoped to one session anyway. -- Matches s2.dev's per-stream sequence model cleanly. - -Workspace-level events (`session.created`, `session.metadata.changed`, `session.deleted`) ride a *separate* per-workspace log with its own `seq`. Analogous to t3code's shell vs detail split. - -### Commands (client → server, tRPC mutations) - -```ts -chat.session.sendMessage({ commandId, sessionId, text, attachments? }) -chat.session.answerApproval({ commandId, sessionId, requestId, reply: "accept" | "acceptForSession" | "decline" | "cancel", message? }) -chat.session.answerQuestion({ commandId, sessionId, requestId, answer: string }) -chat.session.interrupt({ commandId, sessionId }) -``` - -Every command carries a client-generated `commandId` (ULID). Server checks `CommandReceipts` before acting — retries after reconnect are free. The returned value is trivial (`{ ok: true, seqAfter }`) so the UI doesn't depend on it; all real state arrives on the subscription. - -### Subscriptions - -```ts -chat.workspace.watch(workspaceId) -> stream of WorkspaceEvent // sidebar -chat.session.watch({ sessionId, sinceSeq? }) -> stream of ChatEvent // open chat -``` - -`sinceSeq` is optional. If omitted, server sends a `snapshot` envelope first (`{ snapshot: ProjectedState, seqAfter: number }`), then live events. If `sinceSeq` is passed and is still in the server's replay window, server streams from there. If it's older than the window, server falls back to snapshot. - -### Replay - -```ts -chat.session.replayEvents({ sessionId, fromSeq, toSeq? }) -> ChatEvent[] -``` - -Called only when the client detects a gap (received `seq = N+2` while holding `latestSeq = N`). Subscription stream carries recent events; this query fills holes. Server implementation just reads the log. - -## Client-side design - -One Zustand store per currently-open session, plus one shared workspace store. Zustand is already the house pattern (direct dep in `packages/panes` and `apps/desktop`, ~20+ existing stores in `apps/desktop/src/renderer/stores/`), so no new primitives. - -Store shape uses `UIMessage` directly so `ai-elements` can render it without translation: - -```ts -import type { UIMessage } from "ai" - -interface SessionState { - status: "connecting" | "idle" | "running" | "waiting" | "error" - messages: Record // authoritative per-message state - messageOrder: UIMessage["id"][] // insertion order for rendering - pendingApprovals: Record - pendingQuestions: Record - currentTurn: { turnId: TurnId; messageId: UIMessage["id"] } | null - latestSeq: number - highestSeen: number - pendingBuffer: ChatEvent[] // events received out of order -} - -const useSessionStore = (sessionId: SessionId) => create((set, get) => ({ - // ... initial state ... - - applyEvent(event: ChatEvent) { - // Pure reducer. Switch on event.type: - // message.appended → messages[id] = event.message, messageOrder.push(id) - // message.part.appended→ messages[id].parts[partIndex] = event.part - // message.part.delta → messages[id].parts[partIndex][field] += event.delta - // message.part.updated → messages[id].parts[partIndex] = event.part (tool state transitions) - // message.completed → (nothing — message already reflects terminal state) - // turn.started/.completed → currentTurn = … / status = … - // approval.requested / question.requested → add to pendingApprovals / pendingQuestions - // status.changed → status = event.status - }, - - onReceive(event: ChatEvent) { - // 1. update highestSeen - // 2. if seq == latestSeq + 1: apply, drain pendingBuffer - // 3. if seq > latestSeq + 1: push to pendingBuffer, fire chat.session.replayEvents - // 4. if seq <= latestSeq: ignore (duplicate / reconnect overlap) - }, -})) -``` - -Selectors replace `useChatDisplay`: - -```ts -const messages = useSessionStore(sessionId, s => s.messageOrder.map(id => s.messages[id])) // UIMessage[] -const isRunning = useSessionStore(sessionId, s => s.status === "running") -const pendingApproval = useSessionStore(sessionId, s => firstOf(s.pendingApprovals)) -``` - -Because `messages` is already `UIMessage[]`, it flows straight into existing `ai-elements` components (`…`, ``, etc.). No adapter layer. - -This kills `withoutActiveTurnAssistantHistory` — the active assistant message is just the most-recent entry in `messages` whose parts are still mutating. There's no duplication between `currentMessage` and history to reconcile. - -**Per-frame coalescing.** Borrow from opencode: if `message.part.delta` events arrive faster than the browser can render, batch them and flush once per `requestAnimationFrame`. For non-delta events, apply immediately. Straight in the `onReceive` path, not in the reducer. - -**Why not `useChat` from `@ai-sdk/react`?** `useChat` holds `UIMessage[]` state and knows how to apply text deltas, but its mental model is "this client initiated this turn, this client owns the stream." Turns in our system can originate from another device; events arrive for approvals/questions/interrupts that `useChat` has no concept of; we need `sinceSeq` replay on reconnect; multiple open tabs share one session. Bending `useChat` to that model is strictly more work than owning the reducer — and the reducer over `UIMessage` is ~150 lines. - -## Host-service side - -`packages/host-service/src/runtime/chat/` grows an `EventBridge` alongside the existing `ChatRuntimeManager`: - -```ts -interface EventLog { - append(streamId: string, event: TEvent): Promise<{ seq: number }> - readFrom(streamId: string, fromSeq: number, toSeq?: number): Promise - subscribe(streamId: string, fromSeq?: number): AsyncIterable -} -``` - -Two implementations from day 1: -- `LocalEventLog` — in-memory ring buffer (N events or M minutes) with a SQLite durable backing store. Default. -- `S2EventLog` — `s2.dev` client (or whichever durable stream provider we pick). Swapped in via config when running against cloud. - -The `EventBridge`: - -1. Subscribes to `harness.subscribe()` for the session. -2. Translates raw harness events into typed `ChatEvent`s, adding `seq` (from the log) and `causationId`. -3. Appends to the `EventLog`. -4. Synthesizes `user_message_submitted` **before** calling the harness (fixes the gap called out in `chat-mastra-rebuild-execplan.md`), so user messages and assistant responses share the same ordering guarantee. -5. Serializes appends per session with an async queue — the bug call-out in the rebuild plan. - -tRPC router changes: - -- `chat.session.watch` becomes a `.subscription()` over WebSocket (host-service already has `@hono/node-ws`). -- `chat.session.replayEvents` is a regular query. -- Existing `chat.session.sendMessage` mutation wraps the `EventBridge` submission path with `CommandReceipts` dedup. -- `getDisplayState` and `listMessages` are **deleted**. If any internal code still needs a point-in-time snapshot, it reads from the projection (see §Persistence). - -## Message send flow, end-to-end - -```mermaid -sequenceDiagram - autonumber - participant User - participant Store as Client store (Zustand) - participant WS as tRPC over WS - participant Router as chatRouter (host) - participant Receipts as CommandReceipts - participant Bridge as EventBridge - participant Log as EventLog - participant Harness as Mastracode harness - - Note over Store,Log: session.watch subscription already open
client latestSeq = N - - User->>Store: submit "do X" - Store->>Store: optimistic message.appended
(local-only, pending confirm) - Store->>WS: mutation sendMessage
{ commandId, sessionId, text } - WS->>Router: mutation - - Router->>Receipts: lookup(commandId) - alt commandId already seen (retry) - Receipts-->>Router: prior result - Router-->>WS: { ok: true, seqAfter } - WS-->>Store: resolve (no-op, event already applied) - else new command - Router->>Bridge: submit(user message) - Bridge->>Log: append(message.appended{user}) - Log-->>Bridge: seq = N+1 - Bridge->>Log: append(turn.started) - Log-->>Bridge: seq = N+2 - Log-->>WS: fan-out events N+1, N+2 - WS-->>Store: onReceive(N+1) → reconcile optimistic
onReceive(N+2) → status = "running" - Bridge->>Harness: sendTurn() - Router->>Receipts: store(commandId → N+2) - Router-->>WS: { ok: true, seqAfter: N+2 } - WS-->>Store: mutation resolved - end - - loop while turn is running - Harness->>Bridge: runtime event
(token delta · tool call · approval · …) - Bridge->>Bridge: translate → ChatEvent
(stamp causationId) - Bridge->>Log: append(event) - Log-->>Bridge: seq = N+k - Log-->>WS: fan-out - WS-->>Store: onReceive(event) - - alt seq == latestSeq + 1 - Store->>Store: applyEvent
(deltas coalesce to rAF flush) - else seq > latestSeq + 1 (gap) - Store->>Store: push to pendingBuffer - Store->>WS: query replayEvents(fromSeq, toSeq) - WS->>Log: readFrom(fromSeq, toSeq) - Log-->>WS: missing events - WS-->>Store: events - Store->>Store: apply each, drain buffer - else seq <= latestSeq (duplicate / overlap) - Store->>Store: ignore - end - end - - Harness->>Bridge: turn complete - Bridge->>Log: append(message.completed, turn.completed) - Log-->>WS: fan-out - WS-->>Store: apply → status = "idle" - Store-->>User: render final assistant message -``` - -Reading notes: - -- **Steps 1-3.** Optimistic user message lands in the store immediately — no round-trip wait for the first pixel. It gets reconciled (not replaced) when the server-authored `message.appended` arrives at step 12 with the real `seq` and server-authored `messageId`. -- **Steps 4-7.** `commandId` dedup makes the whole mutation idempotent. A flaky network can retry `sendMessage` all day; the server runs the turn exactly once. -- **Steps 13 onwards.** The subscription is the hot path for everything that happens after submission. The mutation's `{ ok: true, seqAfter }` return is a hint, not the data; the UI never blocks on it. -- **Gap branch (step 22-27).** This is the reconnect / out-of-order-delivery path. Client detects `seq > latestSeq + 1`, buffers the new event, fetches the missing slice, applies in order. Same mechanism handles "backgrounded for 10 minutes, WS died" as "one dropped packet." -- **Tool approvals / user questions** follow the same loop — `approval.requested` is just another event on the stream. The user's reply is a separate `answerApproval` mutation (not shown), which produces an `approval.responded` event that unblocks the harness. - -Every box outside of `Harness` is code we write. `Harness` is unchanged Mastracode. - -## Persistence - -The event log is the wire protocol. Storage is separate. - -Short-term: SQLite table `chat_events (stream_id, seq, event_json, occurred_at)` with index on `(stream_id, seq)`. Projection tables (messages, sessions) rebuilt on startup by replaying the log, cached in memory for reads. - -This mirrors t3code but lighter — we only need projections where we need fast server-side reads, not for every aggregate. The `messages` projection probably matters; `pendingApprovals` doesn't (keep it in memory). - -Long-term (cloud): the `EventLog` interface gets a second implementation backed by a cloud-shared durable store (Postgres on Neon, by default), or alternatively the whole control plane moves to Cloudflare Durable Objects. Both are covered in detail below. - -The *client* never sees persistence directly — it always talks to `EventLog` through the tRPC/WS surface. - -## Cloud-backed `EventLog`: two paths - -Once we want cross-device visibility (same session on laptop + phone simultaneously) or a cloud-hosted agent runtime, `LocalEventLog` on host-service's SQLite is no longer enough — the log has to be reachable from any process that might own a runtime or serve a subscription. We have two credible paths; they are genuinely different and worth choosing between with eyes open. - -### Path A — Postgres-backed EventLog (`PostgresEventLog`) - -Stay on the existing stack (Neon). Add one table and adapt the `EventLog` implementation: - -```sql -CREATE TABLE chat_events ( - stream_id TEXT NOT NULL, - seq BIGINT NOT NULL, - event_json JSONB NOT NULL, - occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (stream_id, seq) -); -CREATE INDEX chat_events_stream_time_idx ON chat_events (stream_id, occurred_at); -``` - -- `append()` → `INSERT` + `pg_notify('chat_session_', ...)` for live fan-out. -- `readFrom()` → `SELECT WHERE stream_id = $1 AND seq BETWEEN $2 AND $3`. -- `subscribe()` → open a `LISTEN`, stream rows as they arrive. If many subscribers per host is painful, bounce through an in-process broadcaster per session. -- Per-session write ordering → an in-process async queue in whichever host-service or cloud worker currently owns the session's writes, plus a lease row in Postgres (`chat_session_ownership`) to prevent two processes racing on the same session. -- Command dedup → same `CommandReceipts` table we already have. - -Who owns the `EventLog`: - -- Host-service continues to own its sessions locally and writes events to both `LocalEventLog` (SQLite) and `PostgresEventLog` (for cross-device visibility). -- When host-service isn't reachable, a cloud worker can claim the lease and take over writes. - -**What this buys:** cross-device read, cross-device replay, cloud-runtime handoff, and no new vendor. Everything builds on Neon, which is already in the stack. - -**What you still build:** -- Lease-based session ownership across processes. -- Per-session write serialisation inside whichever process holds the lease. -- A subscription-fan-out tier (probably host-service's tRPC WS server, or a parallel cloud Node service). -- Idle-cost story: any Node process holding N WebSockets for idle sessions is paying to be idle. - -### Path B — Durable Objects as the whole control plane - -Adopt Cloudflare Workers + Durable Objects. The `EventLog` *and* the subscription transport *and* the session-ownership story all collapse into a single primitive: one DO per session. - -``` -Clients (phone / web / desktop renderer) - │ WebSocket direct to SessionDO - ▼ -┌─────────────────────── Cloudflare ──────────────────────────┐ -│ │ -│ Stateless Worker │ -│ auth · ws-token mint · routing │ -│ │ -│ SessionDO (one per sessionId) │ -│ SQLite storage (events · messages · command_receipts) │ -│ WebSocket hub (browsers + agent runtime) │ -│ Single-threaded — ordering free, no locks │ -│ Hibernates when idle — near-zero cost per idle session │ -│ │ -│ WorkspaceDO (one per workspaceId) │ -│ sidebar index + session-list events │ -│ │ -│ D1 (global) │ -│ users · workspaces · session directory · encrypted creds │ -└─────────────────────────────────────────────────────────────┘ - -Laptop host-service (unchanged filesystem + Mastracode ownership) - ▲ - │ WebSocket connects to SessionDO as a "runtime participant" - │ subscribes for user messages, runs turns, streams events back - -Cloud runtime (Modal/Daytona/Fly container, spun up on handoff) - ▲ - │ Same "runtime participant" role; same protocol -``` - -Shifts vs. Path A: - -- Event log = the DO's per-session SQLite. No Postgres table for events. -- Transport = browsers connect WebSocket directly to the DO via a CF Worker. No host-service-hosted tRPC subscription server for chat. -- Session ownership = platform-guaranteed. Every request for `session/abc-123` goes to the same DO instance. No lease table. -- Per-session write serialisation = free. DOs are single-threaded. -- Fan-out = free. The DO owns the WebSockets and broadcasts natively. -- Idle cost = near-zero. DO hibernation holds WebSockets open while the compute sleeps. -- Host-service role = **downgrades from "source of truth + runtime" to "runtime participant only."** It connects to the SessionDO like any other client, listens for user messages, runs turns, streams events back. It no longer owns the chat event log; SessionDO does. -- Relay role = not used for chat (browsers hit DOs directly). Still used for filesystem tools and terminal. - -**What this buys on top of Path A:** -- Multi-device fan-out as a platform feature (no custom broker). -- Ownership coordination as a platform feature (no lease protocol). -- Hibernation as a platform feature (thousands of idle sessions effectively free). -- Handoff between devices / runtimes is essentially free — both laptop and cloud worker just subscribe to the same SessionDO. -- Multiplayer (multiple humans in one session) is essentially free if ever wanted. - -**What it costs:** -- **New cloud vendor.** Cloudflare enters the stack with its own deployment tooling (Wrangler), observability story, secrets model, and debugging surface. -- **Lock-in.** DOs are Cloudflare-specific. Portable to no other cloud without a rewrite. -- **Chat becomes cloud-dependent.** Host-service's local SQLite is no longer authoritative. Laptop-offline-but-chatting stops working unless we build a local-cache shadow layer. Most teams going DO-native accept this; it's a real regression worth being explicit about. -- **Agent still runs elsewhere.** DO CPU limits prevent Mastracode turns from running inside the DO. Runtime is still host-service (or cloud worker on handoff). - -### Decision framing - -The `EventLog` interface (defined in P0) fits both paths without modification, so nothing in P0-P4 is gated on which cloud path we pick. When we do pick: - -- Path A (Postgres) is the default if we want to stay on our current vendors, are willing to write the ownership + fan-out + idle-cost code ourselves, and don't need the platform-level multi-device ergonomics DOs provide. -- Path B (DOs) is the default if we're open to Cloudflare in the stack and want to shave several piles of custom infrastructure in exchange for that lock-in. - -Both are real choices. See §Phased migration — P5a and P5b — for what shipping each one actually involves. - -## Phased migration - -Phases are sequential. P0-P1 can interleave a little; P2 depends on P1; P3 depends on P2; P4 is pure cleanup; P5 is independent from P4 (can ship before or after). - -### Blockers to resolve before P0 - -These are the three questions that will bite us if we skip them: - -- [ ] **Decide `seq` ownership: in-memory counter vs. log-assigned.** Recommendation: log-assigned, returned from `append()`. Keeps `EventBridge` single-source and avoids a second counter to keep in sync. -- [ ] **Resolve provider credential scoping** for renderer ↔ host-service direct WS (the open question from `host-service-chat-architecture.md`). If we don't, P1 stalls. -- [ ] **Pick the event-type ownership location.** Recommendation: a new `packages/chat-protocol` (schema + TypeScript types only, no runtime) importable by host-service, renderer, mobile, and future cloud worker. Current `packages/trpc` is router-level, not protocol-level. - -### P0 — Protocol & EventLog interface (server-side only, no wire changes) - -**Goal:** define the contract and the local implementation, verified by a parity test against the current polling output. - -- [ ] Create `packages/chat-protocol` with: - - [ ] `ChatEvent` envelope type (seq, eventId, sessionId, workspaceId, occurredAt, commandId, causationId). - - [ ] `EventPayload` union (using AI SDK `UIMessage` and its part types — no invented message types). - - [ ] `WorkspaceEvent` union for the sidebar stream. - - [ ] Command input schemas (`sendMessage`, `answerApproval`, `answerQuestion`, `interrupt`) with `commandId: string` required. - - [ ] Zod schemas alongside TypeScript types (so tRPC inputs validate). -- [ ] Define `EventLog` interface in `packages/chat-protocol`: - - [ ] `append(streamId, event): Promise<{ seq }>` - - [ ] `readFrom(streamId, fromSeq, toSeq?): Promise` - - [ ] `subscribe(streamId, fromSeq?): AsyncIterable` - - [ ] `snapshot(streamId): Promise<{ state, seq }>` — serves snapshot-on-subscribe. -- [ ] Implement `LocalEventLog` in `packages/host-service/src/runtime/chat/event-log/`: - - [ ] In-memory ring buffer (default: last 500 events + 15 min, configurable). - - [ ] SQLite durable backing table `chat_events(stream_id, seq, event_json, occurred_at)` via existing Drizzle setup. - - [ ] `subscribe()` implemented as a hot async iterable with pull-based backpressure. -- [ ] Implement `EventBridge` in `packages/host-service/src/runtime/chat/`: - - [ ] Wrap each `RuntimeSession`'s `harness.subscribe()` in a translator that produces typed `ChatEvent`s. - - [ ] Per-session serialized async queue (fixes the ordering bug flagged in `chat-mastra-rebuild-execplan.md`). - - [ ] Synthesize `user_message_submitted` / `message.appended` events *before* calling into the harness so the log ordering is deterministic. - - [ ] Stamp `causationId` where a new event chains from an earlier one. -- [ ] Implement `CommandReceipts` in host-service: - - [ ] SQLite table `command_receipts(command_id PK, session_id, result_seq, created_at)`. - - [ ] Dedup middleware on mutations: if `commandId` exists, return the stored `{ ok: true, seqAfter }` without re-executing. - - [ ] TTL sweep (e.g. 24h). -- [ ] Parity harness: - - [ ] Test that runs a recorded session (user message → tool calls → assistant response) through `EventBridge`, then projects the resulting log through a reducer. - - [ ] Asserts the projected state equals the current `getDisplayState()` + `listMessages()` output for the same inputs. - -**Acceptance:** projection-from-log matches polling-output byte-for-byte on a corpus of ≥5 recorded sessions, including an approval flow and an interrupt. - -### P1 — tRPC surface on host-service (transport, still no client changes) - -**Goal:** expose the event log as typed tRPC subscriptions and replay query. Old polling procedures still work. - -- [ ] Add tRPC subscription procedures in `packages/host-service/src/trpc/router/chat/`: - - [ ] `chat.session.watch({ sessionId, sinceSeq? })` → `Observable`. - - [ ] `chat.workspace.watch({ workspaceId, sinceSeq? })` → `Observable`. -- [ ] Add the replay query: - - [ ] `chat.session.replayEvents({ sessionId, fromSeq, toSeq? })` → `ChatEvent[]`. -- [ ] Wire subscriptions onto `@hono/node-ws` in host-service's app.ts (the terminal route already shows the pattern). -- [ ] Add typed mutations with `commandId` on every input: - - [ ] `chat.session.sendMessage` - - [ ] `chat.session.answerApproval` - - [ ] `chat.session.answerQuestion` - - [ ] `chat.session.interrupt` - - Each wraps the equivalent existing mutation, adds `CommandReceipts` dedup, and returns `{ ok: true, seqAfter }`. -- [ ] Leave `getDisplayState` / `listMessages` intact for now. -- [ ] Write a Node test client (checked in under `packages/host-service/test/`): - - [ ] Connects via WS, subscribes, sends `sendMessage`, asserts expected event sequence arrives. - - [ ] Drops the WS mid-stream, reconnects with `sinceSeq`, asserts no duplicates and no gaps. - - [ ] Simulates a gap (forces server to skip), asserts client-side replay call fills it. - - [ ] Double-submits the same `commandId`, asserts only one effect. - -**Acceptance:** test client green across all four scenarios. No existing chat code has changed. - -### P2 — Client store, reducer, and gap detector (client-side, no UI swap yet) - -**Goal:** everything a UI component would need to render chat off the event stream, shipped as a drop-in hook. - -- [ ] Create `packages/chat/src/client/session-store/` (or similar; can also live in `apps/desktop/src/renderer/stores/chat-session/` if it stays desktop-only — recommend the package to keep web/mobile aligned): - - [ ] Zustand store factory `createSessionStore(sessionId)` with the shape defined in §Client-side design. - - [ ] Pure reducer `applyEvent(state, event)` covering every `EventPayload` variant. Use Immer or structural-clone helpers; keep it obviously pure. - - [ ] `onReceive(event)` with gap detection (`seq vs latestSeq`), pendingBuffer, dedup on `seq <= latestSeq`. - - [ ] Replay trigger: on gap, fires `chat.session.replayEvents`, applies result, drains buffer. -- [ ] Per-frame coalescer: - - [ ] Batch `message.part.delta` events by `(messageId, partIndex, field)` and flush on `requestAnimationFrame`. - - [ ] Non-delta events apply synchronously. -- [ ] Subscription hook `useChatSessionSubscription(sessionId)`: - - [ ] Opens the tRPC subscription, wires events into `onReceive`. - - [ ] Handles WS disconnect → reopen with `sinceSeq = latestSeq + 1`. - - [ ] Surfaces `status: "connecting" | "live" | "replaying" | "error"` for UI affordances. -- [ ] Workspace store (analogous, smaller) for the sidebar. -- [ ] Compatibility shim `useChatDisplay_v2(sessionId)` that exposes the same selector keys today's `useChatDisplay` returns (`messages`, `isRunning`, `currentMessage`, etc.) — this makes P3 a flag flip rather than a rewrite. -- [ ] Unit tests: - - [ ] Apply a recorded event stream to the reducer, snapshot the resulting state. - - [ ] Fuzz: randomized ordering with one missing event, assert store converges to canonical state after replay. - - [ ] Coalescer: 1000 deltas, assert at most 60 flushes per second. - -**Acceptance:** reducer tests green; compatibility shim renders an identical `ChatPane` against a scripted event stream in a Storybook story. - -### P3 — Swap UI consumers - -**Goal:** chat in the app is driven by the event stream; old polling is gated off. - -- [ ] Add a feature flag `chat.useEventStream` (off by default). -- [ ] Swap ChatPane in v2-workspace to use `useChatDisplay_v2` under the flag. -- [ ] Swap any other `useChatDisplay` consumers (grep: `apps/desktop/src/renderer/**/useChatDisplay`). -- [ ] Dogfood on the v2-chat-architecture branch for ≥1 week across desktop. -- [ ] QA matrix: - - [ ] Golden path: send message, get response, render tokens. - - [ ] Tool approval flow. - - [ ] Mid-turn interrupt. - - [ ] Reconnect during active turn. - - [ ] Two windows open on the same session (should converge). - - [ ] Rapid consecutive messages (no lost events). -- [ ] Flip the flag default to on once QA is green; keep the flag for two releases as an escape hatch. - -**Acceptance:** no regressions in the chat QA matrix vs. current main for two full releases. - -### P4 — Delete the old surface - -**Goal:** one code path for chat, not two. - -- [ ] Remove the `chat.useEventStream` flag (and any dead branches it gated). -- [ ] Delete `getDisplayState` and `listMessages` tRPC procedures. -- [ ] Delete `packages/chat/src/client/hooks/use-chat-display/`. -- [ ] Delete `withoutActiveTurnAssistantHistory` and related helpers. -- [ ] Delete the legacy surface in `packages/chat/src/server/trpc/service.ts` (the desktop-only tRPC service). Host-service is the only owner — this finishes Phase 3 of `host-service-chat-architecture.md`. -- [ ] Remove `@superset/chat/client/provider` re-exports that nothing else imports. -- [ ] Update `AGENTS.md` / relevant docs to point at the new surface. - -**Acceptance:** zero references to the deleted surface in `apps/` or `packages/` (excluding `temp/`). CI green. - -### P5 — Cloud-backed EventLog (pick A or B; independent of P4) - -P5 is where cross-device visibility and cloud-runtime handoff become possible. Two alternative paths — choose one; they're not additive. See §Cloud-backed `EventLog` above for the comparison. - -#### P5a — Postgres-backed EventLog (stay on current stack) - -**Goal:** ship `PostgresEventLog` behind the existing `EventLog` interface so host-service and cloud processes can read/write the same session log. - -- [ ] Add Drizzle migration for `chat_events(stream_id, seq, event_json, occurred_at)` with `PRIMARY KEY (stream_id, seq)` and a secondary index on `(stream_id, occurred_at)`. -- [ ] Add `chat_session_ownership(stream_id, owner_id, lease_expires_at)` for single-writer ownership. -- [ ] Implement `PostgresEventLog`: - - [ ] `append` → INSERT + `pg_notify('chat_stream_' || stream_id, seq)`. - - [ ] `readFrom` / `snapshot` → range SELECT. - - [ ] `subscribe` → `LISTEN` + in-process fan-out to multiple local subscribers on the same process. - - [ ] Ownership lease acquisition + heartbeat; lease-loss callback so the losing owner stops writing immediately. -- [ ] Dual-write for host-service: `LocalEventLog` stays the primary when host-service is reachable; `PostgresEventLog` mirrors events for cross-device visibility. (Alternative: make Postgres authoritative and drop `LocalEventLog` — simpler, but breaks laptop-offline chat.) -- [ ] Stand up a thin cloud "chat node" (small Fly / Cloudflare Worker service) that serves subscriptions to browsers when host-service is unreachable. Reads from `PostgresEventLog`; forwards writes to whichever process holds the lease. -- [ ] Per-environment config toggle for which `EventLog` implementation each process uses. -- [ ] Multi-device end-to-end test: desktop + web attached to the same session, events converge identically across both; simulated network partition on one device. -- [ ] Operational runbook: lease renewal, stuck-lease recovery, replay-window sizing, disaster recovery. -- [ ] Capacity / cost model: events per session per day × session count × retention window against Neon's pricing. - -**Acceptance:** two clients on the same session converge to identical state after a scripted sequence with a simulated network partition on one side. - -#### P5b — Durable-Objects-native control plane (Cloudflare) - -**Goal:** replace the cloud subscription tier entirely by making each session live in its own Cloudflare Durable Object. Host-service becomes a runtime participant connecting to the DO instead of a source of truth. - -- [ ] Stand up a new Cloudflare Workers deployment with Durable Objects bindings and a D1 database. -- [ ] Implement `SessionDO`: - - [ ] SQLite storage (events, messages queue, command_receipts, participants, ws_client_mapping). - - [ ] `fetch` handler for tRPC-style mutations (sendMessage, answerApproval, answerQuestion, interrupt). - - [ ] `webSocketMessage` handler for live client messages (subscribe, prompt, stop, typing, presence). - - [ ] Fan-out helper that iterates connected WSs on every event append. - - [ ] Hibernation enabled; `ws_client_mapping` persists `wsId → participantId` for rehydration. -- [ ] Implement `WorkspaceDO` for per-workspace sidebar events. -- [ ] Implement `D1` schema for users, workspaces, session directory, encrypted credentials. -- [ ] Stateless auth Worker: validate OAuth / JWT, mint WS tokens (short TTL), route to the correct DO. -- [ ] Adapt host-service to connect to `SessionDO` as a runtime participant: - - [ ] Open a long-lived WS with a runtime-auth-token (issued per-session). - - [ ] Subscribe to user-message events; feed them to Mastracode. - - [ ] Write harness events back to the DO via the same WS. - - [ ] Handle "another runtime claimed this session" eviction gracefully. -- [ ] Adapt clients (browser, mobile) to open WebSockets directly to `SessionDO` via the Worker surface, not through host-service / the relay. -- [ ] Decide local-offline story: accept that chat requires cloud connectivity, OR ship a `LocalCacheEventLog` shadow layer that mirrors the DO to host-service's SQLite and serves reads when offline. Recommend starting with "accept cloud dependency" and adding local cache only if users push back. -- [ ] Migrate the chat portion of the relay out — browser traffic for chat stops going through the relay. Filesystem / terminal traffic stays on the relay. -- [ ] Multi-device end-to-end test: desktop + web + phone on the same session, convergent state, simulated partition, simulated laptop-off with cloud worker handoff. -- [ ] Operational runbook: DO storage limits, wrangler deploy process, observability + logging, secrets rotation, Cloudflare-specific failure modes. -- [ ] Capacity / cost model: request count × DO-hours × storage × egress. - -**Acceptance:** same as P5a, plus one additional scenario: session active on laptop → laptop disconnects → phone continues seeing the event stream without any latency beyond DO wake-from-hibernation. - -**Note on going from P5a to P5b later:** the `EventLog` interface is implementation-agnostic, so moving from Postgres to DOs *is* possible later, but it's not a drop-in swap — P5b specifically changes who owns the transport (browser connects direct to DO, not through host-service), which is a bigger shift than just swapping the storage. If there's any chance we'll go DO-native, pick it the first time through P5. - -### P6 — Device handoff (turn-boundary + git courier) - -**Goal:** when the primary runtime (laptop host-service) becomes unreachable, subsequent turns can be served by a cloud worker without losing session continuity or uncommitted work. Not mid-turn — turn-boundary only. - -Depends on P5 (either P5a or P5b) — the event log has to be cloud-reachable. - -- [ ] Add event types to the protocol: - - [ ] `runtime_registered { runtimeId, kind: "host" | "cloud", capabilities }` — emitted when a runtime attaches to a session. - - [ ] `runtime_unregistered { runtimeId, reason }` — emitted on graceful disconnect or heartbeat timeout. - - [ ] `handoff_ready { branchName, commitSha, fromRuntimeId }` — emitted when a runtime stashes in-progress work for handoff. - - [ ] `turn_interrupted { turnId, reason }` — emitted when a turn is abandoned mid-flight due to runtime loss. -- [ ] Runtime ownership protocol: - - [ ] Only one `runtime_registered` is active per session at a time. - - [ ] Ownership renewal via heartbeat events every N seconds. - - [ ] New runtime can claim after heartbeat timeout + grace window. -- [ ] Laptop host-service handoff trigger: - - [ ] On graceful shutdown (lid close via macOS power notification, quit, explicit "handoff" command): stage + commit dirty workspace files to `superset/handoff/`, push, emit `handoff_ready`. - - [ ] On ungraceful loss (network drop + grace window expires): cloud coordinator declares the runtime dead, emits `turn_interrupted` if a turn was mid-flight. -- [ ] On-demand cloud runtime spawn: - - [ ] Coordinator service watches sessions with pending user messages and no active runtime. - - [ ] Spawns a Modal/Daytona/Fly-container sandbox, clones the repo, checks out the handoff branch (or `main` if no handoff branch), runs `.superset/setup.sh` if present. - - [ ] New cloud runtime registers with the session via `runtime_registered`, picks up the pending message, runs the turn, streams events back. -- [ ] Return-to-laptop flow: - - [ ] Host-service reconnects, sees later events in the log authored by a cloud runtime. - - [ ] Pulls any ephemeral branches the cloud runtime pushed (e.g. `superset/cloud//`). - - [ ] UI surface: "your session ran in the cloud while you were away; here's the diff — merge or discard?" -- [ ] Speculative runtime warming: when phone sends `typing` and no local runtime is reachable, emit a warming signal so the cloud coordinator starts spawning a sandbox speculatively. Hides cold-start latency. -- [ ] Honest limitations to document: - - [ ] Mid-turn handoff not supported — interrupted turns are lost, user re-prompts. - - [ ] Untracked / gitignored files are lost on handoff (only committed state travels). - - [ ] Long-running processes (dev server, watch modes) started inside a turn don't survive the handoff. -- [ ] Tests: laptop-closes-mid-session convergence; return-to-laptop merge UX; simultaneous runtime-claim race; cloud runtime timeout + re-spawn. - -**Acceptance:** user can close laptop mid-chat, continue from phone, see cloud-run turns land in real time, return to laptop and pull cloud-authored commits without conflict in the golden path. - -### P7 (speculative) — Event-sourced agent for mid-turn handoff - -Deliberately not in scope for the first pass. Covered in conversation because it keeps coming up. - -Seamless mid-turn handoff requires the agent's in-flight state (LLM stream position, partially-applied tool results) to be reconstructible from the event log, which in turn requires the agent to emit *intent* events before acting and to be designed around replay-safe tools. That's a ~quarter-scale of work on top of P6 and produces a quality regression on re-prompted LLM streams. Worth revisiting only if long autonomous runs + device handoff become first-class product requirements. - -### Summary timeline - -Rough sizing (for a single engineer, not including meetings / reviews): - -| Phase | Est. | Can parallelize? | Ships behind flag? | -|-------|------|------------------|--------------------| -| Blockers | 1-2 days | — | n/a | -| P0 | 1-2 weeks | No | n/a (no wire change) | -| P1 | 1 week | Partial with P0 end | n/a (additive) | -| P2 | 1-2 weeks | After P1 | n/a | -| P3 | 1 week + bake time | After P2 | Yes | -| P4 | 2-3 days | After P3 bake | n/a | -| P5a (Postgres) | 2-3 weeks | Any time after P2 | Yes (config) | -| P5b (Durable Objects) | 4-6 weeks | Any time after P2; mutually exclusive with P5a | Yes (config) | -| P6 (handoff) | 2-3 weeks | After P5 | Yes | -| P7 (event-sourced agent) | quarter-scale | Speculative / not on critical path | n/a | - -P0-P4 is the critical path for killing the race condition and unblocking same-machine multi-window. P5 is where cross-device + cloud-runtime becomes possible; P5a and P5b are alternatives, not additive. P6 delivers the close-laptop-continue-on-phone handoff UX on top of whichever P5 path was chosen. P7 is explicitly not on the roadmap — listed so we don't accidentally rediscover why it's hard. - -## Open questions - -- **P5 path: Postgres or Durable Objects?** The single biggest deferred decision. Postgres keeps us on our current vendors and requires more custom infra (ownership leases, fan-out tier, idle-cost story). DOs replace several layers with platform primitives but bring Cloudflare into the stack and make chat cloud-dependent. Worth resolving before P5 starts, but not before — P0-P4 is the same either way. -- **Local-offline chat in the DO path.** If we go P5b, do we accept that chat requires internet, or build a `LocalCacheEventLog` shadow layer that mirrors the DO? Recommend starting without it and adding only if users surface friction. -- **Ownership of `commandId` dedup window.** Keep `CommandReceipts` in our own durable store regardless of P5 path — cleaner semantics, survives backend swaps, less magic. -- **Subscription granularity for workspace stream.** One subscription per workspace or one per user-across-workspaces? Recommend per-workspace — matches the current sidebar scope and keeps events small. -- **Backpressure.** If a slow client can't keep up with token deltas, do we drop, coalesce, or disconnect? Recommend server-side (or DO-side) ring-per-subscriber with coalesce-latest-wins on delta events, hard-drop on sustained overflow with an `error` event sent down the wire. -- **Mobile background reconnect.** When iOS backgrounds the app for 20 minutes, the WS dies. On resume: reopen + `sinceSeq` + replay. Should be free with this design, but worth an explicit test plan. -- **Provider credential scoping for renderer-direct connection.** The open question from `host-service-chat-architecture.md` still applies — if the renderer talks to host-service WS directly (P5a) or a DO directly (P5b), provider creds need to be scoped cleanly. Resolve before P1. -- **Handoff workspace state.** P6 uses git as the filesystem courier. Fine for committed state; what's our story for untracked files the agent wrote but hadn't committed? Options: auto-stash to a handoff branch, snapshot workspace to object storage, or accept the loss. Resolve before P6. - -## Summary - -tRPC subscriptions over the WebSocket we already have, wrapping an `EventLog` abstraction. `LocalEventLog` on SQLite for the local case. Two cloud options: **`PostgresEventLog` on Neon (P5a)** to stay on current vendors, or **Durable Objects (P5b)** to fold event log + subscription transport + session ownership + hibernation into one Cloudflare primitive at the cost of vendor lock-in. Per-session event streams with monotonic `seq`, `commandId`-keyed idempotent mutations, `replayEvents` on gap, and a Zustand reducer over `UIMessage` that applies events in order. Two subscription scopes — workspace for the sidebar, session for the open chat — each writing to a disjoint region of client state. On top of P5, a **P6 handoff flow** (turn-boundary, git as filesystem courier, on-demand cloud runtime) covers close-laptop-continue-on-phone. Mid-turn handoff is explicitly out of scope. Everything above is justification.