fix(web): 禁止投影模式 HTTP 终态补链

This commit is contained in:
root
2026-07-13 20:24:17 +02:00
parent 1c9c990373
commit a1ad03eac8
4 changed files with 46 additions and 10 deletions
@@ -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", () => {
@@ -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
};
}
@@ -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";
@@ -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<ChatMessage["timing"]> = {
@@ -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;
}