kimi-code/packages/agent-core/test/tools/background/agent-timeout.test.ts
2026-05-22 15:54:50 +08:00

137 lines
6 KiB
TypeScript

/**
* `registerAgentTask` `timeoutMs` option.
*
* Semantics:
* - external deadline fires → status=`failed`, `timedOut=true`
* - no `timeoutMs` → the task runs to completion without a wrapper
* - internal `TimeoutError` rejection (e.g. aiohttp sock_read) is a
* generic `failed` with `timedOut` left unset — the flag must
* only be set for the caller-driven deadline
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BackgroundProcessManager } from '../../../src/tools/background/manager';
describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => {
const manager = new BackgroundProcessManager();
afterEach(() => {
manager._reset();
vi.useRealTimers();
});
it('external deadline marks task failed with timedOut=true', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
// A never-resolving completion — only the deadline will fire.
const hangForever = new Promise<{ result: string }>(() => {});
const taskId = manager.registerAgentTask(hangForever, 'hang', { timeoutMs: 2_000 });
// Advance past the deadline; awaitTerminal resolves once the race
// finishes and the `.finally` block runs.
const terminalPromise = manager.waitForTerminal(taskId);
await vi.advanceTimersByTimeAsync(2_100);
const info = await terminalPromise;
expect(info?.status).toBe('failed');
expect(info?.timedOut).toBe(true);
});
it('omitting timeoutMs lets the task run to completion (no wrapper)', async () => {
let resolveFn!: (r: { result: string }) => void;
const completion = new Promise<{ result: string }>((res) => {
resolveFn = res;
});
const taskId = manager.registerAgentTask(completion, 'no deadline');
resolveFn({ result: 'finished' });
const info = await manager.waitForTerminal(taskId);
expect(info?.status).toBe('completed');
expect(info?.timedOut).toBeUndefined();
});
it('internal TimeoutError rejection = generic failure, timedOut unset', async () => {
// Even with a deadline set, an internal TimeoutError that fires
// BEFORE the deadline must land as a plain `failed` (not as a
// deadline-driven timeout).
const internalErr = new Error('aiohttp sock_read timeout');
internalErr.name = 'TimeoutError';
const rejecting = Promise.reject(internalErr);
const taskId = manager.registerAgentTask(rejecting, 'internal timeout', {
timeoutMs: 900_000,
});
const info = await manager.waitForTerminal(taskId);
expect(info?.status).toBe('failed');
// Deadline never fired → timedOut must NOT be set.
expect(info?.timedOut).toBeUndefined();
});
// Explicit per-task timeoutMs must be surfaced on the task info so
// downstream wait-cap consumers can honour the agent-supplied value
// instead of falling back to a hard-coded default. (gap #6 family.)
//
// Uses fake timers so the 30-min deadline armed by registerAgentTask
// does not leak across the test boundary into the Vitest worker —
// the `completion` promise here never resolves, so the lifecycle
// promise's `.finally(clearTimeout)` would not run under real time.
it('explicit timeoutMs is persisted on the task info', () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const taskId = manager.registerAgentTask(new Promise(() => {}), 'persist timeout', {
timeoutMs: 1_800_000,
});
const info = manager.getTask(taskId);
expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBe(1_800_000);
});
// Decision (confirmed with team, 2026-05-19): background tasks in
// kimi-code do NOT carry an implicit default timeout. The Python
// kimi-cli enforced a 30-min default because its agents were
// expected to be short-lived; kimi-code's agents may legitimately
// run a dev server, a long compile, or a watch loop, and an
// auto-kill would be a footgun. The shutdown wait-cap that reads
// timeoutMs falls back to its own policy when the field is
// undefined; the BPM does not invent a default.
//
// This test is kept (rather than deleted) to act as a regression
// guard: if someone later adds a hard-coded default in
// registerAgentTask, the assertion below catches it.
it('omitted timeoutMs leaves the task info field undefined', () => {
const taskId = manager.registerAgentTask(new Promise(() => {}), 'default timeout');
const info = manager.getTask(taskId);
expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBeUndefined();
});
// Contract decision (2026-05-21): kimi-code treats `timeoutMs: 0`
// as "record the value but do NOT arm a deadline" rather than
// Python's "fire immediately" semantics. The field is preserved on
// the task info so shutdown wait-caps / UI can read it; the
// deadline-arming check (`opts.timeoutMs > 0`) deliberately skips
// zero so a caller writing `0` does not lose its task to an
// immediate kill.
it('timeoutMs=0 is preserved on the task info and does not arm a deadline', async () => {
const taskId = manager.registerAgentTask(new Promise(() => {}), 'zero timeout', {
timeoutMs: 0,
});
// The literal zero is preserved on the task info.
const initial = manager.getTask(taskId);
expect((initial as unknown as { timeoutMs?: number }).timeoutMs).toBe(0);
// No deadline armed: the task stays running. We bound the wait
// with a short race so the test does not hang on the never-
// settling completion promise; the racing branch winning is the
// expected outcome.
const raced = await Promise.race<{ status: string; timedOut?: boolean } | undefined>([
manager.waitForTerminal(taskId).then((info) =>
info === undefined ? undefined : { status: info.status, timedOut: info.timedOut },
),
new Promise<{ status: string; timedOut?: boolean }>((res) => {
setTimeout(() => {
res({ status: 'running' });
}, 100);
}),
]);
expect(raced?.status).toBe('running');
expect(raced?.timedOut).toBeUndefined();
});
});