fix(time-context): validate durable zone authority

This commit is contained in:
pku-xht
2026-08-07 23:47:51 +08:00
committed by Tianyi Cui
parent 331b29d779
commit edb23439f6
10 changed files with 261 additions and 76 deletions

View File

@@ -14,6 +14,7 @@ import {
deriveClientTimeZoneContext,
renderTimeZoneContext,
} from './request-zone.ts'
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
export type { ClientTimeZoneContext } from './request-zone.ts'
export { deriveClientTimeZoneContext } from './request-zone.ts'
@@ -38,17 +39,6 @@ export const Config: z<Config> = z.object({
refreshIntervalMs: z.number(),
})
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
const parts = Object.fromEntries(
formatter.formatToParts(now).map(part => [part.type, part.value]),
) as Record<TimestampPart, string>
const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
}
/** Format a non-negative elapsed millisecond count as compact whole-second units. */
function formatDuration(elapsedMs: number): string {
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
@@ -160,20 +150,9 @@ export function apply(ctx: Context, config: Config): () => void {
const timeZone = config.timeZone
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
const createFormatter = (selectedTimeZone?: string): Intl.DateTimeFormat => new Intl.DateTimeFormat('en-US', {
...(selectedTimeZone === undefined ? {} : { timeZone: selectedTimeZone }),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'longOffset',
})
let fallbackFormatter: Intl.DateTimeFormat
try {
fallbackFormatter = createFormatter(timeZone)
fallbackFormatter = createTimestampFormatter(timeZone)
} catch (error: unknown) {
const message = timeZone === undefined
? 'time-context: failed to resolve the system time zone'
@@ -190,7 +169,7 @@ export function apply(ctx: Context, config: Config): () => void {
if (existing !== undefined) return existing
let created: Intl.DateTimeFormat
try {
created = createFormatter(selectedTimeZone)
created = createTimestampFormatter(selectedTimeZone)
} catch (error: unknown) {
throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error })
}

View File

@@ -4,6 +4,7 @@ import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
const SOURCE_NAME = 'time-context'
@@ -140,6 +141,22 @@ function validateReading(
|| event.time < renderedTime) {
fail('time-context rendered timestamp must parse and not postdate its durable event')
}
const sessionTimeZone = session.header.timeZone
if (sessionTimeZone !== undefined) {
let expectedTimestamp: string
try {
expectedTimestamp = formatTimestamp(
renderedTime,
createTimestampFormatter(sessionTimeZone),
sessionTimeZone,
)
} catch (error: unknown) {
fail(`time-context Session time zone cannot format its durable timestamp: ${String(error)}`)
}
if (rendered !== expectedTimestamp) {
fail('time-context rendered timestamp does not match the Session time zone')
}
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */

View File

@@ -12,6 +12,8 @@ export type ClientTimeZoneContext =
function clientTimeZone(message: UserMessage): string | undefined {
const source = message.source
return source.kind === 'user'
&& 'rpcId' in source
&& typeof source.rpcId === 'string'
&& 'clientTimeZone' in source
&& typeof source.clientTimeZone === 'string'
? source.clientTimeZone

View File

@@ -0,0 +1,37 @@
/** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
/**
* Create the exact formatter used by durable time-context readings.
* @param timeZone - Explicit display zone, or `undefined` for the process fallback.
* @returns A formatter with stable numeric local fields and long numeric offset.
*/
export function createTimestampFormatter(timeZone?: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat('en-US', {
...(timeZone === undefined ? {} : { timeZone }),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'longOffset',
})
}
/**
* Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
* @param now - Epoch milliseconds to display.
* @param formatter - Formatter created for `timeZone`.
* @param timeZone - Canonical zone label carried in brackets.
* @returns The durable timestamp text.
*/
export function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
const parts = Object.fromEntries(
formatter.formatToParts(now).map(part => [part.type, part.value]),
) as Record<TimestampPart, string>
const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
}

View File

@@ -126,7 +126,7 @@ describe('time-context invariants', () => {
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'travel request' }],
source: { kind: 'user', clientTimeZone: 'America/New_York' } as never,
source: { kind: 'user', rpcId: 'travel-request', clientTimeZone: 'America/New_York' } as never,
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
@@ -135,7 +135,7 @@ describe('time-context invariants', () => {
'1',
'1',
'model-visible message',
'2026-07-14T00:00:00+00:00[UTC]',
'2026-07-14T08:00:00+08:00[Asia/Shanghai]',
'Asia/Shanghai',
'America/New_York',
)))
@@ -145,11 +145,48 @@ describe('time-context invariants', () => {
'1',
'1',
'model-visible message',
'2026-07-14T00:00:00+00:00[UTC]',
'2026-07-14T08:00:00+08:00[Asia/Shanghai]',
'Asia/Shanghai',
'Asia/Shanghai',
)))
}).toThrow(/does not match the Session and current request zones/)
expect(() => {
ctx.emit('session/event', session, event(reading(
'1',
'1',
'model-visible message',
'2026-07-14T00:00:00+00:00[UTC]',
'Asia/Shanghai',
'America/New_York',
)))
}).toThrow(/rendered timestamp does not match the Session time zone/)
})
it('rejects a durable reading whose Session zone cannot format the timestamp', async () => {
const ctx = await setup()
const id = SessionId('time-invariant-invalid-zone')
const session = Session.create(id, [], {
version: 0,
id,
createdAt: SECOND,
timeZone: 'Invalid/Zone',
})
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'invalid zone request' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
expect(() => {
ctx.emit('session/event', session, event(reading(
'1',
'1',
'model-visible message',
'2026-07-14T00:00:00+00:00[UTC]',
'Invalid/Zone',
)))
}).toThrow(/Session time zone cannot format its durable timestamp/)
})
it('rejects a time-context source that duplicates request authority', async () => {

View File

@@ -11,7 +11,7 @@ function request(clientTimeZone?: unknown) {
content: [{ type: 'text', text: 'request' }],
source: clientTimeZone === undefined
? { kind: 'user' }
: { kind: 'user', clientTimeZone } as never,
: { kind: 'user', rpcId: 'request-zone', clientTimeZone } as never,
})
}
@@ -27,6 +27,10 @@ describe('request-zone derivation', () => {
source: { kind: 'plugin', plugin: 'fixture' },
})
expect(deriveClientTimeZoneContext([plugin, request(), request(1)])).toEqual({ kind: 'missing' })
expect(deriveClientTimeZoneContext([createUserMessage({
content: [],
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never,
})])).toEqual({ kind: 'missing' })
expect(deriveClientTimeZoneContext([
request('Asia/Shanghai'),
request('Asia/Shanghai'),

View File

@@ -104,7 +104,7 @@ async function fire(
function rpcMessage(text: string, clientTimeZone: string): UserMessage {
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user', clientTimeZone } as never,
source: { kind: 'user', rpcId: `rpc-${text}`, clientTimeZone } as never,
})
}