@@ -469,6 +469,92 @@ describe("mockChatAgent", () => {
469469 }
470470 } ) ;
471471
472+ it ( "does not downgrade a hydrated `output-denied` when a stale `approval-responded` arrives" , async ( ) => {
473+ // Terminal hydrated states (`output-available` / `output-error` /
474+ // `output-denied`) are authoritative. A wire copy that re-ships a
475+ // prior `approval-responded` (replay, retry, out-of-order arrival)
476+ // must NOT regress the terminal denial back to a pre-resolution
477+ // state — the agent would then re-run the tool.
478+ const { z } = await import ( "zod" ) ;
479+ const { tool } = await import ( "ai" ) ;
480+ const deleteTool = tool ( {
481+ description : "Delete." ,
482+ inputSchema : z . object ( { resource : z . string ( ) } ) ,
483+ } ) ;
484+
485+ const TC = "tc_denied_no_regress" ;
486+ const HEAD_ID = "a-head-denied" ;
487+
488+ const dbAssistant = {
489+ id : HEAD_ID ,
490+ role : "assistant" as const ,
491+ parts : [
492+ {
493+ type : "tool-delete" as const ,
494+ toolCallId : TC ,
495+ state : "output-denied" as const ,
496+ input : { resource : "/critical/data" } ,
497+ approval : { id : "appr_1" , approved : false , reason : "no" } ,
498+ } ,
499+ ] ,
500+ } ;
501+
502+ const model = new MockLanguageModelV3 ( {
503+ doStream : async ( ) => ( { stream : textStream ( "ok" ) } ) ,
504+ } ) ;
505+
506+ let mergedToolPart : any ;
507+ const agent = chat . agent ( {
508+ id : "mockChatAgent.denied-no-regress" ,
509+ hydrateMessages : async ( ) => [ dbAssistant as any ] ,
510+ onTurnComplete : async ( { uiMessages } ) => {
511+ const head = uiMessages . find ( ( m : any ) => m . id === HEAD_ID ) ;
512+ mergedToolPart = ( head ?. parts ?? [ ] ) . find (
513+ ( p : any ) => p ?. toolCallId === TC
514+ ) ;
515+ } ,
516+ run : async ( { messages, signal } ) => {
517+ return streamText ( {
518+ model,
519+ messages,
520+ tools : { delete : deleteTool } ,
521+ abortSignal : signal ,
522+ } ) ;
523+ } ,
524+ } ) ;
525+
526+ const harness = mockChatAgent ( agent , { chatId : "test-denied-no-regress" } ) ;
527+ try {
528+ // Stale wire arrival — `approval-responded` for a tool that
529+ // hydrated already shows as `output-denied`.
530+ const stale = {
531+ id : HEAD_ID ,
532+ role : "assistant" as const ,
533+ parts : [
534+ {
535+ type : "tool-delete" as const ,
536+ toolCallId : TC ,
537+ state : "approval-responded" as const ,
538+ approval : { id : "appr_1" , approved : true } ,
539+ } ,
540+ ] ,
541+ } ;
542+ await harness . sendMessage ( stale as any ) ;
543+ await new Promise ( ( r ) => setTimeout ( r , 50 ) ) ;
544+
545+ // The hydrated terminal state survives the merge.
546+ expect ( mergedToolPart ?. state ) . toBe ( "output-denied" ) ;
547+ expect ( mergedToolPart ?. approval ) . toEqual ( {
548+ id : "appr_1" ,
549+ approved : false ,
550+ reason : "no" ,
551+ } ) ;
552+ expect ( mergedToolPart ?. input ) . toEqual ( { resource : "/critical/data" } ) ;
553+ } finally {
554+ await harness . close ( ) ;
555+ }
556+ } ) ;
557+
472558 it ( "onValidateMessages sees the slim wire on HITL continuations — fresh-user filter still works" , async ( ) => {
473559 // Slim wire is the wire shape on HITL `addToolOutput` continuations.
474560 // Existing customers calling `validateUIMessages` from `ai` on the
@@ -546,7 +632,10 @@ describe("mockChatAgent", () => {
546632 if ( userMessages . length > 0 ) {
547633 await validateUIMessages ( {
548634 messages : userMessages ,
549- tools : { askUser } ,
635+ // `validateUIMessages` is stricter than `streamText` about
636+ // tool input/output variance — cast to satisfy the wider
637+ // `Tool<unknown, unknown>` it expects.
638+ tools : { askUser } as any ,
550639 } ) ;
551640 }
552641 return messages ;
0 commit comments