843 lines
38 KiB
TypeScript
843 lines
38 KiB
TypeScript
export interface TraceEventRow {
|
||
rowId: string;
|
||
seq: number | null;
|
||
sequenceAuthority: TraceSequenceAuthority;
|
||
tone: "ok" | "blocked" | "warn" | "source";
|
||
toolState?: TraceToolState;
|
||
statusLabel?: string;
|
||
header: string;
|
||
body: string | null;
|
||
preview?: string | null;
|
||
terminal?: boolean;
|
||
bodyFormat?: "markdown" | "text";
|
||
}
|
||
|
||
export type TraceToolState = "running" | "success" | "failure" | "neutral";
|
||
|
||
export type TraceEvent = Record<string, unknown>;
|
||
type TraceClockFormatter = (value: string) => string;
|
||
export type TraceSequenceAuthority = "projected" | "source";
|
||
export type TraceFinalResponsePlacement = "trace" | "outer";
|
||
|
||
export interface TraceDisplayRowsOptions {
|
||
formatClock?: TraceClockFormatter;
|
||
finalResponsePlacement?: TraceFinalResponsePlacement;
|
||
outerFinalResponseText?: string | null;
|
||
}
|
||
|
||
interface ResolvedTraceDisplayRowsOptions extends TraceDisplayRowsOptions {
|
||
sequenceAuthority: TraceSequenceAuthority;
|
||
finalResponsePlacement: TraceFinalResponsePlacement;
|
||
}
|
||
|
||
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] {
|
||
const displayOptions: ResolvedTraceDisplayRowsOptions = {
|
||
...options,
|
||
sequenceAuthority: traceSequenceAuthority(trace),
|
||
finalResponsePlacement: options.finalResponsePlacement ?? "trace"
|
||
};
|
||
const orderedEvents = traceEventsForDisplay(
|
||
events.filter((event) => hasTraceSequence(event, displayOptions.sequenceAuthority)),
|
||
displayOptions.sequenceAuthority
|
||
);
|
||
const effectiveTrace = traceWithInferredStart(trace, orderedEvents);
|
||
const finalResponseText = nonEmptyString(options.outerFinalResponseText) ?? traceFinalResponseText(effectiveTrace);
|
||
const outerFinalAssistantEvent = displayOptions.finalResponsePlacement === "outer" && finalResponseText
|
||
? traceOuterFinalAssistantEvent(orderedEvents)
|
||
: null;
|
||
const hasCompletionEvent = orderedEvents.some(isCompletionTraceEvent);
|
||
const rows: TraceEventRow[] = [];
|
||
let completionEvent: TraceEvent | null = null;
|
||
const renderedSourceEvents = new Set<string>();
|
||
const renderedToolIdentities = new Set<string>();
|
||
for (let index = 0; index < orderedEvents.length; index += 1) {
|
||
const event = orderedEvents[index];
|
||
const sourceEventKey = traceSourceEventKey(event);
|
||
if (sourceEventKey) {
|
||
if (renderedSourceEvents.has(sourceEventKey)) continue;
|
||
renderedSourceEvents.add(sourceEventKey);
|
||
}
|
||
if (!event || isNoisyTraceEvent(event)) continue;
|
||
if (isSupersededTraceItemLifecycleEvent(event, orderedEvents, index)) continue;
|
||
if (isToolTraceEvent(event)) {
|
||
const identity = toolIdentity(event);
|
||
if (identity) {
|
||
if (renderedToolIdentities.has(identity)) continue;
|
||
renderedToolIdentities.add(identity);
|
||
}
|
||
rows.push(traceToolCallRow(event, displayOptions));
|
||
continue;
|
||
}
|
||
if (isDiffTraceEvent(event)) {
|
||
rows.push(traceDiffRow(event, displayOptions));
|
||
continue;
|
||
}
|
||
if (isRequestTraceEvent(event)) {
|
||
continue;
|
||
}
|
||
if (isSetupTraceEvent(event)) {
|
||
continue;
|
||
}
|
||
if (isTerminalAssistantTraceEvent(event)) {
|
||
if (isOuterFinalResponseTraceEvent(event, displayOptions, outerFinalAssistantEvent)) continue;
|
||
if (hasCompletionEvent) continue;
|
||
if (finalResponseText) {
|
||
rows.push(traceFinalResponseRow(event, finalResponseText, displayOptions));
|
||
} else {
|
||
rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: true }, displayOptions));
|
||
}
|
||
continue;
|
||
}
|
||
if (isAssistantTraceEvent(event)) {
|
||
if (isOuterFinalResponseTraceEvent(event, displayOptions, outerFinalAssistantEvent)) continue;
|
||
rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: false }, displayOptions));
|
||
continue;
|
||
}
|
||
if (isCompletionTraceEvent(event)) {
|
||
completionEvent ??= event;
|
||
continue;
|
||
}
|
||
rows.push(traceDisplayRow(event, displayOptions));
|
||
}
|
||
if (completionEvent) rows.push(traceCompletionSummaryRow(completionEvent, displayOptions));
|
||
const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, orderedEvents, displayOptions);
|
||
if (progressRow) rows.push(progressRow);
|
||
if (rows.length > 0) return rows;
|
||
if (orderedEvents.length === 0) return [];
|
||
return orderedEvents
|
||
.filter((event, index) => !isSupersededTraceItemLifecycleEvent(event, orderedEvents, index))
|
||
.filter((event) => !isSuppressedTraceEvent(event))
|
||
.filter((event) => !isOuterFinalResponseTraceEvent(event, displayOptions, outerFinalAssistantEvent))
|
||
.map((event) => traceDisplayRow(event, displayOptions));
|
||
}
|
||
|
||
function isOuterFinalResponseTraceEvent(
|
||
event: TraceEvent,
|
||
options: ResolvedTraceDisplayRowsOptions,
|
||
outerFinalAssistantEvent: TraceEvent | null
|
||
): boolean {
|
||
if (options.finalResponsePlacement !== "outer") return false;
|
||
return event === outerFinalAssistantEvent;
|
||
}
|
||
|
||
function traceOuterFinalAssistantEvent(events: TraceEvent[]): TraceEvent | null {
|
||
const visible = events
|
||
.map((event, index) => ({ event, index }))
|
||
.filter(({ event, index }) => isAssistantTraceEvent(event) && !traceAssistantLifecycleEventIsSuperseded(event, events, index));
|
||
if (visible.length === 0) return null;
|
||
const authoritative = [...visible].reverse().find(({ event }) => event.replyAuthority === true || event.final === true);
|
||
if (authoritative) return authoritative.event;
|
||
const seal = [...events].reverse().find((event) => event.finalSeal === true || nonEmptyString(event.label) === "agentrun:backend:assistant-message-final-sealed");
|
||
if (seal) {
|
||
const replyRef = isRecord(seal.replyRef) ? seal.replyRef : null;
|
||
const itemId = nonEmptyString(replyRef?.itemId ?? seal.itemId);
|
||
if (itemId) {
|
||
const matched = [...visible].reverse().find(({ event }) => nonEmptyString(event.itemId) === itemId);
|
||
if (matched) return matched.event;
|
||
}
|
||
const sealIndex = events.indexOf(seal);
|
||
const preceding = [...visible].reverse().find(({ index }) => index < sealIndex);
|
||
if (preceding) return preceding.event;
|
||
}
|
||
let terminalIndex = -1;
|
||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||
const event = events[index];
|
||
if (!event || !(isCompletionTraceEvent(event) || event.terminal === true || event.eventType === "terminal" || event.type === "result")) continue;
|
||
terminalIndex = index;
|
||
break;
|
||
}
|
||
const precedingTerminal = terminalIndex >= 0 ? [...visible].reverse().find(({ index }) => index < terminalIndex) : null;
|
||
return precedingTerminal?.event ?? visible.at(-1)?.event ?? null;
|
||
}
|
||
|
||
function traceSequenceAuthority(trace: Record<string, unknown>): TraceSequenceAuthority {
|
||
return nonEmptyString(trace.eventSource) === "hwlab-kafka-sse" ? "source" : "projected";
|
||
}
|
||
|
||
function hasTraceSequence(event: TraceEvent | null | undefined, authority: TraceSequenceAuthority): boolean {
|
||
return traceEventDisplaySeq(event, authority) !== null;
|
||
}
|
||
|
||
export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string {
|
||
if (!Array.isArray(rows) || rows.length === 0) return `_No rendered trace rows were returned._`;
|
||
return rows.map((row, index) => renderTraceRowMarkdown(row, index + 1)).join("\n\n");
|
||
}
|
||
|
||
export function renderTraceRowMarkdown(row: TraceEventRow, index = 1): string {
|
||
if (!isToolTraceRow(row)) return renderTraceMessageRowMarkdown(row);
|
||
const body = renderTraceToolDetailBody(row) || row.header || `trace row ${index}`;
|
||
const summary = traceToolSummary(row);
|
||
const accessibleLabel = traceToolAccessibilityLabel(row);
|
||
const visibleSummary = [
|
||
`<code>${escapeHtml(row.header || `trace row ${index}`)}</code>`,
|
||
summary ? escapeHtml(summary) : null
|
||
].filter(Boolean).join(" ");
|
||
return [
|
||
`- <details>`,
|
||
` <summary aria-label="${escapeHtmlAttribute(accessibleLabel)}" title="${escapeHtmlAttribute(accessibleLabel)}">${visibleSummary}</summary>`,
|
||
``,
|
||
indentTraceMarkdownBlock(traceMarkdownFence(row.bodyFormat === "markdown" ? "markdown" : "text", body)),
|
||
``,
|
||
` </details>`
|
||
].join("\n");
|
||
}
|
||
|
||
function renderTraceMessageRowMarkdown(row: TraceEventRow): string {
|
||
const body = row.body?.trimEnd();
|
||
if (body) return traceBoldMarkdown(body);
|
||
return traceBoldMarkdown(row.header || `_No readable trace row body._`);
|
||
}
|
||
|
||
function traceEventsForDisplay(events: TraceEvent[] = [], authority: TraceSequenceAuthority): TraceEvent[] {
|
||
if (authority === "source") return [...events];
|
||
return [...events].sort((left, right) => {
|
||
const leftSeq = traceEventDisplaySeq(left, authority);
|
||
const rightSeq = traceEventDisplaySeq(right, authority);
|
||
if (leftSeq !== null || rightSeq !== null) {
|
||
if (leftSeq === null) return 1;
|
||
if (rightSeq === null) return -1;
|
||
if (leftSeq !== rightSeq) return leftSeq - rightSeq;
|
||
}
|
||
const leftTime = traceEventTimestampMs(left);
|
||
const rightTime = traceEventTimestampMs(right);
|
||
if (leftTime !== null || rightTime !== null) {
|
||
if (leftTime === null) return 1;
|
||
if (rightTime === null) return -1;
|
||
if (leftTime !== rightTime) return leftTime - rightTime;
|
||
}
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
function traceEventDisplaySeq(event: TraceEvent | null | undefined, authority: TraceSequenceAuthority): number | null {
|
||
return numberOrNull(authority === "source" ? event?.sourceSeq : event?.projectedSeq);
|
||
}
|
||
|
||
function traceEventIdentityToken(event: TraceEvent, authority: TraceSequenceAuthority): string | number | null {
|
||
if (authority === "source") {
|
||
const sourceEventId = nonEmptyString(event.sourceEventId);
|
||
if (sourceEventId) return sourceEventId;
|
||
const runId = nonEmptyString(event.runId);
|
||
const sourceSeq = traceEventDisplaySeq(event, authority);
|
||
if (runId && sourceSeq !== null) return `${runId}:${sourceSeq}`;
|
||
return sourceSeq;
|
||
}
|
||
const seq = traceEventDisplaySeq(event, authority);
|
||
return seq ?? nonEmptyString(event.projectedSeq);
|
||
}
|
||
|
||
function isToolTraceRow(row: TraceEventRow): boolean {
|
||
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
|
||
}
|
||
|
||
export function traceToolSummary(row: TraceEventRow): string {
|
||
if (row.preview) return compactTraceOneLine(row.preview, 140);
|
||
const firstLine = traceMarkdownPreviewLines(row.body).find((line) => !/^(stdout|stderr|exitCode|rowId|terminal):/iu.test(line.trim()));
|
||
return compactTraceOneLine(stripTraceToolOutputFromSummary(firstLine || row.header || "tool call"), 140);
|
||
}
|
||
|
||
export function traceToolAccessibilityLabel(row: TraceEventRow): string {
|
||
const statusLabel = row.statusLabel ?? traceToolStatusLabel(traceToolStateFromTone(row.tone));
|
||
return `${statusLabel}:${[row.header, traceToolSummary(row)].filter(Boolean).join(";")}`;
|
||
}
|
||
|
||
function stripTraceToolOutputFromSummary(value: string): string {
|
||
return value.replace(/\s+(stdout:|stderr:|exitCode=).*$/isu, "").trim();
|
||
}
|
||
|
||
function traceToolCallRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const command = cleanShellCommand(event.command);
|
||
const toolState = traceToolState(event);
|
||
const toolName = traceToolName(event, command);
|
||
const body = isFileChangeTraceEvent(event) ? traceFileChangeBody(event) : traceToolCallBody(event, command);
|
||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||
return {
|
||
rowId: `tool:${event.itemId ?? eventKey ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`,
|
||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: traceEventTone(event),
|
||
toolState,
|
||
statusLabel: traceToolStatusLabel(toolState),
|
||
header: traceEventClock(event, options),
|
||
body,
|
||
preview: isFileChangeTraceEvent(event) ? traceFileChangePreview(event) : traceToolPreview(command, toolName),
|
||
bodyFormat: "text"
|
||
};
|
||
}
|
||
|
||
function isFileChangeTraceEvent(event: TraceEvent): boolean {
|
||
return nonEmptyString(event.toolName ?? event.toolType) === "fileChange";
|
||
}
|
||
|
||
function traceFileChangePreview(event: TraceEvent): string {
|
||
const paths = (Array.isArray(event.changes) ? event.changes : [])
|
||
.filter(isRecord)
|
||
.map((change) => nonEmptyString(change.path))
|
||
.filter((path): path is string => Boolean(path));
|
||
return compactTraceOneLine(paths.length > 0 ? `fileChange ${paths.join(", ")}` : "fileChange", 140);
|
||
}
|
||
|
||
function traceFileChangeBody(event: TraceEvent): string | null {
|
||
const changes = (Array.isArray(event.changes) ? event.changes : []).filter(isRecord);
|
||
if (changes.length === 0) return null;
|
||
return changes.map((change) => {
|
||
const path = nonEmptyString(change.path) ?? "(path missing)";
|
||
const kind = nonEmptyString(change.kind) ?? "update";
|
||
const diff = nonEmptyString(change.diff) ?? "";
|
||
return [`path: ${path}`, `kind: ${kind}`, "diff:", diff].filter(Boolean).join("\n");
|
||
}).join("\n\n");
|
||
}
|
||
|
||
function isDiffTraceEvent(event: TraceEvent): boolean {
|
||
return event.type === "diff" || event.eventType === "diff" || nonEmptyString(event.label) === "agentrun:diff";
|
||
}
|
||
|
||
function traceDiffRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||
return {
|
||
rowId: `tool:diff:${eventKey ?? event.createdAt ?? "updated"}`,
|
||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: traceEventTone(event),
|
||
toolState: "success",
|
||
statusLabel: "文件 diff 已更新",
|
||
header: traceEventClock(event, options),
|
||
body: nonEmptyString(event.diff ?? event.message),
|
||
preview: "turn/diff/updated",
|
||
bodyFormat: "text"
|
||
};
|
||
}
|
||
|
||
function traceToolPreview(command: string | null, fallbackToolName: string): string {
|
||
return compactTraceOneLine(command ?? fallbackToolName, 140);
|
||
}
|
||
|
||
function traceToolName(event: TraceEvent, command: string | null): string {
|
||
const direct = nonEmptyString(event.toolName);
|
||
if (direct && !isProtocolToolLifecycleName(direct) && !isGenericToolName(direct)) return direct;
|
||
const label = String(event.label ?? "")
|
||
.replace(/^agentrun:tool:/u, "")
|
||
.replace(/^tool:/u, "")
|
||
.replace(/^item\//u, "")
|
||
.replace(/\/outputDelta$/iu, "")
|
||
.replace(/:(?:started|running|completed|succeeded|failed|blocked|error|timeout|canceled|cancelled)$/iu, "");
|
||
if (label && !isProtocolToolLifecycleName(label) && !isGenericToolName(label)) return label;
|
||
const commandName = traceToolNameFromCommand(command);
|
||
if (commandName) return commandName;
|
||
if (direct && !isProtocolToolLifecycleName(direct)) return direct;
|
||
const previewName = traceToolNameFromPreview(event);
|
||
if (previewName) return previewName;
|
||
return "tool call";
|
||
}
|
||
|
||
function isGenericToolName(value: unknown): boolean {
|
||
return /^(commandExecution|tool|tool_call|tool-call|shell|command|unknown)$/iu.test(String(value ?? "").trim());
|
||
}
|
||
|
||
function isProtocolToolLifecycleName(value: unknown): boolean {
|
||
return /^(?:item\/)?(?:started|running|completed|succeeded|failed|blocked|error|timeout|canceled|cancelled)$/iu.test(String(value ?? "").trim());
|
||
}
|
||
|
||
function traceToolNameFromCommand(command: string | null): string | null {
|
||
const text = cleanTraceText(command);
|
||
const segments = text.split(/\s*(?:&&|\|\||;)\s*/u).map((value) => value.trim()).filter(Boolean);
|
||
const candidate = segments.find((value) => !/^(?:cd|export|set|source|umask)\b/u.test(value)) ?? segments[0] ?? text;
|
||
const normalized = candidate
|
||
.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]+\s+)+/u, "")
|
||
.replace(/^(?:sudo\s+|env\s+|command\s+)+/u, "")
|
||
.replace(/^timeout\s+\S+\s+/u, "");
|
||
const match = normalized.match(/^([A-Za-z./][A-Za-z0-9_./:-]{0,80})(?:\s|$)/u);
|
||
return match?.[1]?.split("/").filter(Boolean).at(-1) ?? null;
|
||
}
|
||
|
||
function traceToolNameFromPreview(event: TraceEvent): string | null {
|
||
const preview = traceEventText(event, ["output", "outputText", "outputSummary", "stdoutSummary", "message"]);
|
||
return jsonPreviewStringField(preview, "toolName") ?? jsonPreviewStringField(preview, "type") ?? jsonPreviewStringField(preview, "name");
|
||
}
|
||
|
||
function jsonPreviewStringField(text: string | null, key: string): string | null {
|
||
const body = String(text ?? "");
|
||
if (!body || !/^[A-Za-z][A-Za-z0-9_]*$/u.test(key)) return null;
|
||
const match = body.match(new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`, "u"));
|
||
if (!match) return null;
|
||
try {
|
||
const parsed = JSON.parse(`"${match[1]}"`);
|
||
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
||
} catch {
|
||
return match[1]?.trim() || null;
|
||
}
|
||
}
|
||
|
||
function traceToolCallBody(event: TraceEvent, command: string | null): string | null {
|
||
const stdout = traceEventText(event, ["stdout", "stdoutText", "stdoutSummary"]);
|
||
const stderr = traceEventText(event, ["stderr", "stderrText", "stderrSummary"]);
|
||
const output = traceEventText(event, ["output", "outputText", "outputSummary"]);
|
||
const distinctOutput = output && output !== stdout && output !== stderr ? output : null;
|
||
const outputBytes = numberOrNull(event.outputBytes);
|
||
const lines = [
|
||
command,
|
||
traceDetailBlock("stdout", stdout),
|
||
traceDetailBlock("stderr", stderr),
|
||
traceDetailBlock("output", distinctOutput),
|
||
event.commandTruncated === true ? "commandTruncated=true" : null,
|
||
outputBytes !== null ? `outputBytes=${outputBytes}` : null,
|
||
event.outputTruncated === true ? "outputTruncated=true" : null,
|
||
event.exitCode !== undefined ? `exitCode=${event.exitCode}` : null
|
||
].map(cleanTraceDetailText).filter(Boolean);
|
||
return lines.length > 0 ? lines.join("\n") : null;
|
||
}
|
||
|
||
export function traceNoiseEventCount(events: TraceEvent[] = []): number {
|
||
return Array.isArray(events) ? events.filter((event) => isNoisyTraceEvent(event)).length : 0;
|
||
}
|
||
|
||
function traceAssistantMessageRow(trace: Record<string, unknown>, event: TraceEvent, { terminal }: { terminal: boolean }, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const text = traceAssistantEventText(trace, event);
|
||
const index = Number.isInteger(event.messageIndex) ? Number(event.messageIndex) : null;
|
||
const count = Number.isInteger(event.messageCount) ? Number(event.messageCount) : null;
|
||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||
return {
|
||
rowId: `event:${eventKey ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`,
|
||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: "ok",
|
||
header: `${traceEventClock(event, options)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`,
|
||
terminal: terminal ? true : undefined,
|
||
body: text || null,
|
||
bodyFormat: "markdown"
|
||
};
|
||
}
|
||
|
||
function traceAssistantEventText(trace: Record<string, unknown>, event: TraceEvent): string {
|
||
return cleanTraceDetailText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? "");
|
||
}
|
||
|
||
function traceFinalResponseRow(event: TraceEvent, finalText: string, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||
return {
|
||
rowId: `trace-final-response:${eventKey ?? "completed"}`,
|
||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: "ok",
|
||
header: `${traceEventClock(event, options)} 助手最终消息`,
|
||
terminal: true,
|
||
body: finalText,
|
||
bodyFormat: "markdown"
|
||
};
|
||
}
|
||
|
||
function traceFinalResponseText(trace: Record<string, unknown>): string | null {
|
||
const response = trace.finalResponse && typeof trace.finalResponse === "object" ? trace.finalResponse as Record<string, unknown> : null;
|
||
return nonEmptyString(response?.text ?? response?.content ?? response?.message);
|
||
}
|
||
|
||
function traceCompletionSummaryRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||
return {
|
||
rowId: `trace-completion:${eventKey ?? "turn"}`,
|
||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: traceEventTone(event),
|
||
header: `${traceEventClock(event, options)} 轮次完成`,
|
||
body: null
|
||
};
|
||
}
|
||
|
||
function traceNoiseSummaryRow(trace: Record<string, unknown>, events: TraceEvent[], options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const lastEvent = events.at(-1);
|
||
const status = nonEmptyString(trace.status ?? trace.traceStatus) ?? "running";
|
||
const lastLabel = lastEvent ? readableTraceLabel(lastEvent) : "未观测";
|
||
return {
|
||
rowId: "trace-noise-summary",
|
||
seq: traceEventDisplaySeq(lastEvent, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: lastEvent ? traceEventTone(lastEvent) : "source",
|
||
header: `Trace ${status},等待可读事件`,
|
||
body: `已隐藏 ${events.length} 条 AgentRun backend 状态事件。最新原始事件:${lastLabel}。`,
|
||
bodyFormat: "text"
|
||
};
|
||
}
|
||
|
||
function traceBackendProgressSummaryRow(trace: Record<string, unknown>, rows: TraceEventRow[], events: TraceEvent[], options: ResolvedTraceDisplayRowsOptions): TraceEventRow | null {
|
||
if (!Array.isArray(events) || events.length === 0) return null;
|
||
if (rows.some((row) => row.rowId.startsWith("tool:") || /助手|轮次完成/u.test(row.header))) return null;
|
||
const lastRenderedSeq = Math.max(0, ...rows.map((row) => row.seq ?? 0).filter(Number.isFinite));
|
||
const hiddenProgress = events.filter((event) => {
|
||
const seq = traceEventDisplaySeq(event, options.sequenceAuthority) ?? 0;
|
||
return seq > lastRenderedSeq && (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isNoisyTraceEvent(event));
|
||
});
|
||
return hiddenProgress.length > 0 ? traceNoiseSummaryRow(trace, hiddenProgress, options) : null;
|
||
}
|
||
|
||
function traceDisplayRow(event: TraceEvent, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||
const label = readableTraceLabel(event);
|
||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||
return {
|
||
rowId: `event:${eventKey ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`,
|
||
seq: traceEventDisplaySeq(event, options.sequenceAuthority),
|
||
sequenceAuthority: options.sequenceAuthority,
|
||
tone: traceEventTone(event),
|
||
header: `${traceEventClock(event, options)} ${label}`.trim(),
|
||
body: traceDisplayBody(event)
|
||
};
|
||
}
|
||
|
||
function assistantStreamText(trace: Record<string, unknown>, event: TraceEvent): string {
|
||
const streams = Array.isArray(trace.assistantStreams) ? trace.assistantStreams.filter(isRecord) : [];
|
||
const itemId = nonEmptyString(event.itemId ?? event.messageId ?? event.id);
|
||
const matched = itemId ? streams.find((stream) => [stream.itemId, stream.messageId, stream.id].map(nonEmptyString).includes(itemId)) : null;
|
||
return nonEmptyString(matched?.text ?? streams.at(-1)?.text) ?? "";
|
||
}
|
||
|
||
function isRequestTraceEvent(event: TraceEvent): boolean {
|
||
const label = String(event.label ?? "");
|
||
return label === "agentrun:request:accepted" || label === "request:accepted-short-connection" || label === "request:accepted" || label === "request:received";
|
||
}
|
||
|
||
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:run:(reuse-skipped|reused)$/iu.test(label) ||
|
||
/^agentrun:command:created$/iu.test(label) ||
|
||
/^agentrun:runner-job:(created|ensured|reused)$/iu.test(label) ||
|
||
/^agentrun:session-storage-recover-warning$/iu.test(label);
|
||
}
|
||
|
||
function isCompletionTraceEvent(event: TraceEvent): boolean {
|
||
const label = String(event.label ?? "");
|
||
return label.startsWith("turn:completed") || label.startsWith("result:completed") || label === "agentrun:backend:turn/completed" || label === "agentrun:terminal:completed" || label === "agentrun:result:completed";
|
||
}
|
||
|
||
function isAssistantTraceEvent(event: TraceEvent): boolean {
|
||
const label = String(event.label ?? "");
|
||
return label === "agentrun:assistant:message"
|
||
|| label === "agentrun:assistant:progress"
|
||
|| label === "assistant:message"
|
||
|| event.type === "assistant_progress"
|
||
|| event.eventType === "assistant_progress"
|
||
|| event.type === "assistant_message"
|
||
|| event.type === "assistant";
|
||
}
|
||
|
||
function isTerminalAssistantTraceEvent(event: TraceEvent): boolean {
|
||
const label = String(event.label ?? "");
|
||
if (label === "assistant:completed") return true;
|
||
if (label === "agentrun:assistant:message") return event.replyAuthority === true || event.final === true;
|
||
return event.type === "assistant_message" && (event.status === "completed" || event.final === true || event.terminal === true);
|
||
}
|
||
|
||
function isToolTraceEvent(event: TraceEvent): boolean {
|
||
const label = String(event.label ?? "");
|
||
const type = String(event.type ?? "");
|
||
const toolName = String(event.toolName ?? "");
|
||
return type === "tool_call" || toolName === "commandExecution" || /commandExecution|^agentrun:tool:/u.test(label);
|
||
}
|
||
|
||
function isToolStartTraceEvent(event: TraceEvent): boolean {
|
||
const status = String(event.status ?? "").toLowerCase();
|
||
const label = String(event.label ?? "");
|
||
return ["started", "running", "inprogress", "in-progress", "output_chunk"].includes(status) || /:started$|\/started$/u.test(label);
|
||
}
|
||
|
||
function isToolCompleteTraceEvent(event: TraceEvent): boolean {
|
||
const status = String(event.status ?? "").toLowerCase();
|
||
const label = String(event.label ?? "");
|
||
return ["completed", "succeeded", "failed", "blocked", "timeout", "canceled", "cancelled"].includes(status) || /:completed$|\/completed$/u.test(label);
|
||
}
|
||
|
||
function toolIdentity(event: TraceEvent): string | null {
|
||
return nonEmptyString(event.itemId ?? event.id ?? event.command);
|
||
}
|
||
|
||
function traceSourceEventKey(event: TraceEvent | null | undefined): string | null {
|
||
if (!event || typeof event !== "object") return null;
|
||
const sourceEventId = nonEmptyString(event.sourceEventId);
|
||
if (sourceEventId) return `source-event:${sourceEventId}`;
|
||
const runId = nonEmptyString(event.runId);
|
||
const sourceSeq = nonEmptyString(event.sourceSeq);
|
||
if (runId && sourceSeq) return `run-source:${runId}:${sourceSeq}`;
|
||
const source = nonEmptyString(event.source);
|
||
if (!source || !sourceSeq) return null;
|
||
return `${source}:${sourceSeq}:${nonEmptyString(event.label ?? event.type) ?? "event"}`;
|
||
}
|
||
|
||
function isSupersededTraceItemLifecycleEvent(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||
return isSupersededToolStart(event, events, index) || traceAssistantLifecycleEventIsSuperseded(event, events, index);
|
||
}
|
||
|
||
function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||
if (!isToolStartTraceEvent(event)) return false;
|
||
const identity = toolIdentity(event);
|
||
if (!identity) return false;
|
||
return events.slice(index + 1).some((candidate) => candidate && isToolTraceEvent(candidate) && isToolCompleteTraceEvent(candidate) && toolIdentity(candidate) === identity);
|
||
}
|
||
|
||
export function traceAssistantLifecycleEventIsSuperseded(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||
if (!isAssistantTraceEvent(event)) return false;
|
||
const identity = nonEmptyString(event.itemId);
|
||
if (!identity) return false;
|
||
const rank = traceAssistantLifecycleRank(event);
|
||
const sequence = traceAssistantLifecycleSequence(event);
|
||
return events.some((candidate, candidateIndex) => {
|
||
if (candidateIndex === index || !candidate || !isAssistantTraceEvent(candidate) || nonEmptyString(candidate.itemId) !== identity) return false;
|
||
const candidateRank = traceAssistantLifecycleRank(candidate);
|
||
if (candidateRank !== rank) return candidateRank > rank;
|
||
const candidateSequence = traceAssistantLifecycleSequence(candidate);
|
||
if (sequence !== null && candidateSequence !== null && candidateSequence !== sequence) return candidateSequence > sequence;
|
||
return candidateIndex > index;
|
||
});
|
||
}
|
||
|
||
function traceAssistantLifecycleRank(event: TraceEvent): number {
|
||
if (event.replyAuthority === true || event.final === true) return 3;
|
||
if (traceAssistantLifecycleProgress(event)) return 1;
|
||
return 2;
|
||
}
|
||
|
||
function traceAssistantLifecycleProgress(event: TraceEvent): boolean {
|
||
return event.progress === true
|
||
|| event.type === "assistant_progress"
|
||
|| event.eventType === "assistant_progress"
|
||
|| nonEmptyString(event.label) === "agentrun:assistant:progress"
|
||
|| nonEmptyString(event.assistantSource) === "agent-message-delta-progress";
|
||
}
|
||
|
||
function traceAssistantLifecycleSequence(event: TraceEvent): number | null {
|
||
return numberOrNull(event.sourceSeq ?? event.projectedSeq ?? event.seq);
|
||
}
|
||
|
||
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";
|
||
if (label === "request:accepted") return "request accepted";
|
||
if (label.startsWith("turn:completed")) return "turn completed";
|
||
if (label.startsWith("result:completed")) return "result ready";
|
||
return label.replace(/^tool:/u, "").replace(/^assistant:/u, "assistant ").replace(/:completed$/u, " completed").replace(/:started$/u, " started");
|
||
}
|
||
|
||
function traceDisplayBody(event: TraceEvent): string | null {
|
||
const command = cleanShellCommand(event.command);
|
||
const lines = [
|
||
command,
|
||
traceDetailBlock("stdout", traceEventText(event, ["stdout", "stdoutText", "stdoutSummary"])),
|
||
traceDetailBlock("stderr", traceEventText(event, ["stderr", "stderrText", "stderrSummary"])),
|
||
traceEventText(event, ["output", "outputText", "outputSummary"]),
|
||
traceEventText(event, ["prompt", "promptText", "promptSummary"]),
|
||
traceEventText(event, ["message"]),
|
||
traceEventText(event, ["chunk"])
|
||
].map(cleanTraceDetailText).filter(Boolean);
|
||
return lines.length > 0 ? lines.join("\n") : null;
|
||
}
|
||
|
||
function traceMarkdownPreviewLines(body: string | null | undefined): string[] {
|
||
return String(body ?? "")
|
||
.replace(/\r\n|\r/gu, "\n")
|
||
.trim()
|
||
.split("\n")
|
||
.map((line) => line.trimEnd())
|
||
.filter((line) => Boolean(line.trim()));
|
||
}
|
||
|
||
function renderTraceRowDetailBody(row: TraceEventRow): string {
|
||
return [
|
||
row.body?.trimEnd() ?? "",
|
||
row.rowId ? `rowId: ${row.rowId}` : "",
|
||
row.terminal === true ? "terminal: true" : ""
|
||
].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function renderTraceToolDetailBody(row: TraceEventRow): string {
|
||
return normalizeTraceToolDetailBody(renderTraceRowDetailBody(row));
|
||
}
|
||
|
||
function normalizeTraceToolDetailBody(value: string): string {
|
||
return String(value ?? "")
|
||
.replace(/\r\n|\r/gu, "\n")
|
||
.replace(/[^\S\n]+(stdout:)[^\S\n]*/giu, "\n$1\n")
|
||
.replace(/[^\S\n]+(stderr:)[^\S\n]*/giu, "\n$1\n")
|
||
.replace(/[^\S\n]+(exitCode=)/giu, "\n$1")
|
||
.replace(/\n{3,}/gu, "\n\n")
|
||
.trimEnd();
|
||
}
|
||
|
||
function traceBoldMarkdown(value: string): string {
|
||
const body = String(value ?? "").trimEnd();
|
||
if (!body) return "";
|
||
return body.split("\n").map((line) => {
|
||
if (!line.trim()) return "";
|
||
return `**${escapeMarkdownStrongText(line)}**`;
|
||
}).join("\n");
|
||
}
|
||
|
||
function escapeMarkdownStrongText(value: string): string {
|
||
return escapeHtml(value).replace(/\\/gu, "\\\\").replace(/\*/gu, "\\*");
|
||
}
|
||
|
||
function indentTraceMarkdownBlock(value: string): string {
|
||
return String(value).split("\n").map((line) => ` ${line}`).join("\n");
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return String(value).replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
||
}
|
||
|
||
function escapeHtmlAttribute(value: string): string {
|
||
return escapeHtml(value).replace(/"/gu, """).replace(/'/gu, "'");
|
||
}
|
||
|
||
function traceMarkdownFence(language: string, body: string): string {
|
||
const fence = body.includes("```") ? "````" : "```";
|
||
return `${fence}${language}\n${body.trimEnd()}\n${fence}`;
|
||
}
|
||
|
||
function traceStatusToken(event: TraceEvent): string {
|
||
const rawStatus = String(event.status ?? "");
|
||
const status = rawStatus.toLowerCase();
|
||
const exitCode = numberOrNull(event.exitCode);
|
||
if (["failed", "blocked", "error", "timeout", "canceled", "cancelled"].includes(status) || event.errorCode || (exitCode !== null && exitCode !== 0)) return "fail";
|
||
if (status === "completed" || status === "succeeded" || exitCode === 0) return "ok";
|
||
if (["started", "running", "accepted", "inprogress", "in-progress", "output_chunk"].includes(status)) return "run";
|
||
return rawStatus || "event";
|
||
}
|
||
|
||
function traceToolState(event: TraceEvent): TraceToolState {
|
||
const status = traceStatusToken(event);
|
||
if (status === "ok") return "success";
|
||
if (status === "fail") return "failure";
|
||
if (status === "run") return "running";
|
||
return "neutral";
|
||
}
|
||
|
||
function traceToolStateFromTone(tone: TraceEventRow["tone"]): TraceToolState {
|
||
if (tone === "ok") return "success";
|
||
if (tone === "blocked") return "failure";
|
||
if (tone === "warn") return "running";
|
||
return "neutral";
|
||
}
|
||
|
||
function traceToolStatusLabel(state: TraceToolState): string {
|
||
if (state === "success") return "工具调用成功";
|
||
if (state === "failure") return "工具调用失败";
|
||
if (state === "running") return "工具调用中";
|
||
return "工具调用状态未知";
|
||
}
|
||
|
||
function traceEventTone(event: TraceEvent): TraceEventRow["tone"] {
|
||
const status = traceStatusToken(event);
|
||
if (status === "ok") return "ok";
|
||
if (status === "fail") return "blocked";
|
||
if (status === "run") return "warn";
|
||
return "source";
|
||
}
|
||
|
||
function traceClock(value: unknown, options: TraceDisplayRowsOptions): string {
|
||
const timestamp = nonEmptyString(value);
|
||
if (timestamp && options.formatClock) return options.formatClock(timestamp);
|
||
const date = new Date(String(value ?? ""));
|
||
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19);
|
||
}
|
||
|
||
function traceEventClock(event: TraceEvent | null | undefined, options: TraceDisplayRowsOptions): string {
|
||
return traceClock(traceEventTimestamp(event), options);
|
||
}
|
||
|
||
function traceWithInferredStart(trace: Record<string, unknown>, events: TraceEvent[]): Record<string, unknown> {
|
||
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 traceEventTimestampMs(event: TraceEvent | null | undefined): number | null {
|
||
const timestamp = traceEventTimestamp(event);
|
||
if (!timestamp) return null;
|
||
const parsed = Date.parse(timestamp);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function cleanTraceText(value: unknown): string {
|
||
return String(value ?? "").replace(/\u0000/gu, "").replace(/\r\n|\r/gu, "\n").replace(/[ \t]{2,}/gu, " ").replace(/\n{3,}/gu, "\n\n").trim();
|
||
}
|
||
|
||
function cleanTraceDetailText(value: unknown): string {
|
||
return String(value ?? "").replace(/\u0000/gu, "").replace(/\r\n|\r/gu, "\n").replace(/\n{4,}/gu, "\n\n\n").trim();
|
||
}
|
||
|
||
function traceEventText(event: TraceEvent, keys: string[]): string | null {
|
||
for (const key of keys) {
|
||
const value = event[key];
|
||
if (value === undefined || value === null) continue;
|
||
const body = cleanTraceDetailText(value);
|
||
if (body) return body;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function traceDetailBlock(label: string, value: string | null): string | null {
|
||
return value ? `${label}:\n${value}` : null;
|
||
}
|
||
|
||
function cleanShellCommand(value: unknown): string | null {
|
||
const raw = cleanTraceDetailText(value);
|
||
if (!raw) return null;
|
||
const stripped = stripShellWrapper(raw);
|
||
const unescaped = stripped.replace(/\\(["`$\\])/gu, "$1");
|
||
return cleanTraceDetailText(unescaped);
|
||
}
|
||
|
||
function stripShellWrapper(value: string): string {
|
||
const match = value.match(/^\s*(?:\/usr\/bin\/env\s+)?(?:\/bin\/sh|sh|bash|\/bin\/bash)\s+-lc\s+([\s\S]*)$/u);
|
||
if (!match) return value;
|
||
return unwrapShellArgument(match[1] ?? "");
|
||
}
|
||
|
||
function unwrapShellArgument(value: string): string {
|
||
const text = value.trim();
|
||
if (text.length >= 2) {
|
||
const quote = text[0];
|
||
if ((quote === "'" || quote === '"') && text[text.length - 1] === quote) {
|
||
return text.slice(1, -1);
|
||
}
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function compactTraceOneLine(value: unknown, limit = 220): string {
|
||
const text = cleanTraceText(value).replace(/\s+/gu, " ");
|
||
return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
|
||
}
|
||
|
||
function numberOrNull(value: unknown): number | null {
|
||
if (value === null || value === undefined || value === "") return null;
|
||
const number = Number(value);
|
||
return Number.isInteger(number) ? number : null;
|
||
}
|
||
|
||
function nonEmptyString(value: unknown): string | null {
|
||
const text = String(value ?? "").trim();
|
||
return text ? text : null;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value && typeof value === "object");
|
||
}
|