Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/utils/__tests__/shanghaiTimestamp.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
26 changes: 17 additions & 9 deletions src/utils/sessionStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -2613,7 +2621,7 @@ function appendEntryToFile(
entry: Record<string, unknown>,
): void {
const fs = getFsImplementation()
const line = jsonStringify(entry) + '\n'
const line = jsonStringify(withShanghaiTimestamp(entry)) + '\n'
try {
fs.appendFileSync(fullPath, line, { mode: 0o600 })
} catch {
Expand Down Expand Up @@ -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)
}
Expand Down
61 changes: 61 additions & 0 deletions src/utils/shanghaiTimestamp.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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
}