Skip to content

Commit d7596ff

Browse files
CL-7802: start gated tool elapsed at grant, not announcement (#1176)
* test(tui): cover gated tool elapsed starting at grant * fix(tui): start gated tool elapsed at grant, not announcement * fix(tui): keep ungated sibling elapsed through an unrelated grant gateOpened snapshotted every timed call into the rebase set, so an auto-allowed sibling that was already executing reset to 0:00 when a later gate settled. Skip ids that already carry a live elapsed clock.
1 parent 9920046 commit d7596ff

2 files changed

Lines changed: 441 additions & 1 deletion

File tree

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 365 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, spyOn, test } from "bun:test";
2+
import { EventEmitter } from "node:events";
23
import { mailboxMailWakeLine } from "../subagent/mailbox-mail-drive.js";
4+
import type { PermissionRequest } from "../permission/types.js";
35
import { defined } from "../../tests/helpers/defined.js";
46
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
57
import { buildShellBackgroundMessage } from "../session/runtime-assembly.js";
@@ -13,10 +15,13 @@ import {
1315
import { DEFAULT_STALL_MS } from "./agent-progress";
1416
import { appendStreamRow, paintChrome } from "./shell/chrome";
1517
import { createAppShell } from "./shell/index";
16-
import { getShellBridgeHooks } from "./shell/internals";
18+
import { getShellBridgeHooks, type AppShell } from "./shell/internals";
1719
import { streamRowCount } from "./shell/transcript";
1820
import { STEER_WAIT_NOTICE_MS } from "./notice-line";
1921
import { 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";
2025
import { badgeCount } from "./delivery-queue";
2126
import { 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+
29763340
describe("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

Comments
 (0)