import assert from "node:assert/strict"; import test from "node:test"; import type { ActivityRef } from "../src/api/client.ts"; function buildMutableActivityRef(initial: number): { ref: ActivityRef; update: (at: number) => void } { const ref: ActivityRef = { lastActivityAt: initial, lastActivityIso: new Date(initial).toISOString(), waitingFor: "test", lastEventLabel: null }; return { ref, update: (at: number) => { ref.lastActivityAt = at; ref.lastActivityIso = new Date(at).toISOString(); } }; } function bindSignalFetch(): typeof fetch { return (async (_input, init) => { return await new Promise((_, reject) => { const signal = init?.signal; if (signal) { if (signal.aborted) reject(new DOMException("aborted", "AbortError")); else signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); } }); }) as typeof fetch; } test("fetchJson 总超时:未传 activityRef 时 timeoutMs 走总窗口", { timeout: 5000 }, async () => { const originalFetch = globalThis.fetch; globalThis.fetch = bindSignalFetch(); try { const { fetchJson } = await import("../src/api/client.ts"); const startedAt = Date.now(); const result = await fetchJson("/v1/agent/chat", { timeoutMs: 200, timeoutName: "test-total" }); const elapsed = Date.now() - startedAt; assert.equal(result.ok, false, "fetch should fail when no activity is supplied"); assert.match(result.error ?? "", /超时/u, `expected timeout error, got: ${result.error}`); assert.ok(elapsed < 1500, `fetchJson should abort near 200ms but took ${elapsed}ms`); } finally { globalThis.fetch = originalFetch; } }); test("fetchJson inactivity:activityRef 每 100ms 推一次活动,stop 后 ~2s abort", { timeout: 15000 }, async () => { const { ref, update } = buildMutableActivityRef(Date.now()); const originalFetch = globalThis.fetch; globalThis.fetch = bindSignalFetch(); const interval = setInterval(() => { update(Date.now()); }, 100); try { const { fetchJson } = await import("../src/api/client.ts"); const startedAt = Date.now(); const promise = fetchJson("/v1/agent/chat", { timeoutMs: 2000, timeoutName: "test-inactivity", activityRef: () => ref }); setTimeout(() => clearInterval(interval), 1800); const result = await promise; const elapsed = Date.now() - startedAt; assert.equal(result.ok, false, "fetch should fail once activity stops"); assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`); assert.ok(elapsed >= 1800, `should not abort before 1800ms with continuous activity, got ${elapsed}ms`); assert.ok(elapsed < 6000, `should abort ~1-2s after activity stops, got ${elapsed}ms`); } finally { clearInterval(interval); globalThis.fetch = originalFetch; } }); test("fetchJson 无 total timeout:10s 持续活动窗口内不会被总上限打掉", { timeout: 30000 }, async () => { // HWLAB #795 PR feedback + #802: the per-request inactivity window is the // SOLE abort signal. There is no client-side total-timeout ceiling on // waitForAgentResult / pollRunnerTrace. A continuous 200ms-cadence // activity stream must keep a fetchJson request alive well past the // 3s inactivity window set on the test, and the test only ends when // the activity stream stops. const { ref, update } = buildMutableActivityRef(Date.now()); const originalFetch = globalThis.fetch; globalThis.fetch = bindSignalFetch(); const interval = setInterval(() => { update(Date.now()); }, 200); try { const { fetchJson } = await import("../src/api/client.ts"); const startedAt = Date.now(); const promise = fetchJson("/v1/agent/chat/result/abc", { timeoutMs: 3000, timeoutName: "trace-poll", activityRef: () => ref }); setTimeout(() => clearInterval(interval), 10_000); const result = await promise; const elapsed = Date.now() - startedAt; assert.equal(result.ok, false, "fetch should fail once activity stops"); assert.ok( elapsed >= 10_000, `continuous 200ms activity must keep the request alive for the full 10s; a total cap or wall-clock shrink would abort earlier; got ${elapsed}ms` ); assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`); } finally { clearInterval(interval); globalThis.fetch = originalFetch; } });