778 lines
30 KiB
TypeScript
778 lines
30 KiB
TypeScript
function installWorkbenchTestHooks() {
|
|
if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return;
|
|
window.__hwlabWorkbenchTestHooks = {
|
|
seedCompletedAgentMessage,
|
|
seedTraceMessage,
|
|
appendTraceEvents,
|
|
reconcileTraceResultFixture,
|
|
latestAgentMessageText,
|
|
traceScrollMetrics,
|
|
traceDomIdentity,
|
|
traceBodyDomIdentity,
|
|
setTraceBodyScrollTop,
|
|
replaceTraceAssistantStream,
|
|
setTraceScrollTop,
|
|
setConversationScrollTop,
|
|
resetProgrammaticScrollWriteCount
|
|
};
|
|
}
|
|
|
|
function isLocalWorkbenchTestHost() {
|
|
return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname);
|
|
}
|
|
|
|
function seedCompletedAgentMessage(options = {}) {
|
|
state.chatMessages = [{
|
|
id: options.messageId ?? "msg_markdown_contract",
|
|
role: "agent",
|
|
title: options.title ?? "Code Agent 回复",
|
|
text: String(options.text ?? ""),
|
|
status: options.status ?? "completed",
|
|
traceId: options.traceId ?? "trc_markdown_contract",
|
|
conversationId: options.conversationId ?? "cnv_markdown_contract",
|
|
sessionId: options.sessionId ?? "ses_markdown_contract"
|
|
}];
|
|
renderConversation();
|
|
}
|
|
|
|
function seedTraceMessage(options = {}) {
|
|
const traceId = options.traceId ?? "trc_scroll_contract";
|
|
const messageId = options.messageId ?? "msg_scroll_contract";
|
|
const count = Math.max(1, Number(options.count ?? 80));
|
|
const events = Array.isArray(options.events)
|
|
? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1))
|
|
: Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
|
|
state.chatMessages = [{
|
|
id: messageId,
|
|
role: "agent",
|
|
title: "Code Agent 处理中",
|
|
text: "Trace scroll contract fixture",
|
|
status: "running",
|
|
traceId,
|
|
runnerTrace: {
|
|
traceId,
|
|
events,
|
|
assistantStreams: Array.isArray(options.assistantStreams) ? options.assistantStreams : [],
|
|
eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length,
|
|
eventsCompacted: options.eventsCompacted === true,
|
|
eventWindow: options.eventWindow ?? null,
|
|
fullTraceLoaded: options.fullTraceLoaded === true,
|
|
lastEvent: options.lastEvent ?? events.at(-1),
|
|
waitingFor: "turn/completed"
|
|
}
|
|
}];
|
|
renderConversation();
|
|
}
|
|
|
|
function normalizeTestTraceEvent(traceId, event, seq) {
|
|
return {
|
|
traceId,
|
|
seq,
|
|
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
|
|
...event,
|
|
traceId: event?.traceId ?? traceId,
|
|
seq: event?.seq ?? seq,
|
|
createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString()
|
|
};
|
|
}
|
|
|
|
function appendTraceEvents(count = 1) {
|
|
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
|
|
if (!message) return;
|
|
const trace = message.runnerTrace;
|
|
const start = Array.isArray(trace.events) ? trace.events.length : 0;
|
|
const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1));
|
|
trace.events = [...trace.events, ...additions];
|
|
trace.eventCount = trace.events.length;
|
|
trace.lastEvent = trace.events.at(-1);
|
|
patchMessageTracePanel(message);
|
|
}
|
|
|
|
function replaceTraceAssistantStream(stream = {}) {
|
|
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
|
|
if (!message) return null;
|
|
const trace = message.runnerTrace;
|
|
trace.assistantStreams = [{
|
|
itemId: "assistant-message",
|
|
chunkCount: Math.max(1, Number(stream.chunkCount ?? 1)),
|
|
createdAt: stream.createdAt ?? new Date(1779699000000).toISOString(),
|
|
updatedAt: stream.updatedAt ?? new Date(1779699001000 + Math.max(1, Number(stream.chunkCount ?? 1)) * 1000).toISOString(),
|
|
waitingFor: stream.waitingFor ?? "turn/completed",
|
|
text: String(stream.text ?? "assistant stream fixture")
|
|
}];
|
|
patchMessageTracePanel(message);
|
|
return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre");
|
|
}
|
|
|
|
async function reconcileTraceResultFixture(result) {
|
|
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId);
|
|
if (!message) return null;
|
|
applyCodeAgentResultToMessage(message.id, {
|
|
...(result && typeof result === "object" ? result : {}),
|
|
traceId: message.traceId,
|
|
conversationId: message.conversationId ?? "cnv_fixture",
|
|
sessionId: message.sessionId ?? "ses_fixture",
|
|
status: "completed",
|
|
provider: "codex-stdio",
|
|
model: "gpt-test",
|
|
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "workspace-write",
|
|
capabilityLevel: "long-lived-codex-stdio-session",
|
|
runner: {
|
|
kind: "codex-app-server-stdio-runner",
|
|
codexStdio: true,
|
|
writeCapable: true,
|
|
durableSession: true,
|
|
workspace: "/workspace/hwlab",
|
|
sandbox: "workspace-write",
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session",
|
|
capabilityLevel: "long-lived-codex-stdio-session"
|
|
},
|
|
session: {
|
|
sessionId: message.sessionId ?? "ses_fixture",
|
|
status: "idle",
|
|
idleTimeoutMs: 1800000,
|
|
lastTraceId: message.traceId,
|
|
longLivedSession: true,
|
|
durableSession: true,
|
|
codexStdio: true,
|
|
writeCapable: true
|
|
},
|
|
sessionMode: "codex-app-server-stdio-long-lived",
|
|
implementationType: "repo-owned-codex-app-server-stdio-session",
|
|
sessionReuse: { reused: true, sessionId: message.sessionId ?? "ses_fixture" },
|
|
longLivedSessionGate: { status: "pass", pass: true },
|
|
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
|
|
skills: [],
|
|
reply: {
|
|
content: "fixture reconciled result",
|
|
...(result?.reply && typeof result.reply === "object" ? result.reply : {})
|
|
},
|
|
providerTrace: {
|
|
protocol: "codex-app-server-jsonrpc-stdio",
|
|
command: "codex app-server --listen stdio://",
|
|
terminalStatus: "completed",
|
|
...(result?.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : {})
|
|
},
|
|
runnerTrace: message.runnerTrace,
|
|
updatedAt: new Date().toISOString()
|
|
}, {
|
|
baseMessage: message,
|
|
retryInput: message.retryInput,
|
|
retryOf: message.retryOf
|
|
});
|
|
renderCodeAgentSummary();
|
|
renderConversation();
|
|
return latestAgentMessageText();
|
|
}
|
|
|
|
function latestAgentMessageText() {
|
|
const message = [...state.chatMessages].reverse().find((item) => item.role === "agent");
|
|
return message ? {
|
|
title: message.title,
|
|
text: message.text,
|
|
status: message.status,
|
|
traceId: message.traceId,
|
|
providerTrace: message.providerTrace ?? null,
|
|
sourceKind: message.sourceKind ?? null,
|
|
capabilityLevel: message.capabilityLevel ?? null,
|
|
error: message.error ?? null
|
|
} : null;
|
|
}
|
|
|
|
function testTraceEvent(traceId, seq) {
|
|
return {
|
|
traceId,
|
|
label: "trace:scroll-contract",
|
|
type: "trace",
|
|
status: "observed",
|
|
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
|
|
message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`,
|
|
waitingFor: "turn/completed"
|
|
};
|
|
}
|
|
|
|
function traceScrollMetrics() {
|
|
const conversation = el.conversationList;
|
|
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
|
|
return {
|
|
conversationTop: conversation.scrollTop,
|
|
conversationBottomGap: scrollBottomGap(conversation),
|
|
traceTop: list?.scrollTop ?? null,
|
|
traceBottomGap: list ? scrollBottomGap(list) : null,
|
|
traceScrollHeight: list?.scrollHeight ?? null,
|
|
traceClientHeight: list?.clientHeight ?? null,
|
|
traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0
|
|
};
|
|
}
|
|
|
|
function traceDomIdentity() {
|
|
const conversation = el.conversationList;
|
|
const panel = conversation.querySelector(".message-trace[data-trace-ui-key]");
|
|
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
|
|
const firstRow = list?.querySelector(".message-trace-row") ?? null;
|
|
if (panel && !panel.__hwlabTraceIdentity) panel.__hwlabTraceIdentity = `panel-${Date.now()}-${Math.random()}`;
|
|
if (list && !list.__hwlabTraceIdentity) list.__hwlabTraceIdentity = `list-${Date.now()}-${Math.random()}`;
|
|
if (firstRow && !firstRow.__hwlabTraceIdentity) firstRow.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`;
|
|
return {
|
|
panel: panel?.__hwlabTraceIdentity ?? null,
|
|
list: list?.__hwlabTraceIdentity ?? null,
|
|
firstRow: firstRow?.__hwlabTraceIdentity ?? null,
|
|
rowCount: list?.querySelectorAll(".message-trace-row").length ?? 0,
|
|
programmaticScrollWrites: state.traceProgrammaticScrollWrites
|
|
};
|
|
}
|
|
|
|
function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") {
|
|
const body = el.conversationList.querySelector(selector);
|
|
const row = body?.closest(".message-trace-row") ?? null;
|
|
if (body && !body.__hwlabTraceIdentity) body.__hwlabTraceIdentity = `body-${Date.now()}-${Math.random()}`;
|
|
if (row && !row.__hwlabTraceIdentity) row.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`;
|
|
return {
|
|
selector,
|
|
body: body?.__hwlabTraceIdentity ?? null,
|
|
row: row?.__hwlabTraceIdentity ?? null,
|
|
rowId: row?.dataset.traceRowId ?? null,
|
|
top: body?.scrollTop ?? null,
|
|
left: body?.scrollLeft ?? null,
|
|
scrollHeight: body?.scrollHeight ?? null,
|
|
clientHeight: body?.clientHeight ?? null,
|
|
text: body?.textContent ?? null
|
|
};
|
|
}
|
|
|
|
function resetProgrammaticScrollWriteCount() {
|
|
state.traceProgrammaticScrollWrites = 0;
|
|
return state.traceProgrammaticScrollWrites;
|
|
}
|
|
|
|
function setTraceScrollTop(top, { user = true } = {}) {
|
|
const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]");
|
|
if (!list) return traceScrollMetrics();
|
|
if (user) markTraceScrollIntent(list.dataset.traceUiKey);
|
|
list.scrollTop = top;
|
|
rememberTraceScrollPosition(list.dataset.traceUiKey, list);
|
|
return traceScrollMetrics();
|
|
}
|
|
|
|
function setTraceBodyScrollTop(selector, top, { user = true } = {}) {
|
|
const body = el.conversationList.querySelector(selector);
|
|
if (!body) return traceBodyDomIdentity(selector);
|
|
if (user) markTraceBodyScrollIntent(body);
|
|
body.scrollTop = top;
|
|
rememberTraceBodyScrollPosition(body);
|
|
return traceBodyDomIdentity(selector);
|
|
}
|
|
|
|
function setConversationScrollTop(top, { user = true } = {}) {
|
|
if (user) markConversationScrollIntent();
|
|
el.conversationList.scrollTop = top;
|
|
captureConversationScrollPosition();
|
|
return traceScrollMetrics();
|
|
}
|
|
|
|
function traceDisplayRows(trace, events) {
|
|
const rows = [];
|
|
const assistantRows = traceAssistantStreamRows(trace);
|
|
let noisyRun = [];
|
|
let toolOutputRun = [];
|
|
const flushNoisyRun = () => {
|
|
if (noisyRun.length === 0) return;
|
|
const row = traceNoiseSummaryRow(trace, noisyRun);
|
|
if (row) rows.push(row);
|
|
noisyRun = [];
|
|
};
|
|
const flushToolOutputRun = () => {
|
|
if (toolOutputRun.length === 0) return;
|
|
const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun);
|
|
rows.push(...outputRows);
|
|
toolOutputRun = [];
|
|
};
|
|
for (const event of events) {
|
|
if (isNoisyTraceEvent(event)) {
|
|
flushToolOutputRun();
|
|
noisyRun.push(event);
|
|
continue;
|
|
}
|
|
if (isToolOutputChunkTraceEvent(event)) {
|
|
flushNoisyRun();
|
|
toolOutputRun.push(event);
|
|
continue;
|
|
}
|
|
flushNoisyRun();
|
|
flushToolOutputRun();
|
|
const row = traceDisplayRow(trace, event);
|
|
if (!row) continue;
|
|
rows.push(row);
|
|
}
|
|
flushNoisyRun();
|
|
flushToolOutputRun();
|
|
rows.push(...assistantRows);
|
|
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
|
|
}
|
|
|
|
function traceAssistantStreamRows(trace) {
|
|
const streams = Array.isArray(trace?.assistantStreams) ? trace.assistantStreams.filter(Boolean) : [];
|
|
return streams.map((stream, index) => traceAssistantStreamRow(trace, stream, index)).filter(Boolean);
|
|
}
|
|
|
|
function traceAssistantStreamRow(trace, stream, index = 0) {
|
|
const chunkCount = Math.max(0, Number(stream?.chunkCount ?? 0));
|
|
if (chunkCount <= 0) return null;
|
|
const clock = traceClock(stream.updatedAt ?? stream.createdAt);
|
|
const total = formatTraceDuration(traceRelativeMs(trace, { createdAt: stream.updatedAt ?? stream.createdAt }));
|
|
return {
|
|
rowId: `assistant-stream:${stream.itemId ?? stream.messageId ?? index}`,
|
|
seq: null,
|
|
tone: "source",
|
|
header: `${clock} total=${total} stream assistant message x${chunkCount}`,
|
|
body: traceAssistantStreamBody(stream, chunkCount)
|
|
};
|
|
}
|
|
|
|
function traceAssistantStreamBody(stream, chunkCount) {
|
|
const message = cleanTraceText(stream?.text ?? "");
|
|
const waitingFor = stream?.waitingFor ? `waiting=${stream.waitingFor}` : null;
|
|
return [
|
|
`chunks=${chunkCount} assistant message chunks`,
|
|
waitingFor,
|
|
message ? `message=${message}` : null
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
function traceToolOutputSummaryRows(trace, events) {
|
|
const visibleEvents = events.filter(Boolean);
|
|
if (visibleEvents.length === 0) return [];
|
|
const first = visibleEvents[0];
|
|
const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join("");
|
|
const parsedItems = parseGatewayJsonRpcOutputs(combined);
|
|
if (parsedItems.length > 0) {
|
|
return parsedItems.map((parsed, index) => {
|
|
const { payload, result, dispatch } = parsed;
|
|
const event = visibleEvents[Math.min(index, visibleEvents.length - 1)] ?? first;
|
|
const clock = traceClock(event.createdAt);
|
|
const total = formatTraceDuration(traceRelativeMs(trace, event));
|
|
const chunkCount = index === 0 ? visibleEvents.length : 0;
|
|
const suffix = chunkCount > 0 ? ` chunks=${chunkCount}` : "";
|
|
const status = result.status ?? dispatch.dispatchStatus ?? "unknown";
|
|
const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null;
|
|
const ok = status === "succeeded" || status === "completed" || exitCode === 0;
|
|
const header = [
|
|
`${clock} total=${total}`,
|
|
ok ? "ok" : status === "timed_out" ? "timeout" : "fail",
|
|
"tool gateway.shell",
|
|
`status=${status}`,
|
|
result.operationId ? `op=${result.operationId}` : "op=null",
|
|
exitCode !== null ? `exit=${exitCode}` : null,
|
|
typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null,
|
|
suffix.trim() || null
|
|
].filter(Boolean).join(" ");
|
|
return {
|
|
rowId: `gateway-jsonrpc:${event.seq ?? first.seq ?? "unknown"}:${payload.id ?? index}`,
|
|
seq: event.seq ?? null,
|
|
tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked",
|
|
header,
|
|
body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined })
|
|
};
|
|
});
|
|
}
|
|
const clock = traceClock(first.createdAt);
|
|
const total = formatTraceDuration(traceRelativeMs(trace, first));
|
|
return [{
|
|
rowId: `tool-output:${first.seq ?? "unknown"}`,
|
|
seq: first.seq ?? null,
|
|
tone: "source",
|
|
header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`,
|
|
body: compactTraceTextTail(cleanTraceOutputText(combined), 1600)
|
|
}];
|
|
}
|
|
|
|
function traceToolOutputSummaryRow(trace, events) {
|
|
return traceToolOutputSummaryRows(trace, events)[0] ?? null;
|
|
}
|
|
|
|
function traceNoiseSummaryRow(trace, events) {
|
|
const visibleEvents = events.filter(Boolean);
|
|
if (visibleEvents.length === 0) return null;
|
|
const last = visibleEvents.at(-1);
|
|
const clock = traceClock(last.createdAt);
|
|
const total = formatTraceDuration(traceRelativeMs(trace, last));
|
|
const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event));
|
|
const label = assistantChunks.length === visibleEvents.length
|
|
? `assistant message x${visibleEvents.length}`
|
|
: `trace noise x${visibleEvents.length}`;
|
|
return {
|
|
rowId: `trace-noise:${visibleEvents[0]?.seq ?? last.seq ?? "unknown"}:${assistantChunks.length > 0 ? "assistant" : "events"}`,
|
|
seq: last.seq ?? null,
|
|
tone: "source",
|
|
header: `${clock} total=${total} stream ${label}`,
|
|
body: traceNoiseSummaryBody(visibleEvents, assistantChunks)
|
|
};
|
|
}
|
|
|
|
function traceNoiseSummaryBody(events, assistantChunks) {
|
|
if (assistantChunks.length > 0) {
|
|
const text = assistantChunks
|
|
.map((event) => String(event.chunk ?? ""))
|
|
.join("");
|
|
const message = cleanTraceText(text);
|
|
const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null;
|
|
return [
|
|
`chunks=${assistantChunks.length} assistant message chunks`,
|
|
waitingFor,
|
|
message ? `message=${message}` : null
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
const labels = new Map();
|
|
for (const event of events) {
|
|
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
|
|
labels.set(label, (labels.get(label) ?? 0) + 1);
|
|
}
|
|
return [...labels.entries()]
|
|
.slice(0, 6)
|
|
.map(([label, count]) => `${readableTraceLabel({ label })} x${count}`)
|
|
.join("\n");
|
|
}
|
|
|
|
function compactTraceTextTail(text, maxLength) {
|
|
const clean = cleanTraceText(text);
|
|
if (clean.length <= maxLength) return clean;
|
|
return `...${clean.slice(-maxLength)}`;
|
|
}
|
|
|
|
function traceDisplayRow(trace, event, options = {}) {
|
|
if (!options.includeNoise && isNoisyTraceEvent(event)) return null;
|
|
if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event);
|
|
const clock = traceClock(event.createdAt);
|
|
const total = formatTraceDuration(traceRelativeMs(trace, event));
|
|
const status = traceStatusToken(event);
|
|
const label = readableTraceLabel(event);
|
|
const meta = [
|
|
status,
|
|
event.errorCode ? `error=${event.errorCode}` : null,
|
|
typeof event.timeoutMs === "number" ? `timeout=${formatTraceDuration(event.timeoutMs)}` : null,
|
|
typeof event.idleMs === "number" ? `idle=${formatTraceDuration(event.idleMs)}` : null,
|
|
event.outputSummary ? `out=${compactTraceSize(event.outputSummary)}` : null
|
|
].filter(Boolean).join(" ");
|
|
const body = traceDisplayBody(event);
|
|
return {
|
|
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`,
|
|
seq: event.seq ?? null,
|
|
tone: traceEventTone(event),
|
|
header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`,
|
|
body
|
|
};
|
|
}
|
|
|
|
function traceCommandExecutionRow(trace, event) {
|
|
const clock = traceClock(event.createdAt);
|
|
const total = formatTraceDuration(traceRelativeMs(trace, event));
|
|
const status = traceStatusToken(event);
|
|
const command = cleanTraceOutputText(event.command ?? "");
|
|
const meta = [
|
|
status,
|
|
"tool cmd",
|
|
command ? `:: ${compactTraceTextTail(command, 220)}` : null,
|
|
Number.isInteger(event.exitCode) ? `exit=${event.exitCode}` : null,
|
|
typeof event.durationMs === "number" ? `s=${(event.durationMs / 1000).toFixed(1)}` : null,
|
|
typeof event.outputBytes === "number" ? `out=${formatTraceBytes(event.outputBytes)}` : null,
|
|
event.itemId ? `item=${event.itemId}` : null
|
|
].filter(Boolean).join(" ");
|
|
return {
|
|
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "command"}:${event.createdAt ?? "unknown"}`}`,
|
|
seq: event.seq ?? null,
|
|
tone: traceEventTone(event),
|
|
header: `${clock} total=${total} ${meta}`,
|
|
body: traceCommandExecutionBody(event)
|
|
};
|
|
}
|
|
|
|
function traceCommandExecutionBody(event) {
|
|
const lines = [
|
|
event.command ? `command=${compactTraceTextTail(cleanTraceOutputText(event.command), 900)}` : null,
|
|
event.stdoutSummary ? `stdout:\n${compactTraceTextTail(cleanTraceOutputText(event.stdoutSummary), 1200)}` : null,
|
|
event.stderrSummary ? `stderr:\n${compactTraceTextTail(cleanTraceOutputText(event.stderrSummary), 800)}` : null,
|
|
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
|
|
].filter(Boolean);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function isCommandExecutionTraceEvent(event) {
|
|
return event?.type === "tool_call" && event?.toolName === "commandExecution" &&
|
|
(event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed");
|
|
}
|
|
|
|
function isNoisyTraceEvent(event) {
|
|
const label = String(event?.label ?? "");
|
|
if (isToolOutputChunkTraceEvent(event)) return false;
|
|
if (isAssistantChunkTraceEvent(event)) return true;
|
|
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
|
|
if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true;
|
|
if (event?.label === "request:accepted" && !event.message) return true;
|
|
return false;
|
|
}
|
|
|
|
function isAssistantChunkTraceEvent(event) {
|
|
const label = String(event?.label ?? "");
|
|
return /assistant:chunk/iu.test(label) ||
|
|
(event?.type === "assistant_message" && event?.status === "chunk");
|
|
}
|
|
|
|
function isToolOutputChunkTraceEvent(event) {
|
|
const label = String(event?.label ?? "");
|
|
return label === "item/commandExecution/outputDelta" ||
|
|
(event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server"));
|
|
}
|
|
|
|
function readableTraceLabel(event) {
|
|
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("tool:codex-app-server.thread/start+turn/start")) return "codex turn start";
|
|
if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume";
|
|
if (label === "item/commandExecution:started") return "cmd started";
|
|
if (label === "item/commandExecution:completed") return "cmd completed";
|
|
if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output";
|
|
if (label === "turn:waiting:first_assistant_token") return "waiting first assistant token";
|
|
if (label.startsWith("turn:completed")) return "turn completed";
|
|
if (label.startsWith("result:completed")) return "result ready";
|
|
if (label.startsWith("result:failed")) return "result failed";
|
|
if (label.startsWith("timeout:")) return "timeout";
|
|
if (label.startsWith("cancel:")) return "cancel";
|
|
return label
|
|
.replace(/^tool:/u, "")
|
|
.replace(/^assistant:/u, "assistant ")
|
|
.replace(/:completed:completed$/u, " completed")
|
|
.replace(/:completed$/u, " completed")
|
|
.replace(/:started$/u, " started");
|
|
}
|
|
|
|
function traceDisplayBody(event) {
|
|
const lines = [
|
|
event.promptSummary ? cleanTraceText(event.promptSummary) : null,
|
|
event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null,
|
|
event.message ? cleanTraceText(event.message) : null,
|
|
event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null,
|
|
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
|
|
].filter(Boolean);
|
|
return lines.join("\n").slice(0, 1400);
|
|
}
|
|
|
|
function rawTraceOutputText(event) {
|
|
return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? "");
|
|
}
|
|
|
|
function parseGatewayJsonRpcOutputs(text) {
|
|
const payloads = parseJsonObjects(text);
|
|
return payloads
|
|
.filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object")
|
|
.map((payload) => {
|
|
const result = payload.result;
|
|
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
|
|
if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null;
|
|
return { payload, result, dispatch };
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseGatewayJsonRpcOutput(text) {
|
|
return parseGatewayJsonRpcOutputs(text)[0] ?? null;
|
|
}
|
|
|
|
function parseJsonObjects(text) {
|
|
const source = String(text ?? "");
|
|
const values = [];
|
|
let searchFrom = 0;
|
|
while (searchFrom < source.length) {
|
|
const start = source.indexOf("{", searchFrom);
|
|
if (start < 0) break;
|
|
const end = jsonObjectEnd(source, start);
|
|
if (end < 0) break;
|
|
try {
|
|
values.push(JSON.parse(source.slice(start, end + 1)));
|
|
searchFrom = end + 1;
|
|
} catch {
|
|
searchFrom = start + 1;
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function parseFirstJsonObject(text) {
|
|
const source = String(text ?? "");
|
|
const start = source.indexOf("{");
|
|
if (start < 0) return null;
|
|
const end = jsonObjectEnd(source, start);
|
|
if (end < 0) return null;
|
|
try {
|
|
return JSON.parse(source.slice(start, end + 1));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function jsonObjectEnd(source, start) {
|
|
let depth = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
for (let index = start; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
if (inString) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === "\\") {
|
|
escaped = true;
|
|
} else if (char === "\"") {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
if (char === "\"") {
|
|
inString = true;
|
|
continue;
|
|
}
|
|
if (char === "{") depth += 1;
|
|
if (char === "}") depth -= 1;
|
|
if (depth === 0) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) {
|
|
const stdout = cleanTraceOutputText(dispatch.stdout);
|
|
const stderr = cleanTraceOutputText(dispatch.stderr);
|
|
const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null;
|
|
return [
|
|
`request=${payload.id ?? "unknown"}`,
|
|
`gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`,
|
|
`dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`,
|
|
error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null,
|
|
dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null,
|
|
result.auditId ? `audit=${result.auditId}` : null,
|
|
result.evidenceId ? `evidence=${result.evidenceId}` : null,
|
|
dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null,
|
|
stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null,
|
|
stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null,
|
|
!stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null
|
|
].filter(Boolean).join("\n");
|
|
}
|
|
|
|
function traceStatusToken(event) {
|
|
const status = String(event?.status ?? "");
|
|
if (status === "completed" || status === "succeeded") return "ok";
|
|
if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event?.errorCode) return "fail";
|
|
if (["started", "running", "accepted"].includes(status)) return "run";
|
|
return status || "event";
|
|
}
|
|
|
|
function traceEventTone(event) {
|
|
const status = traceStatusToken(event);
|
|
if (status === "ok") return "ok";
|
|
if (status === "fail") return "blocked";
|
|
if (status === "run") return "warn";
|
|
return "source";
|
|
}
|
|
|
|
function traceClock(value) {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19);
|
|
}
|
|
|
|
function traceRelativeMs(trace, event) {
|
|
if (typeof event?.elapsedMs === "number") return event.elapsedMs;
|
|
const start = Date.parse(trace?.startedAt ?? trace?.createdAt ?? "");
|
|
const current = Date.parse(event?.createdAt ?? "");
|
|
return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start);
|
|
}
|
|
|
|
function formatTraceDuration(ms) {
|
|
const total = Math.max(0, Math.floor(Number(ms) || 0));
|
|
const seconds = Math.floor(total / 1000);
|
|
const hh = String(Math.floor(seconds / 3600)).padStart(2, "0");
|
|
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
|
|
const ss = String(seconds % 60).padStart(2, "0");
|
|
return `${hh}:${mm}:${ss}`;
|
|
}
|
|
|
|
function compactTraceSize(value) {
|
|
const length = String(value ?? "").length;
|
|
if (length >= 1000) return `${(length / 1000).toFixed(length >= 10000 ? 0 : 1)}k`;
|
|
return String(length);
|
|
}
|
|
|
|
function formatTraceBytes(value) {
|
|
const bytes = Math.max(0, Number(value) || 0);
|
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
|
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
|
|
return `${bytes}B`;
|
|
}
|
|
|
|
function cleanTraceText(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return "";
|
|
return text
|
|
.replace(/\s*\/\s*/gu, " / ")
|
|
.replace(/[ \t]{2,}/gu, " ")
|
|
.replace(/\n{3,}/gu, "\n\n");
|
|
}
|
|
|
|
function cleanTraceOutputText(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return "";
|
|
return text
|
|
.replace(/[ \t]{2,}/gu, " ")
|
|
.replace(/\n{3,}/gu, "\n\n");
|
|
}
|
|
|
|
function traceActionButton(label, onClick, title) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "message-trace-action";
|
|
button.textContent = label;
|
|
button.title = title;
|
|
button.addEventListener("click", onClick);
|
|
return button;
|
|
}
|
|
|
|
function messageTraceJson(message, trace, events) {
|
|
return JSON.stringify({
|
|
traceId: trace?.traceId ?? message.traceId ?? null,
|
|
messageId: message.id ?? null,
|
|
eventCount: Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length,
|
|
eventsCompacted: trace?.eventsCompacted === true,
|
|
eventWindow: trace?.eventWindow ?? null,
|
|
lastEvent: trace?.lastEvent ?? events.at(-1) ?? null,
|
|
assistantStreams: Array.isArray(trace?.assistantStreams) ? trace.assistantStreams : [],
|
|
events
|
|
}, null, 2);
|
|
}
|
|
|
|
function downloadTraceJson(message, trace, events) {
|
|
const traceId = String(trace?.traceId ?? message.traceId ?? "trace")
|
|
.replace(/[^A-Za-z0-9_.-]+/gu, "-")
|
|
.replace(/^-|-$/gu, "")
|
|
.slice(0, 96) || "trace";
|
|
const blob = new Blob([messageTraceJson(message, trace, events)], { type: "application/json" });
|
|
const href = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = href;
|
|
anchor.download = `${traceId}.json`;
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(href), 0);
|
|
}
|
|
|
|
function runnerTraceHeadline(message, trace) {
|
|
const count = Number.isInteger(trace?.eventCount) ? trace.eventCount : Array.isArray(trace?.events) ? trace.events.length : 0;
|
|
const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件";
|
|
const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : "";
|
|
const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : "";
|
|
const thread = trace?.threadId ?? message.threadId ? ` / thread=${trace?.threadId ?? message.threadId}` : "";
|
|
return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}${thread}`;
|
|
}
|