Skip to content

Commit 4f78a1e

Browse files
秦奇claude
andcommitted
feat(sdk/daemon-ui): server timestamps + event-id-based ordering (PR-B)
Closes the "时间定义不标准" gap surfaced in the PR QwenLM#4328 review: - Client-side `Date.now()` drifts across clients - No daemon-authoritative timestamp propagated to UI - Out-of-order replay events get fresher `state.now` than originals, breaking `createdAt` ordering - `DaemonUiEventBase.serverTimestamp?: number` — daemon-authoritative wall-clock timestamp extracted from envelope. - `DaemonTranscriptBlockBase.serverTimestamp?: number` + `clientReceivedAt: number`. - `createdAt` preserved as `@deprecated` alias for `clientReceivedAt` (backward compat for code written before this PR). `extractServerTimestamp` looks at three candidate envelope locations: 1. `event.serverTimestamp` (preferred when daemon adds it) 2. `event._meta.serverTimestamp` (Anthropic-style metadata convention) 3. `event.data._meta.serverTimestamp` (sessionUpdate nested location) The SDK is ready to consume serverTimestamp WHEN daemon emits it, without requiring a coordinated SDK release. Undefined when daemon doesn't emit (current state) — graceful degradation to client-clock ordering. `selectTranscriptBlocksOrderedByEventId(state)` — returns blocks sorted by: 1. `eventId` (daemon-monotonic SSE cursor) — primary key 2. `serverTimestamp` (daemon wall clock) — fallback for synthetic frames 3. `clientReceivedAt` (local clock) — last resort Use this when displaying long sessions where event id 5 may arrive AFTER event id 7 (typical in SSE replay-after-reconnect). `formatBlockTimestamp(block, opts)` — formats the most authoritative timestamp on a block using `Intl.DateTimeFormat`. Prefers `serverTimestamp` over `clientReceivedAt` for cross-client consistency. Accepts locale / timeZone / dateStyle / timeStyle. Daemon needs to stamp `_meta.serverTimestamp` on every SSE envelope. This SDK PR is ready to consume it the moment the daemon ships the field; no coordination needed. - serverTimestamp extraction from all three envelope locations - Defaults undefined when envelope has none - `selectTranscriptBlocksOrderedByEventId` sorts mixed-arrival events by eventId (replay scenario) - `formatBlockTimestamp` prefers serverTimestamp; returns localized string PR-B of the unified follow-up to PR QwenLM#4328 (PR-A + PR-B + PR-C + PR-D + PR-E in one branch). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2f66268 commit 4f78a1e

5 files changed

Lines changed: 403 additions & 22 deletions

File tree

packages/sdk-typescript/src/daemon/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ export { createDaemonToolPreview } from './toolPreview.js';
99
export {
1010
appendLocalUserTranscriptMessage,
1111
createDaemonTranscriptState,
12+
formatBlockTimestamp,
1213
rebuildDaemonTranscriptBlockIndex,
1314
reduceDaemonTranscriptEvents,
1415
selectPendingPermissionBlocks,
1516
selectTranscriptBlocks,
17+
selectTranscriptBlocksOrderedByEventId,
1618
} from './transcript.js';
1719
export { createDaemonTranscriptStore } from './store.js';
1820
export {

packages/sdk-typescript/src/daemon/ui/normalizer.ts

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ import {
2929
stringifyRedactedJson,
3030
} from './utils.js';
3131

32+
/**
33+
* Common base fields stamped on every normalized UI event. Centralized as a
34+
* type alias so adding new envelope fields (e.g., `serverTimestamp` in PR-B,
35+
* `traceId` in future) doesn't require touching every normalizer helper.
36+
*/
37+
type NormalizedEventBase = Pick<
38+
DaemonUiEvent,
39+
'eventId' | 'serverTimestamp' | 'originatorClientId' | 'rawEvent'
40+
>;
41+
3242
const DAEMON_ERROR_KIND_SET = new Set<string>(DAEMON_ERROR_KINDS);
3343
const DEVICE_FLOW_ERROR_KIND_SET = new Set<string>([
3444
'expired',
@@ -227,9 +237,11 @@ export function normalizeDaemonEvent(
227237
function createBase(
228238
event: DaemonEvent,
229239
opts: NormalizeDaemonEventOptions,
230-
): Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'> {
240+
): NormalizedEventBase {
241+
const serverTimestamp = extractServerTimestamp(event);
231242
return {
232243
...(event.id !== undefined ? { eventId: event.id } : {}),
244+
...(serverTimestamp !== undefined ? { serverTimestamp } : {}),
233245
...(event.originatorClientId
234246
? { originatorClientId: event.originatorClientId }
235247
: {}),
@@ -239,9 +251,39 @@ function createBase(
239251
};
240252
}
241253

254+
/**
255+
* Extract daemon-authoritative timestamp from envelope. Looks at three
256+
* candidate locations in order:
257+
*
258+
* 1. `event.serverTimestamp` — top-level, preferred when daemon adds it
259+
* 2. `event._meta.serverTimestamp` — Anthropic-style metadata convention
260+
* 3. `event.data._meta.serverTimestamp` — sessionUpdate nested location
261+
*
262+
* Returns undefined when none of them are present or all are non-finite.
263+
* Forward-compat: SDK reads whichever location the daemon eventually emits
264+
* without requiring a coordinated SDK release.
265+
*/
266+
function extractServerTimestamp(event: DaemonEvent): number | undefined {
267+
const direct = (event as { serverTimestamp?: unknown }).serverTimestamp;
268+
if (typeof direct === 'number' && Number.isFinite(direct)) return direct;
269+
const envelopeMeta = (event as { _meta?: unknown })._meta;
270+
if (isRecord(envelopeMeta)) {
271+
const ts = envelopeMeta['serverTimestamp'];
272+
if (typeof ts === 'number' && Number.isFinite(ts)) return ts;
273+
}
274+
if (isRecord(event.data)) {
275+
const dataMeta = (event.data as Record<string, unknown>)['_meta'];
276+
if (isRecord(dataMeta)) {
277+
const ts = dataMeta['serverTimestamp'];
278+
if (typeof ts === 'number' && Number.isFinite(ts)) return ts;
279+
}
280+
}
281+
return undefined;
282+
}
283+
242284
function normalizeSessionUpdate(
243285
event: DaemonEvent,
244-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
286+
base: NormalizedEventBase,
245287
opts: NormalizeDaemonEventOptions,
246288
): DaemonUiEvent[] {
247289
const update = getSessionUpdatePayload(event.data);
@@ -325,7 +367,7 @@ function normalizeSessionUpdate(
325367

326368
function normalizeToolUpdate(
327369
update: Record<string, unknown>,
328-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
370+
base: NormalizedEventBase,
329371
): DaemonUiEvent {
330372
const metadata = isRecord(update['_meta']) ? update['_meta'] : undefined;
331373
const toolName =
@@ -488,7 +530,7 @@ function capDetails(details: string): string {
488530

489531
function normalizePermissionRequest(
490532
event: DaemonEvent,
491-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
533+
base: NormalizedEventBase,
492534
): DaemonUiEvent[] {
493535
if (!isRecord(event.data)) {
494536
return [
@@ -530,7 +572,7 @@ function normalizePermissionRequest(
530572

531573
function normalizePermissionResolved(
532574
event: DaemonEvent,
533-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
575+
base: NormalizedEventBase,
534576
): DaemonUiEvent[] {
535577
const requestId = getString(event.data, 'requestId');
536578
if (!requestId) {
@@ -625,7 +667,7 @@ function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined {
625667

626668
function fallbackDebug(
627669
event: DaemonEvent,
628-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
670+
base: NormalizedEventBase,
629671
reason: string,
630672
): DaemonUiEvent[] {
631673
return [
@@ -639,7 +681,7 @@ function fallbackDebug(
639681

640682
function normalizeSessionMetadataUpdated(
641683
event: DaemonEvent,
642-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
684+
base: NormalizedEventBase,
643685
): DaemonUiEvent[] {
644686
const sessionId = getString(event.data, 'sessionId');
645687
if (!sessionId) return fallbackDebug(event, base, 'missing sessionId');
@@ -656,7 +698,7 @@ function normalizeSessionMetadataUpdated(
656698

657699
function normalizeApprovalModeChanged(
658700
event: DaemonEvent,
659-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
701+
base: NormalizedEventBase,
660702
): DaemonUiEvent[] {
661703
const sessionId = getString(event.data, 'sessionId');
662704
const previous = getString(event.data, 'previous');
@@ -682,7 +724,7 @@ function normalizeApprovalModeChanged(
682724

683725
function normalizeMemoryChanged(
684726
event: DaemonEvent,
685-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
727+
base: NormalizedEventBase,
686728
): DaemonUiEvent[] {
687729
const scope = getString(event.data, 'scope');
688730
const filePath = getString(event.data, 'filePath');
@@ -713,7 +755,7 @@ function normalizeMemoryChanged(
713755

714756
function normalizeAgentChanged(
715757
event: DaemonEvent,
716-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
758+
base: NormalizedEventBase,
717759
): DaemonUiEvent[] {
718760
const change = getString(event.data, 'change');
719761
const name = getString(event.data, 'name');
@@ -738,7 +780,7 @@ function normalizeAgentChanged(
738780

739781
function normalizeToolToggled(
740782
event: DaemonEvent,
741-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
783+
base: NormalizedEventBase,
742784
): DaemonUiEvent[] {
743785
const toolName = getString(event.data, 'toolName');
744786
const enabled =
@@ -760,7 +802,7 @@ function normalizeToolToggled(
760802

761803
function normalizeWorkspaceInitialized(
762804
event: DaemonEvent,
763-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
805+
base: NormalizedEventBase,
764806
): DaemonUiEvent[] {
765807
const path = getString(event.data, 'path');
766808
const action = getString(event.data, 'action');
@@ -779,7 +821,7 @@ function normalizeWorkspaceInitialized(
779821

780822
function normalizeMcpBudgetWarning(
781823
event: DaemonEvent,
782-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
824+
base: NormalizedEventBase,
783825
): DaemonUiEvent[] {
784826
if (!isRecord(event.data)) {
785827
return fallbackDebug(event, base, 'non-object payload');
@@ -813,7 +855,7 @@ function normalizeMcpBudgetWarning(
813855

814856
function normalizeMcpChildRefused(
815857
event: DaemonEvent,
816-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
858+
base: NormalizedEventBase,
817859
): DaemonUiEvent[] {
818860
if (!isRecord(event.data)) {
819861
return fallbackDebug(event, base, 'non-object payload');
@@ -871,7 +913,7 @@ function normalizeMcpChildRefused(
871913

872914
function normalizeMcpServerRestarted(
873915
event: DaemonEvent,
874-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
916+
base: NormalizedEventBase,
875917
): DaemonUiEvent[] {
876918
const serverName = getString(event.data, 'serverName');
877919
const durationMs = numberField(event.data, 'durationMs');
@@ -890,7 +932,7 @@ function normalizeMcpServerRestarted(
890932

891933
function normalizeMcpServerRestartRefused(
892934
event: DaemonEvent,
893-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
935+
base: NormalizedEventBase,
894936
): DaemonUiEvent[] {
895937
const serverName = getString(event.data, 'serverName');
896938
const reason = getString(event.data, 'reason');
@@ -913,7 +955,7 @@ function normalizeMcpServerRestartRefused(
913955

914956
function normalizeAuthDeviceFlowStarted(
915957
event: DaemonEvent,
916-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
958+
base: NormalizedEventBase,
917959
): DaemonUiEvent[] {
918960
const deviceFlowId = getString(event.data, 'deviceFlowId');
919961
const providerId = getString(event.data, 'providerId');
@@ -943,7 +985,7 @@ function normalizeAuthDeviceFlowStarted(
943985

944986
function normalizeAuthDeviceFlowThrottled(
945987
event: DaemonEvent,
946-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
988+
base: NormalizedEventBase,
947989
): DaemonUiEvent[] {
948990
const deviceFlowId = getString(event.data, 'deviceFlowId');
949991
const intervalMs = numberField(event.data, 'intervalMs');
@@ -966,7 +1008,7 @@ function normalizeAuthDeviceFlowThrottled(
9661008

9671009
function normalizeAuthDeviceFlowAuthorized(
9681010
event: DaemonEvent,
969-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
1011+
base: NormalizedEventBase,
9701012
): DaemonUiEvent[] {
9711013
const deviceFlowId = getString(event.data, 'deviceFlowId');
9721014
const providerId = getString(event.data, 'providerId');
@@ -997,7 +1039,7 @@ function normalizeAuthDeviceFlowAuthorized(
9971039

9981040
function normalizeAuthDeviceFlowFailed(
9991041
event: DaemonEvent,
1000-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
1042+
base: NormalizedEventBase,
10011043
): DaemonUiEvent[] {
10021044
const deviceFlowId = getString(event.data, 'deviceFlowId');
10031045
const errorKind = getString(event.data, 'errorKind');
@@ -1026,7 +1068,7 @@ function normalizeAuthDeviceFlowFailed(
10261068

10271069
function normalizeAuthDeviceFlowCancelled(
10281070
event: DaemonEvent,
1029-
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
1071+
base: NormalizedEventBase,
10301072
): DaemonUiEvent[] {
10311073
const deviceFlowId = getString(event.data, 'deviceFlowId');
10321074
if (!deviceFlowId) {

0 commit comments

Comments
 (0)