@@ -2,8 +2,16 @@ import { signal } from '@angular/core';
22import { describe , it , expect , beforeEach } from 'vitest' ;
33import { TestBed } from '@angular/core/testing' ;
44import { 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
816function 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+ } ) ;
0 commit comments