fix: keep running workbench timing stable (#2193)
This commit is contained in:
@@ -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?.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,
|
||||
updatedAt: incoming.updatedAt ?? existing.updatedAt
|
||||
};
|
||||
return sealExistingTerminalMessageTiming(existing, merged);
|
||||
return sealExistingMessageTiming(existing, merged);
|
||||
}
|
||||
|
||||
function mergeMessageList(existing: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
|
||||
return incoming.map((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 {
|
||||
if (!isTerminalMessageStatus(existing.status)) return incoming;
|
||||
const timing = terminalTimingProjection(existing);
|
||||
@@ -201,6 +222,10 @@ function sealExistingTerminalMessageTiming(existing: ChatMessage, incoming: Chat
|
||||
}
|
||||
|
||||
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 startedAt = timestampOrNull(timing?.startedAt ?? message.startedAt);
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user