@@ -334,6 +334,7 @@ async function runCase(
334334) : Promise < {
335335 calls : RecordedCall [ ] ;
336336 answer : string ;
337+ chunks : UIMessageChunk [ ] ;
337338} > {
338339 const calls : RecordedCall [ ] = [ ] ;
339340 const harness = mockChatAgent ( dashboardAgent , {
@@ -347,7 +348,7 @@ async function runCase(
347348 } ) ;
348349 try {
349350 const turn = await harness . sendMessage ( userMessage ( question ) ) ;
350- return { calls, answer : collectText ( turn . chunks ) } ;
351+ return { calls, answer : collectText ( turn . chunks ) , chunks : turn . chunks } ;
351352 } finally {
352353 await harness . close ( ) ;
353354 }
@@ -463,6 +464,51 @@ function describeRenders(renders: InvestigationRender[]): string {
463464 . join ( "\n" ) ;
464465}
465466
467+ // The turn as the user receives it: runs of prose and tool calls, in emission order.
468+ // Ordering assertions need this — `calls` alone can't say what came after the last word.
469+ type TurnPart = { kind : "text" ; text : string } | { kind : "tool" ; tool : string ; input : unknown } ;
470+
471+ function turnParts ( chunks : UIMessageChunk [ ] ) : TurnPart [ ] {
472+ const parts : TurnPart [ ] = [ ] ;
473+ let text = "" ;
474+ const flush = ( ) => {
475+ if ( text . trim ( ) . length > 0 ) parts . push ( { kind : "text" , text } ) ;
476+ text = "" ;
477+ } ;
478+ for ( const chunk of chunks ) {
479+ if ( chunk . type === "text-delta" ) text += chunk . delta ;
480+ else if ( chunk . type === "tool-input-available" ) {
481+ flush ( ) ;
482+ parts . push ( { kind : "tool" , tool : chunk . toolName , input : chunk . input } ) ;
483+ }
484+ }
485+ flush ( ) ;
486+ return parts ;
487+ }
488+
489+ // The offer's button: an "actions" block whose intent opens the watch card.
490+ function watchButtons ( input : unknown ) : Array < { label ?: string } > {
491+ const blocks =
492+ (
493+ input as {
494+ blocks ?: Array < {
495+ type ?: string ;
496+ actions ?: Array < { label ?: string ; intent ?: { kind ?: string } } > ;
497+ } > ;
498+ }
499+ ) . blocks ?? [ ] ;
500+ return blocks
501+ . filter ( ( b ) => b ?. type === "actions" )
502+ . flatMap ( ( b ) => b . actions ?? [ ] )
503+ . filter ( ( a ) => a ?. intent ?. kind === "watch" ) ;
504+ }
505+
506+ function watchOfferParts ( parts : TurnPart [ ] ) : TurnPart [ ] {
507+ return parts . filter (
508+ ( p ) => p . kind === "tool" && p . tool === "render_view" && watchButtons ( p . input ) . length > 0
509+ ) ;
510+ }
511+
466512function describeCalls ( calls : RecordedCall [ ] ) : string {
467513 if ( calls . length === 0 ) return " (no tool calls)" ;
468514 return calls
@@ -876,6 +922,154 @@ const TRUNCATED_FIXTURES: Record<string, unknown> = {
876922 } ,
877923} ;
878924
925+ // One unresolved, recurring error, and nothing else wrong: the headline the watch
926+ // offer is written for.
927+ const RECURRING_ERROR_FIXTURES : Record < string , unknown > = {
928+ list_errors : {
929+ errors : [
930+ {
931+ id : "error_stripe" ,
932+ taskIdentifier : "send-receipt" ,
933+ errorType : "TimeoutError" ,
934+ errorMessage : "Stripe API timed out after 30s" ,
935+ status : "unresolved" ,
936+ count : 37 ,
937+ } ,
938+ ] ,
939+ nextCursor : undefined ,
940+ } ,
941+ get_report : {
942+ title : "health" ,
943+ scope : "prod" ,
944+ period : "last 1h" ,
945+ generatedAt : "2026-01-01T00:00:00.000Z" ,
946+ windowMinutes : 60 ,
947+ summary : {
948+ severity : "ok" ,
949+ statements : [
950+ { findingType : "flow" , severity : "ok" } ,
951+ { findingType : "execution" , severity : "ok" } ,
952+ { findingType : "liveness" , severity : "ok" } ,
953+ ] ,
954+ } ,
955+ findings : [
956+ { type : "flow" , severity : "ok" , reason : "healthy" , metricIds : [ ] } ,
957+ { type : "execution" , severity : "ok" , reason : "healthy" , metricIds : [ ] } ,
958+ { type : "liveness" , severity : "ok" , reason : "fresh" , metricIds : [ "liveness" ] } ,
959+ ] ,
960+ metrics : [ { id : "pending" , value : 3 , unit : "count" , severity : "ok" } ] ,
961+ facts : { trustworthy : true } ,
962+ footer : [ ] ,
963+ } ,
964+ } ;
965+
966+ // A degraded report, so the report card already carries its own "Watch recovery".
967+ const WARN_REPORT_FIXTURES : Record < string , unknown > = {
968+ list_errors : { errors : [ ] , nextCursor : undefined } ,
969+ get_report : {
970+ title : "health" ,
971+ scope : "prod" ,
972+ period : "last 1h" ,
973+ baselineLabel : "vs your 7d normal" ,
974+ generatedAt : "2026-01-01T00:00:00.000Z" ,
975+ windowMinutes : 60 ,
976+ summary : {
977+ severity : "warn" ,
978+ statements : [
979+ { findingType : "flow" , severity : "warn" } ,
980+ { findingType : "execution" , severity : "ok" } ,
981+ { findingType : "liveness" , severity : "ok" } ,
982+ ] ,
983+ } ,
984+ findings : [
985+ {
986+ type : "flow" ,
987+ severity : "warn" ,
988+ reason : "queue_backlog_growing" ,
989+ read : "backlog_building" ,
990+ metricIds : [ "pending" , "start_latency_p95" ] ,
991+ attribution : { dim : "queue" , key : "task/send-receipt" , share : 0.74 , of : "pending" } ,
992+ } ,
993+ { type : "execution" , severity : "ok" , reason : "healthy" , metricIds : [ ] } ,
994+ { type : "liveness" , severity : "ok" , reason : "fresh" , metricIds : [ "liveness" ] } ,
995+ ] ,
996+ metrics : [
997+ {
998+ id : "start_latency_p95" ,
999+ value : 9000 ,
1000+ unit : "ms" ,
1001+ aggregation : "p95" ,
1002+ normal : 900 ,
1003+ severity : "warn" ,
1004+ } ,
1005+ { id : "pending" , value : 640 , unit : "count" , severity : "warn" } ,
1006+ { id : "concurrency" , value : 32 , unit : "count" , breakdown : { limit : 50 } , severity : "ok" } ,
1007+ ] ,
1008+ facts : {
1009+ trustworthy : true ,
1010+ flowSource : "queue_metrics_v1" ,
1011+ pendingEstimated : false ,
1012+ flowEvidence : { envLimit : 50 , throttledShare : 0.12 , dlqDelta : 0 } ,
1013+ } ,
1014+ footer : [ ] ,
1015+ } ,
1016+ get_queue : {
1017+ queue : "task/send-receipt" ,
1018+ period : "1h" ,
1019+ waitMs : { p50 : 3000 , p95 : 9000 } ,
1020+ peakQueued : 640 ,
1021+ startedCount : 4100 ,
1022+ startedPerMin : 68 ,
1023+ throttledCount : 120 ,
1024+ bucketIntervalMs : 300000 ,
1025+ depthTrend : [ 40 , 120 , 300 , 480 , 640 ] ,
1026+ } ,
1027+ } ;
1028+
1029+ describe . skipIf ( ! HAS_KEY ) ( "dashboardAgent watch offer evals (real model)" , ( ) => {
1030+ it (
1031+ "recurring error: one watch offer, its line last, its button after it" ,
1032+ ( ) =>
1033+ goldenCase ( async ( ) => {
1034+ const question = "What's broken?" ;
1035+ const { calls, answer, chunks } = await runCase ( question , {
1036+ fixtures : RECURRING_ERROR_FIXTURES ,
1037+ } ) ;
1038+ const parts = turnParts ( chunks ) ;
1039+ report ( "watch offer" , calls , answer ) ;
1040+
1041+ const offers = watchOfferParts ( parts ) ;
1042+ expect ( offers ) . toHaveLength ( 1 ) ;
1043+ const offer = offers [ 0 ] ! ;
1044+ expect ( offer . kind === "tool" ? watchButtons ( offer . input ) : [ ] ) . toHaveLength ( 1 ) ;
1045+ // Nothing after the button, and the line before it is the offer question.
1046+ expect ( parts [ parts . length - 1 ] ) . toBe ( offer ) ;
1047+ const line = parts [ parts . length - 2 ] ;
1048+ expect ( line ?. kind ) . toBe ( "text" ) ;
1049+ const text = line ?. kind === "text" ? line . text . trim ( ) : "" ;
1050+ expect ( text ) . toMatch ( / w a t c h / i) ;
1051+ expect ( text ) . toMatch ( / \? $ / ) ;
1052+ } ) ,
1053+ 420_000
1054+ ) ;
1055+
1056+ it (
1057+ "degraded report: the report card is the offer, so no second watch button" ,
1058+ ( ) =>
1059+ goldenCase ( async ( ) => {
1060+ const question = "How is prod doing?" ;
1061+ const { calls, answer, chunks } = await runCase ( question , {
1062+ fixtures : WARN_REPORT_FIXTURES ,
1063+ } ) ;
1064+ report ( "no duplicate watch offer" , calls , answer ) ;
1065+
1066+ expect ( calls . some ( ( c ) => c . tool === "get_report" ) ) . toBe ( true ) ;
1067+ expect ( watchOfferParts ( turnParts ( chunks ) ) ) . toHaveLength ( 0 ) ;
1068+ } ) ,
1069+ 420_000
1070+ ) ;
1071+ } ) ;
1072+
8791073describe . skipIf ( ! HAS_KEY ) ( "dashboardAgent investigation evals (real model)" , ( ) => {
8801074 it (
8811075 "env-limit saturation: concludes on flow/config, not code" ,
0 commit comments