Skip to content

Commit d5bae8a

Browse files
committed
feat(examples-chat): sync itinerary — submit state + value hydration + SDK checkpoint push
1 parent cc95310 commit d5bae8a

3 files changed

Lines changed: 221 additions & 3 deletions

File tree

examples/chat/angular/src/app/app.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser';
55
import { LANGGRAPH_THREADS_CONFIG, LANGGRAPH_CLIENT_OPTIONS } from '@threadplane/langgraph';
66
import { provideChat } from '@threadplane/chat';
77
import { e2eClientOptions } from './shell/e2e-overrides';
8+
import { ItineraryStore } from './itinerary-store';
89
import { routes } from './app.routes';
910
import { environment } from '../environments/environment';
1011

@@ -32,5 +33,9 @@ export const appConfig: ApplicationConfig = {
3233
provideChat({
3334
license: environment.license,
3435
}),
36+
// App-wide singleton so DemoShell, the itinerary panel, and the map cockpit
37+
// all read/write ONE working copy of the itinerary. Provided at root (not at
38+
// the component) so routed children share the same instance.
39+
ItineraryStore,
3540
],
3641
};

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

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ import { signal } from '@angular/core';
22
import { describe, it, expect, beforeEach } from 'vitest';
33
import { TestBed } from '@angular/core/testing';
44
import { provideRouter, Router, NavigationEnd } from '@angular/router';
5-
import { LangGraphThreadsAdapter } from '@threadplane/langgraph';
6-
import { DemoShell } from './demo-shell.component';
5+
import {
6+
LangGraphThreadsAdapter,
7+
LANGGRAPH_THREADS_CONFIG,
8+
provideAgent,
9+
FakeStreamTransport,
10+
type AgentTransport,
11+
} from '@threadplane/langgraph';
12+
import { DemoShell, shouldSyncCheckpoint, extractItinerary } from './demo-shell.component';
13+
import { DEMO_AGENT_REF } from './agent-ref';
14+
import { ItineraryStore, type ItineraryStop } from '../itinerary-store';
715

816
function createThreadsAdapterMock() {
917
const threads = signal([]);
@@ -35,6 +43,7 @@ describe('DemoShell — mode signal', () => {
3543
TestBed.configureTestingModule({
3644
providers: [
3745
threadsAdapterProvider,
46+
ItineraryStore,
3847
provideRouter([
3948
{ path: 'embed', component: DemoShell },
4049
{ path: 'popup', component: DemoShell },
@@ -77,6 +86,7 @@ describe('DemoShell — toolbar layout', () => {
7786
TestBed.configureTestingModule({
7887
providers: [
7988
threadsAdapterProvider,
89+
ItineraryStore,
8090
provideRouter([
8191
{ path: 'embed', component: DemoShell },
8292
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
@@ -93,6 +103,7 @@ describe('DemoShell — toolbar layout', () => {
93103
TestBed.configureTestingModule({
94104
providers: [
95105
threadsAdapterProvider,
106+
ItineraryStore,
96107
provideRouter([
97108
{ path: 'embed', component: DemoShell },
98109
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
@@ -116,6 +127,7 @@ describe('DemoShell — toolbar dropdowns use chat-select', () => {
116127
TestBed.configureTestingModule({
117128
providers: [
118129
threadsAdapterProvider,
130+
ItineraryStore,
119131
provideRouter([
120132
{ path: 'embed', component: DemoShell },
121133
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
@@ -143,6 +155,7 @@ describe('DemoShell — URL thread sync', () => {
143155
TestBed.configureTestingModule({
144156
providers: [
145157
threadsAdapterProvider,
158+
ItineraryStore,
146159
provideRouter([
147160
{ path: 'embed', component: DemoShell },
148161
{ path: 'embed/:threadId', component: DemoShell },
@@ -255,6 +268,7 @@ describe('DemoShell — URL knob hydration', () => {
255268
TestBed.configureTestingModule({
256269
providers: [
257270
threadsAdapterProvider,
271+
ItineraryStore,
258272
provideRouter([
259273
{ path: 'embed', component: DemoShell },
260274
{ path: 'embed/:threadId', component: DemoShell },
@@ -372,3 +386,101 @@ describe('DemoShell — URL knob hydration', () => {
372386
expect(router.url).toContain('model=gpt-5-nano');
373387
});
374388
});
389+
390+
// ── Itinerary ↔ checkpoint sync (Task 9) ────────────────────────────────────
391+
392+
/** A FakeStreamTransport that records the payload of the most recent
393+
* submit so a spec can assert the shell's state injection. */
394+
class CapturingTransport extends FakeStreamTransport {
395+
lastPayload: unknown = undefined;
396+
override async *stream(
397+
assistantId: string,
398+
threadId: string | null,
399+
payload: unknown,
400+
signal: AbortSignal,
401+
options?: Parameters<AgentTransport['stream']>[4],
402+
) {
403+
this.lastPayload = payload;
404+
yield* super.stream(assistantId, threadId, payload, signal, options);
405+
}
406+
}
407+
408+
describe('shouldSyncCheckpoint — push-gate predicate', () => {
409+
it('pushes when settled, has a thread, and content changed', () => {
410+
expect(shouldSyncCheckpoint(false, 'thread-1', '[1]', '[0]')).toBe(true);
411+
});
412+
413+
it('never pushes while a run is loading (mid-run guard)', () => {
414+
expect(shouldSyncCheckpoint(true, 'thread-1', '[1]', '[0]')).toBe(false);
415+
});
416+
417+
it('never pushes without a thread id', () => {
418+
expect(shouldSyncCheckpoint(false, null, '[1]', '[0]')).toBe(false);
419+
});
420+
421+
it('skips when the content already matches lastSynced (echo-loop guard)', () => {
422+
expect(shouldSyncCheckpoint(false, 'thread-1', '[1]', '[1]')).toBe(false);
423+
});
424+
});
425+
426+
describe('extractItinerary — value → stops', () => {
427+
it('returns the itinerary array from a state value', () => {
428+
const stops: ItineraryStop[] = [{ id: 'x', day: 1, place: 'Louvre' }];
429+
expect(extractItinerary({ itinerary: stops })).toEqual(stops);
430+
});
431+
432+
it('returns null when no itinerary present', () => {
433+
expect(extractItinerary({ messages: [] })).toBeNull();
434+
expect(extractItinerary(undefined)).toBeNull();
435+
expect(extractItinerary({ itinerary: 'not-an-array' })).toBeNull();
436+
});
437+
});
438+
439+
describe('DemoShell — submit injects itinerary into state', () => {
440+
beforeEach(() => {
441+
localStorage.clear();
442+
TestBed.configureTestingModule({
443+
providers: [
444+
threadsAdapterProvider,
445+
ItineraryStore,
446+
{ provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://localhost:2024' } },
447+
provideRouter([
448+
{ path: 'embed', component: DemoShell },
449+
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
450+
{ path: '**', redirectTo: 'embed' },
451+
]),
452+
],
453+
});
454+
});
455+
456+
it('forwards store.stops() as state.itinerary on submit', async () => {
457+
const capturing = new CapturingTransport();
458+
const store = new ItineraryStore();
459+
// Override the component-scoped agent so its transport is capturable,
460+
// and share the SAME store instance the shell injects.
461+
TestBed.overrideComponent(DemoShell, {
462+
set: {
463+
providers: [
464+
{ provide: ItineraryStore, useValue: store },
465+
...provideAgent(DEMO_AGENT_REF, {
466+
assistantId: 'fake',
467+
transport: capturing,
468+
}),
469+
],
470+
},
471+
});
472+
473+
const fx = TestBed.createComponent(DemoShell);
474+
fx.detectChanges();
475+
const shell = fx.componentInstance as unknown as {
476+
agent: { submit: (i: unknown) => Promise<void> };
477+
};
478+
479+
store.add(1, 'Louvre');
480+
await shell.agent.submit({ messages: [{ role: 'user', content: 'hi' }] });
481+
482+
const payload = capturing.lastPayload as { itinerary?: ItineraryStop[] };
483+
expect(payload.itinerary).toEqual(store.stops());
484+
expect(payload.itinerary?.[0].place).toBe('Louvre');
485+
});
486+
});

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

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@ import {
1313
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
1414
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
1515
import { filter, map, startWith } from 'rxjs/operators';
16-
import { injectAgent, provideAgent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@threadplane/langgraph';
16+
import {
17+
injectAgent,
18+
provideAgent,
19+
LangGraphThreadsAdapter,
20+
LANGGRAPH_THREADS_CONFIG,
21+
createLangGraphClient,
22+
refreshOnRunEnd,
23+
} from '@threadplane/langgraph';
1724
import { DEMO_AGENT_REF, type DemoState } from './agent-ref';
25+
import { ItineraryStore, type ItineraryStop } from '../itinerary-store';
1826
import { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser';
1927
import {
2028
ChatInterruptPanelComponent,
@@ -65,6 +73,40 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } {
6573
return { mode, threadId };
6674
}
6775

76+
// ── Itinerary ↔ checkpoint sync helpers (Task 9) ────────────────────────────
77+
// Extracted as pure functions so the sync decisions are unit-testable without
78+
// driving the agent's derived `value()`/`isLoading()` signals (which the fake
79+
// transport harness can't emit). The effects below stay thin wrappers.
80+
81+
/**
82+
* Decide whether to push the working itinerary to the durable checkpoint.
83+
* Returns true only when a run has SETTLED (not loading), a thread exists,
84+
* and the content actually changed since the last sync. The `json === lastJson`
85+
* check is the echo-loop guard: hydration stamps `lastSyncedItinerary` with the
86+
* incoming server JSON, so the re-fired push effect sees "no change" and skips —
87+
* making hydrate→push→hydrate converge immediately.
88+
*/
89+
export function shouldSyncCheckpoint(
90+
isLoading: boolean,
91+
threadId: string | null,
92+
json: string,
93+
lastJson: string,
94+
): boolean {
95+
if (isLoading) return false;
96+
if (!threadId) return false;
97+
return json !== lastJson;
98+
}
99+
100+
/** Pull the itinerary array out of a graph-state `value()` snapshot, or null
101+
* when the state has no (well-formed) itinerary to hydrate from. */
102+
export function extractItinerary(value: unknown): ItineraryStop[] | null {
103+
if (value && typeof value === 'object') {
104+
const itin = (value as { itinerary?: unknown }).itinerary;
105+
if (Array.isArray(itin)) return itin as ItineraryStop[];
106+
}
107+
return null;
108+
}
109+
68110
@Component({
69111
selector: 'demo-shell',
70112
standalone: true,
@@ -115,6 +157,21 @@ export class DemoShell {
115157
protected readonly projectsSvc = inject(ProjectsService);
116158
private readonly telemetry = inject(ThreadplaneTelemetryService);
117159

160+
/** Shared working copy of the itinerary — an app-wide singleton (provided in
161+
* app.config.ts) so this shell, the panel, and the map read/write ONE store. */
162+
protected readonly itinerary = inject(ItineraryStore);
163+
164+
/** Out-of-band SDK client used to push the working itinerary to the durable
165+
* checkpoint (`threads.updateState`) between runs. `LANGGRAPH_THREADS_CONFIG`
166+
* is optional so knob/routing unit tests that don't provide it still run. */
167+
private readonly lgClient = createLangGraphClient(
168+
inject(LANGGRAPH_THREADS_CONFIG, { optional: true })?.apiUrl ?? environment.langGraphApiUrl,
169+
);
170+
171+
/** JSON of the last itinerary we synced (either pushed OR hydrated). Breaks
172+
* the hydrate→push→hydrate echo loop — see `shouldSyncCheckpoint`. */
173+
private lastSyncedItinerary = '';
174+
118175
constructor() {
119176
// Reflect the chosen theme onto <html data-theme="..."> so the
120177
// global stylesheet's scoped --a2ui-* overrides activate. Runs on
@@ -180,6 +237,49 @@ export class DemoShell {
180237
// needing a manual thread switch or reload.
181238
refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh());
182239

240+
// ── Itinerary ↔ checkpoint sync (Task 9) ────────────────────────────────
241+
// The checkpoint (per-thread graph state) is the durable record; the
242+
// ItineraryStore is the live working copy. We sync client-authoritatively.
243+
244+
// (2) Hydrate the store from the checkpoint on reload / thread switch.
245+
// Reads the agent's graph-state value(); when it carries an itinerary
246+
// array, replaces the working copy. Stamps lastSyncedItinerary with the
247+
// incoming JSON so the push effect below sees "no change" and skips —
248+
// this is the other half of the echo-loop guard.
249+
effect(() => {
250+
const incoming = extractItinerary(this.agent.value());
251+
if (incoming === null) return;
252+
const json = JSON.stringify(incoming);
253+
if (json === this.lastSyncedItinerary) return;
254+
this.lastSyncedItinerary = json;
255+
this.itinerary.hydrate(incoming);
256+
});
257+
258+
// (3) Push the working copy to the checkpoint at run-settle and on user
259+
// edits between runs. Run-gated (never mid-run) + debounced (~500ms).
260+
// Depends on stops(), isLoading() (so it re-fires when a run SETTLES), and
261+
// threadIdState(). Errors are swallowed — this is a best-effort sync.
262+
effect((onCleanup) => {
263+
const stops = this.itinerary.stops();
264+
const isLoading = this.agent.isLoading();
265+
const tid = threadIdState();
266+
const json = JSON.stringify(stops);
267+
if (!shouldSyncCheckpoint(isLoading, tid, json, this.lastSyncedItinerary)) return;
268+
const timer = setTimeout(() => {
269+
void (async () => {
270+
try {
271+
await this.lgClient.threads.updateState(tid as string, {
272+
values: { itinerary: stops },
273+
});
274+
this.lastSyncedItinerary = json;
275+
} catch {
276+
// best-effort: a failed checkpoint push must not break the UI.
277+
}
278+
})();
279+
}, 500);
280+
onCleanup(() => clearTimeout(timer));
281+
});
282+
183283
if (typeof window !== 'undefined') {
184284
const onResize = () => this.viewportWidth.set(window.innerWidth);
185285
window.addEventListener('resize', onResize);
@@ -415,6 +515,7 @@ export class DemoShell {
415515
model: this.model(),
416516
reasoning_effort: this.effort(),
417517
gen_ui_mode: this.genUiMode(),
518+
itinerary: this.itinerary.stops(),
418519
},
419520
},
420521
opts,

0 commit comments

Comments
 (0)