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 3 months ago

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

Showing the changes to packages/client-runtime/src (19 of 43 files). Show all

Unified · Split

19 files
  1. packages/client-runtime/src/operations/commands.test.ts +72 −2
  2. packages/client-runtime/src/operations/commands.ts +25 −7
  3. packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts +214 −0
  4. packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +156 −0
  5. packages/client-runtime/src/state/orchestrationV2Projection.ts +57 −1
  6. packages/client-runtime/src/state/shellReducer.test.ts +24 −0
  7. packages/client-runtime/src/state/shellReducer.ts +6 −6
  8. packages/client-runtime/src/state/threadCommands.ts +32 −0
  9. packages/client-runtime/src/state/threadDetail.test.ts +45 −20
  10. packages/client-runtime/src/state/threadDetail.ts +7 −0
  11. packages/client-runtime/src/state/threadHistoryController.test.ts +66 −0
  12. packages/client-runtime/src/state/threadHistoryController.ts +93 −0
  13. packages/client-runtime/src/state/threadHistoryHttp.ts +45 −0
  14. packages/client-runtime/src/state/threadHistoryMerge.test.ts +416 −0
  15. packages/client-runtime/src/state/threadHistoryMerge.ts +156 −0
  16. packages/client-runtime/src/state/threadSnapshotHttp.ts +42 −13
  17. packages/client-runtime/src/state/threadState.ts +9 −0
  18. packages/client-runtime/src/state/threads-sync.test.ts +601 −4
  19. packages/client-runtime/src/state/threads.ts +384 −34
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";