diff --git a/web/hwlab-cloud-web/src/state/runner-trace.test.ts b/web/hwlab-cloud-web/src/state/runner-trace.test.ts index 3a353dbc..e358b3ba 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -121,11 +121,70 @@ test("subscribeToTrace keeps polling result when trace terminal lacks final resp }).catch(reject); }); assert.equal(completed.assistantText, "最终回复"); + assert.equal(completed.runnerTrace?.events?.length, 2); assert.equal(resultPolls, 2); assert.deepEqual(fetches, [ "/v1/agent/chat/result/trc_terminal_first", "/v1/agent/chat/trace/trc_terminal_first", - "/v1/agent/chat/result/trc_terminal_first" + "/v1/agent/chat/result/trc_terminal_first", + "/v1/agent/chat/trace/trc_terminal_first" + ]); + } finally { + globalThis.fetch = originalFetch; + if (globalThis.window) { + globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout; + globalThis.window.clearTimeout = originalClearTimeout ?? globalThis.window.clearTimeout; + } + } +}); + +test("subscribeToTrace preserves trace when terminal result with text arrives before trace poll", async () => { + const originalFetch = globalThis.fetch; + const originalSetTimeout = globalThis.window?.setTimeout; + const originalClearTimeout = globalThis.window?.clearTimeout; + const fetches: string[] = []; + const events: TraceEvent[] = [ + { seq: 1, label: "agentrun:request:accepted", status: "accepted" }, + { seq: 2, label: "agentrun:terminal:failed", type: "terminal", status: "failed", message: "provider failed" }, + { seq: 3, label: "agentrun:result:failed", type: "result", status: "failed", message: "provider failed" } + ]; + globalThis.window = { + ...(globalThis.window ?? {}), + setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout, + clearTimeout: (() => undefined) as typeof window.clearTimeout + } as typeof window; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + fetches.push(url); + if (url.includes("/result/")) { + return jsonResponse({ status: "failed", traceId: "trc_failed_fast", assistantText: "provider failed" }); + } + if (url.includes("/trace/")) { + return jsonResponse({ status: "failed", traceId: "trc_failed_fast", events, eventCount: events.length }); + } + return jsonResponse({}); + }) as typeof fetch; + + try { + const completed = await new Promise((resolve, reject) => { + void subscribeToTrace({ + traceId: "trc_failed_fast", + initial: { status: "running", traceId: "trc_failed_fast", resultUrl: "/v1/agent/chat/result/trc_failed_fast" }, + onActivity: () => undefined, + onSnapshot: () => undefined, + onComplete: resolve, + onInfrastructureError: (error) => reject(new Error(error)), + signal: new AbortController().signal, + inactivityTimeoutMs: 1000 + }).catch(reject); + }); + assert.equal(completed.assistantText, "provider failed"); + assert.equal(completed.runnerTrace?.events?.length, 3); + assert.equal(completed.runnerTrace?.eventCount, 3); + assert.equal(completed.traceEvents?.length, 3); + assert.deepEqual(fetches, [ + "/v1/agent/chat/result/trc_failed_fast", + "/v1/agent/chat/trace/trc_failed_fast" ]); } finally { globalThis.fetch = originalFetch; diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index b3318347..d91eb1d2 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -162,7 +162,8 @@ function firstNonEmptyString(...values: unknown[]): string | null { * unresponsive for `inactivityTimeoutMs`). In normal operation the * 1.5s polling cadence keeps `lastActivityAt` fresh and the inactivity * abort never triggers. - * - Terminal status -> `onComplete` and return. + * - Terminal status -> poll trace once before `onComplete` so refresh keeps + * a replayable trace panel even when result text arrives first. * - 5xx -> keep polling, do NOT call `onActivity` (the inactivity * window should detect the real backend outage). * - 4xx (non-5xx) -> `onInfrastructureError` and return. @@ -197,6 +198,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise let lastEventCount = -1; let lastStatus: string | null = null; let lastUpdatedAt: string | null = null; + let terminalResultWithText: AgentChatResultResponse | null = null; let terminalResultWithoutText: AgentChatResultResponse | null = null; for (;;) { @@ -216,10 +218,10 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise onActivity(); if (isTerminalStatus(resultPolled.data.status)) { if (firstNonEmptyResultText(resultPolled.data)) { - onComplete(resultPolled.data); - return; + terminalResultWithText = resultPolled.data; + } else { + terminalResultWithoutText = resultPolled.data; } - terminalResultWithoutText = resultPolled.data; } } else if (!resultPolled.ok && resultPolled.status >= 500) { // server hiccup: keep polling, do NOT call onActivity — the @@ -260,6 +262,18 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise }; onSnapshot(snapshot); } + if (terminalResultWithText) { + const last = events.at(-1); + const traceSnapshot = snapshotToTraceSnapshot({ + ...tracePolled.data, + traceId, + events, + eventCount, + lastEventLabel: String(last?.label ?? last?.type ?? "") + } as AgentChatResultResponse); + onComplete(mergeTraceResults(terminalResultWithText, traceSnapshot)); + return; + } if (isTerminalStatus(status)) { const last = events.at(-1); const traceTerminal = { @@ -277,8 +291,16 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise } } } else if (!tracePolled.ok && tracePolled.status >= 500) { + if (terminalResultWithText) { + onComplete(terminalResultWithText); + return; + } // server hiccup: keep polling, no activity update. } else if (!tracePolled.ok) { + if (terminalResultWithText) { + onComplete(terminalResultWithText); + return; + } onInfrastructureError(tracePolled.error ?? "Code Agent trace poll failed (non-5xx)"); return; }