History

feat(orchestration): bound thread history and resume payloads

- Add paginated thread history with bounded snapshots and replay limits - Trim oversized wire payloads and support progressive mobile history loading

Julius Marminge committed 2 months ago

Commit 7d42d311 parent e3883a943 files · +4239 −211Browse files

Unified · Split

43 files
  1. apps/mobile/src/connection/runtime.ts +9 −2
  2. apps/mobile/src/features/threads/ThreadDetailScreen.tsx +4 −1
  3. apps/mobile/src/features/threads/ThreadFeed.tsx +48 −59
  4. apps/mobile/src/features/threads/ThreadRouteScreen.tsx +25 −0
  5. apps/mobile/src/state/use-thread-outbox-drain.ts +1 −0
  6. apps/server/src/orchestration-v2/ShellStream.test.ts +45 −0
  7. apps/server/src/orchestration-v2/ShellStream.ts +30 −5
  8. apps/server/src/orchestration-v2/ThreadStream.test.ts +75 −0
  9. apps/server/src/orchestration-v2/ThreadStream.ts +51 −0
  10. apps/server/src/orchestration-v2/WireProjection.test.ts +50 −0
  11. apps/server/src/orchestration-v2/WireProjection.ts +108 −0
  12. apps/server/src/orchestration-v2/http.ts +103 −13
  13. apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +473 −0
  14. apps/server/src/orchestration-v2/threadHistoryPaging.ts +442 −0
  15. apps/server/src/ws.ts +74 −30
  16. apps/web/src/components/ChatView.tsx +27 −1
  17. apps/web/src/components/chat/MessagesTimeline.test.tsx +39 −0
  18. apps/web/src/components/chat/MessagesTimeline.tsx +61 −10
  19. apps/web/src/connection/runtime.ts +6 −2
  20. apps/web/src/state/entities.ts +14 −1
  21. docs/internals/performance-regressions.md +30 −0
  22. package.json +1 −0
  23. packages/client-runtime/src/operations/commands.test.ts +72 −2
  24. packages/client-runtime/src/operations/commands.ts +25 −7
  25. packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +214 −0
  26. packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +156 −0
  27. packages/client-runtime/src/state/orchestrationV2Projection.ts +57 −1
  28. packages/client-runtime/src/state/shellReducer.test.ts +24 −0
  29. packages/client-runtime/src/state/shellReducer.ts +6 −6
  30. packages/client-runtime/src/state/threadCommands.ts +32 −0
  31. packages/client-runtime/src/state/threadDetail.test.ts +45 −20
  32. packages/client-runtime/src/state/threadDetail.ts +7 −0
  33. packages/client-runtime/src/state/threadHistoryController.test.ts +66 −0
  34. packages/client-runtime/src/state/threadHistoryController.ts +93 −0
  35. packages/client-runtime/src/state/threadHistoryHttp.ts +45 −0
  36. packages/client-runtime/src/state/threadHistoryMerge.test.ts +416 −0
  37. packages/client-runtime/src/state/threadHistoryMerge.ts +156 −0
  38. packages/client-runtime/src/state/threadSnapshotHttp.ts +42 −13
  39. packages/client-runtime/src/state/threadState.ts +9 −0
  40. packages/client-runtime/src/state/threads-sync.test.ts +601 −4
  41. packages/client-runtime/src/state/threads.ts +384 −34
  42. packages/contracts/src/environmentHttp.ts +33 −0
  43. packages/contracts/src/orchestrationV2.ts +40 −0
apps/mobile/src/connection/runtime.ts+9−2
@@ -1,6 +1,9 @@
1import { Connection } from "@t3tools/client-runtime/connection";1import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";3import {
4 boundedThreadSnapshotLoaderLayer,
5 threadHistoryControllerLayer,
6} from "@t3tools/client-runtime/state/threads";
4import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
5import { Atom } from "effect/unstable/reactivity";8import { Atom } from "effect/unstable/reactivity";
6 9
@@ -20,7 +23,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( · 13 unmodified lines
20 Layer.provide(runtimeContextLayer),23 Layer.provide(runtimeContextLayer),
21);24);
22 25
23const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);26const snapshotLoaderLayer = Layer.mergeAll(
27 boundedThreadSnapshotLoaderLayer,
28 shellSnapshotLoaderLayer,
29 threadHistoryControllerLayer,
30);
24 31
25type ConnectionLayerSource =32type ConnectionLayerSource =
26 | typeof Connection.layer33 | typeof Connection.layer
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4−1
@@ -79,7 +79,7 @@ import { · 78 unmodified lines
79 COMPOSER_EXPANDED_CHROME,79 COMPOSER_EXPANDED_CHROME,
80 ThreadComposer,80 ThreadComposer,
81} from "./ThreadComposer";81} from "./ThreadComposer";
82import { ThreadFeed } from "./ThreadFeed";82import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
83import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";83import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
84import { ThreadQueueControl } from "./ThreadQueueControl";84import { ThreadQueueControl } from "./ThreadQueueControl";
85import type { ThreadContentPresentation } from "./threadContentPresentation";85import type { ThreadContentPresentation } from "./threadContentPresentation";
@@ -104,6 +104,8 @@ export interface ThreadDetailScreenProps { · 18 unmodified lines
104 readonly connectionStateLabel: EnvironmentConnectionPhase;104 readonly connectionStateLabel: EnvironmentConnectionPhase;
105 /** Message sync status for the selected thread (drives the composer status pill). */105 /** Message sync status for the selected thread (drives the composer status pill). */
106 readonly threadSyncStatus?: EnvironmentThreadStatus;106 readonly threadSyncStatus?: EnvironmentThreadStatus;
107 /** Progressive history controls for oversized mobile thread opens. */
108 readonly historyControls?: ThreadFeedHistoryControls;
107 readonly activeThreadBusy: boolean;109 readonly activeThreadBusy: boolean;
108 readonly canStopThread: boolean;110 readonly canStopThread: boolean;
109 readonly environmentId: EnvironmentId;111 readonly environmentId: EnvironmentId;
@@ -650,6 +652,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 540 unmodified lines
650 contentTopInset={0}652 contentTopInset={0}
651 contentBottomInset={estimatedOverlayHeight}653 contentBottomInset={estimatedOverlayHeight}
652 contentMaxWidth={contentMaxWidth}654 contentMaxWidth={contentMaxWidth}
655 historyControls={props.historyControls}
653 topAccessory={656 topAccessory={
654 <ThreadRelationshipsBanner657 <ThreadRelationshipsBanner
655 environmentId={props.environmentId}658 environmentId={props.environmentId}
apps/mobile/src/features/threads/ThreadFeed.tsx+48−59
@@ -55,14 +55,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP · 54 unmodified lines
55import { isPdfFile } from "../../lib/filePreview";55import { isPdfFile } from "../../lib/filePreview";
56import { PresentationSource } from "../../components/NativePresentation";56import { PresentationSource } from "../../components/NativePresentation";
57import { useSafeAreaInsets } from "react-native-safe-area-context";57import { useSafeAreaInsets } from "react-native-safe-area-context";
58import Animated, {58import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
59 FadeIn,
60 FadeInUp,
61 useSharedValue,
62 withTiming,
63 type LayoutAnimationsValues,
64 type SharedValue,
65} from "react-native-reanimated";
66import { useThemeColor } from "../../lib/useThemeColor";59import { useThemeColor } from "../../lib/useThemeColor";
67import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";60import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
68import { useFontFamily } from "../../lib/useFontFamily";61import { useFontFamily } from "../../lib/useFontFamily";
@@ -167,14 +160,6 @@ function formatMessageTime(input: string): string { · 98 unmodified lines
167 return MESSAGE_TIME_FORMATTER.format(timestamp);160 return MESSAGE_TIME_FORMATTER.format(timestamp);
168}161}
169 162
170// Rows shift when content above them grows (streaming text, work-log folds);
171// animating the container position turns those jumps into slides. Applied
172// conditionally — see the gated transition in ThreadFeed: while browsing
173// history the animation must NOT run, or every estimate→actual size
174// correction plays as a visible slide against the instant scroll-offset
175// compensation from maintainVisibleContentPosition.
176const FEED_ITEM_LAYOUT_DURATION_MS = 180;
177
178// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's163// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
179// classNames. The fold row's min-h-11 (44px) stays taller than its single164// classNames. The fold row's min-h-11 (44px) stays taller than its single
180// text-sm line at every supported base font size (26px at the 22pt maximum),165// text-sm line at every supported base font size (26px at the 22pt maximum),
@@ -194,6 +179,13 @@ function isFreshTimestamp(input: string): boolean { · 13 unmodified lines
194 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;179 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
195}180}
196 181
182export interface ThreadFeedHistoryControls {
183 readonly hasMoreHistory: boolean;
184 readonly loading: boolean;
185 readonly error: string | null;
186 readonly onLoadEarlier: () => void;
187}
188
197export interface ThreadFeedProps {189export interface ThreadFeedProps {
198 readonly environmentId: EnvironmentId;190 readonly environmentId: EnvironmentId;
199 readonly threadId: ThreadId;191 readonly threadId: ThreadId;
@@ -210,6 +202,7 @@ export interface ThreadFeedProps { · 10 unmodified lines
210 readonly contentInsetEndAdjustment: SharedValue<number>;202 readonly contentInsetEndAdjustment: SharedValue<number>;
211 readonly contentTopInset?: number;203 readonly contentTopInset?: number;
212 readonly contentBottomInset?: number;204 readonly contentBottomInset?: number;
205 readonly historyControls?: ThreadFeedHistoryControls;
213 readonly topAccessory?: ReactNode;206 readonly topAccessory?: ReactNode;
214 readonly contentMaxWidth?: number;207 readonly contentMaxWidth?: number;
215 readonly layoutVariant?: LayoutVariant;208 readonly layoutVariant?: LayoutVariant;
@@ -1642,6 +1635,41 @@ function compactFileName(filePath: string): string { · 1426 unmodified lines
1642 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;1635 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
1643}1636}
1644 1637
1638function ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) {
1639 const mutedColor = useThemeColor("--color-icon-subtle");
1640 const accentColor = useThemeColor("--color-primary");
1641 if (!props.hasMoreHistory && props.error === null) {
1642 return null;
1643 }
1644 return (
1645 <View className="mb-3 items-center gap-1.5 px-2">
1646 {props.hasMoreHistory ? (
1647 <Pressable
1648 accessibilityRole="button"
1649 accessibilityLabel="Load earlier activity"
1650 disabled={props.loading}
1651 onPress={props.onLoadEarlier}
1652 className="min-h-9 flex-row items-center justify-center gap-2 rounded-full border border-border/60 bg-surface/80 px-4 py-2 disabled:opacity-50"
1653 >
1654 {props.loading ? (
1655 <ActivityIndicator size="small" color={accentColor} />
1656 ) : (
1657 <SymbolView name="chevron.up" size={12} tintColor={accentColor} type="monochrome" />
1658 )}
1659 <Text className="text-sm font-medium text-foreground">
1660 {props.loading ? "Loading earlier activity…" : "Load earlier activity"}
1661 </Text>
1662 </Pressable>
1663 ) : null}
1664 {props.error !== null ? (
1665 <Text className="text-center text-xs" style={{ color: mutedColor }}>
1666 {props.error}
1667 </Text>
1668 ) : null}
1669 </View>
1670 );
1671}
1672
1645function ThreadFeedPlaceholder(props: {1673function ThreadFeedPlaceholder(props: {
1646 readonly bottomInset: number;1674 readonly bottomInset: number;
1647 readonly detail: string;1675 readonly detail: string;
@@ -1891,11 +1919,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 243 unmodified lines
1891 },1919 },
1892 [props.onHeaderMaterialVisibilityChange],1920 [props.onHeaderMaterialVisibilityChange],
1893 );1921 );
1894 // True while the viewport sits within ~one screen of the list end — the
1895 // only region where layout shifts should animate. Starts true because the
1896 // list opens pinned to the end.
1897 const nearListEnd = useSharedValue(true);
1898
1899 const handleScroll = useCallback(1922 const handleScroll = useCallback(
1900 (event: NativeSyntheticEvent<NativeScrollEvent>) => {1923 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1901 // anchorTopInset, not topContentInset: under automatic insets the list1924 // anchorTopInset, not topContentInset: under automatic insets the list
@@ -1903,9 +1926,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
1903 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the1926 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
1904 // header height back or the material toggles a full header too late.1927 // header height back or the material toggles a full header too late.
1905 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);1928 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
1906 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
1907 nearListEnd.value =
1908 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
1909 // LegendList recomputes its inset-aware end distance before invoking1929 // LegendList recomputes its inset-aware end distance before invoking
1910 // this handler, so getState() is current. Only the actual end re-arms1930 // this handler, so getState() is current. Only the actual end re-arms
1911 // follow: its broader maintain-scroll threshold is large enough for a1931 // follow: its broader maintain-scroll threshold is large enough for a
@@ -1921,13 +1941,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 9 unmodified lines
1921 });1941 });
1922 }1942 }
1923 },1943 },
1924 [1944 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
1925 reportHeaderMaterialVisibility,
1926 anchorTopInset,
1927 nearListEnd,
1928 props.listRef,
1929 transitionEndFollow,
1930 ],
1931 );1945 );
1932 const clearUserScrollSettle = useCallback(() => {1946 const clearUserScrollSettle = useCallback(() => {
1933 if (userScrollSettleTimerRef.current !== null) {1947 if (userScrollSettleTimerRef.current !== null) {
@@ -1999,33 +2013,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 65 unmodified lines
1999 }2013 }
2000 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);2014 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
2001 2015
2002 // Gated variant of the 180ms feed layout slide. Instant while browsing
2003 // history: maintainVisibleContentPosition compensates the scroll offset in
2004 // the same frame a row's measured size lands, so an instant reposition is
2005 // invisible — animating it is exactly what made cold upward scrolls slide
2006 // and jump. Near the end the slide stays on: streaming growth and sends
2007 // shift rows at rest, where the animation is the thing preventing a hard
2008 // visual snap.
2009 const feedItemLayoutTransition = useMemo(() => {
2010 return (values: LayoutAnimationsValues) => {
2011 "worklet";
2012 const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0;
2013 return {
2014 initialValues: {
2015 originX: values.currentOriginX,
2016 originY: values.currentOriginY,
2017 width: values.currentWidth,
2018 height: values.currentHeight,
2019 },
2020 animations: {
2021 originX: withTiming(values.targetOriginX, { duration }),
2022 originY: withTiming(values.targetOriginY, { duration }),
2023 width: withTiming(values.targetWidth, { duration }),
2024 height: withTiming(values.targetHeight, { duration }),
2025 },
2026 };
2027 };
2028 }, [nearListEnd]);
2029 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {2016 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
2030 const nextWidth = Math.round(event.nativeEvent.layout.width);2017 const nextWidth = Math.round(event.nativeEvent.layout.width);
2031 const nextHeight = Math.round(event.nativeEvent.layout.height);2018 const nextHeight = Math.round(event.nativeEvent.layout.height);
@@ -2378,7 +2365,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 346 unmodified lines
2378 }2365 }
2379 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}2366 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
2380 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}2367 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2381 itemLayoutAnimation={feedItemLayoutTransition}
2382 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):2368 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
2383 // lets its scroll math clamp programmatic scrolls to -headerInset2369 // lets its scroll math clamp programmatic scrolls to -headerInset
2384 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short2370 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
@@ -2460,6 +2446,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 75 unmodified lines
2460 ListHeaderComponent={2446 ListHeaderComponent={
2461 <>2447 <>
2462 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}2448 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
2449 {props.historyControls ? (
2450 <ThreadFeedLoadEarlierControl {...props.historyControls} />
2451 ) : null}
2463 {props.topAccessory}2452 {props.topAccessory}
2464 </>2453 </>
2465 }2454 }
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+25−0
@@ -197,6 +197,30 @@ function ThreadRouteContent( · 196 unmodified lines
197 const gitActions = useSelectedThreadGitActions();197 const gitActions = useSelectedThreadGitActions();
198 const requests = useSelectedThreadRequests();198 const requests = useSelectedThreadRequests();
199 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");199 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
200 const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
201 label: "load earlier thread history",
202 reportFailure: false,
203 });
204 const historyControls = useMemo(() => {
205 const history = selectedThreadDetailState.history;
206 if (!selectedThread) {
207 return undefined;
208 }
209 if (!history.hasMoreHistory && history.error === null) {
210 return undefined;
211 }
212 return {
213 hasMoreHistory: history.hasMoreHistory,
214 loading: history.loading,
215 error: history.error,
216 onLoadEarlier: () => {
217 void loadEarlierHistory({
218 environmentId: selectedThread.environmentId,
219 input: { threadId: selectedThread.id },
220 });
221 },
222 };
223 }, [loadEarlierHistory, selectedThread, selectedThreadDetailState.history]);
200 const navigation = useNavigation();224 const navigation = useNavigation();
201 const params = props.route.params;225 const params = props.route.params;
202 const environmentIdRaw = firstRouteParam(params.environmentId);226 const environmentIdRaw = firstRouteParam(params.environmentId);
@@ -763,6 +787,7 @@ function ThreadRouteContent( · 560 unmodified lines
763 draftAttachments={composer.draftAttachments}787 draftAttachments={composer.draftAttachments}
764 connectionStateLabel={routeConnectionState}788 connectionStateLabel={routeConnectionState}
765 threadSyncStatus={selectedThreadDetailState.status}789 threadSyncStatus={selectedThreadDetailState.status}
790 historyControls={historyControls}
766 activeThreadBusy={composer.activeThreadBusy}791 activeThreadBusy={composer.activeThreadBusy}
767 canStopThread={composer.interruptibleRunId !== null}792 canStopThread={composer.interruptibleRunId !== null}
768 environmentId={selectedThread.environmentId}793 environmentId={selectedThread.environmentId}
apps/mobile/src/state/use-thread-outbox-drain.ts+1−0
@@ -750,6 +750,7 @@ export function useThreadOutboxDrain(): void { · 749 unmodified lines
750 runtimeMode: settings.runtimeMode,750 runtimeMode: settings.runtimeMode,
751 interactionMode: settings.interactionMode,751 interactionMode: settings.interactionMode,
752 createdAt: queuedMessage.createdAt,752 createdAt: queuedMessage.createdAt,
753 dispatchMode: "start",
753 },754 },
754 });755 });
755 const failure = reportFailure(deliveryResult, "start-turn");756 const failure = reportFailure(deliveryResult, "start-turn");
apps/server/src/orchestration-v2/ShellStream.test.ts+45−0
@@ -5,6 +5,7 @@ import type { · 4 unmodified lines
5 OrchestrationV2StoredEvent,5 OrchestrationV2StoredEvent,
6 OrchestrationV2ThreadShell,6 OrchestrationV2ThreadShell,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { ThreadId } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
10 11
@@ -16,6 +17,7 @@ import { · 5 unmodified lines
16 shellStreamItemFromEnrichmentRefresh,17 shellStreamItemFromEnrichmentRefresh,
17 shellStreamItemFromThreadShell,18 shellStreamItemFromThreadShell,
18 shellStreamItemsFromInitialSnapshot,19 shellStreamItemsFromInitialSnapshot,
20 shellStreamItemsFromResumeSnapshot,
19} from "./ShellStream.ts";21} from "./ShellStream.ts";
20 22
21function project(sequence: number, id: string): ApplicationStoredEvent {23function project(sequence: number, id: string): ApplicationStoredEvent {
@@ -198,6 +200,35 @@ describe("shellStreamItemFromEnrichmentRefresh", () => { · 176 unmodified lines
198});200});
199 201
200describe("shellStreamItemsFromInitialSnapshot", () => {202describe("shellStreamItemsFromInitialSnapshot", () => {
203 it("keeps thread rows out of the metadata-only enrichment frame", () => {
204 const snapshot = {
205 ...emptyShellSnapshot,
206 projects: [
207 { id: "project-a", workspaceRoot: "/workspace/a" },
208 { id: "project-b", workspaceRoot: "/workspace/b" },
209 ],
210 threads: [shellFixture({})],
211 archivedThreads: [shellFixture({ id: ThreadId.make("thread-archived"), archivedAt: null })],
212 } as unknown as OrchestrationV2ShellSnapshot;
213
214 const items = shellStreamItemsFromInitialSnapshot({
215 snapshot,
216 resolvedRepositoryIdentityRoots: ["/workspace/a"],
217 });
218
219 expect(items[0]).toEqual({ kind: "snapshot", snapshot });
220 expect(items[1]).toMatchObject({
221 kind: "snapshot",
222 snapshot: {
223 projects: [{ id: "project-a", workspaceRoot: "/workspace/a" }],
224 threads: [],
225 archivedThreads: [],
226 },
227 resolvedRepositoryIdentityRoots: ["/workspace/a"],
228 });
229 expect(JSON.stringify(items[1]).length).toBeLessThan(JSON.stringify(items[0]).length);
230 });
231
201 it("emits unmarked authoritative then same-sequence marked enrichment when roots resolved", () => {232 it("emits unmarked authoritative then same-sequence marked enrichment when roots resolved", () => {
202 const snapshot = {233 const snapshot = {
203 ...emptyShellSnapshot,234 ...emptyShellSnapshot,
@@ -229,6 +260,20 @@ describe("shellStreamItemsFromInitialSnapshot", () => { · 25 unmodified lines
229 });260 });
230});261});
231 262
263describe("shellStreamItemsFromResumeSnapshot", () => {
264 it("never repeats the authoritative shell snapshot", () => {
265 expect(
266 shellStreamItemsFromResumeSnapshot({
267 snapshot: {
268 ...emptyShellSnapshot,
269 threads: [shellFixture({})],
270 },
271 resolvedRepositoryIdentityRoots: [],
272 }),
273 ).toEqual([]);
274 });
275});
276
232describe("composeShellStreamWithEnrichment", () => {277describe("composeShellStreamWithEnrichment", () => {
233 it.effect(278 it.effect(
234 "emits every initial item before enrichment even when enrichment is already ready",279 "emits every initial item before enrichment even when enrichment is already ready",
apps/server/src/orchestration-v2/ShellStream.ts+30−5
@@ -43,12 +43,20 @@ export function shellStreamItemFromEnrichmentRefresh(input: { · 42 unmodified lines
43 readonly snapshot: OrchestrationV2ShellSnapshot;43 readonly snapshot: OrchestrationV2ShellSnapshot;
44 readonly changes: ReadonlyArray<{ readonly workspaceRoot: string }>;44 readonly changes: ReadonlyArray<{ readonly workspaceRoot: string }>;
45}): Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }> {45}): Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }> {
46 const resolvedRepositoryIdentityRoots = [
47 ...new Set(input.changes.map((change) => change.workspaceRoot)),
48 ];
46 return {49 return {
47 kind: "snapshot",50 kind: "snapshot",
48 snapshot: input.snapshot,51 snapshot: {
49 resolvedRepositoryIdentityRoots: [52 ...input.snapshot,
50 ...new Set(input.changes.map((change) => change.workspaceRoot)),53 projects: input.snapshot.projects.filter((project) =>
51 ],54 resolvedRepositoryIdentityRoots.includes(project.workspaceRoot),
55 ),
56 threads: [],
57 archivedThreads: [],
58 },
59 resolvedRepositoryIdentityRoots,
52 };60 };
53}61}
54 62
@@ -71,12 +79,29 @@ export function shellStreamItemsFromInitialSnapshot(input: { · 16 unmodified lines
71 authoritative,79 authoritative,
72 {80 {
73 kind: "snapshot" as const,81 kind: "snapshot" as const,
74 snapshot: input.snapshot,82 snapshot: {
83 ...input.snapshot,
84 projects: input.snapshot.projects.filter((project) =>
85 input.resolvedRepositoryIdentityRoots.includes(project.workspaceRoot),
86 ),
87 threads: [],
88 archivedThreads: [],
89 },
75 resolvedRepositoryIdentityRoots: [...new Set(input.resolvedRepositoryIdentityRoots)],90 resolvedRepositoryIdentityRoots: [...new Set(input.resolvedRepositoryIdentityRoots)],
76 },91 },
77 ];92 ];
78}93}
79 94
95/** Resume after HTTP/cache hydration with metadata only, never another full shell body. */
96export function shellStreamItemsFromResumeSnapshot(input: {
97 readonly snapshot: OrchestrationV2ShellSnapshot;
98 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
99}): ReadonlyArray<Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>> {
100 return shellStreamItemsFromInitialSnapshot(input).filter(
101 (item) => item.resolvedRepositoryIdentityRoots !== undefined,
102 );
103}
104
80/** Keep only the newest stored event per thread within a coalescing window. */105/** Keep only the newest stored event per thread within a coalescing window. */
81export function coalesceStoredThreadEvents(106export function coalesceStoredThreadEvents(
82 events: ReadonlyArray<OrchestrationV2StoredEvent>,107 events: ReadonlyArray<OrchestrationV2StoredEvent>,
apps/server/src/orchestration-v2/ThreadStream.test.ts+75−0
@@ -0,0 +1,75 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 decideThreadResume,
5 threadReplayEncodedBytes,
6 THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES,
7 THREAD_RESUME_MAX_REPLAY_EVENTS,
8} from "./ThreadStream.ts";
9
10describe("decideThreadResume", () => {
11 it("replays when the gap is zero", () => {
12 expect(
13 decideThreadResume({
14 afterSequence: 10,
15 highWater: 10,
16 replayEventCount: 0,
17 replayEncodedBytes: 0,
18 }),
19 ).toEqual({ mode: "replay", afterSequence: 10, throughSequence: 10 });
20 });
21
22 it("replays when the event count is within the bound", () => {
23 expect(
24 decideThreadResume({
25 afterSequence: 10,
26 highWater: 20_000,
27 replayEventCount: THREAD_RESUME_MAX_REPLAY_EVENTS,
28 replayEncodedBytes: THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES,
29 }),
30 ).toEqual({
31 mode: "replay",
32 afterSequence: 10,
33 throughSequence: 20_000,
34 });
35 });
36
37 it("falls back to a snapshot when the event count exceeds the bound", () => {
38 expect(
39 decideThreadResume({
40 afterSequence: 10,
41 highWater: 20_000,
42 replayEventCount: THREAD_RESUME_MAX_REPLAY_EVENTS + 1,
43 replayEncodedBytes: 1,
44 }),
45 ).toEqual({ mode: "snapshot" });
46 });
47
48 it("falls back to a snapshot when encoded replay bytes exceed the bound", () => {
49 expect(
50 decideThreadResume({
51 afterSequence: 10,
52 highWater: 11,
53 replayEventCount: 1,
54 replayEncodedBytes: THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES + 1,
55 }),
56 ).toEqual({ mode: "snapshot" });
57 });
58
59 it("falls back to a snapshot when the client cursor is ahead of the store", () => {
60 expect(
61 decideThreadResume({
62 afterSequence: 50,
63 highWater: 40,
64 replayEventCount: 0,
65 replayEncodedBytes: 0,
66 }),
67 ).toEqual({ mode: "snapshot" });
68 });
69
70 it("counts UTF-8 bytes across projected stream items", () => {
71 expect(threadReplayEncodedBytes([{ value: "a" }, { value: "🦊" }])).toBe(
72 Buffer.byteLength('{"value":"a"}', "utf8") + Buffer.byteLength('{"value":"🦊"}', "utf8"),
73 );
74 });
75});
apps/server/src/orchestration-v2/ThreadStream.ts+51−0
@@ -0,0 +1,51 @@
1/** Maximum number of reducer applications allowed during a thread resume. */
2export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128;
3
4/** Maximum encoded event JSON allowed during a thread resume. */
5export const THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES = 1_048_576;
6
7/** Encoded payload cost after events have been projected for the wire. */
8export function threadReplayEncodedBytes(items: ReadonlyArray<unknown>): number {
9 let total = 0;
10 for (const item of items) {
11 const encoded = JSON.stringify(item);
12 total += Buffer.byteLength(encoded ?? "", "utf8");
13 }
14 return total;
15}
16
17export type ThreadResumePlan =
18 | {
19 readonly mode: "replay";
20 readonly afterSequence: number;
21 readonly throughSequence: number;
22 }
23 | { readonly mode: "snapshot" };
24
25/**
26 * Decide whether a thread subscription should replay the event gap after the
27 * client's cursor or send a fresh snapshot instead.
28 *
29 * A client cursor above the high water mark is stale or invalid. Event count
30 * limits reducer churn while encoded bytes limit a small number of large
31 * updates. Either excess is cheaper to replace with one current snapshot.
32 */
33export function decideThreadResume(input: {
34 readonly afterSequence: number;
35 readonly highWater: number;
36 readonly replayEventCount: number;
37 readonly replayEncodedBytes: number;
38}): ThreadResumePlan {
39 if (
40 input.afterSequence > input.highWater ||
41 input.replayEventCount > THREAD_RESUME_MAX_REPLAY_EVENTS ||
42 input.replayEncodedBytes > THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES
43 ) {
44 return { mode: "snapshot" };
45 }
46 return {
47 mode: "replay",
48 afterSequence: input.afterSequence,
49 throughSequence: input.highWater,
50 };
51}
apps/server/src/orchestration-v2/WireProjection.test.ts+50−0
@@ -0,0 +1,50 @@
1import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4
5import { projectTurnItemForWire } from "./WireProjection.ts";
6
7const base = {
8 id: TurnItemId.make("tool-1"),
9 type: "dynamic_tool" as const,
10 threadId: ThreadId.make("thread-1"),
11 runId: null,
12 nodeId: null,
13 providerThreadId: null,
14 providerTurnId: null,
15 nativeItemRef: null,
16 parentItemId: null,
17 ordinal: 1,
18 status: "completed" as const,
19 title: "MCP tool",
20 toolName: "mcp__github__fetch_pr",
21 input: { pr: 42 },
22 startedAt: DateTime.makeUnsafe("2026-08-13T00:00:00.000Z"),
23 completedAt: DateTime.makeUnsafe("2026-08-13T00:00:01.000Z"),
24 updatedAt: DateTime.makeUnsafe("2026-08-13T00:00:01.000Z"),
25};
26
27describe("orchestration V2 wire projection", () => {
28 it("summarizes oversized dynamic tool results without mutating persistence data", () => {
29 const output = { content: [{ type: "text", text: "first line\n" + "x".repeat(100_000) }] };
30 const item = { ...base, output } satisfies OrchestrationV2TurnItem;
31 const projected = projectTurnItemForWire(item);
32
33 expect(item.output).toBe(output);
34 expect(projected).not.toBe(item);
35 expect(JSON.stringify(projected).length).toBeLessThan(2_000);
36 expect(projected.type === "dynamic_tool" ? projected.output : null).toMatchObject({
37 truncated: true,
38 });
39 });
40
41 it("keeps small dynamic tool values intact", () => {
42 const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem;
43 expect(projectTurnItemForWire(item)).toEqual(item);
44 });
45
46 it("keeps undefined dynamic input intact", () => {
47 const item = { ...base, input: undefined } satisfies OrchestrationV2TurnItem;
48 expect(projectTurnItemForWire(item)).toEqual(item);
49 });
50});
apps/server/src/orchestration-v2/WireProjection.ts+108−0
@@ -0,0 +1,108 @@
1import type {
2 OrchestrationV2DomainEvent,
3 OrchestrationV2ThreadProjection,
4 OrchestrationV2TurnItem,
5} from "@t3tools/contracts";
6
7const MAX_DETAIL_STRING_BYTES = 32_768;
8const MAX_DYNAMIC_VALUE_BYTES = 16_384;
9
10function encodedBytes(value: unknown): number {
11 try {
12 const serialized = JSON.stringify(value);
13 return Buffer.byteLength(serialized ?? String(value), "utf8");
14 } catch {
15 return Number.POSITIVE_INFINITY;
16 }
17}
18
19function truncateDetail(value: string | undefined): string | undefined {
20 if (value === undefined || Buffer.byteLength(value, "utf8") <= MAX_DETAIL_STRING_BYTES) {
21 return value;
22 }
23 const prefix = Buffer.from(value, "utf8")
24 .subarray(0, MAX_DETAIL_STRING_BYTES)
25 .toString("utf8")
26 .replace(/\uFFFD$/u, "");
27 return `${prefix}\n… output truncated for transport`;
28}
29
30function summarizeDynamicValue(value: unknown): unknown {
31 if (encodedBytes(value) <= MAX_DYNAMIC_VALUE_BYTES) {
32 return value;
33 }
34 let serialized: string;
35 try {
36 serialized = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
37 } catch {
38 serialized = "Unserializable tool output";
39 }
40 const firstLine =
41 serialized
42 .split(/\r?\n/u)
43 .map((line) => line.replace(/\s+/gu, " ").trim())
44 .find((line) => line.length > 0) ?? "Large tool output";
45 return {
46 summary: firstLine.length <= 160 ? firstLine : `${firstLine.slice(0, 159).trimEnd()}…`,
47 truncated: true,
48 };
49}
50
51export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
52 switch (item.type) {
53 case "command_execution":
54 return { ...item, output: truncateDetail(item.output) };
55 case "file_change":
56 return {
57 ...item,
58 diffStr: truncateDetail(item.diffStr),
59 oldStr: truncateDetail(item.oldStr),
60 newStr: truncateDetail(item.newStr),
61 };
62 case "subagent":
63 return {
64 ...item,
65 prompt: truncateDetail(item.prompt) ?? "",
66 progress: truncateDetail(item.progress),
67 result: item.result === null ? null : (truncateDetail(item.result) ?? null),
68 };
69 case "dynamic_tool":
70 return {
71 ...item,
72 input: summarizeDynamicValue(item.input),
73 ...(item.output === undefined ? {} : { output: summarizeDynamicValue(item.output) }),
74 };
75 default:
76 return item;
77 }
78}
79
80export function projectThreadProjectionForWire(
81 projection: OrchestrationV2ThreadProjection,
82): OrchestrationV2ThreadProjection {
83 const projectedById = new Map<string, OrchestrationV2TurnItem>();
84 const project = (item: OrchestrationV2TurnItem) => {
85 const key = `${item.threadId}:${item.id}`;
86 const existing = projectedById.get(key);
87 if (existing !== undefined) return existing;
88 const projected = projectTurnItemForWire(item);
89 projectedById.set(key, projected);
90 return projected;
91 };
92 return {
93 ...projection,
94 turnItems: projection.turnItems.map(project),
95 visibleTurnItems: projection.visibleTurnItems.map((row) => ({
96 ...row,
97 item: project(row.item),
98 })),
99 };
100}
101
102export function projectDomainEventForWire(
103 event: OrchestrationV2DomainEvent,
104): OrchestrationV2DomainEvent {
105 return event.type === "turn-item.updated"
106 ? { ...event, payload: projectTurnItemForWire(event.payload) }
107 : event;
108}
apps/server/src/orchestration-v2/http.ts+103−13
@@ -11,13 +11,20 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; · 10 unmodified lines
11import {11import {
12 annotateEnvironmentRequest,12 annotateEnvironmentRequest,
13 failEnvironmentInternal,13 failEnvironmentInternal,
14 failEnvironmentInvalidRequest,
14 failEnvironmentNotFound,15 failEnvironmentNotFound,
15 requireEnvironmentScope,16 requireEnvironmentScope,
16} from "../auth/http.ts";17} from "../auth/http.ts";
17import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";18import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
18import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";19import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
19import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";20import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
21import {
22 buildBoundedThreadProjection,
23 InvalidThreadHistoryCursorError,
24 selectHistoryPageFromCursor,
25} from "./threadHistoryPaging.ts";
20import * as ThreadManagementService from "./ThreadManagementService.ts";26import * as ThreadManagementService from "./ThreadManagementService.ts";
27import { projectThreadProjectionForWire } from "./WireProjection.ts";
21 28
22function isThreadNotFound(error: unknown): boolean {29function isThreadNotFound(error: unknown): boolean {
23 return (30 return (
@@ -27,9 +34,25 @@ function isThreadNotFound(error: unknown): boolean { · 3 unmodified lines
27 );34 );
28}35}
29 36
37function selectHistoryPageFromCursorOrError(
38 input: Parameters<typeof selectHistoryPageFromCursor>[0],
39):
40 | { readonly _tag: "ok"; readonly page: ReturnType<typeof selectHistoryPageFromCursor> }
41 | { readonly _tag: "invalid_cursor" }
42 | { readonly _tag: "error"; readonly cause: unknown } {
43 try {
44 return { _tag: "ok", page: selectHistoryPageFromCursor(input) };
45 } catch (cause) {
46 if (cause instanceof InvalidThreadHistoryCursorError) {
47 return { _tag: "invalid_cursor" };
48 }
49 return { _tag: "error", cause };
50 }
51}
52
30/**53/**
31 * Serves orchestration V2 snapshots over HTTP so clients can load the54 * Serves orchestration V2 snapshots over HTTP so clients can load the
32 * (potentially large) shell and thread projections off the socket — gzip55 * (potentially large) shell and thread projections off the socket; gzip
33 * compressible and cacheable — and then resume the WebSocket subscription via56 * compressible and cacheable — and then resume the WebSocket subscription via
34 * `afterSequence`.57 * `afterSequence`.
35 */58 */
@@ -80,6 +103,29 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 44 unmodified lines
80 return { ...base, projects };103 return { ...base, projects };
81 });104 });
82 105
106 const loadThreadSnapshot = Effect.fn("http.orchestration.loadThreadSnapshot")(function* (
107 threadId: Parameters<typeof threadManagement.getThreadSnapshot>[0],
108 failureReason:
109 | "orchestration_thread_snapshot_failed"
110 | "orchestration_thread_bounded_snapshot_failed"
111 | "orchestration_thread_history_failed",
112 ) {
113 return yield* threadManagement.getThreadSnapshot(threadId).pipe(
114 Effect.map((snapshot) => ({
115 ...snapshot,
116 projection: projectThreadProjectionForWire(snapshot.projection),
117 })),
118 Effect.catch(
119 Effect.fnUntraced(function* (error) {
120 if (isThreadNotFound(error)) {
121 return yield* failEnvironmentNotFound("thread_not_found");
122 }
123 return yield* failEnvironmentInternal(failureReason, error);
124 }),
125 ),
126 );
127 });
128
83 return handlers129 return handlers
84 .handle(130 .handle(
85 "shellSnapshot",131 "shellSnapshot",
@@ -98,24 +144,68 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 12 unmodified lines
98 Effect.fn("environment.orchestration.threadSnapshot")(function* (args) {144 Effect.fn("environment.orchestration.threadSnapshot")(function* (args) {
99 yield* annotateEnvironmentRequest(args.endpoint.name);145 yield* annotateEnvironmentRequest(args.endpoint.name);
100 yield* requireEnvironmentScope(AuthOrchestrationReadScope);146 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
101 const snapshot = yield* threadManagement.getThreadSnapshot(args.params.threadId).pipe(147 const snapshot = yield* loadThreadSnapshot(
102 Effect.catch(148 args.params.threadId,
103 Effect.fnUntraced(function* (error) {149 "orchestration_thread_snapshot_failed",
104 if (isThreadNotFound(error)) {
105 return yield* failEnvironmentNotFound("thread_not_found");
106 }
107 return yield* failEnvironmentInternal(
108 "orchestration_thread_snapshot_failed",
109 error,
110 );
111 }),
112 ),
113 );150 );
114 return {151 return {
115 snapshotSequence: snapshot.snapshotSequence,152 snapshotSequence: snapshot.snapshotSequence,
116 projection: snapshot.projection,153 projection: snapshot.projection,
117 };154 };
118 }),155 }),
156 )
157 .handle(
158 "threadBoundedSnapshot",
159 Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) {
160 yield* annotateEnvironmentRequest(args.endpoint.name);
161 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
162 const snapshot = yield* loadThreadSnapshot(
163 args.params.threadId,
164 "orchestration_thread_bounded_snapshot_failed",
165 );
166 const bounded = buildBoundedThreadProjection({
167 projection: snapshot.projection,
168 snapshotSequence: snapshot.snapshotSequence,
169 });
170 return {
171 snapshotSequence: snapshot.snapshotSequence,
172 projection: bounded.projection,
173 historyCursor: bounded.historyCursor,
174 hasMoreHistory: bounded.hasMoreHistory,
175 latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal,
176 };
177 }),
178 )
179 .handle(
180 "threadHistoryPage",
181 Effect.fn("environment.orchestration.threadHistoryPage")(function* (args) {
182 yield* annotateEnvironmentRequest(args.endpoint.name);
183 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
184 const snapshot = yield* loadThreadSnapshot(
185 args.params.threadId,
186 "orchestration_thread_history_failed",
187 );
188 const pageOrError = selectHistoryPageFromCursorOrError({
189 items: snapshot.projection.visibleTurnItems,
190 cursor: args.query.cursor,
191 snapshotSequence: snapshot.snapshotSequence,
192 });
193 if (pageOrError._tag === "invalid_cursor") {
194 return yield* failEnvironmentInvalidRequest("invalid_history_cursor");
195 }
196 if (pageOrError._tag === "error") {
197 return yield* failEnvironmentInternal(
198 "orchestration_thread_history_failed",
199 pageOrError.cause,
200 );
201 }
202 return {
203 snapshotSequence: snapshot.snapshotSequence,
204 items: pageOrError.page.items,
205 nextCursor: pageOrError.page.nextCursor,
206 hasMoreHistory: pageOrError.page.hasMoreHistory,
207 };
208 }),
119 );209 );
120 }),210 }),
121);211);
apps/server/src/orchestration-v2/threadHistoryPaging.test.ts+473−0
@@ -0,0 +1,473 @@
1import {
2 MessageId,
3 NodeId,
4 RunId,
5 ThreadId,
6 TurnItemId,
7 type OrchestrationV2ProjectedTurnItem,
8 type OrchestrationV2TurnItem,
9} from "@t3tools/contracts";
10import { describe, expect, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";
12
13import {
14 buildBoundedThreadProjection,
15 boundedTimelineEncodedBytes,
16 computeLatestLocalTurnOrdinal,
17 decodeThreadHistoryCursor,
18 encodeThreadHistoryCursor,
19 InvalidThreadHistoryCursorError,
20 projectedRowBoundedSnapshotEncodedBytes,
21 projectedRowEncodedBytes,
22 selectHistoryPageFromCursor,
23 selectRecentTimelineWindow,
24 THREAD_HISTORY_CURSOR_MAX_LENGTH,
25 THREAD_HISTORY_PAGE_POLICY,
26} from "./threadHistoryPaging.ts";
27
28const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
29const THREAD = ThreadId.make("thread-1");
30const RUN = RunId.make("run-interrupt-1");
31const NODE = NodeId.make("node-interrupt-1");
32
33function makeRow(
34 index: number,
35 options?: { readonly outputBytes?: number },
36): OrchestrationV2ProjectedTurnItem {
37 const id = TurnItemId.make(`item-${index}`);
38 const output =
39 options?.outputBytes !== undefined ? "x".repeat(options.outputBytes) : `output-${index}`;
40 return {
41 position: index,
42 visibility: "local",
43 sourceThreadId: THREAD,
44 sourceItemId: id,
45 item: {
46 id,
47 type: "command_execution",
48 threadId: THREAD,
49 runId: null,
50 nodeId: null,
51 providerThreadId: null,
52 providerTurnId: null,
53 nativeItemRef: null,
54 parentItemId: null,
55 ordinal: index + 1,
56 status: "completed",
57 title: `Command ${index}`,
58 input: `cmd-${index}`,
59 output,
60 exitCode: 0,
61 startedAt: NOW,
62 completedAt: NOW,
63 updatedAt: NOW,
64 },
65 };
66}
67
68function interruptItem(
69 id: string,
70 type: "run_interrupt_request" | "run_interrupt_result",
71 ordinal: number,
72): OrchestrationV2TurnItem {
73 return {
74 id: TurnItemId.make(id),
75 type,
76 threadId: THREAD,
77 runId: RUN,
78 nodeId: NODE,
79 providerThreadId: null,
80 providerTurnId: null,
81 nativeItemRef: null,
82 parentItemId: null,
83 ordinal,
84 status: "completed",
85 title: type,
86 message: type === "run_interrupt_request" ? "Stopping" : "Stopped",
87 startedAt: NOW,
88 completedAt: NOW,
89 updatedAt: NOW,
90 } as OrchestrationV2TurnItem;
91}
92
93function interruptRow(
94 index: number,
95 type: "run_interrupt_request" | "run_interrupt_result",
96): OrchestrationV2ProjectedTurnItem {
97 const item = interruptItem(
98 type === "run_interrupt_request" ? "interrupt-req" : "interrupt-res",
99 type,
100 index + 1,
101 );
102 return {
103 position: index,
104 visibility: "local",
105 sourceThreadId: THREAD,
106 sourceItemId: item.id,
107 item,
108 };
109}
110
111function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) {
112 return {
113 thread: {
114 id: THREAD,
115 projectId: "project-1",
116 title: "Thread",
117 providerInstanceId: "codex",
118 modelSelection: { instanceId: "codex", model: "gpt-5" },
119 runtimeMode: "full-access",
120 interactionMode: "default",
121 branch: null,
122 worktreePath: null,
123 activeProviderThreadId: null,
124 lineage: {
125 parentThreadId: null,
126 relationshipToParent: null,
127 rootThreadId: THREAD,
128 },
129 forkedFrom: null,
130 createdBy: "user",
131 creationSource: "web",
132 createdAt: NOW,
133 updatedAt: NOW,
134 archivedAt: null,
135 deletedAt: null,
136 settledOverride: null,
137 settledAt: null,
138 },
139 runs: [{ id: "run-1" }],
140 attempts: [{ id: "attempt-1" }],
141 nodes: [],
142 subagents: [],
143 providerSessions: [],
144 providerThreads: [],
145 providerTurns: [],
146 runtimeRequests: [{ id: "req-1" }],
147 messages: [],
148 plans: [],
149 turnItems: visibleTurnItems.map((row) => row.item),
150 checkpointScopes: [],
151 checkpoints: [],
152 contextHandoffs: [],
153 contextTransfers: [],
154 visibleTurnItems,
155 updatedAt: NOW,
156 } as unknown as Parameters<typeof buildBoundedThreadProjection>[0]["projection"];
157}
158
159describe("threadHistoryPaging", () => {
160 it("encodes opaque cursors with stable source identity", () => {
161 const cursor = encodeThreadHistoryCursor({
162 snapshotSequence: 9,
163 sourceThreadId: THREAD,
164 sourceItemId: TurnItemId.make("item-3"),
165 position: 3,
166 });
167 expect(cursor.includes("{")).toBe(false);
168 expect(decodeThreadHistoryCursor(cursor)).toEqual({
169 v: 1,
170 seq: 9,
171 st: "thread-1",
172 si: "item-3",
173 p: 3,
174 });
175 });
176
177 it("rejects malformed cursors", () => {
178 expect(() => decodeThreadHistoryCursor("not-valid")).toThrow(InvalidThreadHistoryCursorError);
179 expect(() =>
180 decodeThreadHistoryCursor(Buffer.from("{}", "utf8").toString("base64url")),
181 ).toThrow(InvalidThreadHistoryCursorError);
182 });
183
184 it("rejects cursors longer than the maximum length before decoding", () => {
185 const oversized = "A".repeat(THREAD_HISTORY_CURSOR_MAX_LENGTH + 1);
186 expect(() => decodeThreadHistoryCursor(oversized)).toThrow(InvalidThreadHistoryCursorError);
187 });
188
189 it("selects a recent window by item count", () => {
190 const items = Array.from({ length: 120 }, (_, index) => makeRow(index));
191 const page = selectRecentTimelineWindow({
192 items,
193 snapshotSequence: 4,
194 policy: { maxItems: 10, maxEncodedBytes: 10_000_000 },
195 });
196 expect(page.items).toHaveLength(10);
197 expect(page.items[0]?.sourceItemId).toBe("item-110");
198 expect(page.items[9]?.sourceItemId).toBe("item-119");
199 expect(page.hasMoreHistory).toBe(true);
200 expect(page.nextCursor).not.toBeNull();
201 const cursor = decodeThreadHistoryCursor(page.nextCursor!);
202 expect(cursor.si).toBe("item-110");
203 expect(cursor.seq).toBe(4);
204 });
205
206 it("always includes at least one pathological oversized item", () => {
207 const items = [makeRow(0, { outputBytes: 2_000_000 }), makeRow(1, { outputBytes: 10 })];
208 const page = selectRecentTimelineWindow({
209 items,
210 snapshotSequence: 1,
211 policy: { maxItems: 50, maxEncodedBytes: 1_024 },
212 });
213 expect(page.items).toHaveLength(1);
214 expect(page.items[0]?.sourceItemId).toBe("item-1");
215 expect(page.hasMoreHistory).toBe(true);
216
217 const older = selectHistoryPageFromCursor({
218 items,
219 cursor: page.nextCursor!,
220 snapshotSequence: 1,
221 policy: { maxItems: 50, maxEncodedBytes: 1_024 },
222 });
223 expect(older.items).toHaveLength(1);
224 expect(older.items[0]?.sourceItemId).toBe("item-0");
225 expect(older.hasMoreHistory).toBe(false);
226 expect(older.nextCursor).toBeNull();
227 });
228
229 it("recovers identity-miss cursors when position is past a shrunken timeline", () => {
230 const items = Array.from({ length: 5 }, (_, index) => makeRow(index));
231 const cursor = encodeThreadHistoryCursor({
232 snapshotSequence: 3,
233 sourceThreadId: THREAD,
234 sourceItemId: TurnItemId.make("item-deleted-long-ago"),
235 position: 40,
236 });
237 const page = selectHistoryPageFromCursor({
238 items,
239 cursor,
240 snapshotSequence: 3,
241 policy: { maxItems: 2, maxEncodedBytes: 10_000_000 },
242 });
243 // Clamp exclusive end to items.length and page backward so the client can
244 // dedupe and advance rather than permanently 400 on a dead cursor.
245 expect(page.items.map((row) => row.sourceItemId)).toEqual(["item-3", "item-4"]);
246 expect(page.hasMoreHistory).toBe(true);
247 expect(page.nextCursor).not.toBeNull();
248 });
249
250 it("keeps cursor resolution stable when newer items append", () => {
251 const initial = Array.from({ length: 20 }, (_, index) => makeRow(index));
252 const recent = selectRecentTimelineWindow({
253 items: initial,
254 snapshotSequence: 2,
255 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
256 });
257 const cursor = recent.nextCursor!;
258 const grown = [...initial, makeRow(20), makeRow(21)];
259 const older = selectHistoryPageFromCursor({
260 items: grown,
261 cursor,
262 snapshotSequence: 5,
263 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
264 });
265 expect(older.items.map((row) => row.sourceItemId)).toEqual([
266 "item-10",
267 "item-11",
268 "item-12",
269 "item-13",
270 "item-14",
271 ]);
272 expect(older.hasMoreHistory).toBe(true);
273 });
274
275 it("builds a bounded projection that preserves non-timeline control state", () => {
276 const full = makeProjection(Array.from({ length: 40 }, (_, index) => makeRow(index)));
277 const bounded = buildBoundedThreadProjection({
278 projection: full,
279 snapshotSequence: 7,
280 policy: { maxItems: 5, maxEncodedBytes: 10_000_000 },
281 });
282 expect(bounded.projection.runs).toEqual(full.runs);
283 expect(bounded.projection.runtimeRequests).toEqual(full.runtimeRequests);
284 expect(bounded.projection.attempts).toEqual(full.attempts);
285 expect(bounded.projection.visibleTurnItems).toHaveLength(5);
286 expect(bounded.projection.turnItems).toHaveLength(5);
287 expect(bounded.hasMoreHistory).toBe(true);
288 expect(bounded.historyCursor).not.toBeNull();
289 expect(bounded.projection.visibleTurnItems.map((row) => row.position)).toEqual([0, 1, 2, 3, 4]);
290 // Watermark comes from the full projection, not the trimmed window.
291 expect(bounded.latestLocalTurnOrdinal).toBe(40);
292 expect(computeLatestLocalTurnOrdinal(full.turnItems)).toBe(40);
293 });
294
295 it("carries a full-projection watermark when the bounded window is inherited-only", () => {
296 const localOlder = makeRow(0);
297 const localMid = makeRow(1);
298 const inherited = {
299 ...makeRow(2),
300 visibility: "inherited" as const,
301 sourceThreadId: ThreadId.make("parent-thread"),
302 item: {
303 ...makeRow(2).item,
304 threadId: ThreadId.make("parent-thread"),
305 },
306 };
307 // Full projection still has local turnItems even if the recent window is
308 // only the inherited row (e.g. after aggressive byte budget on a large
309 // local item that is not selected into the recent page). Use a synthetic
310 // projection with local turnItems and inherited-only visible window.
311 const full = makeProjection([localOlder, localMid, inherited]);
312 // Force an inherited-only visible window by rebuilding with only inherited
313 // visible rows while retaining full local turnItems for the watermark.
314 const inheritedOnly = {
315 ...full,
316 visibleTurnItems: [inherited],
317 turnItems: [localOlder.item, localMid.item],
318 };
319 const bounded = buildBoundedThreadProjection({
320 projection: inheritedOnly,
321 snapshotSequence: 2,
322 policy: { maxItems: 1, maxEncodedBytes: 10_000_000 },
323 });
324 expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([
325 String(inherited.sourceItemId),
326 ]);
327 expect(bounded.latestLocalTurnOrdinal).toBe(2);
328 expect(computeLatestLocalTurnOrdinal([])).toBeNull();
329 });
330
331 it("retains an out-of-window run_interrupt_request needed by a visible result", () => {
332 // Older request sits outside a 1-row recent window; result is visible alone.
333 const request = interruptRow(0, "run_interrupt_request");
334 const filler = makeRow(1);
335 const result = interruptRow(2, "run_interrupt_result");
336 const full = makeProjection([request, filler, result]);
337 const bounded = buildBoundedThreadProjection({
338 projection: full,
339 snapshotSequence: 3,
340 policy: { maxItems: 1, maxEncodedBytes: 10_000_000 },
341 });
342
343 expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([
344 "interrupt-res",
345 ]);
346 expect(bounded.projection.turnItems.map((item) => String(item.id)).sort()).toEqual([
347 "interrupt-req",
348 "interrupt-res",
349 ]);
350 expect(bounded.hasMoreHistory).toBe(true);
351 });
352
353 it("retains all run_interrupt_request items even when no result is in the initial window", () => {
354 // Cold open: recent window is only filler. Request and result both live on
355 // older pages, but the request must still ride in turnItems so a later
356 // history page can introduce the result safely under live attempt updates.
357 const request = interruptRow(0, "run_interrupt_request");
358 const result = interruptRow(1, "run_interrupt_result");
359 const recent = makeRow(2);
360 const full = makeProjection([request, result, recent]);
361 const bounded = buildBoundedThreadProjection({
362 projection: full,
363 snapshotSequence: 4,
364 policy: { maxItems: 1, maxEncodedBytes: 10_000_000 },
365 });
366
367 expect(bounded.projection.visibleTurnItems.map((row) => String(row.sourceItemId))).toEqual([
368 "item-2",
369 ]);
370 expect(bounded.projection.turnItems.map((item) => String(item.id)).sort()).toEqual([
371 "interrupt-req",
372 "item-2",
373 ]);
374 expect(bounded.projection.turnItems.some((item) => item.type === "run_interrupt_request")).toBe(
375 true,
376 );
377 expect(bounded.projection.turnItems.some((item) => item.type === "run_interrupt_result")).toBe(
378 false,
379 );
380 });
381
382 it("charges local row.item duplication when measuring bounded timeline bytes", () => {
383 const rows = Array.from({ length: 8 }, (_, index) => makeRow(index, { outputBytes: 2_000 }));
384 const full = makeProjection(rows);
385 const maxEncodedBytes = 12_000;
386 const bounded = buildBoundedThreadProjection({
387 projection: full,
388 snapshotSequence: 1,
389 policy: { maxItems: 50, maxEncodedBytes },
390 });
391
392 const contribution = boundedTimelineEncodedBytes({
393 visibleTurnItems: bounded.projection.visibleTurnItems,
394 turnItems: bounded.projection.turnItems,
395 });
396 expect(contribution).toBeLessThanOrEqual(maxEncodedBytes);
397 expect(bounded.projection.visibleTurnItems.length).toBeGreaterThan(0);
398 expect(bounded.projection.visibleTurnItems.length).toBeLessThan(rows.length);
399
400 // History pages only charge the projected row once (no turnItems copy).
401 const historyPage = selectRecentTimelineWindow({
402 items: rows,
403 snapshotSequence: 1,
404 policy: { maxItems: 50, maxEncodedBytes },
405 });
406 const historyBytes = historyPage.items.reduce(
407 (sum, row) => sum + projectedRowEncodedBytes(row),
408 0,
409 );
410 expect(historyBytes).toBeLessThanOrEqual(maxEncodedBytes);
411 expect(historyPage.items.length).toBeGreaterThan(bounded.projection.visibleTurnItems.length);
412
413 // Dual-cost for one local row exceeds the plain row cost.
414 const sample = rows[0]!;
415 expect(projectedRowBoundedSnapshotEncodedBytes(sample, THREAD)).toBeGreaterThan(
416 projectedRowEncodedBytes(sample),
417 );
418 });
419
420 it("allows a single pathological bounded row to exceed the configured cap", () => {
421 const huge = makeRow(0, { outputBytes: 50_000 });
422 const full = makeProjection([huge]);
423 const maxEncodedBytes = 1_000;
424 const bounded = buildBoundedThreadProjection({
425 projection: full,
426 snapshotSequence: 1,
427 policy: { maxItems: 10, maxEncodedBytes },
428 });
429 expect(bounded.projection.visibleTurnItems).toHaveLength(1);
430 const contribution = boundedTimelineEncodedBytes({
431 visibleTurnItems: bounded.projection.visibleTurnItems,
432 turnItems: bounded.projection.turnItems,
433 });
434 expect(contribution).toBeGreaterThan(maxEncodedBytes);
435 });
436
437 it("exposes conservative default page budgets", () => {
438 expect(THREAD_HISTORY_PAGE_POLICY.maxItems).toBeGreaterThanOrEqual(50);
439 expect(THREAD_HISTORY_PAGE_POLICY.maxItems).toBeLessThanOrEqual(100);
440 expect(THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes).toBe(1_048_576);
441 });
442
443 it("does not carry the full duplicated message table into a bounded snapshot", () => {
444 const projection = makeProjection(Array.from({ length: 120 }, (_, index) => makeRow(index)));
445 const full = {
446 ...projection,
447 messages: Array.from({ length: 2_000 }, (_, index) => ({
448 id: MessageId.make(`message-${index}`),
449 threadId: THREAD,
450 runId: RunId.make(`old-run-${index}`),
451 nodeId: null,
452 role: "assistant" as const,
453 text: "x".repeat(1_000),
454 attachments: [],
455 streaming: false,
456 createdBy: "agent" as const,
457 creationSource: "provider" as const,
458 createdAt: NOW,
459 updatedAt: NOW,
460 })),
461 } as typeof projection;
462
463 const bounded = buildBoundedThreadProjection({
464 projection: full,
465 snapshotSequence: 9,
466 });
467
468 expect(bounded.projection.messages).toEqual([]);
469 expect(Buffer.byteLength(JSON.stringify(bounded.projection), "utf8")).toBeLessThan(
470 THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes + 100_000,
471 );
472 });
473});
apps/server/src/orchestration-v2/threadHistoryPaging.ts+442−0
@@ -0,0 +1,442 @@
1import type {
2 OrchestrationV2ProjectedTurnItem,
3 OrchestrationV2ThreadProjection,
4 OrchestrationV2TurnItem,
5 ThreadId,
6 TurnItemId,
7} from "@t3tools/contracts";
8
9/**
10 * Centralized page budgets for progressive client hydration. Sized for relay
11 * cold opens: dozens of rows and about 1 MiB of encoded JSON per page.
12 */
13export const THREAD_HISTORY_PAGE_POLICY = {
14 maxItems: 75,
15 maxEncodedBytes: 1_048_576,
16} as const;
17
18/** Reject absurd cursors before base64 work or JSON parse. */
19export const THREAD_HISTORY_CURSOR_MAX_LENGTH = 4_096;
20
21export type ThreadHistoryPagePolicy = {
22 readonly maxItems: number;
23 readonly maxEncodedBytes: number;
24};
25
26export type ThreadHistoryCursorPayload = {
27 readonly v: 1;
28 readonly seq: number;
29 readonly st: string;
30 readonly si: string;
31 readonly p: number;
32};
33
34export type SelectTimelinePageResult = {
35 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
36 readonly nextCursor: string | null;
37 readonly hasMoreHistory: boolean;
38};
39
40export type BoundedProjectionResult = {
41 readonly projection: OrchestrationV2ThreadProjection;
42 readonly historyCursor: string | null;
43 readonly hasMoreHistory: boolean;
44 /** Max ordinal across the authoritative full projection's local turnItems. */
45 readonly latestLocalTurnOrdinal: number | null;
46};
47
48export class InvalidThreadHistoryCursorError extends Error {
49 readonly _tag = "InvalidThreadHistoryCursorError";
50 constructor(message = "Invalid thread history cursor.") {
51 super(message);
52 this.name = "InvalidThreadHistoryCursorError";
53 }
54}
55
56function bytesOfJson(value: unknown): number {
57 return Buffer.byteLength(JSON.stringify(value), "utf8");
58}
59
60/** Ordinary history-page cost: one projected row (item is nested once). */
61export function projectedRowEncodedBytes(row: OrchestrationV2ProjectedTurnItem): number {
62 return bytesOfJson(row);
63}
64
65/**
66 * Bounded-snapshot cost: local rows also land in `projection.turnItems`, so
67 * charge the nested item a second time. Inherited rows only appear in the
68 * visible list.
69 */
70export function projectedRowBoundedSnapshotEncodedBytes(
71 row: OrchestrationV2ProjectedTurnItem,
72 threadId: ThreadId | string,
73): number {
74 const rowBytes = projectedRowEncodedBytes(row);
75 if (row.visibility === "local" || String(row.sourceThreadId) === String(threadId)) {
76 return rowBytes + bytesOfJson(row.item);
77 }
78 return rowBytes;
79}
80
81function renumberPositions(
82 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
83): OrchestrationV2ProjectedTurnItem[] {
84 return items.map((row, position) => (row.position === position ? row : { ...row, position }));
85}
86
87function encodeCursorPayload(payload: ThreadHistoryCursorPayload): string {
88 return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
89}
90
91export function encodeThreadHistoryCursor(input: {
92 readonly snapshotSequence: number;
93 readonly sourceThreadId: ThreadId | string;
94 readonly sourceItemId: TurnItemId | string;
95 readonly position: number;
96}): string {
97 return encodeCursorPayload({
98 v: 1,
99 seq: input.snapshotSequence,
100 st: String(input.sourceThreadId),
101 si: String(input.sourceItemId),
102 p: input.position,
103 });
104}
105
106export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPayload {
107 if (cursor.length === 0 || cursor.length > THREAD_HISTORY_CURSOR_MAX_LENGTH) {
108 throw new InvalidThreadHistoryCursorError();
109 }
110 let parsed: unknown;
111 try {
112 const json = Buffer.from(cursor, "base64url").toString("utf8");
113 parsed = JSON.parse(json) as unknown;
114 } catch {
115 throw new InvalidThreadHistoryCursorError();
116 }
117 if (
118 typeof parsed !== "object" ||
119 parsed === null ||
120 !("v" in parsed) ||
121 (parsed as { v: unknown }).v !== 1 ||
122 !("seq" in parsed) ||
123 typeof (parsed as { seq: unknown }).seq !== "number" ||
124 !Number.isInteger((parsed as { seq: number }).seq) ||
125 (parsed as { seq: number }).seq < 0 ||
126 !("st" in parsed) ||
127 typeof (parsed as { st: unknown }).st !== "string" ||
128 (parsed as { st: string }).st.length === 0 ||
129 !("si" in parsed) ||
130 typeof (parsed as { si: unknown }).si !== "string" ||
131 (parsed as { si: string }).si.length === 0 ||
132 !("p" in parsed) ||
133 typeof (parsed as { p: unknown }).p !== "number" ||
134 !Number.isInteger((parsed as { p: number }).p) ||
135 (parsed as { p: number }).p < 0
136 ) {
137 throw new InvalidThreadHistoryCursorError();
138 }
139 return parsed as ThreadHistoryCursorPayload;
140}
141
142/**
143 * Walk a chronological timeline from `startIndex` backward, collecting rows
144 * under count and byte budgets. Always includes at least one row when available
145 * so a single oversized item cannot deadlock pagination.
146 */
147export function selectOlderTimelinePage(input: {
148 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
149 readonly exclusiveEndIndex: number;
150 readonly snapshotSequence: number;
151 readonly policy?: ThreadHistoryPagePolicy | undefined;
152 readonly rowEncodedBytes?: ((row: OrchestrationV2ProjectedTurnItem) => number) | undefined;
153}): SelectTimelinePageResult {
154 const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY;
155 const rowCost = input.rowEncodedBytes ?? projectedRowEncodedBytes;
156 const end = Math.min(Math.max(input.exclusiveEndIndex, 0), input.items.length);
157 if (end <= 0) {
158 return { items: [], nextCursor: null, hasMoreHistory: false };
159 }
160
161 const selected: OrchestrationV2ProjectedTurnItem[] = [];
162 let encodedBytes = 0;
163 for (let index = end - 1; index >= 0; index -= 1) {
164 const row = input.items[index]!;
165 const rowBytes = rowCost(row);
166 if (
167 selected.length > 0 &&
168 (selected.length >= policy.maxItems || encodedBytes + rowBytes > policy.maxEncodedBytes)
169 ) {
170 break;
171 }
172 selected.push(row);
173 encodedBytes += rowBytes;
174 }
175 selected.reverse();
176
177 const oldest = selected[0];
178 const oldestIndex = oldest
179 ? input.items.findIndex(
180 (row) =>
181 row.sourceThreadId === oldest.sourceThreadId && row.sourceItemId === oldest.sourceItemId,
182 )
183 : -1;
184 const hasMoreHistory = oldestIndex > 0;
185 const nextCursor =
186 hasMoreHistory && oldest
187 ? encodeThreadHistoryCursor({
188 snapshotSequence: input.snapshotSequence,
189 sourceThreadId: oldest.sourceThreadId,
190 sourceItemId: oldest.sourceItemId,
191 position: oldest.position,
192 })
193 : null;
194
195 return {
196 items: renumberPositions(selected),
197 nextCursor,
198 hasMoreHistory,
199 };
200}
201
202export function selectRecentTimelineWindow(input: {
203 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
204 readonly snapshotSequence: number;
205 readonly policy?: ThreadHistoryPagePolicy | undefined;
206 readonly rowEncodedBytes?: ((row: OrchestrationV2ProjectedTurnItem) => number) | undefined;
207}): SelectTimelinePageResult {
208 return selectOlderTimelinePage({
209 items: input.items,
210 exclusiveEndIndex: input.items.length,
211 snapshotSequence: input.snapshotSequence,
212 ...(input.policy === undefined ? {} : { policy: input.policy }),
213 ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }),
214 });
215}
216
217function findCursorIndex(
218 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
219 cursor: ThreadHistoryCursorPayload,
220): number {
221 const byIdentity = items.findIndex(
222 (row) => String(row.sourceThreadId) === cursor.st && String(row.sourceItemId) === cursor.si,
223 );
224 if (byIdentity !== -1) {
225 return byIdentity;
226 }
227 // Identity miss can happen if the cursor item was deleted; fall back to the
228 // recorded position clamped into the current timeline so pagination can
229 // continue from a stable-ish anchor rather than failing hard. Clamp to
230 // items.length (exclusive end) so a shrunken timeline never 400s forever.
231 // decodeThreadHistoryCursor already guarantees p is a non-negative integer.
232 return Math.min(cursor.p, items.length);
233}
234
235export function selectHistoryPageFromCursor(input: {
236 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
237 readonly cursor: string;
238 readonly snapshotSequence: number;
239 readonly policy?: ThreadHistoryPagePolicy | undefined;
240}): SelectTimelinePageResult {
241 const decoded = decodeThreadHistoryCursor(input.cursor);
242 const anchorIndex = findCursorIndex(input.items, decoded);
243 return selectOlderTimelinePage({
244 items: input.items,
245 exclusiveEndIndex: anchorIndex,
246 snapshotSequence: input.snapshotSequence,
247 ...(input.policy === undefined ? {} : { policy: input.policy }),
248 });
249}
250
251function isLocalProjectedRow(
252 projection: OrchestrationV2ThreadProjection,
253 row: OrchestrationV2ProjectedTurnItem,
254): boolean {
255 return row.visibility === "local" || String(row.sourceThreadId) === String(projection.thread.id);
256}
257
258/**
259 * Visibility for superseded `run_interrupt_result` rows depends on a matching
260 * `run_interrupt_request` in `turnItems`. Keep every small request item from the
261 * full projection even when it sits outside the recent visible window, so a
262 * later history page that introduces the matching result still has the request
263 * available for live attempt/run reducers.
264 */
265export function retainedInterruptRequestTurnItems(
266 projection: OrchestrationV2ThreadProjection,
267 visible: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
268): OrchestrationV2TurnItem[] {
269 const visibleLocalIds = new Set<string>();
270 for (const row of visible) {
271 if (isLocalProjectedRow(projection, row)) {
272 visibleLocalIds.add(String(row.sourceItemId));
273 }
274 }
275
276 const retained: OrchestrationV2TurnItem[] = [];
277 for (const item of projection.turnItems) {
278 if (item.type !== "run_interrupt_request") {
279 continue;
280 }
281 // Already covered by local turnItems for the visible window.
282 if (visibleLocalIds.has(String(item.id))) {
283 continue;
284 }
285 retained.push(item);
286 }
287 return retained;
288}
289
290function localTurnItemsForVisibleWindow(
291 projection: OrchestrationV2ThreadProjection,
292 visible: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
293): OrchestrationV2TurnItem[] {
294 const localIds = new Set<string>();
295 for (const row of visible) {
296 if (isLocalProjectedRow(projection, row)) {
297 localIds.add(String(row.sourceItemId));
298 }
299 }
300 if (localIds.size === 0) {
301 return [];
302 }
303 return projection.turnItems.filter((item) => localIds.has(String(item.id)));
304}
305
306function messagesForBoundedProjection(
307 projection: OrchestrationV2ThreadProjection,
308 turnItems: ReadonlyArray<OrchestrationV2TurnItem>,
309): OrchestrationV2ThreadProjection["messages"] {
310 const retainedMessageIds = new Set<string>();
311 for (const item of turnItems) {
312 if ("messageId" in item) {
313 retainedMessageIds.add(String(item.messageId));
314 }
315 }
316
317 const latestRun = projection.runs.reduce<(typeof projection.runs)[number] | null>(
318 (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest),
319 null,
320 );
321 const retainedRunIds = new Set<string>();
322 for (const run of projection.runs) {
323 if (
324 run.id === latestRun?.id ||
325 run.status === "preparing" ||
326 run.status === "starting" ||
327 run.status === "running" ||
328 run.status === "waiting" ||
329 run.status === "queued"
330 ) {
331 retainedRunIds.add(String(run.id));
332 retainedMessageIds.add(String(run.userMessageId));
333 }
334 }
335
336 return projection.messages.filter(
337 (message) =>
338 retainedMessageIds.has(String(message.id)) ||
339 (message.runId !== null && retainedRunIds.has(String(message.runId))) ||
340 message.delegatedCompletion !== undefined,
341 );
342}
343
344/**
345 * Max local turn ordinal over the authoritative full projection turnItems.
346 * Used as a cheap partial-timeline watermark so old missing turn-item updates
347 * cannot append newest when the bounded window has no local rows.
348 */
349export function computeLatestLocalTurnOrdinal(
350 turnItems: ReadonlyArray<{ readonly ordinal: number }>,
351): number | null {
352 let latest: number | null = null;
353 for (const item of turnItems) {
354 if (latest === null || item.ordinal > latest) {
355 latest = item.ordinal;
356 }
357 }
358 return latest;
359}
360
361/**
362 * Encoded timeline contribution for a bounded snapshot: visible rows plus the
363 * duplicated local turnItems and any retained interrupt-request dependencies.
364 */
365export function boundedTimelineEncodedBytes(input: {
366 readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
367 readonly turnItems: ReadonlyArray<OrchestrationV2TurnItem>;
368}): number {
369 let total = 0;
370 for (const row of input.visibleTurnItems) {
371 total += projectedRowEncodedBytes(row);
372 }
373 for (const item of input.turnItems) {
374 total += bytesOfJson(item);
375 }
376 return total;
377}
378
379/**
380 * Builds a partial thread projection with full control-plane arrays and a
381 * bounded recent timeline window plus matching local turnItems.
382 */
383export function buildBoundedThreadProjection(input: {
384 readonly projection: OrchestrationV2ThreadProjection;
385 readonly snapshotSequence: number;
386 readonly policy?: ThreadHistoryPagePolicy | undefined;
387}): BoundedProjectionResult {
388 const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY;
389 const threadId = input.projection.thread.id;
390
391 // Reserve bytes for small interrupt-request dependencies that may sit outside
392 // the recent window but are required for visibility of results inside it.
393 const dependencyReserve = (() => {
394 // Upper bound: all request items in the full projection. Window selection
395 // uses this reserve so the final contribution stays under the cap.
396 let reserve = 0;
397 for (const item of input.projection.turnItems) {
398 if (item.type === "run_interrupt_request") {
399 reserve += bytesOfJson(item);
400 }
401 }
402 return reserve;
403 })();
404 const windowBudget = Math.max(0, policy.maxEncodedBytes - dependencyReserve);
405 const windowPolicy: ThreadHistoryPagePolicy = {
406 maxItems: policy.maxItems,
407 // Zero still admits the first row via the at-least-one rule.
408 maxEncodedBytes: windowBudget,
409 };
410
411 const window = selectRecentTimelineWindow({
412 items: input.projection.visibleTurnItems,
413 snapshotSequence: input.snapshotSequence,
414 policy: windowPolicy,
415 rowEncodedBytes: (row) => projectedRowBoundedSnapshotEncodedBytes(row, threadId),
416 });
417 const visibleTurnItems = renumberPositions(window.items);
418 const windowTurnItems = localTurnItemsForVisibleWindow(input.projection, visibleTurnItems);
419 const dependencyTurnItems = retainedInterruptRequestTurnItems(input.projection, visibleTurnItems);
420 const turnItemById = new Map<string, OrchestrationV2TurnItem>();
421 for (const item of windowTurnItems) {
422 turnItemById.set(String(item.id), item);
423 }
424 for (const item of dependencyTurnItems) {
425 turnItemById.set(String(item.id), item);
426 }
427 const turnItems = [...turnItemById.values()];
428
429 return {
430 projection: {
431 ...input.projection,
432 messages: messagesForBoundedProjection(input.projection, turnItems),
433 turnItems,
434 visibleTurnItems,
435 },
436 historyCursor: window.nextCursor,
437 hasMoreHistory: window.hasMoreHistory,
438 // Always from the full projection so inherited-only windows still carry a
439 // watermark for partial live reducers.
440 latestLocalTurnOrdinal: computeLatestLocalTurnOrdinal(input.projection.turnItems),
441 };
442}
apps/server/src/ws.ts+74−30
@@ -83,7 +83,17 @@ import { · 82 unmodified lines
83 shellStreamItemFromEnrichmentRefresh,83 shellStreamItemFromEnrichmentRefresh,
84 shellStreamItemFromThreadShell,84 shellStreamItemFromThreadShell,
85 shellStreamItemsFromInitialSnapshot,85 shellStreamItemsFromInitialSnapshot,
86 shellStreamItemsFromResumeSnapshot,
86} from "./orchestration-v2/ShellStream.ts";87} from "./orchestration-v2/ShellStream.ts";
88import {
89 decideThreadResume,
90 threadReplayEncodedBytes,
91 THREAD_RESUME_MAX_REPLAY_EVENTS,
92} from "./orchestration-v2/ThreadStream.ts";
93import {
94 projectDomainEventForWire,
95 projectThreadProjectionForWire,
96} from "./orchestration-v2/WireProjection.ts";
87import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";97import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
88import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";98import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
89import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";99import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
@@ -616,7 +626,7 @@ const makeWsRpcLayer = ( · 526 unmodified lines
616 Stream.map((stored) => ({626 Stream.map((stored) => ({
617 kind: "event" as const,627 kind: "event" as const,
618 sequence: stored.sequence,628 sequence: stored.sequence,
619 event: stored.event,629 event: projectDomainEventForWire(stored.event),
620 })),630 })),
621 Stream.mapError(631 Stream.mapError(
622 (cause) =>632 (cause) =>
@@ -628,20 +638,23 @@ const makeWsRpcLayer = ( · 5 unmodified lines
628 ),638 ),
629 );639 );
630 640
631 const replayThrough = (afterSequence: number, throughSequence: number) =>641 const loadReplayThrough = (afterSequence: number, throughSequence: number) =>
632 applicationEvents642 applicationEvents
633 .readAgentEvents({643 .readAgentEvents({
634 threadId: input.threadId,644 threadId: input.threadId,
635 afterSequence,645 afterSequence,
636 throughSequence,646 throughSequence,
647 limit: THREAD_RESUME_MAX_REPLAY_EVENTS + 1,
637 })648 })
638 .pipe(649 .pipe(
639 Stream.map((stored) => ({650 Stream.map((stored) => ({
640 kind: "event" as const,651 kind: "event" as const,
641 sequence: stored.sequence,652 sequence: stored.sequence,
642 event: stored.event,653 event: projectDomainEventForWire(stored.event),
643 })),654 })),
644 Stream.mapError(655 Stream.runCollect,
656 Effect.map((items) => Array.from(items)),
657 Effect.mapError(
645 (cause) =>658 (cause) =>
646 new OrchestrationV2GetThreadProjectionError({659 new OrchestrationV2GetThreadProjectionError({
647 threadId: input.threadId,660 threadId: input.threadId,
@@ -656,6 +669,34 @@ const makeWsRpcLayer = ( · 8 unmodified lines
656 ? Stream.make({ kind: "synchronized" as const })669 ? Stream.make({ kind: "synchronized" as const })
657 : Stream.empty;670 : Stream.empty;
658 671
672 const snapshotThenLive = Effect.fn("ws.orchestrationV2.threadSnapshotThenLive")(
673 function* () {
674 const snapshot = yield* threadManagement.getThreadSnapshot(input.threadId).pipe(
675 Effect.mapError(
676 (cause) =>
677 new OrchestrationV2GetThreadProjectionError({
678 threadId: input.threadId,
679 message: `Failed to load orchestration V2 thread ${input.threadId}`,
680 cause,
681 }),
682 ),
683 );
684 const { snapshotSequence } = snapshot;
685 const projection = projectThreadProjectionForWire(snapshot.projection);
686 return Stream.concat(
687 Stream.concat(
688 Stream.make({
689 kind: "snapshot" as const,
690 snapshotSequence,
691 projection,
692 }),
693 completionMarker,
694 ),
695 eventStreamFrom(snapshotSequence),
696 );
697 },
698 );
699
659 // When the client already holds the projection (cached, or loaded over700 // When the client already holds the projection (cached, or loaded over
660 // HTTP) it passes that snapshot's sequence, and we resume by replaying701 // HTTP) it passes that snapshot's sequence, and we resume by replaying
661 // persisted events after it instead of re-sending the (potentially702 // persisted events after it instead of re-sending the (potentially
@@ -674,35 +715,23 @@ const makeWsRpcLayer = ( · 12 unmodified lines
674 }),715 }),
675 ),716 ),
676 );717 );
718 const replay = yield* loadReplayThrough(input.afterSequence, highWater);
719 const plan = decideThreadResume({
720 afterSequence: input.afterSequence,
721 highWater,
722 replayEventCount: replay.length,
723 replayEncodedBytes: threadReplayEncodedBytes(replay),
724 });
725 if (plan.mode === "snapshot") {
726 return yield* snapshotThenLive();
727 }
677 return Stream.concat(728 return Stream.concat(
678 Stream.concat(replayThrough(input.afterSequence, highWater), completionMarker),729 Stream.concat(Stream.fromIterable(replay), completionMarker),
679 eventStreamFrom(highWater),730 eventStreamFrom(highWater),
680 );731 );
681 }732 }
682 733
683 const snapshot = yield* threadManagement.getThreadSnapshot(input.threadId).pipe(734 return yield* snapshotThenLive();
684 Effect.mapError(
685 (cause) =>
686 new OrchestrationV2GetThreadProjectionError({
687 threadId: input.threadId,
688 message: `Failed to load orchestration V2 thread ${input.threadId}`,
689 cause,
690 }),
691 ),
692 );
693 const { projection, snapshotSequence } = snapshot;
694
695 return Stream.concat(
696 Stream.concat(
697 Stream.make({
698 kind: "snapshot" as const,
699 snapshotSequence,
700 projection,
701 }),
702 completionMarker,
703 ),
704 eventStreamFrom(snapshotSequence),
705 );
706 },735 },
707 );736 );
708 737
@@ -809,7 +838,7 @@ const makeWsRpcLayer = ( · 100 unmodified lines
809 // completions that race HTTP snapshot fetch still push a refresh.838 // completions that race HTTP snapshot fetch still push a refresh.
810 // When the client already holds a shell snapshot (cached, or loaded839 // When the client already holds a shell snapshot (cached, or loaded
811 // over HTTP) it passes that snapshot's sequence. We still emit one840 // over HTTP) it passes that snapshot's sequence. We still emit one
812 // enriched snapshot up front: getAvailable may have been cold on the841 // compact metadata refresh up front: getAvailable may have been cold on the
813 // HTTP path (null identity), and enrichment PubSub events published842 // HTTP path (null identity), and enrichment PubSub events published
814 // before this subscribe attached are dropped. Rehydrating here fills843 // before this subscribe attached are dropped. Rehydrating here fills
815 // repositoryIdentity for cross-environment project grouping even on844 // repositoryIdentity for cross-environment project grouping even on
@@ -835,6 +864,16 @@ const makeWsRpcLayer = ( · 19 unmodified lines
835 resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots,864 resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots,
836 }),865 }),
837 );866 );
867 const initialEnrichmentItems = (loaded: {
868 readonly snapshot: OrchestrationV2ShellSnapshot;
869 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
870 }) =>
871 Stream.fromIterable(
872 shellStreamItemsFromResumeSnapshot({
873 snapshot: loaded.snapshot,
874 resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots,
875 }),
876 );
838 // Initial unmarked (+ optional same-load marked) always drains first.877 // Initial unmarked (+ optional same-load marked) always drains first.
839 // Enrichment merges only with the post-prefix tail so a ready marked878 // Enrichment merges only with the post-prefix tail so a ready marked
840 // refresh cannot interleave before the authoritative initial frame.879 // refresh cannot interleave before the authoritative initial frame.
@@ -869,7 +908,7 @@ const makeWsRpcLayer = ( · 28 unmodified lines
869 }),908 }),
870 );909 );
871 return composeShellStreamWithEnrichment({910 return composeShellStreamWithEnrichment({
872 initial,911 initial: initialEnrichmentItems(loaded),
873 tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)),912 tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)),
874 enrichment: enrichmentRefreshes,913 enrichment: enrichmentRefreshes,
875 });914 });
@@ -1119,6 +1158,7 @@ const makeWsRpcLayer = ( · 243 unmodified lines
1119 observeRpcEffect(1158 observeRpcEffect(
1120 ORCHESTRATION_V2_WS_METHODS.getThreadProjection,1159 ORCHESTRATION_V2_WS_METHODS.getThreadProjection,
1121 threadManagement.getThreadProjection(input.threadId).pipe(1160 threadManagement.getThreadProjection(input.threadId).pipe(
1161 Effect.map(projectThreadProjectionForWire),
1122 Effect.mapError(1162 Effect.mapError(
1123 (cause) =>1163 (cause) =>
1124 new OrchestrationV2GetThreadProjectionError({1164 new OrchestrationV2GetThreadProjectionError({
@@ -1169,6 +1209,10 @@ const makeWsRpcLayer = ( · 44 unmodified lines
1169 }),1209 }),
1170 )1210 )
1171 .pipe(1211 .pipe(
1212 Effect.map((result) => ({
1213 ...result,
1214 projection: projectThreadProjectionForWire(result.projection),
1215 })),
1172 Effect.mapError(1216 Effect.mapError(
1173 (cause) =>1217 (cause) =>
1174 new OrchestrationV2ThreadLaunchError({1218 new OrchestrationV2ThreadLaunchError({
apps/web/src/components/ChatView.tsx+27−1
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
34 deriveThreadRuntime,34 deriveThreadRuntime,
35} from "@t3tools/client-runtime/state/thread-execution";35} from "@t3tools/client-runtime/state/thread-execution";
36import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";36import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
37import { shouldShowLoadEarlierControl } from "@t3tools/client-runtime/state/threads";
37import { resolveThreadLastVisitedAt } from "./Sidebar.logic";38import { resolveThreadLastVisitedAt } from "./Sidebar.logic";
38import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";39import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
39import {40import {
@@ -249,6 +250,7 @@ import { · 209 unmodified lines
249 useProject,250 useProject,
250 useProjects,251 useProjects,
251 useThreadProjection,252 useThreadProjection,
253 useThreadHistory,
252 useThreadShell,254 useThreadShell,
253 useThreadRefs,255 useThreadRefs,
254 useThreadVisibleTurnItems,256 useThreadVisibleTurnItems,
@@ -259,7 +261,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; · 4 unmodified lines
259import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";261import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
260import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";262import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
261import { PullRequestThreadDialog } from "./PullRequestThreadDialog";263import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
262import { MessagesTimeline } from "./chat/MessagesTimeline";264import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
263import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";265import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
264import { ChatHeader } from "./chat/ChatHeader";266import { ChatHeader } from "./chat/ChatHeader";
265import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic";267import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic";
@@ -1258,6 +1260,10 @@ function ChatViewContent(props: ChatViewProps) { · 992 unmodified lines
1258 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {1260 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
1259 reportFailure: false,1261 reportFailure: false,
1260 });1262 });
1263 const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
1264 label: "load earlier thread history",
1265 reportFailure: false,
1266 });
1261 const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, {1267 const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, {
1262 reportFailure: false,1268 reportFailure: false,
1263 });1269 });
@@ -1304,6 +1310,23 @@ function ChatViewContent(props: ChatViewProps) { · 40 unmodified lines
1304 [serverProjection?.subagents],1310 [serverProjection?.subagents],
1305 );1311 );
1306 const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);1312 const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);
1313 const serverThreadHistory = useThreadHistory(routeThreadDetailRef);
1314 const threadHistoryControls = useMemo<MessagesTimelineHistoryControls | undefined>(() => {
1315 if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) {
1316 return undefined;
1317 }
1318 return {
1319 hasMoreHistory: serverThreadHistory.hasMoreHistory,
1320 loading: serverThreadHistory.loading,
1321 error: serverThreadHistory.error,
1322 onLoadEarlier: () => {
1323 void loadEarlierThreadHistory({
1324 environmentId: routeThreadDetailRef.environmentId,
1325 input: { threadId: routeThreadDetailRef.threadId },
1326 });
1327 },
1328 };
1329 }, [loadEarlierThreadHistory, routeThreadDetailRef, serverThreadHistory]);
1307 const committedServerMessageIds = useMemo(1330 const committedServerMessageIds = useMemo(
1308 () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),1331 () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),
1309 [serverVisibleTurnItems],1332 [serverVisibleTurnItems],
@@ -6569,6 +6592,9 @@ function ChatViewContent(props: ChatViewProps) { · 5259 unmodified lines
6569 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}6592 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
6570 hideEmptyPlaceholder={isDraftHeroState}6593 hideEmptyPlaceholder={isDraftHeroState}
6571 topFadeEnabled={!hasTimelineTopBanner}6594 topFadeEnabled={!hasTimelineTopBanner}
6595 {...(threadHistoryControls === undefined
6596 ? {}
6597 : { historyControls: threadHistoryControls })}
6572 />6598 />
6573 6599
6574 {/* scroll to end pill — shown when user has scrolled away from the live edge */}6600 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
apps/web/src/components/chat/MessagesTimeline.test.tsx+39−0
@@ -231,6 +231,45 @@ function buildAssistantTimelineEntry(text: string) { · 230 unmodified lines
231}231}
232 232
233describe("MessagesTimeline", () => {233describe("MessagesTimeline", () => {
234 it("renders progressive history controls ahead of the bounded timeline", () => {
235 const markup = renderToStaticMarkup(
236 <MessagesTimeline
237 {...buildProps()}
238 timelineEntries={[buildUserTimelineEntry("Recent activity")]}
239 historyControls={{
240 hasMoreHistory: true,
241 loading: false,
242 error: "Earlier activity could not be loaded.",
243 onLoadEarlier: () => {},
244 }}
245 />,
246 );
247
248 expect(markup).toContain('aria-label="Load earlier activity"');
249 expect(markup).toContain("Load earlier activity");
250 expect(markup).toContain("Earlier activity could not be loaded.");
251 expect(markup.indexOf("Load earlier activity")).toBeLessThan(markup.indexOf("Recent activity"));
252 });
253
254 it("keeps an empty bounded timeline actionable while earlier history loads", () => {
255 const markup = renderToStaticMarkup(
256 <MessagesTimeline
257 {...buildProps()}
258 timelineEntries={[]}
259 historyControls={{
260 hasMoreHistory: true,
261 loading: true,
262 error: null,
263 onLoadEarlier: () => {},
264 }}
265 />,
266 );
267
268 expect(markup).toContain("Loading earlier activity…");
269 expect(markup).toContain("disabled");
270 expect(markup).not.toContain("Send a message to start the conversation.");
271 });
272
234 it("uses the larger leading inset only when the top fade is enabled", () => {273 it("uses the larger leading inset only when the top fade is enabled", () => {
235 const timelineEntries = [buildUserTimelineEntry("Hello")];274 const timelineEntries = [buildUserTimelineEntry("Hello")];
236 275
apps/web/src/components/chat/MessagesTimeline.tsx+61−10
@@ -195,6 +195,13 @@ const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = []; · 194 unmodified lines
195// Props (public API)195// Props (public API)
196// ---------------------------------------------------------------------------196// ---------------------------------------------------------------------------
197 197
198export interface MessagesTimelineHistoryControls {
199 readonly hasMoreHistory: boolean;
200 readonly loading: boolean;
201 readonly error: string | null;
202 readonly onLoadEarlier: () => void;
203}
204
198interface MessagesTimelineProps {205interface MessagesTimelineProps {
199 isWorking: boolean;206 isWorking: boolean;
200 activeTurnInProgress: boolean;207 activeTurnInProgress: boolean;
@@ -249,6 +256,7 @@ interface MessagesTimelineProps { · 48 unmodified lines
249 onManualNavigation: () => void;256 onManualNavigation: () => void;
250 hideEmptyPlaceholder?: boolean;257 hideEmptyPlaceholder?: boolean;
251 topFadeEnabled?: boolean;258 topFadeEnabled?: boolean;
259 historyControls?: MessagesTimelineHistoryControls;
252}260}
253 261
254// ---------------------------------------------------------------------------262// ---------------------------------------------------------------------------
@@ -291,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 36 unmodified lines
291 onManualNavigation,299 onManualNavigation,
292 hideEmptyPlaceholder = false,300 hideEmptyPlaceholder = false,
293 topFadeEnabled = false,301 topFadeEnabled = false,
302 historyControls,
294}: MessagesTimelineProps) {303}: MessagesTimelineProps) {
295 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());304 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());
296 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(305 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(
@@ -590,10 +599,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 293 unmodified lines
590 }),599 }),
591 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestRun?.runId],600 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestRun?.runId],
592 );601 );
593 const listHeader = useMemo(602 const listHeader = useMemo(() => {
594 () =>603 const leadingContent =
595 parentThreadLink === null ? (604 parentThreadLink === null ? (
596 TIMELINE_LIST_HEADER605 topFadeEnabled ? (
606 TIMELINE_LIST_FADE_HEADER
607 ) : (
608 TIMELINE_LIST_HEADER
609 )
597 ) : (610 ) : (
598 <div className="messages-timeline-row-frame">611 <div className="messages-timeline-row-frame">
599 <div className="chat-content-lane pt-1 sm:pt-2">612 <div className="chat-content-lane pt-1 sm:pt-2">
@@ -606,9 +619,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 6 unmodified lines
606 />619 />
607 </div>620 </div>
608 </div>621 </div>
609 ),622 );
610 [onOpenThread, parentThreadLink],623 return (
611 );624 <>
625 {parentThreadLink === null ? leadingContent : null}
626 {historyControls ? <TimelineHistoryControl {...historyControls} /> : null}
627 {parentThreadLink !== null ? leadingContent : null}
628 </>
629 );
630 }, [historyControls, onOpenThread, parentThreadLink, topFadeEnabled]);
612 631
613 // Stable renderItem — no closure deps. Row components read shared state632 // Stable renderItem — no closure deps. Row components read shared state
614 // from TimelineRowCtx, which propagates through LegendList's memo.633 // from TimelineRowCtx, which propagates through LegendList's memo.
@@ -623,7 +642,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 8 unmodified lines
623 [],642 [],
624 );643 );
625 644
626 if (rows.length === 0 && !isWorking && parentThreadLink === null) {645 if (
646 rows.length === 0 &&
647 !isWorking &&
648 parentThreadLink === null &&
649 historyControls === undefined
650 ) {
627 if (hideEmptyPlaceholder) {651 if (hideEmptyPlaceholder) {
628 return null;652 return null;
629 }653 }
@@ -665,9 +689,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 35 unmodified lines
665 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",689 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
666 topFadeEnabled && "chat-timeline-scroll-fade",690 topFadeEnabled && "chat-timeline-scroll-fade",
667 )}691 )}
668 ListHeaderComponent={692 ListHeaderComponent={listHeader}
669 topFadeEnabled && parentThreadLink === null ? TIMELINE_LIST_FADE_HEADER : listHeader
670 }
671 ListFooterComponent={TIMELINE_LIST_FOOTER}693 ListFooterComponent={TIMELINE_LIST_FOOTER}
672 />694 />
673 <TimelineMinimap695 <TimelineMinimap
@@ -691,6 +713,35 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 17 unmodified lines
691 );713 );
692});714});
693 715
716function TimelineHistoryControl(props: MessagesTimelineHistoryControls) {
717 if (!props.hasMoreHistory && props.error === null) {
718 return null;
719 }
720 return (
721 <div className="messages-timeline-row-frame">
722 <div className="chat-content-lane flex flex-col items-center gap-1.5 py-2">
723 {props.hasMoreHistory ? (
724 <Button
725 size="sm"
726 variant="outline"
727 disabled={props.loading}
728 aria-label="Load earlier activity"
729 onClick={props.onLoadEarlier}
730 >
731 <ChevronUpIcon />
732 {props.loading ? "Loading earlier activity…" : "Load earlier activity"}
733 </Button>
734 ) : null}
735 {props.error !== null ? (
736 <p role="status" className="text-center text-muted-foreground text-xs">
737 {props.error}
738 </p>
739 ) : null}
740 </div>
741 </div>
742 );
743}
744
694function keyExtractor(item: MessagesTimelineRow) {745function keyExtractor(item: MessagesTimelineRow) {
695 return item.id;746 return item.id;
696}747}
apps/web/src/connection/runtime.ts+6−2
@@ -1,6 +1,9 @@
1import { Connection } from "@t3tools/client-runtime/connection";1import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";3import {
4 boundedThreadSnapshotLoaderLayer,
5 threadHistoryControllerLayer,
6} from "@t3tools/client-runtime/state/threads";
4import { pullRequestDiffLoaderLayer } from "@t3tools/client-runtime/state/pull-requests";7import { pullRequestDiffLoaderLayer } from "@t3tools/client-runtime/state/pull-requests";
5import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
6import { Atom } from "effect/unstable/reactivity";9import { Atom } from "effect/unstable/reactivity";
@@ -17,8 +20,9 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( · 10 unmodified lines
17);20);
18 21
19const snapshotLoaderLayer = Layer.mergeAll(22const snapshotLoaderLayer = Layer.mergeAll(
20 threadSnapshotLoaderLayer,23 boundedThreadSnapshotLoaderLayer,
21 shellSnapshotLoaderLayer,24 shellSnapshotLoaderLayer,
25 threadHistoryControllerLayer,
22 pullRequestDiffLoaderLayer,26 pullRequestDiffLoaderLayer,
23);27);
24 28
apps/web/src/state/entities.ts+14−1
@@ -4,7 +4,11 @@ import type { · 3 unmodified lines
4 EnvironmentThread,4 EnvironmentThread,
5 EnvironmentThreadShell,5 EnvironmentThreadShell,
6} from "@t3tools/client-runtime/state/shell";6} from "@t3tools/client-runtime/state/shell";
7import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";7import {
8 EMPTY_THREAD_HISTORY_META,
9 type EnvironmentThreadStatus,
10 type ThreadHistoryMeta,
11} from "@t3tools/client-runtime/state/threads";
8import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts";12import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts";
9import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts";13import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts";
10import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
@@ -43,6 +47,9 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pip · 32 unmodified lines
43const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe(47const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe(
44 Atom.withLabel("web-thread-visible-turn-items:empty"),48 Atom.withLabel("web-thread-visible-turn-items:empty"),
45);49);
50const EMPTY_THREAD_HISTORY_ATOM = Atom.make<ThreadHistoryMeta>(EMPTY_THREAD_HISTORY_META).pipe(
51 Atom.withLabel("web-thread-history:empty"),
52);
46 53
47export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(54export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(
48 Atom.keepAlive,55 Atom.keepAlive,
@@ -137,6 +144,12 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS · 88 unmodified lines
137 );144 );
138}145}
139 146
147export function useThreadHistory(ref: ScopedThreadRef | null): ThreadHistoryMeta {
148 return useAtomValue(
149 ref === null ? EMPTY_THREAD_HISTORY_ATOM : environmentThreadDetails.historyAtom(ref),
150 );
151}
152
140export function resolveThreadDetailRef(153export function resolveThreadDetailRef(
141 ref: ScopedThreadRef | null,154 ref: ScopedThreadRef | null,
142 options: {155 options: {
docs/internals/performance-regressions.md+30−0
@@ -0,0 +1,30 @@
1# Performance regression checks
2
3The v2 transport has a focused regression command:
4
5```bash
6vp run test:perf:v2-wire
7```
8
9It exercises the real v2 projection and client reducers. This matters because the older
10built-app benchmark fixtures seed v1 orchestration events; running those fixtures against a v2
11client can produce reassuring timings while missing the active transport path.
12
13The v2 checks pin these invariants:
14
15- Client cold opens use a recent timeline window capped at 75 rows and about 1 MiB of encoded
16 timeline data.
17- The bounded snapshot does not retain the full duplicate conversation-message table.
18- Older activity is fetched in bounded HTTP pages and merged without disturbing the live scroll
19 window.
20- Resume catch-up replays at most 128 thread events and 1 MiB of projected event JSON before
21 replacing stale state with a current snapshot.
22- Oversized dynamic-tool results and detail strings are reduced only at the wire boundary; the
23 persisted event remains complete.
24- Shell resume sends deltas plus compact repository-enrichment metadata, not another full project
25 and thread snapshot.
26- Known idle sends and interrupts do not fetch a full thread projection before dispatch.
27
28When changing projection schemas, paging, shell synchronization, or thread state, run this command
29alongside the focused package typechecks and a real-client pass on every affected surface. Payload
30budgets belong in these tests rather than logs or one-off recordings so regressions fail locally.
package.json+1−0
@@ -27,6 +27,7 @@ · 26 unmodified lines
27 "lint": "vp lint --report-unused-disable-directives",27 "lint": "vp lint --report-unused-disable-directives",
28 "lint:mobile": "node scripts/mobile-native-static-check.ts",28 "lint:mobile": "node scripts/mobile-native-static-check.ts",
29 "test": "vp run -r test",29 "test": "vp run -r test",
30 "test:perf:v2-wire": "vp test run apps/server/src/orchestration-v2/WireProjection.test.ts apps/server/src/orchestration-v2/threadHistoryPaging.test.ts apps/server/src/orchestration-v2/ThreadStream.test.ts apps/server/src/orchestration-v2/ShellStream.test.ts packages/contracts/src/environmentHttp.test.ts packages/contracts/src/orchestrationV2.test.ts packages/client-runtime/src/operations/commands.test.ts packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts packages/client-runtime/src/state/threadHistoryController.test.ts packages/client-runtime/src/state/threadHistoryMerge.test.ts packages/client-runtime/src/state/threadDetail.test.ts packages/client-runtime/src/state/threads-sync.test.ts packages/client-runtime/src/state/shellReducer.test.ts",
30 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",31 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
31 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",32 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
32 "fmt": "vp fmt",33 "fmt": "vp fmt",
packages/client-runtime/src/operations/commands.test.ts+72−2
@@ -38,6 +38,7 @@ import { v2Now, v2Projection, v2ThreadId } from "../state/orchestrationV2TestFix · 37 unmodified lines
38import {38import {
39 archiveThread,39 archiveThread,
40 createProject,40 createProject,
41 interruptThreadTurn,
41 forkThreadFromRun,42 forkThreadFromRun,
42 mergeThreadBack,43 mergeThreadBack,
43 cancelQueuedRun,44 cancelQueuedRun,
@@ -71,6 +72,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 27 unmodified lines
71 readonly projects: ProjectMutation[];72 readonly projects: ProjectMutation[];
72 readonly launches?: OrchestrationV2ThreadLaunchInput[];73 readonly launches?: OrchestrationV2ThreadLaunchInput[];
73 readonly projection?: OrchestrationV2ThreadProjection;74 readonly projection?: OrchestrationV2ThreadProjection;
75 readonly projectionRequests?: ThreadId[];
74}) {76}) {
75 const client = {77 const client = {
76 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>78 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
@@ -78,8 +80,13 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 1 unmodified line
78 input.commands.push(command);80 input.commands.push(command);
79 return { sequence: input.commands.length };81 return { sequence: input.commands.length };
80 }),82 }),
81 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: () =>83 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (requestInput: {
82 Effect.succeed(input.projection ?? v2Projection),84 readonly threadId: ThreadId;
85 }) =>
86 Effect.sync(() => {
87 input.projectionRequests?.push(requestInput.threadId);
88 return input.projection ?? v2Projection;
89 }),
83 [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) =>90 [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) =>
84 Effect.sync(() => {91 Effect.sync(() => {
85 input.launches?.push(launchInput);92 input.launches?.push(launchInput);
@@ -511,4 +518,67 @@ describe("V2 environment commands", () => { · 425 unmodified lines
511 ]);518 ]);
512 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),519 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
513 );520 );
521
522 it.effect("dispatches an explicit idle start without fetching the full projection", () =>
523 Effect.gen(function* () {
524 const commands: OrchestrationV2Command[] = [];
525 const projectionRequests: ThreadId[] = [];
526 const supervisor = yield* makeSupervisor({
527 commands,
528 projects: [],
529 projectionRequests,
530 });
531
532 yield* startThreadTurn({
533 commandId: CommandId.make("direct-start"),
534 threadId: v2ThreadId,
535 message: {
536 messageId: MessageId.make("direct-start-message"),
537 role: "user",
538 text: "continue",
539 attachments: [],
540 },
541 runtimeMode: "full-access",
542 interactionMode: "default",
543 dispatchMode: "start",
544 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
545
546 expect(projectionRequests).toEqual([]);
547 expect(commands).toMatchObject([
548 {
549 type: "message.dispatch",
550 threadId: v2ThreadId,
551 dispatchMode: { type: "start_immediately" },
552 },
553 ]);
554 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
555 );
556
557 it.effect("interrupts a known run without fetching the full projection", () =>
558 Effect.gen(function* () {
559 const commands: OrchestrationV2Command[] = [];
560 const projectionRequests: ThreadId[] = [];
561 const supervisor = yield* makeSupervisor({
562 commands,
563 projects: [],
564 projectionRequests,
565 });
566
567 yield* interruptThreadTurn({
568 commandId: CommandId.make("direct-interrupt"),
569 threadId: v2ThreadId,
570 runId: RunId.make("active-run"),
571 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
572
573 expect(projectionRequests).toEqual([]);
574 expect(commands).toEqual([
575 {
576 type: "run.interrupt",
577 commandId: "direct-interrupt",
578 threadId: v2ThreadId,
579 runId: "active-run",
580 },
581 ]);
582 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
583 );
514});584});
packages/client-runtime/src/operations/commands.ts+25−7
@@ -153,7 +153,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput { · 152 unmodified lines
153 readonly interactionMode: ProviderInteractionMode;153 readonly interactionMode: ProviderInteractionMode;
154 readonly bootstrap?: StartThreadBootstrap;154 readonly bootstrap?: StartThreadBootstrap;
155 readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };155 readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };
156 readonly dispatchMode?: "auto" | "queue" | "steer" | "restart";156 readonly dispatchMode?: "auto" | "queue" | "steer" | "restart" | "start";
157}157}
158 158
159export interface InterruptThreadTurnInput extends ThreadCommandInput {159export interface InterruptThreadTurnInput extends ThreadCommandInput {
@@ -589,6 +589,25 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 429 unmodified lines
589 });589 });
590 }590 }
591 591
592 const requestedMode = input.dispatchMode ?? "auto";
593 if (requestedMode === "start") {
594 return yield* dispatch({
595 type: "message.dispatch",
596 commandId,
597 createdBy: "user",
598 creationSource: input.creationSource ?? "web",
599 threadId: input.threadId,
600 messageId: input.message.messageId,
601 text: input.message.text,
602 attachments,
603 ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }),
604 ...(input.sourceProposedPlan === undefined
605 ? {}
606 : { sourcePlanRef: input.sourceProposedPlan }),
607 dispatchMode: { type: "start_immediately" },
608 });
609 }
610
592 const projection = yield* getProjection(input.threadId);611 const projection = yield* getProjection(input.threadId);
593 const activeRun = projection.runs.findLast(612 const activeRun = projection.runs.findLast(
594 (run) =>613 (run) =>
@@ -597,7 +616,6 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 2 unmodified lines
597 run.status === "running" ||616 run.status === "running" ||
598 run.status === "waiting",617 run.status === "waiting",
599 );618 );
600 const requestedMode = input.dispatchMode ?? "auto";
601 const activeProviderThread =619 const activeProviderThread =
602 activeRun === undefined620 activeRun === undefined
603 ? undefined621 ? undefined
@@ -647,17 +665,17 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 43 unmodified lines
647export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (665export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
648 input: InterruptThreadTurnInput,666 input: InterruptThreadTurnInput,
649) {667) {
650 const projection = yield* getProjection(input.threadId);668 let runId = input.runId ?? (input.turnId as RunId | undefined);
651 const runId =669 if (runId === undefined) {
652 input.runId ??670 const projection = yield* getProjection(input.threadId);
653 (input.turnId as RunId | undefined) ??671 runId = projection.runs.findLast(
654 projection.runs.findLast(
655 (run) =>672 (run) =>
656 run.status === "preparing" ||673 run.status === "preparing" ||
657 run.status === "starting" ||674 run.status === "starting" ||
658 run.status === "running" ||675 run.status === "running" ||
659 run.status === "waiting",676 run.status === "waiting",
660 )?.id;677 )?.id;
678 }
661 if (runId === undefined) return { sequence: 0 };679 if (runId === undefined) return { sequence: 0 };
662 return yield* dispatch({680 return yield* dispatch({
663 type: "run.interrupt",681 type: "run.interrupt",
packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts+214−0
@@ -0,0 +1,214 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts";
7import { remoteHttpClientLayer } from "../rpc/http.ts";
8import { boundedThreadSnapshotLoaderLayer } from "./boundedThreadSnapshotHttp.ts";
9import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
10
11const TARGET = new PrimaryConnectionTarget({
12 environmentId: "environment-bounded" as never,
13 label: "Bounded",
14 httpBaseUrl: "https://environment.example.test",
15 wsBaseUrl: "wss://environment.example.test",
16});
17
18const PREPARED: PreparedConnection = {
19 environmentId: TARGET.environmentId,
20 label: TARGET.label,
21 httpBaseUrl: TARGET.httpBaseUrl,
22 socketUrl: TARGET.wsBaseUrl,
23 httpAuthorization: null,
24 target: TARGET,
25};
26
27const THREAD_ID = ThreadId.make("thread-bounded");
28const NOW = "2026-06-20T00:00:00.000Z";
29
30const FULL_SNAPSHOT_BODY = {
31 snapshotSequence: 9,
32 projection: {
33 thread: {
34 createdBy: "user",
35 creationSource: "web",
36 id: String(THREAD_ID),
37 projectId: "project-1",
38 title: "Full fallback thread",
39 providerInstanceId: "codex",
40 modelSelection: { instanceId: "codex", model: "gpt-5" },
41 runtimeMode: "full-access",
42 interactionMode: "default",
43 branch: null,
44 worktreePath: null,
45 activeProviderThreadId: null,
46 lineage: {
47 parentThreadId: null,
48 relationshipToParent: null,
49 rootThreadId: String(THREAD_ID),
50 },
51 forkedFrom: null,
52 createdAt: NOW,
53 updatedAt: NOW,
54 archivedAt: null,
55 deletedAt: null,
56 settledOverride: null,
57 settledAt: null,
58 },
59 runs: [],
60 attempts: [],
61 nodes: [],
62 subagents: [],
63 providerSessions: [],
64 providerThreads: [],
65 providerTurns: [],
66 runtimeRequests: [],
67 messages: [],
68 plans: [],
69 turnItems: [],
70 checkpointScopes: [],
71 checkpoints: [],
72 contextHandoffs: [],
73 contextTransfers: [],
74 visibleTurnItems: [],
75 updatedAt: NOW,
76 },
77};
78
79describe("boundedThreadSnapshotLoader", () => {
80 it.effect("falls back to full HTTP snapshot when bounded returns a plain route 404", () => {
81 const fetchFn = ((input: RequestInfo | URL) => {
82 const url = String(input);
83 if (url.includes(`/api/orchestration/threads/${THREAD_ID}/bounded`)) {
84 // Generic HTML/route 404: not a structured thread_not_found payload.
85 return Promise.resolve(
86 new Response("Not Found", {
87 status: 404,
88 headers: { "content-type": "text/plain" },
89 }),
90 );
91 }
92 expect(url).toContain(`/api/orchestration/threads/${THREAD_ID}`);
93 expect(url.includes("/bounded")).toBe(false);
94 return Promise.resolve(
95 new Response(JSON.stringify(FULL_SNAPSHOT_BODY), {
96 status: 200,
97 headers: { "content-type": "application/json" },
98 }),
99 );
100 }) satisfies typeof fetch;
101
102 return Effect.gen(function* () {
103 const loader = yield* ThreadSnapshotLoader;
104 const result = yield* loader.load(PREPARED, THREAD_ID);
105 expect(result._tag).toBe("present");
106 if (result._tag === "present") {
107 expect(result.snapshot.snapshotSequence).toBe(9);
108 expect(result.snapshot.projection.thread.title).toBe("Full fallback thread");
109 expect(result.history).toBeUndefined();
110 }
111 }).pipe(
112 Effect.provide(
113 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
114 ),
115 );
116 });
117
118 it.effect("treats structured EnvironmentResourceNotFoundError from bounded as missing", () => {
119 const fetchFn = ((input: RequestInfo | URL) => {
120 const url = String(input);
121 expect(url).toContain(`/api/orchestration/threads/${THREAD_ID}/bounded`);
122 return Promise.resolve(
123 Response.json(
124 {
125 _tag: "EnvironmentResourceNotFoundError",
126 code: "not_found",
127 reason: "thread_not_found",
128 traceId: "trace-bounded-missing",
129 },
130 { status: 404 },
131 ),
132 );
133 }) satisfies typeof fetch;
134
135 return Effect.gen(function* () {
136 const loader = yield* ThreadSnapshotLoader;
137 const result = yield* loader.load(PREPARED, THREAD_ID);
138 expect(result).toEqual({ _tag: "missing" });
139 }).pipe(
140 Effect.provide(
141 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
142 ),
143 );
144 });
145
146 it.effect("treats structured missing from full fallback as missing", () => {
147 let fullCalls = 0;
148 const fetchFn = ((input: RequestInfo | URL) => {
149 const url = String(input);
150 if (url.includes("/bounded")) {
151 return Promise.resolve(
152 new Response("Not Found", {
153 status: 404,
154 headers: { "content-type": "text/plain" },
155 }),
156 );
157 }
158 fullCalls += 1;
159 return Promise.resolve(
160 Response.json(
161 {
162 _tag: "EnvironmentResourceNotFoundError",
163 code: "not_found",
164 reason: "thread_not_found",
165 traceId: "trace-full-missing",
166 },
167 { status: 404 },
168 ),
169 );
170 }) satisfies typeof fetch;
171
172 return Effect.gen(function* () {
173 const loader = yield* ThreadSnapshotLoader;
174 const result = yield* loader.load(PREPARED, THREAD_ID);
175 expect(result).toEqual({ _tag: "missing" });
176 expect(fullCalls).toBe(1);
177 }).pipe(
178 Effect.provide(
179 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
180 ),
181 );
182 });
183
184 it.effect(
185 "uses socket fallback directly when the bounded endpoint has a transient failure",
186 () => {
187 let fullCalls = 0;
188 const fetchFn = ((input: RequestInfo | URL) => {
189 const url = String(input);
190 if (url.includes("/bounded")) {
191 return Promise.resolve(new Response("Service Unavailable", { status: 503 }));
192 }
193 fullCalls += 1;
194 return Promise.resolve(
195 new Response(JSON.stringify(FULL_SNAPSHOT_BODY), {
196 status: 200,
197 headers: { "content-type": "application/json" },
198 }),
199 );
200 }) satisfies typeof fetch;
201
202 return Effect.gen(function* () {
203 const loader = yield* ThreadSnapshotLoader;
204 const result = yield* loader.load(PREPARED, THREAD_ID);
205 expect(result).toEqual({ _tag: "unavailable" });
206 expect(fullCalls).toBe(0);
207 }).pipe(
208 Effect.provide(
209 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
210 ),
211 );
212 },
213 );
214});
packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts+156−0
@@ -0,0 +1,156 @@
1import type { ThreadId } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import { HttpClient } from "effect/unstable/http";
7
8import type { PreparedConnection } from "../connection/model.ts";
9import { environmentEndpointUrl } from "../environment/endpoint.ts";
10import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
11import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
12import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
13import {
14 fetchEnvironmentThreadSnapshot,
15 ThreadSnapshotLoader,
16 type ThreadSnapshotLoadResult,
17} from "./threadSnapshotHttp.ts";
18
19// Same cold-open budget as the full snapshot path; bounded payloads should fit.
20const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
21
22/** Load a bounded recent-window thread snapshot over HTTP. */
23export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
24 "clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot",
25)(function* (input: {
26 readonly prepared: PreparedConnection;
27 readonly threadId: ThreadId;
28 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
29 readonly timeoutMs?: number;
30}) {
31 const requestUrl = environmentEndpointUrl(
32 input.prepared.httpBaseUrl,
33 `/api/orchestration/threads/${input.threadId}/bounded`,
34 );
35 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
36 const headers = yield* buildEnvironmentAuthHeaders(
37 input.prepared.httpAuthorization,
38 "GET",
39 requestUrl,
40 input.signer,
41 );
42 return yield* executeEnvironmentHttpRequest(
43 requestUrl,
44 input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS,
45 withEnvironmentCredentials(
46 input.prepared.httpAuthorization,
47 client.orchestration.threadBoundedSnapshot({
48 params: { threadId: input.threadId },
49 headers,
50 }),
51 ),
52 );
53});
54
55/**
56 * Shared ThreadSnapshotLoader for clients that render progressive history.
57 * Seeds from the bounded HTTP snapshot when available. Older servers that lack
58 * the bounded route (plain/generic 404) fall back to the existing full HTTP
59 * thread snapshot. Structured EnvironmentResourceNotFoundError from either
60 * endpoint still means missing. Transient failures report `unavailable` so the
61 * socket path remains a last resort for connectivity issues.
62 */
63export const boundedThreadSnapshotLoaderLayer: Layer.Layer<
64 ThreadSnapshotLoader,
65 never,
66 HttpClient.HttpClient
67> = Layer.effect(
68 ThreadSnapshotLoader,
69 Effect.gen(function* () {
70 const httpClient = yield* HttpClient.HttpClient;
71 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
72 return ThreadSnapshotLoader.of({
73 load: (prepared: PreparedConnection, threadId: ThreadId) => {
74 const loadFullFallback = fetchEnvironmentThreadSnapshot({
75 prepared,
76 threadId,
77 signer,
78 }).pipe(
79 Effect.map(
80 (snapshot): ThreadSnapshotLoadResult => ({
81 _tag: "present",
82 snapshot,
83 }),
84 ),
85 Effect.provideService(HttpClient.HttpClient, httpClient),
86 Effect.catchTags({
87 EnvironmentResourceNotFoundError: () =>
88 Effect.logDebug(
89 "Full thread snapshot not found over HTTP after bounded fallback; treating the thread as deleted.",
90 ).pipe(
91 Effect.annotateLogs({ threadId }),
92 Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
93 ),
94 }),
95 Effect.catchCause((cause) =>
96 Effect.logWarning(
97 "Could not load the full thread snapshot over HTTP after bounded fallback; using the socket snapshot instead.",
98 ).pipe(
99 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
100 Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
101 ),
102 ),
103 );
104
105 return fetchEnvironmentBoundedThreadSnapshot({ prepared, threadId, signer }).pipe(
106 Effect.map(
107 (bounded): ThreadSnapshotLoadResult => ({
108 _tag: "present",
109 snapshot: {
110 snapshotSequence: bounded.snapshotSequence,
111 projection: bounded.projection,
112 latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal,
113 },
114 history: {
115 historyCursor: bounded.historyCursor,
116 hasMoreHistory: bounded.hasMoreHistory,
117 latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal,
118 },
119 }),
120 ),
121 Effect.provideService(HttpClient.HttpClient, httpClient),
122 Effect.catchTags({
123 EnvironmentResourceNotFoundError: () =>
124 Effect.logDebug(
125 "Bounded thread snapshot not found over HTTP; treating the thread as deleted.",
126 ).pipe(
127 Effect.annotateLogs({ threadId }),
128 Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
129 ),
130 RemoteEnvironmentAuthInvalidJsonError: (error) =>
131 Effect.logDebug(
132 "Bounded thread snapshot returned an invalid response; trying the full HTTP thread snapshot for an older server.",
133 ).pipe(
134 Effect.annotateLogs({ threadId, cause: error.message }),
135 Effect.andThen(loadFullFallback),
136 ),
137 RemoteEnvironmentAuthUndeclaredStatusError: (error) =>
138 error.status === 404
139 ? Effect.logDebug(
140 "Bounded thread snapshot route was not found; trying the full HTTP thread snapshot for an older server.",
141 ).pipe(Effect.annotateLogs({ threadId }), Effect.andThen(loadFullFallback))
142 : Effect.fail(error),
143 }),
144 Effect.catchCause((cause) =>
145 Effect.logWarning(
146 "Could not load the bounded thread snapshot over HTTP; using the socket snapshot instead.",
147 ).pipe(
148 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
149 Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
150 ),
151 ),
152 );
153 },
154 });
155 }),
156);
packages/client-runtime/src/state/orchestrationV2Projection.ts+57−1
@@ -5,6 +5,11 @@ import type { · 4 unmodified lines
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
7 7
8export type ApplyOrchestrationV2ProjectionEventOptions = {
9 readonly partialTimeline?: boolean;
10 readonly latestLocalTurnOrdinal?: number | null;
11};
12
8function upsertEntity<T extends { readonly id: unknown }>(13function upsertEntity<T extends { readonly id: unknown }>(
9 items: ReadonlyArray<T>,14 items: ReadonlyArray<T>,
10 item: T,15 item: T,
@@ -60,9 +65,43 @@ function activeVisibleTurnItems( · 49 unmodified lines
60 return next === null ? rows : renumberVisibleItems(next);65 return next === null ? rows : renumberVisibleItems(next);
61}66}
62 67
68function oldestLocalTurnOrdinal(
69 rows: OrchestrationV2ThreadProjection["visibleTurnItems"],
70): number | null {
71 let oldest: number | null = null;
72 for (const row of rows) {
73 if (row.visibility !== "local") continue;
74 if (oldest === null || row.item.ordinal < oldest) {
75 oldest = row.item.ordinal;
76 }
77 }
78 return oldest;
79}
80
81function shouldDropMissingPartialTurnItem(
82 projection: OrchestrationV2ThreadProjection,
83 item: OrchestrationV2TurnItem,
84 latestLocalTurnOrdinal: number | null | undefined,
85): boolean {
86 if (projection.visibleTurnItems.some((row) => row.sourceItemId === item.id)) {
87 return false;
88 }
89 if (
90 latestLocalTurnOrdinal !== null &&
91 latestLocalTurnOrdinal !== undefined &&
92 item.ordinal <= latestLocalTurnOrdinal
93 ) {
94 return true;
95 }
96 const oldest = oldestLocalTurnOrdinal(projection.visibleTurnItems);
97 return oldest !== null && item.ordinal < oldest;
98}
99
63function upsertVisibleTurnItem(100function upsertVisibleTurnItem(
64 projection: OrchestrationV2ThreadProjection,101 projection: OrchestrationV2ThreadProjection,
65 item: OrchestrationV2TurnItem,102 item: OrchestrationV2TurnItem,
103 partialTimeline: boolean,
104 latestLocalTurnOrdinal: number | null | undefined,
66): OrchestrationV2ThreadProjection["visibleTurnItems"] {105): OrchestrationV2ThreadProjection["visibleTurnItems"] {
67 const rows = projection.visibleTurnItems;106 const rows = projection.visibleTurnItems;
68 const index = rows.findIndex((row) => row.sourceItemId === item.id);107 const index = rows.findIndex((row) => row.sourceItemId === item.id);
@@ -83,6 +122,13 @@ function upsertVisibleTurnItem( · 14 unmodified lines
83 ) {122 ) {
84 return rows;123 return rows;
85 }124 }
125 if (
126 index === -1 &&
127 partialTimeline &&
128 shouldDropMissingPartialTurnItem(projection, item, latestLocalTurnOrdinal)
129 ) {
130 return rows;
131 }
86 const updated = index === -1 ? [...rows] : [...rows.slice(0, index), ...rows.slice(index + 1)];132 const updated = index === -1 ? [...rows] : [...rows.slice(0, index), ...rows.slice(index + 1)];
87 const insertionIndex = updated.findIndex(133 const insertionIndex = updated.findIndex(
88 (row) =>134 (row) =>
@@ -99,9 +145,12 @@ function upsertVisibleTurnItem( · 10 unmodified lines
99export function applyOrchestrationV2ProjectionEvent(145export function applyOrchestrationV2ProjectionEvent(
100 projection: OrchestrationV2ThreadProjection | null,146 projection: OrchestrationV2ThreadProjection | null,
101 event: OrchestrationV2DomainEvent,147 event: OrchestrationV2DomainEvent,
148 options?: ApplyOrchestrationV2ProjectionEventOptions,
102): OrchestrationV2ThreadProjection | null {149): OrchestrationV2ThreadProjection | null {
103 if (projection === null || event.threadId !== projection.thread.id) return projection;150 if (projection === null || event.threadId !== projection.thread.id) return projection;
104 151
152 const partialTimeline = options?.partialTimeline === true;
153 const latestLocalTurnOrdinal = options?.latestLocalTurnOrdinal;
105 const base = { ...projection, updatedAt: event.occurredAt };154 const base = { ...projection, updatedAt: event.occurredAt };
106 switch (event.type) {155 switch (event.type) {
107 case "thread.created":156 case "thread.created":
@@ -163,12 +212,19 @@ export function applyOrchestrationV2ProjectionEvent( · 55 unmodified lines
163 case "plan.updated":212 case "plan.updated":
164 return { ...base, plans: upsertEntity(base.plans, event.payload) };213 return { ...base, plans: upsertEntity(base.plans, event.payload) };
165 case "turn-item.updated": {214 case "turn-item.updated": {
215 if (
216 partialTimeline &&
217 !projection.turnItems.some((candidate) => candidate.id === event.payload.id) &&
218 shouldDropMissingPartialTurnItem(projection, event.payload, latestLocalTurnOrdinal)
219 ) {
220 return projection;
221 }
166 const next = { ...base, turnItems: upsertEntity(base.turnItems, event.payload) };222 const next = { ...base, turnItems: upsertEntity(base.turnItems, event.payload) };
167 const visible = { ...next, visibleTurnItems: activeVisibleTurnItems(next) };223 const visible = { ...next, visibleTurnItems: activeVisibleTurnItems(next) };
168 return {224 return {
169 ...next,225 ...next,
170 visibleTurnItems: shouldShowLocalTurnItem(next, event.payload)226 visibleTurnItems: shouldShowLocalTurnItem(next, event.payload)
171 ? upsertVisibleTurnItem(visible, event.payload)227 ? upsertVisibleTurnItem(visible, event.payload, partialTimeline, latestLocalTurnOrdinal)
172 : removeVisibleItem(visible.visibleTurnItems, event.payload.id),228 : removeVisibleItem(visible.visibleTurnItems, event.payload.id),
173 };229 };
174 }230 }
packages/client-runtime/src/state/shellReducer.test.ts+24−0
@@ -214,6 +214,30 @@ describe("applyShellStreamEvent", () => { · 213 unmodified lines
214 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);214 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
215 });215 });
216 216
217 it("treats higher-sequence compact enrichment as metadata-only", () => {
218 const previous = {
219 ...v2ShellSnapshot,
220 snapshotSequence: 5,
221 projects: [{ ...v2Project, repositoryIdentity: null }],
222 threads: [{ ...v2ThreadShell, title: "Newest title" }],
223 };
224 const next = mergeShellSnapshotProjects(
225 previous,
226 {
227 ...v2ShellSnapshot,
228 snapshotSequence: 8,
229 projects: [{ ...v2Project, repositoryIdentity }],
230 threads: [],
231 archivedThreads: [],
232 },
233 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
234 );
235
236 expect(next.snapshotSequence).toBe(5);
237 expect(next.threads[0]?.title).toBe("Newest title");
238 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
239 });
240
217 it("lower-sequence enrichment cannot resurrect empty structural state", () => {241 it("lower-sequence enrichment cannot resurrect empty structural state", () => {
218 const previous = {242 const previous = {
219 ...v2ShellSnapshot,243 ...v2ShellSnapshot,
packages/client-runtime/src/state/shellReducer.ts+6−6
@@ -29,8 +29,8 @@ function retainRepositoryIdentity( · 28 unmodified lines
29 29
30export interface MergeShellSnapshotOptions {30export interface MergeShellSnapshotOptions {
31 /**31 /**
32 * Enrichment refresh: structure and sequence must not roll back when at or32 * Metadata-only enrichment refresh: structure and sequence never change;
33 * below cache; listed roots accept identity exactly (including null).33 * listed roots accept identity exactly (including null).
34 * Omit this options object for authoritative HTTP/initial WebSocket snapshots.34 * Omit this options object for authoritative HTTP/initial WebSocket snapshots.
35 */35 */
36 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;36 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
@@ -43,9 +43,9 @@ export interface MergeShellSnapshotOptions { · 6 unmodified lines
43 * lower than cache, while retaining a prior non-null identity when the candidate43 * lower than cache, while retaining a prior non-null identity when the candidate
44 * is still unresolved/null for the same root.44 * is still unresolved/null for the same root.
45 *45 *
46 * Enrichment snapshots (options present) never resurrect or roll back46 * Enrichment snapshots (options present) only patch repository identity for
47 * projects/threads/archives/sequence when at or below cache; they only patch47 * matching current projects. They never replace projects, threads, archives,
48 * repository identity for matching current projects.48 * or sequence, regardless of the incoming snapshot sequence.
49 */49 */
50export function mergeShellSnapshotProjects(50export function mergeShellSnapshotProjects(
51 previous: OrchestrationV2ShellSnapshot | null | undefined,51 previous: OrchestrationV2ShellSnapshot | null | undefined,
@@ -59,7 +59,7 @@ export function mergeShellSnapshotProjects( · 7 unmodified lines
59 const isEnrichment = options !== undefined;59 const isEnrichment = options !== undefined;
60 const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null;60 const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null;
61 61
62 if (isEnrichment && next.snapshotSequence <= previous.snapshotSequence) {62 if (isEnrichment) {
63 const nextById = new Map(next.projects.map((project) => [project.id, project] as const));63 const nextById = new Map(next.projects.map((project) => [project.id, project] as const));
64 return {64 return {
65 ...previous,65 ...previous,
packages/client-runtime/src/state/threadCommands.ts+32−0
@@ -1,4 +1,7 @@
1import type { ThreadId } from "@t3tools/contracts";
1import * as Crypto from "effect/Crypto";2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
2import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
3 6
4import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";7import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";
@@ -61,6 +64,15 @@ import { · 56 unmodified lines
61 visitThread,64 visitThread,
62} from "../operations/commands.ts";65} from "../operations/commands.ts";
63import type { EnvironmentRegistry } from "../connection/registry.ts";66import type { EnvironmentRegistry } from "../connection/registry.ts";
67import { EnvironmentSupervisor } from "../connection/supervisor.ts";
68import {
69 ThreadHistoryController,
70 type ThreadHistoryLoadEarlierResult,
71} from "./threadHistoryController.ts";
72
73export type LoadEarlierThreadHistoryInput = {
74 readonly threadId: ThreadId;
75};
64 76
65export type {77export type {
66 ArchiveThreadInput,78 ArchiveThreadInput,
@@ -279,5 +291,25 @@ export function createThreadEnvironmentAtoms<R, E>( · 212 unmodified lines
279 scheduler,291 scheduler,
280 concurrency,292 concurrency,
281 }),293 }),
294 loadEarlierHistory: createEnvironmentCommand(runtime, {
295 label: "environment-data:commands:thread:load-earlier-history",
296 execute: (input: LoadEarlierThreadHistoryInput) =>
297 Effect.gen(function* () {
298 const supervisor = yield* EnvironmentSupervisor;
299 const controller = yield* Effect.serviceOption(ThreadHistoryController);
300 if (Option.isNone(controller)) {
301 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
302 }
303 return yield* controller.value.loadEarlier(
304 supervisor.target.environmentId,
305 input.threadId,
306 );
307 }),
308 scheduler,
309 concurrency: {
310 mode: "serial",
311 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]),
312 },
313 }),
282 };314 };
283}315}
packages/client-runtime/src/state/threadDetail.test.ts+45−20
@@ -4,6 +4,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; · 3 unmodified lines
4import { describe, expect, it } from "vite-plus/test";4import { describe, expect, it } from "vite-plus/test";
5 5
6import { v2Projection } from "./orchestrationV2TestFixtures.ts";6import { v2Projection } from "./orchestrationV2TestFixtures.ts";
7import { EMPTY_THREAD_HISTORY_META } from "./threadHistoryMerge.ts";
7import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";8import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
8import type { EnvironmentThreadState } from "./threads.ts";9import type { EnvironmentThreadState } from "./threads.ts";
9 10
@@ -12,13 +13,22 @@ const ref: ScopedThreadRef = { · 2 unmodified lines
12 threadId: ThreadId.make(v2Projection.thread.id),13 threadId: ThreadId.make(v2Projection.thread.id),
13};14};
14 15
16function threadState(
17 partial: Pick<EnvironmentThreadState, "data" | "status" | "error"> &
18 Partial<Pick<EnvironmentThreadState, "history">>,
19): EnvironmentThreadState {
20 return { ...partial, history: partial.history ?? EMPTY_THREAD_HISTORY_META };
21}
22
15describe("createEnvironmentThreadDetailAtoms", () => {23describe("createEnvironmentThreadDetailAtoms", () => {
16 it("adds environment scope while preserving the pristine projection", () => {24 it("adds environment scope while preserving the pristine projection", () => {
17 const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success({25 const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success(
18 data: Option.some(v2Projection),26 threadState({
19 status: "cached",27 data: Option.some(v2Projection),
20 error: Option.none(),28 status: "cached",
21 });29 error: Option.none(),
30 }),
31 );
22 const sourceAtom = Atom.make(initial);32 const sourceAtom = Atom.make(initial);
23 const details = createEnvironmentThreadDetailAtoms(() => sourceAtom);33 const details = createEnvironmentThreadDetailAtoms(() => sourceAtom);
24 const registry = AtomRegistry.make();34 const registry = AtomRegistry.make();
@@ -28,46 +38,61 @@ describe("createEnvironmentThreadDetailAtoms", () => { · 3 unmodified lines
28 expect(thread?.projection).toBe(v2Projection);38 expect(thread?.projection).toBe(v2Projection);
29 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);39 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
30 expect(registry.get(details.statusAtom(ref))).toBe("cached");40 expect(registry.get(details.statusAtom(ref))).toBe("cached");
41 expect(registry.get(details.historyAtom(ref))).toBe(EMPTY_THREAD_HISTORY_META);
31 42
32 registry.set(43 registry.set(
33 sourceAtom,44 sourceAtom,
34 AsyncResult.success({45 AsyncResult.success(
35 data: Option.some(v2Projection),46 threadState({
36 status: "synchronizing",47 data: Option.some(v2Projection),
37 error: Option.none(),48 status: "synchronizing",
38 }),49 error: Option.none(),
50 }),
51 ),
39 );52 );
40 53
41 expect(registry.get(details.threadAtom(ref))).toBe(thread);54 expect(registry.get(details.threadAtom(ref))).toBe(thread);
42 expect(registry.get(details.statusAtom(ref))).toBe("synchronizing");55 expect(registry.get(details.statusAtom(ref))).toBe("synchronizing");
43 56
57 const progressiveHistory = {
58 ...EMPTY_THREAD_HISTORY_META,
59 historyCursor: "cursor-1",
60 hasMoreHistory: true,
61 };
44 registry.set(62 registry.set(
45 sourceAtom,63 sourceAtom,
46 AsyncResult.success({64 AsyncResult.success(
47 data: Option.some(v2Projection),65 threadState({
48 status: "live",66 data: Option.some(v2Projection),
49 error: Option.some("Stream interrupted."),67 status: "live",
50 }),68 error: Option.some("Stream interrupted."),
69 history: progressiveHistory,
70 }),
71 ),
51 );72 );
52 73
53 expect(registry.get(details.threadAtom(ref))).toBe(thread);74 expect(registry.get(details.threadAtom(ref))).toBe(thread);
54 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);75 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
55 expect(registry.get(details.statusAtom(ref))).toBe("live");76 expect(registry.get(details.statusAtom(ref))).toBe("live");
56 expect(registry.get(details.errorAtom(ref))).toBe("Stream interrupted.");77 expect(registry.get(details.errorAtom(ref))).toBe("Stream interrupted.");
78 expect(registry.get(details.historyAtom(ref))).toBe(progressiveHistory);
57 79
58 registry.set(80 registry.set(
59 sourceAtom,81 sourceAtom,
60 AsyncResult.success({82 AsyncResult.success(
61 data: Option.none(),83 threadState({
62 status: "deleted",84 data: Option.none(),
63 error: Option.none(),85 status: "deleted",
64 }),86 error: Option.none(),
87 }),
88 ),
65 );89 );
66 90
67 expect(registry.get(details.threadAtom(ref))).toBeNull();91 expect(registry.get(details.threadAtom(ref))).toBeNull();
68 expect(registry.get(details.visibleTurnItemsAtom(ref))).toEqual([]);92 expect(registry.get(details.visibleTurnItemsAtom(ref))).toEqual([]);
69 expect(registry.get(details.statusAtom(ref))).toBe("deleted");93 expect(registry.get(details.statusAtom(ref))).toBe("deleted");
70 expect(registry.get(details.errorAtom(ref))).toBeNull();94 expect(registry.get(details.errorAtom(ref))).toBeNull();
95 expect(registry.get(details.historyAtom(ref))).toBe(EMPTY_THREAD_HISTORY_META);
71 registry.dispose();96 registry.dispose();
72 });97 });
73});98});
packages/client-runtime/src/state/threadDetail.ts+7−0
@@ -62,6 +62,12 @@ export function createEnvironmentThreadDetailAtoms<E>( · 61 unmodified lines
62 Atom.withLabel(`environment-thread-error:${key}`),62 Atom.withLabel(`environment-thread-error:${key}`),
63 ),63 ),
64 );64 );
65 const historyAtomFamily = Atom.family((key: string) =>
66 Atom.make((get) => get(threadStateValueAtomFamily(key)).history).pipe(
67 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
68 Atom.withLabel(`environment-thread-history:${key}`),
69 ),
70 );
65 71
66 return {72 return {
67 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),73 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),
@@ -69,5 +75,6 @@ export function createEnvironmentThreadDetailAtoms<E>( · 1 unmodified line
69 visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),75 visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),
70 statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)),76 statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)),
71 errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)),77 errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)),
78 historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)),
72 };79 };
73}80}
packages/client-runtime/src/state/threadHistoryController.test.ts+66−0
@@ -0,0 +1,66 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import {
7 ThreadHistoryController,
8 threadHistoryControllerLayer,
9 type ThreadHistoryHandler,
10 type ThreadHistoryLoadEarlierResult,
11} from "./threadHistoryController.ts";
12
13const ENV = "env-history" as EnvironmentId;
14const THREAD = "thread-history" as ThreadId;
15
16function handler(
17 tag: string,
18): ThreadHistoryHandler & { readonly tag: string; readonly calls: number } {
19 const state = { tag, calls: 0 };
20 return {
21 get tag() {
22 return state.tag;
23 },
24 get calls() {
25 return state.calls;
26 },
27 loadEarlier: () => {
28 state.calls += 1;
29 return Effect.succeed({ _tag: "loaded" } satisfies ThreadHistoryLoadEarlierResult);
30 },
31 };
32}
33
34describe("ThreadHistoryController", () => {
35 it.effect("does not let an older finalizer delete a newer registration", () =>
36 Effect.gen(function* () {
37 const controller = yield* ThreadHistoryController;
38 const older = handler("older");
39 const newer = handler("newer");
40
41 const olderRegistration = yield* controller.register(ENV, THREAD, older);
42 const newerRegistration = yield* controller.register(ENV, THREAD, newer);
43
44 // Older fiber finalizes after the newer handler is already registered.
45 yield* controller.unregister(olderRegistration);
46
47 const result = yield* controller.loadEarlier(ENV, THREAD);
48 expect(result).toEqual({ _tag: "loaded" });
49 expect(newer.calls).toBe(1);
50 expect(older.calls).toBe(0);
51
52 yield* controller.unregister(newerRegistration);
53 expect(yield* controller.loadEarlier(ENV, THREAD)).toEqual({ _tag: "noop" });
54 }).pipe(Effect.provide(threadHistoryControllerLayer)),
55 );
56
57 it.effect("unregister removes only its own matching registration", () =>
58 Effect.gen(function* () {
59 const controller = yield* ThreadHistoryController;
60 const first = handler("first");
61 const registration = yield* controller.register(ENV, THREAD, first);
62 yield* controller.unregister(registration);
63 expect(yield* controller.loadEarlier(ENV, THREAD)).toEqual({ _tag: "noop" });
64 }).pipe(Effect.provide(Layer.fresh(threadHistoryControllerLayer))),
65 );
66});
packages/client-runtime/src/state/threadHistoryController.ts+93−0
@@ -0,0 +1,93 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import { threadKey } from "./entities.ts";
8
9export type ThreadHistoryLoadEarlierResult =
10 | { readonly _tag: "loaded" }
11 | { readonly _tag: "noop" }
12 | { readonly _tag: "busy" }
13 | { readonly _tag: "error"; readonly message: string };
14
15export type ThreadHistoryHandler = {
16 readonly loadEarlier: () => Effect.Effect<ThreadHistoryLoadEarlierResult>;
17};
18
19/**
20 * Opaque registration token returned by `register`. Finalizers must pass the
21 * same token so an older fiber cannot delete a newer handler for the key.
22 */
23export type ThreadHistoryRegistration = {
24 readonly environmentId: EnvironmentId;
25 readonly threadId: ThreadId;
26 readonly handler: ThreadHistoryHandler;
27};
28
29/**
30 * Per-thread registration for progressive history loads. Thread state fibers
31 * register while active; mobile UI dispatches through `loadEarlier`.
32 */
33export class ThreadHistoryController extends Context.Service<
34 ThreadHistoryController,
35 {
36 readonly register: (
37 environmentId: EnvironmentId,
38 threadId: ThreadId,
39 handler: ThreadHistoryHandler,
40 ) => Effect.Effect<ThreadHistoryRegistration>;
41 readonly unregister: (registration: ThreadHistoryRegistration) => Effect.Effect<void>;
42 readonly loadEarlier: (
43 environmentId: EnvironmentId,
44 threadId: ThreadId,
45 ) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
46 }
47>()("@t3tools/client-runtime/state/threadHistoryController") {}
48
49export const threadHistoryControllerLayer: Layer.Layer<ThreadHistoryController> = Layer.effect(
50 ThreadHistoryController,
51 Effect.gen(function* () {
52 const handlers = yield* Ref.make(new Map<string, ThreadHistoryHandler>());
53 return ThreadHistoryController.of({
54 register: (environmentId, threadId, handler) =>
55 Ref.update(handlers, (current) => {
56 const next = new Map(current);
57 next.set(threadKey({ environmentId, threadId }), handler);
58 return next;
59 }).pipe(
60 Effect.as({
61 environmentId,
62 threadId,
63 handler,
64 } satisfies ThreadHistoryRegistration),
65 ),
66 unregister: (registration) =>
67 Ref.update(handlers, (current) => {
68 const key = threadKey({
69 environmentId: registration.environmentId,
70 threadId: registration.threadId,
71 });
72 // Only remove when this registration still owns the slot. An older
73 // fiber finalizer must not delete a newer handler for the same key.
74 if (current.get(key) !== registration.handler) {
75 return current;
76 }
77 const next = new Map(current);
78 next.delete(key);
79 return next;
80 }),
81 loadEarlier: (environmentId, threadId) =>
82 Ref.get(handlers).pipe(
83 Effect.flatMap((current) => {
84 const handler = current.get(threadKey({ environmentId, threadId }));
85 if (handler === undefined) {
86 return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult);
87 }
88 return handler.loadEarlier();
89 }),
90 ),
91 });
92 }),
93);
packages/client-runtime/src/state/threadHistoryHttp.ts+45−0
@@ -0,0 +1,45 @@
1import type { ThreadId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type { PreparedConnection } from "../connection/model.ts";
6import { environmentEndpointUrl } from "../environment/endpoint.ts";
7import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
8import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
9import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
10
11const DEFAULT_THREAD_HISTORY_TIMEOUT_MS = 6_000;
12
13export const fetchEnvironmentThreadHistoryPage = Effect.fn(
14 "clientRuntime.state.fetchEnvironmentThreadHistoryPage",
15)(function* (input: {
16 readonly prepared: PreparedConnection;
17 readonly threadId: ThreadId;
18 readonly cursor: string;
19 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
20 readonly timeoutMs?: number;
21}) {
22 const requestUrl = environmentEndpointUrl(
23 input.prepared.httpBaseUrl,
24 `/api/orchestration/threads/${input.threadId}/history`,
25 );
26 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
27 const headers = yield* buildEnvironmentAuthHeaders(
28 input.prepared.httpAuthorization,
29 "GET",
30 requestUrl,
31 input.signer,
32 );
33 return yield* executeEnvironmentHttpRequest(
34 requestUrl,
35 input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS,
36 withEnvironmentCredentials(
37 input.prepared.httpAuthorization,
38 client.orchestration.threadHistoryPage({
39 params: { threadId: input.threadId },
40 query: { cursor: input.cursor },
41 headers,
42 }),
43 ),
44 );
45});
packages/client-runtime/src/state/threadHistoryMerge.test.ts+416−0
@@ -0,0 +1,416 @@
1import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4
5import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
6import {
7 applyHistoryPageMeta,
8 clearActiveHistoryLoading,
9 isActiveHistoryRequestCursor,
10 mergeOlderHistoryIntoProjection,
11 shouldShowLoadEarlierControl,
12} from "./threadHistoryMerge.ts";
13import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
14
15const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
16
17function row(index: number): OrchestrationV2ProjectedTurnItem {
18 const id = `item-${index}` as never;
19 return {
20 position: index,
21 visibility: "local",
22 sourceThreadId: v2ThreadId,
23 sourceItemId: id,
24 item: {
25 id,
26 type: "command_execution",
27 threadId: v2ThreadId,
28 runId: null,
29 nodeId: null,
30 providerThreadId: null,
31 providerTurnId: null,
32 nativeItemRef: null,
33 parentItemId: null,
34 ordinal: index + 1,
35 status: "completed",
36 title: `Command ${index}`,
37 input: `cmd-${index}`,
38 output: `out-${index}`,
39 exitCode: 0,
40 startedAt: NOW,
41 completedAt: NOW,
42 updatedAt: NOW,
43 },
44 };
45}
46
47describe("threadHistoryMerge", () => {
48 it("prepends older rows and dedupes by source identity", () => {
49 const recent = [row(2), row(3)];
50 const projection = {
51 ...v2Projection,
52 turnItems: recent.map((entry) => entry.item),
53 visibleTurnItems: recent.map((entry, position) => ({ ...entry, position })),
54 };
55 const older = [row(0), row(1), row(2)];
56 const merged = mergeOlderHistoryIntoProjection(projection, older);
57 expect(merged.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
58 "item-0",
59 "item-1",
60 "item-2",
61 "item-3",
62 ]);
63 expect(merged.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3]);
64 expect(merged.turnItems.map((item) => item.id)).toEqual([
65 "item-2",
66 "item-3",
67 "item-0",
68 "item-1",
69 ]);
70 });
71
72 it("retains live rows that arrived after the older page was fetched", () => {
73 const live = [row(5), row(6)];
74 const projection = {
75 ...v2Projection,
76 turnItems: live.map((entry) => entry.item),
77 visibleTurnItems: live.map((entry, position) => ({ ...entry, position })),
78 };
79 const older = [row(3), row(4)];
80 const merged = mergeOlderHistoryIntoProjection(projection, older);
81 expect(merged.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
82 "item-3",
83 "item-4",
84 "item-5",
85 "item-6",
86 ]);
87 });
88
89 it("does not resurrect a local item hidden while an older page was in flight", () => {
90 const stalePageRow = row(1);
91 const currentItem = {
92 ...stalePageRow.item,
93 output: "newer live output",
94 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"),
95 };
96 const projection = {
97 ...v2Projection,
98 turnItems: [currentItem],
99 visibleTurnItems: [],
100 };
101
102 const merged = mergeOlderHistoryIntoProjection(projection, [stalePageRow]);
103
104 expect(merged).toBe(projection);
105 expect(merged.turnItems).toEqual([currentItem]);
106 expect(merged.visibleTurnItems).toEqual([]);
107 });
108
109 it("marks history expanded after a successful page", () => {
110 const next = applyHistoryPageMeta(
111 {
112 historyCursor: "cursor-a",
113 hasMoreHistory: true,
114 loading: true,
115 error: null,
116 expanded: false,
117 latestLocalTurnOrdinal: 42,
118 },
119 { nextCursor: "cursor-b", hasMoreHistory: true },
120 );
121 expect(next).toEqual({
122 historyCursor: "cursor-b",
123 hasMoreHistory: true,
124 loading: false,
125 error: null,
126 expanded: true,
127 latestLocalTurnOrdinal: 42,
128 });
129 });
130
131 it("only applies history responses for the active request cursor", () => {
132 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: "cursor-a" })).toBe(true);
133 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: "cursor-b" })).toBe(false);
134 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: null })).toBe(false);
135 });
136
137 it("clears loading on interrupt only for the active request cursor", () => {
138 const loading = {
139 historyCursor: "cursor-a",
140 hasMoreHistory: true,
141 loading: true,
142 error: null,
143 expanded: false,
144 latestLocalTurnOrdinal: 10,
145 };
146 expect(clearActiveHistoryLoading("cursor-a", loading)).toEqual({
147 ...loading,
148 loading: false,
149 });
150 // Stale cursor must not mutate replacement progressive meta.
151 expect(
152 clearActiveHistoryLoading("cursor-a", { ...loading, historyCursor: "cursor-b" }),
153 ).toEqual({ ...loading, historyCursor: "cursor-b" });
154 // Already idle: identity-preserving no-op shape.
155 const idle = { ...loading, loading: false };
156 expect(clearActiveHistoryLoading("cursor-a", idle)).toBe(idle);
157 });
158
159 it("shows the load-earlier control when history remains or a local error is set", () => {
160 expect(
161 shouldShowLoadEarlierControl({
162 historyCursor: "c",
163 hasMoreHistory: true,
164 loading: false,
165 error: null,
166 expanded: false,
167 latestLocalTurnOrdinal: null,
168 }),
169 ).toBe(true);
170 expect(
171 shouldShowLoadEarlierControl({
172 historyCursor: null,
173 hasMoreHistory: false,
174 loading: false,
175 error: "Could not load earlier activity.",
176 expanded: true,
177 latestLocalTurnOrdinal: null,
178 }),
179 ).toBe(true);
180 expect(
181 shouldShowLoadEarlierControl({
182 historyCursor: null,
183 hasMoreHistory: false,
184 loading: false,
185 error: null,
186 expanded: false,
187 latestLocalTurnOrdinal: null,
188 }),
189 ).toBe(false);
190 });
191
192 it("retains merged older rows when a live turn-item update arrives", () => {
193 const recent = [row(2), row(3)];
194 const projection = {
195 ...v2Projection,
196 turnItems: recent.map((entry) => entry.item),
197 visibleTurnItems: recent.map((entry, position) => ({ ...entry, position })),
198 };
199 const merged = mergeOlderHistoryIntoProjection(projection, [row(0), row(1)]);
200 const liveItem = {
201 ...row(3).item,
202 output: "streamed",
203 };
204 const next = applyOrchestrationV2ProjectionEvent(merged, {
205 id: "event-live-item" as never,
206 type: "turn-item.updated",
207 threadId: v2ThreadId,
208 occurredAt: NOW,
209 payload: liveItem,
210 } as never);
211 expect(next?.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
212 "item-0",
213 "item-1",
214 "item-2",
215 "item-3",
216 ]);
217 expect(next?.visibleTurnItems[3]?.item).toEqual(liveItem);
218 });
219
220 it("keeps a history-page interrupt result visible when its request was retained at cold open", () => {
221 // Cold bounded window: only a recent filler. Request was retained in
222 // turnItems (not visible). Result arrives later via load-earlier merge.
223 const runId = "run-interrupt-cold" as never;
224 const nodeId = "node-interrupt-cold" as never;
225 const recent = row(5);
226 const requestItem = {
227 id: "item-interrupt-request" as never,
228 type: "run_interrupt_request" as const,
229 threadId: v2ThreadId,
230 runId,
231 nodeId,
232 providerThreadId: null,
233 providerTurnId: null,
234 nativeItemRef: null,
235 parentItemId: null,
236 ordinal: 1,
237 status: "completed" as const,
238 title: null,
239 message: "Stopping",
240 startedAt: NOW,
241 completedAt: NOW,
242 updatedAt: NOW,
243 };
244 const resultItem = {
245 id: "item-interrupt-result" as never,
246 type: "run_interrupt_result" as const,
247 threadId: v2ThreadId,
248 runId,
249 nodeId,
250 providerThreadId: null,
251 providerTurnId: null,
252 nativeItemRef: null,
253 parentItemId: null,
254 ordinal: 2,
255 status: "completed" as const,
256 title: null,
257 message: "Stopped",
258 startedAt: NOW,
259 completedAt: NOW,
260 updatedAt: NOW,
261 };
262 const resultRow = {
263 position: 0,
264 visibility: "local" as const,
265 sourceThreadId: v2ThreadId,
266 sourceItemId: resultItem.id,
267 item: resultItem,
268 };
269 const coldOpen = {
270 ...v2Projection,
271 runs: [
272 {
273 id: runId,
274 threadId: v2ThreadId,
275 ordinal: 1,
276 providerInstanceId: "codex" as never,
277 modelSelection: { instanceId: "codex" as never, model: "gpt-5" },
278 providerThreadId: null,
279 userMessageId: "message-1" as never,
280 rootNodeId: nodeId,
281 activeAttemptId: null,
282 status: "running" as const,
283 requestedAt: NOW,
284 startedAt: NOW,
285 completedAt: null,
286 checkpointId: null,
287 contextHandoffId: null,
288 },
289 ],
290 attempts: [
291 {
292 id: "attempt-1" as never,
293 runId,
294 attemptOrdinal: 1,
295 rootNodeId: nodeId,
296 providerInstanceId: "codex" as never,
297 providerThreadId: "provider-thread-1" as never,
298 providerTurnId: null,
299 reason: "initial" as const,
300 status: "running" as const,
301 startedAt: NOW,
302 completedAt: null,
303 },
304 ],
305 // Request retained from full projection; no result yet in the window.
306 turnItems: [requestItem, recent.item],
307 visibleTurnItems: [{ ...recent, position: 0 }],
308 };
309
310 const requestMerged = mergeOlderHistoryIntoProjection(coldOpen, [
311 {
312 position: 0,
313 visibility: "local",
314 sourceThreadId: v2ThreadId,
315 sourceItemId: requestItem.id,
316 item: requestItem,
317 },
318 ]);
319 expect(requestMerged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
320 "item-interrupt-request",
321 "item-5",
322 ]);
323
324 const rolledBackRequestMerged = mergeOlderHistoryIntoProjection(
325 {
326 ...coldOpen,
327 runs: coldOpen.runs.map((run) => ({
328 ...run,
329 status: "rolled_back" as const,
330 completedAt: NOW,
331 })),
332 },
333 [
334 {
335 position: 0,
336 visibility: "local",
337 sourceThreadId: v2ThreadId,
338 sourceItemId: requestItem.id,
339 item: requestItem,
340 },
341 ],
342 );
343 expect(
344 rolledBackRequestMerged.visibleTurnItems.map((entry) => String(entry.sourceItemId)),
345 ).toEqual(["item-5"]);
346
347 const merged = mergeOlderHistoryIntoProjection(coldOpen, [resultRow]);
348 expect(merged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
349 "item-interrupt-result",
350 "item-5",
351 ]);
352 expect(merged.turnItems.some((item) => item.type === "run_interrupt_request")).toBe(true);
353 expect(merged.turnItems.some((item) => item.type === "run_interrupt_result")).toBe(true);
354
355 const attemptSuperseded = applyOrchestrationV2ProjectionEvent(
356 merged,
357 {
358 id: "event-attempt-superseded" as never,
359 type: "run-attempt.updated",
360 threadId: v2ThreadId,
361 runId,
362 occurredAt: NOW,
363 payload: {
364 id: "attempt-1" as never,
365 runId,
366 attemptOrdinal: 1,
367 rootNodeId: nodeId,
368 providerInstanceId: "codex" as never,
369 providerThreadId: "provider-thread-1" as never,
370 providerTurnId: null,
371 reason: "initial",
372 status: "superseded",
373 startedAt: NOW,
374 completedAt: NOW,
375 },
376 } as never,
377 { partialTimeline: true },
378 );
379
380 expect(attemptSuperseded?.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
381 "item-interrupt-result",
382 "item-5",
383 ]);
384
385 // Without the cold-open retained request, the same update would hide the result.
386 const withoutRequest = {
387 ...merged,
388 turnItems: merged.turnItems.filter((item) => item.type !== "run_interrupt_request"),
389 };
390 const hidden = applyOrchestrationV2ProjectionEvent(
391 withoutRequest,
392 {
393 id: "event-attempt-superseded-missing" as never,
394 type: "run-attempt.updated",
395 threadId: v2ThreadId,
396 runId,
397 occurredAt: NOW,
398 payload: {
399 id: "attempt-1" as never,
400 runId,
401 attemptOrdinal: 1,
402 rootNodeId: nodeId,
403 providerInstanceId: "codex" as never,
404 providerThreadId: "provider-thread-1" as never,
405 providerTurnId: null,
406 reason: "initial",
407 status: "superseded",
408 startedAt: NOW,
409 completedAt: NOW,
410 },
411 } as never,
412 { partialTimeline: true },
413 );
414 expect(hidden?.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual(["item-5"]);
415 });
416});
packages/client-runtime/src/state/threadHistoryMerge.ts+156−0
@@ -0,0 +1,156 @@
1import type {
2 OrchestrationV2ProjectedTurnItem,
3 OrchestrationV2ThreadProjection,
4 OrchestrationV2TurnItem,
5} from "@t3tools/contracts";
6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
7
8export type ThreadHistoryMeta = {
9 readonly historyCursor: string | null;
10 readonly hasMoreHistory: boolean;
11 readonly loading: boolean;
12 readonly error: string | null;
13 /** True after at least one older page was merged; skip monolithic cache growth. */
14 readonly expanded: boolean;
15 /**
16 * Max local turn ordinal known for partial progressive windows. Null when
17 * unknown (legacy cache) or when history meta is cleared for a full snapshot.
18 */
19 readonly latestLocalTurnOrdinal: number | null;
20};
21
22export const EMPTY_THREAD_HISTORY_META: ThreadHistoryMeta = {
23 historyCursor: null,
24 hasMoreHistory: false,
25 loading: false,
26 error: null,
27 expanded: false,
28 latestLocalTurnOrdinal: null,
29};
30
31/** Whether a history-page response still matches the cursor that started it. */
32export function isActiveHistoryRequestCursor(
33 requestCursor: string,
34 history: { readonly historyCursor: string | null },
35): boolean {
36 return history.historyCursor === requestCursor;
37}
38
39/**
40 * Clear history loading after an interrupted load-earlier only when the
41 * request cursor is still active. Does not touch stream/status/error.
42 */
43export function clearActiveHistoryLoading(
44 requestCursor: string,
45 history: ThreadHistoryMeta,
46): ThreadHistoryMeta {
47 if (!isActiveHistoryRequestCursor(requestCursor, history) || !history.loading) {
48 return history;
49 }
50 return { ...history, loading: false };
51}
52
53function projectedItemKey(row: OrchestrationV2ProjectedTurnItem): string {
54 return `${row.sourceThreadId}:${row.sourceItemId}`;
55}
56
57function renumberVisible(
58 rows: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
59): OrchestrationV2ProjectedTurnItem[] {
60 return rows.map((row, position) => (row.position === position ? row : { ...row, position }));
61}
62
63/**
64 * Merge an older history page into the live projection. Dedupes by
65 * sourceThreadId + sourceItemId, keeps chronological order (older first), and
66 * preserves newer live rows already present in state.
67 */
68export function mergeOlderHistoryIntoProjection(
69 projection: OrchestrationV2ThreadProjection,
70 olderItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
71): OrchestrationV2ThreadProjection {
72 if (olderItems.length === 0) {
73 return projection;
74 }
75
76 const existingKeys = new Set(projection.visibleTurnItems.map(projectedItemKey));
77 const turnItemById = new Map<string, OrchestrationV2TurnItem>();
78 for (const item of projection.turnItems) {
79 turnItemById.set(String(item.id), item);
80 }
81 const prepended: OrchestrationV2ProjectedTurnItem[] = [];
82 for (const pageRow of olderItems) {
83 let row = pageRow;
84 const key = projectedItemKey(row);
85 if (existingKeys.has(key)) {
86 continue;
87 }
88 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {
89 const currentItem = turnItemById.get(String(row.sourceItemId));
90 if (currentItem !== undefined) {
91 // A live event may have hidden this item while the page was in flight.
92 // Do not resurrect the stale page copy. Retained interrupt requests are
93 // the exception: they intentionally exist outside the bounded visible
94 // window and should become visible when their history page arrives.
95 if (currentItem.type !== "run_interrupt_request") {
96 continue;
97 }
98 if (
99 !isOrchestrationV2TurnItemVisible({
100 item: currentItem,
101 runs: projection.runs,
102 attempts: projection.attempts,
103 items: projection.turnItems,
104 })
105 ) {
106 continue;
107 }
108 row = { ...row, item: currentItem };
109 }
110 }
111 existingKeys.add(key);
112 prepended.push(row);
113 }
114 if (prepended.length === 0) {
115 return projection;
116 }
117
118 const visibleTurnItems = renumberVisible([...prepended, ...projection.visibleTurnItems]);
119
120 for (const row of prepended) {
121 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {
122 if (!turnItemById.has(String(row.sourceItemId))) {
123 turnItemById.set(String(row.sourceItemId), row.item);
124 }
125 }
126 }
127
128 return {
129 ...projection,
130 turnItems: [...turnItemById.values()],
131 visibleTurnItems,
132 };
133}
134
135export function applyHistoryPageMeta(
136 current: ThreadHistoryMeta,
137 page: {
138 readonly nextCursor: string | null;
139 readonly hasMoreHistory: boolean;
140 },
141): ThreadHistoryMeta {
142 return {
143 historyCursor: page.nextCursor,
144 hasMoreHistory: page.hasMoreHistory,
145 loading: false,
146 error: null,
147 expanded: true,
148 // Preserve the partial-timeline watermark across load-earlier pages.
149 latestLocalTurnOrdinal: current.latestLocalTurnOrdinal,
150 };
151}
152
153/** Whether a client timeline should render its load-earlier control. */
154export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean {
155 return history.hasMoreHistory || history.error !== null;
156}
packages/client-runtime/src/state/threadSnapshotHttp.ts+42−13
@@ -18,6 +18,30 @@ import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAut · 17 unmodified lines
18// delays the transition to live data on the first open, not the initial paint.18// delays the transition to live data on the first open, not the initial paint.
19const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;19const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
20 20
21/** Progressive history metadata returned by a bounded snapshot loader. */
22export type ThreadSnapshotHistoryMeta = {
23 readonly historyCursor: string | null;
24 readonly hasMoreHistory: boolean;
25 /** Max local turn ordinal from the full projection; optional on older servers. */
26 readonly latestLocalTurnOrdinal?: number | null;
27};
28
29/**
30 * Outcome of an HTTP thread-detail snapshot load.
31 *
32 * - `present`: snapshot body is available (seed projection, resume via socket).
33 * - `missing`: server definitively reported the thread does not exist (404).
34 * - `unavailable`: transport/timeout/5xx/etc.; fall back to the socket path.
35 */
36export type ThreadSnapshotLoadResult =
37 | {
38 readonly _tag: "present";
39 readonly snapshot: OrchestrationV2ThreadDetailSnapshot;
40 readonly history?: ThreadSnapshotHistoryMeta;
41 }
42 | { readonly _tag: "missing" }
43 | { readonly _tag: "unavailable" };
44
21/**45/**
22 * Load a thread's detail snapshot over HTTP instead of embedding it in the46 * Load a thread's detail snapshot over HTTP instead of embedding it in the
23 * WebSocket subscription's first frame. The response is gzip-compressible by47 * WebSocket subscription's first frame. The response is gzip-compressible by
@@ -49,10 +73,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( · 25 unmodified lines
49export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;73export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;
50 74
51/**75/**
52 * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it76 * Loads a thread's detail snapshot over HTTP.
53 * cannot be loaded (so the caller falls back to the socket-embedded snapshot).77 *
54 * Decouples the thread state machine from the underlying HTTP + DPoP details and78 * Distinguishes a definitive missing thread (HTTP 404) from transient snapshot
55 * keeps them out of test contexts.79 * unavailability so the thread state machine can mark the thread deleted without
80 * opening a socket subscription, while still falling back to the socket when the
81 * HTTP path is merely unavailable.
56 */82 */
57export class ThreadSnapshotLoader extends Context.Service<83export class ThreadSnapshotLoader extends Context.Service<
58 ThreadSnapshotLoader,84 ThreadSnapshotLoader,
@@ -60,7 +86,7 @@ export class ThreadSnapshotLoader extends Context.Service< · 1 unmodified line
60 readonly load: (86 readonly load: (
61 prepared: PreparedConnection,87 prepared: PreparedConnection,
62 threadId: ThreadId,88 threadId: ThreadId,
63 ) => Effect.Effect<Option.Option<OrchestrationV2ThreadDetailSnapshot>>;89 ) => Effect.Effect<ThreadSnapshotLoadResult>;
64 }90 }
65>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}91>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
66 92
@@ -80,19 +106,22 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 13 unmodified lines
80 return ThreadSnapshotLoader.of({106 return ThreadSnapshotLoader.of({
81 load: (prepared: PreparedConnection, threadId: ThreadId) =>107 load: (prepared: PreparedConnection, threadId: ThreadId) =>
82 fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(108 fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(
83 Effect.map(Option.some<OrchestrationV2ThreadDetailSnapshot>),109 Effect.map(
110 (snapshot): ThreadSnapshotLoadResult => ({
111 _tag: "present",
112 snapshot,
113 }),
114 ),
84 Effect.provideService(HttpClient.HttpClient, httpClient),115 Effect.provideService(HttpClient.HttpClient, httpClient),
85 // A genuinely missing thread (404) is expected — the socket116 // A genuinely missing thread (404) is definitive: do not fall back to
86 // subscription is the source of truth for thread existence and will117 // the socket or retry. Callers mark the thread deleted and clear cache.
87 // surface the deletion — so don't treat it as an error worth warning
88 // about; just defer to the socket path.
89 Effect.catchTags({118 Effect.catchTags({
90 EnvironmentResourceNotFoundError: () =>119 EnvironmentResourceNotFoundError: () =>
91 Effect.logDebug(120 Effect.logDebug(
92 "Thread snapshot not found over HTTP; deferring to the socket subscription.",121 "Thread snapshot not found over HTTP; treating the thread as deleted.",
93 ).pipe(122 ).pipe(
94 Effect.annotateLogs({ threadId }),123 Effect.annotateLogs({ threadId }),
95 Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()),124 Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
96 ),125 ),
97 }),126 }),
98 Effect.catchCause((cause) =>127 Effect.catchCause((cause) =>
@@ -100,7 +129,7 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 1 unmodified line
100 "Could not load the thread snapshot over HTTP; using the socket snapshot instead.",129 "Could not load the thread snapshot over HTTP; using the socket snapshot instead.",
101 ).pipe(130 ).pipe(
102 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),131 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
103 Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()),132 Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
104 ),133 ),
105 ),134 ),
106 ),135 ),
packages/client-runtime/src/state/threadState.ts+9−0
@@ -1,16 +1,25 @@
1import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";1import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3 3
4import { EMPTY_THREAD_HISTORY_META, type ThreadHistoryMeta } from "./threadHistoryMerge.ts";
5
4export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";6export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
5 7
6export interface EnvironmentThreadState {8export interface EnvironmentThreadState {
7 readonly data: Option.Option<OrchestrationV2ThreadProjection>;9 readonly data: Option.Option<OrchestrationV2ThreadProjection>;
8 readonly status: EnvironmentThreadStatus;10 readonly status: EnvironmentThreadStatus;
9 readonly error: Option.Option<string>;11 readonly error: Option.Option<string>;
12 /**
13 * Progressive history cursor for bounded hydration. Absent/cleared on full
14 * snapshot paths. Errors here are thread/history-local and must not be
15 * promoted into the environment disconnect path.
16 */
17 readonly history: ThreadHistoryMeta;
10}18}
11 19
12export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {20export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
13 data: Option.none(),21 data: Option.none(),
14 status: "empty",22 status: "empty",
15 error: Option.none(),23 error: Option.none(),
24 history: EMPTY_THREAD_HISTORY_META,
16};25};
packages/client-runtime/src/state/threads-sync.test.ts+601−4
@@ -3,9 +3,11 @@ import { · 2 unmodified lines
3 EventId,3 EventId,
4 ORCHESTRATION_V2_WS_METHODS,4 ORCHESTRATION_V2_WS_METHODS,
5 ThreadId,5 ThreadId,
6 TurnItemId,
6 type OrchestrationV2ThreadDetailSnapshot,7 type OrchestrationV2ThreadDetailSnapshot,
7 type OrchestrationV2ThreadProjection,8 type OrchestrationV2ThreadProjection,
8 type OrchestrationV2ThreadStreamItem,9 type OrchestrationV2ThreadStreamItem,
10 type OrchestrationV2TurnItem,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
10import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";13import * as DateTime from "effect/DateTime";
@@ -36,6 +38,7 @@ import { · 24 unmodified lines
36 makeEnvironmentThreadState,38 makeEnvironmentThreadState,
37 ThreadSnapshotLoader,39 ThreadSnapshotLoader,
38 type EnvironmentThreadState,40 type EnvironmentThreadState,
41 type ThreadSnapshotLoadResult,
39} from "./threads.ts";42} from "./threads.ts";
40 43
41const TARGET = new PrimaryConnectionTarget({44const TARGET = new PrimaryConnectionTarget({
@@ -89,7 +92,12 @@ function awaitThreadState( · 47 unmodified lines
89 92
90const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {93const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
91 readonly cached?: OrchestrationV2ThreadProjection;94 readonly cached?: OrchestrationV2ThreadProjection;
92 readonly httpSnapshot?: Option.Option<OrchestrationV2ThreadDetailSnapshot>;95 readonly cachedHistory?: {
96 readonly historyCursor: string | null;
97 readonly hasMoreHistory: boolean;
98 readonly latestLocalTurnOrdinal?: number | null;
99 };
100 readonly httpSnapshot?: ThreadSnapshotLoadResult;
93 readonly completionMarker?: boolean;101 readonly completionMarker?: boolean;
94}) {102}) {
95 const inputs = yield* Queue.unbounded<TestThreadInput>();103 const inputs = yield* Queue.unbounded<TestThreadInput>();
@@ -138,8 +146,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 42 unmodified lines
138 Ref.update(loaderCalls, (count) => count + 1).pipe(146 Ref.update(loaderCalls, (count) => count + 1).pipe(
139 Effect.as(147 Effect.as(
140 threadId === THREAD_ID148 threadId === THREAD_ID
141 ? (options?.httpSnapshot ?? Option.none<OrchestrationV2ThreadDetailSnapshot>())149 ? (options?.httpSnapshot ??
142 : Option.none<OrchestrationV2ThreadDetailSnapshot>(),150 ({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult))
151 : ({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
143 ),152 ),
144 ),153 ),
145 });154 });
@@ -161,6 +170,17 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 15 unmodified lines
161 ? Option.some({170 ? Option.some({
162 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,171 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
163 projection: options.cached,172 projection: options.cached,
173 ...(options.cachedHistory === undefined
174 ? {}
175 : {
176 historyCursor: options.cachedHistory.historyCursor,
177 hasMoreHistory: options.cachedHistory.hasMoreHistory,
178 ...(options.cachedHistory.latestLocalTurnOrdinal === undefined
179 ? {}
180 : {
181 latestLocalTurnOrdinal: options.cachedHistory.latestLocalTurnOrdinal,
182 }),
183 }),
164 })184 })
165 : Option.none(),185 : Option.none(),
166 ),186 ),
@@ -318,7 +338,10 @@ describe("EnvironmentThreads", () => { · 151 unmodified lines
318 thread: { ...BASE_PROJECTION.thread, title: "HTTP title" },338 thread: { ...BASE_PROJECTION.thread, title: "HTTP title" },
319 };339 };
320 const harness = yield* makeHarness({340 const harness = yield* makeHarness({
321 httpSnapshot: Option.some({ snapshotSequence: 1, projection: httpProjection }),341 httpSnapshot: {
342 _tag: "present",
343 snapshot: { snapshotSequence: 1, projection: httpProjection },
344 },
322 });345 });
323 // No socket snapshot is pushed; only a live event arrives over the socket.346 // No socket snapshot is pushed; only a live event arrives over the socket.
324 // It can only be applied if the HTTP snapshot already seeded the thread.347 // It can only be applied if the HTTP snapshot already seeded the thread.
@@ -340,6 +363,580 @@ describe("EnvironmentThreads", () => { · 15 unmodified lines
340 }),363 }),
341 );364 );
342 365
366 it.effect("installs bounded snapshot history meta and resumes via afterSequence", () =>
367 Effect.gen(function* () {
368 const httpProjection: OrchestrationV2ThreadProjection = {
369 ...BASE_PROJECTION,
370 thread: { ...BASE_PROJECTION.thread, title: "Bounded title" },
371 };
372 const harness = yield* makeHarness({
373 httpSnapshot: {
374 _tag: "present",
375 snapshot: { snapshotSequence: 11, projection: httpProjection },
376 history: {
377 historyCursor: "opaque-cursor",
378 hasMoreHistory: true,
379 },
380 },
381 });
382 yield* Queue.offer(harness.inputs, titleUpdated("Live after bounded", 12));
383
384 const state = yield* awaitThreadState(
385 harness.observed,
386 (value) =>
387 value.status === "live" &&
388 Option.isSome(value.data) &&
389 value.data.value.thread.title === "Live after bounded" &&
390 value.history.hasMoreHistory,
391 );
392
393 expect(state.history).toMatchObject({
394 historyCursor: "opaque-cursor",
395 hasMoreHistory: true,
396 loading: false,
397 error: null,
398 expanded: false,
399 });
400 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(11);
401 // Bounded HTTP success must not require a socket snapshot frame.
402 expect(Option.getOrThrow(state.data).thread.title).toBe("Live after bounded");
403 }),
404 );
405
406 it.effect("persists progressive history meta with a settled bounded snapshot", () =>
407 Effect.gen(function* () {
408 const httpProjection: OrchestrationV2ThreadProjection = {
409 ...BASE_PROJECTION,
410 thread: { ...BASE_PROJECTION.thread, title: "Bounded cache title" },
411 };
412 const harness = yield* makeHarness({
413 httpSnapshot: {
414 _tag: "present",
415 snapshot: { snapshotSequence: 4, projection: httpProjection },
416 history: {
417 historyCursor: "cursor-oldest",
418 hasMoreHistory: true,
419 },
420 },
421 });
422 // Bounded install alone (settled) must enqueue progressive meta with the
423 // projection. Never persist the partial window as a complete full snapshot.
424 yield* awaitThreadState(
425 harness.observed,
426 (value) =>
427 Option.isSome(value.data) &&
428 value.data.value.thread.title === "Bounded cache title" &&
429 value.history.historyCursor === "cursor-oldest",
430 );
431 yield* TestClock.adjust("500 millis");
432 yield* Effect.yieldNow;
433
434 const savedAll = yield* Ref.get(harness.savedThreads);
435 expect(savedAll.length).toBeGreaterThanOrEqual(1);
436 const first = savedAll[0];
437 expect(first?.snapshotSequence).toBe(4);
438 expect(first?.projection.thread.title).toBe("Bounded cache title");
439 expect(first?.historyCursor).toBe("cursor-oldest");
440 expect(first?.hasMoreHistory).toBe(true);
441 // No earlier complete-looking entry without progressive meta.
442 expect(
443 savedAll.some(
444 (entry) =>
445 entry.projection.thread.title === "Bounded cache title" &&
446 entry.historyCursor === undefined &&
447 entry.hasMoreHistory === undefined,
448 ),
449 ).toBe(false);
450
451 // A later live update still carries the progressive cursor.
452 yield* Queue.offer(harness.inputs, titleUpdated("Settled bounded", 5));
453 yield* awaitThreadState(
454 harness.observed,
455 (value) =>
456 value.status === "live" &&
457 Option.isSome(value.data) &&
458 value.data.value.thread.title === "Settled bounded",
459 );
460 yield* TestClock.adjust("500 millis");
461 yield* Effect.yieldNow;
462
463 const saved = (yield* Ref.get(harness.savedThreads)).at(-1);
464 expect(saved?.snapshotSequence).toBe(5);
465 expect(saved?.projection.thread.title).toBe("Settled bounded");
466 expect(saved?.historyCursor).toBe("cursor-oldest");
467 expect(saved?.hasMoreHistory).toBe(true);
468 }),
469 );
470
471 it.effect("warm resume restores progressive history meta and skips HTTP", () =>
472 Effect.gen(function* () {
473 const harness = yield* makeHarness({
474 cached: {
475 ...BASE_PROJECTION,
476 thread: { ...BASE_PROJECTION.thread, title: "Cached bounded" },
477 },
478 cachedHistory: {
479 historyCursor: "warm-cursor",
480 hasMoreHistory: true,
481 },
482 });
483
484 // Apply a live event so the subscription is known to have resumed from cache.
485 yield* Queue.offer(
486 harness.inputs,
487 titleUpdated("Cached bounded live", CACHED_SNAPSHOT_SEQUENCE + 1),
488 );
489 const state = yield* awaitThreadState(
490 harness.observed,
491 (value) =>
492 value.status === "live" &&
493 Option.isSome(value.data) &&
494 value.data.value.thread.title === "Cached bounded live" &&
495 value.history.hasMoreHistory,
496 );
497
498 expect(state.history).toMatchObject({
499 historyCursor: "warm-cursor",
500 hasMoreHistory: true,
501 expanded: false,
502 });
503 // Warm progressive cache must not re-download; resume via afterSequence.
504 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
505 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE);
506 }),
507 );
508
509 it.effect("legacy warm cache without history meta stays complete (no false load-earlier)", () =>
510 Effect.gen(function* () {
511 const harness = yield* makeHarness({ cached: BASE_PROJECTION });
512 const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data));
513 expect(state.history).toMatchObject({
514 historyCursor: null,
515 hasMoreHistory: false,
516 expanded: false,
517 });
518 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
519 }),
520 );
521
522 it.effect("socket snapshot clears progressive history meta left from a bounded window", () =>
523 Effect.gen(function* () {
524 const harness = yield* makeHarness({
525 cached: {
526 ...BASE_PROJECTION,
527 thread: { ...BASE_PROJECTION.thread, title: "Warm progressive" },
528 },
529 cachedHistory: {
530 historyCursor: "stale-cursor",
531 hasMoreHistory: true,
532 },
533 });
534
535 yield* Queue.offer(
536 harness.inputs,
537 snapshot(
538 {
539 ...BASE_PROJECTION,
540 thread: { ...BASE_PROJECTION.thread, title: "Full socket snapshot" },
541 },
542 CACHED_SNAPSHOT_SEQUENCE + 1,
543 ),
544 );
545
546 const state = yield* awaitThreadState(
547 harness.observed,
548 (value) =>
549 value.status === "live" &&
550 Option.isSome(value.data) &&
551 value.data.value.thread.title === "Full socket snapshot" &&
552 value.history.historyCursor === null &&
553 value.history.hasMoreHistory === false,
554 );
555
556 expect(state.history).toMatchObject({
557 historyCursor: null,
558 hasMoreHistory: false,
559 expanded: false,
560 loading: false,
561 error: null,
562 });
563
564 // Settled full snapshot persistence must not keep the stale cursor.
565 yield* TestClock.adjust("500 millis");
566 yield* Effect.yieldNow;
567 const saved = (yield* Ref.get(harness.savedThreads)).at(-1);
568 expect(saved?.projection.thread.title).toBe("Full socket snapshot");
569 expect(saved?.historyCursor).toBeUndefined();
570 expect(saved?.hasMoreHistory).toBeUndefined();
571 }),
572 );
573
574 it.effect("live events preserve progressive history meta under atomic setThread", () =>
575 Effect.gen(function* () {
576 const harness = yield* makeHarness({
577 httpSnapshot: {
578 _tag: "present",
579 snapshot: {
580 snapshotSequence: 8,
581 projection: {
582 ...BASE_PROJECTION,
583 thread: { ...BASE_PROJECTION.thread, title: "Bounded seed" },
584 },
585 },
586 history: {
587 historyCursor: "keep-me",
588 hasMoreHistory: true,
589 },
590 },
591 });
592
593 yield* Queue.offer(harness.inputs, titleUpdated("Live preserves meta", 9));
594 const state = yield* awaitThreadState(
595 harness.observed,
596 (value) =>
597 value.status === "live" &&
598 Option.isSome(value.data) &&
599 value.data.value.thread.title === "Live preserves meta",
600 );
601
602 expect(state.history).toMatchObject({
603 historyCursor: "keep-me",
604 hasMoreHistory: true,
605 expanded: false,
606 });
607 }),
608 );
609
610 it.effect("bounded HTTP install sets projection and progressive meta atomically", () =>
611 Effect.gen(function* () {
612 // One setThread with explicit history (not applyItem reset + later meta).
613 const harness = yield* makeHarness({
614 httpSnapshot: {
615 _tag: "present",
616 snapshot: {
617 snapshotSequence: 2,
618 projection: {
619 ...BASE_PROJECTION,
620 thread: { ...BASE_PROJECTION.thread, title: "Bounded atomic install" },
621 },
622 },
623 history: {
624 historyCursor: "post-install-cursor",
625 hasMoreHistory: true,
626 },
627 },
628 });
629
630 const state = yield* awaitThreadState(
631 harness.observed,
632 (value) =>
633 Option.isSome(value.data) &&
634 value.data.value.thread.title === "Bounded atomic install" &&
635 value.history.historyCursor === "post-install-cursor" &&
636 value.history.hasMoreHistory === true,
637 );
638
639 expect(state.history).toMatchObject({
640 historyCursor: "post-install-cursor",
641 hasMoreHistory: true,
642 loading: false,
643 error: null,
644 expanded: false,
645 });
646 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(2);
647 }),
648 );
649
650 it.effect("dropped partial-timeline turn item is a true applyItem no-op", () =>
651 Effect.gen(function* () {
652 const recent = {
653 id: TurnItemId.make("item-window"),
654 threadId: THREAD_ID,
655 runId: null,
656 nodeId: null,
657 providerThreadId: null,
658 providerTurnId: null,
659 nativeItemRef: null,
660 parentItemId: null,
661 ordinal: 10,
662 status: "completed" as const,
663 title: null,
664 startedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
665 completedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
666 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
667 type: "command_execution" as const,
668 input: "pwd",
669 output: "ok",
670 exitCode: 0,
671 } satisfies OrchestrationV2TurnItem;
672 const recentRow = {
673 position: 0,
674 visibility: "local" as const,
675 sourceThreadId: THREAD_ID,
676 sourceItemId: recent.id,
677 item: recent,
678 };
679 const boundedProjection: OrchestrationV2ThreadProjection = {
680 ...BASE_PROJECTION,
681 thread: { ...BASE_PROJECTION.thread, title: "Partial noop" },
682 turnItems: [recent],
683 visibleTurnItems: [recentRow],
684 };
685 const harness = yield* makeHarness({
686 httpSnapshot: {
687 _tag: "present",
688 snapshot: {
689 snapshotSequence: 5,
690 projection: boundedProjection,
691 latestLocalTurnOrdinal: 10,
692 },
693 history: {
694 historyCursor: "partial-cursor",
695 hasMoreHistory: true,
696 latestLocalTurnOrdinal: 10,
697 },
698 },
699 });
700
701 const seeded = yield* awaitThreadState(
702 harness.observed,
703 (value) =>
704 value.status === "live" &&
705 Option.isSome(value.data) &&
706 value.data.value.thread.title === "Partial noop" &&
707 value.history.historyCursor === "partial-cursor",
708 );
709 const seededProjection = Option.getOrThrow(seeded.data);
710 yield* TestClock.adjust("500 millis");
711 yield* Effect.yieldNow;
712 const savedBefore = (yield* Ref.get(harness.savedThreads)).length;
713
714 const older = {
715 ...recent,
716 id: TurnItemId.make("item-old-outside"),
717 ordinal: 3,
718 output: "must-not-append",
719 } satisfies OrchestrationV2TurnItem;
720 yield* Queue.offer(harness.inputs, {
721 kind: "event",
722 sequence: 6,
723 event: {
724 id: EventId.make("event-old-partial"),
725 type: "turn-item.updated",
726 threadId: THREAD_ID,
727 occurredAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
728 payload: older,
729 },
730 });
731
732 // Allow the event to be processed without requiring a state transition.
733 for (let attempt = 0; attempt < 30; attempt += 1) {
734 yield* Effect.yieldNow;
735 }
736 // Drive a later unrelated title update so we know the stream continued.
737 yield* Queue.offer(harness.inputs, titleUpdated("After dropped event", 7));
738 const after = yield* awaitThreadState(
739 harness.observed,
740 (value) =>
741 value.status === "live" &&
742 Option.isSome(value.data) &&
743 value.data.value.thread.title === "After dropped event",
744 );
745
746 // The dropped event must not have appended the old item.
747 expect(Option.getOrThrow(after.data).turnItems.map((item) => String(item.id))).toEqual([
748 String(recent.id),
749 ]);
750 // Watermark unchanged (only advanced on successful newer turn-item applies).
751 expect(after.history.latestLocalTurnOrdinal).toBe(10);
752 // No persistence enqueue from the dropped event itself.
753 const savedAfterDrop = (yield* Ref.get(harness.savedThreads)).length;
754 expect(savedAfterDrop).toBe(savedBefore);
755 // Seeded projection reference path: event path kept partial meta intact.
756 expect(after.history.historyCursor).toBe("partial-cursor");
757 expect(after.history.hasMoreHistory).toBe(true);
758 // Title event applied; drop itself did not clear progressive meta.
759 expect(seededProjection.turnItems.map((item) => String(item.id))).toEqual([
760 String(recent.id),
761 ]);
762 }),
763 );
764
765 it.effect("installs and advances latestLocalTurnOrdinal for partial progressive windows", () =>
766 Effect.gen(function* () {
767 const harness = yield* makeHarness({
768 httpSnapshot: {
769 _tag: "present",
770 snapshot: {
771 snapshotSequence: 3,
772 projection: {
773 ...BASE_PROJECTION,
774 thread: { ...BASE_PROJECTION.thread, title: "Watermark seed" },
775 },
776 latestLocalTurnOrdinal: 15,
777 },
778 history: {
779 historyCursor: "wm-cursor",
780 hasMoreHistory: true,
781 latestLocalTurnOrdinal: 15,
782 },
783 },
784 });
785
786 const seeded = yield* awaitThreadState(
787 harness.observed,
788 (value) =>
789 Option.isSome(value.data) &&
790 value.data.value.thread.title === "Watermark seed" &&
791 value.history.latestLocalTurnOrdinal === 15,
792 );
793 expect(seeded.history.latestLocalTurnOrdinal).toBe(15);
794
795 yield* TestClock.adjust("500 millis");
796 yield* Effect.yieldNow;
797 const savedSeed = (yield* Ref.get(harness.savedThreads)).at(-1);
798 expect(savedSeed?.latestLocalTurnOrdinal).toBe(15);
799
800 const newer = {
801 id: TurnItemId.make("item-newer-live"),
802 threadId: THREAD_ID,
803 runId: null,
804 nodeId: null,
805 providerThreadId: null,
806 providerTurnId: null,
807 nativeItemRef: null,
808 parentItemId: null,
809 ordinal: 22,
810 status: "completed" as const,
811 title: null,
812 startedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
813 completedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
814 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
815 type: "command_execution" as const,
816 input: "echo newer",
817 output: "newer",
818 exitCode: 0,
819 } satisfies OrchestrationV2TurnItem;
820
821 yield* Queue.offer(harness.inputs, {
822 kind: "event",
823 sequence: 4,
824 event: {
825 id: EventId.make("event-newer-item"),
826 type: "turn-item.updated",
827 threadId: THREAD_ID,
828 occurredAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
829 payload: newer,
830 },
831 });
832
833 const advanced = yield* awaitThreadState(
834 harness.observed,
835 (value) =>
836 value.status === "live" &&
837 Option.isSome(value.data) &&
838 value.history.latestLocalTurnOrdinal === 22 &&
839 value.data.value.turnItems.some((item) => String(item.id) === String(newer.id)),
840 );
841 expect(advanced.history.latestLocalTurnOrdinal).toBe(22);
842 expect(advanced.history.historyCursor).toBe("wm-cursor");
843 }),
844 );
845
846 it.effect("warm resume restores latestLocalTurnOrdinal from progressive cache", () =>
847 Effect.gen(function* () {
848 const harness = yield* makeHarness({
849 cached: {
850 ...BASE_PROJECTION,
851 thread: { ...BASE_PROJECTION.thread, title: "Warm watermark" },
852 },
853 cachedHistory: {
854 historyCursor: "warm-wm-cursor",
855 hasMoreHistory: true,
856 latestLocalTurnOrdinal: 33,
857 },
858 });
859
860 const state = yield* awaitThreadState(
861 harness.observed,
862 (value) =>
863 Option.isSome(value.data) &&
864 value.data.value.thread.title === "Warm watermark" &&
865 value.history.latestLocalTurnOrdinal === 33,
866 );
867 expect(state.history).toMatchObject({
868 historyCursor: "warm-wm-cursor",
869 hasMoreHistory: true,
870 latestLocalTurnOrdinal: 33,
871 expanded: false,
872 });
873 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
874 }),
875 );
876
877 it.effect("marks a cold definitive HTTP miss deleted without socket subscribe or retry", () =>
878 Effect.gen(function* () {
879 const harness = yield* makeHarness({
880 httpSnapshot: { _tag: "missing" },
881 });
882
883 const state = yield* awaitThreadState(
884 harness.observed,
885 (value) => value.status === "deleted",
886 );
887
888 expect(Option.isNone(state.data)).toBe(true);
889 expect(Option.isNone(state.error)).toBe(true);
890 expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1);
891 expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]);
892 expect(yield* Ref.get(harness.subscriptionCount)).toBe(0);
893
894 // A definitive miss must not schedule the expected-failure retry path.
895 yield* TestClock.adjust("1 second");
896 for (let attempt = 0; attempt < 20; attempt += 1) {
897 yield* Effect.yieldNow;
898 }
899 yield* harness.replaceSession;
900 yield* Queue.offer(harness.wakeups, "application-active");
901 for (let attempt = 0; attempt < 20; attempt += 1) {
902 yield* Effect.yieldNow;
903 }
904 expect(yield* Ref.get(harness.subscriptionCount)).toBe(0);
905 expect(yield* Ref.get(harness.retryCount)).toBe(0);
906 expect(yield* Ref.get(harness.loaderCalls)).toBe(1);
907 expect((yield* Ref.get(harness.latest)).status).toBe("deleted");
908 }),
909 );
910
911 it.effect("falls back to the socket when the HTTP snapshot is only unavailable", () =>
912 Effect.gen(function* () {
913 const harness = yield* makeHarness({
914 httpSnapshot: { _tag: "unavailable" },
915 });
916
917 yield* Queue.offer(
918 harness.inputs,
919 snapshot({
920 ...BASE_PROJECTION,
921 thread: { ...BASE_PROJECTION.thread, title: "Socket title" },
922 }),
923 );
924
925 const state = yield* awaitThreadState(
926 harness.observed,
927 (value) =>
928 value.status === "live" &&
929 Option.isSome(value.data) &&
930 value.data.value.thread.title === "Socket title",
931 );
932
933 expect(Option.getOrThrow(state.data).thread.title).toBe("Socket title");
934 expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1);
935 expect(yield* Ref.get(harness.subscriptionCount)).toBe(1);
936 expect(yield* Ref.get(harness.removedThreads)).toEqual([]);
937 }),
938 );
939
343 it.effect("ignores replayed thread events at or below the snapshot sequence", () =>940 it.effect("ignores replayed thread events at or below the snapshot sequence", () =>
344 Effect.gen(function* () {941 Effect.gen(function* () {
345 const harness = yield* makeHarness({ cached: BASE_PROJECTION });942 const harness = yield* makeHarness({ cached: BASE_PROJECTION });
packages/client-runtime/src/state/threads.ts+384−34
@@ -11,8 +11,10 @@ import * as Effect from "effect/Effect"; · 10 unmodified lines
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";12import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";13import * as Ref from "effect/Ref";
14import * as Result from "effect/Result";
14import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";16import * as SubscriptionRef from "effect/SubscriptionRef";
17import { HttpClient } from "effect/unstable/http";
16import { Atom } from "effect/unstable/reactivity";18import { Atom } from "effect/unstable/reactivity";
17 19
18import { EnvironmentRegistry } from "../connection/registry.ts";20import { EnvironmentRegistry } from "../connection/registry.ts";
@@ -20,12 +22,26 @@ import { connectionProjectionPhase } from "../connection/model.ts"; · 1 unmodified line
20import { EnvironmentSupervisor } from "../connection/supervisor.ts";22import { EnvironmentSupervisor } from "../connection/supervisor.ts";
21import * as ConnectionWakeups from "../connection/wakeups.ts";23import * as ConnectionWakeups from "../connection/wakeups.ts";
22import { EnvironmentCacheStore } from "../platform/persistence.ts";24import { EnvironmentCacheStore } from "../platform/persistence.ts";
25import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
23import { subscribeDynamic } from "../rpc/client.ts";26import { subscribeDynamic } from "../rpc/client.ts";
24import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
25import { parseThreadKey, threadKey } from "./entities.ts";27import { parseThreadKey, threadKey } from "./entities.ts";
26import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";28import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
27import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
28import { followStreamInEnvironment } from "./runtime.ts";29import { followStreamInEnvironment } from "./runtime.ts";
30import {
31 ThreadHistoryController,
32 type ThreadHistoryLoadEarlierResult,
33} from "./threadHistoryController.ts";
34import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts";
35import {
36 applyHistoryPageMeta,
37 clearActiveHistoryLoading,
38 EMPTY_THREAD_HISTORY_META,
39 isActiveHistoryRequestCursor,
40 mergeOlderHistoryIntoProjection,
41 type ThreadHistoryMeta,
42} from "./threadHistoryMerge.ts";
43import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
44import { ThreadSnapshotLoader, type ThreadSnapshotLoadResult } from "./threadSnapshotHttp.ts";
29import {45import {
30 EMPTY_ENVIRONMENT_THREAD_STATE,46 EMPTY_ENVIRONMENT_THREAD_STATE,
31 type EnvironmentThreadState,47 type EnvironmentThreadState,
@@ -45,7 +61,57 @@ function formatThreadError(cause: Cause.Cause<unknown>): string { · 13 unmodified lines
45 : "Could not synchronize the thread.";61 : "Could not synchronize the thread.";
46}62}
47 63
48function shouldPersistThread(thread: OrchestrationV2ThreadProjection): boolean {64function formatHistoryError(error: unknown): string {
65 if (error instanceof Error && error.message.trim().length > 0) {
66 return error.message;
67 }
68 return "Could not load earlier activity.";
69}
70
71function historyMetaFromCachedSnapshot(
72 snapshot: OrchestrationV2ThreadDetailSnapshot,
73): ThreadHistoryMeta {
74 const historyCursor = snapshot.historyCursor ?? null;
75 const hasMoreHistory = snapshot.hasMoreHistory ?? false;
76 return {
77 historyCursor,
78 hasMoreHistory,
79 loading: false,
80 error: null,
81 // Cache never stores expanded progressive history (load-earlier growth).
82 expanded: false,
83 latestLocalTurnOrdinal: snapshot.latestLocalTurnOrdinal ?? null,
84 };
85}
86
87function snapshotToPersist(
88 snapshotSequence: number,
89 projection: OrchestrationV2ThreadProjection,
90 history: ThreadHistoryMeta,
91): OrchestrationV2ThreadDetailSnapshot {
92 // Persist progressive meta with the bounded window so warm resume restores the
93 // history cursor instead of looking like a complete full-projection cache hit.
94 if (history.hasMoreHistory || history.historyCursor !== null) {
95 return {
96 snapshotSequence,
97 projection,
98 historyCursor: history.historyCursor,
99 hasMoreHistory: history.hasMoreHistory,
100 latestLocalTurnOrdinal: history.latestLocalTurnOrdinal,
101 };
102 }
103 return { snapshotSequence, projection };
104}
105
106function shouldPersistThread(
107 thread: OrchestrationV2ThreadProjection,
108 history: ThreadHistoryMeta,
109): boolean {
110 // After the user loads older pages the in-memory timeline can grow large.
111 // Keep those expanded projections out of the monolithic cache.
112 if (history.expanded) {
113 return false;
114 }
49 return !thread.runs.some(115 return !thread.runs.some(
50 (run) => run.status === "preparing" || run.status === "starting" || run.status === "running",116 (run) => run.status === "preparing" || run.status === "starting" || run.status === "running",
51 );117 );
@@ -94,6 +160,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 42 unmodified lines
94 const supervisor = yield* EnvironmentSupervisor;160 const supervisor = yield* EnvironmentSupervisor;
95 const cache = yield* EnvironmentCacheStore;161 const cache = yield* EnvironmentCacheStore;
96 const snapshotLoader = yield* ThreadSnapshotLoader;162 const snapshotLoader = yield* ThreadSnapshotLoader;
163 const historyController = yield* Effect.serviceOption(ThreadHistoryController);
164 const httpClient = yield* Effect.serviceOption(HttpClient.HttpClient);
165 const dpopSigner = yield* Effect.serviceOption(ManagedRelayDpopSigner);
97 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);166 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);
98 const environmentId = supervisor.target.environmentId;167 const environmentId = supervisor.target.environmentId;
99 const cached = yield* cache.loadThread(environmentId, threadId).pipe(168 const cached = yield* cache.loadThread(environmentId, threadId).pipe(
@@ -109,10 +178,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 9 unmodified lines
109 ),178 ),
110 );179 );
111 const cachedThread = Option.map(cached, (snapshot) => snapshot.projection);180 const cachedThread = Option.map(cached, (snapshot) => snapshot.projection);
181 const cachedHistory = Option.match(cached, {
182 onNone: () => EMPTY_THREAD_HISTORY_META,
183 onSome: historyMetaFromCachedSnapshot,
184 });
112 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({185 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({
113 data: cachedThread,186 data: cachedThread,
114 status: statusWithoutLiveData(cachedThread),187 status: statusWithoutLiveData(cachedThread),
115 error: Option.none(),188 error: Option.none(),
189 history: cachedHistory,
116 });190 });
117 // Seed the resume cursor from the cached snapshot so a warm cache can catch up191 // Seed the resume cursor from the cached snapshot so a warm cache can catch up
118 // via `afterSequence` instead of re-downloading the full thread body.192 // via `afterSequence` instead of re-downloading the full thread body.
@@ -204,28 +278,57 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 85 unmodified lines
204 278
205 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (279 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (
206 thread: OrchestrationV2ThreadProjection,280 thread: OrchestrationV2ThreadProjection,
281 options?: {
282 /** Socket/full snapshots: drop progressive meta with the new timeline. */
283 readonly resetHistory?: boolean;
284 /**
285 * Explicit progressive meta installed atomically with the projection
286 * (bounded HTTP). Wins over resetHistory when both are supplied.
287 */
288 readonly history?: ThreadHistoryMeta;
289 },
207 ) {290 ) {
208 const waiting = yield* Ref.get(awaitingCompletion);291 const waiting = yield* Ref.get(awaitingCompletion);
209 yield* SubscriptionRef.set(state, {292 // Atomic with concurrent history meta updates: never get-then-set the whole
210 data: Option.some(thread),293 // state when only the projection changes. Bounded installs pass history so
211 status: waiting ? "synchronizing" : "live",294 // projection + cursor persist together in one enqueue.
212 error: Option.none(),295 const next = yield* SubscriptionRef.updateAndGet(state, (previous) => {
296 const history =
297 options?.history !== undefined
298 ? options.history
299 : options?.resetHistory === true
300 ? EMPTY_THREAD_HISTORY_META
301 : previous.history;
302 return {
303 ...previous,
304 data: Option.some(thread),
305 status: waiting ? ("synchronizing" as const) : ("live" as const),
306 error: Option.none(),
307 history,
308 };
213 });309 });
214 // Active projections can update many times per second and retain large tool310 // Active projections can update many times per second and retain large tool
215 // payloads. Persist once the run settles so cache encoding stays off the311 // payloads. Persist once the run settles so cache encoding stays off the
216 // streaming path.312 // streaming path. Progressive meta rides along when the window is incomplete.
217 if (shouldPersistThread(thread)) {313 if (shouldPersistThread(thread, next.history)) {
218 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);314 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
219 yield* Queue.offer(persistence, { snapshotSequence, projection: thread });315 yield* Queue.offer(persistence, snapshotToPersist(snapshotSequence, thread, next.history));
220 }316 }
221 });317 });
222 318
319 const patchHistoryMeta = (patch: (history: ThreadHistoryMeta) => ThreadHistoryMeta) =>
320 SubscriptionRef.update(state, (current) => ({
321 ...current,
322 history: patch(current.history),
323 }));
324
223 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {325 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
224 yield* Ref.set(awaitingCompletion, false);326 yield* Ref.set(awaitingCompletion, false);
225 yield* SubscriptionRef.set(state, {327 yield* SubscriptionRef.set(state, {
226 data: Option.none(),328 data: Option.none(),
227 status: "deleted",329 status: "deleted",
228 error: Option.none(),330 error: Option.none(),
331 history: EMPTY_THREAD_HISTORY_META,
229 });332 });
230 yield* remember;333 yield* remember;
231 if (resumeCache !== undefined && resumeCache.owner !== owner) return;334 if (resumeCache !== undefined && resumeCache.owner !== owner) return;
@@ -257,7 +360,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 25 unmodified lines
257 360
258 if (item.kind === "snapshot") {361 if (item.kind === "snapshot") {
259 yield* SubscriptionRef.set(lastSequence, item.snapshotSequence);362 yield* SubscriptionRef.set(lastSequence, item.snapshotSequence);
260 yield* setThread(item.projection);363 // True socket/full snapshots replace the full timeline; progressive cursor
364 // state from a prior bounded window must not stick around. Bounded HTTP
365 // installs call setThread with explicit history and never route here.
366 yield* setThread(item.projection, { resetHistory: true });
261 return;367 return;
262 }368 }
263 369
@@ -267,23 +373,230 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines
267 }373 }
268 yield* SubscriptionRef.set(lastSequence, item.sequence);374 yield* SubscriptionRef.set(lastSequence, item.sequence);
269 375
270 const current = yield* SubscriptionRef.get(state);376 const waiting = yield* Ref.get(awaitingCompletion);
271 if (Option.isNone(current.data)) {377 // Apply against the latest projection/history in one update so a concurrent
272 if (item.event.type === "thread.deleted") {378 // loadEarlier merge (or history-meta patch) cannot be clobbered by a stale
273 yield* setDeleted();379 // get-then-set rebuild.
274 }380 type EventApplyResult =
275 return;381 | { readonly _tag: "noop" }
276 }382 | { readonly _tag: "delete" }
277 if (item.event.type === "thread.deleted") {383 | {
384 readonly _tag: "applied";
385 readonly projection: OrchestrationV2ThreadProjection;
386 readonly history: ThreadHistoryMeta;
387 };
388
389 const result = yield* SubscriptionRef.modify(
390 state,
391 (current): readonly [EventApplyResult, EnvironmentThreadState] => {
392 if (current.status === "deleted") {
393 return [{ _tag: "noop" }, current];
394 }
395 if (Option.isNone(current.data)) {
396 return [
397 item.event.type === "thread.deleted" ? { _tag: "delete" } : { _tag: "noop" },
398 current,
399 ];
400 }
401 if (item.event.type === "thread.deleted") {
402 return [{ _tag: "delete" }, current];
403 }
404
405 // Incomplete progressive windows only (hasMore or open cursor). Do not
406 // use expanded: it remains true after the last page as a cache marker.
407 // Full/web and fully-loaded timelines keep default append-on-miss.
408 const partial =
409 current.history.hasMoreHistory || current.history.historyCursor !== null
410 ? {
411 partialTimeline: true as const,
412 latestLocalTurnOrdinal: current.history.latestLocalTurnOrdinal,
413 }
414 : undefined;
415 const next = applyOrchestrationV2ProjectionEvent(current.data.value, item.event, partial);
416 // True no-op when the reducer deliberately returns the current projection
417 // reference (e.g. dropped old partial-timeline turn-item). Do not clear
418 // stream error/status or enqueue persistence.
419 if (next === null || next === current.data.value) {
420 return [{ _tag: "noop" }, current];
421 }
422
423 let history = current.history;
424 if (partial !== undefined && item.event.type === "turn-item.updated") {
425 const ordinal = item.event.payload.ordinal;
426 const watermark = history.latestLocalTurnOrdinal;
427 if (watermark === null || ordinal > watermark) {
428 history = { ...history, latestLocalTurnOrdinal: ordinal };
429 }
430 }
431
432 const updated: EnvironmentThreadState = {
433 ...current,
434 data: Option.some(next),
435 status: waiting ? "synchronizing" : "live",
436 error: Option.none(),
437 history,
438 };
439 return [{ _tag: "applied", projection: next, history: updated.history }, updated];
440 },
441 );
442
443 if (result._tag === "delete") {
278 yield* setDeleted();444 yield* setDeleted();
279 return;445 return;
280 }446 }
281 const next = applyOrchestrationV2ProjectionEvent(current.data.value, item.event);447 if (result._tag === "applied" && shouldPersistThread(result.projection, result.history)) {
282 if (next !== null) {448 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
283 yield* setThread(next);449 yield* Queue.offer(
450 persistence,
451 snapshotToPersist(snapshotSequence, result.projection, result.history),
452 );
453 }
454 });
455
456 const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* () {
457 const current = yield* SubscriptionRef.get(state);
458 if (
459 current.status === "deleted" ||
460 Option.isNone(current.data) ||
461 !current.history.hasMoreHistory ||
462 current.history.historyCursor === null
463 ) {
464 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
465 }
466 if (current.history.loading) {
467 return { _tag: "busy" } satisfies ThreadHistoryLoadEarlierResult;
284 }468 }
469
470 // Capture the cursor that initiated this request. Completions/failures must
471 // no-op if a socket or new bounded snapshot replaced progressive meta mid-flight.
472 const requestCursor = current.history.historyCursor;
473 yield* patchHistoryMeta((history) =>
474 isActiveHistoryRequestCursor(requestCursor, history)
475 ? { ...history, loading: true, error: null }
476 : history,
477 );
478
479 const runLoad = Effect.gen(function* () {
480 const preparedOption = yield* SubscriptionRef.get(supervisor.prepared);
481 if (Option.isNone(preparedOption) || Option.isNone(httpClient)) {
482 const message = "Environment is not connected.";
483 const stillCurrent = yield* SubscriptionRef.modify(
484 state,
485 (latest): readonly [boolean, EnvironmentThreadState] => {
486 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
487 return [false, latest];
488 }
489 return [
490 true,
491 {
492 ...latest,
493 history: { ...latest.history, loading: false, error: message },
494 },
495 ];
496 },
497 );
498 if (!stillCurrent) {
499 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
500 }
501 return {
502 _tag: "error",
503 message,
504 } satisfies ThreadHistoryLoadEarlierResult;
505 }
506
507 const pageResult = yield* fetchEnvironmentThreadHistoryPage({
508 prepared: preparedOption.value,
509 threadId,
510 cursor: requestCursor,
511 signer: dpopSigner,
512 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result);
513
514 if (Result.isFailure(pageResult)) {
515 const message = formatHistoryError(pageResult.failure);
516 // Only mark error when this request's cursor is still active. Leave
517 // stream/status/error alone so concurrent live updates stay intact.
518 const stillCurrent = yield* SubscriptionRef.modify(
519 state,
520 (latest): readonly [boolean, EnvironmentThreadState] => {
521 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
522 return [false, latest];
523 }
524 return [
525 true,
526 {
527 ...latest,
528 history: { ...latest.history, loading: false, error: message },
529 },
530 ];
531 },
532 );
533 if (!stillCurrent) {
534 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
535 }
536 return { _tag: "error", message } satisfies ThreadHistoryLoadEarlierResult;
537 }
538
539 const page = pageResult.success;
540 const waiting = yield* Ref.get(awaitingCompletion);
541 // Single atomic merge against whatever is current after the await so a
542 // concurrent applyItem cannot be clobbered by a stale get/set pair.
543 return yield* SubscriptionRef.modify(
544 state,
545 (latest): readonly [ThreadHistoryLoadEarlierResult, EnvironmentThreadState] => {
546 // Stale page: socket/full snapshot or newer bounded install changed the
547 // progressive cursor while this request was in flight. Never mutate the
548 // replacement meta (including deleted/empty installs).
549 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
550 return [{ _tag: "noop" }, latest];
551 }
552 if (Option.isNone(latest.data) || latest.status === "deleted") {
553 return [
554 { _tag: "noop" },
555 {
556 ...latest,
557 history: EMPTY_THREAD_HISTORY_META,
558 },
559 ];
560 }
561
562 const merged = mergeOlderHistoryIntoProjection(latest.data.value, page.items);
563 const history = applyHistoryPageMeta(latest.history, page);
564 return [
565 { _tag: "loaded" },
566 {
567 ...latest,
568 data: Option.some(merged),
569 status: waiting
570 ? ("synchronizing" as const)
571 : latest.status === "live"
572 ? ("live" as const)
573 : latest.status,
574 history,
575 },
576 ];
577 },
578 );
579 });
580
581 // On Effect interruption only: clear loading when this request cursor is
582 // still active. Never mutate stream status/error from the interrupt path.
583 return yield* runLoad.pipe(
584 Effect.onInterrupt(() =>
585 SubscriptionRef.update(state, (latest) => ({
586 ...latest,
587 history: clearActiveHistoryLoading(requestCursor, latest.history),
588 })),
589 ),
590 );
285 });591 });
286 592
593 if (Option.isSome(historyController)) {
594 const registration = yield* historyController.value.register(environmentId, threadId, {
595 loadEarlier: () => loadEarlier(),
596 });
597 yield* Effect.addFinalizer(() => historyController.value.unregister(registration));
598 }
599
287 yield* SubscriptionRef.changes(supervisor.state).pipe(600 yield* SubscriptionRef.changes(supervisor.state).pipe(
288 Stream.runForEach((connectionState) => {601 Stream.runForEach((connectionState) => {
289 switch (connectionProjectionPhase(connectionState)) {602 switch (connectionProjectionPhase(connectionState)) {
@@ -309,6 +622,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 19 unmodified lines
309 subscribeDynamic(622 subscribeDynamic(
310 ORCHESTRATION_V2_WS_METHODS.subscribeThread,623 ORCHESTRATION_V2_WS_METHODS.subscribeThread,
311 Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) {624 Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) {
625 let current = yield* SubscriptionRef.get(state);
626 // A prior definitive miss (or delete event) already cleared this thread.
627 // Park the subscription attempt without opening the socket so we do not
628 // retry forever against a known-missing id.
629 if (current.status === "deleted") {
630 return yield* Effect.never;
631 }
632
312 const supportsCompletionMarker = yield* session.initialConfig.pipe(633 const supportsCompletionMarker = yield* session.initialConfig.pipe(
313 Effect.map((config) => config.threadResumeCompletionMarker === true),634 Effect.map((config) => config.threadResumeCompletionMarker === true),
314 Effect.orElseSucceed(() => false),635 Effect.orElseSucceed(() => false),
@@ -316,8 +637,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 1 unmodified line
316 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);637 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
317 yield* setSynchronizing;638 yield* setSynchronizing;
318 639
319 let current = yield* SubscriptionRef.get(state);640 if (Option.isNone(current.data)) {
320 if (Option.isNone(current.data) && current.status !== "deleted") {
321 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(641 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
322 Effect.flatMap(642 Effect.flatMap(
323 Option.match({643 Option.match({
@@ -332,14 +652,41 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 8 unmodified lines
332 }),652 }),
333 ),653 ),
334 );654 );
335 const httpSnapshot = yield* snapshotLoader.load(prepared, threadId);655 const httpResult: ThreadSnapshotLoadResult = yield* snapshotLoader.load(
336 if (Option.isSome(httpSnapshot)) {656 prepared,
337 yield* applyItem({657 threadId,
338 kind: "snapshot",658 );
339 snapshotSequence: httpSnapshot.value.snapshotSequence,659 switch (httpResult._tag) {
340 projection: httpSnapshot.value.projection,660 case "present": {
341 });661 // Atomic projection + progressive meta so a settled bounded window
342 current = yield* SubscriptionRef.get(state);662 // never persists as a complete full-timeline cache entry. Socket
663 // snapshots still go through applyItem (resetHistory).
664 yield* SubscriptionRef.set(lastSequence, httpResult.snapshot.snapshotSequence);
665 const history: ThreadHistoryMeta =
666 httpResult.history !== undefined
667 ? {
668 historyCursor: httpResult.history.historyCursor,
669 hasMoreHistory: httpResult.history.hasMoreHistory,
670 loading: false,
671 error: null,
672 expanded: false,
673 latestLocalTurnOrdinal: httpResult.history.latestLocalTurnOrdinal ?? null,
674 }
675 : EMPTY_THREAD_HISTORY_META;
676 yield* setThread(httpResult.snapshot.projection, { history });
677 current = yield* SubscriptionRef.get(state);
678 break;
679 }
680 case "missing": {
681 // Definitive HTTP 404: clear any stale cache and do not open or
682 // retry a socket subscription for this attempt.
683 yield* setDeleted();
684 return yield* Effect.never;
685 }
686 case "unavailable": {
687 // Transient HTTP failure: fall through to the socket path.
688 break;
689 }
343 }690 }
344 }691 }
345 692
@@ -373,8 +720,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 27 unmodified lines
373 Option.match(current.data, {720 Option.match(current.data, {
374 onNone: () => Effect.void,721 onNone: () => Effect.void,
375 onSome: (projection) =>722 onSome: (projection) =>
376 shouldPersistThread(projection)723 shouldPersistThread(projection, current.history)
377 ? persist({ snapshotSequence, projection })724 ? persist(snapshotToPersist(snapshotSequence, projection, current.history))
378 : Effect.void,725 : Effect.void,
379 }),726 }),
380 ),727 ),
@@ -442,6 +789,9 @@ export function createEnvironmentThreadStateAtoms<R, E>( · 61 unmodified lines
442 789
443export * from "./archivedThreads.ts";790export * from "./archivedThreads.ts";
444export * from "./checkpointDiff.ts";791export * from "./checkpointDiff.ts";
792export * from "./boundedThreadSnapshotHttp.ts";
793export * from "./threadHistoryController.ts";
794export * from "./threadHistoryMerge.ts";
445export * from "./threadSnapshotHttp.ts";795export * from "./threadSnapshotHttp.ts";
446export * from "./composerPathSearch.ts";796export * from "./composerPathSearch.ts";
447export * from "./threadCommands.ts";797export * from "./threadCommands.ts";
packages/contracts/src/environmentHttp.ts+33−0
@@ -33,7 +33,9 @@ import { · 32 unmodified lines
33import { ExecutionEnvironmentDescriptor } from "./environment.ts";33import { ExecutionEnvironmentDescriptor } from "./environment.ts";
34import {34import {
35 OrchestrationV2ShellSnapshot,35 OrchestrationV2ShellSnapshot,
36 OrchestrationV2ThreadBoundedSnapshot,
36 OrchestrationV2ThreadDetailSnapshot,37 OrchestrationV2ThreadDetailSnapshot,
38 OrchestrationV2ThreadHistoryPage,
37} from "./orchestrationV2.ts";39} from "./orchestrationV2.ts";
38import { Project, ProjectMutation, ProjectSnapshot } from "./project.ts";40import { Project, ProjectMutation, ProjectSnapshot } from "./project.ts";
39import {41import {
@@ -65,6 +67,7 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ · 25 unmodified lines
65 "invalid_scope",67 "invalid_scope",
66 "scope_not_granted",68 "scope_not_granted",
67 "invalid_command",69 "invalid_command",
70 "invalid_history_cursor",
68]);71]);
69export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type;72export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type;
70 73
@@ -94,6 +97,8 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ · 23 unmodified lines
94 "project_mutation_failed",97 "project_mutation_failed",
95 "orchestration_snapshot_failed",98 "orchestration_snapshot_failed",
96 "orchestration_thread_snapshot_failed",99 "orchestration_thread_snapshot_failed",
100 "orchestration_thread_bounded_snapshot_failed",
101 "orchestration_thread_history_failed",
97 "internal_error",102 "internal_error",
98]);103]);
99export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type;104export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type;
@@ -497,6 +502,17 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ · 397 unmodified lines
497 threadId: ThreadId,502 threadId: ThreadId,
498});503});
499 504
505const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({
506 cursor: TrimmedNonEmptyString,
507});
508
509const EnvironmentOrchestrationThreadHistoryErrors = [
510 EnvironmentRequestInvalidError,
511 EnvironmentScopeRequiredError,
512 EnvironmentResourceNotFoundError,
513 EnvironmentInternalError,
514] as const;
515
500export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")516export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
501 .add(517 .add(
502 HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", {518 HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", {
@@ -512,6 +528,23 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr · 9 unmodified lines
512 success: OrchestrationV2ThreadDetailSnapshot,528 success: OrchestrationV2ThreadDetailSnapshot,
513 error: EnvironmentOrchestrationThreadSnapshotErrors,529 error: EnvironmentOrchestrationThreadSnapshotErrors,
514 }).middleware(EnvironmentAuthenticatedAuth),530 }).middleware(EnvironmentAuthenticatedAuth),
531 )
532 .add(
533 HttpApiEndpoint.get("threadBoundedSnapshot", "/api/orchestration/threads/:threadId/bounded", {
534 headers: OptionalBearerHeaders,
535 params: EnvironmentOrchestrationThreadSnapshotParams,
536 success: OrchestrationV2ThreadBoundedSnapshot,
537 error: EnvironmentOrchestrationThreadSnapshotErrors,
538 }).middleware(EnvironmentAuthenticatedAuth),
539 )
540 .add(
541 HttpApiEndpoint.get("threadHistoryPage", "/api/orchestration/threads/:threadId/history", {
542 headers: OptionalBearerHeaders,
543 params: EnvironmentOrchestrationThreadSnapshotParams,
544 query: EnvironmentOrchestrationThreadHistoryQuery,
545 success: OrchestrationV2ThreadHistoryPage,
546 error: EnvironmentOrchestrationThreadHistoryErrors,
547 }).middleware(EnvironmentAuthenticatedAuth),
515 ) {}548 ) {}
516 549
517export class EnvironmentProjectsHttpApi extends HttpApiGroup.make("projects")550export class EnvironmentProjectsHttpApi extends HttpApiGroup.make("projects")
packages/contracts/src/orchestrationV2.ts+40−0
@@ -2384,9 +2384,49 @@ export type OrchestrationV2SubscribeThreadInput = typeof OrchestrationV2Subscrib · 2383 unmodified lines
2384export const OrchestrationV2ThreadDetailSnapshot = Schema.Struct({2384export const OrchestrationV2ThreadDetailSnapshot = Schema.Struct({
2385 snapshotSequence: NonNegativeInt,2385 snapshotSequence: NonNegativeInt,
2386 projection: OrchestrationV2ThreadProjection,2386 projection: OrchestrationV2ThreadProjection,
2387 /**
2388 * Progressive history metadata. Omitted on full-projection caches and older
2389 * clients. When present with hasMoreHistory/cursor, the projection is a
2390 * bounded window and must not be treated as a complete timeline.
2391 */
2392 historyCursor: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
2393 hasMoreHistory: Schema.optionalKey(Schema.Boolean),
2394 /**
2395 * Max local turn ordinal from the authoritative full projection at snapshot
2396 * time. Optional for backward-compatible warm cache entries.
2397 */
2398 latestLocalTurnOrdinal: Schema.optionalKey(Schema.NullOr(NonNegativeInt)),
2387});2399});
2388export type OrchestrationV2ThreadDetailSnapshot = typeof OrchestrationV2ThreadDetailSnapshot.Type;2400export type OrchestrationV2ThreadDetailSnapshot = typeof OrchestrationV2ThreadDetailSnapshot.Type;
2389 2401
2402/**
2403 * Progressive cold open: full control-plane projection arrays with only a
2404 * recent window of timeline rows. `historyCursor` is opaque; clients must not
2405 * parse it. Resume live events with `afterSequence = snapshotSequence`.
2406 */
2407export const OrchestrationV2ThreadBoundedSnapshot = Schema.Struct({
2408 snapshotSequence: NonNegativeInt,
2409 projection: OrchestrationV2ThreadProjection,
2410 historyCursor: Schema.NullOr(TrimmedNonEmptyString),
2411 hasMoreHistory: Schema.Boolean,
2412 /**
2413 * Max local turn ordinal over the authoritative full projection turnItems.
2414 * Clients use this as a partial-timeline watermark when the bounded window
2415 * has no local rows (inherited-only).
2416 */
2417 latestLocalTurnOrdinal: Schema.NullOr(NonNegativeInt),
2418});
2419export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type;
2420
2421/** Older timeline page for progressive history. Rows are chronological. */
2422export const OrchestrationV2ThreadHistoryPage = Schema.Struct({
2423 snapshotSequence: NonNegativeInt,
2424 items: Schema.Array(OrchestrationV2ProjectedTurnItem),
2425 nextCursor: Schema.NullOr(TrimmedNonEmptyString),
2426 hasMoreHistory: Schema.Boolean,
2427});
2428export type OrchestrationV2ThreadHistoryPage = typeof OrchestrationV2ThreadHistoryPage.Type;
2429
2390export const OrchestrationV2ThreadStreamItem = Schema.Union([2430export const OrchestrationV2ThreadStreamItem = Schema.Union([
2391 Schema.Struct({2431 Schema.Struct({
2392 kind: Schema.Literal("synchronized"),2432 kind: Schema.Literal("synchronized"),