From f2614ad521141657b1214d4c4e893ed055d73337 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 15 Jun 2026 15:40:22 +0800 Subject: [PATCH] fix: align trace rendering stream --- internal/cloud/access-control.test.ts | 1 + internal/cloud/code-agent-trace-store.test.ts | 19 ++++++++ internal/cloud/code-agent-trace-store.ts | 24 ++++++++++ tools/src/hwlab-cli/trace-renderer.ts | 14 ++++-- .../scripts/workbench-r1-parity.test.ts | 45 ++++++++++++++++--- .../src/composables/useTraceSubscription.ts | 34 +++++++++++++- web/hwlab-cloud-web/src/stores/workbench.ts | 6 ++- 7 files changed, 131 insertions(+), 12 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 618bb97b..9c3501ef 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -272,6 +272,7 @@ test("workbench workspace permits a new turn after AgentRun active trace reaches HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek" }, diff --git a/internal/cloud/code-agent-trace-store.test.ts b/internal/cloud/code-agent-trace-store.test.ts index 4c26a7a7..9a6c0ca5 100644 --- a/internal/cloud/code-agent-trace-store.test.ts +++ b/internal/cloud/code-agent-trace-store.test.ts @@ -23,6 +23,25 @@ test("code agent trace store keeps assistant deltas outside event count", () => assert.equal(snapshot.waitingFor, "turn/completed"); }); +test("code agent trace store dedupes repeated AgentRun source events", () => { + const traceStore = createCodeAgentTraceStore({ maxEvents: 20 }); + const traceId = "trc_trace-store-agentrun-dedupe"; + const event = { + type: "backend", + status: "running", + label: "agentrun:backend:resource-bundle-materialized", + source: "agentrun", + sourceSeq: 42, + createdAt: "2026-06-01T00:00:00.000Z" + }; + traceStore.append(traceId, event); + traceStore.append(traceId, event); + traceStore.append(traceId, { ...event, type: "assistant", label: "agentrun:assistant:message", message: "OK" }); + const snapshot = traceStore.snapshot(traceId); + assert.equal(snapshot.eventCount, 2); + assert.deepEqual(snapshot.events.map((item) => item.label), ["agentrun:backend:resource-bundle-materialized", "agentrun:assistant:message"]); +}); + test("code agent trace store retains six thousand regular events", () => { const traceStore = createCodeAgentTraceStore({ maxEvents: 6000 }); const traceId = "trc_trace-store-regular-events"; diff --git a/internal/cloud/code-agent-trace-store.ts b/internal/cloud/code-agent-trace-store.ts index 5267caa1..80125045 100644 --- a/internal/cloud/code-agent-trace-store.ts +++ b/internal/cloud/code-agent-trace-store.ts @@ -24,6 +24,7 @@ export function createCodeAgentTraceStore(options = {}) { finishedAt: null, nextSeq: 1, events: [], + sourceEventKeys: new Set(), assistantStreams: new Map(), listeners: new Set(), meta: { @@ -53,14 +54,23 @@ export function createCodeAgentTraceStore(options = {}) { function append(traceId, event = {}, meta = {}) { const trace = ensure(traceId, meta); + const incomingSourceKey = traceSourceEventKey(event); + if (incomingSourceKey && trace.sourceEventKeys.has(incomingSourceKey)) { + return trace.events.find((candidate) => traceSourceEventKey(candidate) === incomingSourceKey) ?? null; + } const normalized = normalizeTraceEvent(event, { traceId: trace.traceId, seq: trace.nextSeq, now: meta.now, fallbackRunnerKind: trace.meta.runnerKind }); + const normalizedSourceKey = traceSourceEventKey(normalized); + if (normalizedSourceKey && trace.sourceEventKeys.has(normalizedSourceKey)) { + return trace.events.find((candidate) => traceSourceEventKey(candidate) === normalizedSourceKey) ?? null; + } trace.nextSeq += 1; trace.events.push(normalized); + if (normalizedSourceKey) trace.sourceEventKeys.add(normalizedSourceKey); if (trace.events.length > maxEvents) { trace.events.splice(0, trace.events.length - maxEvents); } @@ -569,6 +579,20 @@ function timestampFor(now) { return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); } +function traceSourceEventKey(event) { + if (!event || typeof event !== "object") return null; + const source = textOrNull(event.source); + const sourceSeq = textOrNull(event.sourceSeq); + if (!source || !sourceSeq) return null; + const label = textOrNull(event.label ?? event.type) ?? "event"; + return `${source}:${sourceSeq}:${label}`; +} + +function textOrNull(value) { + const text = String(value ?? "").trim(); + return text ? text : null; +} + function positiveInteger(value, fallback) { const parsed = Number.parseInt(value ?? "", 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 4f58e618..4c09fc8e 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -69,8 +69,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr } if (rows.length > 0) return rows; if (events.length === 0) return []; - if (traceNoiseEventCount(events) === events.length) return [traceNoiseSummaryRow(effectiveTrace, events)]; - return events.filter((event) => !isNoisyTraceEvent(event) && !isRequestTraceEvent(event) && !isSetupTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event)); + return events.filter((event) => !isSuppressedTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event)); } export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string { @@ -357,7 +356,9 @@ function isRequestTraceEvent(event: TraceEvent): boolean { function isSetupTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); return /^session:|^stdio:|^prompt:|^thread:|^turn:started|^turn:start|turn\/start|codex turn|tool:codex-app-server/iu.test(label) || - /^agentrun:backend:(codex-app-server-starting|initialize:completed|thread\/start|thread\/resume|thread\/started|turn\/start|turn\/started)/iu.test(label); + /^agentrun:backend:(codex-app-server-starting|initialize:completed|thread\/start|thread\/resume|thread\/started|turn\/start|turn\/started)/iu.test(label) || + /^agentrun:(run|command|runner-job):(reuse-skipped|created)$/iu.test(label) || + /^agentrun:session-storage-recover-warning$/iu.test(label); } function isCompletionTraceEvent(event: TraceEvent): boolean { @@ -405,7 +406,7 @@ function traceSourceEventKey(event: TraceEvent | null | undefined): string | nul const source = nonEmptyString(event.source); const sourceSeq = nonEmptyString(event.sourceSeq); if (!source || !sourceSeq) return null; - return `${source}:${sourceSeq}`; + return `${source}:${sourceSeq}:${nonEmptyString(event.label ?? event.type) ?? "event"}`; } function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: number): boolean { @@ -418,12 +419,17 @@ function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: n function isNoisyTraceEvent(event: TraceEvent): boolean { const label = String(event.label ?? ""); if (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isCompletionTraceEvent(event) || isTerminalAssistantTraceEvent(event) || isAssistantTraceEvent(event)) return false; + if (/^agentrun:backend:/u.test(label) && !event.errorCode) return true; if (/token_count|outputDelta:chunk/iu.test(label)) return true; if (/^agentrun:output:(stdout|stderr)$/u.test(label)) return true; if (/^agentrun:backend:(run-created|command-created|runner-job-created|thread\/status\/changed|thread\/tokenUsage\/updated|account\/rateLimits\/updated|remoteControl\/status\/changed|configWarning|codex-app-server-closed|codex-app-server-notifications-suppressed|session-updated|command-terminal|backend-turn-finished|item\/agentMessage:(started|completed)|thread\/goal\/cleared)$/u.test(label)) return true; return event.type === "event" && !event.outputSummary && !event.message && !event.errorCode; } +function isSuppressedTraceEvent(event: TraceEvent): boolean { + return isNoisyTraceEvent(event) || isRequestTraceEvent(event) || isSetupTraceEvent(event); +} + function readableTraceLabel(event: TraceEvent): string { const label = String(event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`); if (label === "request:accepted-short-connection") return "submit short-connection"; diff --git a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts index 048639b2..f6e89b36 100644 --- a/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-r1-parity.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import type { AgentRunProvenance, ChatMessage, TraceEvent } from "../src/types/index.ts"; import { canCancelMessage, canReplayTrace, canRetryMessage, messageTraceId, renderSafeMarkdown, traceEventBody, traceEventLabel, traceIdentityText, visibleTraceEvents } from "../src/components/workbench/message-rendering.ts"; +import { mergeRunnerTrace } from "../src/composables/useTraceSubscription.ts"; import { traceDisplayRows, traceNoiseEventCount } from "../../../tools/src/hwlab-cli/trace-renderer.ts"; test("R1 markdown rendering keeps structure and strips unsafe HTML", () => { @@ -49,18 +50,52 @@ test("R1 trace rendering hides chunk noise without dropping meaningful events", assert.equal(visibleTraceEvents(events, false).length, 3); }); -test("R1 shared trace renderer collapses pure AgentRun backend noise", () => { +test("R1 shared trace renderer suppresses pure AgentRun backend noise", () => { const events = Array.from({ length: 12 }, (_, index) => ({ - label: index % 3 === 0 ? "agentrun:backend:run-created" : index % 3 === 1 ? "agentrun:backend:command-created" : "agentrun:backend:runner-job-created", + label: index % 3 === 0 ? "agentrun:backend:run-created" : index % 3 === 1 ? "agentrun:backend:resource-bundle-materialized" : "agentrun:backend:mcpServer/startupStatus/updated", status: "running", createdAt: "2026-06-15T02:00:00.000Z" })); const rows = traceDisplayRows({ traceId: "trc_noise", status: "running" }, events); assert.equal(traceNoiseEventCount(events), 12); + assert.equal(rows.length, 0); +}); + +test("R1 shared trace renderer starts at the first user-meaningful event", () => { + const rows = traceDisplayRows({ traceId: "trc_ok", status: "completed" }, [ + { label: "agentrun:request:accepted", status: "accepted" }, + { label: "agentrun:runner-job:created", status: "running", message: "runner created" }, + { label: "agentrun:backend:resource-bundle-materialized", status: "running", source: "agentrun", sourceSeq: 1, message: "bundle" }, + { label: "agentrun:assistant:message", type: "assistant", message: "OK", source: "agentrun", sourceSeq: 2 } + ]); assert.equal(rows.length, 1); - assert.equal(rows[0]?.rowId, "trace-noise-summary"); - assert.match(rows[0]?.body ?? "", /已隐藏 12 条 AgentRun backend 状态事件/u); - assert.doesNotMatch(rows[0]?.body ?? "", /run-created\ncommand-created/u); + assert.equal(rows[0]?.body, "OK"); + assert.doesNotMatch(rows[0]?.header ?? "", /runner-job|resource-bundle/u); +}); + +test("R1 trace API snapshots stay authoritative over compact result traces", () => { + const previous = { + traceId: "trc_merge", + status: "running", + eventSource: "trace-api", + events: [ + { seq: 1, label: "agentrun:backend:resource-bundle-materialized" }, + { seq: 2, label: "agentrun:assistant:message", message: "OK" } + ], + eventCount: 2 + } as NonNullable; + const next = { + traceId: "trc_merge", + status: "completed", + eventsCompacted: true, + events: [{ label: "trace:compacted", message: "compacted" }], + eventCount: 99 + } as NonNullable; + const merged = mergeRunnerTrace(previous, next); + assert.equal(merged.status, "completed"); + assert.equal(merged.eventCount, 2); + assert.equal(merged.events?.length, 2); + assert.equal(merged.events?.[1]?.message, "OK"); }); test("R1 status summary keeps the restored 23-row floor", () => { diff --git a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts index 61dd43fb..7f4b9853 100644 --- a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts +++ b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts @@ -18,6 +18,7 @@ export interface TraceSnapshot { traceSummary?: unknown; lastEventLabel?: string; waitingFor?: string; + eventSource?: string; updatedAt?: string; } @@ -61,6 +62,7 @@ export function snapshotToRunnerTrace(snapshot: TraceSnapshot): NonNullable= previousEvents.length ? nextEvents : [...previousEvents, ...nextEvents]; - return { ...previous, ...next, events, eventCount: next.eventCount ?? previous.eventCount ?? events.length, updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString() }; + const nextAuthoritative = next.fullTraceLoaded === true || next.eventSource === "trace-api"; + const previousAuthoritative = previous.fullTraceLoaded === true || previous.eventSource === "trace-api"; + const keepPreviousEvents = previousAuthoritative && !nextAuthoritative && (next.eventsCompacted === true || nextEvents.length < previousEvents.length); + const events = nextAuthoritative ? nextEvents : keepPreviousEvents ? previousEvents : nextEvents.length >= previousEvents.length ? nextEvents : mergeTraceEvents(previousEvents, nextEvents); + const eventCount = keepPreviousEvents ? previous.eventCount ?? events.length : next.eventCount ?? previous.eventCount ?? events.length; + return { ...previous, ...next, events, eventCount, updatedAt: next.updatedAt ?? previous.updatedAt ?? new Date().toISOString() }; +} + +function mergeTraceEvents(previousEvents: TraceEvent[], nextEvents: TraceEvent[]): TraceEvent[] { + const merged: TraceEvent[] = []; + const keys = new Set(); + for (const event of [...previousEvents, ...nextEvents]) { + const key = traceEventIdentity(event); + if (key && keys.has(key)) continue; + if (key) keys.add(key); + merged.push(event); + } + return merged; +} + +function traceEventIdentity(event: TraceEvent): string | null { + const source = typeof event.source === "string" || typeof event.source === "number" ? String(event.source) : ""; + const sourceSeq = typeof event.sourceSeq === "string" || typeof event.sourceSeq === "number" ? String(event.sourceSeq) : ""; + const label = typeof event.label === "string" ? event.label : typeof event.type === "string" ? event.type : ""; + if (source && sourceSeq) return `${source}:${sourceSeq}:${label || "event"}`; + const seq = typeof event.seq === "number" || typeof event.seq === "string" ? String(event.seq) : ""; + if (seq) return `seq:${seq}:${label || "event"}`; + const createdAt = typeof event.createdAt === "string" ? event.createdAt : ""; + return label && createdAt ? `${label}:${createdAt}` : null; } export function mergeTraceResults(terminal: AgentChatResultResponse, trace: TraceSnapshot | null): AgentChatResultResponse { @@ -199,6 +228,7 @@ function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse) finalResponse: result.finalResponse, traceSummary: result.traceSummary, lastEventLabel: result.lastEventLabel ?? lastEvent?.label ?? lastEvent?.type, + eventSource: "trace-api", updatedAt: new Date().toISOString() }; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index f11ed2dd..6ce80ba2 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -286,7 +286,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { function completeTrace(traceId: string, result: AgentChatResultResponse): void { const text = firstNonEmptyString(result.assistantText, typeof result.reply === "string" ? result.reply : result.reply?.content, result.text, result.summary) ?? "Code Agent 已完成,但没有返回可展示的 final response。"; - markMessage(traceId, { status: result.status === "completed" ? "completed" : statusFromResult(result.status), text, runnerTrace: result.runnerTrace ?? null }); + messages.value = messages.value.map((message) => { + if (message.traceId !== traceId || message.role !== "agent") return message; + const runnerTrace = result.runnerTrace ? mergeRunnerTrace(message.runnerTrace, result.runnerTrace) : message.runnerTrace ?? null; + return { ...message, status: result.status === "completed" ? "completed" : statusFromResult(result.status), text, runnerTrace, updatedAt: new Date().toISOString() }; + }); chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-terminal");