mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 10:34:35 -07:00
feat(core): pause agent timeout budget while waiting for tool confirmation (#18415)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { DeadlineTimer } from './deadlineTimer.js';
|
||||
|
||||
describe('DeadlineTimer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should abort when timeout is reached', () => {
|
||||
const timer = new DeadlineTimer(1000);
|
||||
const signal = timer.signal;
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(signal.reason).toBeInstanceOf(Error);
|
||||
expect((signal.reason as Error).message).toBe('Timeout exceeded.');
|
||||
});
|
||||
|
||||
it('should allow extending the deadline', () => {
|
||||
const timer = new DeadlineTimer(1000);
|
||||
const signal = timer.signal;
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
timer.extend(1000); // New deadline is 1000 + 1000 = 2000 from start
|
||||
|
||||
vi.advanceTimersByTime(600); // 1100 total
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(900); // 2000 total
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow pausing and resuming the timer', () => {
|
||||
const timer = new DeadlineTimer(1000);
|
||||
const signal = timer.signal;
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
timer.pause();
|
||||
|
||||
vi.advanceTimersByTime(2000); // Wait a long time while paused
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
timer.resume();
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(200); // Total active time 500 + 400 + 200 = 1100
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should abort immediately when abort() is called', () => {
|
||||
const timer = new DeadlineTimer(1000);
|
||||
const signal = timer.signal;
|
||||
|
||||
timer.abort('cancelled');
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(signal.reason).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('should not fire timeout if aborted manually', () => {
|
||||
const timer = new DeadlineTimer(1000);
|
||||
const signal = timer.signal;
|
||||
|
||||
timer.abort();
|
||||
vi.advanceTimersByTime(1000);
|
||||
// Already aborted, but shouldn't re-abort or throw
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* A utility that manages a timeout and an AbortController, allowing the
|
||||
* timeout to be paused, resumed, and dynamically extended.
|
||||
*/
|
||||
export class DeadlineTimer {
|
||||
private readonly controller: AbortController;
|
||||
private timeoutId: NodeJS.Timeout | null = null;
|
||||
private remainingMs: number;
|
||||
private lastStartedAt: number;
|
||||
private isPaused = false;
|
||||
|
||||
constructor(timeoutMs: number, reason = 'Timeout exceeded.') {
|
||||
this.controller = new AbortController();
|
||||
this.remainingMs = timeoutMs;
|
||||
this.lastStartedAt = Date.now();
|
||||
this.schedule(timeoutMs, reason);
|
||||
}
|
||||
|
||||
/** The AbortSignal managed by this timer. */
|
||||
get signal(): AbortSignal {
|
||||
return this.controller.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the timer, clearing any active timeout.
|
||||
*/
|
||||
pause(): void {
|
||||
if (this.isPaused || this.controller.signal.aborted) return;
|
||||
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - this.lastStartedAt;
|
||||
this.remainingMs = Math.max(0, this.remainingMs - elapsed);
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the timer with the remaining budget.
|
||||
*/
|
||||
resume(reason = 'Timeout exceeded.'): void {
|
||||
if (!this.isPaused || this.controller.signal.aborted) return;
|
||||
|
||||
this.lastStartedAt = Date.now();
|
||||
this.schedule(this.remainingMs, reason);
|
||||
this.isPaused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the current budget by the specified number of milliseconds.
|
||||
*/
|
||||
extend(ms: number, reason = 'Timeout exceeded.'): void {
|
||||
if (this.controller.signal.aborted) return;
|
||||
|
||||
if (this.isPaused) {
|
||||
this.remainingMs += ms;
|
||||
} else {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
const elapsed = Date.now() - this.lastStartedAt;
|
||||
this.remainingMs = Math.max(0, this.remainingMs - elapsed) + ms;
|
||||
this.lastStartedAt = Date.now();
|
||||
this.schedule(this.remainingMs, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the signal immediately and clears any pending timers.
|
||||
*/
|
||||
abort(reason?: unknown): void {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
this.isPaused = false;
|
||||
this.controller.abort(reason);
|
||||
}
|
||||
|
||||
private schedule(ms: number, reason: string): void {
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.timeoutId = null;
|
||||
this.controller.abort(new Error(reason));
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user