diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index fccbf590..d3cef5b0 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -1616,7 +1616,10 @@ test("hwlab-cli client agent trace can render with the Web trace row path", asyn assert.equal(result.payload.body.sourceEventCount, 3); assert.ok(result.payload.body.renderedRowCount >= 1); assert.ok(result.payload.body.rows.some((row: any) => /助手最终消息/u.test(row.header))); + assert.equal(JSON.stringify(result.payload.body.rows).includes("request accepted"), false); + assert.equal(JSON.stringify(result.payload.body.rows).includes("request:accepted"), false); assert.equal(JSON.stringify(result.payload.body.rows).includes("session:reused"), false); + assert.equal(result.payload.body.rows[0].body, "当前有两个 HWPOD 可用。"); assert.equal(result.payload.traceStatus, "completed"); assert.equal(result.payload.rendered.traceStatus, "completed"); assert.equal(result.payload.rendered.render, "web"); @@ -1710,6 +1713,8 @@ test("hwlab-cli Web trace render does not treat AgentRun result-ready as final a assert.equal(text.includes("助手最终消息"), false); assert.equal(text.includes("助手消息"), false); assert.match(text, /轮次完成/u); + assert.match(text, /总耗时 00:00:02/u); + assert.equal(text.includes("总耗时 00:00:00"), false); assert.equal(text.includes("AgentRun result is ready for HWLAB short-connection polling"), false); }); @@ -1785,6 +1790,9 @@ test("hwlab-cli Web trace render keeps AgentRun middle assistant message", async assert.match(text, /我先检查当前 HWPOD 列表/u); assert.match(text, /当前有 2 个 HWPOD 可用/u); assert.match(text, /助手最终消息/u); + assert.match(text, /总耗时 00:00:03/u); + assert.equal(text.includes("agentrun:request:accepted"), false); + assert.equal(text.includes("总耗时 00:00:00"), false); }); test("hwlab-cli Web trace render keeps the full final-response body without truncating long agent summaries", async () => { diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index f0a01490..4f58e618 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -11,12 +11,11 @@ export interface TraceEventRow { type TraceEvent = Record; export function traceDisplayRows(trace: Record = {}, events: TraceEvent[] = []): TraceEventRow[] { + const effectiveTrace = traceWithInferredStart(trace, events); const rows: TraceEventRow[] = []; const renderedSourceEvents = new Set(); const renderedToolIdentities = new Set(); const assistantRows: AssistantRowState[] = []; - let requestRendered = false; - let setupRendered = false; let lastAssistantRowIndex = -1; let completionEvent: TraceEvent | null = null; for (let index = 0; index < events.length; index += 1) { @@ -34,30 +33,22 @@ export function traceDisplayRows(trace: Record = {}, events: Tr if (renderedToolIdentities.has(identity)) continue; renderedToolIdentities.add(identity); } - rows.push(traceToolCallRow(trace, event)); + rows.push(traceToolCallRow(effectiveTrace, event)); continue; } if (isRequestTraceEvent(event)) { - if (!requestRendered) { - rows.push(traceRequestSummaryRow(event)); - requestRendered = true; - } continue; } if (isSetupTraceEvent(event)) { - if (!setupRendered) { - rows.push(traceSetupSummaryRow(event)); - setupRendered = true; - } continue; } if (isTerminalAssistantTraceEvent(event)) { - const assistantRowIndex = upsertAssistantMessageRow(rows, assistantRows, trace, event, { terminal: true }); + const assistantRowIndex = upsertAssistantMessageRow(rows, assistantRows, effectiveTrace, event, { terminal: true }); if (assistantRowIndex >= 0) lastAssistantRowIndex = assistantRowIndex; continue; } if (isAssistantTraceEvent(event)) { - const assistantRowIndex = upsertAssistantMessageRow(rows, assistantRows, trace, event, { terminal: false }); + const assistantRowIndex = upsertAssistantMessageRow(rows, assistantRows, effectiveTrace, event, { terminal: false }); if (assistantRowIndex >= 0) lastAssistantRowIndex = assistantRowIndex; continue; } @@ -65,21 +56,21 @@ export function traceDisplayRows(trace: Record = {}, events: Tr completionEvent = event; continue; } - rows.push(traceDisplayRow(trace, event)); + rows.push(traceDisplayRow(effectiveTrace, event)); } if (completionEvent) { - const finalResponseText = traceFinalResponseText(trace); + const finalResponseText = traceFinalResponseText(effectiveTrace); if (finalResponseText) { lastAssistantRowIndex = upsertAuthoritativeFinalResponseRow(rows, assistantRows, completionEvent, finalResponseText); } const lastAssistantRow = lastAssistantRowIndex >= 0 ? rows[lastAssistantRowIndex] : undefined; - if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent); - else rows.push(traceCompletionSummaryRow(trace, completionEvent)); + if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(effectiveTrace, lastAssistantRow, completionEvent); + else rows.push(traceCompletionSummaryRow(effectiveTrace, completionEvent)); } if (rows.length > 0) return rows; if (events.length === 0) return []; - if (traceNoiseEventCount(events) === events.length) return [traceNoiseSummaryRow(trace, events)]; - return events.filter((event) => !isNoisyTraceEvent(event)).map((event) => traceDisplayRow(trace, event)); + if (traceNoiseEventCount(events) === events.length) return [traceNoiseSummaryRow(effectiveTrace, events)]; + return events.filter((event) => !isNoisyTraceEvent(event) && !isRequestTraceEvent(event) && !isSetupTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event)); } export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string { @@ -140,7 +131,7 @@ function traceToolCallRow(trace: Record, event: TraceEvent): Tr rowId: `tool:${event.itemId ?? event.seq ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: traceEventTone(event), - header: `${traceClock(event.createdAt)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${status} ${toolName}`.trim(), + header: `${traceEventClock(event)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${status} ${toolName}`.trim(), body, bodyFormat: "text" }; @@ -161,33 +152,6 @@ export function traceNoiseEventCount(events: TraceEvent[] = []): number { return Array.isArray(events) ? events.filter((event) => isNoisyTraceEvent(event)).length : 0; } -function traceRequestSummaryRow(event: TraceEvent): TraceEventRow { - const prompt = cleanTraceText(event.promptSummary ?? event.prompt ?? ""); - return { - rowId: `trace-request:${event.seq ?? "accepted"}`, - seq: numberOrNull(event.seq), - tone: traceEventTone(event), - header: `${traceClock(event.createdAt)} 请求接受${prompt ? `,提示词:"${compactTraceOneLine(prompt, 120)}"` : ""}`, - body: null - }; -} - -function traceSetupSummaryRow(event: TraceEvent): TraceEventRow { - const label = String(event.label ?? ""); - const parts = [ - /session:reused|thread\/resume/iu.test(label) ? "会话复用" : "会话就绪", - /prompt:sent|sent prompt/iu.test(label) ? "提示词已发送" : null, - /turn[:/]start|turn:started|turn\/start/iu.test(label) ? "轮次开始" : null - ].filter(Boolean).join(","); - return { - rowId: `trace-setup:${event.seq ?? "session-turn"}`, - seq: numberOrNull(event.seq), - tone: "source", - header: `${traceClock(event.createdAt)} ${parts || "会话准备完成"}`, - body: null - }; -} - function traceAssistantMessageRow(trace: Record, event: TraceEvent, { terminal }: { terminal: boolean }): TraceEventRow { const text = cleanTraceText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); const index = Number.isInteger(event.messageIndex) ? Number(event.messageIndex) : null; @@ -196,7 +160,7 @@ function traceAssistantMessageRow(trace: Record, event: TraceEv rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: "ok", - header: `${traceClock(event.createdAt)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`, + header: `${traceEventClock(event)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`, terminal: terminal ? true : undefined, body: text || null, bodyFormat: "markdown" @@ -274,7 +238,7 @@ function upsertAuthoritativeFinalResponseRow(rows: TraceEventRow[], assistantRow body: finalText, terminal: true, tone: "ok", - header: `${traceClock(completionEvent.createdAt)} 助手最终消息` + header: `${traceEventClock(completionEvent)} 助手最终消息` }; matchedState.comparableText = comparableText; matchedState.derivedSnapshotSuffix = false; @@ -285,7 +249,7 @@ function upsertAuthoritativeFinalResponseRow(rows: TraceEventRow[], assistantRow rowId: `trace-final-response:${completionEvent.seq ?? "completed"}`, seq: numberOrNull(completionEvent.seq), tone: "ok", - header: `${traceClock(completionEvent.createdAt)} 助手最终消息`, + header: `${traceEventClock(completionEvent)} 助手最终消息`, terminal: true, body: finalText, bodyFormat: "markdown" @@ -348,7 +312,7 @@ function traceCompletionSummaryRow(trace: Record, event: TraceE rowId: `trace-completion:${event.seq ?? "turn"}`, seq: numberOrNull(event.seq), tone: traceEventTone(event), - header: `${traceClock(event.createdAt)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, + header: `${traceEventClock(event)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, body: null }; } @@ -361,7 +325,7 @@ function traceNoiseSummaryRow(trace: Record, events: TraceEvent rowId: "trace-noise-summary", seq: numberOrNull(lastEvent?.seq), tone: lastEvent ? traceEventTone(lastEvent) : "source", - header: `${traceClock(lastEvent?.createdAt)} Trace ${status},等待可读事件`, + header: `${traceEventClock(lastEvent)} Trace ${status},等待可读事件`, body: `已隐藏 ${events.length} 条 AgentRun backend 状态事件。最新原始事件:${lastLabel}。`, bodyFormat: "text" }; @@ -373,7 +337,7 @@ function traceDisplayRow(trace: Record, event: TraceEvent): Tra rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, seq: numberOrNull(event.seq), tone: traceEventTone(event), - header: `${traceClock(event.createdAt)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(), + header: `${traceEventClock(event)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(), body: traceDisplayBody(event) }; } @@ -561,10 +525,25 @@ function traceClock(value: unknown): string { return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19); } +function traceEventClock(event: TraceEvent | null | undefined): string { + return traceClock(traceEventTimestamp(event)); +} + +function traceWithInferredStart(trace: Record, events: TraceEvent[]): Record { + if (nonEmptyString(trace.startedAt ?? trace.createdAt)) return trace; + const firstTimestamp = events.map((event) => traceEventTimestamp(event)).find(Boolean); + return firstTimestamp ? { ...trace, startedAt: firstTimestamp } : trace; +} + +function traceEventTimestamp(event: TraceEvent | null | undefined): string | null { + if (!event) return null; + return nonEmptyString(event.createdAt ?? event.ts ?? event.timestamp ?? event.observedAt); +} + function traceRelativeMs(trace: Record, event: TraceEvent): number { if (typeof event.elapsedMs === "number") return event.elapsedMs; const start = Date.parse(String(trace.startedAt ?? trace.createdAt ?? "")); - const current = Date.parse(String(event.createdAt ?? "")); + const current = Date.parse(String(traceEventTimestamp(event) ?? "")); return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start); } diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 787128ed..d0a2dafa 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -93,6 +93,13 @@ assertIncludes(appSource, "activityRef: () => activityRef.value", "Workbench mus assertIncludes(appSource, "getAgentChatResult(resultUrl, inactivityTimeoutMs, activityRef)", "Trace result polling must use activityRef-backed inactivity timeout"); assertIncludes(appSource, "for (;;) ", "trace subscription must keep unbounded polling without total timeout"); assertIncludes(appSource, "/v1/agent/chat/trace/", "trace replay must use Cloud Web same-origin trace API"); +assertIncludes(appSource, "trace-meta-details", "Trace identity and raw-event controls must stay behind a compact details disclosure"); +assertIncludes(appSource, "grid-template-columns: minmax(0, 1fr);", "Trace rows must give the readable body the full content width"); +assertIncludes(appSource, "const primaryAction = computed", "Workbench composer must derive a single primary send/cancel/steer action"); +assertIncludes(appSource, "primaryAction.value === \"cancel\"", "Composer primary button must switch to cancel while a turn is running and the draft is empty"); +assertIncludes(appSource, "primaryAction.value === \"steer\"", "Composer primary button must switch to steer while a turn is running and the draft has text"); +assertIncludes(appSource, ":data-action=\"primaryAction\"", "Composer primary button must expose the active turn/steer/cancel action"); +assert.doesNotMatch(appSource, /取消运行中 Trace/u, "Workbench must not render a separate stale cancel-running-trace button"); assertIncludes(appSource, "session_required", "explicit session policy must be represented"); assertIncludes(appSource, "DEV-LIVE", "evidence labels must remain visible"); assertIncludes(appSource, "CodeWorkbenchView", "Code Workbench must be a route view, not the app shell"); diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 9ceaeb39..b8d0cfb4 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -21,6 +21,7 @@ const noiseCount = computed(() => traceNoiseEventCount(events.value as Record events.value.at(-1) ?? null); const lastReadableRow = computed(() => readableRows.value.at(-1) ?? null); const lastEventLabel = computed(() => firstNonEmptyString(lastReadableRow.value?.header, props.trace?.lastEventLabel, lastEvent.value?.label, lastEvent.value?.type, lastEvent.value?.kind) ?? "未观测"); +const detailTitle = computed(() => props.trace?.traceId ? `运行详情 ${props.trace.traceId}` : "运行详情"); const summaryItems = computed(() => { const agentRun = recordValue(props.trace?.agentRun); return [ @@ -83,19 +84,28 @@ function recordValue(value: unknown): Record | null {
Trace - {{ trace.traceId || "pending" }} - {{ readableRows.length }} readable / {{ eventCount }} events - hidden={{ noiseCount }} - last={{ lastEventLabel }} - - -
-
- {{ item.label }}{{ item.value }} -
-

当前 result 返回被压缩,已通过 trace replay 入口请求完整事件;完成前不要把压缩窗口当完整 trace。

-

已按共享 trace renderer 聚合隐藏 {{ noiseCount }} 条 backend/chunk/token 噪声事件;需要审计原始 payload 时可切换到原始事件。

+
+ {{ detailTitle }} +
+
+
trace
{{ trace.traceId || "pending" }}
+
rows
{{ readableRows.length }} / {{ eventCount }}
+
hidden
{{ noiseCount }}
+
last
{{ lastEventLabel }}
+
+
+ {{ item.label }}{{ item.value }} +
+

当前 result 返回被压缩,已通过 trace replay 入口请求完整事件;完成前不要把压缩窗口当完整 trace。

+

已聚合隐藏 {{ noiseCount }} 条 backend/chunk/token 噪声事件;原始 payload 仍可在原始事件中审计。

+
+ + + +
+
+
  1. {{ row.header }} diff --git a/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue b/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue index 553caea6..97cd6c8c 100644 --- a/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue +++ b/web/hwlab-cloud-web/src/components/workbench/CommandComposer.vue @@ -1,18 +1,44 @@