fix: align trace rendering stream

This commit is contained in:
lyon
2026-06-15 15:40:22 +08:00
parent 2c6574e625
commit f2614ad521
7 changed files with 131 additions and 12 deletions
+1
View File
@@ -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"
},
@@ -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";
+24
View File
@@ -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;
+10 -4
View File
@@ -69,8 +69,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, 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";
@@ -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<ChatMessage["runnerTrace"]>;
const next = {
traceId: "trc_merge",
status: "completed",
eventsCompacted: true,
events: [{ label: "trace:compacted", message: "compacted" }],
eventCount: 99
} as NonNullable<ChatMessage["runnerTrace"]>;
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", () => {
@@ -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<Chat
finalResponse: snapshot.finalResponse,
traceSummary: snapshot.traceSummary,
agentRun: snapshot.agentRun,
eventSource: snapshot.eventSource ?? "trace-api",
runnerKind: snapshot.agentRun?.adapter,
sessionMode: snapshot.agentRun?.backendProfile,
lastEventLabel: snapshot.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type,
@@ -73,8 +75,35 @@ export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: Non
if (!previous) return next;
const previousEvents = Array.isArray(previous.events) ? previous.events : [];
const nextEvents = Array.isArray(next.events) ? next.events : [];
const events = nextEvents.length >= 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<string>();
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()
};
}
+5 -1
View File
@@ -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");