Skip to content

Commit f3f29ed

Browse files
committed
fix(examples-chat): reliably persist itinerary to checkpoint (retry mid-run 409)
The live gate caught that reload lost the trip: the client-tool resume loop keeps the agent loading for the whole plan, so the run-gated updateState push never fired with the final itinerary and the checkpoint kept the empty list sent at first submit. Drop the isLoading gate and retry the updateState on the 409 a mid-run write returns until the run settles, so the final itinerary always lands. Also guard hydration so a behind/empty server snapshot can't wipe a populated local working copy mid-plan. Verified live: plan → reload restores.
1 parent 6318ac3 commit f3f29ed

2 files changed

Lines changed: 39 additions & 30 deletions

File tree

examples/chat/angular/src/app/shell/demo-shell.component.spec.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -530,20 +530,16 @@ class CapturingTransport extends FakeStreamTransport {
530530
}
531531

532532
describe('shouldSyncCheckpoint — push-gate predicate', () => {
533-
it('pushes when settled, has a thread, and content changed', () => {
534-
expect(shouldSyncCheckpoint(false, 'thread-1', '[1]', '[0]')).toBe(true);
535-
});
536-
537-
it('never pushes while a run is loading (mid-run guard)', () => {
538-
expect(shouldSyncCheckpoint(true, 'thread-1', '[1]', '[0]')).toBe(false);
533+
it('pushes when a thread exists and content changed', () => {
534+
expect(shouldSyncCheckpoint('thread-1', '[1]', '[0]')).toBe(true);
539535
});
540536

541537
it('never pushes without a thread id', () => {
542-
expect(shouldSyncCheckpoint(false, null, '[1]', '[0]')).toBe(false);
538+
expect(shouldSyncCheckpoint(null, '[1]', '[0]')).toBe(false);
543539
});
544540

545541
it('skips when the content already matches lastSynced (echo-loop guard)', () => {
546-
expect(shouldSyncCheckpoint(false, 'thread-1', '[1]', '[1]')).toBe(false);
542+
expect(shouldSyncCheckpoint('thread-1', '[1]', '[1]')).toBe(false);
547543
});
548544
});
549545

examples/chat/angular/src/app/shell/demo-shell.component.ts

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,21 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } {
8383

8484
/**
8585
* Decide whether to push the working itinerary to the durable checkpoint.
86-
* Returns true only when a run has SETTLED (not loading), a thread exists,
87-
* and the content actually changed since the last sync. The `json === lastJson`
88-
* check is the echo-loop guard: hydration stamps `lastSyncedItinerary` with the
89-
* incoming server JSON, so the re-fired push effect sees "no change" and skips —
90-
* making hydrate→push→hydrate converge immediately.
86+
* Returns true when a thread exists and the itinerary changed since the last
87+
* sync. The `json === lastJson` check is the echo-loop guard: hydration stamps
88+
* `lastSyncedItinerary` with the incoming server JSON, so the re-fired push
89+
* effect sees "no change" and skips — making hydrate→push→hydrate converge.
90+
*
91+
* Run-state is intentionally NOT gated. The client-tool resume loop keeps the
92+
* agent loading for the whole plan, so a run-gated push never fires with the
93+
* final itinerary; instead the push attempts anyway and retries on the 409 a
94+
* mid-run `updateState` returns, until the run settles (see the push effect).
9195
*/
9296
export function shouldSyncCheckpoint(
93-
isLoading: boolean,
9497
threadId: string | null,
9598
json: string,
9699
lastJson: string,
97100
): boolean {
98-
if (isLoading) return false;
99101
if (!threadId) return false;
100102
return json !== lastJson;
101103
}
@@ -259,6 +261,11 @@ export class DemoShell {
259261
effect(() => {
260262
const incoming = extractItinerary(this.agent.value());
261263
if (incoming === null) return;
264+
// Don't let a behind/empty server snapshot wipe a populated local working
265+
// copy: during a plan the client tools fill the store before the checkpoint
266+
// catches up via the push below. Adopt an empty server itinerary only when
267+
// the store is itself empty (a genuine thread switch / fresh load).
268+
if (incoming.length === 0 && untracked(() => this.itinerary.stops()).length > 0) return;
262269
const json = JSON.stringify(incoming);
263270
if (json === this.lastSyncedItinerary) return;
264271
this.lastSyncedItinerary = json;
@@ -271,23 +278,29 @@ export class DemoShell {
271278
// threadIdState(). Errors are swallowed — this is a best-effort sync.
272279
effect((onCleanup) => {
273280
const stops = this.itinerary.stops();
274-
const isLoading = this.agent.isLoading();
275281
const tid = threadIdState();
276282
const json = JSON.stringify(stops);
277-
if (!shouldSyncCheckpoint(isLoading, tid, json, this.lastSyncedItinerary)) return;
278-
const timer = setTimeout(() => {
279-
void (async () => {
280-
try {
281-
await this.lgClient.threads.updateState(tid as string, {
282-
values: { itinerary: stops },
283-
});
284-
this.lastSyncedItinerary = json;
285-
} catch {
286-
// best-effort: a failed checkpoint push must not break the UI.
287-
}
288-
})();
289-
}, 500);
290-
onCleanup(() => clearTimeout(timer));
283+
if (!shouldSyncCheckpoint(tid, json, this.lastSyncedItinerary)) return;
284+
// Debounce, then push. A mid-run write returns 409 (the client-tool resume
285+
// loop is streaming); retry until the run settles so the FINAL itinerary
286+
// always lands in the checkpoint. onCleanup cancels a superseded attempt.
287+
let cancelled = false;
288+
const attempt = async (): Promise<void> => {
289+
if (cancelled) return;
290+
try {
291+
await this.lgClient.threads.updateState(tid as string, {
292+
values: { itinerary: stops },
293+
});
294+
this.lastSyncedItinerary = json;
295+
} catch {
296+
if (!cancelled) setTimeout(() => void attempt(), 1500);
297+
}
298+
};
299+
const timer = setTimeout(() => void attempt(), 1200);
300+
onCleanup(() => {
301+
cancelled = true;
302+
clearTimeout(timer);
303+
});
291304
});
292305

293306
if (typeof window !== 'undefined') {

0 commit comments

Comments
 (0)