fix(context): order pruned messages by envelope timestamp, not phantom field (#1934)

pruneByRelevance keyed recency scoring, the group tie-break, and the final
"restore chronological order" sort off message.message?.created_at. That
nested API-body field is never populated on our Message objects (nothing in
the tree assigns it), so every read was undefined and `?? 0` made all three
into no-ops: the recency bonus never fired, the tie-break never broke ties,
and the final sort left the list as [...recentMessages, ...olderGroups] —
the newest preserveRecent messages jumped ahead of older retained ones.

The chronological key actually lives on the Message envelope as `timestamp`
(an ISO-8601 string present on every variant). Add a messageTimeMs() helper
that parses it and route all three sites through it. This runs in the
auto-compaction path (autoCompact -> pruneByRelevance), so the reordered
list was being sent to the model. Add a regression using the real envelope
shape asserting retained messages stay in chronological order.
This commit is contained in:
0xfandom
2026-07-13 16:19:33 +08:00
committed by GitHub
parent 9bf9926805
commit 2970b5fd5f
2 changed files with 39 additions and 4 deletions
+22
View File
@@ -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))
})
})
})
+17 -4
View File
@@ -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(