@@ -564,6 +564,63 @@ test("context injects a boundary nudge below 30% after an explicit topic change"
564564 assert . match ( result . messages [ 1 ] . content , / N o t e b o o k t o p i c c h a n g e d f r o m o a u t h t o b i l l i n g / ) ;
565565} ) ;
566566
567+
568+ test ( "context injects a no-topic nudge when context is high" , async ( ) => {
569+ const pi = new MockPi ( ) ;
570+ registerAgenticoding ( pi as any ) ;
571+ const [ handler ] = pi . handlers . get ( "context" ) ! ;
572+
573+ const result = await handler (
574+ { messages : [ { role : "user" , content : "hi" , timestamp : 1 } ] } ,
575+ { getContextUsage : ( ) => ( { percent : 70 } ) } ,
576+ ) ;
577+
578+ assert . equal ( result . messages . length , 2 ) ;
579+ assert . equal ( result . messages [ 1 ] . role , "custom" ) ;
580+ assert . equal ( result . messages [ 1 ] . customType , "agenticoding-watchdog" ) ;
581+ assert . equal ( result . messages [ 1 ] . display , false ) ;
582+ assert . match ( result . messages [ 1 ] . content , / N o a c t i v e n o t e b o o k t o p i c i s s e t / ) ;
583+ assert . match ( result . messages [ 1 ] . content , / A s s i g n a f r e s h t o p i c i n t h e n e x t c l e a n c o n t e x t a f t e r h a n d o f f / i) ;
584+ } ) ;
585+
586+
587+ test ( "context consumes a boundary hint after the first injected nudge" , async ( ) => {
588+ const pi = new MockPi ( ) ;
589+ registerAgenticoding ( pi as any ) ;
590+ const [ handler ] = pi . handlers . get ( "context" ) ! ;
591+ await pi . commands . get ( "notebook" ) ! . handler ( "oauth" , { hasUI : false , getContextUsage : ( ) => null } ) ;
592+ await pi . commands . get ( "notebook" ) ! . handler ( "billing" , { hasUI : false , getContextUsage : ( ) => null } ) ;
593+
594+ const first = await handler (
595+ { messages : [ { role : "user" , content : "hi" , timestamp : 1 } ] } ,
596+ { getContextUsage : ( ) => ( { percent : 20 } ) } ,
597+ ) ;
598+ assert . match ( first . messages [ 1 ] . content , / N o t e b o o k t o p i c c h a n g e d f r o m o a u t h t o b i l l i n g / ) ;
599+
600+ const second = await handler (
601+ { messages : [ { role : "user" , content : "hi" , timestamp : 2 } ] } ,
602+ { getContextUsage : ( ) => ( { percent : 20 } ) } ,
603+ ) ;
604+ assert . equal ( second , undefined ) ;
605+ } ) ;
606+
607+
608+ test ( "buildNudge handles null percent and boundary hints before topic guidance" , ( ) => {
609+ const boundary = buildNudge (
610+ {
611+ activeNotebookTopic : "oauth" ,
612+ pendingTopicBoundaryHint : { from : "oauth" , to : "billing" , source : "human" } ,
613+ } ,
614+ null ,
615+ ) ;
616+ assert . match ( boundary , / N o t e b o o k t o p i c c h a n g e d f r o m o a u t h t o b i l l i n g / ) ;
617+ assert . doesNotMatch ( boundary , / A c t i v e n o t e b o o k t o p i c : o a u t h / ) ;
618+
619+ const noTopic = buildNudge ( { activeNotebookTopic : null , pendingTopicBoundaryHint : null } , null ) ;
620+ assert . match ( noTopic , / T o p i c - a w a r e c o n t e x t r e m i n d e r / ) ;
621+ assert . match ( noTopic , / N o a c t i v e n o t e b o o k t o p i c i s s e t / ) ;
622+ } ) ;
623+
567624test ( "watchdog stays advisory when a requested handoff is not completed" , async ( ) => {
568625 const pi = new MockPi ( ) ;
569626 const state = createState ( ) ;
@@ -2089,6 +2146,34 @@ test("/notebook exits cleanly when headless", async () => {
20892146 await assert . doesNotReject ( ( ) => pi . commands . get ( "notebook" ) ! . handler ( "" , { hasUI : false } ) ) ;
20902147} ) ;
20912148
2149+
2150+ test ( "/notebook <topic> notifies with info on first set and warning on boundary change" , async ( ) => {
2151+ const pi = new MockPi ( ) ;
2152+ registerAgenticoding ( pi as any ) ;
2153+ const notifications : Array < { message : string ; level : string } > = [ ] ;
2154+ const statuses = new Map < string , string | undefined > ( ) ;
2155+ const widgets = new Map < string , string [ ] | undefined > ( ) ;
2156+ const ctx = {
2157+ hasUI : true ,
2158+ getContextUsage : ( ) => ( { percent : 20 } ) ,
2159+ ui : {
2160+ theme : { fg : ( _name : string , text : string ) => text } ,
2161+ notify : ( message : string , level : string ) => { notifications . push ( { message, level } ) ; } ,
2162+ setStatus : ( key : string , status : string | undefined ) => { statuses . set ( key , status ) ; } ,
2163+ setWidget : ( key : string , content : string [ ] | undefined ) => { widgets . set ( key , content ) ; } ,
2164+ } ,
2165+ } ;
2166+
2167+ await pi . commands . get ( "notebook" ) ! . handler ( "oauth" , ctx as any ) ;
2168+ await pi . commands . get ( "notebook" ) ! . handler ( "billing" , ctx as any ) ;
2169+
2170+ assert . deepEqual ( notifications [ 0 ] , { message : "Active notebook topic: oauth" , level : "info" } ) ;
2171+ assert . match ( notifications [ 1 ] . message , / A c t i v e n o t e b o o k t o p i c c h a n g e d : o a u t h → b i l l i n g / ) ;
2172+ assert . equal ( notifications [ 1 ] . level , "warning" ) ;
2173+ assert . equal ( statuses . get ( STATUS_KEY_TOPIC ) , "🧭 billing" ) ;
2174+ assert . equal ( widgets . get ( WIDGET_KEY_WARNING ) , undefined ) ;
2175+ } ) ;
2176+
20922177test ( "/notebook empty overlay renders empty state and closes on input" , async ( ) => {
20932178 const pi = new MockPi ( ) ;
20942179 registerAgenticoding ( pi as any ) ;
@@ -3146,7 +3231,7 @@ test("topic helpers manage the active notebook topic lifecycle", () => {
31463231 assert . equal ( state . pendingTopicBoundaryHint , null ) ;
31473232} ) ;
31483233
3149- test ( "notebook_topic_set establishes a fresh topic and refuses overrides" , async ( ) => {
3234+ test ( "notebook_topic_set establishes a fresh topic, is idempotent, and refuses overrides" , async ( ) => {
31503235 const pi = new MockPi ( ) ;
31513236 const state = createState ( ) ;
31523237 registerNotebookTopicTool ( pi as any , state ) ;
@@ -3157,7 +3242,39 @@ test("notebook_topic_set establishes a fresh topic and refuses overrides", async
31573242 assert . equal ( state . activeNotebookTopic , "oauth" ) ;
31583243 assert . equal ( state . activeNotebookTopicSource , "agent" ) ;
31593244
3160- await assert . rejects ( ( ) => tool . execute ( "2" , { topic : "billing" } ) , / a l r e a d y e x i s t s / ) ;
3245+ const second = await tool . execute ( "2" , { topic : "oauth" } ) ;
3246+ assert . equal ( second . details . changed , false ) ;
3247+ assert . equal ( second . details . source , "agent" ) ;
3248+ assert . match ( second . content [ 0 ] . text , / a l r e a d y s e t t o " o a u t h " / i) ;
3249+
3250+ await assert . rejects ( ( ) => tool . execute ( "3" , { topic : "billing" } ) , / a l r e a d y e x i s t s / ) ;
3251+ } ) ;
3252+
3253+
3254+ test ( "notebook_topic_set preserves human authority, stays idempotent for equal topics, and rejects empty normalized topics" , async ( ) => {
3255+ const pi = new MockPi ( ) ;
3256+ const state = createState ( ) ;
3257+ registerNotebookTopicTool ( pi as any , state ) ;
3258+ const tool = pi . tools . get ( "notebook_topic_set" ) ;
3259+
3260+ setActiveNotebookTopic ( state , "oauth" , "human" ) ;
3261+ const same = await tool . execute ( "1" , { topic : "OAuth" } ) ;
3262+ assert . equal ( same . details . changed , false ) ;
3263+ assert . equal ( same . details . source , "human" ) ;
3264+ assert . match ( same . content [ 0 ] . text , / a l r e a d y s e t t o " o a u t h " / i) ;
3265+ await assert . rejects (
3266+ ( ) => tool . execute ( "2" , { topic : "billing" } ) ,
3267+ / h u m a n - s e t n o t e b o o k t o p i c i s a u t h o r i t a t i v e / i,
3268+ ) ;
3269+
3270+ const freshPi = new MockPi ( ) ;
3271+ const freshState = createState ( ) ;
3272+ registerNotebookTopicTool ( freshPi as any , freshState ) ;
3273+ const freshTool = freshPi . tools . get ( "notebook_topic_set" ) ;
3274+ await assert . rejects (
3275+ ( ) => freshTool . execute ( "3" , { topic : "@@@" } ) ,
3276+ / n o t e b o o k t o p i c c a n n o t b e e m p t y / i,
3277+ ) ;
31613278} ) ;
31623279
31633280test ( "buildNudge no longer emits the old percent-only handoff text" , ( ) => {
@@ -3167,41 +3284,38 @@ test("buildNudge no longer emits the old percent-only handoff text", () => {
31673284 assert . match ( old , / p r e f e r s p a w n / i) ;
31683285} ) ;
31693286
3170- test ( "CONTEXT_PRIMER frames the notebook as durable grounding and handoff as direction" , ( ) => {
3171- // No stale "ledger" references
3287+
3288+ test ( "CONTEXT_PRIMER states the notebook, topic, and handoff contracts" , ( ) => {
31723289 assert . doesNotMatch ( CONTEXT_PRIMER , / l e d g e r / i,
31733290 "CONTEXT_PRIMER should contain zero stale ledger references after the rename" ) ;
31743291
3175- // Structural: section headers exist
3176- // Structural: section headers exist
3177- assert . match ( CONTEXT_PRIMER , / # # # N o t e b o o k / ) ;
3178- assert . match ( CONTEXT_PRIMER , / # # # A c t i v e n o t e b o o k t o p i c / ) ;
3179- assert . match ( CONTEXT_PRIMER , / # # # H a n d o f f / ) ;
3180- assert . match ( CONTEXT_PRIMER , / # # # R u l e s / ) ;
3181-
3182- // Structural: Rules section names the tools it references
3183- const rules = CONTEXT_PRIMER . split ( "### Rules" ) [ 1 ] ;
3184- assert . ok ( rules . includes ( "notebook_index" ) , "Rules should mention notebook_index" ) ;
3185- assert . ok ( rules . includes ( "notebook_read" ) , "Rules should mention notebook_read" ) ;
3186- assert . ok ( rules . includes ( "distilled next task" ) , "Rules should frame handoff as the next task" ) ;
3187-
3188- // Conceptual: Notebook section contains durable grounding concepts
3189- const notebookSection = CONTEXT_PRIMER . split ( "### Notebook" ) [ 1 ] . split ( "### Active notebook topic" ) [ 0 ] . toLowerCase ( ) ;
3190- for ( const concept of [ "future contexts" , "subject" , "architecture" , "constraints" , "verified facts" ] ) {
3191- assert . ok ( notebookSection . includes ( concept ) , `Notebook section should mention "${ concept } "` ) ;
3192- }
3292+ const notebookParts = CONTEXT_PRIMER . split ( "### Notebook" ) ;
3293+ const topicParts = CONTEXT_PRIMER . split ( "### Active notebook topic" ) ;
3294+ const handoffParts = CONTEXT_PRIMER . split ( "### Handoff" ) ;
3295+ const rulesParts = CONTEXT_PRIMER . split ( "### Rules" ) ;
3296+ assert . equal ( notebookParts . length , 2 ) ;
3297+ assert . equal ( topicParts . length , 2 ) ;
3298+ assert . equal ( handoffParts . length , 2 ) ;
3299+ assert . equal ( rulesParts . length , 2 ) ;
31933300
3194- const topicSection = CONTEXT_PRIMER . split ( "### Active notebook topic" ) [ 1 ] . split ( "### Handoff" ) [ 0 ] . toLowerCase ( ) ;
3195- assert . ok ( topicSection . includes ( "semantic frame" ) , "Topic section should mention semantic frame" ) ;
3196- assert . ok ( topicSection . includes ( "prefer spawn" ) , "Topic section should bias spawn inside a topic" ) ;
3197- assert . ok ( topicSection . includes ( "prefer handoff" ) , "Topic section should bias handoff across topics" ) ;
3301+ const notebookSection = notebookParts [ 1 ] . split ( "### Active notebook topic" ) [ 0 ] ;
3302+ const topicSection = topicParts [ 1 ] . split ( "### Handoff" ) [ 0 ] ;
3303+ const handoffSection = handoffParts [ 1 ] . split ( "### Rules" ) [ 0 ] ;
3304+ const rulesSection = rulesParts [ 1 ] ;
31983305
3199- const handoffSection = CONTEXT_PRIMER . split ( "### Handoff" ) [ 1 ] . split ( "### Rules" ) [ 0 ] . toLowerCase ( ) ;
3200- assert . ok ( handoffSection . includes ( "situational context" ) , "Handoff should mention situational context" ) ;
3201- assert . ok ( handoffSection . includes ( "do not duplicate" ) , "Handoff should avoid duplicating notebook content" ) ;
3306+ assert . match ( notebookSection , / n o t e b o o k _ i n d e x / ) ;
3307+ assert . match ( notebookSection , / n o t e b o o k _ r e a d / ) ;
3308+ assert . match ( notebookSection , / f u t u r e c o n t e x t s / i) ;
3309+ assert . match ( topicSection , / s e m a n t i c f r a m e / i) ;
3310+ assert . match ( topicSection , / p r e f e r s p a w n / i) ;
3311+ assert . match ( topicSection , / p r e f e r h a n d o f f / i) ;
3312+ assert . match ( handoffSection , / h a n d o f f / i) ;
3313+ assert . match ( handoffSection , / n o t e b o o k / i) ;
3314+ assert . match ( rulesSection , / o n e s u b j e c t , t h r e a d , o r s u b s y s t e m / i) ;
32023315} ) ;
32033316
3204- test ( "before_agent_start injects the notebook primer and live notebook pages" , async ( ) => {
3317+
3318+ test ( "before_agent_start injects notebook contracts plus live topic and page data" , async ( ) => {
32053319 const pi = new MockPi ( ) ;
32063320 registerAgenticoding ( pi as any ) ;
32073321 await pi . commands . get ( "notebook" ) ! . handler ( "oauth" , { hasUI : false , getContextUsage : ( ) => null } ) ;
@@ -3216,10 +3330,21 @@ test("before_agent_start injects the notebook primer and live notebook pages", a
32163330 assert . match ( result . systemPrompt , / # # A c t i v e N o t e b o o k T o p i c / ) ;
32173331 assert . match ( result . systemPrompt , / C u r r e n t t o p i c : ` o a u t h ` / ) ;
32183332 assert . match ( result . systemPrompt , / # # A c t i v e N o t e b o o k P a g e s / ) ;
3219- assert . match ( result . systemPrompt , / T h e f o l l o w i n g p a g e s a r e a v a i l a b l e v i a n o t e b o o k _ r e a d b y n a m e : / ) ;
3220- assert . ok ( result . systemPrompt . includes ( "Reference pages by name" ) , "should reference pages by name" ) ;
3333+ assert . match ( result . systemPrompt , / n o t e b o o k _ r e a d / ) ;
3334+ assert . match ( result . systemPrompt , / R e f e r e n c e p a g e s b y n a m e / i ) ;
32213335 assert . match ( result . systemPrompt , / a l p h a : f i r s t l i n e / ) ;
3222- assert . ok ( result . systemPrompt . includes ( CONTEXT_PRIMER ) , "system prompt should include CONTEXT_PRIMER verbatim" ) ;
3336+ } ) ;
3337+
3338+
3339+ test ( "before_agent_start injects no-topic guidance when the topic is unset" , async ( ) => {
3340+ const pi = new MockPi ( ) ;
3341+ registerAgenticoding ( pi as any ) ;
3342+ const [ handler ] = pi . handlers . get ( "before_agent_start" ) ! ;
3343+ const result = await handler ( { systemPrompt : "Base system prompt." } , makeTUICtx ( { hasUI : false } ) ) ;
3344+
3345+ assert . match ( result . systemPrompt , / # # A c t i v e N o t e b o o k T o p i c / ) ;
3346+ assert . match ( result . systemPrompt , / N o a c t i v e n o t e b o o k t o p i c i s s e t \. / ) ;
3347+ assert . match ( result . systemPrompt , / n o t e b o o k _ t o p i c _ s e t / ) ;
32233348} ) ;
32243349
32253350test ( "notebook tool definitions omit prompt hints by default" , ( ) => {
0 commit comments