diff --git a/src/utils/relevancePruning.test.ts b/src/utils/relevancePruning.test.ts index 97a26c01b4..b0afca595d 100644 --- a/src/utils/relevancePruning.test.ts +++ b/src/utils/relevancePruning.test.ts @@ -141,4 +141,26 @@ describe('relevancePruning', () => { expect(stats.toolCallCount).toBeGreaterThanOrEqual(0) }) }) + + describe('chronological ordering', () => { + // Production Message objects carry the chronological key on the envelope + // `timestamp` (an ISO string), not on `message.created_at`. Build that real + // shape here so the final "restore chronological order" sort is exercised. + function envMessage(idx: number): any { + return { + type: 'user', + uuid: `u${idx}`, + timestamp: new Date(1_700_000_000_000 + idx * 1000).toISOString(), + message: { role: 'user', content: `message number ${idx} content here`, id: `m${idx}` }, + } + } + + it('returns retained messages in chronological order', () => { + const messages = Array.from({ length: 8 }, (_, i) => envMessage(i)) + // Large target keeps every group; with preserveRecent=3 the last three + // are sliced off first, so a broken final sort leaves them out front. + const result = pruneByRelevance(messages, { targetTokens: 1_000_000 }) + expect(result.map(m => m.message.id)).toEqual(messages.map(m => m.message.id)) + }) + }) }) \ No newline at end of file diff --git a/src/utils/relevancePruning.ts b/src/utils/relevancePruning.ts index 2a8f268574..777c3b9181 100644 --- a/src/utils/relevancePruning.ts +++ b/src/utils/relevancePruning.ts @@ -80,6 +80,19 @@ export function hasErrors(message: Message): boolean { return textContent.includes('error') || textContent.includes('fail') || textContent.includes('exception') } +/** + * Chronological key for a message, in epoch milliseconds. Reads the envelope + * `timestamp` (an ISO-8601 string present on every Message variant), NOT + * `message.message.created_at` — that nested API-body field is never populated + * on our Message objects, so the old code always saw `undefined` and its + * recency scoring, tie-break and final chronological sort were all no-ops. + * Returns 0 for a missing/unparseable timestamp (sorts as oldest). + */ +function messageTimeMs(message: Message | undefined): number { + const parsed = message?.timestamp ? Date.parse(message.timestamp) : NaN + return Number.isNaN(parsed) ? 0 : parsed +} + export function calculateRelevance( message: Message, options: PruningOptions, @@ -104,7 +117,7 @@ export function calculateRelevance( score += 0.3 } - const ageHours = (Date.now() - (message.message?.created_at ?? 0)) / (1000 * 60 * 60) + const ageHours = (Date.now() - messageTimeMs(message)) / (1000 * 60 * 60) if (ageHours < 1) { score += 0.15 } @@ -169,8 +182,8 @@ export function pruneByRelevance( scored.sort((a, b) => { if (b.score !== a.score) return b.score - a.score - const aTime = a.group[0]?.message?.created_at ?? 0 - const bTime = b.group[0]?.message?.created_at ?? 0 + const aTime = messageTimeMs(a.group[0]) + const bTime = messageTimeMs(b.group[0]) return bTime - aTime }) @@ -191,7 +204,7 @@ export function pruneByRelevance( totalTokens += tokens } - return result.sort((a, b) => (a.message?.created_at ?? 0) - (b.message?.created_at ?? 0)) + return result.sort((a, b) => messageTimeMs(a) - messageTimeMs(b)) } export function getTopRelevantMessages(