Skip to content

Commit d728cd6

Browse files
committed
test(tui): cover gated tool elapsed starting at grant
1 parent b167db3 commit d728cd6

1 file changed

Lines changed: 303 additions & 1 deletion

File tree

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

Lines changed: 303 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,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+
29763278
describe("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

Comments
 (0)