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 dc05e3a7..097ea0ee 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.test.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.test.ts @@ -195,6 +195,47 @@ test("subscribeToTrace preserves trace when terminal result with text arrives be } }); +test("subscribeToTrace preserves trace when initial response is already terminal", async () => { + const originalFetch = globalThis.fetch; + const events: TraceEvent[] = [ + { seq: 1, label: "agentrun:request:accepted", status: "accepted" }, + { seq: 2, label: "agentrun:result:failed", type: "result", status: "failed", message: "provider failed" } + ]; + const fetches: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + fetches.push(url); + if (url.includes("/trace/")) { + return jsonResponse({ status: "failed", traceId: "trc_initial_terminal", events, eventCount: events.length }); + } + return jsonResponse({}); + }) as typeof fetch; + + try { + const snapshots: TraceSnapshot[] = []; + const completed = await new Promise((resolve, reject) => { + void subscribeToTrace({ + traceId: "trc_initial_terminal", + initial: { status: "failed", traceId: "trc_initial_terminal", assistantText: "provider failed" }, + onActivity: () => undefined, + onSnapshot: (snapshot) => snapshots.push(snapshot), + 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, 2); + assert.equal(completed.runnerTrace?.eventCount, 2); + assert.equal(completed.traceEvents?.length, 2); + assert.equal(snapshots.length, 1); + assert.deepEqual(fetches, ["/v1/agent/chat/trace/trc_initial_terminal?projectId=prj_device_pod_workbench"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("subscribeToTrace merges terminal result without text with trace assistant content", async () => { const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.window?.setTimeout; diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index a95cc93d..be618c8f 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -184,9 +184,14 @@ export interface TraceSubscriptionConfig { export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise { const { traceId, initial, onActivity, onSnapshot, onComplete, onInfrastructureError, signal, inactivityTimeoutMs } = config; - // If the initial response is already terminal, fire onComplete and return. + // If the initial response is already terminal, still fetch the trace once. + // Provider fast-fail responses can include final text immediately while the + // trace endpoint already has the event list needed for refresh persistence. if (isTerminalStatus(initial.status)) { - onComplete(initial as AgentChatResultResponse); + const traceSnapshot = await fetchTerminalTraceSnapshot(traceId, traceId, inactivityTimeoutMs, signal); + if (signal.aborted) return; + if (traceSnapshot) onSnapshot(traceSnapshot); + onComplete(mergeTraceResults(initial as AgentChatResultResponse, traceSnapshot)); return; } if (!initial.resultUrl) { @@ -308,6 +313,22 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise } } +async function fetchTerminalTraceSnapshot(traceId: string, traceUrlOrId: string, inactivityTimeoutMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return null; + const url = traceUrlOrId.startsWith("/") ? traceUrlOrId : `/v1/agent/chat/trace/${encodeURIComponent(traceUrlOrId)}?projectId=prj_device_pod_workbench`; + const tracePolled = await api.getAgentChatResult(url, inactivityTimeoutMs, null); + if (signal.aborted || !tracePolled.ok || !tracePolled.data) return null; + const events = Array.isArray(tracePolled.data.events) ? tracePolled.data.events : []; + const last = events.at(-1); + return snapshotToTraceSnapshot({ + ...tracePolled.data, + traceId, + events, + eventCount: Number(tracePolled.data.eventCount ?? events.length) || 0, + lastEventLabel: String(last?.label ?? last?.type ?? tracePolled.data.lastEventLabel ?? "") + } as AgentChatResultResponse); +} + function snapshotToTraceSnapshot(result: AgentChatResultResponse): TraceSnapshot { const events = Array.isArray((result as { events?: TraceEvent[] }).events) ? ((result as { events?: TraceEvent[] }).events ?? []) : []; return {