fix: keep running workbench timing stable (#2193)

This commit is contained in:
Lyon
2026-06-26 15:27:34 +08:00
committed by GitHub
parent 8a7e17cc43
commit 607e06070a
2 changed files with 64 additions and 3 deletions
@@ -156,3 +156,28 @@ test("Workbench session messages keep sealed terminal timing on bulk refresh", (
assert.equal(agent?.timing?.durationMs, 12_000); assert.equal(agent?.timing?.durationMs, 12_000);
assert.equal(agent?.finishedAt, "2026-06-24T00:00:12.000Z"); assert.equal(agent?.finishedAt, "2026-06-24T00:00:12.000Z");
}); });
test("Workbench server-state keeps running card startedAt stable across early snapshots", () => {
let state = createWorkbenchServerState();
state = reduceWorkbenchServerState(state, {
type: "session.messages",
sessionId: "ses_running_seal",
messages: [
{ id: "msg_user", messageId: "msg_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-24T00:00:00.000Z", sessionId: "ses_running_seal", traceId: "trc_running_seal" },
{ id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-24T00:00:01.000Z", sessionId: "ses_running_seal", traceId: "trc_running_seal", traceAutoLifecycle: "running", timing: { startedAt: "2026-06-24T00:00:01.000Z", lastEventAt: "2026-06-24T00:00:01.000Z", durationMs: null, valuesRedacted: true }, startedAt: "2026-06-24T00:00:01.000Z", lastEventAt: "2026-06-24T00:00:01.000Z", durationMs: null }
]
});
state = reduceWorkbenchServerState(state, {
type: "message.snapshot",
sessionId: "ses_running_seal",
message: { id: "msg_agent", messageId: "msg_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: "2026-06-24T00:00:02.000Z", sessionId: "ses_running_seal", traceId: "trc_running_seal", timing: { startedAt: "2026-06-24T00:00:02.000Z", lastEventAt: "2026-06-24T00:00:02.000Z", durationMs: 0, valuesRedacted: true }, startedAt: "2026-06-24T00:00:02.000Z", lastEventAt: "2026-06-24T00:00:02.000Z", durationMs: 0 }
});
const agent = selectActiveMessages(state, "ses_running_seal").find((message) => message.role === "agent");
assert.equal(agent?.startedAt, "2026-06-24T00:00:01.000Z");
assert.equal(agent?.timing?.startedAt, "2026-06-24T00:00:01.000Z");
assert.equal(agent?.durationMs, null);
assert.equal(agent?.timing?.durationMs, null);
assert.equal(agent?.lastEventAt, "2026-06-24T00:00:02.000Z");
});
@@ -173,16 +173,37 @@ function mergeMessageSnapshot(existing: ChatMessage, incoming: ChatMessage): Cha
traceAutoLifecycle: existing.traceAutoLifecycle, traceAutoLifecycle: existing.traceAutoLifecycle,
updatedAt: incoming.updatedAt ?? existing.updatedAt updatedAt: incoming.updatedAt ?? existing.updatedAt
}; };
return sealExistingTerminalMessageTiming(existing, merged); return sealExistingMessageTiming(existing, merged);
} }
function mergeMessageList(existing: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] { function mergeMessageList(existing: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
return incoming.map((message) => { return incoming.map((message) => {
const previous = existing.find((item) => messageMatchesSnapshot(item, message)); const previous = existing.find((item) => messageMatchesSnapshot(item, message));
return previous ? sealExistingTerminalMessageTiming(previous, message) : message; return previous ? sealExistingMessageTiming(previous, message) : message;
}); });
} }
function sealExistingMessageTiming(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
if (isTerminalMessageStatus(existing.status)) return sealExistingTerminalMessageTiming(existing, incoming);
return sealExistingRunningMessageTiming(existing, incoming);
}
function sealExistingRunningMessageTiming(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
if (!isRunningAgentMessageForTiming(existing) || !isRunningAgentMessageForTiming(incoming)) return incoming;
const existingTiming = messageTimingProjection(existing);
if (!existingTiming?.startedAt) return incoming;
const incomingTiming = messageTimingProjection(incoming) ?? null;
const timing: NonNullable<ChatMessage["timing"]> = {
...(incomingTiming ?? {}),
startedAt: existingTiming.startedAt,
lastEventAt: incomingTiming?.lastEventAt ?? existingTiming.lastEventAt ?? existingTiming.startedAt,
finishedAt: null,
durationMs: null,
valuesRedacted: incomingTiming?.valuesRedacted !== false && existingTiming.valuesRedacted !== false,
};
return { ...incoming, timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: null, durationMs: null };
}
function sealExistingTerminalMessageTiming(existing: ChatMessage, incoming: ChatMessage): ChatMessage { function sealExistingTerminalMessageTiming(existing: ChatMessage, incoming: ChatMessage): ChatMessage {
if (!isTerminalMessageStatus(existing.status)) return incoming; if (!isTerminalMessageStatus(existing.status)) return incoming;
const timing = terminalTimingProjection(existing); const timing = terminalTimingProjection(existing);
@@ -201,6 +222,10 @@ function sealExistingTerminalMessageTiming(existing: ChatMessage, incoming: Chat
} }
function terminalTimingProjection(message: ChatMessage): ChatMessage["timing"] | null { function terminalTimingProjection(message: ChatMessage): ChatMessage["timing"] | null {
return messageTimingProjection(message);
}
function messageTimingProjection(message: ChatMessage): ChatMessage["timing"] | null {
const timing = message.timing && typeof message.timing === "object" ? message.timing : null; const timing = message.timing && typeof message.timing === "object" ? message.timing : null;
const startedAt = timestampOrNull(timing?.startedAt ?? message.startedAt); const startedAt = timestampOrNull(timing?.startedAt ?? message.startedAt);
const lastEventAt = timestampOrNull(timing?.lastEventAt ?? message.lastEventAt); const lastEventAt = timestampOrNull(timing?.lastEventAt ?? message.lastEventAt);
@@ -210,8 +235,19 @@ function terminalTimingProjection(message: ChatMessage): ChatMessage["timing"] |
return { ...(timing ?? {}), startedAt, lastEventAt, finishedAt, durationMs, valuesRedacted: timing?.valuesRedacted !== false }; return { ...(timing ?? {}), startedAt, lastEventAt, finishedAt, durationMs, valuesRedacted: timing?.valuesRedacted !== false };
} }
function isRunningAgentMessageForTiming(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
const status = normalizedMessageStatus(message.status);
if (isTerminalMessageStatus(status)) return false;
return status === "" || status === "pending" || status === "running" || message.traceAutoLifecycle === "running";
}
function isTerminalMessageStatus(value: unknown): boolean { function isTerminalMessageStatus(value: unknown): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")); return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(normalizedMessageStatus(value));
}
function normalizedMessageStatus(value: unknown): string {
return String(value ?? "").trim().toLowerCase().replace(/_/gu, "-");
} }
function timestampOrNull(value: unknown): string | null { function timestampOrNull(value: unknown): string | null {