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 managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
|
||||
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`, {
|
||||
method: "GET",
|
||||
timeoutMs
|
||||
});
|
||||
const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : [];
|
||||
const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } });
|
||||
const events = eventsResponse.events;
|
||||
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`, {
|
||||
method: "GET",
|
||||
@@ -448,7 +445,7 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op
|
||||
const nextMapping = {
|
||||
...mapped.agentRun,
|
||||
...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",
|
||||
runStatus: result?.runStatus ?? mapped.agentRun.runStatus ?? 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 managerUrl = resolveAgentRunManagerUrl(env, mapped.agentRun.managerUrl);
|
||||
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`, {
|
||||
method: "GET",
|
||||
timeoutMs
|
||||
});
|
||||
const events = Array.isArray(eventsResponse?.items) ? eventsResponse.items : [];
|
||||
const eventsResponse = await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } });
|
||||
const events = eventsResponse.events;
|
||||
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)) {
|
||||
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 = {}) {
|
||||
const currentCommandId = typeof mapping.commandId === "string" ? mapping.commandId : "";
|
||||
if (!currentCommandId) return false;
|
||||
const payload = event?.payload && typeof event.payload === "object" ? event.payload : {};
|
||||
const eventCommandId = typeof payload.commandId === "string" ? payload.commandId : "";
|
||||
const eventCommandId = agentRunEventCommandId(event);
|
||||
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 () => {
|
||||
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-"));
|
||||
|
||||
@@ -1835,8 +1835,8 @@ export async function handleCodeAgentTraceHttp(request, response, url, options)
|
||||
}
|
||||
|
||||
function traceSnapshotWithPersistentFallback(snapshot, persistedResult, traceId, refreshError = null) {
|
||||
if (snapshot?.status !== "missing") return snapshot;
|
||||
const fallback = persistentTraceFallback(persistedResult, traceId);
|
||||
if (snapshot?.status !== "missing") return traceSnapshotWithPersistentEvidence(snapshot, fallback, refreshError);
|
||||
if (!fallback) return {
|
||||
...snapshot,
|
||||
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) {
|
||||
if (!result || typeof result !== "object") return 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));
|
||||
}
|
||||
if (completionEvent) {
|
||||
const finalResponseText = traceFinalResponseText(trace);
|
||||
if (finalResponseText) {
|
||||
lastAssistantRowIndex = upsertAuthoritativeFinalResponseRow(rows, assistantRows, completionEvent, finalResponseText);
|
||||
}
|
||||
const lastAssistantRow = lastAssistantRowIndex >= 0 ? rows[lastAssistantRowIndex] : undefined;
|
||||
if (lastAssistantRow) rows[lastAssistantRowIndex] = markAssistantRowTerminal(trace, lastAssistantRow, completionEvent);
|
||||
else rows.push(traceCompletionSummaryRow(trace, completionEvent));
|
||||
@@ -78,6 +82,13 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
interface AssistantRowState {
|
||||
rowIndex: number;
|
||||
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 {
|
||||
@@ -157,7 +168,7 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista
|
||||
const comparableText = comparableAssistantText(row.body);
|
||||
if (!comparableText) {
|
||||
rows.push(row);
|
||||
assistantRows.push({ rowIndex: rows.length - 1, comparableText });
|
||||
assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: event });
|
||||
return rows.length - 1;
|
||||
}
|
||||
|
||||
@@ -168,11 +179,31 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista
|
||||
if (!existingRow) continue;
|
||||
if (state.comparableText === comparableText) {
|
||||
rows[state.rowIndex] = mergeAssistantRows(existingRow, row);
|
||||
state.sourceEvent = event;
|
||||
state.derivedSnapshotSuffix = false;
|
||||
return state.rowIndex;
|
||||
}
|
||||
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);
|
||||
state.comparableText = comparableText;
|
||||
state.sourceEvent = event;
|
||||
state.derivedSnapshotSuffix = false;
|
||||
return state.rowIndex;
|
||||
}
|
||||
if (state.comparableText.includes(comparableText)) {
|
||||
@@ -182,10 +213,67 @@ function upsertAssistantMessageRow(rows: TraceEventRow[], assistantRows: Assista
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
assistantRows.push({ rowIndex: rows.length - 1, comparableText });
|
||||
assistantRows.push({ rowIndex: rows.length - 1, comparableText, sourceEvent: event });
|
||||
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 {
|
||||
return {
|
||||
...preferred,
|
||||
@@ -199,6 +287,11 @@ function comparableAssistantText(value: unknown): string {
|
||||
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 {
|
||||
return {
|
||||
...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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const events: Record<string, unknown>[] = [
|
||||
{ seq: 1, label: "agentrun:request:accepted", status: "accepted", createdAt: "2026-06-04T12:13:58.212Z" },
|
||||
|
||||
Reference in New Issue
Block a user