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 @@
11 import { Connection } from "@t3tools/client-runtime/connection";
22 import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3−import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
3+import {
4+ boundedThreadSnapshotLoaderLayer,
5+ threadHistoryControllerLayer,
6+} from "@t3tools/client-runtime/state/threads";
47 import * as Layer from "effect/Layer";
58 import { Atom } from "effect/unstable/reactivity";
69
@@ -20,7 +23,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( · 13 unmodified lines
2023 Layer.provide(runtimeContextLayer),
2124 );
2225
23−const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
26+const snapshotLoaderLayer = Layer.mergeAll(
27+ boundedThreadSnapshotLoaderLayer,
28+ shellSnapshotLoaderLayer,
29+ threadHistoryControllerLayer,
30+);
2431
2532 type ConnectionLayerSource =
2633 | typeof Connection.layer
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+4−1
@@ -79,7 +79,7 @@ import { · 78 unmodified lines
7979 COMPOSER_EXPANDED_CHROME,
8080 ThreadComposer,
8181 } from "./ThreadComposer";
82−import { ThreadFeed } from "./ThreadFeed";
82+import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed";
8383 import { ThreadRelationshipsBanner } from "./ThreadRelationshipsBanner";
8484 import { ThreadQueueControl } from "./ThreadQueueControl";
8585 import type { ThreadContentPresentation } from "./threadContentPresentation";
@@ -104,6 +104,8 @@ export interface ThreadDetailScreenProps { · 18 unmodified lines
104104 readonly connectionStateLabel: EnvironmentConnectionPhase;
105105 /** Message sync status for the selected thread (drives the composer status pill). */
106106 readonly threadSyncStatus?: EnvironmentThreadStatus;
107+ /** Progressive history controls for oversized mobile thread opens. */
108+ readonly historyControls?: ThreadFeedHistoryControls;
107109 readonly activeThreadBusy: boolean;
108110 readonly canStopThread: boolean;
109111 readonly environmentId: EnvironmentId;
@@ -650,6 +652,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread · 540 unmodified lines
650652 contentTopInset={0}
651653 contentBottomInset={estimatedOverlayHeight}
652654 contentMaxWidth={contentMaxWidth}
655+ historyControls={props.historyControls}
653656 topAccessory={
654657 <ThreadRelationshipsBanner
655658 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
5555 import { isPdfFile } from "../../lib/filePreview";
5656 import { PresentationSource } from "../../components/NativePresentation";
5757 import { useSafeAreaInsets } from "react-native-safe-area-context";
58−import Animated, {
59− FadeIn,
60− FadeInUp,
61− useSharedValue,
62− withTiming,
63− type LayoutAnimationsValues,
64− type SharedValue,
65−} from "react-native-reanimated";
58+import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
6659 import { useThemeColor } from "../../lib/useThemeColor";
6760 import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
6861 import { useFontFamily } from "../../lib/useFontFamily";
@@ -167,14 +160,6 @@ function formatMessageTime(input: string): string { · 98 unmodified lines
167160 return MESSAGE_TIME_FORMATTER.format(timestamp);
168161 }
169162
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.
176−const FEED_ITEM_LAYOUT_DURATION_MS = 180;
177−
178163 // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
179164 // classNames. The fold row's min-h-11 (44px) stays taller than its single
180165 // 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
194179 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS;
195180 }
196181
182+export interface ThreadFeedHistoryControls {
183+ readonly hasMoreHistory: boolean;
184+ readonly loading: boolean;
185+ readonly error: string | null;
186+ readonly onLoadEarlier: () => void;
187+}
188+
197189 export interface ThreadFeedProps {
198190 readonly environmentId: EnvironmentId;
199191 readonly threadId: ThreadId;
@@ -210,6 +202,7 @@ export interface ThreadFeedProps { · 10 unmodified lines
210202 readonly contentInsetEndAdjustment: SharedValue<number>;
211203 readonly contentTopInset?: number;
212204 readonly contentBottomInset?: number;
205+ readonly historyControls?: ThreadFeedHistoryControls;
213206 readonly topAccessory?: ReactNode;
214207 readonly contentMaxWidth?: number;
215208 readonly layoutVariant?: LayoutVariant;
@@ -1642,6 +1635,41 @@ function compactFileName(filePath: string): string { · 1426 unmodified lines
16421635 return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized;
16431636 }
16441637
1638+function 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+
16451673 function ThreadFeedPlaceholder(props: {
16461674 readonly bottomInset: number;
16471675 readonly detail: string;
@@ -1891,11 +1919,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 243 unmodified lines
18911919 },
18921920 [props.onHeaderMaterialVisibilityChange],
18931921 );
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−
18991922 const handleScroll = useCallback(
19001923 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
19011924 // anchorTopInset, not topContentInset: under automatic insets the list
@@ -1903,9 +1926,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line
19031926 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
19041927 // header height back or the material toggles a full header too late.
19051928 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;
19091929 // LegendList recomputes its inset-aware end distance before invoking
19101930 // this handler, so getState() is current. Only the actual end re-arms
19111931 // 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
19211941 });
19221942 }
19231943 },
1924− [
1925− reportHeaderMaterialVisibility,
1926− anchorTopInset,
1927− nearListEnd,
1928− props.listRef,
1929− transitionEndFollow,
1930− ],
1944+ [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
19311945 );
19321946 const clearUserScrollSettle = useCallback(() => {
19331947 if (userScrollSettleTimerRef.current !== null) {
@@ -1999,33 +2013,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 65 unmodified lines
19992013 }
20002014 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
20012015
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]);
20292016 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
20302017 const nextWidth = Math.round(event.nativeEvent.layout.width);
20312018 const nextHeight = Math.round(event.nativeEvent.layout.height);
@@ -2378,7 +2365,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 346 unmodified lines
23782365 }
23792366 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
23802367 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
2381− itemLayoutAnimation={feedItemLayoutTransition}
23822368 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
23832369 // lets its scroll math clamp programmatic scrolls to -headerInset
23842370 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
@@ -2460,6 +2446,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 75 unmodified lines
24602446 ListHeaderComponent={
24612447 <>
24622448 {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />}
2449+ {props.historyControls ? (
2450+ <ThreadFeedLoadEarlierControl {...props.historyControls} />
2451+ ) : null}
24632452 {props.topAccessory}
24642453 </>
24652454 }
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+25−0
@@ -197,6 +197,30 @@ function ThreadRouteContent( · 196 unmodified lines
197197 const gitActions = useSelectedThreadGitActions();
198198 const requests = useSelectedThreadRequests();
199199 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]);
200224 const navigation = useNavigation();
201225 const params = props.route.params;
202226 const environmentIdRaw = firstRouteParam(params.environmentId);
@@ -763,6 +787,7 @@ function ThreadRouteContent( · 560 unmodified lines
763787 draftAttachments={composer.draftAttachments}
764788 connectionStateLabel={routeConnectionState}
765789 threadSyncStatus={selectedThreadDetailState.status}
790+ historyControls={historyControls}
766791 activeThreadBusy={composer.activeThreadBusy}
767792 canStopThread={composer.interruptibleRunId !== null}
768793 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
750750 runtimeMode: settings.runtimeMode,
751751 interactionMode: settings.interactionMode,
752752 createdAt: queuedMessage.createdAt,
753+ dispatchMode: "start",
753754 },
754755 });
755756 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
55 OrchestrationV2StoredEvent,
66 OrchestrationV2ThreadShell,
77 } from "@t3tools/contracts";
8+import { ThreadId } from "@t3tools/contracts";
89 import * as Effect from "effect/Effect";
910 import * as Stream from "effect/Stream";
1011
@@ -16,6 +17,7 @@ import { · 5 unmodified lines
1617 shellStreamItemFromEnrichmentRefresh,
1718 shellStreamItemFromThreadShell,
1819 shellStreamItemsFromInitialSnapshot,
20+ shellStreamItemsFromResumeSnapshot,
1921 } from "./ShellStream.ts";
2022
2123 function project(sequence: number, id: string): ApplicationStoredEvent {
@@ -198,6 +200,35 @@ describe("shellStreamItemFromEnrichmentRefresh", () => { · 176 unmodified lines
198200 });
199201
200202 describe("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+
201232 it("emits unmarked authoritative then same-sequence marked enrichment when roots resolved", () => {
202233 const snapshot = {
203234 ...emptyShellSnapshot,
@@ -229,6 +260,20 @@ describe("shellStreamItemsFromInitialSnapshot", () => { · 25 unmodified lines
229260 });
230261 });
231262
263+describe("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+
232277 describe("composeShellStreamWithEnrichment", () => {
233278 it.effect(
234279 "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
4343 readonly snapshot: OrchestrationV2ShellSnapshot;
4444 readonly changes: ReadonlyArray<{ readonly workspaceRoot: string }>;
4545 }): Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }> {
46+ const resolvedRepositoryIdentityRoots = [
47+ ...new Set(input.changes.map((change) => change.workspaceRoot)),
48+ ];
4649 return {
4750 kind: "snapshot",
48− snapshot: input.snapshot,
49− resolvedRepositoryIdentityRoots: [
50− ...new Set(input.changes.map((change) => change.workspaceRoot)),
51− ],
51+ snapshot: {
52+ ...input.snapshot,
53+ projects: input.snapshot.projects.filter((project) =>
54+ resolvedRepositoryIdentityRoots.includes(project.workspaceRoot),
55+ ),
56+ threads: [],
57+ archivedThreads: [],
58+ },
59+ resolvedRepositoryIdentityRoots,
5260 };
5361 }
5462
@@ -71,12 +79,29 @@ export function shellStreamItemsFromInitialSnapshot(input: { · 16 unmodified lines
7179 authoritative,
7280 {
7381 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+ },
7590 resolvedRepositoryIdentityRoots: [...new Set(input.resolvedRepositoryIdentityRoots)],
7691 },
7792 ];
7893 }
7994
95+/** Resume after HTTP/cache hydration with metadata only, never another full shell body. */
96+export 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+
80105 /** Keep only the newest stored event per thread within a coalescing window. */
81106 export function coalesceStoredThreadEvents(
82107 events: ReadonlyArray<OrchestrationV2StoredEvent>,
apps/server/src/orchestration-v2/ThreadStream.test.ts+75−0
@@ -0,0 +1,75 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ decideThreadResume,
5+ threadReplayEncodedBytes,
6+ THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES,
7+ THREAD_RESUME_MAX_REPLAY_EVENTS,
8+} from "./ThreadStream.ts";
9+
10+describe("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. */
2+export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128;
3+
4+/** Maximum encoded event JSON allowed during a thread resume. */
5+export const THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES = 1_048_576;
6+
7+/** Encoded payload cost after events have been projected for the wire. */
8+export 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+
17+export 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+ */
33+export 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 @@
1+import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as DateTime from "effect/DateTime";
4+
5+import { projectTurnItemForWire } from "./WireProjection.ts";
6+
7+const 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+
27+describe("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 @@
1+import type {
2+ OrchestrationV2DomainEvent,
3+ OrchestrationV2ThreadProjection,
4+ OrchestrationV2TurnItem,
5+} from "@t3tools/contracts";
6+
7+const MAX_DETAIL_STRING_BYTES = 32_768;
8+const MAX_DYNAMIC_VALUE_BYTES = 16_384;
9+
10+function 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+
19+function 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+
30+function 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+
51+export 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+
80+export 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+
102+export 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
1111 import {
1212 annotateEnvironmentRequest,
1313 failEnvironmentInternal,
14+ failEnvironmentInvalidRequest,
1415 failEnvironmentNotFound,
1516 requireEnvironmentScope,
1617 } from "../auth/http.ts";
1718 import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
1819 import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts";
1920 import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts";
21+import {
22+ buildBoundedThreadProjection,
23+ InvalidThreadHistoryCursorError,
24+ selectHistoryPageFromCursor,
25+} from "./threadHistoryPaging.ts";
2026 import * as ThreadManagementService from "./ThreadManagementService.ts";
27+import { projectThreadProjectionForWire } from "./WireProjection.ts";
2128
2229 function isThreadNotFound(error: unknown): boolean {
2330 return (
@@ -27,9 +34,25 @@ function isThreadNotFound(error: unknown): boolean { · 3 unmodified lines
2734 );
2835 }
2936
37+function 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+
3053 /**
3154 * Serves orchestration V2 snapshots over HTTP so clients can load the
32− * (potentially large) shell and thread projections off the socket — gzip
55+ * (potentially large) shell and thread projections off the socket; gzip
3356 * compressible and cacheable — and then resume the WebSocket subscription via
3457 * `afterSequence`.
3558 */
@@ -80,6 +103,29 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 44 unmodified lines
80103 return { ...base, projects };
81104 });
82105
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+
83129 return handlers
84130 .handle(
85131 "shellSnapshot",
@@ -98,24 +144,68 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( · 12 unmodified lines
98144 Effect.fn("environment.orchestration.threadSnapshot")(function* (args) {
99145 yield* annotateEnvironmentRequest(args.endpoint.name);
100146 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
101− const snapshot = yield* threadManagement.getThreadSnapshot(args.params.threadId).pipe(
102− Effect.catch(
103− Effect.fnUntraced(function* (error) {
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− ),
147+ const snapshot = yield* loadThreadSnapshot(
148+ args.params.threadId,
149+ "orchestration_thread_snapshot_failed",
113150 );
114151 return {
115152 snapshotSequence: snapshot.snapshotSequence,
116153 projection: snapshot.projection,
117154 };
118155 }),
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+ }),
119209 );
120210 }),
121211 );
apps/server/src/orchestration-v2/threadHistoryPaging.test.ts+473−0
@@ -0,0 +1,473 @@
1+import {
2+ MessageId,
3+ NodeId,
4+ RunId,
5+ ThreadId,
6+ TurnItemId,
7+ type OrchestrationV2ProjectedTurnItem,
8+ type OrchestrationV2TurnItem,
9+} from "@t3tools/contracts";
10+import { describe, expect, it } from "@effect/vitest";
11+import * as DateTime from "effect/DateTime";
12+
13+import {
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+
28+const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
29+const THREAD = ThreadId.make("thread-1");
30+const RUN = RunId.make("run-interrupt-1");
31+const NODE = NodeId.make("node-interrupt-1");
32+
33+function 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+
68+function 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+
93+function 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+
111+function 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+
159+describe("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 @@
1+import 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+ */
13+export 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. */
19+export const THREAD_HISTORY_CURSOR_MAX_LENGTH = 4_096;
20+
21+export type ThreadHistoryPagePolicy = {
22+ readonly maxItems: number;
23+ readonly maxEncodedBytes: number;
24+};
25+
26+export type ThreadHistoryCursorPayload = {
27+ readonly v: 1;
28+ readonly seq: number;
29+ readonly st: string;
30+ readonly si: string;
31+ readonly p: number;
32+};
33+
34+export type SelectTimelinePageResult = {
35+ readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
36+ readonly nextCursor: string | null;
37+ readonly hasMoreHistory: boolean;
38+};
39+
40+export 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+
48+export 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+
56+function 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). */
61+export 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+ */
70+export 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+
81+function renumberPositions(
82+ items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
83+): OrchestrationV2ProjectedTurnItem[] {
84+ return items.map((row, position) => (row.position === position ? row : { ...row, position }));
85+}
86+
87+function encodeCursorPayload(payload: ThreadHistoryCursorPayload): string {
88+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
89+}
90+
91+export 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+
106+export 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+ */
147+export 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+
202+export 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+
217+function 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+
235+export 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+
251+function 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+ */
265+export 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+
290+function 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+
306+function 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+ */
349+export 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+ */
365+export 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+ */
383+export 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
8383 shellStreamItemFromEnrichmentRefresh,
8484 shellStreamItemFromThreadShell,
8585 shellStreamItemsFromInitialSnapshot,
86+ shellStreamItemsFromResumeSnapshot,
8687 } from "./orchestration-v2/ShellStream.ts";
88+import {
89+ decideThreadResume,
90+ threadReplayEncodedBytes,
91+ THREAD_RESUME_MAX_REPLAY_EVENTS,
92+} from "./orchestration-v2/ThreadStream.ts";
93+import {
94+ projectDomainEventForWire,
95+ projectThreadProjectionForWire,
96+} from "./orchestration-v2/WireProjection.ts";
8797 import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
8898 import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts";
8999 import { userFacingDispatchErrorMessage } from "./orchestration-v2/UserFacingErrors.ts";
@@ -616,7 +626,7 @@ const makeWsRpcLayer = ( · 526 unmodified lines
616626 Stream.map((stored) => ({
617627 kind: "event" as const,
618628 sequence: stored.sequence,
619− event: stored.event,
629+ event: projectDomainEventForWire(stored.event),
620630 })),
621631 Stream.mapError(
622632 (cause) =>
@@ -628,20 +638,23 @@ const makeWsRpcLayer = ( · 5 unmodified lines
628638 ),
629639 );
630640
631− const replayThrough = (afterSequence: number, throughSequence: number) =>
641+ const loadReplayThrough = (afterSequence: number, throughSequence: number) =>
632642 applicationEvents
633643 .readAgentEvents({
634644 threadId: input.threadId,
635645 afterSequence,
636646 throughSequence,
647+ limit: THREAD_RESUME_MAX_REPLAY_EVENTS + 1,
637648 })
638649 .pipe(
639650 Stream.map((stored) => ({
640651 kind: "event" as const,
641652 sequence: stored.sequence,
642− event: stored.event,
653+ event: projectDomainEventForWire(stored.event),
643654 })),
644− Stream.mapError(
655+ Stream.runCollect,
656+ Effect.map((items) => Array.from(items)),
657+ Effect.mapError(
645658 (cause) =>
646659 new OrchestrationV2GetThreadProjectionError({
647660 threadId: input.threadId,
@@ -656,6 +669,34 @@ const makeWsRpcLayer = ( · 8 unmodified lines
656669 ? Stream.make({ kind: "synchronized" as const })
657670 : Stream.empty;
658671
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+
659700 // When the client already holds the projection (cached, or loaded over
660701 // HTTP) it passes that snapshot's sequence, and we resume by replaying
661702 // persisted events after it instead of re-sending the (potentially
@@ -674,35 +715,23 @@ const makeWsRpcLayer = ( · 12 unmodified lines
674715 }),
675716 ),
676717 );
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+ }
677728 return Stream.concat(
678− Stream.concat(replayThrough(input.afterSequence, highWater), completionMarker),
729+ Stream.concat(Stream.fromIterable(replay), completionMarker),
679730 eventStreamFrom(highWater),
680731 );
681732 }
682733
683− const snapshot = yield* threadManagement.getThreadSnapshot(input.threadId).pipe(
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− );
734+ return yield* snapshotThenLive();
706735 },
707736 );
708737
@@ -809,7 +838,7 @@ const makeWsRpcLayer = ( · 100 unmodified lines
809838 // completions that race HTTP snapshot fetch still push a refresh.
810839 // When the client already holds a shell snapshot (cached, or loaded
811840 // over HTTP) it passes that snapshot's sequence. We still emit one
812− // enriched snapshot up front: getAvailable may have been cold on the
841+ // compact metadata refresh up front: getAvailable may have been cold on the
813842 // HTTP path (null identity), and enrichment PubSub events published
814843 // before this subscribe attached are dropped. Rehydrating here fills
815844 // repositoryIdentity for cross-environment project grouping even on
@@ -835,6 +864,16 @@ const makeWsRpcLayer = ( · 19 unmodified lines
835864 resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots,
836865 }),
837866 );
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+ );
838877 // Initial unmarked (+ optional same-load marked) always drains first.
839878 // Enrichment merges only with the post-prefix tail so a ready marked
840879 // refresh cannot interleave before the authoritative initial frame.
@@ -869,7 +908,7 @@ const makeWsRpcLayer = ( · 28 unmodified lines
869908 }),
870909 );
871910 return composeShellStreamWithEnrichment({
872− initial,
911+ initial: initialEnrichmentItems(loaded),
873912 tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)),
874913 enrichment: enrichmentRefreshes,
875914 });
@@ -1119,6 +1158,7 @@ const makeWsRpcLayer = ( · 243 unmodified lines
11191158 observeRpcEffect(
11201159 ORCHESTRATION_V2_WS_METHODS.getThreadProjection,
11211160 threadManagement.getThreadProjection(input.threadId).pipe(
1161+ Effect.map(projectThreadProjectionForWire),
11221162 Effect.mapError(
11231163 (cause) =>
11241164 new OrchestrationV2GetThreadProjectionError({
@@ -1169,6 +1209,10 @@ const makeWsRpcLayer = ( · 44 unmodified lines
11691209 }),
11701210 )
11711211 .pipe(
1212+ Effect.map((result) => ({
1213+ ...result,
1214+ projection: projectThreadProjectionForWire(result.projection),
1215+ })),
11721216 Effect.mapError(
11731217 (cause) =>
11741218 new OrchestrationV2ThreadLaunchError({
apps/web/src/components/ChatView.tsx+27−1
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
3434 deriveThreadRuntime,
3535 } from "@t3tools/client-runtime/state/thread-execution";
3636 import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows";
37+import { shouldShowLoadEarlierControl } from "@t3tools/client-runtime/state/threads";
3738 import { resolveThreadLastVisitedAt } from "./Sidebar.logic";
3839 import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
3940 import {
@@ -249,6 +250,7 @@ import { · 209 unmodified lines
249250 useProject,
250251 useProjects,
251252 useThreadProjection,
253+ useThreadHistory,
252254 useThreadShell,
253255 useThreadRefs,
254256 useThreadVisibleTurnItems,
@@ -259,7 +261,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; · 4 unmodified lines
259261 import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
260262 import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
261263 import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
262−import { MessagesTimeline } from "./chat/MessagesTimeline";
264+import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
263265 import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
264266 import { ChatHeader } from "./chat/ChatHeader";
265267 import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic";
@@ -1258,6 +1260,10 @@ function ChatViewContent(props: ChatViewProps) { · 992 unmodified lines
12581260 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
12591261 reportFailure: false,
12601262 });
1263+ const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
1264+ label: "load earlier thread history",
1265+ reportFailure: false,
1266+ });
12611267 const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, {
12621268 reportFailure: false,
12631269 });
@@ -1304,6 +1310,23 @@ function ChatViewContent(props: ChatViewProps) { · 40 unmodified lines
13041310 [serverProjection?.subagents],
13051311 );
13061312 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]);
13071330 const committedServerMessageIds = useMemo(
13081331 () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),
13091332 [serverVisibleTurnItems],
@@ -6569,6 +6592,9 @@ function ChatViewContent(props: ChatViewProps) { · 5259 unmodified lines
65696592 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
65706593 hideEmptyPlaceholder={isDraftHeroState}
65716594 topFadeEnabled={!hasTimelineTopBanner}
6595+ {...(threadHistoryControls === undefined
6596+ ? {}
6597+ : { historyControls: threadHistoryControls })}
65726598 />
65736599
65746600 {/* 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
231231 }
232232
233233 describe("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+
234273 it("uses the larger leading inset only when the top fade is enabled", () => {
235274 const timelineEntries = [buildUserTimelineEntry("Hello")];
236275
apps/web/src/components/chat/MessagesTimeline.tsx+61−10
@@ -195,6 +195,13 @@ const EMPTY_TIMELINE_RUNS: ReadonlyArray<HandoffTimelineRun> = []; · 194 unmodified lines
195195 // Props (public API)
196196 // ---------------------------------------------------------------------------
197197
198+export interface MessagesTimelineHistoryControls {
199+ readonly hasMoreHistory: boolean;
200+ readonly loading: boolean;
201+ readonly error: string | null;
202+ readonly onLoadEarlier: () => void;
203+}
204+
198205 interface MessagesTimelineProps {
199206 isWorking: boolean;
200207 activeTurnInProgress: boolean;
@@ -249,6 +256,7 @@ interface MessagesTimelineProps { · 48 unmodified lines
249256 onManualNavigation: () => void;
250257 hideEmptyPlaceholder?: boolean;
251258 topFadeEnabled?: boolean;
259+ historyControls?: MessagesTimelineHistoryControls;
252260 }
253261
254262 // ---------------------------------------------------------------------------
@@ -291,6 +299,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 36 unmodified lines
291299 onManualNavigation,
292300 hideEmptyPlaceholder = false,
293301 topFadeEnabled = false,
302+ historyControls,
294303 }: MessagesTimelineProps) {
295304 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());
296305 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(
@@ -590,10 +599,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 293 unmodified lines
590599 }),
591600 [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestRun?.runId],
592601 );
593− const listHeader = useMemo(
594− () =>
602+ const listHeader = useMemo(() => {
603+ const leadingContent =
595604 parentThreadLink === null ? (
596− TIMELINE_LIST_HEADER
605+ topFadeEnabled ? (
606+ TIMELINE_LIST_FADE_HEADER
607+ ) : (
608+ TIMELINE_LIST_HEADER
609+ )
597610 ) : (
598611 <div className="messages-timeline-row-frame">
599612 <div className="chat-content-lane pt-1 sm:pt-2">
@@ -606,9 +619,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 6 unmodified lines
606619 />
607620 </div>
608621 </div>
609− ),
610− [onOpenThread, parentThreadLink],
611− );
622+ );
623+ return (
624+ <>
625+ {parentThreadLink === null ? leadingContent : null}
626+ {historyControls ? <TimelineHistoryControl {...historyControls} /> : null}
627+ {parentThreadLink !== null ? leadingContent : null}
628+ </>
629+ );
630+ }, [historyControls, onOpenThread, parentThreadLink, topFadeEnabled]);
612631
613632 // Stable renderItem — no closure deps. Row components read shared state
614633 // from TimelineRowCtx, which propagates through LegendList's memo.
@@ -623,7 +642,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 8 unmodified lines
623642 [],
624643 );
625644
626− if (rows.length === 0 && !isWorking && parentThreadLink === null) {
645+ if (
646+ rows.length === 0 &&
647+ !isWorking &&
648+ parentThreadLink === null &&
649+ historyControls === undefined
650+ ) {
627651 if (hideEmptyPlaceholder) {
628652 return null;
629653 }
@@ -665,9 +689,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 35 unmodified lines
665689 "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",
666690 topFadeEnabled && "chat-timeline-scroll-fade",
667691 )}
668− ListHeaderComponent={
669− topFadeEnabled && parentThreadLink === null ? TIMELINE_LIST_FADE_HEADER : listHeader
670− }
692+ ListHeaderComponent={listHeader}
671693 ListFooterComponent={TIMELINE_LIST_FOOTER}
672694 />
673695 <TimelineMinimap
@@ -691,6 +713,35 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 17 unmodified lines
691713 );
692714 });
693715
716+function 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+
694745 function keyExtractor(item: MessagesTimelineRow) {
695746 return item.id;
696747 }
apps/web/src/connection/runtime.ts+6−2
@@ -1,6 +1,9 @@
11 import { Connection } from "@t3tools/client-runtime/connection";
22 import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3−import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
3+import {
4+ boundedThreadSnapshotLoaderLayer,
5+ threadHistoryControllerLayer,
6+} from "@t3tools/client-runtime/state/threads";
47 import { pullRequestDiffLoaderLayer } from "@t3tools/client-runtime/state/pull-requests";
58 import * as Layer from "effect/Layer";
69 import { Atom } from "effect/unstable/reactivity";
@@ -17,8 +20,9 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( · 10 unmodified lines
1720 );
1821
1922 const snapshotLoaderLayer = Layer.mergeAll(
20− threadSnapshotLoaderLayer,
23+ boundedThreadSnapshotLoaderLayer,
2124 shellSnapshotLoaderLayer,
25+ threadHistoryControllerLayer,
2226 pullRequestDiffLoaderLayer,
2327 );
2428
apps/web/src/state/entities.ts+14−1
@@ -4,7 +4,11 @@ import type { · 3 unmodified lines
44 EnvironmentThread,
55 EnvironmentThreadShell,
66 } from "@t3tools/client-runtime/state/shell";
7−import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
7+import {
8+ EMPTY_THREAD_HISTORY_META,
9+ type EnvironmentThreadStatus,
10+ type ThreadHistoryMeta,
11+} from "@t3tools/client-runtime/state/threads";
812 import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts";
913 import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, ThreadId } from "@t3tools/contracts";
1014 import { Atom } from "effect/unstable/reactivity";
@@ -43,6 +47,9 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pip · 32 unmodified lines
4347 const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe(
4448 Atom.withLabel("web-thread-visible-turn-items:empty"),
4549 );
50+const EMPTY_THREAD_HISTORY_ATOM = Atom.make<ThreadHistoryMeta>(EMPTY_THREAD_HISTORY_META).pipe(
51+ Atom.withLabel("web-thread-history:empty"),
52+);
4653
4754 export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(
4855 Atom.keepAlive,
@@ -137,6 +144,12 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS · 88 unmodified lines
137144 );
138145 }
139146
147+export function useThreadHistory(ref: ScopedThreadRef | null): ThreadHistoryMeta {
148+ return useAtomValue(
149+ ref === null ? EMPTY_THREAD_HISTORY_ATOM : environmentThreadDetails.historyAtom(ref),
150+ );
151+}
152+
140153 export function resolveThreadDetailRef(
141154 ref: ScopedThreadRef | null,
142155 options: {
docs/internals/performance-regressions.md+30−0
@@ -0,0 +1,30 @@
1+# Performance regression checks
2+
3+The v2 transport has a focused regression command:
4+
5+```bash
6+vp run test:perf:v2-wire
7+```
8+
9+It exercises the real v2 projection and client reducers. This matters because the older
10+built-app benchmark fixtures seed v1 orchestration events; running those fixtures against a v2
11+client can produce reassuring timings while missing the active transport path.
12+
13+The 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+
28+When changing projection schemas, paging, shell synchronization, or thread state, run this command
29+alongside the focused package typechecks and a real-client pass on every affected surface. Payload
30+budgets 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
2727 "lint": "vp lint --report-unused-disable-directives",
2828 "lint:mobile": "node scripts/mobile-native-static-check.ts",
2929 "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",
3031 "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml",
3132 "test:desktop-smoke": "vp run --filter @t3tools/desktop smoke-test",
3233 "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
3838 import {
3939 archiveThread,
4040 createProject,
41+ interruptThreadTurn,
4142 forkThreadFromRun,
4243 mergeThreadBack,
4344 cancelQueuedRun,
@@ -71,6 +72,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 27 unmodified lines
7172 readonly projects: ProjectMutation[];
7273 readonly launches?: OrchestrationV2ThreadLaunchInput[];
7374 readonly projection?: OrchestrationV2ThreadProjection;
75+ readonly projectionRequests?: ThreadId[];
7476 }) {
7577 const client = {
7678 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
@@ -78,8 +80,13 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 1 unmodified line
7880 input.commands.push(command);
7981 return { sequence: input.commands.length };
8082 }),
81− [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: () =>
82− Effect.succeed(input.projection ?? v2Projection),
83+ [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (requestInput: {
84+ readonly threadId: ThreadId;
85+ }) =>
86+ Effect.sync(() => {
87+ input.projectionRequests?.push(requestInput.threadId);
88+ return input.projection ?? v2Projection;
89+ }),
8390 [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) =>
8491 Effect.sync(() => {
8592 input.launches?.push(launchInput);
@@ -511,4 +518,67 @@ describe("V2 environment commands", () => { · 425 unmodified lines
511518 ]);
512519 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
513520 );
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+ );
514584 });
packages/client-runtime/src/operations/commands.ts+25−7
@@ -153,7 +153,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput { · 152 unmodified lines
153153 readonly interactionMode: ProviderInteractionMode;
154154 readonly bootstrap?: StartThreadBootstrap;
155155 readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };
156− readonly dispatchMode?: "auto" | "queue" | "steer" | "restart";
156+ readonly dispatchMode?: "auto" | "queue" | "steer" | "restart" | "start";
157157 }
158158
159159 export interface InterruptThreadTurnInput extends ThreadCommandInput {
@@ -589,6 +589,25 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 429 unmodified lines
589589 });
590590 }
591591
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+
592611 const projection = yield* getProjection(input.threadId);
593612 const activeRun = projection.runs.findLast(
594613 (run) =>
@@ -597,7 +616,6 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 2 unmodified lines
597616 run.status === "running" ||
598617 run.status === "waiting",
599618 );
600− const requestedMode = input.dispatchMode ?? "auto";
601619 const activeProviderThread =
602620 activeRun === undefined
603621 ? undefined
@@ -647,17 +665,17 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 43 unmodified lines
647665 export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
648666 input: InterruptThreadTurnInput,
649667 ) {
650− const projection = yield* getProjection(input.threadId);
651− const runId =
652− input.runId ??
653− (input.turnId as RunId | undefined) ??
654− projection.runs.findLast(
668+ let runId = input.runId ?? (input.turnId as RunId | undefined);
669+ if (runId === undefined) {
670+ const projection = yield* getProjection(input.threadId);
671+ runId = projection.runs.findLast(
655672 (run) =>
656673 run.status === "preparing" ||
657674 run.status === "starting" ||
658675 run.status === "running" ||
659676 run.status === "waiting",
660677 )?.id;
678+ }
661679 if (runId === undefined) return { sequence: 0 };
662680 return yield* dispatch({
663681 type: "run.interrupt",
packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts+214−0
@@ -0,0 +1,214 @@
1+import { ThreadId } from "@t3tools/contracts";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+
6+import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts";
7+import { remoteHttpClientLayer } from "../rpc/http.ts";
8+import { boundedThreadSnapshotLoaderLayer } from "./boundedThreadSnapshotHttp.ts";
9+import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
10+
11+const 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+
18+const 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+
27+const THREAD_ID = ThreadId.make("thread-bounded");
28+const NOW = "2026-06-20T00:00:00.000Z";
29+
30+const 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+
79+describe("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 @@
1+import type { ThreadId } from "@t3tools/contracts";
2+import * as Cause from "effect/Cause";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+import { HttpClient } from "effect/unstable/http";
7+
8+import type { PreparedConnection } from "../connection/model.ts";
9+import { environmentEndpointUrl } from "../environment/endpoint.ts";
10+import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
11+import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
12+import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
13+import {
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.
20+const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
21+
22+/** Load a bounded recent-window thread snapshot over HTTP. */
23+export 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+ */
63+export 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
55 } from "@t3tools/contracts";
66 import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
77
8+export type ApplyOrchestrationV2ProjectionEventOptions = {
9+ readonly partialTimeline?: boolean;
10+ readonly latestLocalTurnOrdinal?: number | null;
11+};
12+
813 function upsertEntity<T extends { readonly id: unknown }>(
914 items: ReadonlyArray<T>,
1015 item: T,
@@ -60,9 +65,43 @@ function activeVisibleTurnItems( · 49 unmodified lines
6065 return next === null ? rows : renumberVisibleItems(next);
6166 }
6267
68+function 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+
81+function 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+
63100 function upsertVisibleTurnItem(
64101 projection: OrchestrationV2ThreadProjection,
65102 item: OrchestrationV2TurnItem,
103+ partialTimeline: boolean,
104+ latestLocalTurnOrdinal: number | null | undefined,
66105 ): OrchestrationV2ThreadProjection["visibleTurnItems"] {
67106 const rows = projection.visibleTurnItems;
68107 const index = rows.findIndex((row) => row.sourceItemId === item.id);
@@ -83,6 +122,13 @@ function upsertVisibleTurnItem( · 14 unmodified lines
83122 ) {
84123 return rows;
85124 }
125+ if (
126+ index === -1 &&
127+ partialTimeline &&
128+ shouldDropMissingPartialTurnItem(projection, item, latestLocalTurnOrdinal)
129+ ) {
130+ return rows;
131+ }
86132 const updated = index === -1 ? [...rows] : [...rows.slice(0, index), ...rows.slice(index + 1)];
87133 const insertionIndex = updated.findIndex(
88134 (row) =>
@@ -99,9 +145,12 @@ function upsertVisibleTurnItem( · 10 unmodified lines
99145 export function applyOrchestrationV2ProjectionEvent(
100146 projection: OrchestrationV2ThreadProjection | null,
101147 event: OrchestrationV2DomainEvent,
148+ options?: ApplyOrchestrationV2ProjectionEventOptions,
102149 ): OrchestrationV2ThreadProjection | null {
103150 if (projection === null || event.threadId !== projection.thread.id) return projection;
104151
152+ const partialTimeline = options?.partialTimeline === true;
153+ const latestLocalTurnOrdinal = options?.latestLocalTurnOrdinal;
105154 const base = { ...projection, updatedAt: event.occurredAt };
106155 switch (event.type) {
107156 case "thread.created":
@@ -163,12 +212,19 @@ export function applyOrchestrationV2ProjectionEvent( · 55 unmodified lines
163212 case "plan.updated":
164213 return { ...base, plans: upsertEntity(base.plans, event.payload) };
165214 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+ }
166222 const next = { ...base, turnItems: upsertEntity(base.turnItems, event.payload) };
167223 const visible = { ...next, visibleTurnItems: activeVisibleTurnItems(next) };
168224 return {
169225 ...next,
170226 visibleTurnItems: shouldShowLocalTurnItem(next, event.payload)
171− ? upsertVisibleTurnItem(visible, event.payload)
227+ ? upsertVisibleTurnItem(visible, event.payload, partialTimeline, latestLocalTurnOrdinal)
172228 : removeVisibleItem(visible.visibleTurnItems, event.payload.id),
173229 };
174230 }
packages/client-runtime/src/state/shellReducer.test.ts+24−0
@@ -214,6 +214,30 @@ describe("applyShellStreamEvent", () => { · 213 unmodified lines
214214 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
215215 });
216216
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+
217241 it("lower-sequence enrichment cannot resurrect empty structural state", () => {
218242 const previous = {
219243 ...v2ShellSnapshot,
packages/client-runtime/src/state/shellReducer.ts+6−6
@@ -29,8 +29,8 @@ function retainRepositoryIdentity( · 28 unmodified lines
2929
3030 export interface MergeShellSnapshotOptions {
3131 /**
32− * Enrichment refresh: structure and sequence must not roll back when at or
33− * below cache; listed roots accept identity exactly (including null).
32+ * Metadata-only enrichment refresh: structure and sequence never change;
33+ * listed roots accept identity exactly (including null).
3434 * Omit this options object for authoritative HTTP/initial WebSocket snapshots.
3535 */
3636 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
@@ -43,9 +43,9 @@ export interface MergeShellSnapshotOptions { · 6 unmodified lines
4343 * lower than cache, while retaining a prior non-null identity when the candidate
4444 * is still unresolved/null for the same root.
4545 *
46− * Enrichment snapshots (options present) never resurrect or roll back
47− * projects/threads/archives/sequence when at or below cache; they only patch
48− * repository identity for matching current projects.
46+ * Enrichment snapshots (options present) only patch repository identity for
47+ * matching current projects. They never replace projects, threads, archives,
48+ * or sequence, regardless of the incoming snapshot sequence.
4949 */
5050 export function mergeShellSnapshotProjects(
5151 previous: OrchestrationV2ShellSnapshot | null | undefined,
@@ -59,7 +59,7 @@ export function mergeShellSnapshotProjects( · 7 unmodified lines
5959 const isEnrichment = options !== undefined;
6060 const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null;
6161
62− if (isEnrichment && next.snapshotSequence <= previous.snapshotSequence) {
62+ if (isEnrichment) {
6363 const nextById = new Map(next.projects.map((project) => [project.id, project] as const));
6464 return {
6565 ...previous,
packages/client-runtime/src/state/threadCommands.ts+32−0
@@ -1,4 +1,7 @@
1+import type { ThreadId } from "@t3tools/contracts";
12 import * as Crypto from "effect/Crypto";
3+import * as Effect from "effect/Effect";
4+import * as Option from "effect/Option";
25 import { Atom } from "effect/unstable/reactivity";
36
47 import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";
@@ -61,6 +64,15 @@ import { · 56 unmodified lines
6164 visitThread,
6265 } from "../operations/commands.ts";
6366 import type { EnvironmentRegistry } from "../connection/registry.ts";
67+import { EnvironmentSupervisor } from "../connection/supervisor.ts";
68+import {
69+ ThreadHistoryController,
70+ type ThreadHistoryLoadEarlierResult,
71+} from "./threadHistoryController.ts";
72+
73+export type LoadEarlierThreadHistoryInput = {
74+ readonly threadId: ThreadId;
75+};
6476
6577 export type {
6678 ArchiveThreadInput,
@@ -279,5 +291,25 @@ export function createThreadEnvironmentAtoms<R, E>( · 212 unmodified lines
279291 scheduler,
280292 concurrency,
281293 }),
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+ }),
282314 };
283315 }
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
44 import { describe, expect, it } from "vite-plus/test";
55
66 import { v2Projection } from "./orchestrationV2TestFixtures.ts";
7+import { EMPTY_THREAD_HISTORY_META } from "./threadHistoryMerge.ts";
78 import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
89 import type { EnvironmentThreadState } from "./threads.ts";
910
@@ -12,13 +13,22 @@ const ref: ScopedThreadRef = { · 2 unmodified lines
1213 threadId: ThreadId.make(v2Projection.thread.id),
1314 };
1415
16+function 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+
1523 describe("createEnvironmentThreadDetailAtoms", () => {
1624 it("adds environment scope while preserving the pristine projection", () => {
17− const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success({
18− data: Option.some(v2Projection),
19− status: "cached",
20− error: Option.none(),
21− });
25+ const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success(
26+ threadState({
27+ data: Option.some(v2Projection),
28+ status: "cached",
29+ error: Option.none(),
30+ }),
31+ );
2232 const sourceAtom = Atom.make(initial);
2333 const details = createEnvironmentThreadDetailAtoms(() => sourceAtom);
2434 const registry = AtomRegistry.make();
@@ -28,46 +38,61 @@ describe("createEnvironmentThreadDetailAtoms", () => { · 3 unmodified lines
2838 expect(thread?.projection).toBe(v2Projection);
2939 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
3040 expect(registry.get(details.statusAtom(ref))).toBe("cached");
41+ expect(registry.get(details.historyAtom(ref))).toBe(EMPTY_THREAD_HISTORY_META);
3142
3243 registry.set(
3344 sourceAtom,
34− AsyncResult.success({
35− data: Option.some(v2Projection),
36− status: "synchronizing",
37− error: Option.none(),
38− }),
45+ AsyncResult.success(
46+ threadState({
47+ data: Option.some(v2Projection),
48+ status: "synchronizing",
49+ error: Option.none(),
50+ }),
51+ ),
3952 );
4053
4154 expect(registry.get(details.threadAtom(ref))).toBe(thread);
4255 expect(registry.get(details.statusAtom(ref))).toBe("synchronizing");
4356
57+ const progressiveHistory = {
58+ ...EMPTY_THREAD_HISTORY_META,
59+ historyCursor: "cursor-1",
60+ hasMoreHistory: true,
61+ };
4462 registry.set(
4563 sourceAtom,
46− AsyncResult.success({
47− data: Option.some(v2Projection),
48− status: "live",
49− error: Option.some("Stream interrupted."),
50− }),
64+ AsyncResult.success(
65+ threadState({
66+ data: Option.some(v2Projection),
67+ status: "live",
68+ error: Option.some("Stream interrupted."),
69+ history: progressiveHistory,
70+ }),
71+ ),
5172 );
5273
5374 expect(registry.get(details.threadAtom(ref))).toBe(thread);
5475 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
5576 expect(registry.get(details.statusAtom(ref))).toBe("live");
5677 expect(registry.get(details.errorAtom(ref))).toBe("Stream interrupted.");
78+ expect(registry.get(details.historyAtom(ref))).toBe(progressiveHistory);
5779
5880 registry.set(
5981 sourceAtom,
60− AsyncResult.success({
61− data: Option.none(),
62− status: "deleted",
63− error: Option.none(),
64− }),
82+ AsyncResult.success(
83+ threadState({
84+ data: Option.none(),
85+ status: "deleted",
86+ error: Option.none(),
87+ }),
88+ ),
6589 );
6690
6791 expect(registry.get(details.threadAtom(ref))).toBeNull();
6892 expect(registry.get(details.visibleTurnItemsAtom(ref))).toEqual([]);
6993 expect(registry.get(details.statusAtom(ref))).toBe("deleted");
7094 expect(registry.get(details.errorAtom(ref))).toBeNull();
95+ expect(registry.get(details.historyAtom(ref))).toBe(EMPTY_THREAD_HISTORY_META);
7196 registry.dispose();
7297 });
7398 });
packages/client-runtime/src/state/threadDetail.ts+7−0
@@ -62,6 +62,12 @@ export function createEnvironmentThreadDetailAtoms<E>( · 61 unmodified lines
6262 Atom.withLabel(`environment-thread-error:${key}`),
6363 ),
6464 );
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+ );
6571
6672 return {
6773 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),
@@ -69,5 +75,6 @@ export function createEnvironmentThreadDetailAtoms<E>( · 1 unmodified line
6975 visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),
7076 statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)),
7177 errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)),
78+ historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)),
7279 };
7380 }
packages/client-runtime/src/state/threadHistoryController.test.ts+66−0
@@ -0,0 +1,66 @@
1+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+
6+import {
7+ ThreadHistoryController,
8+ threadHistoryControllerLayer,
9+ type ThreadHistoryHandler,
10+ type ThreadHistoryLoadEarlierResult,
11+} from "./threadHistoryController.ts";
12+
13+const ENV = "env-history" as EnvironmentId;
14+const THREAD = "thread-history" as ThreadId;
15+
16+function 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+
34+describe("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 @@
1+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Ref from "effect/Ref";
6+
7+import { threadKey } from "./entities.ts";
8+
9+export type ThreadHistoryLoadEarlierResult =
10+ | { readonly _tag: "loaded" }
11+ | { readonly _tag: "noop" }
12+ | { readonly _tag: "busy" }
13+ | { readonly _tag: "error"; readonly message: string };
14+
15+export 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+ */
23+export 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+ */
33+export 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+
49+export 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 @@
1+import type { ThreadId } from "@t3tools/contracts";
2+import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
4+
5+import type { PreparedConnection } from "../connection/model.ts";
6+import { environmentEndpointUrl } from "../environment/endpoint.ts";
7+import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
8+import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
9+import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
10+
11+const DEFAULT_THREAD_HISTORY_TIMEOUT_MS = 6_000;
12+
13+export 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 @@
1+import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as DateTime from "effect/DateTime";
4+
5+import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
6+import {
7+ applyHistoryPageMeta,
8+ clearActiveHistoryLoading,
9+ isActiveHistoryRequestCursor,
10+ mergeOlderHistoryIntoProjection,
11+ shouldShowLoadEarlierControl,
12+} from "./threadHistoryMerge.ts";
13+import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
14+
15+const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
16+
17+function 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+
47+describe("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 @@
1+import type {
2+ OrchestrationV2ProjectedTurnItem,
3+ OrchestrationV2ThreadProjection,
4+ OrchestrationV2TurnItem,
5+} from "@t3tools/contracts";
6+import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
7+
8+export 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+
22+export 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. */
32+export 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+ */
43+export 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+
53+function projectedItemKey(row: OrchestrationV2ProjectedTurnItem): string {
54+ return `${row.sourceThreadId}:${row.sourceItemId}`;
55+}
56+
57+function 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+ */
68+export 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+
135+export 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. */
154+export 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
1818 // delays the transition to live data on the first open, not the initial paint.
1919 const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
2020
21+/** Progressive history metadata returned by a bounded snapshot loader. */
22+export 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+ */
36+export 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+
2145 /**
2246 * Load a thread's detail snapshot over HTTP instead of embedding it in the
2347 * WebSocket subscription's first frame. The response is gzip-compressible by
@@ -49,10 +73,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( · 25 unmodified lines
4973 export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;
5074
5175 /**
52− * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it
53− * cannot be loaded (so the caller falls back to the socket-embedded snapshot).
54− * Decouples the thread state machine from the underlying HTTP + DPoP details and
55− * keeps them out of test contexts.
76+ * Loads a thread's detail snapshot over HTTP.
77+ *
78+ * Distinguishes a definitive missing thread (HTTP 404) from transient snapshot
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.
5682 */
5783 export class ThreadSnapshotLoader extends Context.Service<
5884 ThreadSnapshotLoader,
@@ -60,7 +86,7 @@ export class ThreadSnapshotLoader extends Context.Service< · 1 unmodified line
6086 readonly load: (
6187 prepared: PreparedConnection,
6288 threadId: ThreadId,
63− ) => Effect.Effect<Option.Option<OrchestrationV2ThreadDetailSnapshot>>;
89+ ) => Effect.Effect<ThreadSnapshotLoadResult>;
6490 }
6591 >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
6692
@@ -80,19 +106,22 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 13 unmodified lines
80106 return ThreadSnapshotLoader.of({
81107 load: (prepared: PreparedConnection, threadId: ThreadId) =>
82108 fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(
83− Effect.map(Option.some<OrchestrationV2ThreadDetailSnapshot>),
109+ Effect.map(
110+ (snapshot): ThreadSnapshotLoadResult => ({
111+ _tag: "present",
112+ snapshot,
113+ }),
114+ ),
84115 Effect.provideService(HttpClient.HttpClient, httpClient),
85− // A genuinely missing thread (404) is expected — the socket
86− // subscription is the source of truth for thread existence and will
87− // surface the deletion — so don't treat it as an error worth warning
88− // about; just defer to the socket path.
116+ // A genuinely missing thread (404) is definitive: do not fall back to
117+ // the socket or retry. Callers mark the thread deleted and clear cache.
89118 Effect.catchTags({
90119 EnvironmentResourceNotFoundError: () =>
91120 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.",
93122 ).pipe(
94123 Effect.annotateLogs({ threadId }),
95− Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()),
124+ Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
96125 ),
97126 }),
98127 Effect.catchCause((cause) =>
@@ -100,7 +129,7 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 1 unmodified line
100129 "Could not load the thread snapshot over HTTP; using the socket snapshot instead.",
101130 ).pipe(
102131 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
103− Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()),
132+ Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
104133 ),
105134 ),
106135 ),
packages/client-runtime/src/state/threadState.ts+9−0
@@ -1,16 +1,25 @@
11 import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";
22 import * as Option from "effect/Option";
33
4+import { EMPTY_THREAD_HISTORY_META, type ThreadHistoryMeta } from "./threadHistoryMerge.ts";
5+
46 export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
57
68 export interface EnvironmentThreadState {
79 readonly data: Option.Option<OrchestrationV2ThreadProjection>;
810 readonly status: EnvironmentThreadStatus;
911 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;
1018 }
1119
1220 export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
1321 data: Option.none(),
1422 status: "empty",
1523 error: Option.none(),
24+ history: EMPTY_THREAD_HISTORY_META,
1625 };
packages/client-runtime/src/state/threads-sync.test.ts+601−4
@@ -3,9 +3,11 @@ import { · 2 unmodified lines
33 EventId,
44 ORCHESTRATION_V2_WS_METHODS,
55 ThreadId,
6+ TurnItemId,
67 type OrchestrationV2ThreadDetailSnapshot,
78 type OrchestrationV2ThreadProjection,
89 type OrchestrationV2ThreadStreamItem,
10+ type OrchestrationV2TurnItem,
911 } from "@t3tools/contracts";
1012 import { describe, expect, it } from "@effect/vitest";
1113 import * as DateTime from "effect/DateTime";
@@ -36,6 +38,7 @@ import { · 24 unmodified lines
3638 makeEnvironmentThreadState,
3739 ThreadSnapshotLoader,
3840 type EnvironmentThreadState,
41+ type ThreadSnapshotLoadResult,
3942 } from "./threads.ts";
4043
4144 const TARGET = new PrimaryConnectionTarget({
@@ -89,7 +92,12 @@ function awaitThreadState( · 47 unmodified lines
8992
9093 const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
9194 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;
93101 readonly completionMarker?: boolean;
94102 }) {
95103 const inputs = yield* Queue.unbounded<TestThreadInput>();
@@ -138,8 +146,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 42 unmodified lines
138146 Ref.update(loaderCalls, (count) => count + 1).pipe(
139147 Effect.as(
140148 threadId === THREAD_ID
141− ? (options?.httpSnapshot ?? Option.none<OrchestrationV2ThreadDetailSnapshot>())
142− : Option.none<OrchestrationV2ThreadDetailSnapshot>(),
149+ ? (options?.httpSnapshot ??
150+ ({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult))
151+ : ({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
143152 ),
144153 ),
145154 });
@@ -161,6 +170,17 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 15 unmodified lines
161170 ? Option.some({
162171 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
163172 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+ }),
164184 })
165185 : Option.none(),
166186 ),
@@ -318,7 +338,10 @@ describe("EnvironmentThreads", () => { · 151 unmodified lines
318338 thread: { ...BASE_PROJECTION.thread, title: "HTTP title" },
319339 };
320340 const harness = yield* makeHarness({
321− httpSnapshot: Option.some({ snapshotSequence: 1, projection: httpProjection }),
341+ httpSnapshot: {
342+ _tag: "present",
343+ snapshot: { snapshotSequence: 1, projection: httpProjection },
344+ },
322345 });
323346 // No socket snapshot is pushed; only a live event arrives over the socket.
324347 // It can only be applied if the HTTP snapshot already seeded the thread.
@@ -340,6 +363,580 @@ describe("EnvironmentThreads", () => { · 15 unmodified lines
340363 }),
341364 );
342365
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+
343940 it.effect("ignores replayed thread events at or below the snapshot sequence", () =>
344941 Effect.gen(function* () {
345942 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
1111 import * as Option from "effect/Option";
1212 import * as Queue from "effect/Queue";
1313 import * as Ref from "effect/Ref";
14+import * as Result from "effect/Result";
1415 import * as Stream from "effect/Stream";
1516 import * as SubscriptionRef from "effect/SubscriptionRef";
17+import { HttpClient } from "effect/unstable/http";
1618 import { Atom } from "effect/unstable/reactivity";
1719
1820 import { EnvironmentRegistry } from "../connection/registry.ts";
@@ -20,12 +22,26 @@ import { connectionProjectionPhase } from "../connection/model.ts"; · 1 unmodified line
2022 import { EnvironmentSupervisor } from "../connection/supervisor.ts";
2123 import * as ConnectionWakeups from "../connection/wakeups.ts";
2224 import { EnvironmentCacheStore } from "../platform/persistence.ts";
25+import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
2326 import { subscribeDynamic } from "../rpc/client.ts";
24−import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
2527 import { parseThreadKey, threadKey } from "./entities.ts";
2628 import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
27−import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
2829 import { followStreamInEnvironment } from "./runtime.ts";
30+import {
31+ ThreadHistoryController,
32+ type ThreadHistoryLoadEarlierResult,
33+} from "./threadHistoryController.ts";
34+import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts";
35+import {
36+ applyHistoryPageMeta,
37+ clearActiveHistoryLoading,
38+ EMPTY_THREAD_HISTORY_META,
39+ isActiveHistoryRequestCursor,
40+ mergeOlderHistoryIntoProjection,
41+ type ThreadHistoryMeta,
42+} from "./threadHistoryMerge.ts";
43+import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
44+import { ThreadSnapshotLoader, type ThreadSnapshotLoadResult } from "./threadSnapshotHttp.ts";
2945 import {
3046 EMPTY_ENVIRONMENT_THREAD_STATE,
3147 type EnvironmentThreadState,
@@ -45,7 +61,57 @@ function formatThreadError(cause: Cause.Cause<unknown>): string { · 13 unmodified lines
4561 : "Could not synchronize the thread.";
4662 }
4763
48−function shouldPersistThread(thread: OrchestrationV2ThreadProjection): boolean {
64+function 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+
71+function 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+
87+function 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+
106+function 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+ }
49115 return !thread.runs.some(
50116 (run) => run.status === "preparing" || run.status === "starting" || run.status === "running",
51117 );
@@ -94,6 +160,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 42 unmodified lines
94160 const supervisor = yield* EnvironmentSupervisor;
95161 const cache = yield* EnvironmentCacheStore;
96162 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);
97166 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);
98167 const environmentId = supervisor.target.environmentId;
99168 const cached = yield* cache.loadThread(environmentId, threadId).pipe(
@@ -109,10 +178,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 9 unmodified lines
109178 ),
110179 );
111180 const cachedThread = Option.map(cached, (snapshot) => snapshot.projection);
181+ const cachedHistory = Option.match(cached, {
182+ onNone: () => EMPTY_THREAD_HISTORY_META,
183+ onSome: historyMetaFromCachedSnapshot,
184+ });
112185 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({
113186 data: cachedThread,
114187 status: statusWithoutLiveData(cachedThread),
115188 error: Option.none(),
189+ history: cachedHistory,
116190 });
117191 // Seed the resume cursor from the cached snapshot so a warm cache can catch up
118192 // via `afterSequence` instead of re-downloading the full thread body.
@@ -204,28 +278,57 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 85 unmodified lines
204278
205279 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (
206280 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+ },
207290 ) {
208291 const waiting = yield* Ref.get(awaitingCompletion);
209− yield* SubscriptionRef.set(state, {
210− data: Option.some(thread),
211− status: waiting ? "synchronizing" : "live",
212− error: Option.none(),
292+ // Atomic with concurrent history meta updates: never get-then-set the whole
293+ // state when only the projection changes. Bounded installs pass history so
294+ // projection + cursor persist together in one enqueue.
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+ };
213309 });
214310 // Active projections can update many times per second and retain large tool
215311 // payloads. Persist once the run settles so cache encoding stays off the
216− // streaming path.
217− if (shouldPersistThread(thread)) {
312+ // streaming path. Progressive meta rides along when the window is incomplete.
313+ if (shouldPersistThread(thread, next.history)) {
218314 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
219− yield* Queue.offer(persistence, { snapshotSequence, projection: thread });
315+ yield* Queue.offer(persistence, snapshotToPersist(snapshotSequence, thread, next.history));
220316 }
221317 });
222318
319+ const patchHistoryMeta = (patch: (history: ThreadHistoryMeta) => ThreadHistoryMeta) =>
320+ SubscriptionRef.update(state, (current) => ({
321+ ...current,
322+ history: patch(current.history),
323+ }));
324+
223325 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
224326 yield* Ref.set(awaitingCompletion, false);
225327 yield* SubscriptionRef.set(state, {
226328 data: Option.none(),
227329 status: "deleted",
228330 error: Option.none(),
331+ history: EMPTY_THREAD_HISTORY_META,
229332 });
230333 yield* remember;
231334 if (resumeCache !== undefined && resumeCache.owner !== owner) return;
@@ -257,7 +360,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 25 unmodified lines
257360
258361 if (item.kind === "snapshot") {
259362 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 });
261367 return;
262368 }
263369
@@ -267,23 +373,230 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines
267373 }
268374 yield* SubscriptionRef.set(lastSequence, item.sequence);
269375
270− const current = yield* SubscriptionRef.get(state);
271− if (Option.isNone(current.data)) {
272− if (item.event.type === "thread.deleted") {
273− yield* setDeleted();
274− }
275− return;
276− }
277− if (item.event.type === "thread.deleted") {
376+ const waiting = yield* Ref.get(awaitingCompletion);
377+ // Apply against the latest projection/history in one update so a concurrent
378+ // loadEarlier merge (or history-meta patch) cannot be clobbered by a stale
379+ // get-then-set rebuild.
380+ type EventApplyResult =
381+ | { readonly _tag: "noop" }
382+ | { readonly _tag: "delete" }
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") {
278444 yield* setDeleted();
279445 return;
280446 }
281− const next = applyOrchestrationV2ProjectionEvent(current.data.value, item.event);
282− if (next !== null) {
283− yield* setThread(next);
447+ if (result._tag === "applied" && shouldPersistThread(result.projection, result.history)) {
448+ const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
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;
284468 }
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+ );
285591 });
286592
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+
287600 yield* SubscriptionRef.changes(supervisor.state).pipe(
288601 Stream.runForEach((connectionState) => {
289602 switch (connectionProjectionPhase(connectionState)) {
@@ -309,6 +622,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 19 unmodified lines
309622 subscribeDynamic(
310623 ORCHESTRATION_V2_WS_METHODS.subscribeThread,
311624 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+
312633 const supportsCompletionMarker = yield* session.initialConfig.pipe(
313634 Effect.map((config) => config.threadResumeCompletionMarker === true),
314635 Effect.orElseSucceed(() => false),
@@ -316,8 +637,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 1 unmodified line
316637 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
317638 yield* setSynchronizing;
318639
319− let current = yield* SubscriptionRef.get(state);
320− if (Option.isNone(current.data) && current.status !== "deleted") {
640+ if (Option.isNone(current.data)) {
321641 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
322642 Effect.flatMap(
323643 Option.match({
@@ -332,14 +652,41 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 8 unmodified lines
332652 }),
333653 ),
334654 );
335− const httpSnapshot = yield* snapshotLoader.load(prepared, threadId);
336− if (Option.isSome(httpSnapshot)) {
337− yield* applyItem({
338− kind: "snapshot",
339− snapshotSequence: httpSnapshot.value.snapshotSequence,
340− projection: httpSnapshot.value.projection,
341− });
342− current = yield* SubscriptionRef.get(state);
655+ const httpResult: ThreadSnapshotLoadResult = yield* snapshotLoader.load(
656+ prepared,
657+ threadId,
658+ );
659+ switch (httpResult._tag) {
660+ case "present": {
661+ // Atomic projection + progressive meta so a settled bounded window
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+ }
343690 }
344691 }
345692
@@ -373,8 +720,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 27 unmodified lines
373720 Option.match(current.data, {
374721 onNone: () => Effect.void,
375722 onSome: (projection) =>
376− shouldPersistThread(projection)
377− ? persist({ snapshotSequence, projection })
723+ shouldPersistThread(projection, current.history)
724+ ? persist(snapshotToPersist(snapshotSequence, projection, current.history))
378725 : Effect.void,
379726 }),
380727 ),
@@ -442,6 +789,9 @@ export function createEnvironmentThreadStateAtoms<R, E>( · 61 unmodified lines
442789
443790 export * from "./archivedThreads.ts";
444791 export * from "./checkpointDiff.ts";
792+export * from "./boundedThreadSnapshotHttp.ts";
793+export * from "./threadHistoryController.ts";
794+export * from "./threadHistoryMerge.ts";
445795 export * from "./threadSnapshotHttp.ts";
446796 export * from "./composerPathSearch.ts";
447797 export * from "./threadCommands.ts";
packages/contracts/src/environmentHttp.ts+33−0
@@ -33,7 +33,9 @@ import { · 32 unmodified lines
3333 import { ExecutionEnvironmentDescriptor } from "./environment.ts";
3434 import {
3535 OrchestrationV2ShellSnapshot,
36+ OrchestrationV2ThreadBoundedSnapshot,
3637 OrchestrationV2ThreadDetailSnapshot,
38+ OrchestrationV2ThreadHistoryPage,
3739 } from "./orchestrationV2.ts";
3840 import { Project, ProjectMutation, ProjectSnapshot } from "./project.ts";
3941 import {
@@ -65,6 +67,7 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ · 25 unmodified lines
6567 "invalid_scope",
6668 "scope_not_granted",
6769 "invalid_command",
70+ "invalid_history_cursor",
6871 ]);
6972 export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type;
7073
@@ -94,6 +97,8 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ · 23 unmodified lines
9497 "project_mutation_failed",
9598 "orchestration_snapshot_failed",
9699 "orchestration_thread_snapshot_failed",
100+ "orchestration_thread_bounded_snapshot_failed",
101+ "orchestration_thread_history_failed",
97102 "internal_error",
98103 ]);
99104 export type EnvironmentInternalErrorReason = typeof EnvironmentInternalErrorReason.Type;
@@ -497,6 +502,17 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ · 397 unmodified lines
497502 threadId: ThreadId,
498503 });
499504
505+const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({
506+ cursor: TrimmedNonEmptyString,
507+});
508+
509+const EnvironmentOrchestrationThreadHistoryErrors = [
510+ EnvironmentRequestInvalidError,
511+ EnvironmentScopeRequiredError,
512+ EnvironmentResourceNotFoundError,
513+ EnvironmentInternalError,
514+] as const;
515+
500516 export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
501517 .add(
502518 HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", {
@@ -512,6 +528,23 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr · 9 unmodified lines
512528 success: OrchestrationV2ThreadDetailSnapshot,
513529 error: EnvironmentOrchestrationThreadSnapshotErrors,
514530 }).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),
515548 ) {}
516549
517550 export 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
23842384 export const OrchestrationV2ThreadDetailSnapshot = Schema.Struct({
23852385 snapshotSequence: NonNegativeInt,
23862386 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)),
23872399 });
23882400 export type OrchestrationV2ThreadDetailSnapshot = typeof OrchestrationV2ThreadDetailSnapshot.Type;
23892401
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+ */
2407+export 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+});
2419+export type OrchestrationV2ThreadBoundedSnapshot = typeof OrchestrationV2ThreadBoundedSnapshot.Type;
2420+
2421+/** Older timeline page for progressive history. Rows are chronological. */
2422+export const OrchestrationV2ThreadHistoryPage = Schema.Struct({
2423+ snapshotSequence: NonNegativeInt,
2424+ items: Schema.Array(OrchestrationV2ProjectedTurnItem),
2425+ nextCursor: Schema.NullOr(TrimmedNonEmptyString),
2426+ hasMoreHistory: Schema.Boolean,
2427+});
2428+export type OrchestrationV2ThreadHistoryPage = typeof OrchestrationV2ThreadHistoryPage.Type;
2429+
23902430 export const OrchestrationV2ThreadStreamItem = Schema.Union([
23912431 Schema.Struct({
23922432 kind: Schema.Literal("synchronized"),