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,365 @@ 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 ( "ungated in-flight sibling does not rebase when a later gate settles" , 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 : { name : "grep" , callId : "sibling" , arguments : "needle" } ,
3197+ } ) ;
3198+ const siblingIndex = streamRowCount ( shell ) - 1 ;
3199+ const siblingStat = ( ) =>
3200+ defined ( shell . streamLog [ siblingIndex ] , "sibling row" ) . stat ;
3201+
3202+ nowMs = 60_000 ;
3203+ tick ?.( ) ;
3204+ await h . renderOnce ( ) ;
3205+ expect ( siblingStat ( ) ) . toBe ( "1:00" ) ;
3206+
3207+ bridge . handle ( {
3208+ type : "inference.tool_call.end" ,
3209+ data : { name : "run_shell" , callId : "gated" , arguments : "sleep 30" } ,
3210+ } ) ;
3211+ const gatedIndex = streamRowCount ( shell ) - 1 ;
3212+ const gatedStat = ( ) =>
3213+ defined ( shell . streamLog [ gatedIndex ] , "gated row" ) . stat ;
3214+
3215+ bridge . gateOpened ( ) ;
3216+ bridge . gateClosed ( ) ;
3217+ await h . renderOnce ( ) ;
3218+ expect ( siblingStat ( ) ) . toBe ( "1:00" ) ;
3219+ expect ( gatedStat ( ) ) . toBe ( "0:00" ) ;
3220+
3221+ nowMs = 65_000 ;
3222+ tick ?.( ) ;
3223+ await h . renderOnce ( ) ;
3224+ expect ( siblingStat ( ) ) . toBe ( "1:05" ) ;
3225+ expect ( gatedStat ( ) ) . toBe ( "0:05" ) ;
3226+ } finally {
3227+ bridge . dispose ( ) ;
3228+ shell . dispose ( ) ;
3229+ }
3230+ } ,
3231+ { width : 80 , height : 24 } ,
3232+ ) ;
3233+ } ) ;
3234+
3235+ test ( "diff rows keep their +/- stat through a gate cycle" , async ( ) => {
3236+ await withTestRenderer (
3237+ async ( h ) => {
3238+ const shell = createAppShell ( h . renderer , {
3239+ terminal : { columns : 80 , rows : 24 } ,
3240+ wireKeys : false ,
3241+ run : "busy" ,
3242+ } ) ;
3243+ let nowMs = 0 ;
3244+ let tick : ( ( ) => void ) | undefined ;
3245+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3246+ now : ( ) => nowMs ,
3247+ schedule : ( fn ) => {
3248+ tick = fn ;
3249+ return ( ) => {
3250+ tick = undefined ;
3251+ } ;
3252+ } ,
3253+ } ) ;
3254+ try {
3255+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3256+ bridge . handle ( {
3257+ type : "inference.tool_call.end" ,
3258+ data : {
3259+ name : "write_file" ,
3260+ callId : "c1" ,
3261+ arguments : JSON . stringify ( { path : "a.txt" , content : "hi\n" } ) ,
3262+ } ,
3263+ } ) ;
3264+ const index = streamRowCount ( shell ) - 1 ;
3265+ const before = defined ( shell . streamLog [ index ] , "diff row" ) . stat ;
3266+ expect ( before ) . toContain ( "+" ) ;
3267+
3268+ bridge . gateOpened ( ) ;
3269+ nowMs = 120_000 ;
3270+ tick ?.( ) ;
3271+ await h . renderOnce ( ) ;
3272+ bridge . gateClosed ( ) ;
3273+ nowMs = 125_000 ;
3274+ tick ?.( ) ;
3275+ await h . renderOnce ( ) ;
3276+ expect ( defined ( shell . streamLog [ index ] , "diff row" ) . stat ) . toBe ( before ) ;
3277+ } finally {
3278+ bridge . dispose ( ) ;
3279+ shell . dispose ( ) ;
3280+ }
3281+ } ,
3282+ { width : 80 , height : 24 } ,
3283+ ) ;
3284+ } ) ;
3285+
3286+ test ( "spawn_agent rows keep their session clock through a gate cycle" , async ( ) => {
3287+ await withTestRenderer (
3288+ async ( h ) => {
3289+ const shell = createAppShell ( h . renderer , {
3290+ terminal : { columns : 80 , rows : 24 } ,
3291+ wireKeys : false ,
3292+ run : "busy" ,
3293+ } ) ;
3294+ let nowMs = 0 ;
3295+ const bridge = attachSessionBridge ( shell , createRecordingPort ( ) , {
3296+ now : ( ) => nowMs ,
3297+ } ) ;
3298+ try {
3299+ bridge . handle ( { type : "inference.start" , data : { } } ) ;
3300+ bridge . handle ( {
3301+ type : "inference.tool_call.end" ,
3302+ data : {
3303+ name : "spawn_agent" ,
3304+ callId : "task-1" ,
3305+ arguments : { description : "Review permission gate" } ,
3306+ } ,
3307+ } ) ;
3308+ const index = streamRowCount ( shell ) - 1 ;
3309+ nowMs = 120_000 ;
3310+ bridge . syncAgentProgress ( [
3311+ {
3312+ id : "task-1" ,
3313+ status : "running" ,
3314+ currentToolName : "grep" ,
3315+ currentToolPreview : null ,
3316+ currentToolStartedAt : null ,
3317+ startedAt : 0 ,
3318+ lastActivityAt : nowMs ,
3319+ } ,
3320+ ] ) ;
3321+ await h . renderOnce ( ) ;
3322+ const before = defined ( shell . streamLog [ index ] , "progress row" ) . stat ;
3323+
3324+ bridge . gateOpened ( ) ;
3325+ bridge . gateClosed ( ) ;
3326+ await h . renderOnce ( ) ;
3327+ expect ( defined ( shell . streamLog [ index ] , "progress row" ) . stat ) . toBe (
3328+ before ,
3329+ ) ;
3330+ } finally {
3331+ bridge . dispose ( ) ;
3332+ shell . dispose ( ) ;
3333+ }
3334+ } ,
3335+ { width : 80 , height : 24 } ,
3336+ ) ;
3337+ } ) ;
3338+ } ) ;
3339+
29763340describe ( "task checklist calls stay out of the transcript" , ( ) => {
29773341 test ( "a manage_tasks call and its result paint no rows" , async ( ) => {
29783342 await withTestRenderer (
0 commit comments