fix(web): 禁止投影模式 HTTP 终态补链
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user