authorgravatar for 35393046+lnieuwenhuis@users.noreply.github.comLars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> 2026-09-15 22:52:54+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-15 13:52:54-07:00
loga5da32750125c3bedb959bf331e5e49e95b95e5d
tree6a62362126c817bbcde2ab63aea000b13563ff53
parent3efdcc5296f1754e0f3bf7fee5fc2ada510e0438
signature Signed by PGP key B5690EEEBB952194

fix(client-runtime): preserve cached turns and older-page loading (#8309)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 177 insertions(+), 23 deletions(-)

packages/client-runtime/src/state/threads-pagination.test.ts+59
...@@ -398,6 +398,65 @@ describe("thread pagination state", () => {...@@ -398,6 +398,65 @@ describe("thread pagination state", () => {
398 }),398 }),
399 );399 );
400400
401 it.effect("keeps a new page loading when a snapshot replaced a parked older page", () =>
402 Effect.gen(function* () {
403 const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
404 yield* harness.awaitState((value) => Option.isSome(value.page));
405 requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
406 yield* harness.resolveNextPage(
407 Option.some({
408 ...OLDER_PAGE,
409 snapshotSequence: 30,
410 page: { beforeCursor: null, hasMore: false, snapshotSequence: 30, threadSequence: 30 },
411 }),
412 );
413 yield* Queue.offer(harness.inputs, titleEvent("Waiting for old watermark", 11));
414 yield* harness.awaitState((value) =>
415 Option.exists(value.data, (thread) => thread.title === "Waiting for old watermark"),
416 );
417 expect(
418 Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).page).loadingOlder,
419 ).toBe(true);
420
421 yield* Queue.offer(harness.inputs, {
422 kind: "snapshot",
423 snapshot: {
424 snapshotSequence: 20,
425 thread: { ...BASE_THREAD, title: "Replacement snapshot" },
426 page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 },
427 },
428 });
429 yield* harness.awaitState((value) =>
430 Option.exists(value.data, (thread) => thread.title === "Replacement snapshot"),
431 );
432 requestOlderThreadTurns(TARGET.environmentId, THREAD_ID);
433 yield* harness.awaitState((value) =>
434 Option.exists(value.page, (page) => page.loadingOlder && page.beforeCursor === "cursor-2"),
435 );
436 yield* Queue.offer(harness.inputs, titleEvent("New request still loading", 21));
437 yield* harness.awaitState((value) =>
438 Option.exists(value.data, (thread) => thread.title === "New request still loading"),
439 );
440 const loading = yield* SubscriptionRef.get(harness.threadState);
441 expect(Option.getOrThrow(loading.page).loadingOlder).toBe(true);
442 expect(hasMessage(loading, "message-old")).toBe(false);
443 expect((yield* Ref.get(harness.loaderWindows)).map((window) => window?.beforeCursor)).toEqual(
444 [undefined, "cursor-1", "cursor-2"],
445 );
446
447 yield* harness.resolveNextPage(
448 Option.some({
449 ...OLDER_PAGE,
450 snapshotSequence: 21,
451 page: { beforeCursor: null, hasMore: false, snapshotSequence: 21, threadSequence: 21 },
452 }),
453 );
454 const completed = yield* harness.awaitState((value) => hasMessage(value, "message-old"));
455 expect(Option.getOrThrow(completed.page).loadingOlder).toBe(false);
456 expect(Option.getOrThrow(completed.page).beforeCursor).toBeNull();
457 }),
458 );
459
401 it.effect("discards an older page read from a projection behind the loaded state", () =>460 it.effect("discards an older page read from a projection behind the loaded state", () =>
402 Effect.gen(function* () {461 Effect.gen(function* () {
403 const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });462 const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) });
packages/client-runtime/src/state/threads-sync.test.ts+81-4
...@@ -146,6 +146,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -146,6 +146,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
146 const inputs = yield* Queue.unbounded<TestThreadInput>();146 const inputs = yield* Queue.unbounded<TestThreadInput>();
147 const observed = yield* Queue.unbounded<EnvironmentThreadState>();147 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
148 const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);148 const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);
149 const stateChangeCount = yield* Ref.make(0);
149 const retryCount = yield* Ref.make(0);150 const retryCount = yield* Ref.make(0);
150 const subscriptionCount = yield* Ref.make(0);151 const subscriptionCount = yield* Ref.make(0);
151 const loaderCalls = yield* Ref.make(0);152 const loaderCalls = yield* Ref.make(0);
...@@ -157,11 +158,19 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -157,11 +158,19 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
157 const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(158 const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(
158 AVAILABLE_CONNECTION_STATE,159 AVAILABLE_CONNECTION_STATE,
159 );160 );
161 // Preserve queued event batches while failing at the first error.
160 const streamFrom = (queue: Queue.Queue<TestThreadInput>) =>162 const streamFrom = (queue: Queue.Queue<TestThreadInput>) =>
161 Stream.fromQueue(queue).pipe(163 Stream.fromQueue(queue).pipe(
162 Stream.mapEffect((input) =>164 Stream.chunks,
163 input instanceof Error ? Effect.fail(input) : Effect.succeed(input),165 Stream.flatMap((chunk) => {
164 ),166 const errorIndex = chunk.findIndex((input) => input instanceof Error);
167 if (errorIndex === -1) {
168 return Stream.fromArray(chunk as ReadonlyArray<OrchestrationThreadStreamItem>);
169 }
170 const prefix = chunk.slice(0, errorIndex) as ReadonlyArray<OrchestrationThreadStreamItem>;
171 const failure = Stream.fail(chunk[errorIndex] as Error);
172 return prefix.length === 0 ? failure : Stream.concat(Stream.fromArray(prefix), failure);
173 }),
165 );174 );
166 const client = {175 const client = {
167 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: {176 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: {
...@@ -244,7 +253,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -244,7 +253,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
244 );253 );
245 yield* SubscriptionRef.changes(threadState).pipe(254 yield* SubscriptionRef.changes(threadState).pipe(
246 Stream.runForEach((state) =>255 Stream.runForEach((state) =>
247 Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))),256 Ref.update(stateChangeCount, (count) => count + 1).pipe(
257 Effect.andThen(Ref.set(latest, state)),
258 Effect.andThen(Queue.offer(observed, state)),
259 ),
248 ),260 ),
249 Effect.forkScoped,261 Effect.forkScoped,
250 );262 );
...@@ -254,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -254,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
254 inputs,266 inputs,
255 observed,267 observed,
256 latest,268 latest,
269 stateChangeCount,
257 retryCount,270 retryCount,
258 subscriptionCount,271 subscriptionCount,
259 loaderCalls,272 loaderCalls,
...@@ -307,6 +320,38 @@ const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamIte...@@ -307,6 +320,38 @@ const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamIte
307 },320 },
308});321});
309322
323const sessionSet = (
324 status: "ready" | "running",
325 turnId: string,
326 sequence: number,
327): OrchestrationThreadStreamItem => ({
328 kind: "event",
329 event: {
330 eventId: EventId.make(`event-session-${status}-${sequence}`),
331 sequence,
332 occurredAt: "2026-04-01T03:00:00.000Z",
333 commandId: null,
334 causationEventId: null,
335 correlationId: null,
336 metadata: {},
337 aggregateKind: "thread",
338 aggregateId: THREAD_ID,
339 type: "thread.session-set",
340 payload: {
341 threadId: THREAD_ID,
342 session: {
343 threadId: THREAD_ID,
344 status,
345 providerName: "codex",
346 runtimeMode: "full-access",
347 activeTurnId: status === "running" ? TurnId.make(turnId) : null,
348 lastError: null,
349 updatedAt: "2026-04-01T03:00:00.000Z",
350 },
351 },
352 },
353});
354
310const deleted = (): OrchestrationThreadStreamItem => ({355const deleted = (): OrchestrationThreadStreamItem => ({
311 kind: "event",356 kind: "event",
312 event: {357 event: {
...@@ -989,4 +1034,36 @@ describe("EnvironmentThreads", () => {...@@ -989,4 +1034,36 @@ describe("EnvironmentThreads", () => {
989 expect(yield* Ref.get(harness.subscriptionCount)).toBe(3);1034 expect(yield* Ref.get(harness.subscriptionCount)).toBe(3);
990 }),1035 }),
991 );1036 );
1037
1038 it.effect(
1039 "persists a turn that settles mid-batch when the next turn starts in the same batch",
1040 () =>
1041 Effect.gen(function* () {
1042 const harness = yield* makeHarness({ cached: ACTIVE_THREAD });
1043 yield* awaitThreadState(harness.observed, (value) => value.status === "live");
1044 const before = yield* Ref.get(harness.stateChangeCount);
1045
1046 // Both events arrive in one transport batch: the session settles and the
1047 // next turn starts before the fold publishes.
1048 yield* Queue.offerAll(harness.inputs, [
1049 sessionSet("ready", "turn-1", CACHED_SNAPSHOT_SEQUENCE + 1),
1050 sessionSet("running", "turn-2", CACHED_SNAPSHOT_SEQUENCE + 2),
1051 ]);
1052 yield* awaitThreadState(
1053 harness.observed,
1054 (value) =>
1055 Option.isSome(value.data) &&
1056 value.data.value.session?.activeTurnId === TurnId.make("turn-2"),
1057 );
1058 expect((yield* Ref.get(harness.stateChangeCount)) - before).toBe(1);
1059 yield* TestClock.adjust("500 millis");
1060 yield* Effect.yieldNow;
1061
1062 // The settled state reached the cache under its own sequence even
1063 // though the batch ended on a running session.
1064 const saved = (yield* Ref.get(harness.savedThreads)).at(-1);
1065 expect(saved?.thread.session?.status).toBe("ready");
1066 expect(saved?.snapshotSequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1);
1067 }),
1068 );
992});1069});
packages/client-runtime/src/state/threads.ts+37-19
...@@ -353,6 +353,29 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -353,6 +353,29 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
353 ),353 ),
354 );354 );
355355
356 const offerThreadPersistence = Effect.fn("EnvironmentThreadState.offerThreadPersistence")(
357 function* (thread: OrchestrationThread, snapshotSequence: number) {
358 const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page));
359 yield* Queue.offer(persistence, {
360 snapshotSequence,
361 thread,
362 // Persist the window boundary with the window's content so a cache
363 // restore can keep paging from where the loaded history ends.
364 ...Option.match(currentPage, {
365 onNone: () => ({}),
366 onSome: (value) =>
367 ({
368 page: {
369 beforeCursor: value.beforeCursor,
370 hasMore: value.hasMore,
371 snapshotSequence,
372 },
373 }) as const,
374 }),
375 });
376 },
377 );
378
356 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (379 const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* (
357 thread: OrchestrationThread,380 thread: OrchestrationThread,
358 // "keep" preserves the current page state (live events touch only loaded381 // "keep" preserves the current page state (live events touch only loaded
...@@ -376,24 +399,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -376,24 +399,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
376 // persist once it settles so cache encoding stays off the streaming path.399 // persist once it settles so cache encoding stays off the streaming path.
377 if (shouldPersistThread(thread)) {400 if (shouldPersistThread(thread)) {
378 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);401 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
379 const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page));402 yield* offerThreadPersistence(thread, snapshotSequence);
380 yield* Queue.offer(persistence, {
381 snapshotSequence,
382 thread,
383 // Persist the window boundary with the window's content so a cache
384 // restore can keep paging from where the loaded history ends.
385 ...Option.match(currentPage, {
386 onNone: () => ({}),
387 onSome: (value) =>
388 ({
389 page: {
390 beforeCursor: value.beforeCursor,
391 hasMore: value.hasMore,
392 snapshotSequence,
393 },
394 }) as const,
395 }),
396 });
397 }403 }
398 });404 });
399405
...@@ -441,6 +447,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -441,6 +447,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
441 // in the preserved history with no event left to remove it. The447 // in the preserved history with no event left to remove it. The
442 // epoch bump discards any older-page fetch racing this snapshot.448 // epoch bump discards any older-page fetch racing this snapshot.
443 yield* Ref.update(historyEpoch, (epoch) => epoch + 1);449 yield* Ref.update(historyEpoch, (epoch) => epoch + 1);
450 // A parked response must not clear loadingOlder on a request started
451 // from the replacement snapshot's cursor.
452 yield* Ref.set(pendingOlderPage, null);
444 yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence);453 yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence);
445 yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page));454 yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page));
446 return;455 return;
...@@ -539,17 +548,26 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -539,17 +548,26 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
539 let thread = current.data.value;548 let thread = current.data.value;
540 let sequence = yield* SubscriptionRef.get(lastSequence);549 let sequence = yield* SubscriptionRef.get(lastSequence);
541 let synchronized = false;550 let synchronized = false;
551 // Retain the last settled state even if the next turn starts before
552 // this batch publishes. Its cursor must describe that settled content.
553 let persistable: { thread: OrchestrationThread; sequence: number } | undefined;
542 for (const item of items) {554 for (const item of items) {
543 if (item.kind === "synchronized") {555 if (item.kind === "synchronized") {
544 synchronized = true;556 synchronized = true;
545 } else if (item.kind === "event" && item.event.sequence > sequence) {557 } else if (item.kind === "event" && item.event.sequence > sequence) {
546 sequence = item.event.sequence;558 sequence = item.event.sequence;
547 const result = applyThreadDetailEvent(thread, item.event);559 const result = applyThreadDetailEvent(thread, item.event);
548 if (result.kind === "updated") thread = result.thread;560 if (result.kind === "updated") {
561 thread = result.thread;
562 if (shouldPersistThread(thread)) persistable = { thread, sequence };
563 }
549 }564 }
550 }565 }
551 yield* SubscriptionRef.set(lastSequence, sequence);566 yield* SubscriptionRef.set(lastSequence, sequence);
552 if (thread !== current.data.value) yield* setThread(thread, "keep");567 if (thread !== current.data.value) yield* setThread(thread, "keep");
568 if (persistable !== undefined && !shouldPersistThread(thread)) {
569 yield* offerThreadPersistence(persistable.thread, persistable.sequence);
570 }
553 if (synchronized) yield* applyItemLocked({ kind: "synchronized" });571 if (synchronized) yield* applyItemLocked({ kind: "synchronized" });
554 yield* remember;572 yield* remember;
555 }),573 }),