refactor(sync): replace message reloads with incremental tail sync

This commit is contained in:
weishu
2026-07-28 12:20:53 +08:00
parent 2235b924a7
commit faf70c64dd
28 changed files with 2927 additions and 2077 deletions
+39 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ListCodexSessionsRpcResponseSchema } from './apiTypes'
import { ListCodexSessionsRpcResponseSchema, MessagesQuerySchema } from './apiTypes'
describe('ListCodexSessionsRpcResponseSchema', () => {
it('preserves Codex session messages when parsing runner RPC responses', () => {
@@ -29,3 +29,41 @@ describe('ListCodexSessionsRpcResponseSchema', () => {
}
})
})
describe('MessagesQuerySchema', () => {
it('parses a forward cursor with a bounded snapshot and epoch', () => {
expect(MessagesQuerySchema.parse({
afterAt: '1000',
afterSeq: '10',
untilAt: '2000',
untilSeq: '20',
epoch: '3',
limit: '200'
})).toEqual({
afterAt: 1000,
afterSeq: 10,
untilAt: 2000,
untilSeq: 20,
epoch: 3,
limit: 200
})
})
it('rejects mixed before and after directions', () => {
expect(MessagesQuerySchema.safeParse({
beforeAt: 1000,
beforeSeq: 10,
afterAt: 2000,
afterSeq: 20
}).success).toBe(false)
})
it('rejects an unpaired or unscoped until cursor', () => {
expect(MessagesQuerySchema.safeParse({ untilAt: 2000, untilSeq: 20 }).success).toBe(false)
expect(MessagesQuerySchema.safeParse({ afterAt: 1000, afterSeq: 10, untilAt: 2000 }).success).toBe(false)
})
it('rejects epoch without a forward cursor', () => {
expect(MessagesQuerySchema.safeParse({ epoch: 1 }).success).toBe(false)
})
})
+36 -3
View File
@@ -78,9 +78,16 @@ export type SessionResponse = { session: Session }
export type MessagesResponse = {
messages: DecryptedMessage[]
page: {
direction: 'latest' | 'before' | 'after'
limit: number
epoch: number
reset: boolean
nextBeforeSeq: number | null
nextBeforeAt: number | null
nextAfterSeq: number | null
nextAfterAt: number | null
snapshotHeadSeq: number | null
snapshotHeadAt: number | null
hasMore: boolean
}
}
@@ -330,10 +337,36 @@ export const MessagesQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
beforeSeq: z.coerce.number().int().min(1).optional(),
beforeAt: z.coerce.number().int().min(0).optional(),
}).refine((data) => (data.beforeAt === undefined) === (data.beforeSeq === undefined), {
message: 'beforeAt and beforeSeq must be provided together',
path: ['beforeAt'],
afterSeq: z.coerce.number().int().min(1).optional(),
afterAt: z.coerce.number().int().min(0).optional(),
untilSeq: z.coerce.number().int().min(1).optional(),
untilAt: z.coerce.number().int().min(0).optional(),
epoch: z.coerce.number().int().min(0).optional(),
})
.refine((data) => (data.beforeAt === undefined) === (data.beforeSeq === undefined), {
message: 'beforeAt and beforeSeq must be provided together',
path: ['beforeAt'],
})
.refine((data) => (data.afterAt === undefined) === (data.afterSeq === undefined), {
message: 'afterAt and afterSeq must be provided together',
path: ['afterAt'],
})
.refine((data) => (data.untilAt === undefined) === (data.untilSeq === undefined), {
message: 'untilAt and untilSeq must be provided together',
path: ['untilAt'],
})
.refine((data) => data.beforeAt === undefined || data.afterAt === undefined, {
message: 'before and after cursors are mutually exclusive',
path: ['afterAt'],
})
.refine((data) => data.untilAt === undefined || data.afterAt !== undefined, {
message: 'until cursor requires an after cursor',
path: ['untilAt'],
})
.refine((data) => data.epoch === undefined || data.afterAt !== undefined, {
message: 'epoch requires an after cursor',
path: ['epoch'],
})
export type MessagesQuery = z.infer<typeof MessagesQuerySchema>