diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index 9001a575..79d80870 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -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"); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index f05157bd..5e77a4ad 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -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 = { + ...(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 {