Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts
T
Lyon 10c03e6c32 fix(web): v0.2 unify Code Agent trace polling path + persist fail (HWLAB #802) (#806)
Follow-up to PR #798 (#795 final). PR #798 closed the total-timeout /
hard-cap issue, but left two residual code-path inconsistencies that
#802 captures:

- Submit path and refresh path used different trace-polling code:
  submitMessage called `pollRunnerTrace` + `waitForAgentResult` (a
  dual function pair from pre-#798), while hydrate() never subscribed
  to the running trace at all. After a page refresh, the user saw
  the cloud-api "running" state but no live trace events.
- The submit fail branch only dispatched a local "Code Agent 超时"
  message; it never called `persistConversation`, so the failure was
  invisible after F5 — the cloud-api conversation would show
  "running" forever from the user's perspective.

This PR collapses both into a single `subscribeToTrace` entry point
and wires the refresh path into the same active subscription:

- `state/runner-trace.ts` — replace `waitForAgentResult` +
  `pollRunnerTrace` with a single `subscribeToTrace(config)` that
  handles both the result endpoint and the trace endpoint in one
  `for(;;)` loop. Calls `onActivity` on EVERY successful poll (not
  just on new events) so the per-poll inactivity window in
  `fetchJson` does not fire on a healthy polling loop. Terminal
  status -> `onComplete`; 5xx -> keep polling, no activity; 4xx ->
  `onInfrastructureError`; `signal.aborted` -> silent return. The
  hard-cap / 4x / `TRACE_HARD_CAP_ATTEMPTS` machinery is gone (was
  already removed by #798).
- `state/trace-reattach.ts` — new `useTraceReattach` hook that
  subscribes to the running trace on hydrate (same `subscribeToTrace`
  call as submitMessage). Reuses an existing agent message slot for
  the traceId if one exists; otherwise creates a placeholder. Persists
  on both onComplete and onInfrastructureError. Path unification
  achieved: one entry, two callers.
- `state/workbench.ts`:
  - `submitMessage` calls `subscribeToTrace` directly with a
    single-line `onActivity: () => updateActivity()` plus
    `onComplete` / `onInfrastructureError` callbacks. The
    `onInfrastructureError` branch now ALSO calls
    `persistConversation`, persisting the fail state to cloud-api.
  - The re-attach useEffect is replaced with a one-liner
    `useTraceReattach({...})` call (workbench.ts stays under the
    400-line guard).
- `scripts/fetchJson-inactivity.test.ts` — third case tightened:
  200ms-cadence activity for 10s with `timeoutMs: 3000`; the
  request must live the full 10s and abort only after activity
  stops. Asserts `elapsed >= 10_000`.
- `docs/reference/spec-v02-hwlab-cloud-web.md` — the "Code Agent
  Timeout Model" section is renamed "Code Agent Timeout Model 与
  Path Unification" and now also pins: (a) `subscribeToTrace` is
  the only entry; `waitForAgentResult` / `pollRunnerTrace` /
  `TRACE_HARD_CAP_ATTEMPTS` MUST NOT exist; (b) `useTraceReattach`
  MUST be wired in; (c) `persistConversation` is required in both
  onComplete and onInfrastructureError; (d) `state.workspace.activeTraceId`
  changes MUST re-attach. Includes a distilled history of
  #775 / #777 / #791 / #795 / #797 / #798 / #802.

Verification
- `bun run scripts/tsc-check.ts` → strict React TSX, 0 explicit any
- `bun run check` → 5 pass / 0 fail (3 inactivity + 2 dist-contract)
- `bun run build` → 9 dist files verified fresh; `app.js`
  238460 → 240990 B
- `state/trace-reattach.ts` is now wired in; `state/runner-trace.ts`
  exports only `subscribeToTrace` (verified by tsc + grep)
- `workbench.ts` line count 400 (under the 400-line guard)
- New 200ms-cadence 10s test passes; old 100ms-cadence 2s test
  and total-timeout 200ms test still pass.

Refs: HWLAB #802, #795, #777, #791, #798

Co-authored-by: Codex Agent <codex@hwlab.local>
2026-06-04 11:08:20 +08:00

117 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof setTimeout>)
};
}
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<Response>((_, 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/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 inactivityactivityRef 每 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 无 total timeout10s 持续活动窗口内不会被总上限打掉", { 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/services/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;
}
});