Merge pull request #970 from pikasTech/fix/issue955-trace-replay-final
fix: stabilize AgentRun trace replay and final rows
This commit is contained in:
@@ -435,11 +435,8 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
|
|||||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||||
const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
|
const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
|
||||||
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
|
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
|
||||||
const eventsResponse = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/events?afterSeq=${encodeURIComponent(String(mapped.agentRun.lastSeq ?? 0))}&limit=500`, {
|
const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } });
|
||||||
method: "GET",
|
const events = eventsResponse.events;
|
||||||
timeoutMs
|
|
||||||
});
|
|
||||||
const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : [];
|
|
||||||
appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun);
|
appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun);
|
||||||
const result = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands/${encodeURIComponent(mapped.agentRun.commandId)}/result`, {
|
const result = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands/${encodeURIComponent(mapped.agentRun.commandId)}/result`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -448,7 +445,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
|
|||||||
const nextMapping = {
|
const nextMapping = {
|
||||||
...mapped.agentRun,
|
...mapped.agentRun,
|
||||||
...agentRunResultRefs(result),
|
...agentRunResultRefs(result),
|
||||||
lastSeq: Math.max(Number(mapped.agentRun.lastSeq ?? 0), Number(result?.lastSeq ?? 0), ...events.map((event) => Number(event?.seq ?? 0))),
|
lastSeq: agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq),
|
||||||
status: result?.status ?? mapped.agentRun.status ?? "running",
|
status: result?.status ?? mapped.agentRun.status ?? "running",
|
||||||
runStatus: result?.runStatus ?? mapped.agentRun.runStatus ?? null,
|
runStatus: result?.runStatus ?? mapped.agentRun.runStatus ?? null,
|
||||||
commandState: result?.commandState ?? mapped.agentRun.commandState ?? null,
|
commandState: result?.commandState ?? mapped.agentRun.commandState ?? null,
|
||||||
@@ -469,13 +466,10 @@ export async function refreshAgentRunTrace({ traceId, result = null, options = {
|
|||||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||||
const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
|
const managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
|
||||||
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
|
const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000);
|
||||||
const eventsResponse = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/events?afterSeq=${encodeURIComponent(String(mapped.agentRun.lastSeq ?? 0))}&limit=500`, {
|
const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } });
|
||||||
method: "GET",
|
const events = eventsResponse.events;
|
||||||
timeoutMs
|
|
||||||
});
|
|
||||||
const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : [];
|
|
||||||
appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun);
|
appendAgentRunEventsToTrace(traceStore, traceId, events, mapped.agentRun);
|
||||||
const lastSeq = Math.max(Number(mapped.agentRun.lastSeq ?? 0), ...events.map((event) => Number(event?.seq ?? 0)));
|
const lastSeq = agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq);
|
||||||
if (lastSeq !== Number(mapped.agentRun.lastSeq ?? 0)) {
|
if (lastSeq !== Number(mapped.agentRun.lastSeq ?? 0)) {
|
||||||
options.codeAgentChatResults?.set?.(traceId, { ...mapped, agentRun: { ...mapped.agentRun, lastSeq, updatedAt: nowIso(options.now) } });
|
options.codeAgentChatResults?.set?.(traceId, { ...mapped, agentRun: { ...mapped.agentRun, lastSeq, updatedAt: nowIso(options.now) } });
|
||||||
}
|
}
|
||||||
@@ -1293,11 +1287,64 @@ function appendAgentRunEventsToTrace(traceStore, traceId, events, mapping = {})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping = {} }) {
|
||||||
|
const runId = requiredString(mapping.runId, "runId");
|
||||||
|
const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : "";
|
||||||
|
const { afterSeq, endSeq } = agentRunTraceReplayWindow(mapping);
|
||||||
|
const path = `/api/v1/runs/${encodeURIComponent(runId)}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=500`;
|
||||||
|
const response = await agentRunJson(fetchImpl, managerUrl, path, { method: "GET", timeoutMs });
|
||||||
|
const rawEvents = Array.isArray(response?.items) ? response.items : [];
|
||||||
|
const events = rawEvents.filter((event) => agentRunEventBelongsToTrace(event, { currentCommandId, afterSeq, endSeq }));
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
afterSeq,
|
||||||
|
endSeq,
|
||||||
|
commandFiltered: Boolean(currentCommandId),
|
||||||
|
maxSeq: Math.max(afterSeq, ...rawEvents.map((event) => Number(event?.seq ?? 0))),
|
||||||
|
traceLastSeq: Math.max(afterSeq, ...events.map((event) => Number(event?.seq ?? 0)).filter(Number.isFinite))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRunTraceCursorSeq(eventsResponse = {}, previousLastSeq = 0) {
|
||||||
|
const afterSeq = Number(eventsResponse.afterSeq ?? 0);
|
||||||
|
const endSeq = Number(eventsResponse.endSeq ?? 0);
|
||||||
|
const traceLastSeq = Number(eventsResponse.traceLastSeq ?? 0);
|
||||||
|
if (Number.isFinite(traceLastSeq) && traceLastSeq > afterSeq) return Math.floor(traceLastSeq);
|
||||||
|
if (Number.isFinite(endSeq) && endSeq > 0) return Math.floor(endSeq);
|
||||||
|
if (eventsResponse.commandFiltered === true) return Math.max(Number(previousLastSeq ?? 0), afterSeq);
|
||||||
|
return Math.max(Number(previousLastSeq ?? 0), Number(eventsResponse.maxSeq ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRunTraceReplayWindow(mapping = {}) {
|
||||||
|
const eventStartSeq = Number(mapping.eventStartSeq ?? mapping.commandStartSeq ?? mapping.startSeq ?? 0);
|
||||||
|
const summary = mapping.traceSummary && typeof mapping.traceSummary === "object" ? mapping.traceSummary : null;
|
||||||
|
const summaryAgentRun = summary?.agentRun && typeof summary.agentRun === "object" ? summary.agentRun : null;
|
||||||
|
const summaryLastSeq = Number(summaryAgentRun?.lastSeq ?? summary?.lastSeq ?? 0);
|
||||||
|
const currentLastSeq = Number(mapping.lastSeq ?? 0);
|
||||||
|
const endSeq = Number.isFinite(summaryLastSeq) && summaryLastSeq > 0 ? Math.floor(summaryLastSeq) : 0;
|
||||||
|
if (Number.isFinite(eventStartSeq) && eventStartSeq > 0) return { afterSeq: Math.max(0, Math.floor(eventStartSeq) - 1), endSeq };
|
||||||
|
if (endSeq > 0) return { afterSeq: Math.max(0, endSeq - 500), endSeq };
|
||||||
|
if (Number.isFinite(currentLastSeq) && currentLastSeq > 0) return { afterSeq: Math.floor(currentLastSeq), endSeq: 0 };
|
||||||
|
return { afterSeq: 0, endSeq: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRunEventBelongsToTrace(event, { currentCommandId = "", afterSeq = 0, endSeq = 0 } = {}) {
|
||||||
|
const eventCommandId = agentRunEventCommandId(event);
|
||||||
|
const seq = Number(event?.seq ?? 0);
|
||||||
|
if (currentCommandId && eventCommandId && eventCommandId !== currentCommandId) return false;
|
||||||
|
if (endSeq > 0 && Number.isFinite(seq)) return seq > afterSeq && seq <= endSeq;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRunEventCommandId(event) {
|
||||||
|
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
||||||
|
return typeof payload.commandId === "string" ? payload.commandId : "";
|
||||||
|
}
|
||||||
|
|
||||||
function isForeignAgentRunCommandEvent(event, mapping = {}) {
|
function isForeignAgentRunCommandEvent(event, mapping = {}) {
|
||||||
const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : "";
|
const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : "";
|
||||||
if (!currentCommandId) return false;
|
if (!currentCommandId) return false;
|
||||||
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
const eventCommandId = agentRunEventCommandId(event);
|
||||||
const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : "";
|
|
||||||
return Boolean(eventCommandId && eventCommandId !== currentCommandId);
|
return Boolean(eventCommandId && eventCommandId !== currentCommandId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1662,6 +1662,102 @@ test("cloud api trace returns persisted summary when live trace store has expire
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cloud api trace replays an earlier AgentRun command after same-run lastSeq advances (#955)", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const firstTraceId = "trc_issue955_first_trace";
|
||||||
|
const codeAgentChatResults = new Map();
|
||||||
|
const runId = "run_issue955_replay";
|
||||||
|
const firstCommandId = "cmd_issue955_first";
|
||||||
|
const secondCommandId = "cmd_issue955_second";
|
||||||
|
const firstFinalText = "目前只有一个 HWPOD 可用:\n\n- d601-f103-v2 (board: D601-F103-V2 / STM32F103)\n\nAPI 返回 count=1, availableCount=1。";
|
||||||
|
const firstEvents = [
|
||||||
|
{ id: "evt_issue955_first_setup", runId, seq: 1, type: "backend_status", payload: { phase: "runner-job-created", commandId: firstCommandId, jobName: "agentrun-v01-runner-issue955-first", namespace: "agentrun-v01" }, createdAt: "2026-06-06T01:43:25.000Z" },
|
||||||
|
{ id: "evt_issue955_first_tool", runId, seq: 18, type: "tool_call", payload: { method: "item/completed", type: "commandExecution", toolName: "commandExecution", itemId: "call_issue955_hwpod_list", command: "/bin/sh -lc 'hwpod list --available'", status: "completed", exitCode: 0, outputSummary: '{"count":1,"availableCount":1,"ids":["d601-f103-v2"]}', commandId: firstCommandId }, createdAt: "2026-06-06T01:43:42.000Z" },
|
||||||
|
{ id: "evt_issue955_first_assistant", runId, seq: 27, type: "assistant_message", payload: { commandId: firstCommandId, itemId: "msg_item_0", text: firstFinalText }, createdAt: "2026-06-06T01:43:55.000Z" },
|
||||||
|
{ id: "evt_issue955_first_terminal", runId, seq: 35, type: "terminal_status", payload: { commandId: firstCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:43:56.000Z" }
|
||||||
|
];
|
||||||
|
const secondEvents = [
|
||||||
|
{ id: "evt_issue955_second_setup", runId, seq: 47, type: "backend_status", payload: { phase: "runner-claim-waiting-for-stale-lease" }, createdAt: "2026-06-06T01:45:56.000Z" },
|
||||||
|
{ id: "evt_issue955_second_assistant", runId, seq: 58, type: "assistant_message", payload: { commandId: secondCommandId, itemId: "msg_item_0", text: "编译成功!来总结一下结果。" }, createdAt: "2026-06-06T01:46:24.000Z" },
|
||||||
|
{ id: "evt_issue955_second_terminal", runId, seq: 66, type: "terminal_status", payload: { commandId: secondCommandId, terminalStatus: "completed" }, createdAt: "2026-06-06T01:46:28.000Z" }
|
||||||
|
];
|
||||||
|
const agentRunServer = createHttpServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
||||||
|
calls.push({ method: request.method, path: url.pathname, afterSeq: url.searchParams.get("afterSeq") });
|
||||||
|
if (request.method === "GET" && url.pathname === `/api/v1/runs/${runId}/events`) {
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(`${JSON.stringify({ ok: true, data: { items: [...firstEvents, ...secondEvents] }, traceId: "trc_fake_issue955_replay" })}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404, { "content-type": "application/json" });
|
||||||
|
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
||||||
|
|
||||||
|
const agentRunPort = agentRunServer.address().port;
|
||||||
|
codeAgentChatResults.set(firstTraceId, {
|
||||||
|
accepted: true,
|
||||||
|
status: "completed",
|
||||||
|
shortConnection: true,
|
||||||
|
traceId: firstTraceId,
|
||||||
|
conversationId: "cnv_issue955_replay",
|
||||||
|
sessionId: "ses_issue955_replay",
|
||||||
|
threadId: "thread-issue955-replay",
|
||||||
|
ownerUserId: TEST_AGENT_ACTOR.id,
|
||||||
|
ownerRole: TEST_AGENT_ACTOR.role,
|
||||||
|
reply: { role: "assistant", content: firstFinalText },
|
||||||
|
finalResponse: { text: firstFinalText, textChars: firstFinalText.length, role: "assistant", status: "completed", traceId: firstTraceId, valuesPrinted: false },
|
||||||
|
traceSummary: { traceId: firstTraceId, source: "agent-session-snapshot", sourceEventCount: 41, terminalStatus: "completed", agentRun: { runId, commandId: firstCommandId, lastSeq: 35, valuesPrinted: false }, valuesPrinted: false },
|
||||||
|
agentRun: { adapter: "agentrun-v01", managerUrl: `http://127.0.0.1:${agentRunPort}`, runId, commandId: firstCommandId, lastSeq: 66, terminalStatus: "completed", valuesPrinted: false },
|
||||||
|
valuesPrinted: false
|
||||||
|
});
|
||||||
|
const traceStore = createCodeAgentTraceStore();
|
||||||
|
const server = createCloudApiServer({
|
||||||
|
traceStore,
|
||||||
|
codeAgentChatResults,
|
||||||
|
env: {
|
||||||
|
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_ENVIRONMENT: "v02",
|
||||||
|
HWLAB_GITOPS_PROFILE: "v02"
|
||||||
|
},
|
||||||
|
accessController: {
|
||||||
|
required: false,
|
||||||
|
async authenticate() {
|
||||||
|
return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { port } = server.address();
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/${firstTraceId}`, {
|
||||||
|
headers: { cookie: "hwlab_session=test-stub-session" }
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const body = await response.json();
|
||||||
|
const text = JSON.stringify(body);
|
||||||
|
assert.equal(body.traceId, firstTraceId);
|
||||||
|
assert.equal(body.status, "completed");
|
||||||
|
assert.ok(calls.some((call) => call.path === `/api/v1/runs/${runId}/events` && call.afterSeq === "0"));
|
||||||
|
assert.ok(body.events.some((event) => event.commandId === firstCommandId && event.label === "item/commandExecution:completed"));
|
||||||
|
assert.ok(body.events.some((event) => event.commandId === firstCommandId && event.label === "agentrun:assistant:message"));
|
||||||
|
assert.equal(body.events.some((event) => event.commandId === secondCommandId), false);
|
||||||
|
assert.equal(body.finalResponse.text, firstFinalText);
|
||||||
|
assert.match(text, /d601-f103-v2/u);
|
||||||
|
assert.doesNotMatch(text, /编译成功/u);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
agentRunServer.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => {
|
test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => {
|
||||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-"));
|
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-"));
|
||||||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-"));
|
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-"));
|
||||||
|
|||||||
@@ -1835,8 +1835,8 @@ export async function handleCodeAgentTraceHttp(request, response, url, options)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId, refreshError = null) {
|
function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId, refreshError = null) {
|
||||||
if (snapshot?.status !== "missing") return snapshot;
|
|
||||||
const fallback = persistentTraceFallback(persistedResult, traceId);
|
const fallback = persistentTraceFallback(persistedResult, traceId);
|
||||||
|
if (snapshot?.status !== "missing") return traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError);
|
||||||
if (!fallback) return {
|
if (!fallback) return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1878,6 +1878,31 @@ function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError = null) {
|
||||||
|
if (!fallback) return snapshot;
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
conversationId: snapshot.conversationId ?? fallback.conversationId,
|
||||||
|
sessionId: snapshot.sessionId ?? fallback.sessionId,
|
||||||
|
threadId: snapshot.threadId ?? fallback.threadId,
|
||||||
|
agentRun: snapshot.agentRun ?? fallback.agentRun,
|
||||||
|
finalResponse: snapshot.finalResponse ?? fallback.finalResponse,
|
||||||
|
traceSummary: snapshot.traceSummary ?? fallback.traceSummary,
|
||||||
|
fallback: snapshot.fallback ?? (refreshError ? {
|
||||||
|
available: true,
|
||||||
|
source: fallback.source,
|
||||||
|
refresh: {
|
||||||
|
attempted: true,
|
||||||
|
ok: false,
|
||||||
|
code: refreshError?.code ?? "agentrun_trace_refresh_failed",
|
||||||
|
message: refreshError?.message ?? "AgentRun trace refresh failed; live events may be partial.",
|
||||||
|
valuesPrinted: false
|
||||||
|
},
|
||||||
|
valuesPrinted: false
|
||||||
|
} : undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function persistentTraceFallback(result, traceId) {
|
function persistentTraceFallback(result, traceId) {
|
||||||
if (!result || typeof result !== "object") return null;
|
if (!result || typeof result !== "object") return null;
|
||||||
const storedSummary = result.traceSummary && typeof result.traceSummary === "object" ? result.traceSummary : null;
|
const storedSummary = result.traceSummary && typeof result.traceSummary === "object" ? result.traceSummary : null;
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
|||||||
rows.push(traceDisplayRow(trace, event));
|
rows.push(traceDisplayRow(trace, event));
|
||||||
}
|
}
|
||||||
if (completionEvent) {
|
if (completionEvent) {
|
||||||
|
const finalResponseText = traceFinalResponseText(trace);
|
||||||
|
if (finalResponseText) {
|
||||||
|
lastAssistantRowIndex = upsertAuthoritativeFinalResponseRow(rows, assistantRows, completionEvent, finalResponseText);
|
||||||
|
}
|
||||||
const lastAssistantRow = lastAssistantRowIndex >= 0 ? rows[lastAssistantRowIndex] : undefined;
|
const lastAssistantRow = lastAssistantRowIndex >= 0 ? rows[lastAssistantRowIndex] : undefined;
|
||||||
if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent);
|
if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent);
|
||||||
else rows.push(traceCompletionSummaryRow(trace, completionEvent));
|
else rows.push(traceCompletionSummaryRow(trace, completionEvent));
|
||||||
@@ -78,6 +82,13 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
|||||||
interface AssistantRowState {
|
interface AssistantRowState {
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
comparableText: string;
|
comparableText: string;
|
||||||
|
sourceEvent: TraceEvent;
|
||||||
|
derivedSnapshotSuffix?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantSnapshotDecision {
|
||||||
|
action: "replace" | "append-suffix" | "keep-existing";
|
||||||
|
suffixText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function traceToolCallRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
|
function traceToolCallRow(trace: Record<string, unknown>, event: TraceEvent): TraceEventRow {
|
||||||
@@ -157,7 +168,7 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista
|
|||||||
const comparableText = comparableAssistantText(row.body);
|
const comparableText = comparableAssistantText(row.body);
|
||||||
if (!comparableText) {
|
if (!comparableText) {
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
assistantRows.push({ rowIndex: rows.length - 1, comparableText });
|
assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: event });
|
||||||
return rows.length - 1;
|
return rows.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +179,31 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista
|
|||||||
if (!existingRow) continue;
|
if (!existingRow) continue;
|
||||||
if (state.comparableText === comparableText) {
|
if (state.comparableText === comparableText) {
|
||||||
rows[state.rowIndex] = mergeAssistantRows(existingRow, row);
|
rows[state.rowIndex] = mergeAssistantRows(existingRow, row);
|
||||||
|
state.sourceEvent = event;
|
||||||
|
state.derivedSnapshotSuffix = false;
|
||||||
return state.rowIndex;
|
return state.rowIndex;
|
||||||
}
|
}
|
||||||
if (comparableText.includes(state.comparableText)) {
|
if (comparableText.includes(state.comparableText)) {
|
||||||
|
const previousIndex = comparableText.indexOf(state.comparableText);
|
||||||
|
if (state.derivedSnapshotSuffix === true && previousIndex === 0) {
|
||||||
|
rows[state.rowIndex] = mergeAssistantRows(row, existingRow);
|
||||||
|
state.comparableText = comparableText;
|
||||||
|
state.sourceEvent = event;
|
||||||
|
state.derivedSnapshotSuffix = false;
|
||||||
|
return state.rowIndex;
|
||||||
|
}
|
||||||
|
const snapshotDecision = assistantSnapshotDecision(comparableText, state.comparableText, event, state.sourceEvent);
|
||||||
|
if (snapshotDecision.action === "append-suffix" && snapshotDecision.suffixText) {
|
||||||
|
const suffixRow = { ...row, body: snapshotDecision.suffixText };
|
||||||
|
rows.push(suffixRow);
|
||||||
|
assistantRows.push({ rowIndex: rows.length - 1, comparableText: snapshotDecision.suffixText, sourceEvent: event, derivedSnapshotSuffix: true });
|
||||||
|
return rows.length - 1;
|
||||||
|
}
|
||||||
|
if (snapshotDecision.action === "keep-existing") return state.rowIndex;
|
||||||
rows[state.rowIndex] = mergeAssistantRows(row, existingRow);
|
rows[state.rowIndex] = mergeAssistantRows(row, existingRow);
|
||||||
state.comparableText = comparableText;
|
state.comparableText = comparableText;
|
||||||
|
state.sourceEvent = event;
|
||||||
|
state.derivedSnapshotSuffix = false;
|
||||||
return state.rowIndex;
|
return state.rowIndex;
|
||||||
}
|
}
|
||||||
if (state.comparableText.includes(comparableText)) {
|
if (state.comparableText.includes(comparableText)) {
|
||||||
@@ -182,10 +213,67 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
assistantRows.push({ rowIndex: rows.length - 1, comparableText });
|
assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: event });
|
||||||
return rows.length - 1;
|
return rows.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertAuthoritativeFinalResponseRow(rows: TraceEventRow[], assistantRows: AssistantRowState[], completionEvent: TraceEvent, finalText: string): number {
|
||||||
|
const comparableText = comparableAssistantText(finalText);
|
||||||
|
let matchedState: AssistantRowState | null = null;
|
||||||
|
for (const state of assistantRows) {
|
||||||
|
const row = rows[state.rowIndex];
|
||||||
|
if (!row || row.bodyFormat !== "markdown") continue;
|
||||||
|
row.terminal = undefined;
|
||||||
|
if (!matchedState && state.comparableText === comparableText) matchedState = state;
|
||||||
|
}
|
||||||
|
if (matchedState) {
|
||||||
|
const row = rows[matchedState.rowIndex];
|
||||||
|
rows[matchedState.rowIndex] = {
|
||||||
|
...row,
|
||||||
|
body: finalText,
|
||||||
|
terminal: true,
|
||||||
|
tone: "ok",
|
||||||
|
header: `${traceClock(completionEvent.createdAt)} 助手最终消息`
|
||||||
|
};
|
||||||
|
matchedState.comparableText = comparableText;
|
||||||
|
matchedState.derivedSnapshotSuffix = false;
|
||||||
|
return matchedState.rowIndex;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
rowId: `trace-final-response:${completionEvent.seq ?? "completed"}`,
|
||||||
|
seq: numberOrNull(completionEvent.seq),
|
||||||
|
tone: "ok",
|
||||||
|
header: `${traceClock(completionEvent.createdAt)} 助手最终消息`,
|
||||||
|
terminal: true,
|
||||||
|
body: finalText,
|
||||||
|
bodyFormat: "markdown"
|
||||||
|
});
|
||||||
|
assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: completionEvent });
|
||||||
|
return rows.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantSnapshotDecision(nextText: string, previousText: string, nextEvent: TraceEvent, previousEvent: TraceEvent): AssistantSnapshotDecision {
|
||||||
|
if (!sameAssistantSnapshotIdentity(nextEvent, previousEvent)) return { action: "replace" };
|
||||||
|
if (isAuthoritativeAssistantEvent(nextEvent) || isAuthoritativeAssistantEvent(previousEvent)) return { action: "replace" };
|
||||||
|
const previousIndex = nextText.indexOf(previousText);
|
||||||
|
if (previousIndex < 0) return { action: "replace" };
|
||||||
|
const suffixText = nextText.slice(previousIndex + previousText.length).trim();
|
||||||
|
if (suffixText && suffixText.length >= Math.max(80, previousText.length / 3)) return { action: "append-suffix", suffixText };
|
||||||
|
if (previousIndex > 0 || !suffixText) return { action: "keep-existing" };
|
||||||
|
return { action: "replace" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAssistantSnapshotIdentity(left: TraceEvent, right: TraceEvent): boolean {
|
||||||
|
const leftItem = nonEmptyString(left.itemId ?? left.messageId ?? left.id);
|
||||||
|
const rightItem = nonEmptyString(right.itemId ?? right.messageId ?? right.id);
|
||||||
|
if (leftItem && rightItem) return leftItem === rightItem;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthoritativeAssistantEvent(event: TraceEvent): boolean {
|
||||||
|
return event.replyAuthority === true || event.final === true || event.terminal === true || String(event.status ?? "") === "completed";
|
||||||
|
}
|
||||||
|
|
||||||
function mergeAssistantRows(preferred: TraceEventRow, metadata: TraceEventRow): TraceEventRow {
|
function mergeAssistantRows(preferred: TraceEventRow, metadata: TraceEventRow): TraceEventRow {
|
||||||
return {
|
return {
|
||||||
...preferred,
|
...preferred,
|
||||||
@@ -199,6 +287,11 @@ function comparableAssistantText(value: unknown): string {
|
|||||||
return cleanTraceText(value).replace(/\s+/gu, " ");
|
return cleanTraceText(value).replace(/\s+/gu, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 markAssistantRowTerminal(trace: Record<string, unknown>, row: TraceEventRow, completionEvent: TraceEvent): TraceEventRow {
|
function markAssistantRowTerminal(trace: Record<string, unknown>, row: TraceEventRow, completionEvent: TraceEvent): TraceEventRow {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ test("web trace rows do not fold progress assistant text into the final response
|
|||||||
assert.doesNotMatch(terminalRows[0]?.body ?? "", /Let me check|当前工作区还没有/u);
|
assert.doesNotMatch(terminalRows[0]?.body ?? "", /Let me check|当前工作区还没有/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("web trace rows keep clean AgentRun final response when same itemId snapshots stay running (#955)", () => {
|
||||||
|
const progressOne = "好,我来走 HWPOD 编译路径。先看看 hwpod 命令是否可用,并读取 hwpod-cli 技能了解完整的编译流程。";
|
||||||
|
const progressTwo = "好,现在用 hwpod build 走编译。根据 skill,标准路径是 hwpod build 触发 hwpod-compiler-cli,再通过 API 发给 hwpod-node。";
|
||||||
|
const finalText = "编译成功!来总结一下结果:\n\n hwpod build ✅ completed\n\n- 设备: D601-F103-V2 (STM32F103)\n- 工具链: Keil MDK,通过 keil-cli.py build 异步发起\n- 构建命令: py -3 keil-cli.py build -p atk_f103.uvprojx -t USART\n- exit code: 0,任务已入队列\n- 预期产物: F:\\Work\\D601-HWLAB\\projects\\01_baseline\\Output\\atk_f103.hex\n- 无 blocker";
|
||||||
|
const events: Record<string, unknown>[] = [
|
||||||
|
{ seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-06T01:46:11.000Z" },
|
||||||
|
{ seq: 17, source: "agentrun", sourceSeq: 48, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: progressOne, createdAt: "2026-06-06T01:46:15.000Z" },
|
||||||
|
{ seq: 22, source: "agentrun", sourceSeq: 53, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: progressTwo, createdAt: "2026-06-06T01:46:18.000Z" },
|
||||||
|
{ seq: 26, source: "agentrun", sourceSeq: 57, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: `${progressOne}${progressTwo}${finalText.slice(0, 170)}`, createdAt: "2026-06-06T01:46:24.000Z" },
|
||||||
|
{ seq: 27, source: "agentrun", sourceSeq: 58, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: finalText, createdAt: "2026-06-06T01:46:25.000Z" },
|
||||||
|
{ seq: 30, source: "agentrun", sourceSeq: 61, label: "agentrun:assistant:message", type: "assistant", status: "running", itemId: "msg_item_0", message: `${progressOne}${progressTwo}${finalText}`, createdAt: "2026-06-06T01:46:26.000Z" },
|
||||||
|
{ seq: 36, label: "agentrun:result:completed", type: "result", status: "completed", terminal: true, message: "AgentRun result is ready for HWLAB short-connection polling.", createdAt: "2026-06-06T01:46:28.000Z" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = traceDisplayRows({ startedAt: "2026-06-06T01:46:11.000Z", finalResponse: { text: finalText } }, events);
|
||||||
|
const assistantRows = rows.filter((row) => row.bodyFormat === "markdown");
|
||||||
|
const terminalRows = assistantRows.filter((row) => row.terminal === true);
|
||||||
|
|
||||||
|
assert.equal(terminalRows.length, 1);
|
||||||
|
assert.equal(terminalRows[0]?.body, finalText);
|
||||||
|
assert.doesNotMatch(terminalRows[0]?.body ?? "", /好,我来走 HWPOD 编译路径|好,现在用 hwpod build/u);
|
||||||
|
assert.equal(assistantRows.filter((row) => /好,我来走 HWPOD 编译路径/u.test(row.body ?? "")).length, 1);
|
||||||
|
assert.equal(assistantRows.filter((row) => /好,现在用 hwpod build/u.test(row.body ?? "")).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("web trace rows collapse tool start/completed and clean shell command escaping", () => {
|
test("web trace rows collapse tool start/completed and clean shell command escaping", () => {
|
||||||
const events: Record<string, unknown>[] = [
|
const events: Record<string, unknown>[] = [
|
||||||
{ seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T12:13:58.212Z" },
|
{ seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T12:13:58.212Z" },
|
||||||
|
|||||||
Reference in New Issue
Block a user