From a1ad03eac8a475cfdaaa12fb413dbd042688f8b1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Jul 2026 20:24:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E7=A6=81=E6=AD=A2=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E6=A8=A1=E5=BC=8F=20HTTP=20=E7=BB=88=E6=80=81?= =?UTF-8?q?=E8=A1=A5=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workbench-kafka-refresh-policy.test.ts | 17 +++++++---- .../stores/workbench-kafka-refresh-policy.ts | 7 +++-- .../src/stores/workbench-server-state.test.ts | 28 +++++++++++++++++++ .../src/stores/workbench-server-state.ts | 4 +-- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts b/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts index 3ca1c751..290dc8b2 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.test.ts @@ -3,15 +3,22 @@ import { test } from "bun:test"; import { workbenchHistoryAuthorityPolicy } from "./workbench-kafka-refresh-policy"; -test("obsolete Kafka retention flags cannot replace projection replay or durable history", () => { - assert.deepEqual(workbenchHistoryAuthorityPolicy({ liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: true }), { +test("projection authority disables every automatic HTTP terminal and history read", () => { + const policy = workbenchHistoryAuthorityPolicy({ liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: true }); + const calls = { fetchSessionMessagesPage: 0, fetchTurn: 0, fetchTraceEvents: 0 }; + if (policy.sessionMessagesHydrate) calls.fetchSessionMessagesPage += 1; + if (policy.turnStatusHydrate) calls.fetchTurn += 1; + if (policy.traceEventsHydrate) calls.fetchTraceEvents += 1; + + assert.deepEqual(policy, { kafkaRetention: false, sessionMetadataRead: true, - sessionMessagesHydrate: true, - turnStatusHydrate: true, - traceEventsHydrate: true, + sessionMessagesHydrate: false, + turnStatusHydrate: false, + traceEventsHydrate: false, syncReplay: true }); + assert.deepEqual(calls, { fetchSessionMessagesPage: 0, fetchTurn: 0, fetchTraceEvents: 0 }); }); test("projection capability alone controls product replay", () => { diff --git a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts b/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts index b38a5269..622d84fb 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-kafka-refresh-policy.ts @@ -10,12 +10,13 @@ export interface WorkbenchHistoryAuthorityPolicy { } export function workbenchHistoryAuthorityPolicy(capabilities: WorkbenchRealtimeCapabilities): WorkbenchHistoryAuthorityPolicy { + const automaticHttpHistoryReads = !capabilities.projectionRealtime; return { kafkaRetention: false, sessionMetadataRead: true, - sessionMessagesHydrate: true, - turnStatusHydrate: true, - traceEventsHydrate: true, + sessionMessagesHydrate: automaticHttpHistoryReads, + turnStatusHydrate: automaticHttpHistoryReads, + traceEventsHydrate: automaticHttpHistoryReads, syncReplay: capabilities.projectionRealtime }; } diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts index f02ce844..190a4269 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts @@ -46,6 +46,34 @@ test("server state reducer keeps completed message without final response unseal assert.notEqual(selectSessionStatusAuthority(state)[sessionId]?.status, "completed"); }); +test("late terminal replay derives duration only from canonical started and finished timestamps", () => { + const sessionId = "ses_state_late_terminal_duration"; + const traceId = "trc_state_late_terminal_duration"; + const messageId = "msg_state_late_terminal_duration"; + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) }); + state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId, message: agentMessage({ id: messageId, status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", updatedAt: "2026-07-01T00:00:01.000Z" }) }); + state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId, message: agentMessage({ id: messageId, status: "completed", text: "final answer", finalResponse: { text: "final answer" }, traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:05.000Z", updatedAt: "2026-07-01T00:17:25.000Z" }) }); + + const [message] = selectActiveMessages(state, sessionId); + assert.equal(message?.finishedAt, "2026-07-01T00:00:05.000Z"); + assert.equal(message?.durationMs, 5000); +}); + +test("late terminal receipt time cannot synthesize duration without canonical finish time", () => { + const sessionId = "ses_state_late_terminal_unknown_duration"; + const traceId = "trc_state_late_terminal_unknown_duration"; + const messageId = "msg_state_late_terminal_unknown_duration"; + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) }); + state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId, message: agentMessage({ id: messageId, status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" }) }); + state = reduceWorkbenchServerState(state, { type: "message.upsert", sessionId, message: agentMessage({ id: messageId, status: "completed", text: "final answer", finalResponse: { text: "final answer" }, traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", lastEventAt: "2026-07-01T00:17:25.000Z", updatedAt: "2026-07-01T00:17:25.000Z" }) }); + + const [message] = selectActiveMessages(state, sessionId); + assert.equal(message?.finishedAt ?? null, null); + assert.equal(message?.durationMs ?? null, null); +}); + test("server state reducer keeps session detail reference stable when detail has no semantic change", () => { const sessionId = "ses_state_stable_detail"; const traceId = "trc_state_stable_detail"; 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 e4d641ec..9360cde2 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -327,7 +327,7 @@ function sealTerminalTransitionMessageTiming(existing: ChatMessage, incoming: Ch if (positiveDuration(incomingTiming?.durationMs) !== null) return incoming; const existingTiming = messageTimingProjection(existing); const startedAt = existingTiming?.startedAt ?? incomingTiming?.startedAt ?? timestampOrNull(existing.createdAt) ?? null; - const finishedAt = terminalFinishedAt(startedAt, incomingTiming?.finishedAt, timestampOrNull(incoming.updatedAt), incomingTiming?.lastEventAt) ?? incomingTiming?.finishedAt ?? null; + const finishedAt = terminalFinishedAt(startedAt, incomingTiming?.finishedAt) ?? incomingTiming?.finishedAt ?? null; const durationMs = positiveDuration(durationBetween(startedAt, finishedAt)) ?? positiveDuration(incomingTiming?.durationMs) ?? (incomingTiming?.durationMs === 0 ? 0 : null); if (durationMs === null) return incoming; const timing: NonNullable = { @@ -346,7 +346,7 @@ function sealedTerminalTimingProjection(existing: ChatMessage, incoming: ChatMes const incomingTiming = terminalTimingProjection(incoming); const existingDuration = positiveDuration(existingTiming?.durationMs); if (existingTiming && existingDuration !== null) return existingTiming; - const incomingDuration = positiveDuration(incomingTiming?.durationMs) ?? positiveDuration(durationBetween(incomingTiming?.startedAt, terminalFinishedAt(incomingTiming?.startedAt, incomingTiming?.finishedAt, incomingTiming?.lastEventAt))); + const incomingDuration = positiveDuration(incomingTiming?.durationMs) ?? positiveDuration(durationBetween(incomingTiming?.startedAt, terminalFinishedAt(incomingTiming?.startedAt, incomingTiming?.finishedAt))); if (incomingTiming && incomingDuration !== null) return { ...(existingTiming ?? {}), ...incomingTiming, durationMs: incomingDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }; return existingTiming ?? incomingTiming; }