diff --git a/src/utils/__tests__/shanghaiTimestamp.test.ts b/src/utils/__tests__/shanghaiTimestamp.test.ts new file mode 100644 index 0000000000..9512a51947 --- /dev/null +++ b/src/utils/__tests__/shanghaiTimestamp.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { + compareTimestamps, + toShanghaiISOString, + toShanghaiTimestamp, + withShanghaiTimestamp, +} from '../shanghaiTimestamp' + +describe('toShanghaiISOString', () => { + test('renders UTC instants with a +08:00 offset', () => { + expect(toShanghaiISOString(new Date('2026-09-19T09:23:26.000Z'))).toBe( + '2026-09-19T17:23:26.000+08:00', + ) + }) + + test('represents the same instant as the source date', () => { + const date = new Date('2026-01-01T00:00:00.000Z') + expect(Date.parse(toShanghaiISOString(date))).toBe(date.getTime()) + }) +}) + +describe('toShanghaiTimestamp', () => { + test('converts a Z timestamp to +08:00', () => { + expect(toShanghaiTimestamp('2026-09-19T09:23:26.000Z')).toBe( + '2026-09-19T17:23:26.000+08:00', + ) + }) + + test('leaves timestamps that already carry an offset untouched', () => { + expect(toShanghaiTimestamp('2026-09-19T17:23:26.000+08:00')).toBe( + '2026-09-19T17:23:26.000+08:00', + ) + }) + + test('leaves unparseable values untouched', () => { + expect(toShanghaiTimestamp('not-a-dateZ')).toBe('not-a-dateZ') + }) +}) + +describe('withShanghaiTimestamp', () => { + test('rewrites the top-level timestamp without mutating the input', () => { + const entry = { uuid: 'u1', timestamp: '2026-09-19T09:23:26.000Z' } + expect(withShanghaiTimestamp(entry)).toEqual({ + uuid: 'u1', + timestamp: '2026-09-19T17:23:26.000+08:00', + }) + expect(entry.timestamp).toBe('2026-09-19T09:23:26.000Z') + }) + + test('leaves nested timestamps byte-for-byte intact', () => { + const nested = '2026-09-19T09:23:26.000Z' + const converted = withShanghaiTimestamp({ + timestamp: nested, + toolUseResult: { timestamp: nested }, + }) as { timestamp: string; toolUseResult: { timestamp: string } } + expect(converted.timestamp).toBe('2026-09-19T17:23:26.000+08:00') + expect(converted.toolUseResult.timestamp).toBe(nested) + }) + + test('passes through non-objects and entries without a string timestamp', () => { + expect(withShanghaiTimestamp(null)).toBeNull() + expect(withShanghaiTimestamp('x')).toBe('x') + expect(withShanghaiTimestamp([1])).toEqual([1]) + const noTimestamp = { uuid: 'u1' } + expect(withShanghaiTimestamp(noTimestamp)).toBe(noTimestamp) + }) +}) + +describe('compareTimestamps', () => { + test('orders mixed Z and +08:00 entries by instant, not lexically', () => { + const earlier = '2026-09-19T09:23:26.000Z' // 17:23 +08:00 + const later = '2026-09-19T18:23:26.000+08:00' // 10:23Z + expect(compareTimestamps(earlier, later)).toBeLessThan(0) + expect(compareTimestamps(later, earlier)).toBeGreaterThan(0) + expect(compareTimestamps(earlier, earlier)).toBe(0) + }) + + test('falls back to lexicographic order for unparseable values', () => { + expect(compareTimestamps('abc', 'abd')).toBeLessThan(0) + expect(compareTimestamps('abd', 'abc')).toBeGreaterThan(0) + expect(compareTimestamps('abc', 'abc')).toBe(0) + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index d6da26184a..6b73d64b83 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -91,6 +91,10 @@ import { SKIP_PRECOMPACT_THRESHOLD, } from './sessionStoragePortable.js' import { getSettings_DEPRECATED } from './settings/settings.js' +import { + compareTimestamps, + withShanghaiTimestamp, +} from './shanghaiTimestamp.js' import { jsonParse, jsonStringify } from './slowOperations.js' import type { ContentReplacementRecord } from './toolResultStorage.js' import { validateUuid } from './uuid.js' @@ -667,7 +671,7 @@ class Project { const resolvers: Array<() => void> = [] for (const { entry, resolve } of batch) { - const line = jsonStringify(entry) + '\n' + const line = jsonStringify(withShanghaiTimestamp(entry)) + '\n' if (content.length + line.length >= this.MAX_CHUNK_BYTES) { // Flush chunk and resolve its entries before starting a new one @@ -1641,7 +1645,9 @@ export async function hydrateRemoteSession( // Replace local logs with remote logs. writeFile truncates, so no // unlink is needed; an empty remoteLogs array produces an empty file. - const content = remoteLogs.map(e => jsonStringify(e) + '\n').join('') + const content = remoteLogs + .map(e => jsonStringify(withShanghaiTimestamp(e)) + '\n') + .join('') await writeFile(sessionFile, content, { encoding: 'utf8', mode: 0o600 }) logForDebugging(`Hydrated ${remoteLogs.length} entries from remote`) @@ -1693,7 +1699,9 @@ export async function hydrateFromCCRv2InternalEvents( // Write foreground transcript const sessionFile = getTranscriptPathForSession(sessionId) - const fgContent = events.map(e => jsonStringify(e.payload) + '\n').join('') + const fgContent = events + .map(e => jsonStringify(withShanghaiTimestamp(e.payload)) + '\n') + .join('') await writeFile(sessionFile, fgContent, { encoding: 'utf8', mode: 0o600 }) logForDebugging( @@ -1725,7 +1733,7 @@ export async function hydrateFromCCRv2InternalEvents( const agentFile = getAgentTranscriptPath(asAgentId(agentId)) await mkdir(dirname(agentFile), { recursive: true, mode: 0o700 }) const agentContent = entries - .map(p => jsonStringify(p) + '\n') + .map(p => jsonStringify(withShanghaiTimestamp(p)) + '\n') .join('') await writeFile(agentFile, agentContent, { encoding: 'utf8', @@ -2220,8 +2228,8 @@ function recoverOrphanedParallelToolResults( // Timestamp sort keeps content-block / completion order; stable-sort // preserves JSONL write order on ties. - orphanedSiblings.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) - orphanedTRs.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + orphanedSiblings.sort((a, b) => compareTimestamps(a.timestamp, b.timestamp)) + orphanedTRs.sort((a, b) => compareTimestamps(a.timestamp, b.timestamp)) const anchor = anchorByMsgId.get(msgId)! const recovered = [...orphanedSiblings, ...orphanedTRs] @@ -2613,7 +2621,7 @@ function appendEntryToFile( entry: Record, ): void { const fs = getFsImplementation() - const line = jsonStringify(entry) + '\n' + const line = jsonStringify(withShanghaiTimestamp(entry)) + '\n' try { fs.appendFileSync(fullPath, line, { mode: 0o600 }) } catch { @@ -4788,9 +4796,9 @@ export async function loadAllLogsFromSessionFile( // Append trailing messages that are children of the leaf const trailingMessages = childrenByParent.get(leafMessage.uuid) if (trailingMessages) { - // ISO-8601 UTC timestamps are lexically sortable + // Offset-aware: transcripts may mix legacy `...Z` with `+08:00` entries trailingMessages.sort((a, b) => - a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0, + compareTimestamps(a.timestamp, b.timestamp), ) chain.push(...trailingMessages) } diff --git a/src/utils/shanghaiTimestamp.ts b/src/utils/shanghaiTimestamp.ts new file mode 100644 index 0000000000..709313f282 --- /dev/null +++ b/src/utils/shanghaiTimestamp.ts @@ -0,0 +1,61 @@ +const SHANGHAI_UTC_OFFSET_MINUTES = 480 +const UTC_DESIGNATOR = 'Z' +const SHANGHAI_OFFSET_SUFFIX = '+08:00' + +/** + * Asia/Shanghai has held a fixed UTC+8 offset with no DST since 1991, so the + * constant 480-minute shift below is exact rather than an approximation. + */ +export function toShanghaiISOString(date: Date = new Date()): string { + const shifted = new Date( + date.getTime() + SHANGHAI_UTC_OFFSET_MINUTES * 60_000, + ) + return shifted.toISOString().replace(UTC_DESIGNATOR, SHANGHAI_OFFSET_SUFFIX) +} + +export function toShanghaiTimestamp(value: string): string { + if (!value.endsWith(UTC_DESIGNATOR)) { + return value + } + const ms = Date.parse(value) + if (Number.isNaN(ms)) { + return value + } + return toShanghaiISOString(new Date(ms)) +} + +/** + * Rewrites a transcript entry's top-level `timestamp` to Shanghai time. + * Nested timestamps are deliberately left alone: they occur inside tool + * results and MCP metadata, which are opaque payloads that must round-trip + * byte-for-byte. + */ +export function withShanghaiTimestamp(entry: unknown): unknown { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + return entry + } + const record = entry as Record + const timestamp = record.timestamp + if (typeof timestamp !== 'string') { + return entry + } + const converted = toShanghaiTimestamp(timestamp) + if (converted === timestamp) { + return entry + } + return { ...record, timestamp: converted } +} + +/** + * Chronological comparison that ignores the offset notation, so a transcript + * mixing legacy `...Z` entries with `+08:00` entries still orders correctly. + * Lexicographic comparison would not: `13:14Z` sorts after `21:14+08:00`. + */ +export function compareTimestamps(a: string, b: string): number { + const msA = Date.parse(a) + const msB = Date.parse(b) + if (Number.isNaN(msA) || Number.isNaN(msB)) { + return a < b ? -1 : a > b ? 1 : 0 + } + return msA - msB +}