Merge pull request #2509 from pikasTech/fix/2508-assistant-progress-contract
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
修复纯 Kafka 助手进度事件生命周期
This commit is contained in:
@@ -103,6 +103,61 @@ test("projects AgentRun assistant_message Kafka event into HWLAB trace event", (
|
||||
assert.equal(projected.valuesPrinted, false);
|
||||
});
|
||||
|
||||
test("projects AgentRun assistant_progress as an identity-preserving HWLAB assistant lifecycle event", () => {
|
||||
const text = "p".repeat(500);
|
||||
const projected = projectAgentRunKafkaEventToHwlabEvent({
|
||||
schema: "agentrun.event.v1",
|
||||
eventType: "agentrun.event.committed",
|
||||
eventId: "evt_assistant_progress",
|
||||
sourceSeq: 136,
|
||||
traceId: "trc_assistant_progress",
|
||||
hwlabSessionId: "ses_assistant_progress",
|
||||
runId: "run_assistant_progress",
|
||||
commandId: "cmd_assistant_progress",
|
||||
valuesPrinted: false,
|
||||
run: { runId: "run_assistant_progress", sessionId: "ses_agentrun_progress", hwlabSessionId: "ses_assistant_progress", valuesPrinted: false },
|
||||
command: { commandId: "cmd_assistant_progress", runId: "run_assistant_progress", seq: 1, type: "turn", state: "running", payloadHash: "hash", valuesPrinted: false },
|
||||
event: {
|
||||
id: "evt_assistant_progress",
|
||||
runId: "run_assistant_progress",
|
||||
seq: 136,
|
||||
type: "assistant_progress",
|
||||
createdAt: "2026-07-12T10:00:01.000Z",
|
||||
payload: {
|
||||
traceId: "trc_assistant_progress",
|
||||
commandId: "cmd_assistant_progress",
|
||||
text,
|
||||
itemId: "msg_assistant_progress",
|
||||
source: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
progressFlush: false,
|
||||
replyAuthority: false,
|
||||
final: false,
|
||||
textBytes: 500,
|
||||
outputBytes: 500,
|
||||
valuesPrinted: false
|
||||
}
|
||||
}
|
||||
}, { source: "hwlab-test", sourceTopic: "agentrun.event.v1", sourceOffset: "136" });
|
||||
|
||||
assert.equal(projected.context.agentRunEventType, "assistant_progress");
|
||||
assert.equal(projected.event.agentRunEventType, "assistant_progress");
|
||||
assert.equal(projected.event.type, "assistant");
|
||||
assert.equal(projected.event.eventType, "assistant_progress");
|
||||
assert.equal(projected.event.label, "agentrun:assistant:progress");
|
||||
assert.equal(projected.event.itemId, "msg_assistant_progress");
|
||||
assert.equal(projected.event.sourceSeq, 136);
|
||||
assert.equal(projected.event.assistantSource, "agent-message-delta-progress");
|
||||
assert.equal(projected.event.progress, true);
|
||||
assert.equal(projected.event.progressFlush, false);
|
||||
assert.equal(projected.event.replyAuthority, false);
|
||||
assert.equal(projected.event.final, false);
|
||||
assert.equal(projected.event.textBytes, 500);
|
||||
assert.equal(projected.event.assistantText, text);
|
||||
assert.equal(projected.event.finalResponse, null);
|
||||
assert.equal(projected.event.terminal, false);
|
||||
});
|
||||
|
||||
test("projects AgentRun user_message into a stable HWLAB user event", () => {
|
||||
const projected = projectAgentRunKafkaEventToHwlabEvent({
|
||||
schema: "agentrun.event.v1",
|
||||
|
||||
@@ -476,7 +476,7 @@ export function shouldEmitLiveKafkaOtelSpan(envelope = {}) {
|
||||
const context = objectValue(envelope.context);
|
||||
const eventType = firstText(context.agentRunEventType, event.eventType, event.type, envelope.eventType)?.toLowerCase();
|
||||
if (event.terminal === true || eventType === "terminal" || eventType === "terminal_status") return true;
|
||||
if (["assistant", "assistant_message", "tool", "tool_call"].includes(eventType)) return true;
|
||||
if (["assistant", "assistant_progress", "assistant_message", "tool", "tool_call"].includes(eventType)) return true;
|
||||
if (eventType !== "command_output" && event.type !== "output") return true;
|
||||
const sourceSeq = integerValue(context.sourceSeq ?? event.sourceSeq);
|
||||
if (Number.isInteger(sourceSeq) && sourceSeq > 0) return sourceSeq % LIVE_KAFKA_COMMAND_OUTPUT_OTEL_SAMPLE_MODULUS === 0;
|
||||
@@ -1309,6 +1309,7 @@ export async function openKafkaEventStream({ env = process.env, stream = "hwlab"
|
||||
}
|
||||
|
||||
function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run, command, traceId, sessionId, sourceSeq, runId, commandId }) {
|
||||
const type = firstText(sourceEvent.type, payload.type, input.eventType) || "event";
|
||||
const base = {
|
||||
traceId,
|
||||
sessionId,
|
||||
@@ -1319,10 +1320,10 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run,
|
||||
attemptId: firstText(command.attemptId, sourceEvent.attemptId, payload.attemptId),
|
||||
runnerId: firstText(command.runnerId, sourceEvent.runnerId, payload.runnerId),
|
||||
backend: firstText(run.backendProfile, payload.backendProfile, payload.providerProfile),
|
||||
agentRunEventType: type,
|
||||
createdAt: timestampValue(sourceEvent.createdAt ?? input.producedAt),
|
||||
valuesPrinted: false
|
||||
};
|
||||
const type = firstText(sourceEvent.type, payload.type, input.eventType) || "event";
|
||||
if (type === "user_message") {
|
||||
const userMessageId = firstText(payload.userMessageId);
|
||||
const userText = textPayload(payload, "user message");
|
||||
@@ -1359,16 +1360,60 @@ function mapAgentRunSourceEventToHwlabEvent({ input, sourceEvent, payload, run,
|
||||
assistantSource: firstText(payload.source),
|
||||
messageIndex: integerValue(payload.messageIndex),
|
||||
messageCount: integerValue(payload.messageCount),
|
||||
progress: payload.progress === true,
|
||||
progressFlush: payload.progressFlush === true,
|
||||
durableText: payload.durableText === true,
|
||||
replyAuthority: payload.replyAuthority === true,
|
||||
final: payload.final === true,
|
||||
textBytes: integerValue(payload.textBytes),
|
||||
textTruncated: payload.textTruncated === true,
|
||||
outputBytes: integerValue(payload.outputBytes),
|
||||
outputTruncated: payload.outputTruncated === true,
|
||||
terminal: false
|
||||
};
|
||||
}
|
||||
if (type === "assistant_progress") {
|
||||
const assistantText = textPayload(payload, "assistant progress");
|
||||
return {
|
||||
...base,
|
||||
type: "assistant",
|
||||
eventType: "assistant_progress",
|
||||
status: "running",
|
||||
label: "agentrun:assistant:progress",
|
||||
message: assistantText,
|
||||
text: assistantText,
|
||||
assistantText,
|
||||
finalResponse: null,
|
||||
itemId: firstText(payload.itemId),
|
||||
assistantSource: firstText(payload.source),
|
||||
messageIndex: integerValue(payload.messageIndex),
|
||||
messageCount: integerValue(payload.messageCount),
|
||||
progress: payload.progress === true,
|
||||
progressFlush: payload.progressFlush === true,
|
||||
durableText: false,
|
||||
replyAuthority: false,
|
||||
final: false,
|
||||
textBytes: integerValue(payload.textBytes),
|
||||
textTruncated: payload.textTruncated === true,
|
||||
outputBytes: integerValue(payload.outputBytes),
|
||||
outputTruncated: payload.outputTruncated === true,
|
||||
terminal: false
|
||||
};
|
||||
}
|
||||
if (type === "backend_status") {
|
||||
const phase = firstText(payload.phase) || "status";
|
||||
return { ...base, type: "backend", eventType: "backend", status: "running", label: `agentrun:backend:${phase}`, message: textPayload(payload, phase) };
|
||||
return {
|
||||
...base,
|
||||
type: "backend",
|
||||
eventType: "backend",
|
||||
status: "running",
|
||||
label: `agentrun:backend:${phase}`,
|
||||
message: textPayload(payload, phase),
|
||||
itemId: firstText(payload.itemId),
|
||||
finalSeal: payload.finalSeal === true,
|
||||
replyRef: payload.replyRef && typeof payload.replyRef === "object" && !Array.isArray(payload.replyRef) ? payload.replyRef : null,
|
||||
terminal: false
|
||||
};
|
||||
}
|
||||
if (type === "terminal_status") {
|
||||
const terminalStatus = firstText(payload.terminalStatus, sourceEvent.terminalStatus, payload.status, sourceEvent.status) || "failed";
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { projectAgentRunKafkaEventToHwlabEvent, queryKafkaEventStream } from "../../internal/cloud/kafka-event-bridge.ts";
|
||||
import { mapAgentRunRecordsToHwlabDebugEvents, renderKafkaCliText, runKafkaCli } from "../src/hwlab-cli/kafka-regenerate.ts";
|
||||
import { mapAgentRunRecordsToHwlabDebugEvents, projectHwlabRecordsThroughWorkbench, renderKafkaCliText, runKafkaCli } from "../src/hwlab-cli/kafka-regenerate.ts";
|
||||
import { renderTraceRowsMarkdown, traceDisplayRows, traceToolSummary } from "../src/hwlab-cli/trace-renderer.ts";
|
||||
|
||||
const SESSION_ID = "ses_agentrun_5ec4e141_6abc_466d_9afe_049f7c0ac105";
|
||||
@@ -252,6 +252,198 @@ test("shared row model removes late assistant progress after the same item compl
|
||||
assert.equal((result.markdownOutput?.match(new RegExp(duplicateText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu")) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("fixed Kafka assistant lifecycle replays 500/1003 byte progress into one 1190 byte final", async () => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-kafka-assistant-progress-contract-"));
|
||||
const inputFile = path.join(cwd, "hwlab-events.jsonl");
|
||||
const itemId = "msg_0d2ec_assistant_lifecycle";
|
||||
const finalText = "x".repeat(1190);
|
||||
const progress500 = finalText.slice(0, 500);
|
||||
const progress1003 = finalText.slice(0, 1003);
|
||||
assert.deepEqual([Buffer.byteLength(progress500), Buffer.byteLength(progress1003), Buffer.byteLength(finalText)], [500, 1003, 1190]);
|
||||
|
||||
const agentrunRecords = [
|
||||
agentrunAssistantLifecycleRecord(136, 1, "assistant_progress", {
|
||||
text: progress500,
|
||||
itemId,
|
||||
source: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
progressFlush: false,
|
||||
replyAuthority: false,
|
||||
final: false,
|
||||
textBytes: 500,
|
||||
outputBytes: 500,
|
||||
valuesPrinted: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(137, 2, "assistant_progress", {
|
||||
text: progress1003,
|
||||
itemId,
|
||||
source: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
progressFlush: false,
|
||||
replyAuthority: false,
|
||||
final: false,
|
||||
textBytes: 1003,
|
||||
outputBytes: 1003,
|
||||
valuesPrinted: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(138, 3, "assistant_message", {
|
||||
text: finalText,
|
||||
itemId,
|
||||
source: "completed-agent-message",
|
||||
durableText: true,
|
||||
replyAuthority: false,
|
||||
final: false,
|
||||
textBytes: 1190,
|
||||
outputBytes: 1190,
|
||||
valuesPrinted: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(142, 4, "backend_status", {
|
||||
phase: "assistant-message-final-sealed",
|
||||
itemId,
|
||||
source: "assistant-message-final-seal",
|
||||
finalSeal: true,
|
||||
replyAuthority: true,
|
||||
final: true,
|
||||
replyRef: {
|
||||
eventType: "assistant_message",
|
||||
itemId,
|
||||
source: "completed-agent-message",
|
||||
textSha256: "a".repeat(64)
|
||||
},
|
||||
valuesPrinted: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(143, 5, "terminal_status", {
|
||||
terminalStatus: "completed",
|
||||
valuesPrinted: false
|
||||
})
|
||||
];
|
||||
const hwlabRecords = agentrunRecords.map((record, index) => hwlabOrderRecord(record, index));
|
||||
const hwlabEvents = hwlabRecords.map((record) => record.value.event);
|
||||
|
||||
assert.deepEqual(hwlabRecords.map((record) => record.value.context.agentRunEventType), [
|
||||
"assistant_progress",
|
||||
"assistant_progress",
|
||||
"assistant_message",
|
||||
"backend_status",
|
||||
"terminal_status"
|
||||
]);
|
||||
assert.deepEqual(hwlabEvents.slice(0, 3).map((event) => [event.itemId, event.sourceSeq, event.progress, event.assistantText.length]), [
|
||||
[itemId, 136, true, 500],
|
||||
[itemId, 137, true, 1003],
|
||||
[itemId, 138, false, 1190]
|
||||
]);
|
||||
assert.equal(hwlabEvents[3]?.finalSeal, true);
|
||||
assert.equal(hwlabEvents[3]?.message, "assistant-message-final-sealed");
|
||||
assert.equal(hwlabEvents[3]?.assistantText, undefined, "seal must not copy the completed body");
|
||||
assert.equal(hwlabEvents[4]?.assistantText, undefined, "terminal must not copy the completed body");
|
||||
|
||||
const runningRows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, hwlabEvents.slice(0, 2));
|
||||
assert.equal(runningRows.filter((row) => /助手/u.test(row.header)).length, 1);
|
||||
assert.equal(runningRows.find((row) => /助手/u.test(row.header))?.seq, 137);
|
||||
assert.equal(runningRows.find((row) => /助手/u.test(row.header))?.body, progress1003);
|
||||
const completedRows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, hwlabEvents.slice(0, 3));
|
||||
assert.equal(completedRows.filter((row) => /助手/u.test(row.header)).length, 1);
|
||||
assert.equal(completedRows.find((row) => /助手/u.test(row.header))?.seq, 138);
|
||||
assert.equal(completedRows.find((row) => /助手/u.test(row.header))?.body, finalText);
|
||||
|
||||
const projected = projectHwlabRecordsThroughWorkbench(hwlabRecords, {
|
||||
traceId: TRACE_ID,
|
||||
sessionId: HWLAB_SESSION_ID,
|
||||
observedAt: "2026-07-12T10:01:00.000Z"
|
||||
});
|
||||
assert.equal(projected.appliedCount, 5);
|
||||
assert.equal(projected.sourceEvents.length, 5);
|
||||
assert.equal(projected.message?.runnerTrace?.events?.length, 5, "raw/applied observation must retain every unfiltered event");
|
||||
assert.equal(projected.message?.text, finalText);
|
||||
assert.equal(projected.message?.status, "completed");
|
||||
assert.equal((projected.message?.finalResponse as { text?: string } | null)?.text, finalText);
|
||||
|
||||
await writeFile(inputFile, hwlabRecords.map((record) => JSON.stringify(record)).join("\n") + "\n", "utf8");
|
||||
const result = await runKafkaCli([
|
||||
"render", "trace",
|
||||
"--from", "jsonl",
|
||||
"--trace-id", TRACE_ID,
|
||||
"--session-id", HWLAB_SESSION_ID,
|
||||
"--jsonl-file", inputFile,
|
||||
"--format", "markdown"
|
||||
], { cwd, env: {}, now: () => "2026-07-12T10:02:00.000Z" });
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.projection.appliedCount, 5);
|
||||
assert.equal(result.payload.projection.eventCount, 5);
|
||||
assert.equal(result.payload.render.assistantRowCount, 0, "the authoritative outer final replaces the progress lifecycle");
|
||||
assert.equal(result.payload.render.finalResponsePresent, true);
|
||||
assert.equal(result.payload.render.finalResponseInTrace, false);
|
||||
assert.deepEqual(result.payload.replayLineage.counts, { source: 5, applied: 5, assistant: 0, tool: 0, terminal: 1, final: 1 });
|
||||
assert.equal(result.payload.replayLineage.layers.source.assistant, 3, "raw source lineage must keep both progress snapshots and the durable completion");
|
||||
assert.equal(result.payload.replayLineage.layers.applied.assistant, 3);
|
||||
assert.equal(result.payload.validation.allMatchedEventsApplied, true);
|
||||
assert.equal(result.payload.validation.finalResponseOutsideTrace, true);
|
||||
assert.equal(result.markdownOutput?.split(finalText).length, 2, "the final body must render exactly once");
|
||||
const traceMarkdown = (result.markdownOutput ?? "").split("## 运行记录")[1]?.split("## 最终回复")[0] ?? "";
|
||||
assert.doesNotMatch(traceMarkdown, /x{500}/u, "progress must not remain as a separate rendered row");
|
||||
});
|
||||
|
||||
test("HWLAB compatibility window coalesces legacy assistant_message progress before AgentRun assistant_progress rollout", () => {
|
||||
const itemId = "msg_legacy_progress_contract";
|
||||
const finalText = "legacy durable body";
|
||||
const agentrunRecords = [
|
||||
agentrunAssistantLifecycleRecord(201, 1, "assistant_message", {
|
||||
text: "legacy progress 1",
|
||||
itemId,
|
||||
source: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
progressFlush: false,
|
||||
replyAuthority: false,
|
||||
final: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(202, 2, "assistant_message", {
|
||||
text: "legacy progress 2",
|
||||
itemId,
|
||||
source: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
progressFlush: true,
|
||||
replyAuthority: false,
|
||||
final: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(203, 3, "assistant_message", {
|
||||
text: finalText,
|
||||
itemId,
|
||||
source: "completed-agent-message",
|
||||
durableText: true,
|
||||
replyAuthority: false,
|
||||
final: false
|
||||
}),
|
||||
agentrunAssistantLifecycleRecord(204, 4, "terminal_status", { terminalStatus: "completed" })
|
||||
];
|
||||
const hwlabRecords = agentrunRecords.map((record, index) => hwlabOrderRecord(record, index));
|
||||
const events = hwlabRecords.map((record) => record.value.event);
|
||||
|
||||
assert.deepEqual(events.slice(0, 2).map((event) => [event.agentRunEventType, event.eventType, event.progress]), [
|
||||
["assistant_message", "assistant", true],
|
||||
["assistant_message", "assistant", true]
|
||||
]);
|
||||
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, events.slice(0, 3));
|
||||
const assistantRows = rows.filter((row) => /助手/u.test(row.header));
|
||||
assert.equal(assistantRows.length, 1);
|
||||
assert.equal(assistantRows[0]?.seq, 203);
|
||||
assert.equal(assistantRows[0]?.body, finalText);
|
||||
|
||||
const projected = projectHwlabRecordsThroughWorkbench(hwlabRecords, {
|
||||
traceId: TRACE_ID,
|
||||
sessionId: HWLAB_SESSION_ID,
|
||||
observedAt: "2026-07-12T10:03:00.000Z"
|
||||
});
|
||||
assert.equal(projected.message?.text, finalText);
|
||||
assert.equal(projected.message?.runnerTrace?.events?.length, 4);
|
||||
assert.equal((projected.message?.finalResponse as { text?: string } | null)?.text, finalText);
|
||||
const finalRows = traceDisplayRows(projected.message?.runnerTrace ?? {}, projected.message?.runnerTrace?.events ?? [], {
|
||||
finalResponsePlacement: "outer",
|
||||
outerFinalResponseText: finalText
|
||||
});
|
||||
assert.equal(finalRows.filter((row) => /助手/u.test(row.header)).length, 0);
|
||||
});
|
||||
|
||||
test("shared tool summary model keeps status semantic and visible content status-free", () => {
|
||||
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [
|
||||
{
|
||||
@@ -325,7 +517,7 @@ test("shared tool summary model keeps status semantic and visible content status
|
||||
assert.match(markdown, /aria-label="工具调用失败:/u);
|
||||
});
|
||||
|
||||
test("outer final response stays out of the Trace fallback when no completion event exists", () => {
|
||||
test("outer final response suppression follows formal assistant identity instead of equal text", () => {
|
||||
const finalText = "Hi. What do you want to work on?";
|
||||
const rows = traceDisplayRows({ eventSource: "hwlab-kafka-sse" }, [
|
||||
{
|
||||
@@ -360,7 +552,9 @@ test("outer final response stays out of the Trace fallback when no completion ev
|
||||
outerFinalResponseText: finalText
|
||||
});
|
||||
|
||||
assert.deepEqual(rows, []);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0]?.rowId, "event:evt_same_text");
|
||||
assert.equal(rows[0]?.body, finalText, "a distinct itemId with equal text must remain visible");
|
||||
});
|
||||
|
||||
test("trace JSON defaults to a bounded tail while the Markdown artifact stays complete", async () => {
|
||||
@@ -1504,6 +1698,14 @@ function agentrunOrderRecord(seq: number, traceId: string, type: string, payload
|
||||
return record;
|
||||
}
|
||||
|
||||
function agentrunAssistantLifecycleRecord(seq: number, ordinal: number, type: string, payload: Record<string, unknown>): any {
|
||||
const record = agentrunOrderRecord(seq, TRACE_ID, type, payload);
|
||||
const createdAt = `2026-07-12T10:00:0${ordinal}.000Z`;
|
||||
record.value.committedAt = createdAt;
|
||||
record.value.event.createdAt = createdAt;
|
||||
return record;
|
||||
}
|
||||
|
||||
function hwlabOrderRecord(source: any, index: number): any {
|
||||
const valueText = JSON.stringify(source.value);
|
||||
const projected = projectAgentRunKafkaEventToHwlabEvent(source.value, {
|
||||
|
||||
@@ -1502,7 +1502,7 @@ function traceLayerEventKind(event: JsonRecord): "user" | "assistant" | "tool" |
|
||||
const type = text(event.type ?? event.eventType);
|
||||
const label = text(event.label);
|
||||
if (type === "user") return "user";
|
||||
if (type === "assistant" || type === "assistant_message" || label === "agentrun:assistant:message" || label === "assistant:message") return "assistant";
|
||||
if (type === "assistant" || type === "assistant_progress" || type === "assistant_message" || label === "agentrun:assistant:progress" || label === "agentrun:assistant:message" || label === "assistant:message") return "assistant";
|
||||
if (type === "tool" || type === "tool_call" || text(event.toolName) === "commandExecution" || /commandExecution|^agentrun:tool:/u.test(label)) return "tool";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface TraceEventRow {
|
||||
|
||||
export type TraceToolState = "running" | "success" | "failure" | "neutral";
|
||||
|
||||
type TraceEvent = Record<string, unknown>;
|
||||
export type TraceEvent = Record<string, unknown>;
|
||||
type TraceClockFormatter = (value: string) => string;
|
||||
export type TraceSequenceAuthority = "projected" | "source";
|
||||
export type TraceFinalResponsePlacement = "trace" | "outer";
|
||||
@@ -42,12 +42,9 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
);
|
||||
const effectiveTrace = traceWithInferredStart(trace, orderedEvents);
|
||||
const finalResponseText = nonEmptyString(options.outerFinalResponseText) ?? traceFinalResponseText(effectiveTrace);
|
||||
const finalResponseFingerprint = traceMessageFingerprint(finalResponseText);
|
||||
const terminalAssistantFingerprints = new Set(orderedEvents
|
||||
.filter(isTerminalAssistantTraceEvent)
|
||||
.map((event) => traceAssistantEventFingerprint(effectiveTrace, event))
|
||||
.filter((value): value is string => Boolean(value)));
|
||||
if (finalResponseFingerprint) terminalAssistantFingerprints.add(finalResponseFingerprint);
|
||||
const outerFinalAssistantEvent = displayOptions.finalResponsePlacement === "outer" && finalResponseText
|
||||
? traceOuterFinalAssistantEvent(orderedEvents)
|
||||
: null;
|
||||
const hasCompletionEvent = orderedEvents.some(isCompletionTraceEvent);
|
||||
const rows: TraceEventRow[] = [];
|
||||
let completionEvent: TraceEvent | null = null;
|
||||
@@ -78,7 +75,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
continue;
|
||||
}
|
||||
if (isTerminalAssistantTraceEvent(event)) {
|
||||
if (isOuterFinalResponseTraceEvent(effectiveTrace, event, displayOptions, terminalAssistantFingerprints)) continue;
|
||||
if (isOuterFinalResponseTraceEvent(event, displayOptions, outerFinalAssistantEvent)) continue;
|
||||
if (hasCompletionEvent) continue;
|
||||
if (finalResponseText) {
|
||||
rows.push(traceFinalResponseRow(event, finalResponseText, displayOptions));
|
||||
@@ -88,9 +85,7 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
continue;
|
||||
}
|
||||
if (isAssistantTraceEvent(event)) {
|
||||
const fingerprint = traceAssistantEventFingerprint(effectiveTrace, event);
|
||||
if (isOuterFinalResponseTraceEvent(effectiveTrace, event, displayOptions, terminalAssistantFingerprints)) continue;
|
||||
if (finalResponseFingerprint && fingerprint === finalResponseFingerprint) continue;
|
||||
if (isOuterFinalResponseTraceEvent(event, displayOptions, outerFinalAssistantEvent)) continue;
|
||||
rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: false }, displayOptions));
|
||||
continue;
|
||||
}
|
||||
@@ -108,21 +103,47 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
||||
return orderedEvents
|
||||
.filter((event, index) => !isSupersededTraceItemLifecycleEvent(event, orderedEvents, index))
|
||||
.filter((event) => !isSuppressedTraceEvent(event))
|
||||
.filter((event) => !isOuterFinalResponseTraceEvent(effectiveTrace, event, displayOptions, terminalAssistantFingerprints))
|
||||
.filter((event) => !isOuterFinalResponseTraceEvent(event, displayOptions, outerFinalAssistantEvent))
|
||||
.map((event) => traceDisplayRow(event, displayOptions));
|
||||
}
|
||||
|
||||
function isOuterFinalResponseTraceEvent(
|
||||
trace: Record<string, unknown>,
|
||||
event: TraceEvent,
|
||||
options: ResolvedTraceDisplayRowsOptions,
|
||||
terminalAssistantFingerprints: Set<string>
|
||||
outerFinalAssistantEvent: TraceEvent | null
|
||||
): boolean {
|
||||
if (options.finalResponsePlacement !== "outer") return false;
|
||||
if (isTerminalAssistantTraceEvent(event)) return true;
|
||||
if (!isAssistantTraceEvent(event)) return false;
|
||||
const fingerprint = traceAssistantEventFingerprint(trace, event);
|
||||
return Boolean(fingerprint && terminalAssistantFingerprints.has(fingerprint));
|
||||
return event === outerFinalAssistantEvent;
|
||||
}
|
||||
|
||||
function traceOuterFinalAssistantEvent(events: TraceEvent[]): TraceEvent | null {
|
||||
const visible = events
|
||||
.map((event, index) => ({ event, index }))
|
||||
.filter(({ event, index }) => isAssistantTraceEvent(event) && !traceAssistantLifecycleEventIsSuperseded(event, events, index));
|
||||
if (visible.length === 0) return null;
|
||||
const authoritative = [...visible].reverse().find(({ event }) => event.replyAuthority === true || event.final === true);
|
||||
if (authoritative) return authoritative.event;
|
||||
const seal = [...events].reverse().find((event) => event.finalSeal === true || nonEmptyString(event.label) === "agentrun:backend:assistant-message-final-sealed");
|
||||
if (seal) {
|
||||
const replyRef = isRecord(seal.replyRef) ? seal.replyRef : null;
|
||||
const itemId = nonEmptyString(replyRef?.itemId ?? seal.itemId);
|
||||
if (itemId) {
|
||||
const matched = [...visible].reverse().find(({ event }) => nonEmptyString(event.itemId) === itemId);
|
||||
if (matched) return matched.event;
|
||||
}
|
||||
const sealIndex = events.indexOf(seal);
|
||||
const preceding = [...visible].reverse().find(({ index }) => index < sealIndex);
|
||||
if (preceding) return preceding.event;
|
||||
}
|
||||
let terminalIndex = -1;
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (!event || !(isCompletionTraceEvent(event) || event.terminal === true || event.eventType === "terminal" || event.type === "result")) continue;
|
||||
terminalIndex = index;
|
||||
break;
|
||||
}
|
||||
const precedingTerminal = terminalIndex >= 0 ? [...visible].reverse().find(({ index }) => index < terminalIndex) : null;
|
||||
return precedingTerminal?.event ?? visible.at(-1)?.event ?? null;
|
||||
}
|
||||
|
||||
function traceSequenceAuthority(trace: Record<string, unknown>): TraceSequenceAuthority {
|
||||
@@ -344,15 +365,6 @@ function traceAssistantEventText(trace: Record<string, unknown>, event: TraceEve
|
||||
return cleanTraceDetailText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? "");
|
||||
}
|
||||
|
||||
function traceAssistantEventFingerprint(trace: Record<string, unknown>, event: TraceEvent): string | null {
|
||||
return traceMessageFingerprint(traceAssistantEventText(trace, event));
|
||||
}
|
||||
|
||||
function traceMessageFingerprint(value: unknown): string | null {
|
||||
const text = cleanTraceDetailText(value ?? "").replace(/\s+/gu, " ").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function traceFinalResponseRow(event: TraceEvent, finalText: string, options: ResolvedTraceDisplayRowsOptions): TraceEventRow {
|
||||
const eventKey = traceEventIdentityToken(event, options.sequenceAuthority);
|
||||
return {
|
||||
@@ -452,7 +464,13 @@ function isCompletionTraceEvent(event: TraceEvent): boolean {
|
||||
|
||||
function isAssistantTraceEvent(event: TraceEvent): boolean {
|
||||
const label = String(event.label ?? "");
|
||||
return label === "agentrun:assistant:message" || label === "assistant:message" || event.type === "assistant_message" || event.type === "assistant";
|
||||
return label === "agentrun:assistant:message"
|
||||
|| label === "agentrun:assistant:progress"
|
||||
|| label === "assistant:message"
|
||||
|| event.type === "assistant_progress"
|
||||
|| event.eventType === "assistant_progress"
|
||||
|| event.type === "assistant_message"
|
||||
|| event.type === "assistant";
|
||||
}
|
||||
|
||||
function isTerminalAssistantTraceEvent(event: TraceEvent): boolean {
|
||||
@@ -498,7 +516,7 @@ function traceSourceEventKey(event: TraceEvent | null | undefined): string | nul
|
||||
}
|
||||
|
||||
function isSupersededTraceItemLifecycleEvent(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||||
return isSupersededToolStart(event, events, index) || isLateAssistantProgressAfterCompletion(event, events, index);
|
||||
return isSupersededToolStart(event, events, index) || traceAssistantLifecycleEventIsSuperseded(event, events, index);
|
||||
}
|
||||
|
||||
function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||||
@@ -508,14 +526,38 @@ function isSupersededToolStart(event: TraceEvent, events: TraceEvent[], index: n
|
||||
return events.slice(index + 1).some((candidate) => candidate && isToolTraceEvent(candidate) && isToolCompleteTraceEvent(candidate) && toolIdentity(candidate) === identity);
|
||||
}
|
||||
|
||||
function isLateAssistantProgressAfterCompletion(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||||
if (!isAssistantTraceEvent(event) || nonEmptyString(event.assistantSource) !== "agent-message-delta-progress") return false;
|
||||
export function traceAssistantLifecycleEventIsSuperseded(event: TraceEvent, events: TraceEvent[], index: number): boolean {
|
||||
if (!isAssistantTraceEvent(event)) return false;
|
||||
const identity = nonEmptyString(event.itemId);
|
||||
if (!identity) return false;
|
||||
return events.slice(0, index).some((candidate) => candidate
|
||||
&& isAssistantTraceEvent(candidate)
|
||||
&& nonEmptyString(candidate.itemId) === identity
|
||||
&& nonEmptyString(candidate.assistantSource) === "completed-agent-message");
|
||||
const rank = traceAssistantLifecycleRank(event);
|
||||
const sequence = traceAssistantLifecycleSequence(event);
|
||||
return events.some((candidate, candidateIndex) => {
|
||||
if (candidateIndex === index || !candidate || !isAssistantTraceEvent(candidate) || nonEmptyString(candidate.itemId) !== identity) return false;
|
||||
const candidateRank = traceAssistantLifecycleRank(candidate);
|
||||
if (candidateRank !== rank) return candidateRank > rank;
|
||||
const candidateSequence = traceAssistantLifecycleSequence(candidate);
|
||||
if (sequence !== null && candidateSequence !== null && candidateSequence !== sequence) return candidateSequence > sequence;
|
||||
return candidateIndex > index;
|
||||
});
|
||||
}
|
||||
|
||||
function traceAssistantLifecycleRank(event: TraceEvent): number {
|
||||
if (event.replyAuthority === true || event.final === true) return 3;
|
||||
if (traceAssistantLifecycleProgress(event)) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function traceAssistantLifecycleProgress(event: TraceEvent): boolean {
|
||||
return event.progress === true
|
||||
|| event.type === "assistant_progress"
|
||||
|| event.eventType === "assistant_progress"
|
||||
|| nonEmptyString(event.label) === "agentrun:assistant:progress"
|
||||
|| nonEmptyString(event.assistantSource) === "agent-message-delta-progress";
|
||||
}
|
||||
|
||||
function traceAssistantLifecycleSequence(event: TraceEvent): number | null {
|
||||
return numberOrNull(event.sourceSeq ?? event.projectedSeq ?? event.seq);
|
||||
}
|
||||
|
||||
function isNoisyTraceEvent(event: TraceEvent): boolean {
|
||||
|
||||
@@ -219,6 +219,66 @@ test("live Kafka projection refreshes lastEventAt from each ingress receipt", ()
|
||||
assert.equal(second.runnerTrace?.eventCount, 2);
|
||||
});
|
||||
|
||||
test("assistant item completion stays authoritative over a later progress event while raw events remain observable", () => {
|
||||
const traceId = "trc_live_assistant_lifecycle";
|
||||
const sessionId = "ses_live_assistant_lifecycle";
|
||||
const itemId = "msg_live_assistant_lifecycle";
|
||||
const progress = projectWorkbenchLiveKafkaMessage({
|
||||
previous: null,
|
||||
traceId,
|
||||
sessionId,
|
||||
receivedAt: "2026-07-12T10:00:01.000Z",
|
||||
event: traceEvent(136, "assistant", {
|
||||
eventType: "assistant_progress",
|
||||
itemId,
|
||||
assistantSource: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
assistantText: "partial body"
|
||||
})
|
||||
});
|
||||
const completed = projectWorkbenchLiveKafkaMessage({
|
||||
previous: progress,
|
||||
traceId,
|
||||
sessionId,
|
||||
receivedAt: "2026-07-12T10:00:02.000Z",
|
||||
event: traceEvent(138, "assistant", {
|
||||
eventType: "assistant",
|
||||
itemId,
|
||||
assistantSource: "completed-agent-message",
|
||||
durableText: true,
|
||||
assistantText: "durable body"
|
||||
})
|
||||
});
|
||||
const lateProgress = projectWorkbenchLiveKafkaMessage({
|
||||
previous: completed,
|
||||
traceId,
|
||||
sessionId,
|
||||
receivedAt: "2026-07-12T10:00:03.000Z",
|
||||
event: traceEvent(139, "assistant", {
|
||||
eventType: "assistant_progress",
|
||||
itemId,
|
||||
assistantSource: "agent-message-delta-progress",
|
||||
progress: true,
|
||||
assistantText: "stale late progress"
|
||||
})
|
||||
});
|
||||
const terminal = projectWorkbenchLiveKafkaMessage({
|
||||
previous: lateProgress,
|
||||
traceId,
|
||||
sessionId,
|
||||
receivedAt: "2026-07-12T10:00:04.000Z",
|
||||
event: traceEvent(143, "result", { eventType: "terminal", terminal: true, status: "completed" })
|
||||
});
|
||||
|
||||
assert.equal(progress.text, "partial body");
|
||||
assert.equal(completed.text, "durable body");
|
||||
assert.equal(lateProgress.text, "durable body");
|
||||
assert.equal(lateProgress.runnerTrace?.events?.length, 3, "visibility keeps the superseded progress source event");
|
||||
assert.equal(terminal.text, "durable body");
|
||||
assert.equal((terminal.finalResponse as { text?: string } | null)?.text, "durable body");
|
||||
assert.equal(terminal.runnerTrace?.events?.length, 4);
|
||||
});
|
||||
|
||||
test("live Kafka projection keeps terminal lifecycle while accepting a later event", () => {
|
||||
const traceId = "trc_live_reopened";
|
||||
const sessionId = "ses_live_reopened";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { mergeRunnerTrace } from "../composables/workbench-trace-snapshot";
|
||||
import type { ChatMessage, TraceEvent, WorkbenchTurnTimingProjection } from "../types";
|
||||
import { firstNonEmptyString } from "../utils";
|
||||
import { traceAssistantLifecycleEventIsSuperseded } from "../../../../tools/src/hwlab-cli/trace-renderer.ts";
|
||||
|
||||
export interface WorkbenchLiveMessageState {
|
||||
text: string;
|
||||
@@ -28,11 +29,14 @@ export interface WorkbenchLiveKafkaUserProjectionInput {
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
export function reduceWorkbenchLiveKafkaMessageState(previous: WorkbenchLiveMessageState, event: TraceEvent): WorkbenchLiveMessageState {
|
||||
export function reduceWorkbenchLiveKafkaMessageState(previous: WorkbenchLiveMessageState, event: TraceEvent, previousEvents: TraceEvent[] = []): WorkbenchLiveMessageState {
|
||||
if (previous.terminal) return previous;
|
||||
const terminal = workbenchLiveKafkaEventIsTerminal(event);
|
||||
const status = terminal ? firstNonEmptyString(event.status, "completed") ?? "completed" : "running";
|
||||
const assistantText = workbenchLiveKafkaAssistantText(event);
|
||||
const lifecycleEvents = [...previousEvents, event];
|
||||
const assistantText = traceAssistantLifecycleEventIsSuperseded(event, lifecycleEvents, lifecycleEvents.length - 1)
|
||||
? null
|
||||
: workbenchLiveKafkaAssistantText(event);
|
||||
return {
|
||||
text: assistantText ?? (terminal ? workbenchLiveKafkaTerminalText(event, previous.text, status) : previous.text),
|
||||
status,
|
||||
@@ -98,7 +102,7 @@ export function workbenchLiveKafkaEventIsTerminal(event: TraceEvent | null | und
|
||||
}
|
||||
|
||||
export function workbenchLiveKafkaAssistantText(event: TraceEvent | null | undefined): string | null {
|
||||
if (!event || firstNonEmptyString(event.type, event.eventType) !== "assistant") return null;
|
||||
if (!event || !["assistant", "assistant_progress"].includes(firstNonEmptyString(event.type, event.eventType) ?? "")) return null;
|
||||
const finalResponse = recordValue(event.finalResponse);
|
||||
return firstNonEmptyString(finalResponse?.text, event.assistantText, event.text, event.message) ?? null;
|
||||
}
|
||||
@@ -121,7 +125,7 @@ export function projectWorkbenchLiveKafkaMessage(input: WorkbenchLiveKafkaProjec
|
||||
text: previous?.text ?? "",
|
||||
status: previous?.status ?? "running",
|
||||
terminal: previous?.traceAutoLifecycle === "terminal"
|
||||
}, input.event);
|
||||
}, input.event, Array.isArray(previous?.runnerTrace?.events) ? previous.runnerTrace.events : []);
|
||||
const startedAt = timestamp(previous?.timing?.startedAt)
|
||||
?? timestamp(previous?.startedAt)
|
||||
?? timestamp(input.event.createdAt)
|
||||
|
||||
Reference in New Issue
Block a user