diff --git a/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts b/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts new file mode 100644 index 00000000..32dbfe3d --- /dev/null +++ b/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ActivityRef } from "../src/services/api/client.ts"; + +const win = globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }; +if (!win.window) { + win.window = { + setTimeout: (handler, delay) => setTimeout(handler, delay), + clearTimeout: (timer) => clearTimeout(timer as unknown as ReturnType) + }; +} + +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; +} + +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(); + } + }; +} + +test("fetchJson 总超时:未传 activityRef 时 timeoutMs 走总窗口", { timeout: 5000 }, async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = bindSignalFetch(); + try { + const { fetchJson } = await import("../src/services/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/services/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 inactivity:trace poll 风格的 1.5s 间隔在 6.5s 内不会触发总超时", { timeout: 20000 }, async () => { + // HWLAB #795 regression: the runner-trace poll loop used a total timeout + // equal to the user-configured codeAgentTimeoutMs, so an operation that + // kept emitting events every 1.5s would still be killed by the outer + // total cap. With activityRef, the inactivity window resets on every + // snapshot so the request only aborts after timeoutMs of NO activity. + const { ref, update } = buildMutableActivityRef(Date.now()); + const originalFetch = globalThis.fetch; + globalThis.fetch = bindSignalFetch(); + + const interval = setInterval(() => { + update(Date.now()); + }, 1500); + + try { + const { fetchJson } = await import("../src/services/api/client.ts"); + const startedAt = Date.now(); + const promise = fetchJson("/v1/agent/chat/result/abc", { timeoutMs: 5000, timeoutName: "trace-poll", activityRef: () => ref }); + setTimeout(() => clearInterval(interval), 6500); + const result = await promise; + const elapsed = Date.now() - startedAt; + assert.equal(result.ok, false, "fetch should fail once activity stops"); + assert.ok(elapsed >= 6000, `continuous 1.5s activity should keep the request alive past the per-poll inactivity window; got ${elapsed}ms`); + assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`); + } finally { + clearInterval(interval); + globalThis.fetch = originalFetch; + } +}); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index a18b84c3..0e1ae2c4 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback } from "react"; import { useAuth } from "./hooks/useAuth"; import { useSidebarResize } from "./hooks/useSidebarResize"; @@ -70,6 +71,16 @@ export function App(): ReactElement { setPickedDraft(null); } + // HWLAB #795: wire the command bar's onTyping to the workbench activity + // clock so the Code Agent inactivity-timeout (driven by activityRef in + // state/workbench.ts) does not falsely fire while the user is reading the + // trace / composing a follow-up. The bar only emits onTyping on actual + // input changes; we keep this stable via useCallback so the CommandBar + // does not re-render on every keystroke. + const noteActivity = useCallback(() => { + store.recordActivity(); + }, [store]); + async function submitWithDraftRecord(value: string): Promise { recordDraft(value); await store.submitMessage(value); @@ -128,7 +139,7 @@ export function App(): ReactElement { {route === "help" ? : null} {route === "settings" ? : null} - +
hwlab -