@@ -3,6 +3,7 @@ import os from "node:os";
33import path from "node:path" ;
44import type { AgentMessage } from "@earendil-works/pi-agent-core" ;
55import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
6+ import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../../../auto-reply/heartbeat.js" ;
67import type { OpenClawConfig } from "../../../config/types.js" ;
78import { buildMemorySystemPromptAddition } from "../../../context-engine/delegate.js" ;
89import {
@@ -392,6 +393,294 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
392393 }
393394 } ) ;
394395
396+ it ( "filters heartbeat response-tool transcript artifacts before normal prompt snapshots" , async ( ) => {
397+ const contextEngine = createContextEngineBootstrapAndAssemble ( ) ;
398+ const sessionMessages = [
399+ { role : "user" , content : HEARTBEAT_TRANSCRIPT_PROMPT , timestamp : 1 } ,
400+ {
401+ role : "assistant" ,
402+ content : [
403+ {
404+ type : "toolCall" ,
405+ id : "call_bash" ,
406+ name : "bash" ,
407+ arguments : { command : "cat HEARTBEAT.md" } ,
408+ } ,
409+ ] ,
410+ timestamp : 2 ,
411+ } ,
412+ {
413+ role : "toolResult" ,
414+ toolCallId : "call_bash" ,
415+ content : [ { type : "text" , text : "HEARTBEAT.md says stay quiet" } ] ,
416+ timestamp : 3 ,
417+ } ,
418+ {
419+ role : "assistant" ,
420+ content : [
421+ {
422+ type : "toolCall" ,
423+ id : "call_heartbeat" ,
424+ name : "heartbeat_respond" ,
425+ arguments : {
426+ outcome : "no_change" ,
427+ notify : false ,
428+ summary : "No visible update." ,
429+ } ,
430+ } ,
431+ ] ,
432+ timestamp : 4 ,
433+ } ,
434+ {
435+ role : "toolResult" ,
436+ toolCallId : "call_heartbeat" ,
437+ content : [ { type : "text" , text : '{"notify":false}' } ] ,
438+ timestamp : 5 ,
439+ } ,
440+ { role : "assistant" , content : "No visible update. notify=false" , timestamp : 6 } ,
441+ ] as AgentMessage [ ] ;
442+
443+ const result = await createContextEngineAttemptRunner ( {
444+ contextEngine,
445+ sessionKey,
446+ tempPaths,
447+ sessionMessages,
448+ attemptOverrides : {
449+ prompt : "what model are you" ,
450+ transcriptPrompt : "what model are you" ,
451+ } ,
452+ sessionPrompt : async ( session ) => {
453+ session . messages = [
454+ ...session . messages ,
455+ { role : "assistant" , content : "gpt-test" , timestamp : 7 } ,
456+ ] ;
457+ } ,
458+ } ) ;
459+
460+ const assembleInput = contextEngine . assemble . mock . calls . at ( 0 ) ?. [ 0 ] ;
461+ const assembledMessagesJson = JSON . stringify ( assembleInput ?. messages ?? [ ] ) ;
462+ const snapshotJson = JSON . stringify ( result . messagesSnapshot ) ;
463+ for ( const artifact of [
464+ "HEARTBEAT.md" ,
465+ "heartbeat_respond" ,
466+ "notify=false" ,
467+ '"notify":false' ,
468+ HEARTBEAT_TRANSCRIPT_PROMPT ,
469+ ] ) {
470+ expect ( assembledMessagesJson ) . not . toContain ( artifact ) ;
471+ expect ( snapshotJson ) . not . toContain ( artifact ) ;
472+ }
473+ expect ( result . finalPromptText ) . toBe ( "what model are you" ) ;
474+ } ) ;
475+
476+ it ( "filters interrupted prompt-only heartbeat artifacts before normal prompt snapshots" , async ( ) => {
477+ const contextEngine = createContextEngineBootstrapAndAssemble ( ) ;
478+ const sessionMessages = [
479+ { role : "user" , content : HEARTBEAT_TRANSCRIPT_PROMPT , timestamp : 1 } ,
480+ ] as AgentMessage [ ] ;
481+
482+ const result = await createContextEngineAttemptRunner ( {
483+ contextEngine,
484+ sessionKey,
485+ tempPaths,
486+ sessionMessages,
487+ attemptOverrides : {
488+ prompt : "what model are you" ,
489+ transcriptPrompt : "what model are you" ,
490+ } ,
491+ sessionPrompt : async ( session ) => {
492+ session . messages = [
493+ ...session . messages ,
494+ { role : "assistant" , content : "gpt-test" , timestamp : 2 } ,
495+ ] ;
496+ } ,
497+ } ) ;
498+
499+ const assembleInput = contextEngine . assemble . mock . calls . at ( 0 ) ?. [ 0 ] ;
500+ const assembledMessagesJson = JSON . stringify ( assembleInput ?. messages ?? [ ] ) ;
501+ const snapshotJson = JSON . stringify ( result . messagesSnapshot ) ;
502+ expect ( assembledMessagesJson ) . not . toContain ( HEARTBEAT_TRANSCRIPT_PROMPT ) ;
503+ expect ( snapshotJson ) . not . toContain ( HEARTBEAT_TRANSCRIPT_PROMPT ) ;
504+ expect ( result . finalPromptText ) . toBe ( "what model are you" ) ;
505+ } ) ;
506+
507+ it ( "filters pending notify=true heartbeat response-tool calls before normal prompt snapshots" , async ( ) => {
508+ const contextEngine = createContextEngineBootstrapAndAssemble ( ) ;
509+ const sessionMessages = [
510+ { role : "user" , content : HEARTBEAT_TRANSCRIPT_PROMPT , timestamp : 1 } ,
511+ {
512+ role : "assistant" ,
513+ content : [
514+ {
515+ type : "toolCall" ,
516+ id : "call_heartbeat" ,
517+ name : "heartbeat_respond" ,
518+ arguments : {
519+ outcome : "needs_attention" ,
520+ notify : true ,
521+ summary : "Build is blocked." ,
522+ notificationText : "Build is blocked on missing credentials." ,
523+ } ,
524+ } ,
525+ ] ,
526+ timestamp : 2 ,
527+ } ,
528+ ] as AgentMessage [ ] ;
529+
530+ const result = await createContextEngineAttemptRunner ( {
531+ contextEngine,
532+ sessionKey,
533+ tempPaths,
534+ sessionMessages,
535+ attemptOverrides : {
536+ prompt : "what model are you" ,
537+ transcriptPrompt : "what model are you" ,
538+ } ,
539+ sessionPrompt : async ( session ) => {
540+ session . messages = [
541+ ...session . messages ,
542+ { role : "assistant" , content : "gpt-test" , timestamp : 3 } ,
543+ ] ;
544+ } ,
545+ } ) ;
546+
547+ const assembleInput = contextEngine . assemble . mock . calls . at ( 0 ) ?. [ 0 ] ;
548+ const assembledMessagesJson = JSON . stringify ( assembleInput ?. messages ?? [ ] ) ;
549+ const snapshotJson = JSON . stringify ( result . messagesSnapshot ) ;
550+ for ( const artifact of [
551+ HEARTBEAT_TRANSCRIPT_PROMPT ,
552+ "heartbeat_respond" ,
553+ '"notify":true' ,
554+ "Build is blocked on missing credentials." ,
555+ ] ) {
556+ expect ( assembledMessagesJson ) . not . toContain ( artifact ) ;
557+ expect ( snapshotJson ) . not . toContain ( artifact ) ;
558+ }
559+ expect ( result . finalPromptText ) . toBe ( "what model are you" ) ;
560+ } ) ;
561+
562+ it ( "preserves visible heartbeat alerts in normal prompt snapshots" , async ( ) => {
563+ const contextEngine = createContextEngineBootstrapAndAssemble ( ) ;
564+ const sessionMessages = [
565+ { role : "user" , content : HEARTBEAT_TRANSCRIPT_PROMPT , timestamp : 1 } ,
566+ {
567+ role : "assistant" ,
568+ content : [
569+ {
570+ type : "toolCall" ,
571+ id : "call_bash" ,
572+ name : "bash" ,
573+ arguments : { command : "cat HEARTBEAT.md" } ,
574+ } ,
575+ ] ,
576+ timestamp : 2 ,
577+ } ,
578+ {
579+ role : "toolResult" ,
580+ toolCallId : "call_bash" ,
581+ content : [ { type : "text" , text : "HEARTBEAT.md says check deployment" } ] ,
582+ timestamp : 3 ,
583+ } ,
584+ {
585+ role : "assistant" ,
586+ content : "Build is blocked on a failing release check." ,
587+ timestamp : 4 ,
588+ } ,
589+ ] as AgentMessage [ ] ;
590+
591+ const result = await createContextEngineAttemptRunner ( {
592+ contextEngine,
593+ sessionKey,
594+ tempPaths,
595+ sessionMessages,
596+ attemptOverrides : {
597+ prompt : "what changed while I was away?" ,
598+ transcriptPrompt : "what changed while I was away?" ,
599+ } ,
600+ sessionPrompt : async ( session ) => {
601+ session . messages = [
602+ ...session . messages ,
603+ { role : "assistant" , content : "gpt-test" , timestamp : 5 } ,
604+ ] ;
605+ } ,
606+ } ) ;
607+
608+ const assembleInput = contextEngine . assemble . mock . calls . at ( 0 ) ?. [ 0 ] ;
609+ const assembledMessagesJson = JSON . stringify ( assembleInput ?. messages ?? [ ] ) ;
610+ const snapshotJson = JSON . stringify ( result . messagesSnapshot ) ;
611+ for ( const visibleContext of [
612+ HEARTBEAT_TRANSCRIPT_PROMPT ,
613+ "HEARTBEAT.md says check deployment" ,
614+ "Build is blocked on a failing release check." ,
615+ ] ) {
616+ expect ( assembledMessagesJson ) . toContain ( visibleContext ) ;
617+ expect ( snapshotJson ) . toContain ( visibleContext ) ;
618+ }
619+ expect ( result . finalPromptText ) . toBe ( "what changed while I was away?" ) ;
620+ } ) ;
621+
622+ it ( "preserves visible heartbeat response-tool notifications in normal prompt snapshots" , async ( ) => {
623+ const contextEngine = createContextEngineBootstrapAndAssemble ( ) ;
624+ const sessionMessages = [
625+ { role : "user" , content : HEARTBEAT_TRANSCRIPT_PROMPT , timestamp : 1 } ,
626+ {
627+ role : "assistant" ,
628+ content : [
629+ {
630+ type : "toolCall" ,
631+ id : "call_heartbeat" ,
632+ name : "heartbeat_respond" ,
633+ arguments : {
634+ outcome : "needs_attention" ,
635+ notify : true ,
636+ summary : "Build is blocked." ,
637+ notificationText : "Build is blocked on missing credentials." ,
638+ } ,
639+ } ,
640+ ] ,
641+ timestamp : 2 ,
642+ } ,
643+ {
644+ role : "toolResult" ,
645+ toolCallId : "call_heartbeat" ,
646+ content : [ { type : "text" , text : '{"notify":true}' } ] ,
647+ timestamp : 3 ,
648+ } ,
649+ { role : "assistant" , content : "HEARTBEAT_OK" , timestamp : 4 } ,
650+ ] as AgentMessage [ ] ;
651+
652+ const result = await createContextEngineAttemptRunner ( {
653+ contextEngine,
654+ sessionKey,
655+ tempPaths,
656+ sessionMessages,
657+ attemptOverrides : {
658+ prompt : "what changed while I was away?" ,
659+ transcriptPrompt : "what changed while I was away?" ,
660+ } ,
661+ sessionPrompt : async ( session ) => {
662+ session . messages = [
663+ ...session . messages ,
664+ { role : "assistant" , content : "gpt-test" , timestamp : 5 } ,
665+ ] ;
666+ } ,
667+ } ) ;
668+
669+ const assembleInput = contextEngine . assemble . mock . calls . at ( 0 ) ?. [ 0 ] ;
670+ const assembledMessagesJson = JSON . stringify ( assembleInput ?. messages ?? [ ] ) ;
671+ const snapshotJson = JSON . stringify ( result . messagesSnapshot ) ;
672+ for ( const visibleContext of [
673+ "heartbeat_respond" ,
674+ '"notify":true' ,
675+ "Build is blocked on missing credentials." ,
676+ "HEARTBEAT_OK" ,
677+ ] ) {
678+ expect ( assembledMessagesJson ) . toContain ( visibleContext ) ;
679+ expect ( snapshotJson ) . toContain ( visibleContext ) ;
680+ }
681+ expect ( result . finalPromptText ) . toBe ( "what changed while I was away?" ) ;
682+ } ) ;
683+
395684 it ( "rebuilds skill prompt inputs from the sandbox workspace for non-rw sandbox runs" , async ( ) => {
396685 const sandboxWorkspace = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-sandbox-skills-" ) ) ;
397686 tempPaths . push ( sandboxWorkspace ) ;
0 commit comments