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
38import {38import {
39 archiveThread,39 archiveThread,
40 createProject,40 createProject,
41 interruptThreadTurn,
41 forkThreadFromRun,42 forkThreadFromRun,
42 mergeThreadBack,43 mergeThreadBack,
43 cancelQueuedRun,44 cancelQueuedRun,
@@ -71,6 +72,7 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 27 unmodified lines
71 readonly projects: ProjectMutation[];72 readonly projects: ProjectMutation[];
72 readonly launches?: OrchestrationV2ThreadLaunchInput[];73 readonly launches?: OrchestrationV2ThreadLaunchInput[];
73 readonly projection?: OrchestrationV2ThreadProjection;74 readonly projection?: OrchestrationV2ThreadProjection;
75 readonly projectionRequests?: ThreadId[];
74}) {76}) {
75 const client = {77 const client = {
76 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>78 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
@@ -78,8 +80,13 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct · 1 unmodified line
78 input.commands.push(command);80 input.commands.push(command);
79 return { sequence: input.commands.length };81 return { sequence: input.commands.length };
80 }),82 }),
81 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: () =>83 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (requestInput: {
82 Effect.succeed(input.projection ?? v2Projection),84 readonly threadId: ThreadId;
85 }) =>
86 Effect.sync(() => {
87 input.projectionRequests?.push(requestInput.threadId);
88 return input.projection ?? v2Projection;
89 }),
83 [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) =>90 [ORCHESTRATION_V2_WS_METHODS.launchThread]: (launchInput: OrchestrationV2ThreadLaunchInput) =>
84 Effect.sync(() => {91 Effect.sync(() => {
85 input.launches?.push(launchInput);92 input.launches?.push(launchInput);
@@ -511,4 +518,67 @@ describe("V2 environment commands", () => { · 425 unmodified lines
511 ]);518 ]);
512 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),519 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
513 );520 );
521
522 it.effect("dispatches an explicit idle start without fetching the full projection", () =>
523 Effect.gen(function* () {
524 const commands: OrchestrationV2Command[] = [];
525 const projectionRequests: ThreadId[] = [];
526 const supervisor = yield* makeSupervisor({
527 commands,
528 projects: [],
529 projectionRequests,
530 });
531
532 yield* startThreadTurn({
533 commandId: CommandId.make("direct-start"),
534 threadId: v2ThreadId,
535 message: {
536 messageId: MessageId.make("direct-start-message"),
537 role: "user",
538 text: "continue",
539 attachments: [],
540 },
541 runtimeMode: "full-access",
542 interactionMode: "default",
543 dispatchMode: "start",
544 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
545
546 expect(projectionRequests).toEqual([]);
547 expect(commands).toMatchObject([
548 {
549 type: "message.dispatch",
550 threadId: v2ThreadId,
551 dispatchMode: { type: "start_immediately" },
552 },
553 ]);
554 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
555 );
556
557 it.effect("interrupts a known run without fetching the full projection", () =>
558 Effect.gen(function* () {
559 const commands: OrchestrationV2Command[] = [];
560 const projectionRequests: ThreadId[] = [];
561 const supervisor = yield* makeSupervisor({
562 commands,
563 projects: [],
564 projectionRequests,
565 });
566
567 yield* interruptThreadTurn({
568 commandId: CommandId.make("direct-interrupt"),
569 threadId: v2ThreadId,
570 runId: RunId.make("active-run"),
571 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
572
573 expect(projectionRequests).toEqual([]);
574 expect(commands).toEqual([
575 {
576 type: "run.interrupt",
577 commandId: "direct-interrupt",
578 threadId: v2ThreadId,
579 runId: "active-run",
580 },
581 ]);
582 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
583 );
514});584});
packages/client-runtime/src/operations/commands.ts+25−7
@@ -153,7 +153,7 @@ export interface StartThreadTurnInput extends ThreadCommandInput { · 152 unmodified lines
153 readonly interactionMode: ProviderInteractionMode;153 readonly interactionMode: ProviderInteractionMode;
154 readonly bootstrap?: StartThreadBootstrap;154 readonly bootstrap?: StartThreadBootstrap;
155 readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };155 readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };
156 readonly dispatchMode?: "auto" | "queue" | "steer" | "restart";156 readonly dispatchMode?: "auto" | "queue" | "steer" | "restart" | "start";
157}157}
158 158
159export interface InterruptThreadTurnInput extends ThreadCommandInput {159export interface InterruptThreadTurnInput extends ThreadCommandInput {
@@ -589,6 +589,25 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 429 unmodified lines
589 });589 });
590 }590 }
591 591
592 const requestedMode = input.dispatchMode ?? "auto";
593 if (requestedMode === "start") {
594 return yield* dispatch({
595 type: "message.dispatch",
596 commandId,
597 createdBy: "user",
598 creationSource: input.creationSource ?? "web",
599 threadId: input.threadId,
600 messageId: input.message.messageId,
601 text: input.message.text,
602 attachments,
603 ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }),
604 ...(input.sourceProposedPlan === undefined
605 ? {}
606 : { sourcePlanRef: input.sourceProposedPlan }),
607 dispatchMode: { type: "start_immediately" },
608 });
609 }
610
592 const projection = yield* getProjection(input.threadId);611 const projection = yield* getProjection(input.threadId);
593 const activeRun = projection.runs.findLast(612 const activeRun = projection.runs.findLast(
594 (run) =>613 (run) =>
@@ -597,7 +616,6 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 2 unmodified lines
597 run.status === "running" ||616 run.status === "running" ||
598 run.status === "waiting",617 run.status === "waiting",
599 );618 );
600 const requestedMode = input.dispatchMode ?? "auto";
601 const activeProviderThread =619 const activeProviderThread =
602 activeRun === undefined620 activeRun === undefined
603 ? undefined621 ? undefined
@@ -647,17 +665,17 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( · 43 unmodified lines
647export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (665export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
648 input: InterruptThreadTurnInput,666 input: InterruptThreadTurnInput,
649) {667) {
650 const projection = yield* getProjection(input.threadId);668 let runId = input.runId ?? (input.turnId as RunId | undefined);
651 const runId =669 if (runId === undefined) {
652 input.runId ??670 const projection = yield* getProjection(input.threadId);
653 (input.turnId as RunId | undefined) ??671 runId = projection.runs.findLast(
654 projection.runs.findLast(
655 (run) =>672 (run) =>
656 run.status === "preparing" ||673 run.status === "preparing" ||
657 run.status === "starting" ||674 run.status === "starting" ||
658 run.status === "running" ||675 run.status === "running" ||
659 run.status === "waiting",676 run.status === "waiting",
660 )?.id;677 )?.id;
678 }
661 if (runId === undefined) return { sequence: 0 };679 if (runId === undefined) return { sequence: 0 };
662 return yield* dispatch({680 return yield* dispatch({
663 type: "run.interrupt",681 type: "run.interrupt",
packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts+214−0
@@ -0,0 +1,214 @@
1import { ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { PrimaryConnectionTarget, type PreparedConnection } from "../connection/model.ts";
7import { remoteHttpClientLayer } from "../rpc/http.ts";
8import { boundedThreadSnapshotLoaderLayer } from "./boundedThreadSnapshotHttp.ts";
9import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
10
11const TARGET = new PrimaryConnectionTarget({
12 environmentId: "environment-bounded" as never,
13 label: "Bounded",
14 httpBaseUrl: "https://environment.example.test",
15 wsBaseUrl: "wss://environment.example.test",
16});
17
18const PREPARED: PreparedConnection = {
19 environmentId: TARGET.environmentId,
20 label: TARGET.label,
21 httpBaseUrl: TARGET.httpBaseUrl,
22 socketUrl: TARGET.wsBaseUrl,
23 httpAuthorization: null,
24 target: TARGET,
25};
26
27const THREAD_ID = ThreadId.make("thread-bounded");
28const NOW = "2026-06-20T00:00:00.000Z";
29
30const FULL_SNAPSHOT_BODY = {
31 snapshotSequence: 9,
32 projection: {
33 thread: {
34 createdBy: "user",
35 creationSource: "web",
36 id: String(THREAD_ID),
37 projectId: "project-1",
38 title: "Full fallback thread",
39 providerInstanceId: "codex",
40 modelSelection: { instanceId: "codex", model: "gpt-5" },
41 runtimeMode: "full-access",
42 interactionMode: "default",
43 branch: null,
44 worktreePath: null,
45 activeProviderThreadId: null,
46 lineage: {
47 parentThreadId: null,
48 relationshipToParent: null,
49 rootThreadId: String(THREAD_ID),
50 },
51 forkedFrom: null,
52 createdAt: NOW,
53 updatedAt: NOW,
54 archivedAt: null,
55 deletedAt: null,
56 settledOverride: null,
57 settledAt: null,
58 },
59 runs: [],
60 attempts: [],
61 nodes: [],
62 subagents: [],
63 providerSessions: [],
64 providerThreads: [],
65 providerTurns: [],
66 runtimeRequests: [],
67 messages: [],
68 plans: [],
69 turnItems: [],
70 checkpointScopes: [],
71 checkpoints: [],
72 contextHandoffs: [],
73 contextTransfers: [],
74 visibleTurnItems: [],
75 updatedAt: NOW,
76 },
77};
78
79describe("boundedThreadSnapshotLoader", () => {
80 it.effect("falls back to full HTTP snapshot when bounded returns a plain route 404", () => {
81 const fetchFn = ((input: RequestInfo | URL) => {
82 const url = String(input);
83 if (url.includes(`/api/orchestration/threads/${THREAD_ID}/bounded`)) {
84 // Generic HTML/route 404: not a structured thread_not_found payload.
85 return Promise.resolve(
86 new Response("Not Found", {
87 status: 404,
88 headers: { "content-type": "text/plain" },
89 }),
90 );
91 }
92 expect(url).toContain(`/api/orchestration/threads/${THREAD_ID}`);
93 expect(url.includes("/bounded")).toBe(false);
94 return Promise.resolve(
95 new Response(JSON.stringify(FULL_SNAPSHOT_BODY), {
96 status: 200,
97 headers: { "content-type": "application/json" },
98 }),
99 );
100 }) satisfies typeof fetch;
101
102 return Effect.gen(function* () {
103 const loader = yield* ThreadSnapshotLoader;
104 const result = yield* loader.load(PREPARED, THREAD_ID);
105 expect(result._tag).toBe("present");
106 if (result._tag === "present") {
107 expect(result.snapshot.snapshotSequence).toBe(9);
108 expect(result.snapshot.projection.thread.title).toBe("Full fallback thread");
109 expect(result.history).toBeUndefined();
110 }
111 }).pipe(
112 Effect.provide(
113 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
114 ),
115 );
116 });
117
118 it.effect("treats structured EnvironmentResourceNotFoundError from bounded as missing", () => {
119 const fetchFn = ((input: RequestInfo | URL) => {
120 const url = String(input);
121 expect(url).toContain(`/api/orchestration/threads/${THREAD_ID}/bounded`);
122 return Promise.resolve(
123 Response.json(
124 {
125 _tag: "EnvironmentResourceNotFoundError",
126 code: "not_found",
127 reason: "thread_not_found",
128 traceId: "trace-bounded-missing",
129 },
130 { status: 404 },
131 ),
132 );
133 }) satisfies typeof fetch;
134
135 return Effect.gen(function* () {
136 const loader = yield* ThreadSnapshotLoader;
137 const result = yield* loader.load(PREPARED, THREAD_ID);
138 expect(result).toEqual({ _tag: "missing" });
139 }).pipe(
140 Effect.provide(
141 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
142 ),
143 );
144 });
145
146 it.effect("treats structured missing from full fallback as missing", () => {
147 let fullCalls = 0;
148 const fetchFn = ((input: RequestInfo | URL) => {
149 const url = String(input);
150 if (url.includes("/bounded")) {
151 return Promise.resolve(
152 new Response("Not Found", {
153 status: 404,
154 headers: { "content-type": "text/plain" },
155 }),
156 );
157 }
158 fullCalls += 1;
159 return Promise.resolve(
160 Response.json(
161 {
162 _tag: "EnvironmentResourceNotFoundError",
163 code: "not_found",
164 reason: "thread_not_found",
165 traceId: "trace-full-missing",
166 },
167 { status: 404 },
168 ),
169 );
170 }) satisfies typeof fetch;
171
172 return Effect.gen(function* () {
173 const loader = yield* ThreadSnapshotLoader;
174 const result = yield* loader.load(PREPARED, THREAD_ID);
175 expect(result).toEqual({ _tag: "missing" });
176 expect(fullCalls).toBe(1);
177 }).pipe(
178 Effect.provide(
179 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
180 ),
181 );
182 });
183
184 it.effect(
185 "uses socket fallback directly when the bounded endpoint has a transient failure",
186 () => {
187 let fullCalls = 0;
188 const fetchFn = ((input: RequestInfo | URL) => {
189 const url = String(input);
190 if (url.includes("/bounded")) {
191 return Promise.resolve(new Response("Service Unavailable", { status: 503 }));
192 }
193 fullCalls += 1;
194 return Promise.resolve(
195 new Response(JSON.stringify(FULL_SNAPSHOT_BODY), {
196 status: 200,
197 headers: { "content-type": "application/json" },
198 }),
199 );
200 }) satisfies typeof fetch;
201
202 return Effect.gen(function* () {
203 const loader = yield* ThreadSnapshotLoader;
204 const result = yield* loader.load(PREPARED, THREAD_ID);
205 expect(result).toEqual({ _tag: "unavailable" });
206 expect(fullCalls).toBe(0);
207 }).pipe(
208 Effect.provide(
209 Layer.provide(boundedThreadSnapshotLoaderLayer, remoteHttpClientLayer(fetchFn)),
210 ),
211 );
212 },
213 );
214});
packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts+156−0
@@ -0,0 +1,156 @@
1import type { ThreadId } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import { HttpClient } from "effect/unstable/http";
7
8import type { PreparedConnection } from "../connection/model.ts";
9import { environmentEndpointUrl } from "../environment/endpoint.ts";
10import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
11import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
12import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
13import {
14 fetchEnvironmentThreadSnapshot,
15 ThreadSnapshotLoader,
16 type ThreadSnapshotLoadResult,
17} from "./threadSnapshotHttp.ts";
18
19// Same cold-open budget as the full snapshot path; bounded payloads should fit.
20const DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
21
22/** Load a bounded recent-window thread snapshot over HTTP. */
23export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn(
24 "clientRuntime.state.fetchEnvironmentBoundedThreadSnapshot",
25)(function* (input: {
26 readonly prepared: PreparedConnection;
27 readonly threadId: ThreadId;
28 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
29 readonly timeoutMs?: number;
30}) {
31 const requestUrl = environmentEndpointUrl(
32 input.prepared.httpBaseUrl,
33 `/api/orchestration/threads/${input.threadId}/bounded`,
34 );
35 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
36 const headers = yield* buildEnvironmentAuthHeaders(
37 input.prepared.httpAuthorization,
38 "GET",
39 requestUrl,
40 input.signer,
41 );
42 return yield* executeEnvironmentHttpRequest(
43 requestUrl,
44 input.timeoutMs ?? DEFAULT_BOUNDED_THREAD_SNAPSHOT_TIMEOUT_MS,
45 withEnvironmentCredentials(
46 input.prepared.httpAuthorization,
47 client.orchestration.threadBoundedSnapshot({
48 params: { threadId: input.threadId },
49 headers,
50 }),
51 ),
52 );
53});
54
55/**
56 * Shared ThreadSnapshotLoader for clients that render progressive history.
57 * Seeds from the bounded HTTP snapshot when available. Older servers that lack
58 * the bounded route (plain/generic 404) fall back to the existing full HTTP
59 * thread snapshot. Structured EnvironmentResourceNotFoundError from either
60 * endpoint still means missing. Transient failures report `unavailable` so the
61 * socket path remains a last resort for connectivity issues.
62 */
63export const boundedThreadSnapshotLoaderLayer: Layer.Layer<
64 ThreadSnapshotLoader,
65 never,
66 HttpClient.HttpClient
67> = Layer.effect(
68 ThreadSnapshotLoader,
69 Effect.gen(function* () {
70 const httpClient = yield* HttpClient.HttpClient;
71 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
72 return ThreadSnapshotLoader.of({
73 load: (prepared: PreparedConnection, threadId: ThreadId) => {
74 const loadFullFallback = fetchEnvironmentThreadSnapshot({
75 prepared,
76 threadId,
77 signer,
78 }).pipe(
79 Effect.map(
80 (snapshot): ThreadSnapshotLoadResult => ({
81 _tag: "present",
82 snapshot,
83 }),
84 ),
85 Effect.provideService(HttpClient.HttpClient, httpClient),
86 Effect.catchTags({
87 EnvironmentResourceNotFoundError: () =>
88 Effect.logDebug(
89 "Full thread snapshot not found over HTTP after bounded fallback; treating the thread as deleted.",
90 ).pipe(
91 Effect.annotateLogs({ threadId }),
92 Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
93 ),
94 }),
95 Effect.catchCause((cause) =>
96 Effect.logWarning(
97 "Could not load the full thread snapshot over HTTP after bounded fallback; using the socket snapshot instead.",
98 ).pipe(
99 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
100 Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
101 ),
102 ),
103 );
104
105 return fetchEnvironmentBoundedThreadSnapshot({ prepared, threadId, signer }).pipe(
106 Effect.map(
107 (bounded): ThreadSnapshotLoadResult => ({
108 _tag: "present",
109 snapshot: {
110 snapshotSequence: bounded.snapshotSequence,
111 projection: bounded.projection,
112 latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal,
113 },
114 history: {
115 historyCursor: bounded.historyCursor,
116 hasMoreHistory: bounded.hasMoreHistory,
117 latestLocalTurnOrdinal: bounded.latestLocalTurnOrdinal,
118 },
119 }),
120 ),
121 Effect.provideService(HttpClient.HttpClient, httpClient),
122 Effect.catchTags({
123 EnvironmentResourceNotFoundError: () =>
124 Effect.logDebug(
125 "Bounded thread snapshot not found over HTTP; treating the thread as deleted.",
126 ).pipe(
127 Effect.annotateLogs({ threadId }),
128 Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
129 ),
130 RemoteEnvironmentAuthInvalidJsonError: (error) =>
131 Effect.logDebug(
132 "Bounded thread snapshot returned an invalid response; trying the full HTTP thread snapshot for an older server.",
133 ).pipe(
134 Effect.annotateLogs({ threadId, cause: error.message }),
135 Effect.andThen(loadFullFallback),
136 ),
137 RemoteEnvironmentAuthUndeclaredStatusError: (error) =>
138 error.status === 404
139 ? Effect.logDebug(
140 "Bounded thread snapshot route was not found; trying the full HTTP thread snapshot for an older server.",
141 ).pipe(Effect.annotateLogs({ threadId }), Effect.andThen(loadFullFallback))
142 : Effect.fail(error),
143 }),
144 Effect.catchCause((cause) =>
145 Effect.logWarning(
146 "Could not load the bounded thread snapshot over HTTP; using the socket snapshot instead.",
147 ).pipe(
148 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
149 Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
150 ),
151 ),
152 );
153 },
154 });
155 }),
156);
packages/client-runtime/src/state/orchestrationV2Projection.ts+57−1
@@ -5,6 +5,11 @@ import type { · 4 unmodified lines
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
7 7
8export type ApplyOrchestrationV2ProjectionEventOptions = {
9 readonly partialTimeline?: boolean;
10 readonly latestLocalTurnOrdinal?: number | null;
11};
12
8function upsertEntity<T extends { readonly id: unknown }>(13function upsertEntity<T extends { readonly id: unknown }>(
9 items: ReadonlyArray<T>,14 items: ReadonlyArray<T>,
10 item: T,15 item: T,
@@ -60,9 +65,43 @@ function activeVisibleTurnItems( · 49 unmodified lines
60 return next === null ? rows : renumberVisibleItems(next);65 return next === null ? rows : renumberVisibleItems(next);
61}66}
62 67
68function oldestLocalTurnOrdinal(
69 rows: OrchestrationV2ThreadProjection["visibleTurnItems"],
70): number | null {
71 let oldest: number | null = null;
72 for (const row of rows) {
73 if (row.visibility !== "local") continue;
74 if (oldest === null || row.item.ordinal < oldest) {
75 oldest = row.item.ordinal;
76 }
77 }
78 return oldest;
79}
80
81function shouldDropMissingPartialTurnItem(
82 projection: OrchestrationV2ThreadProjection,
83 item: OrchestrationV2TurnItem,
84 latestLocalTurnOrdinal: number | null | undefined,
85): boolean {
86 if (projection.visibleTurnItems.some((row) => row.sourceItemId === item.id)) {
87 return false;
88 }
89 if (
90 latestLocalTurnOrdinal !== null &&
91 latestLocalTurnOrdinal !== undefined &&
92 item.ordinal <= latestLocalTurnOrdinal
93 ) {
94 return true;
95 }
96 const oldest = oldestLocalTurnOrdinal(projection.visibleTurnItems);
97 return oldest !== null && item.ordinal < oldest;
98}
99
63function upsertVisibleTurnItem(100function upsertVisibleTurnItem(
64 projection: OrchestrationV2ThreadProjection,101 projection: OrchestrationV2ThreadProjection,
65 item: OrchestrationV2TurnItem,102 item: OrchestrationV2TurnItem,
103 partialTimeline: boolean,
104 latestLocalTurnOrdinal: number | null | undefined,
66): OrchestrationV2ThreadProjection["visibleTurnItems"] {105): OrchestrationV2ThreadProjection["visibleTurnItems"] {
67 const rows = projection.visibleTurnItems;106 const rows = projection.visibleTurnItems;
68 const index = rows.findIndex((row) => row.sourceItemId === item.id);107 const index = rows.findIndex((row) => row.sourceItemId === item.id);
@@ -83,6 +122,13 @@ function upsertVisibleTurnItem( · 14 unmodified lines
83 ) {122 ) {
84 return rows;123 return rows;
85 }124 }
125 if (
126 index === -1 &&
127 partialTimeline &&
128 shouldDropMissingPartialTurnItem(projection, item, latestLocalTurnOrdinal)
129 ) {
130 return rows;
131 }
86 const updated = index === -1 ? [...rows] : [...rows.slice(0, index), ...rows.slice(index + 1)];132 const updated = index === -1 ? [...rows] : [...rows.slice(0, index), ...rows.slice(index + 1)];
87 const insertionIndex = updated.findIndex(133 const insertionIndex = updated.findIndex(
88 (row) =>134 (row) =>
@@ -99,9 +145,12 @@ function upsertVisibleTurnItem( · 10 unmodified lines
99export function applyOrchestrationV2ProjectionEvent(145export function applyOrchestrationV2ProjectionEvent(
100 projection: OrchestrationV2ThreadProjection | null,146 projection: OrchestrationV2ThreadProjection | null,
101 event: OrchestrationV2DomainEvent,147 event: OrchestrationV2DomainEvent,
148 options?: ApplyOrchestrationV2ProjectionEventOptions,
102): OrchestrationV2ThreadProjection | null {149): OrchestrationV2ThreadProjection | null {
103 if (projection === null || event.threadId !== projection.thread.id) return projection;150 if (projection === null || event.threadId !== projection.thread.id) return projection;
104 151
152 const partialTimeline = options?.partialTimeline === true;
153 const latestLocalTurnOrdinal = options?.latestLocalTurnOrdinal;
105 const base = { ...projection, updatedAt: event.occurredAt };154 const base = { ...projection, updatedAt: event.occurredAt };
106 switch (event.type) {155 switch (event.type) {
107 case "thread.created":156 case "thread.created":
@@ -163,12 +212,19 @@ export function applyOrchestrationV2ProjectionEvent( · 55 unmodified lines
163 case "plan.updated":212 case "plan.updated":
164 return { ...base, plans: upsertEntity(base.plans, event.payload) };213 return { ...base, plans: upsertEntity(base.plans, event.payload) };
165 case "turn-item.updated": {214 case "turn-item.updated": {
215 if (
216 partialTimeline &&
217 !projection.turnItems.some((candidate) => candidate.id === event.payload.id) &&
218 shouldDropMissingPartialTurnItem(projection, event.payload, latestLocalTurnOrdinal)
219 ) {
220 return projection;
221 }
166 const next = { ...base, turnItems: upsertEntity(base.turnItems, event.payload) };222 const next = { ...base, turnItems: upsertEntity(base.turnItems, event.payload) };
167 const visible = { ...next, visibleTurnItems: activeVisibleTurnItems(next) };223 const visible = { ...next, visibleTurnItems: activeVisibleTurnItems(next) };
168 return {224 return {
169 ...next,225 ...next,
170 visibleTurnItems: shouldShowLocalTurnItem(next, event.payload)226 visibleTurnItems: shouldShowLocalTurnItem(next, event.payload)
171 ? upsertVisibleTurnItem(visible, event.payload)227 ? upsertVisibleTurnItem(visible, event.payload, partialTimeline, latestLocalTurnOrdinal)
172 : removeVisibleItem(visible.visibleTurnItems, event.payload.id),228 : removeVisibleItem(visible.visibleTurnItems, event.payload.id),
173 };229 };
174 }230 }
packages/client-runtime/src/state/shellReducer.test.ts+24−0
@@ -214,6 +214,30 @@ describe("applyShellStreamEvent", () => { · 213 unmodified lines
214 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);214 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
215 });215 });
216 216
217 it("treats higher-sequence compact enrichment as metadata-only", () => {
218 const previous = {
219 ...v2ShellSnapshot,
220 snapshotSequence: 5,
221 projects: [{ ...v2Project, repositoryIdentity: null }],
222 threads: [{ ...v2ThreadShell, title: "Newest title" }],
223 };
224 const next = mergeShellSnapshotProjects(
225 previous,
226 {
227 ...v2ShellSnapshot,
228 snapshotSequence: 8,
229 projects: [{ ...v2Project, repositoryIdentity }],
230 threads: [],
231 archivedThreads: [],
232 },
233 { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] },
234 );
235
236 expect(next.snapshotSequence).toBe(5);
237 expect(next.threads[0]?.title).toBe("Newest title");
238 expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity);
239 });
240
217 it("lower-sequence enrichment cannot resurrect empty structural state", () => {241 it("lower-sequence enrichment cannot resurrect empty structural state", () => {
218 const previous = {242 const previous = {
219 ...v2ShellSnapshot,243 ...v2ShellSnapshot,
packages/client-runtime/src/state/shellReducer.ts+6−6
@@ -29,8 +29,8 @@ function retainRepositoryIdentity( · 28 unmodified lines
29 29
30export interface MergeShellSnapshotOptions {30export interface MergeShellSnapshotOptions {
31 /**31 /**
32 * Enrichment refresh: structure and sequence must not roll back when at or32 * Metadata-only enrichment refresh: structure and sequence never change;
33 * below cache; listed roots accept identity exactly (including null).33 * listed roots accept identity exactly (including null).
34 * Omit this options object for authoritative HTTP/initial WebSocket snapshots.34 * Omit this options object for authoritative HTTP/initial WebSocket snapshots.
35 */35 */
36 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;36 readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>;
@@ -43,9 +43,9 @@ export interface MergeShellSnapshotOptions { · 6 unmodified lines
43 * lower than cache, while retaining a prior non-null identity when the candidate43 * lower than cache, while retaining a prior non-null identity when the candidate
44 * is still unresolved/null for the same root.44 * is still unresolved/null for the same root.
45 *45 *
46 * Enrichment snapshots (options present) never resurrect or roll back46 * Enrichment snapshots (options present) only patch repository identity for
47 * projects/threads/archives/sequence when at or below cache; they only patch47 * matching current projects. They never replace projects, threads, archives,
48 * repository identity for matching current projects.48 * or sequence, regardless of the incoming snapshot sequence.
49 */49 */
50export function mergeShellSnapshotProjects(50export function mergeShellSnapshotProjects(
51 previous: OrchestrationV2ShellSnapshot | null | undefined,51 previous: OrchestrationV2ShellSnapshot | null | undefined,
@@ -59,7 +59,7 @@ export function mergeShellSnapshotProjects( · 7 unmodified lines
59 const isEnrichment = options !== undefined;59 const isEnrichment = options !== undefined;
60 const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null;60 const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null;
61 61
62 if (isEnrichment && next.snapshotSequence <= previous.snapshotSequence) {62 if (isEnrichment) {
63 const nextById = new Map(next.projects.map((project) => [project.id, project] as const));63 const nextById = new Map(next.projects.map((project) => [project.id, project] as const));
64 return {64 return {
65 ...previous,65 ...previous,
packages/client-runtime/src/state/threadCommands.ts+32−0
@@ -1,4 +1,7 @@
1import type { ThreadId } from "@t3tools/contracts";
1import * as Crypto from "effect/Crypto";2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
2import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
3 6
4import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";7import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts";
@@ -61,6 +64,15 @@ import { · 56 unmodified lines
61 visitThread,64 visitThread,
62} from "../operations/commands.ts";65} from "../operations/commands.ts";
63import type { EnvironmentRegistry } from "../connection/registry.ts";66import type { EnvironmentRegistry } from "../connection/registry.ts";
67import { EnvironmentSupervisor } from "../connection/supervisor.ts";
68import {
69 ThreadHistoryController,
70 type ThreadHistoryLoadEarlierResult,
71} from "./threadHistoryController.ts";
72
73export type LoadEarlierThreadHistoryInput = {
74 readonly threadId: ThreadId;
75};
64 76
65export type {77export type {
66 ArchiveThreadInput,78 ArchiveThreadInput,
@@ -279,5 +291,25 @@ export function createThreadEnvironmentAtoms<R, E>( · 212 unmodified lines
279 scheduler,291 scheduler,
280 concurrency,292 concurrency,
281 }),293 }),
294 loadEarlierHistory: createEnvironmentCommand(runtime, {
295 label: "environment-data:commands:thread:load-earlier-history",
296 execute: (input: LoadEarlierThreadHistoryInput) =>
297 Effect.gen(function* () {
298 const supervisor = yield* EnvironmentSupervisor;
299 const controller = yield* Effect.serviceOption(ThreadHistoryController);
300 if (Option.isNone(controller)) {
301 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
302 }
303 return yield* controller.value.loadEarlier(
304 supervisor.target.environmentId,
305 input.threadId,
306 );
307 }),
308 scheduler,
309 concurrency: {
310 mode: "serial",
311 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]),
312 },
313 }),
282 };314 };
283}315}
packages/client-runtime/src/state/threadDetail.test.ts+45−20
@@ -4,6 +4,7 @@ import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; · 3 unmodified lines
4import { describe, expect, it } from "vite-plus/test";4import { describe, expect, it } from "vite-plus/test";
5 5
6import { v2Projection } from "./orchestrationV2TestFixtures.ts";6import { v2Projection } from "./orchestrationV2TestFixtures.ts";
7import { EMPTY_THREAD_HISTORY_META } from "./threadHistoryMerge.ts";
7import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";8import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
8import type { EnvironmentThreadState } from "./threads.ts";9import type { EnvironmentThreadState } from "./threads.ts";
9 10
@@ -12,13 +13,22 @@ const ref: ScopedThreadRef = { · 2 unmodified lines
12 threadId: ThreadId.make(v2Projection.thread.id),13 threadId: ThreadId.make(v2Projection.thread.id),
13};14};
14 15
16function threadState(
17 partial: Pick<EnvironmentThreadState, "data" | "status" | "error"> &
18 Partial<Pick<EnvironmentThreadState, "history">>,
19): EnvironmentThreadState {
20 return { ...partial, history: partial.history ?? EMPTY_THREAD_HISTORY_META };
21}
22
15describe("createEnvironmentThreadDetailAtoms", () => {23describe("createEnvironmentThreadDetailAtoms", () => {
16 it("adds environment scope while preserving the pristine projection", () => {24 it("adds environment scope while preserving the pristine projection", () => {
17 const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success({25 const initial: AsyncResult.AsyncResult<EnvironmentThreadState, never> = AsyncResult.success(
18 data: Option.some(v2Projection),26 threadState({
19 status: "cached",27 data: Option.some(v2Projection),
20 error: Option.none(),28 status: "cached",
21 });29 error: Option.none(),
30 }),
31 );
22 const sourceAtom = Atom.make(initial);32 const sourceAtom = Atom.make(initial);
23 const details = createEnvironmentThreadDetailAtoms(() => sourceAtom);33 const details = createEnvironmentThreadDetailAtoms(() => sourceAtom);
24 const registry = AtomRegistry.make();34 const registry = AtomRegistry.make();
@@ -28,46 +38,61 @@ describe("createEnvironmentThreadDetailAtoms", () => { · 3 unmodified lines
28 expect(thread?.projection).toBe(v2Projection);38 expect(thread?.projection).toBe(v2Projection);
29 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);39 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
30 expect(registry.get(details.statusAtom(ref))).toBe("cached");40 expect(registry.get(details.statusAtom(ref))).toBe("cached");
41 expect(registry.get(details.historyAtom(ref))).toBe(EMPTY_THREAD_HISTORY_META);
31 42
32 registry.set(43 registry.set(
33 sourceAtom,44 sourceAtom,
34 AsyncResult.success({45 AsyncResult.success(
35 data: Option.some(v2Projection),46 threadState({
36 status: "synchronizing",47 data: Option.some(v2Projection),
37 error: Option.none(),48 status: "synchronizing",
38 }),49 error: Option.none(),
50 }),
51 ),
39 );52 );
40 53
41 expect(registry.get(details.threadAtom(ref))).toBe(thread);54 expect(registry.get(details.threadAtom(ref))).toBe(thread);
42 expect(registry.get(details.statusAtom(ref))).toBe("synchronizing");55 expect(registry.get(details.statusAtom(ref))).toBe("synchronizing");
43 56
57 const progressiveHistory = {
58 ...EMPTY_THREAD_HISTORY_META,
59 historyCursor: "cursor-1",
60 hasMoreHistory: true,
61 };
44 registry.set(62 registry.set(
45 sourceAtom,63 sourceAtom,
46 AsyncResult.success({64 AsyncResult.success(
47 data: Option.some(v2Projection),65 threadState({
48 status: "live",66 data: Option.some(v2Projection),
49 error: Option.some("Stream interrupted."),67 status: "live",
50 }),68 error: Option.some("Stream interrupted."),
69 history: progressiveHistory,
70 }),
71 ),
51 );72 );
52 73
53 expect(registry.get(details.threadAtom(ref))).toBe(thread);74 expect(registry.get(details.threadAtom(ref))).toBe(thread);
54 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);75 expect(registry.get(details.visibleTurnItemsAtom(ref))).toBe(v2Projection.visibleTurnItems);
55 expect(registry.get(details.statusAtom(ref))).toBe("live");76 expect(registry.get(details.statusAtom(ref))).toBe("live");
56 expect(registry.get(details.errorAtom(ref))).toBe("Stream interrupted.");77 expect(registry.get(details.errorAtom(ref))).toBe("Stream interrupted.");
78 expect(registry.get(details.historyAtom(ref))).toBe(progressiveHistory);
57 79
58 registry.set(80 registry.set(
59 sourceAtom,81 sourceAtom,
60 AsyncResult.success({82 AsyncResult.success(
61 data: Option.none(),83 threadState({
62 status: "deleted",84 data: Option.none(),
63 error: Option.none(),85 status: "deleted",
64 }),86 error: Option.none(),
87 }),
88 ),
65 );89 );
66 90
67 expect(registry.get(details.threadAtom(ref))).toBeNull();91 expect(registry.get(details.threadAtom(ref))).toBeNull();
68 expect(registry.get(details.visibleTurnItemsAtom(ref))).toEqual([]);92 expect(registry.get(details.visibleTurnItemsAtom(ref))).toEqual([]);
69 expect(registry.get(details.statusAtom(ref))).toBe("deleted");93 expect(registry.get(details.statusAtom(ref))).toBe("deleted");
70 expect(registry.get(details.errorAtom(ref))).toBeNull();94 expect(registry.get(details.errorAtom(ref))).toBeNull();
95 expect(registry.get(details.historyAtom(ref))).toBe(EMPTY_THREAD_HISTORY_META);
71 registry.dispose();96 registry.dispose();
72 });97 });
73});98});
packages/client-runtime/src/state/threadDetail.ts+7−0
@@ -62,6 +62,12 @@ export function createEnvironmentThreadDetailAtoms<E>( · 61 unmodified lines
62 Atom.withLabel(`environment-thread-error:${key}`),62 Atom.withLabel(`environment-thread-error:${key}`),
63 ),63 ),
64 );64 );
65 const historyAtomFamily = Atom.family((key: string) =>
66 Atom.make((get) => get(threadStateValueAtomFamily(key)).history).pipe(
67 Atom.setIdleTTL(THREAD_STATE_IDLE_TTL_MS),
68 Atom.withLabel(`environment-thread-history:${key}`),
69 ),
70 );
65 71
66 return {72 return {
67 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),73 stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)),
@@ -69,5 +75,6 @@ export function createEnvironmentThreadDetailAtoms<E>( · 1 unmodified line
69 visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),75 visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)),
70 statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)),76 statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)),
71 errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)),77 errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)),
78 historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)),
72 };79 };
73}80}
packages/client-runtime/src/state/threadHistoryController.test.ts+66−0
@@ -0,0 +1,66 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import {
7 ThreadHistoryController,
8 threadHistoryControllerLayer,
9 type ThreadHistoryHandler,
10 type ThreadHistoryLoadEarlierResult,
11} from "./threadHistoryController.ts";
12
13const ENV = "env-history" as EnvironmentId;
14const THREAD = "thread-history" as ThreadId;
15
16function handler(
17 tag: string,
18): ThreadHistoryHandler & { readonly tag: string; readonly calls: number } {
19 const state = { tag, calls: 0 };
20 return {
21 get tag() {
22 return state.tag;
23 },
24 get calls() {
25 return state.calls;
26 },
27 loadEarlier: () => {
28 state.calls += 1;
29 return Effect.succeed({ _tag: "loaded" } satisfies ThreadHistoryLoadEarlierResult);
30 },
31 };
32}
33
34describe("ThreadHistoryController", () => {
35 it.effect("does not let an older finalizer delete a newer registration", () =>
36 Effect.gen(function* () {
37 const controller = yield* ThreadHistoryController;
38 const older = handler("older");
39 const newer = handler("newer");
40
41 const olderRegistration = yield* controller.register(ENV, THREAD, older);
42 const newerRegistration = yield* controller.register(ENV, THREAD, newer);
43
44 // Older fiber finalizes after the newer handler is already registered.
45 yield* controller.unregister(olderRegistration);
46
47 const result = yield* controller.loadEarlier(ENV, THREAD);
48 expect(result).toEqual({ _tag: "loaded" });
49 expect(newer.calls).toBe(1);
50 expect(older.calls).toBe(0);
51
52 yield* controller.unregister(newerRegistration);
53 expect(yield* controller.loadEarlier(ENV, THREAD)).toEqual({ _tag: "noop" });
54 }).pipe(Effect.provide(threadHistoryControllerLayer)),
55 );
56
57 it.effect("unregister removes only its own matching registration", () =>
58 Effect.gen(function* () {
59 const controller = yield* ThreadHistoryController;
60 const first = handler("first");
61 const registration = yield* controller.register(ENV, THREAD, first);
62 yield* controller.unregister(registration);
63 expect(yield* controller.loadEarlier(ENV, THREAD)).toEqual({ _tag: "noop" });
64 }).pipe(Effect.provide(Layer.fresh(threadHistoryControllerLayer))),
65 );
66});
packages/client-runtime/src/state/threadHistoryController.ts+93−0
@@ -0,0 +1,93 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6
7import { threadKey } from "./entities.ts";
8
9export type ThreadHistoryLoadEarlierResult =
10 | { readonly _tag: "loaded" }
11 | { readonly _tag: "noop" }
12 | { readonly _tag: "busy" }
13 | { readonly _tag: "error"; readonly message: string };
14
15export type ThreadHistoryHandler = {
16 readonly loadEarlier: () => Effect.Effect<ThreadHistoryLoadEarlierResult>;
17};
18
19/**
20 * Opaque registration token returned by `register`. Finalizers must pass the
21 * same token so an older fiber cannot delete a newer handler for the key.
22 */
23export type ThreadHistoryRegistration = {
24 readonly environmentId: EnvironmentId;
25 readonly threadId: ThreadId;
26 readonly handler: ThreadHistoryHandler;
27};
28
29/**
30 * Per-thread registration for progressive history loads. Thread state fibers
31 * register while active; mobile UI dispatches through `loadEarlier`.
32 */
33export class ThreadHistoryController extends Context.Service<
34 ThreadHistoryController,
35 {
36 readonly register: (
37 environmentId: EnvironmentId,
38 threadId: ThreadId,
39 handler: ThreadHistoryHandler,
40 ) => Effect.Effect<ThreadHistoryRegistration>;
41 readonly unregister: (registration: ThreadHistoryRegistration) => Effect.Effect<void>;
42 readonly loadEarlier: (
43 environmentId: EnvironmentId,
44 threadId: ThreadId,
45 ) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
46 }
47>()("@t3tools/client-runtime/state/threadHistoryController") {}
48
49export const threadHistoryControllerLayer: Layer.Layer<ThreadHistoryController> = Layer.effect(
50 ThreadHistoryController,
51 Effect.gen(function* () {
52 const handlers = yield* Ref.make(new Map<string, ThreadHistoryHandler>());
53 return ThreadHistoryController.of({
54 register: (environmentId, threadId, handler) =>
55 Ref.update(handlers, (current) => {
56 const next = new Map(current);
57 next.set(threadKey({ environmentId, threadId }), handler);
58 return next;
59 }).pipe(
60 Effect.as({
61 environmentId,
62 threadId,
63 handler,
64 } satisfies ThreadHistoryRegistration),
65 ),
66 unregister: (registration) =>
67 Ref.update(handlers, (current) => {
68 const key = threadKey({
69 environmentId: registration.environmentId,
70 threadId: registration.threadId,
71 });
72 // Only remove when this registration still owns the slot. An older
73 // fiber finalizer must not delete a newer handler for the same key.
74 if (current.get(key) !== registration.handler) {
75 return current;
76 }
77 const next = new Map(current);
78 next.delete(key);
79 return next;
80 }),
81 loadEarlier: (environmentId, threadId) =>
82 Ref.get(handlers).pipe(
83 Effect.flatMap((current) => {
84 const handler = current.get(threadKey({ environmentId, threadId }));
85 if (handler === undefined) {
86 return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult);
87 }
88 return handler.loadEarlier();
89 }),
90 ),
91 });
92 }),
93);
packages/client-runtime/src/state/threadHistoryHttp.ts+45−0
@@ -0,0 +1,45 @@
1import type { ThreadId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4
5import type { PreparedConnection } from "../connection/model.ts";
6import { environmentEndpointUrl } from "../environment/endpoint.ts";
7import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
8import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
9import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
10
11const DEFAULT_THREAD_HISTORY_TIMEOUT_MS = 6_000;
12
13export const fetchEnvironmentThreadHistoryPage = Effect.fn(
14 "clientRuntime.state.fetchEnvironmentThreadHistoryPage",
15)(function* (input: {
16 readonly prepared: PreparedConnection;
17 readonly threadId: ThreadId;
18 readonly cursor: string;
19 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
20 readonly timeoutMs?: number;
21}) {
22 const requestUrl = environmentEndpointUrl(
23 input.prepared.httpBaseUrl,
24 `/api/orchestration/threads/${input.threadId}/history`,
25 );
26 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
27 const headers = yield* buildEnvironmentAuthHeaders(
28 input.prepared.httpAuthorization,
29 "GET",
30 requestUrl,
31 input.signer,
32 );
33 return yield* executeEnvironmentHttpRequest(
34 requestUrl,
35 input.timeoutMs ?? DEFAULT_THREAD_HISTORY_TIMEOUT_MS,
36 withEnvironmentCredentials(
37 input.prepared.httpAuthorization,
38 client.orchestration.threadHistoryPage({
39 params: { threadId: input.threadId },
40 query: { cursor: input.cursor },
41 headers,
42 }),
43 ),
44 );
45});
packages/client-runtime/src/state/threadHistoryMerge.test.ts+416−0
@@ -0,0 +1,416 @@
1import type { OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";
4
5import { v2Projection, v2ThreadId } from "./orchestrationV2TestFixtures.ts";
6import {
7 applyHistoryPageMeta,
8 clearActiveHistoryLoading,
9 isActiveHistoryRequestCursor,
10 mergeOlderHistoryIntoProjection,
11 shouldShowLoadEarlierControl,
12} from "./threadHistoryMerge.ts";
13import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
14
15const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
16
17function row(index: number): OrchestrationV2ProjectedTurnItem {
18 const id = `item-${index}` as never;
19 return {
20 position: index,
21 visibility: "local",
22 sourceThreadId: v2ThreadId,
23 sourceItemId: id,
24 item: {
25 id,
26 type: "command_execution",
27 threadId: v2ThreadId,
28 runId: null,
29 nodeId: null,
30 providerThreadId: null,
31 providerTurnId: null,
32 nativeItemRef: null,
33 parentItemId: null,
34 ordinal: index + 1,
35 status: "completed",
36 title: `Command ${index}`,
37 input: `cmd-${index}`,
38 output: `out-${index}`,
39 exitCode: 0,
40 startedAt: NOW,
41 completedAt: NOW,
42 updatedAt: NOW,
43 },
44 };
45}
46
47describe("threadHistoryMerge", () => {
48 it("prepends older rows and dedupes by source identity", () => {
49 const recent = [row(2), row(3)];
50 const projection = {
51 ...v2Projection,
52 turnItems: recent.map((entry) => entry.item),
53 visibleTurnItems: recent.map((entry, position) => ({ ...entry, position })),
54 };
55 const older = [row(0), row(1), row(2)];
56 const merged = mergeOlderHistoryIntoProjection(projection, older);
57 expect(merged.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
58 "item-0",
59 "item-1",
60 "item-2",
61 "item-3",
62 ]);
63 expect(merged.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3]);
64 expect(merged.turnItems.map((item) => item.id)).toEqual([
65 "item-2",
66 "item-3",
67 "item-0",
68 "item-1",
69 ]);
70 });
71
72 it("retains live rows that arrived after the older page was fetched", () => {
73 const live = [row(5), row(6)];
74 const projection = {
75 ...v2Projection,
76 turnItems: live.map((entry) => entry.item),
77 visibleTurnItems: live.map((entry, position) => ({ ...entry, position })),
78 };
79 const older = [row(3), row(4)];
80 const merged = mergeOlderHistoryIntoProjection(projection, older);
81 expect(merged.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
82 "item-3",
83 "item-4",
84 "item-5",
85 "item-6",
86 ]);
87 });
88
89 it("does not resurrect a local item hidden while an older page was in flight", () => {
90 const stalePageRow = row(1);
91 const currentItem = {
92 ...stalePageRow.item,
93 output: "newer live output",
94 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"),
95 };
96 const projection = {
97 ...v2Projection,
98 turnItems: [currentItem],
99 visibleTurnItems: [],
100 };
101
102 const merged = mergeOlderHistoryIntoProjection(projection, [stalePageRow]);
103
104 expect(merged).toBe(projection);
105 expect(merged.turnItems).toEqual([currentItem]);
106 expect(merged.visibleTurnItems).toEqual([]);
107 });
108
109 it("marks history expanded after a successful page", () => {
110 const next = applyHistoryPageMeta(
111 {
112 historyCursor: "cursor-a",
113 hasMoreHistory: true,
114 loading: true,
115 error: null,
116 expanded: false,
117 latestLocalTurnOrdinal: 42,
118 },
119 { nextCursor: "cursor-b", hasMoreHistory: true },
120 );
121 expect(next).toEqual({
122 historyCursor: "cursor-b",
123 hasMoreHistory: true,
124 loading: false,
125 error: null,
126 expanded: true,
127 latestLocalTurnOrdinal: 42,
128 });
129 });
130
131 it("only applies history responses for the active request cursor", () => {
132 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: "cursor-a" })).toBe(true);
133 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: "cursor-b" })).toBe(false);
134 expect(isActiveHistoryRequestCursor("cursor-a", { historyCursor: null })).toBe(false);
135 });
136
137 it("clears loading on interrupt only for the active request cursor", () => {
138 const loading = {
139 historyCursor: "cursor-a",
140 hasMoreHistory: true,
141 loading: true,
142 error: null,
143 expanded: false,
144 latestLocalTurnOrdinal: 10,
145 };
146 expect(clearActiveHistoryLoading("cursor-a", loading)).toEqual({
147 ...loading,
148 loading: false,
149 });
150 // Stale cursor must not mutate replacement progressive meta.
151 expect(
152 clearActiveHistoryLoading("cursor-a", { ...loading, historyCursor: "cursor-b" }),
153 ).toEqual({ ...loading, historyCursor: "cursor-b" });
154 // Already idle: identity-preserving no-op shape.
155 const idle = { ...loading, loading: false };
156 expect(clearActiveHistoryLoading("cursor-a", idle)).toBe(idle);
157 });
158
159 it("shows the load-earlier control when history remains or a local error is set", () => {
160 expect(
161 shouldShowLoadEarlierControl({
162 historyCursor: "c",
163 hasMoreHistory: true,
164 loading: false,
165 error: null,
166 expanded: false,
167 latestLocalTurnOrdinal: null,
168 }),
169 ).toBe(true);
170 expect(
171 shouldShowLoadEarlierControl({
172 historyCursor: null,
173 hasMoreHistory: false,
174 loading: false,
175 error: "Could not load earlier activity.",
176 expanded: true,
177 latestLocalTurnOrdinal: null,
178 }),
179 ).toBe(true);
180 expect(
181 shouldShowLoadEarlierControl({
182 historyCursor: null,
183 hasMoreHistory: false,
184 loading: false,
185 error: null,
186 expanded: false,
187 latestLocalTurnOrdinal: null,
188 }),
189 ).toBe(false);
190 });
191
192 it("retains merged older rows when a live turn-item update arrives", () => {
193 const recent = [row(2), row(3)];
194 const projection = {
195 ...v2Projection,
196 turnItems: recent.map((entry) => entry.item),
197 visibleTurnItems: recent.map((entry, position) => ({ ...entry, position })),
198 };
199 const merged = mergeOlderHistoryIntoProjection(projection, [row(0), row(1)]);
200 const liveItem = {
201 ...row(3).item,
202 output: "streamed",
203 };
204 const next = applyOrchestrationV2ProjectionEvent(merged, {
205 id: "event-live-item" as never,
206 type: "turn-item.updated",
207 threadId: v2ThreadId,
208 occurredAt: NOW,
209 payload: liveItem,
210 } as never);
211 expect(next?.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
212 "item-0",
213 "item-1",
214 "item-2",
215 "item-3",
216 ]);
217 expect(next?.visibleTurnItems[3]?.item).toEqual(liveItem);
218 });
219
220 it("keeps a history-page interrupt result visible when its request was retained at cold open", () => {
221 // Cold bounded window: only a recent filler. Request was retained in
222 // turnItems (not visible). Result arrives later via load-earlier merge.
223 const runId = "run-interrupt-cold" as never;
224 const nodeId = "node-interrupt-cold" as never;
225 const recent = row(5);
226 const requestItem = {
227 id: "item-interrupt-request" as never,
228 type: "run_interrupt_request" as const,
229 threadId: v2ThreadId,
230 runId,
231 nodeId,
232 providerThreadId: null,
233 providerTurnId: null,
234 nativeItemRef: null,
235 parentItemId: null,
236 ordinal: 1,
237 status: "completed" as const,
238 title: null,
239 message: "Stopping",
240 startedAt: NOW,
241 completedAt: NOW,
242 updatedAt: NOW,
243 };
244 const resultItem = {
245 id: "item-interrupt-result" as never,
246 type: "run_interrupt_result" as const,
247 threadId: v2ThreadId,
248 runId,
249 nodeId,
250 providerThreadId: null,
251 providerTurnId: null,
252 nativeItemRef: null,
253 parentItemId: null,
254 ordinal: 2,
255 status: "completed" as const,
256 title: null,
257 message: "Stopped",
258 startedAt: NOW,
259 completedAt: NOW,
260 updatedAt: NOW,
261 };
262 const resultRow = {
263 position: 0,
264 visibility: "local" as const,
265 sourceThreadId: v2ThreadId,
266 sourceItemId: resultItem.id,
267 item: resultItem,
268 };
269 const coldOpen = {
270 ...v2Projection,
271 runs: [
272 {
273 id: runId,
274 threadId: v2ThreadId,
275 ordinal: 1,
276 providerInstanceId: "codex" as never,
277 modelSelection: { instanceId: "codex" as never, model: "gpt-5" },
278 providerThreadId: null,
279 userMessageId: "message-1" as never,
280 rootNodeId: nodeId,
281 activeAttemptId: null,
282 status: "running" as const,
283 requestedAt: NOW,
284 startedAt: NOW,
285 completedAt: null,
286 checkpointId: null,
287 contextHandoffId: null,
288 },
289 ],
290 attempts: [
291 {
292 id: "attempt-1" as never,
293 runId,
294 attemptOrdinal: 1,
295 rootNodeId: nodeId,
296 providerInstanceId: "codex" as never,
297 providerThreadId: "provider-thread-1" as never,
298 providerTurnId: null,
299 reason: "initial" as const,
300 status: "running" as const,
301 startedAt: NOW,
302 completedAt: null,
303 },
304 ],
305 // Request retained from full projection; no result yet in the window.
306 turnItems: [requestItem, recent.item],
307 visibleTurnItems: [{ ...recent, position: 0 }],
308 };
309
310 const requestMerged = mergeOlderHistoryIntoProjection(coldOpen, [
311 {
312 position: 0,
313 visibility: "local",
314 sourceThreadId: v2ThreadId,
315 sourceItemId: requestItem.id,
316 item: requestItem,
317 },
318 ]);
319 expect(requestMerged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
320 "item-interrupt-request",
321 "item-5",
322 ]);
323
324 const rolledBackRequestMerged = mergeOlderHistoryIntoProjection(
325 {
326 ...coldOpen,
327 runs: coldOpen.runs.map((run) => ({
328 ...run,
329 status: "rolled_back" as const,
330 completedAt: NOW,
331 })),
332 },
333 [
334 {
335 position: 0,
336 visibility: "local",
337 sourceThreadId: v2ThreadId,
338 sourceItemId: requestItem.id,
339 item: requestItem,
340 },
341 ],
342 );
343 expect(
344 rolledBackRequestMerged.visibleTurnItems.map((entry) => String(entry.sourceItemId)),
345 ).toEqual(["item-5"]);
346
347 const merged = mergeOlderHistoryIntoProjection(coldOpen, [resultRow]);
348 expect(merged.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
349 "item-interrupt-result",
350 "item-5",
351 ]);
352 expect(merged.turnItems.some((item) => item.type === "run_interrupt_request")).toBe(true);
353 expect(merged.turnItems.some((item) => item.type === "run_interrupt_result")).toBe(true);
354
355 const attemptSuperseded = applyOrchestrationV2ProjectionEvent(
356 merged,
357 {
358 id: "event-attempt-superseded" as never,
359 type: "run-attempt.updated",
360 threadId: v2ThreadId,
361 runId,
362 occurredAt: NOW,
363 payload: {
364 id: "attempt-1" as never,
365 runId,
366 attemptOrdinal: 1,
367 rootNodeId: nodeId,
368 providerInstanceId: "codex" as never,
369 providerThreadId: "provider-thread-1" as never,
370 providerTurnId: null,
371 reason: "initial",
372 status: "superseded",
373 startedAt: NOW,
374 completedAt: NOW,
375 },
376 } as never,
377 { partialTimeline: true },
378 );
379
380 expect(attemptSuperseded?.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual([
381 "item-interrupt-result",
382 "item-5",
383 ]);
384
385 // Without the cold-open retained request, the same update would hide the result.
386 const withoutRequest = {
387 ...merged,
388 turnItems: merged.turnItems.filter((item) => item.type !== "run_interrupt_request"),
389 };
390 const hidden = applyOrchestrationV2ProjectionEvent(
391 withoutRequest,
392 {
393 id: "event-attempt-superseded-missing" as never,
394 type: "run-attempt.updated",
395 threadId: v2ThreadId,
396 runId,
397 occurredAt: NOW,
398 payload: {
399 id: "attempt-1" as never,
400 runId,
401 attemptOrdinal: 1,
402 rootNodeId: nodeId,
403 providerInstanceId: "codex" as never,
404 providerThreadId: "provider-thread-1" as never,
405 providerTurnId: null,
406 reason: "initial",
407 status: "superseded",
408 startedAt: NOW,
409 completedAt: NOW,
410 },
411 } as never,
412 { partialTimeline: true },
413 );
414 expect(hidden?.visibleTurnItems.map((entry) => String(entry.sourceItemId))).toEqual(["item-5"]);
415 });
416});
packages/client-runtime/src/state/threadHistoryMerge.ts+156−0
@@ -0,0 +1,156 @@
1import type {
2 OrchestrationV2ProjectedTurnItem,
3 OrchestrationV2ThreadProjection,
4 OrchestrationV2TurnItem,
5} from "@t3tools/contracts";
6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
7
8export type ThreadHistoryMeta = {
9 readonly historyCursor: string | null;
10 readonly hasMoreHistory: boolean;
11 readonly loading: boolean;
12 readonly error: string | null;
13 /** True after at least one older page was merged; skip monolithic cache growth. */
14 readonly expanded: boolean;
15 /**
16 * Max local turn ordinal known for partial progressive windows. Null when
17 * unknown (legacy cache) or when history meta is cleared for a full snapshot.
18 */
19 readonly latestLocalTurnOrdinal: number | null;
20};
21
22export const EMPTY_THREAD_HISTORY_META: ThreadHistoryMeta = {
23 historyCursor: null,
24 hasMoreHistory: false,
25 loading: false,
26 error: null,
27 expanded: false,
28 latestLocalTurnOrdinal: null,
29};
30
31/** Whether a history-page response still matches the cursor that started it. */
32export function isActiveHistoryRequestCursor(
33 requestCursor: string,
34 history: { readonly historyCursor: string | null },
35): boolean {
36 return history.historyCursor === requestCursor;
37}
38
39/**
40 * Clear history loading after an interrupted load-earlier only when the
41 * request cursor is still active. Does not touch stream/status/error.
42 */
43export function clearActiveHistoryLoading(
44 requestCursor: string,
45 history: ThreadHistoryMeta,
46): ThreadHistoryMeta {
47 if (!isActiveHistoryRequestCursor(requestCursor, history) || !history.loading) {
48 return history;
49 }
50 return { ...history, loading: false };
51}
52
53function projectedItemKey(row: OrchestrationV2ProjectedTurnItem): string {
54 return `${row.sourceThreadId}:${row.sourceItemId}`;
55}
56
57function renumberVisible(
58 rows: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
59): OrchestrationV2ProjectedTurnItem[] {
60 return rows.map((row, position) => (row.position === position ? row : { ...row, position }));
61}
62
63/**
64 * Merge an older history page into the live projection. Dedupes by
65 * sourceThreadId + sourceItemId, keeps chronological order (older first), and
66 * preserves newer live rows already present in state.
67 */
68export function mergeOlderHistoryIntoProjection(
69 projection: OrchestrationV2ThreadProjection,
70 olderItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
71): OrchestrationV2ThreadProjection {
72 if (olderItems.length === 0) {
73 return projection;
74 }
75
76 const existingKeys = new Set(projection.visibleTurnItems.map(projectedItemKey));
77 const turnItemById = new Map<string, OrchestrationV2TurnItem>();
78 for (const item of projection.turnItems) {
79 turnItemById.set(String(item.id), item);
80 }
81 const prepended: OrchestrationV2ProjectedTurnItem[] = [];
82 for (const pageRow of olderItems) {
83 let row = pageRow;
84 const key = projectedItemKey(row);
85 if (existingKeys.has(key)) {
86 continue;
87 }
88 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {
89 const currentItem = turnItemById.get(String(row.sourceItemId));
90 if (currentItem !== undefined) {
91 // A live event may have hidden this item while the page was in flight.
92 // Do not resurrect the stale page copy. Retained interrupt requests are
93 // the exception: they intentionally exist outside the bounded visible
94 // window and should become visible when their history page arrives.
95 if (currentItem.type !== "run_interrupt_request") {
96 continue;
97 }
98 if (
99 !isOrchestrationV2TurnItemVisible({
100 item: currentItem,
101 runs: projection.runs,
102 attempts: projection.attempts,
103 items: projection.turnItems,
104 })
105 ) {
106 continue;
107 }
108 row = { ...row, item: currentItem };
109 }
110 }
111 existingKeys.add(key);
112 prepended.push(row);
113 }
114 if (prepended.length === 0) {
115 return projection;
116 }
117
118 const visibleTurnItems = renumberVisible([...prepended, ...projection.visibleTurnItems]);
119
120 for (const row of prepended) {
121 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {
122 if (!turnItemById.has(String(row.sourceItemId))) {
123 turnItemById.set(String(row.sourceItemId), row.item);
124 }
125 }
126 }
127
128 return {
129 ...projection,
130 turnItems: [...turnItemById.values()],
131 visibleTurnItems,
132 };
133}
134
135export function applyHistoryPageMeta(
136 current: ThreadHistoryMeta,
137 page: {
138 readonly nextCursor: string | null;
139 readonly hasMoreHistory: boolean;
140 },
141): ThreadHistoryMeta {
142 return {
143 historyCursor: page.nextCursor,
144 hasMoreHistory: page.hasMoreHistory,
145 loading: false,
146 error: null,
147 expanded: true,
148 // Preserve the partial-timeline watermark across load-earlier pages.
149 latestLocalTurnOrdinal: current.latestLocalTurnOrdinal,
150 };
151}
152
153/** Whether a client timeline should render its load-earlier control. */
154export function shouldShowLoadEarlierControl(history: ThreadHistoryMeta): boolean {
155 return history.hasMoreHistory || history.error !== null;
156}
packages/client-runtime/src/state/threadSnapshotHttp.ts+42−13
@@ -18,6 +18,30 @@ import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAut · 17 unmodified lines
18// delays the transition to live data on the first open, not the initial paint.18// delays the transition to live data on the first open, not the initial paint.
19const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;19const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
20 20
21/** Progressive history metadata returned by a bounded snapshot loader. */
22export type ThreadSnapshotHistoryMeta = {
23 readonly historyCursor: string | null;
24 readonly hasMoreHistory: boolean;
25 /** Max local turn ordinal from the full projection; optional on older servers. */
26 readonly latestLocalTurnOrdinal?: number | null;
27};
28
29/**
30 * Outcome of an HTTP thread-detail snapshot load.
31 *
32 * - `present`: snapshot body is available (seed projection, resume via socket).
33 * - `missing`: server definitively reported the thread does not exist (404).
34 * - `unavailable`: transport/timeout/5xx/etc.; fall back to the socket path.
35 */
36export type ThreadSnapshotLoadResult =
37 | {
38 readonly _tag: "present";
39 readonly snapshot: OrchestrationV2ThreadDetailSnapshot;
40 readonly history?: ThreadSnapshotHistoryMeta;
41 }
42 | { readonly _tag: "missing" }
43 | { readonly _tag: "unavailable" };
44
21/**45/**
22 * Load a thread's detail snapshot over HTTP instead of embedding it in the46 * Load a thread's detail snapshot over HTTP instead of embedding it in the
23 * WebSocket subscription's first frame. The response is gzip-compressible by47 * WebSocket subscription's first frame. The response is gzip-compressible by
@@ -49,10 +73,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( · 25 unmodified lines
49export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;73export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;
50 74
51/**75/**
52 * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it76 * Loads a thread's detail snapshot over HTTP.
53 * cannot be loaded (so the caller falls back to the socket-embedded snapshot).77 *
54 * Decouples the thread state machine from the underlying HTTP + DPoP details and78 * Distinguishes a definitive missing thread (HTTP 404) from transient snapshot
55 * keeps them out of test contexts.79 * unavailability so the thread state machine can mark the thread deleted without
80 * opening a socket subscription, while still falling back to the socket when the
81 * HTTP path is merely unavailable.
56 */82 */
57export class ThreadSnapshotLoader extends Context.Service<83export class ThreadSnapshotLoader extends Context.Service<
58 ThreadSnapshotLoader,84 ThreadSnapshotLoader,
@@ -60,7 +86,7 @@ export class ThreadSnapshotLoader extends Context.Service< · 1 unmodified line
60 readonly load: (86 readonly load: (
61 prepared: PreparedConnection,87 prepared: PreparedConnection,
62 threadId: ThreadId,88 threadId: ThreadId,
63 ) => Effect.Effect<Option.Option<OrchestrationV2ThreadDetailSnapshot>>;89 ) => Effect.Effect<ThreadSnapshotLoadResult>;
64 }90 }
65>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}91>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
66 92
@@ -80,19 +106,22 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 13 unmodified lines
80 return ThreadSnapshotLoader.of({106 return ThreadSnapshotLoader.of({
81 load: (prepared: PreparedConnection, threadId: ThreadId) =>107 load: (prepared: PreparedConnection, threadId: ThreadId) =>
82 fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(108 fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(
83 Effect.map(Option.some<OrchestrationV2ThreadDetailSnapshot>),109 Effect.map(
110 (snapshot): ThreadSnapshotLoadResult => ({
111 _tag: "present",
112 snapshot,
113 }),
114 ),
84 Effect.provideService(HttpClient.HttpClient, httpClient),115 Effect.provideService(HttpClient.HttpClient, httpClient),
85 // A genuinely missing thread (404) is expected — the socket116 // A genuinely missing thread (404) is definitive: do not fall back to
86 // subscription is the source of truth for thread existence and will117 // the socket or retry. Callers mark the thread deleted and clear cache.
87 // surface the deletion — so don't treat it as an error worth warning
88 // about; just defer to the socket path.
89 Effect.catchTags({118 Effect.catchTags({
90 EnvironmentResourceNotFoundError: () =>119 EnvironmentResourceNotFoundError: () =>
91 Effect.logDebug(120 Effect.logDebug(
92 "Thread snapshot not found over HTTP; deferring to the socket subscription.",121 "Thread snapshot not found over HTTP; treating the thread as deleted.",
93 ).pipe(122 ).pipe(
94 Effect.annotateLogs({ threadId }),123 Effect.annotateLogs({ threadId }),
95 Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()),124 Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult),
96 ),125 ),
97 }),126 }),
98 Effect.catchCause((cause) =>127 Effect.catchCause((cause) =>
@@ -100,7 +129,7 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 1 unmodified line
100 "Could not load the thread snapshot over HTTP; using the socket snapshot instead.",129 "Could not load the thread snapshot over HTTP; using the socket snapshot instead.",
101 ).pipe(130 ).pipe(
102 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),131 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
103 Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()),132 Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
104 ),133 ),
105 ),134 ),
106 ),135 ),
packages/client-runtime/src/state/threadState.ts+9−0
@@ -1,16 +1,25 @@
1import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";1import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts";
2import * as Option from "effect/Option";2import * as Option from "effect/Option";
3 3
4import { EMPTY_THREAD_HISTORY_META, type ThreadHistoryMeta } from "./threadHistoryMerge.ts";
5
4export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";6export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted";
5 7
6export interface EnvironmentThreadState {8export interface EnvironmentThreadState {
7 readonly data: Option.Option<OrchestrationV2ThreadProjection>;9 readonly data: Option.Option<OrchestrationV2ThreadProjection>;
8 readonly status: EnvironmentThreadStatus;10 readonly status: EnvironmentThreadStatus;
9 readonly error: Option.Option<string>;11 readonly error: Option.Option<string>;
12 /**
13 * Progressive history cursor for bounded hydration. Absent/cleared on full
14 * snapshot paths. Errors here are thread/history-local and must not be
15 * promoted into the environment disconnect path.
16 */
17 readonly history: ThreadHistoryMeta;
10}18}
11 19
12export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {20export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = {
13 data: Option.none(),21 data: Option.none(),
14 status: "empty",22 status: "empty",
15 error: Option.none(),23 error: Option.none(),
24 history: EMPTY_THREAD_HISTORY_META,
16};25};
packages/client-runtime/src/state/threads-sync.test.ts+601−4
@@ -3,9 +3,11 @@ import { · 2 unmodified lines
3 EventId,3 EventId,
4 ORCHESTRATION_V2_WS_METHODS,4 ORCHESTRATION_V2_WS_METHODS,
5 ThreadId,5 ThreadId,
6 TurnItemId,
6 type OrchestrationV2ThreadDetailSnapshot,7 type OrchestrationV2ThreadDetailSnapshot,
7 type OrchestrationV2ThreadProjection,8 type OrchestrationV2ThreadProjection,
8 type OrchestrationV2ThreadStreamItem,9 type OrchestrationV2ThreadStreamItem,
10 type OrchestrationV2TurnItem,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
10import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";13import * as DateTime from "effect/DateTime";
@@ -36,6 +38,7 @@ import { · 24 unmodified lines
36 makeEnvironmentThreadState,38 makeEnvironmentThreadState,
37 ThreadSnapshotLoader,39 ThreadSnapshotLoader,
38 type EnvironmentThreadState,40 type EnvironmentThreadState,
41 type ThreadSnapshotLoadResult,
39} from "./threads.ts";42} from "./threads.ts";
40 43
41const TARGET = new PrimaryConnectionTarget({44const TARGET = new PrimaryConnectionTarget({
@@ -89,7 +92,12 @@ function awaitThreadState( · 47 unmodified lines
89 92
90const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {93const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
91 readonly cached?: OrchestrationV2ThreadProjection;94 readonly cached?: OrchestrationV2ThreadProjection;
92 readonly httpSnapshot?: Option.Option<OrchestrationV2ThreadDetailSnapshot>;95 readonly cachedHistory?: {
96 readonly historyCursor: string | null;
97 readonly hasMoreHistory: boolean;
98 readonly latestLocalTurnOrdinal?: number | null;
99 };
100 readonly httpSnapshot?: ThreadSnapshotLoadResult;
93 readonly completionMarker?: boolean;101 readonly completionMarker?: boolean;
94}) {102}) {
95 const inputs = yield* Queue.unbounded<TestThreadInput>();103 const inputs = yield* Queue.unbounded<TestThreadInput>();
@@ -138,8 +146,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 42 unmodified lines
138 Ref.update(loaderCalls, (count) => count + 1).pipe(146 Ref.update(loaderCalls, (count) => count + 1).pipe(
139 Effect.as(147 Effect.as(
140 threadId === THREAD_ID148 threadId === THREAD_ID
141 ? (options?.httpSnapshot ?? Option.none<OrchestrationV2ThreadDetailSnapshot>())149 ? (options?.httpSnapshot ??
142 : Option.none<OrchestrationV2ThreadDetailSnapshot>(),150 ({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult))
151 : ({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult),
143 ),152 ),
144 ),153 ),
145 });154 });
@@ -161,6 +170,17 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o · 15 unmodified lines
161 ? Option.some({170 ? Option.some({
162 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,171 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
163 projection: options.cached,172 projection: options.cached,
173 ...(options.cachedHistory === undefined
174 ? {}
175 : {
176 historyCursor: options.cachedHistory.historyCursor,
177 hasMoreHistory: options.cachedHistory.hasMoreHistory,
178 ...(options.cachedHistory.latestLocalTurnOrdinal === undefined
179 ? {}
180 : {
181 latestLocalTurnOrdinal: options.cachedHistory.latestLocalTurnOrdinal,
182 }),
183 }),
164 })184 })
165 : Option.none(),185 : Option.none(),
166 ),186 ),
@@ -318,7 +338,10 @@ describe("EnvironmentThreads", () => { · 151 unmodified lines
318 thread: { ...BASE_PROJECTION.thread, title: "HTTP title" },338 thread: { ...BASE_PROJECTION.thread, title: "HTTP title" },
319 };339 };
320 const harness = yield* makeHarness({340 const harness = yield* makeHarness({
321 httpSnapshot: Option.some({ snapshotSequence: 1, projection: httpProjection }),341 httpSnapshot: {
342 _tag: "present",
343 snapshot: { snapshotSequence: 1, projection: httpProjection },
344 },
322 });345 });
323 // No socket snapshot is pushed; only a live event arrives over the socket.346 // No socket snapshot is pushed; only a live event arrives over the socket.
324 // It can only be applied if the HTTP snapshot already seeded the thread.347 // It can only be applied if the HTTP snapshot already seeded the thread.
@@ -340,6 +363,580 @@ describe("EnvironmentThreads", () => { · 15 unmodified lines
340 }),363 }),
341 );364 );
342 365
366 it.effect("installs bounded snapshot history meta and resumes via afterSequence", () =>
367 Effect.gen(function* () {
368 const httpProjection: OrchestrationV2ThreadProjection = {
369 ...BASE_PROJECTION,
370 thread: { ...BASE_PROJECTION.thread, title: "Bounded title" },
371 };
372 const harness = yield* makeHarness({
373 httpSnapshot: {
374 _tag: "present",
375 snapshot: { snapshotSequence: 11, projection: httpProjection },
376 history: {
377 historyCursor: "opaque-cursor",
378 hasMoreHistory: true,
379 },
380 },
381 });
382 yield* Queue.offer(harness.inputs, titleUpdated("Live after bounded", 12));
383
384 const state = yield* awaitThreadState(
385 harness.observed,
386 (value) =>
387 value.status === "live" &&
388 Option.isSome(value.data) &&
389 value.data.value.thread.title === "Live after bounded" &&
390 value.history.hasMoreHistory,
391 );
392
393 expect(state.history).toMatchObject({
394 historyCursor: "opaque-cursor",
395 hasMoreHistory: true,
396 loading: false,
397 error: null,
398 expanded: false,
399 });
400 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(11);
401 // Bounded HTTP success must not require a socket snapshot frame.
402 expect(Option.getOrThrow(state.data).thread.title).toBe("Live after bounded");
403 }),
404 );
405
406 it.effect("persists progressive history meta with a settled bounded snapshot", () =>
407 Effect.gen(function* () {
408 const httpProjection: OrchestrationV2ThreadProjection = {
409 ...BASE_PROJECTION,
410 thread: { ...BASE_PROJECTION.thread, title: "Bounded cache title" },
411 };
412 const harness = yield* makeHarness({
413 httpSnapshot: {
414 _tag: "present",
415 snapshot: { snapshotSequence: 4, projection: httpProjection },
416 history: {
417 historyCursor: "cursor-oldest",
418 hasMoreHistory: true,
419 },
420 },
421 });
422 // Bounded install alone (settled) must enqueue progressive meta with the
423 // projection. Never persist the partial window as a complete full snapshot.
424 yield* awaitThreadState(
425 harness.observed,
426 (value) =>
427 Option.isSome(value.data) &&
428 value.data.value.thread.title === "Bounded cache title" &&
429 value.history.historyCursor === "cursor-oldest",
430 );
431 yield* TestClock.adjust("500 millis");
432 yield* Effect.yieldNow;
433
434 const savedAll = yield* Ref.get(harness.savedThreads);
435 expect(savedAll.length).toBeGreaterThanOrEqual(1);
436 const first = savedAll[0];
437 expect(first?.snapshotSequence).toBe(4);
438 expect(first?.projection.thread.title).toBe("Bounded cache title");
439 expect(first?.historyCursor).toBe("cursor-oldest");
440 expect(first?.hasMoreHistory).toBe(true);
441 // No earlier complete-looking entry without progressive meta.
442 expect(
443 savedAll.some(
444 (entry) =>
445 entry.projection.thread.title === "Bounded cache title" &&
446 entry.historyCursor === undefined &&
447 entry.hasMoreHistory === undefined,
448 ),
449 ).toBe(false);
450
451 // A later live update still carries the progressive cursor.
452 yield* Queue.offer(harness.inputs, titleUpdated("Settled bounded", 5));
453 yield* awaitThreadState(
454 harness.observed,
455 (value) =>
456 value.status === "live" &&
457 Option.isSome(value.data) &&
458 value.data.value.thread.title === "Settled bounded",
459 );
460 yield* TestClock.adjust("500 millis");
461 yield* Effect.yieldNow;
462
463 const saved = (yield* Ref.get(harness.savedThreads)).at(-1);
464 expect(saved?.snapshotSequence).toBe(5);
465 expect(saved?.projection.thread.title).toBe("Settled bounded");
466 expect(saved?.historyCursor).toBe("cursor-oldest");
467 expect(saved?.hasMoreHistory).toBe(true);
468 }),
469 );
470
471 it.effect("warm resume restores progressive history meta and skips HTTP", () =>
472 Effect.gen(function* () {
473 const harness = yield* makeHarness({
474 cached: {
475 ...BASE_PROJECTION,
476 thread: { ...BASE_PROJECTION.thread, title: "Cached bounded" },
477 },
478 cachedHistory: {
479 historyCursor: "warm-cursor",
480 hasMoreHistory: true,
481 },
482 });
483
484 // Apply a live event so the subscription is known to have resumed from cache.
485 yield* Queue.offer(
486 harness.inputs,
487 titleUpdated("Cached bounded live", CACHED_SNAPSHOT_SEQUENCE + 1),
488 );
489 const state = yield* awaitThreadState(
490 harness.observed,
491 (value) =>
492 value.status === "live" &&
493 Option.isSome(value.data) &&
494 value.data.value.thread.title === "Cached bounded live" &&
495 value.history.hasMoreHistory,
496 );
497
498 expect(state.history).toMatchObject({
499 historyCursor: "warm-cursor",
500 hasMoreHistory: true,
501 expanded: false,
502 });
503 // Warm progressive cache must not re-download; resume via afterSequence.
504 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
505 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE);
506 }),
507 );
508
509 it.effect("legacy warm cache without history meta stays complete (no false load-earlier)", () =>
510 Effect.gen(function* () {
511 const harness = yield* makeHarness({ cached: BASE_PROJECTION });
512 const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data));
513 expect(state.history).toMatchObject({
514 historyCursor: null,
515 hasMoreHistory: false,
516 expanded: false,
517 });
518 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
519 }),
520 );
521
522 it.effect("socket snapshot clears progressive history meta left from a bounded window", () =>
523 Effect.gen(function* () {
524 const harness = yield* makeHarness({
525 cached: {
526 ...BASE_PROJECTION,
527 thread: { ...BASE_PROJECTION.thread, title: "Warm progressive" },
528 },
529 cachedHistory: {
530 historyCursor: "stale-cursor",
531 hasMoreHistory: true,
532 },
533 });
534
535 yield* Queue.offer(
536 harness.inputs,
537 snapshot(
538 {
539 ...BASE_PROJECTION,
540 thread: { ...BASE_PROJECTION.thread, title: "Full socket snapshot" },
541 },
542 CACHED_SNAPSHOT_SEQUENCE + 1,
543 ),
544 );
545
546 const state = yield* awaitThreadState(
547 harness.observed,
548 (value) =>
549 value.status === "live" &&
550 Option.isSome(value.data) &&
551 value.data.value.thread.title === "Full socket snapshot" &&
552 value.history.historyCursor === null &&
553 value.history.hasMoreHistory === false,
554 );
555
556 expect(state.history).toMatchObject({
557 historyCursor: null,
558 hasMoreHistory: false,
559 expanded: false,
560 loading: false,
561 error: null,
562 });
563
564 // Settled full snapshot persistence must not keep the stale cursor.
565 yield* TestClock.adjust("500 millis");
566 yield* Effect.yieldNow;
567 const saved = (yield* Ref.get(harness.savedThreads)).at(-1);
568 expect(saved?.projection.thread.title).toBe("Full socket snapshot");
569 expect(saved?.historyCursor).toBeUndefined();
570 expect(saved?.hasMoreHistory).toBeUndefined();
571 }),
572 );
573
574 it.effect("live events preserve progressive history meta under atomic setThread", () =>
575 Effect.gen(function* () {
576 const harness = yield* makeHarness({
577 httpSnapshot: {
578 _tag: "present",
579 snapshot: {
580 snapshotSequence: 8,
581 projection: {
582 ...BASE_PROJECTION,
583 thread: { ...BASE_PROJECTION.thread, title: "Bounded seed" },
584 },
585 },
586 history: {
587 historyCursor: "keep-me",
588 hasMoreHistory: true,
589 },
590 },
591 });
592
593 yield* Queue.offer(harness.inputs, titleUpdated("Live preserves meta", 9));
594 const state = yield* awaitThreadState(
595 harness.observed,
596 (value) =>
597 value.status === "live" &&
598 Option.isSome(value.data) &&
599 value.data.value.thread.title === "Live preserves meta",
600 );
601
602 expect(state.history).toMatchObject({
603 historyCursor: "keep-me",
604 hasMoreHistory: true,
605 expanded: false,
606 });
607 }),
608 );
609
610 it.effect("bounded HTTP install sets projection and progressive meta atomically", () =>
611 Effect.gen(function* () {
612 // One setThread with explicit history (not applyItem reset + later meta).
613 const harness = yield* makeHarness({
614 httpSnapshot: {
615 _tag: "present",
616 snapshot: {
617 snapshotSequence: 2,
618 projection: {
619 ...BASE_PROJECTION,
620 thread: { ...BASE_PROJECTION.thread, title: "Bounded atomic install" },
621 },
622 },
623 history: {
624 historyCursor: "post-install-cursor",
625 hasMoreHistory: true,
626 },
627 },
628 });
629
630 const state = yield* awaitThreadState(
631 harness.observed,
632 (value) =>
633 Option.isSome(value.data) &&
634 value.data.value.thread.title === "Bounded atomic install" &&
635 value.history.historyCursor === "post-install-cursor" &&
636 value.history.hasMoreHistory === true,
637 );
638
639 expect(state.history).toMatchObject({
640 historyCursor: "post-install-cursor",
641 hasMoreHistory: true,
642 loading: false,
643 error: null,
644 expanded: false,
645 });
646 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(2);
647 }),
648 );
649
650 it.effect("dropped partial-timeline turn item is a true applyItem no-op", () =>
651 Effect.gen(function* () {
652 const recent = {
653 id: TurnItemId.make("item-window"),
654 threadId: THREAD_ID,
655 runId: null,
656 nodeId: null,
657 providerThreadId: null,
658 providerTurnId: null,
659 nativeItemRef: null,
660 parentItemId: null,
661 ordinal: 10,
662 status: "completed" as const,
663 title: null,
664 startedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
665 completedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
666 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
667 type: "command_execution" as const,
668 input: "pwd",
669 output: "ok",
670 exitCode: 0,
671 } satisfies OrchestrationV2TurnItem;
672 const recentRow = {
673 position: 0,
674 visibility: "local" as const,
675 sourceThreadId: THREAD_ID,
676 sourceItemId: recent.id,
677 item: recent,
678 };
679 const boundedProjection: OrchestrationV2ThreadProjection = {
680 ...BASE_PROJECTION,
681 thread: { ...BASE_PROJECTION.thread, title: "Partial noop" },
682 turnItems: [recent],
683 visibleTurnItems: [recentRow],
684 };
685 const harness = yield* makeHarness({
686 httpSnapshot: {
687 _tag: "present",
688 snapshot: {
689 snapshotSequence: 5,
690 projection: boundedProjection,
691 latestLocalTurnOrdinal: 10,
692 },
693 history: {
694 historyCursor: "partial-cursor",
695 hasMoreHistory: true,
696 latestLocalTurnOrdinal: 10,
697 },
698 },
699 });
700
701 const seeded = yield* awaitThreadState(
702 harness.observed,
703 (value) =>
704 value.status === "live" &&
705 Option.isSome(value.data) &&
706 value.data.value.thread.title === "Partial noop" &&
707 value.history.historyCursor === "partial-cursor",
708 );
709 const seededProjection = Option.getOrThrow(seeded.data);
710 yield* TestClock.adjust("500 millis");
711 yield* Effect.yieldNow;
712 const savedBefore = (yield* Ref.get(harness.savedThreads)).length;
713
714 const older = {
715 ...recent,
716 id: TurnItemId.make("item-old-outside"),
717 ordinal: 3,
718 output: "must-not-append",
719 } satisfies OrchestrationV2TurnItem;
720 yield* Queue.offer(harness.inputs, {
721 kind: "event",
722 sequence: 6,
723 event: {
724 id: EventId.make("event-old-partial"),
725 type: "turn-item.updated",
726 threadId: THREAD_ID,
727 occurredAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
728 payload: older,
729 },
730 });
731
732 // Allow the event to be processed without requiring a state transition.
733 for (let attempt = 0; attempt < 30; attempt += 1) {
734 yield* Effect.yieldNow;
735 }
736 // Drive a later unrelated title update so we know the stream continued.
737 yield* Queue.offer(harness.inputs, titleUpdated("After dropped event", 7));
738 const after = yield* awaitThreadState(
739 harness.observed,
740 (value) =>
741 value.status === "live" &&
742 Option.isSome(value.data) &&
743 value.data.value.thread.title === "After dropped event",
744 );
745
746 // The dropped event must not have appended the old item.
747 expect(Option.getOrThrow(after.data).turnItems.map((item) => String(item.id))).toEqual([
748 String(recent.id),
749 ]);
750 // Watermark unchanged (only advanced on successful newer turn-item applies).
751 expect(after.history.latestLocalTurnOrdinal).toBe(10);
752 // No persistence enqueue from the dropped event itself.
753 const savedAfterDrop = (yield* Ref.get(harness.savedThreads)).length;
754 expect(savedAfterDrop).toBe(savedBefore);
755 // Seeded projection reference path: event path kept partial meta intact.
756 expect(after.history.historyCursor).toBe("partial-cursor");
757 expect(after.history.hasMoreHistory).toBe(true);
758 // Title event applied; drop itself did not clear progressive meta.
759 expect(seededProjection.turnItems.map((item) => String(item.id))).toEqual([
760 String(recent.id),
761 ]);
762 }),
763 );
764
765 it.effect("installs and advances latestLocalTurnOrdinal for partial progressive windows", () =>
766 Effect.gen(function* () {
767 const harness = yield* makeHarness({
768 httpSnapshot: {
769 _tag: "present",
770 snapshot: {
771 snapshotSequence: 3,
772 projection: {
773 ...BASE_PROJECTION,
774 thread: { ...BASE_PROJECTION.thread, title: "Watermark seed" },
775 },
776 latestLocalTurnOrdinal: 15,
777 },
778 history: {
779 historyCursor: "wm-cursor",
780 hasMoreHistory: true,
781 latestLocalTurnOrdinal: 15,
782 },
783 },
784 });
785
786 const seeded = yield* awaitThreadState(
787 harness.observed,
788 (value) =>
789 Option.isSome(value.data) &&
790 value.data.value.thread.title === "Watermark seed" &&
791 value.history.latestLocalTurnOrdinal === 15,
792 );
793 expect(seeded.history.latestLocalTurnOrdinal).toBe(15);
794
795 yield* TestClock.adjust("500 millis");
796 yield* Effect.yieldNow;
797 const savedSeed = (yield* Ref.get(harness.savedThreads)).at(-1);
798 expect(savedSeed?.latestLocalTurnOrdinal).toBe(15);
799
800 const newer = {
801 id: TurnItemId.make("item-newer-live"),
802 threadId: THREAD_ID,
803 runId: null,
804 nodeId: null,
805 providerThreadId: null,
806 providerTurnId: null,
807 nativeItemRef: null,
808 parentItemId: null,
809 ordinal: 22,
810 status: "completed" as const,
811 title: null,
812 startedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
813 completedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
814 updatedAt: DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"),
815 type: "command_execution" as const,
816 input: "echo newer",
817 output: "newer",
818 exitCode: 0,
819 } satisfies OrchestrationV2TurnItem;
820
821 yield* Queue.offer(harness.inputs, {
822 kind: "event",
823 sequence: 4,
824 event: {
825 id: EventId.make("event-newer-item"),
826 type: "turn-item.updated",
827 threadId: THREAD_ID,
828 occurredAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
829 payload: newer,
830 },
831 });
832
833 const advanced = yield* awaitThreadState(
834 harness.observed,
835 (value) =>
836 value.status === "live" &&
837 Option.isSome(value.data) &&
838 value.history.latestLocalTurnOrdinal === 22 &&
839 value.data.value.turnItems.some((item) => String(item.id) === String(newer.id)),
840 );
841 expect(advanced.history.latestLocalTurnOrdinal).toBe(22);
842 expect(advanced.history.historyCursor).toBe("wm-cursor");
843 }),
844 );
845
846 it.effect("warm resume restores latestLocalTurnOrdinal from progressive cache", () =>
847 Effect.gen(function* () {
848 const harness = yield* makeHarness({
849 cached: {
850 ...BASE_PROJECTION,
851 thread: { ...BASE_PROJECTION.thread, title: "Warm watermark" },
852 },
853 cachedHistory: {
854 historyCursor: "warm-wm-cursor",
855 hasMoreHistory: true,
856 latestLocalTurnOrdinal: 33,
857 },
858 });
859
860 const state = yield* awaitThreadState(
861 harness.observed,
862 (value) =>
863 Option.isSome(value.data) &&
864 value.data.value.thread.title === "Warm watermark" &&
865 value.history.latestLocalTurnOrdinal === 33,
866 );
867 expect(state.history).toMatchObject({
868 historyCursor: "warm-wm-cursor",
869 hasMoreHistory: true,
870 latestLocalTurnOrdinal: 33,
871 expanded: false,
872 });
873 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
874 }),
875 );
876
877 it.effect("marks a cold definitive HTTP miss deleted without socket subscribe or retry", () =>
878 Effect.gen(function* () {
879 const harness = yield* makeHarness({
880 httpSnapshot: { _tag: "missing" },
881 });
882
883 const state = yield* awaitThreadState(
884 harness.observed,
885 (value) => value.status === "deleted",
886 );
887
888 expect(Option.isNone(state.data)).toBe(true);
889 expect(Option.isNone(state.error)).toBe(true);
890 expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1);
891 expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]);
892 expect(yield* Ref.get(harness.subscriptionCount)).toBe(0);
893
894 // A definitive miss must not schedule the expected-failure retry path.
895 yield* TestClock.adjust("1 second");
896 for (let attempt = 0; attempt < 20; attempt += 1) {
897 yield* Effect.yieldNow;
898 }
899 yield* harness.replaceSession;
900 yield* Queue.offer(harness.wakeups, "application-active");
901 for (let attempt = 0; attempt < 20; attempt += 1) {
902 yield* Effect.yieldNow;
903 }
904 expect(yield* Ref.get(harness.subscriptionCount)).toBe(0);
905 expect(yield* Ref.get(harness.retryCount)).toBe(0);
906 expect(yield* Ref.get(harness.loaderCalls)).toBe(1);
907 expect((yield* Ref.get(harness.latest)).status).toBe("deleted");
908 }),
909 );
910
911 it.effect("falls back to the socket when the HTTP snapshot is only unavailable", () =>
912 Effect.gen(function* () {
913 const harness = yield* makeHarness({
914 httpSnapshot: { _tag: "unavailable" },
915 });
916
917 yield* Queue.offer(
918 harness.inputs,
919 snapshot({
920 ...BASE_PROJECTION,
921 thread: { ...BASE_PROJECTION.thread, title: "Socket title" },
922 }),
923 );
924
925 const state = yield* awaitThreadState(
926 harness.observed,
927 (value) =>
928 value.status === "live" &&
929 Option.isSome(value.data) &&
930 value.data.value.thread.title === "Socket title",
931 );
932
933 expect(Option.getOrThrow(state.data).thread.title).toBe("Socket title");
934 expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1);
935 expect(yield* Ref.get(harness.subscriptionCount)).toBe(1);
936 expect(yield* Ref.get(harness.removedThreads)).toEqual([]);
937 }),
938 );
939
343 it.effect("ignores replayed thread events at or below the snapshot sequence", () =>940 it.effect("ignores replayed thread events at or below the snapshot sequence", () =>
344 Effect.gen(function* () {941 Effect.gen(function* () {
345 const harness = yield* makeHarness({ cached: BASE_PROJECTION });942 const harness = yield* makeHarness({ cached: BASE_PROJECTION });
packages/client-runtime/src/state/threads.ts+384−34
@@ -11,8 +11,10 @@ import * as Effect from "effect/Effect"; · 10 unmodified lines
11import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";12import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";13import * as Ref from "effect/Ref";
14import * as Result from "effect/Result";
14import * as Stream from "effect/Stream";15import * as Stream from "effect/Stream";
15import * as SubscriptionRef from "effect/SubscriptionRef";16import * as SubscriptionRef from "effect/SubscriptionRef";
17import { HttpClient } from "effect/unstable/http";
16import { Atom } from "effect/unstable/reactivity";18import { Atom } from "effect/unstable/reactivity";
17 19
18import { EnvironmentRegistry } from "../connection/registry.ts";20import { EnvironmentRegistry } from "../connection/registry.ts";
@@ -20,12 +22,26 @@ import { connectionProjectionPhase } from "../connection/model.ts"; · 1 unmodified line
20import { EnvironmentSupervisor } from "../connection/supervisor.ts";22import { EnvironmentSupervisor } from "../connection/supervisor.ts";
21import * as ConnectionWakeups from "../connection/wakeups.ts";23import * as ConnectionWakeups from "../connection/wakeups.ts";
22import { EnvironmentCacheStore } from "../platform/persistence.ts";24import { EnvironmentCacheStore } from "../platform/persistence.ts";
25import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
23import { subscribeDynamic } from "../rpc/client.ts";26import { subscribeDynamic } from "../rpc/client.ts";
24import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
25import { parseThreadKey, threadKey } from "./entities.ts";27import { parseThreadKey, threadKey } from "./entities.ts";
26import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";28import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
27import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
28import { followStreamInEnvironment } from "./runtime.ts";29import { followStreamInEnvironment } from "./runtime.ts";
30import {
31 ThreadHistoryController,
32 type ThreadHistoryLoadEarlierResult,
33} from "./threadHistoryController.ts";
34import { fetchEnvironmentThreadHistoryPage } from "./threadHistoryHttp.ts";
35import {
36 applyHistoryPageMeta,
37 clearActiveHistoryLoading,
38 EMPTY_THREAD_HISTORY_META,
39 isActiveHistoryRequestCursor,
40 mergeOlderHistoryIntoProjection,
41 type ThreadHistoryMeta,
42} from "./threadHistoryMerge.ts";
43import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
44import { ThreadSnapshotLoader, type ThreadSnapshotLoadResult } from "./threadSnapshotHttp.ts";
29import {45import {
30 EMPTY_ENVIRONMENT_THREAD_STATE,46 EMPTY_ENVIRONMENT_THREAD_STATE,
31 type EnvironmentThreadState,47 type EnvironmentThreadState,
@@ -45,7 +61,57 @@ function formatThreadError(cause: Cause.Cause<unknown>): string { · 13 unmodified lines
45 : "Could not synchronize the thread.";61 : "Could not synchronize the thread.";
46}62}
47 63
48function shouldPersistThread(thread: OrchestrationV2ThreadProjection): boolean {64function formatHistoryError(error: unknown): string {
65 if (error instanceof Error && error.message.trim().length > 0) {
66 return error.message;
67 }
68 return "Could not load earlier activity.";
69}
70
71function historyMetaFromCachedSnapshot(
72 snapshot: OrchestrationV2ThreadDetailSnapshot,
73): ThreadHistoryMeta {
74 const historyCursor = snapshot.historyCursor ?? null;
75 const hasMoreHistory = snapshot.hasMoreHistory ?? false;
76 return {
77 historyCursor,
78 hasMoreHistory,
79 loading: false,
80 error: null,
81 // Cache never stores expanded progressive history (load-earlier growth).
82 expanded: false,
83 latestLocalTurnOrdinal: snapshot.latestLocalTurnOrdinal ?? null,
84 };
85}
86
87function snapshotToPersist(
88 snapshotSequence: number,
89 projection: OrchestrationV2ThreadProjection,
90 history: ThreadHistoryMeta,
91): OrchestrationV2ThreadDetailSnapshot {
92 // Persist progressive meta with the bounded window so warm resume restores the
93 // history cursor instead of looking like a complete full-projection cache hit.
94 if (history.hasMoreHistory || history.historyCursor !== null) {
95 return {
96 snapshotSequence,
97 projection,
98 historyCursor: history.historyCursor,
99 hasMoreHistory: history.hasMoreHistory,
100 latestLocalTurnOrdinal: history.latestLocalTurnOrdinal,
101 };
102 }
103 return { snapshotSequence, projection };
104}
105
106function shouldPersistThread(
107 thread: OrchestrationV2ThreadProjection,
108 history: ThreadHistoryMeta,
109): boolean {
110 // After the user loads older pages the in-memory timeline can grow large.
111 // Keep those expanded projections out of the monolithic cache.
112 if (history.expanded) {
113 return false;
114 }
49 return !thread.runs.some(115 return !thread.runs.some(
50 (run) => run.status === "preparing" || run.status === "starting" || run.status === "running",116 (run) => run.status === "preparing" || run.status === "starting" || run.status === "running",
51 );117 );
@@ -94,6 +160,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 42 unmodified lines
94 const supervisor = yield* EnvironmentSupervisor;160 const supervisor = yield* EnvironmentSupervisor;
95 const cache = yield* EnvironmentCacheStore;161 const cache = yield* EnvironmentCacheStore;
96 const snapshotLoader = yield* ThreadSnapshotLoader;162 const snapshotLoader = yield* ThreadSnapshotLoader;
163 const historyController = yield* Effect.serviceOption(ThreadHistoryController);
164 const httpClient = yield* Effect.serviceOption(HttpClient.HttpClient);
165 const dpopSigner = yield* Effect.serviceOption(ManagedRelayDpopSigner);
97 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);166 const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups);
98 const environmentId = supervisor.target.environmentId;167 const environmentId = supervisor.target.environmentId;
99 const cached = yield* cache.loadThread(environmentId, threadId).pipe(168 const cached = yield* cache.loadThread(environmentId, threadId).pipe(
@@ -109,10 +178,15 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 9 unmodified lines
109 ),178 ),
110 );179 );
111 const cachedThread = Option.map(cached, (snapshot) => snapshot.projection);180 const cachedThread = Option.map(cached, (snapshot) => snapshot.projection);
181 const cachedHistory = Option.match(cached, {
182 onNone: () => EMPTY_THREAD_HISTORY_META,
183 onSome: historyMetaFromCachedSnapshot,
184 });
112 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({185 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({
113 data: cachedThread,186 data: cachedThread,
114 status: statusWithoutLiveData(cachedThread),187 status: statusWithoutLiveData(cachedThread),
115 error: Option.none(),188 error: Option.none(),
189 history: cachedHistory,
116 });190 });
117 // Seed the resume cursor from the cached snapshot so a warm cache can catch up191 // Seed the resume cursor from the cached snapshot so a warm cache can catch up
118 // via `afterSequence` instead of re-downloading the full thread body.192 // via `afterSequence` instead of re-downloading the full thread body.
@@ -204,28 +278,57 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 85 unmodified lines
204 278
205 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (279 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (
206 thread: OrchestrationV2ThreadProjection,280 thread: OrchestrationV2ThreadProjection,
281 options?: {
282 /** Socket/full snapshots: drop progressive meta with the new timeline. */
283 readonly resetHistory?: boolean;
284 /**
285 * Explicit progressive meta installed atomically with the projection
286 * (bounded HTTP). Wins over resetHistory when both are supplied.
287 */
288 readonly history?: ThreadHistoryMeta;
289 },
207 ) {290 ) {
208 const waiting = yield* Ref.get(awaitingCompletion);291 const waiting = yield* Ref.get(awaitingCompletion);
209 yield* SubscriptionRef.set(state, {292 // Atomic with concurrent history meta updates: never get-then-set the whole
210 data: Option.some(thread),293 // state when only the projection changes. Bounded installs pass history so
211 status: waiting ? "synchronizing" : "live",294 // projection + cursor persist together in one enqueue.
212 error: Option.none(),295 const next = yield* SubscriptionRef.updateAndGet(state, (previous) => {
296 const history =
297 options?.history !== undefined
298 ? options.history
299 : options?.resetHistory === true
300 ? EMPTY_THREAD_HISTORY_META
301 : previous.history;
302 return {
303 ...previous,
304 data: Option.some(thread),
305 status: waiting ? ("synchronizing" as const) : ("live" as const),
306 error: Option.none(),
307 history,
308 };
213 });309 });
214 // Active projections can update many times per second and retain large tool310 // Active projections can update many times per second and retain large tool
215 // payloads. Persist once the run settles so cache encoding stays off the311 // payloads. Persist once the run settles so cache encoding stays off the
216 // streaming path.312 // streaming path. Progressive meta rides along when the window is incomplete.
217 if (shouldPersistThread(thread)) {313 if (shouldPersistThread(thread, next.history)) {
218 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);314 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
219 yield* Queue.offer(persistence, { snapshotSequence, projection: thread });315 yield* Queue.offer(persistence, snapshotToPersist(snapshotSequence, thread, next.history));
220 }316 }
221 });317 });
222 318
319 const patchHistoryMeta = (patch: (history: ThreadHistoryMeta) => ThreadHistoryMeta) =>
320 SubscriptionRef.update(state, (current) => ({
321 ...current,
322 history: patch(current.history),
323 }));
324
223 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {325 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
224 yield* Ref.set(awaitingCompletion, false);326 yield* Ref.set(awaitingCompletion, false);
225 yield* SubscriptionRef.set(state, {327 yield* SubscriptionRef.set(state, {
226 data: Option.none(),328 data: Option.none(),
227 status: "deleted",329 status: "deleted",
228 error: Option.none(),330 error: Option.none(),
331 history: EMPTY_THREAD_HISTORY_META,
229 });332 });
230 yield* remember;333 yield* remember;
231 if (resumeCache !== undefined && resumeCache.owner !== owner) return;334 if (resumeCache !== undefined && resumeCache.owner !== owner) return;
@@ -257,7 +360,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 25 unmodified lines
257 360
258 if (item.kind === "snapshot") {361 if (item.kind === "snapshot") {
259 yield* SubscriptionRef.set(lastSequence, item.snapshotSequence);362 yield* SubscriptionRef.set(lastSequence, item.snapshotSequence);
260 yield* setThread(item.projection);363 // True socket/full snapshots replace the full timeline; progressive cursor
364 // state from a prior bounded window must not stick around. Bounded HTTP
365 // installs call setThread with explicit history and never route here.
366 yield* setThread(item.projection, { resetHistory: true });
261 return;367 return;
262 }368 }
263 369
@@ -267,23 +373,230 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 3 unmodified lines
267 }373 }
268 yield* SubscriptionRef.set(lastSequence, item.sequence);374 yield* SubscriptionRef.set(lastSequence, item.sequence);
269 375
270 const current = yield* SubscriptionRef.get(state);376 const waiting = yield* Ref.get(awaitingCompletion);
271 if (Option.isNone(current.data)) {377 // Apply against the latest projection/history in one update so a concurrent
272 if (item.event.type === "thread.deleted") {378 // loadEarlier merge (or history-meta patch) cannot be clobbered by a stale
273 yield* setDeleted();379 // get-then-set rebuild.
274 }380 type EventApplyResult =
275 return;381 | { readonly _tag: "noop" }
276 }382 | { readonly _tag: "delete" }
277 if (item.event.type === "thread.deleted") {383 | {
384 readonly _tag: "applied";
385 readonly projection: OrchestrationV2ThreadProjection;
386 readonly history: ThreadHistoryMeta;
387 };
388
389 const result = yield* SubscriptionRef.modify(
390 state,
391 (current): readonly [EventApplyResult, EnvironmentThreadState] => {
392 if (current.status === "deleted") {
393 return [{ _tag: "noop" }, current];
394 }
395 if (Option.isNone(current.data)) {
396 return [
397 item.event.type === "thread.deleted" ? { _tag: "delete" } : { _tag: "noop" },
398 current,
399 ];
400 }
401 if (item.event.type === "thread.deleted") {
402 return [{ _tag: "delete" }, current];
403 }
404
405 // Incomplete progressive windows only (hasMore or open cursor). Do not
406 // use expanded: it remains true after the last page as a cache marker.
407 // Full/web and fully-loaded timelines keep default append-on-miss.
408 const partial =
409 current.history.hasMoreHistory || current.history.historyCursor !== null
410 ? {
411 partialTimeline: true as const,
412 latestLocalTurnOrdinal: current.history.latestLocalTurnOrdinal,
413 }
414 : undefined;
415 const next = applyOrchestrationV2ProjectionEvent(current.data.value, item.event, partial);
416 // True no-op when the reducer deliberately returns the current projection
417 // reference (e.g. dropped old partial-timeline turn-item). Do not clear
418 // stream error/status or enqueue persistence.
419 if (next === null || next === current.data.value) {
420 return [{ _tag: "noop" }, current];
421 }
422
423 let history = current.history;
424 if (partial !== undefined && item.event.type === "turn-item.updated") {
425 const ordinal = item.event.payload.ordinal;
426 const watermark = history.latestLocalTurnOrdinal;
427 if (watermark === null || ordinal > watermark) {
428 history = { ...history, latestLocalTurnOrdinal: ordinal };
429 }
430 }
431
432 const updated: EnvironmentThreadState = {
433 ...current,
434 data: Option.some(next),
435 status: waiting ? "synchronizing" : "live",
436 error: Option.none(),
437 history,
438 };
439 return [{ _tag: "applied", projection: next, history: updated.history }, updated];
440 },
441 );
442
443 if (result._tag === "delete") {
278 yield* setDeleted();444 yield* setDeleted();
279 return;445 return;
280 }446 }
281 const next = applyOrchestrationV2ProjectionEvent(current.data.value, item.event);447 if (result._tag === "applied" && shouldPersistThread(result.projection, result.history)) {
282 if (next !== null) {448 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
283 yield* setThread(next);449 yield* Queue.offer(
450 persistence,
451 snapshotToPersist(snapshotSequence, result.projection, result.history),
452 );
453 }
454 });
455
456 const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* () {
457 const current = yield* SubscriptionRef.get(state);
458 if (
459 current.status === "deleted" ||
460 Option.isNone(current.data) ||
461 !current.history.hasMoreHistory ||
462 current.history.historyCursor === null
463 ) {
464 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
465 }
466 if (current.history.loading) {
467 return { _tag: "busy" } satisfies ThreadHistoryLoadEarlierResult;
284 }468 }
469
470 // Capture the cursor that initiated this request. Completions/failures must
471 // no-op if a socket or new bounded snapshot replaced progressive meta mid-flight.
472 const requestCursor = current.history.historyCursor;
473 yield* patchHistoryMeta((history) =>
474 isActiveHistoryRequestCursor(requestCursor, history)
475 ? { ...history, loading: true, error: null }
476 : history,
477 );
478
479 const runLoad = Effect.gen(function* () {
480 const preparedOption = yield* SubscriptionRef.get(supervisor.prepared);
481 if (Option.isNone(preparedOption) || Option.isNone(httpClient)) {
482 const message = "Environment is not connected.";
483 const stillCurrent = yield* SubscriptionRef.modify(
484 state,
485 (latest): readonly [boolean, EnvironmentThreadState] => {
486 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
487 return [false, latest];
488 }
489 return [
490 true,
491 {
492 ...latest,
493 history: { ...latest.history, loading: false, error: message },
494 },
495 ];
496 },
497 );
498 if (!stillCurrent) {
499 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
500 }
501 return {
502 _tag: "error",
503 message,
504 } satisfies ThreadHistoryLoadEarlierResult;
505 }
506
507 const pageResult = yield* fetchEnvironmentThreadHistoryPage({
508 prepared: preparedOption.value,
509 threadId,
510 cursor: requestCursor,
511 signer: dpopSigner,
512 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result);
513
514 if (Result.isFailure(pageResult)) {
515 const message = formatHistoryError(pageResult.failure);
516 // Only mark error when this request's cursor is still active. Leave
517 // stream/status/error alone so concurrent live updates stay intact.
518 const stillCurrent = yield* SubscriptionRef.modify(
519 state,
520 (latest): readonly [boolean, EnvironmentThreadState] => {
521 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
522 return [false, latest];
523 }
524 return [
525 true,
526 {
527 ...latest,
528 history: { ...latest.history, loading: false, error: message },
529 },
530 ];
531 },
532 );
533 if (!stillCurrent) {
534 return { _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult;
535 }
536 return { _tag: "error", message } satisfies ThreadHistoryLoadEarlierResult;
537 }
538
539 const page = pageResult.success;
540 const waiting = yield* Ref.get(awaitingCompletion);
541 // Single atomic merge against whatever is current after the await so a
542 // concurrent applyItem cannot be clobbered by a stale get/set pair.
543 return yield* SubscriptionRef.modify(
544 state,
545 (latest): readonly [ThreadHistoryLoadEarlierResult, EnvironmentThreadState] => {
546 // Stale page: socket/full snapshot or newer bounded install changed the
547 // progressive cursor while this request was in flight. Never mutate the
548 // replacement meta (including deleted/empty installs).
549 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
550 return [{ _tag: "noop" }, latest];
551 }
552 if (Option.isNone(latest.data) || latest.status === "deleted") {
553 return [
554 { _tag: "noop" },
555 {
556 ...latest,
557 history: EMPTY_THREAD_HISTORY_META,
558 },
559 ];
560 }
561
562 const merged = mergeOlderHistoryIntoProjection(latest.data.value, page.items);
563 const history = applyHistoryPageMeta(latest.history, page);
564 return [
565 { _tag: "loaded" },
566 {
567 ...latest,
568 data: Option.some(merged),
569 status: waiting
570 ? ("synchronizing" as const)
571 : latest.status === "live"
572 ? ("live" as const)
573 : latest.status,
574 history,
575 },
576 ];
577 },
578 );
579 });
580
581 // On Effect interruption only: clear loading when this request cursor is
582 // still active. Never mutate stream status/error from the interrupt path.
583 return yield* runLoad.pipe(
584 Effect.onInterrupt(() =>
585 SubscriptionRef.update(state, (latest) => ({
586 ...latest,
587 history: clearActiveHistoryLoading(requestCursor, latest.history),
588 })),
589 ),
590 );
285 });591 });
286 592
593 if (Option.isSome(historyController)) {
594 const registration = yield* historyController.value.register(environmentId, threadId, {
595 loadEarlier: () => loadEarlier(),
596 });
597 yield* Effect.addFinalizer(() => historyController.value.unregister(registration));
598 }
599
287 yield* SubscriptionRef.changes(supervisor.state).pipe(600 yield* SubscriptionRef.changes(supervisor.state).pipe(
288 Stream.runForEach((connectionState) => {601 Stream.runForEach((connectionState) => {
289 switch (connectionProjectionPhase(connectionState)) {602 switch (connectionProjectionPhase(connectionState)) {
@@ -309,6 +622,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 19 unmodified lines
309 subscribeDynamic(622 subscribeDynamic(
310 ORCHESTRATION_V2_WS_METHODS.subscribeThread,623 ORCHESTRATION_V2_WS_METHODS.subscribeThread,
311 Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) {624 Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) {
625 let current = yield* SubscriptionRef.get(state);
626 // A prior definitive miss (or delete event) already cleared this thread.
627 // Park the subscription attempt without opening the socket so we do not
628 // retry forever against a known-missing id.
629 if (current.status === "deleted") {
630 return yield* Effect.never;
631 }
632
312 const supportsCompletionMarker = yield* session.initialConfig.pipe(633 const supportsCompletionMarker = yield* session.initialConfig.pipe(
313 Effect.map((config) => config.threadResumeCompletionMarker === true),634 Effect.map((config) => config.threadResumeCompletionMarker === true),
314 Effect.orElseSucceed(() => false),635 Effect.orElseSucceed(() => false),
@@ -316,8 +637,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 1 unmodified line
316 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);637 yield* Ref.set(awaitingCompletion, supportsCompletionMarker);
317 yield* setSynchronizing;638 yield* setSynchronizing;
318 639
319 let current = yield* SubscriptionRef.get(state);640 if (Option.isNone(current.data)) {
320 if (Option.isNone(current.data) && current.status !== "deleted") {
321 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(641 const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe(
322 Effect.flatMap(642 Effect.flatMap(
323 Option.match({643 Option.match({
@@ -332,14 +652,41 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 8 unmodified lines
332 }),652 }),
333 ),653 ),
334 );654 );
335 const httpSnapshot = yield* snapshotLoader.load(prepared, threadId);655 const httpResult: ThreadSnapshotLoadResult = yield* snapshotLoader.load(
336 if (Option.isSome(httpSnapshot)) {656 prepared,
337 yield* applyItem({657 threadId,
338 kind: "snapshot",658 );
339 snapshotSequence: httpSnapshot.value.snapshotSequence,659 switch (httpResult._tag) {
340 projection: httpSnapshot.value.projection,660 case "present": {
341 });661 // Atomic projection + progressive meta so a settled bounded window
342 current = yield* SubscriptionRef.get(state);662 // never persists as a complete full-timeline cache entry. Socket
663 // snapshots still go through applyItem (resetHistory).
664 yield* SubscriptionRef.set(lastSequence, httpResult.snapshot.snapshotSequence);
665 const history: ThreadHistoryMeta =
666 httpResult.history !== undefined
667 ? {
668 historyCursor: httpResult.history.historyCursor,
669 hasMoreHistory: httpResult.history.hasMoreHistory,
670 loading: false,
671 error: null,
672 expanded: false,
673 latestLocalTurnOrdinal: httpResult.history.latestLocalTurnOrdinal ?? null,
674 }
675 : EMPTY_THREAD_HISTORY_META;
676 yield* setThread(httpResult.snapshot.projection, { history });
677 current = yield* SubscriptionRef.get(state);
678 break;
679 }
680 case "missing": {
681 // Definitive HTTP 404: clear any stale cache and do not open or
682 // retry a socket subscription for this attempt.
683 yield* setDeleted();
684 return yield* Effect.never;
685 }
686 case "unavailable": {
687 // Transient HTTP failure: fall through to the socket path.
688 break;
689 }
343 }690 }
344 }691 }
345 692
@@ -373,8 +720,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make · 27 unmodified lines
373 Option.match(current.data, {720 Option.match(current.data, {
374 onNone: () => Effect.void,721 onNone: () => Effect.void,
375 onSome: (projection) =>722 onSome: (projection) =>
376 shouldPersistThread(projection)723 shouldPersistThread(projection, current.history)
377 ? persist({ snapshotSequence, projection })724 ? persist(snapshotToPersist(snapshotSequence, projection, current.history))
378 : Effect.void,725 : Effect.void,
379 }),726 }),
380 ),727 ),
@@ -442,6 +789,9 @@ export function createEnvironmentThreadStateAtoms<R, E>( · 61 unmodified lines
442 789
443export * from "./archivedThreads.ts";790export * from "./archivedThreads.ts";
444export * from "./checkpointDiff.ts";791export * from "./checkpointDiff.ts";
792export * from "./boundedThreadSnapshotHttp.ts";
793export * from "./threadHistoryController.ts";
794export * from "./threadHistoryMerge.ts";
445export * from "./threadSnapshotHttp.ts";795export * from "./threadSnapshotHttp.ts";
446export * from "./composerPathSearch.ts";796export * from "./composerPathSearch.ts";
447export * from "./threadCommands.ts";797export * from "./threadCommands.ts";