11import { describe , expect , spyOn , test } from "bun:test" ;
2+ import { EventEmitter } from "node:events" ;
23import { mailboxMailWakeLine } from "../subagent/mailbox-mail-drive.js" ;
4+ import type { PermissionRequest } from "../permission/types.js" ;
35import { defined } from "../../tests/helpers/defined.js" ;
46import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js" ;
57import { buildShellBackgroundMessage } from "../session/runtime-assembly.js" ;
@@ -13,10 +15,13 @@ import {
1315import { DEFAULT_STALL_MS } from "./agent-progress" ;
1416import { appendStreamRow , paintChrome } from "./shell/chrome" ;
1517import { createAppShell } from "./shell/index" ;
16- import { getShellBridgeHooks } from "./shell/internals" ;
18+ import { getShellBridgeHooks , type AppShell } from "./shell/internals" ;
1719import { streamRowCount } from "./shell/transcript" ;
1820import { STEER_WAIT_NOTICE_MS } from "./notice-line" ;
1921import { withTestRenderer } from "./harness" ;
22+ import { wireGates } from "./gate-wire.js" ;
23+ import { acceptOverlaySelection } from "./shell/overlay-host.js" ;
24+ import { moveOverlaySelection } from "./shell/overlay-list.js" ;
2025import { badgeCount } from "./delivery-queue" ;
2126import { LIVE_ACTIVITY_WORDS } from "./chrome-state" ;
2227
@@ -2973,6 +2978,303 @@ describe("in-flight tool row elapsed time", () => {
29732978 } ) ;
29742979} ) ;
29752980
2981+ describe ( "CL-7802 gated tool elapsed starts at grant" , ( ) => {
2982+ function acceptOnce ( shell : AppShell ) : void {
2983+ moveOverlaySelection ( shell , 1 ) ;
2984+ acceptOverlaySelection ( shell ) ;
2985+ }
2986+
2987+ function destructiveRequest ( subject : string ) : PermissionRequest {
2988+ return {
2989+ tool : "run_shell" ,
2990+ action : "Run shell command" ,
2991+ subject,
2992+ scopes : [ ] ,
2993+ } ;
2994+ }
2995+
2996+ test ( "R1 hidden-gate grant: post-grant stat reads time-since-grant, not time-since-announce" , async ( ) => {
2997+ await withTestRenderer (
2998+ async ( h ) => {
2999+ const shell = createAppShell ( h . renderer , {
3000+ terminal : { columns : 80 , rows : 24 } ,
3001+ wireKeys : false ,
3002+ run : "busy" ,
3003+ } ) ;
3004+ let nowMs = 0 ;
3005+ let tick : ( ( ) => void ) | undefined ;
3006+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3007+ now : ( ) => nowMs ,
3008+ schedule : ( fn ) => {
3009+ tick = fn ;
3010+ return ( ) => {
3011+ tick = undefined ;
3012+ } ;
3013+ } ,
3014+ } ) ;
3015+ try {
3016+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3017+ bridge . handle ( {
3018+ type : "inference.tool_call.end" ,
3019+ data : { name : "run_shell" , callId : "c1" , arguments : "sleep 30" } ,
3020+ } ) ;
3021+ const index = streamRowCount ( shell ) - 1 ;
3022+ const stat = ( ) => defined ( shell . streamLog [ index ] , "tool row" ) . stat ;
3023+
3024+ bridge . gateOpened ( ) ;
3025+ nowMs = 120_000 ;
3026+ tick ?.( ) ;
3027+ await h . renderOnce ( ) ;
3028+ expect ( stat ( ) ) . toBeUndefined ( ) ;
3029+
3030+ bridge . gateClosed ( ) ;
3031+ await h . renderOnce ( ) ;
3032+ expect ( stat ( ) ) . toBe ( "0:00" ) ;
3033+
3034+ nowMs = 125_000 ;
3035+ tick ?.( ) ;
3036+ await h . renderOnce ( ) ;
3037+ expect ( stat ( ) ) . toBe ( "0:05" ) ;
3038+ } finally {
3039+ bridge . dispose ( ) ;
3040+ shell . dispose ( ) ;
3041+ }
3042+ } ,
3043+ { width : 80 , height : 24 } ,
3044+ ) ;
3045+ } ) ;
3046+
3047+ test ( "R2 gated deny: final row carries the answer stat, no m:ss leftover" , async ( ) => {
3048+ await withTestRenderer (
3049+ async ( h ) => {
3050+ const shell = createAppShell ( h . renderer , {
3051+ terminal : { columns : 80 , rows : 24 } ,
3052+ wireKeys : false ,
3053+ run : "busy" ,
3054+ } ) ;
3055+ let nowMs = 0 ;
3056+ let tick : ( ( ) => void ) | undefined ;
3057+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3058+ now : ( ) => nowMs ,
3059+ schedule : ( fn ) => {
3060+ tick = fn ;
3061+ return ( ) => {
3062+ tick = undefined ;
3063+ } ;
3064+ } ,
3065+ } ) ;
3066+ try {
3067+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3068+ bridge . handle ( {
3069+ type : "inference.tool_call.end" ,
3070+ data : { name : "run_shell" , callId : "c1" , arguments : "sleep 30" } ,
3071+ } ) ;
3072+ const index = streamRowCount ( shell ) - 1 ;
3073+
3074+ bridge . gateOpened ( ) ;
3075+ nowMs = 120_000 ;
3076+ tick ?.( ) ;
3077+ await h . renderOnce ( ) ;
3078+ bridge . gateClosed ( ) ;
3079+ bridge . handle ( {
3080+ type : "tool.done" ,
3081+ data : {
3082+ result : {
3083+ callId : "c1" ,
3084+ name : "run_shell" ,
3085+ content : "Denied by operator" ,
3086+ isError : true ,
3087+ } ,
3088+ } ,
3089+ } ) ;
3090+ await h . renderOnce ( ) ;
3091+ expect (
3092+ defined ( shell . streamLog [ index ] , "tool row" ) . stat ?? "" ,
3093+ ) . not . toMatch ( / ^ \d + : \d \d $ / ) ;
3094+ } finally {
3095+ bridge . dispose ( ) ;
3096+ shell . dispose ( ) ;
3097+ }
3098+ } ,
3099+ { width : 80 , height : 24 } ,
3100+ ) ;
3101+ } ) ;
3102+
3103+ test ( "R3 shown-gate grant: the wait clock rebases to time-since-grant" , async ( ) => {
3104+ await withTestRenderer (
3105+ async ( h ) => {
3106+ const shell = createAppShell ( h . renderer , {
3107+ terminal : { columns : 80 , rows : 24 } ,
3108+ wireKeys : false ,
3109+ run : "busy" ,
3110+ } ) ;
3111+ const emitter = new EventEmitter ( ) ;
3112+ const disposeGates = wireGates ( emitter , shell ) ;
3113+ let nowMs = 0 ;
3114+ let tick : ( ( ) => void ) | undefined ;
3115+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3116+ now : ( ) => nowMs ,
3117+ schedule : ( fn ) => {
3118+ tick = fn ;
3119+ return ( ) => {
3120+ tick = undefined ;
3121+ } ;
3122+ } ,
3123+ } ) ;
3124+ try {
3125+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3126+ bridge . handle ( {
3127+ type : "inference.tool_call.end" ,
3128+ data : {
3129+ name : "run_shell" ,
3130+ callId : "c1" ,
3131+ arguments : "rm -rf /tmp/cl7802" ,
3132+ } ,
3133+ } ) ;
3134+ const index = streamRowCount ( shell ) - 1 ;
3135+ const stat = ( ) => defined ( shell . streamLog [ index ] , "tool row" ) . stat ;
3136+
3137+ bridge . gateOpened ( ) ;
3138+ let resolved : unknown ;
3139+ emitter . emit ( "permission.gate" , {
3140+ id : "req-g" ,
3141+ request : destructiveRequest ( "rm -rf /tmp/cl7802" ) ,
3142+ resolve : ( outcome : unknown ) => {
3143+ resolved = outcome ;
3144+ } ,
3145+ } ) ;
3146+ expect ( shell . overlayKind ) . toBe ( "permissions" ) ;
3147+
3148+ nowMs = 120_000 ;
3149+ tick ?.( ) ;
3150+ await h . renderOnce ( ) ;
3151+ expect ( stat ( ) ) . toBe ( "2:00" ) ;
3152+
3153+ acceptOnce ( shell ) ;
3154+ expect ( resolved ) . toEqual ( { allow : true } ) ;
3155+ bridge . gateClosed ( ) ;
3156+ await h . renderOnce ( ) ;
3157+ expect ( stat ( ) ) . toBe ( "0:00" ) ;
3158+
3159+ nowMs = 125_000 ;
3160+ tick ?.( ) ;
3161+ await h . renderOnce ( ) ;
3162+ expect ( stat ( ) ) . toBe ( "0:05" ) ;
3163+ } finally {
3164+ bridge . dispose ( ) ;
3165+ disposeGates ( ) ;
3166+ shell . dispose ( ) ;
3167+ }
3168+ } ,
3169+ { width : 80 , height : 24 } ,
3170+ ) ;
3171+ } ) ;
3172+
3173+ test ( "diff rows keep their +/- stat through a gate cycle" , async ( ) => {
3174+ await withTestRenderer (
3175+ async ( h ) => {
3176+ const shell = createAppShell ( h . renderer , {
3177+ terminal : { columns : 80 , rows : 24 } ,
3178+ wireKeys : false ,
3179+ run : "busy" ,
3180+ } ) ;
3181+ let nowMs = 0 ;
3182+ let tick : ( ( ) => void ) | undefined ;
3183+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3184+ now : ( ) => nowMs ,
3185+ schedule : ( fn ) => {
3186+ tick = fn ;
3187+ return ( ) => {
3188+ tick = undefined ;
3189+ } ;
3190+ } ,
3191+ } ) ;
3192+ try {
3193+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3194+ bridge . handle ( {
3195+ type : "inference.tool_call.end" ,
3196+ data : {
3197+ name : "write_file" ,
3198+ callId : "c1" ,
3199+ arguments : JSON . stringify ( { path : "a.txt" , content : "hi\n" } ) ,
3200+ } ,
3201+ } ) ;
3202+ const index = streamRowCount ( shell ) - 1 ;
3203+ const before = defined ( shell . streamLog [ index ] , "diff row" ) . stat ;
3204+ expect ( before ) . toContain ( "+" ) ;
3205+
3206+ bridge . gateOpened ( ) ;
3207+ nowMs = 120_000 ;
3208+ tick ?.( ) ;
3209+ await h . renderOnce ( ) ;
3210+ bridge . gateClosed ( ) ;
3211+ nowMs = 125_000 ;
3212+ tick ?.( ) ;
3213+ await h . renderOnce ( ) ;
3214+ expect ( defined ( shell . streamLog [ index ] , "diff row" ) . stat ) . toBe ( before ) ;
3215+ } finally {
3216+ bridge . dispose ( ) ;
3217+ shell . dispose ( ) ;
3218+ }
3219+ } ,
3220+ { width : 80 , height : 24 } ,
3221+ ) ;
3222+ } ) ;
3223+
3224+ test ( "spawn_agent rows keep their session clock through a gate cycle" , async ( ) => {
3225+ await withTestRenderer (
3226+ async ( h ) => {
3227+ const shell = createAppShell ( h . renderer , {
3228+ terminal : { columns : 80 , rows : 24 } ,
3229+ wireKeys : false ,
3230+ run : "busy" ,
3231+ } ) ;
3232+ let nowMs = 0 ;
3233+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3234+ now : ( ) => nowMs ,
3235+ } ) ;
3236+ try {
3237+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3238+ bridge . handle ( {
3239+ type : "inference.tool_call.end" ,
3240+ data : {
3241+ name : "spawn_agent" ,
3242+ callId : "task-1" ,
3243+ arguments : { description : "Review permission gate" } ,
3244+ } ,
3245+ } ) ;
3246+ const index = streamRowCount ( shell ) - 1 ;
3247+ nowMs = 120_000 ;
3248+ bridge . syncAgentProgress ( [
3249+ {
3250+ id : "task-1" ,
3251+ status : "running" ,
3252+ currentToolName : "grep" ,
3253+ currentToolPreview : null ,
3254+ currentToolStartedAt : null ,
3255+ startedAt : 0 ,
3256+ lastActivityAt : nowMs ,
3257+ } ,
3258+ ] ) ;
3259+ await h . renderOnce ( ) ;
3260+ const before = defined ( shell . streamLog [ index ] , "progress row" ) . stat ;
3261+
3262+ bridge . gateOpened ( ) ;
3263+ bridge . gateClosed ( ) ;
3264+ await h . renderOnce ( ) ;
3265+ expect ( defined ( shell . streamLog [ index ] , "progress row" ) . stat ) . toBe (
3266+ before ,
3267+ ) ;
3268+ } finally {
3269+ bridge . dispose ( ) ;
3270+ shell . dispose ( ) ;
3271+ }
3272+ } ,
3273+ { width : 80 , height : 24 } ,
3274+ ) ;
3275+ } ) ;
3276+ } ) ;
3277+
29763278describe ( "task checklist calls stay out of the transcript" , ( ) => {
29773279 test ( "a manage_tasks call and its result paint no rows" , async ( ) => {
29783280 await withTestRenderer (
0 commit comments