fix(snapshot): harden message id retention

This commit is contained in:
Tianyi Cui
2026-08-08 02:15:44 +08:00
parent fa31f27865
commit 8982d714cb
12 changed files with 283 additions and 131 deletions
+140 -59
View File
@@ -7,6 +7,7 @@ import { afterAll, describe, expect, it } from 'vitest'
import {
defineAcpSnapshotSuite,
stabilizeFixtureMessageIds,
tokenizeSessionFixtureCwd,
type HarvestedLog,
type Scenario,
} from '../src/index.ts'
@@ -679,6 +680,109 @@ describe('stabilizeFixtureMessageIds', () => {
}
})
it('rewrites only complete messages carried by surface events or durable inbox splices', () => {
const ids = {
freshUser: '11111111-1111-4111-8111-111111111111',
oldUser: '22222222-2222-4222-8222-222222222222',
freshAssistant: '33333333-3333-4333-8333-333333333333',
oldAssistant: '44444444-4444-4444-8444-444444444444',
freshTool: '55555555-5555-4555-8555-555555555555',
oldTool: '66666666-6666-4666-8666-666666666666',
oldMalformed: '77777777-7777-4777-8777-777777777777',
} as const
const message = (id: string, role: string, text: string): Record<string, unknown> => ({
id,
role,
content: [{ type: 'text', text }],
source: { kind: role === 'user' ? 'user' : 'model' },
})
const log = (userId: string, assistantId: string, toolId: string, malformedId: string): string => [
JSON.stringify({ type: 'session', id: 'same', cwd: '{{cwd}}' }),
JSON.stringify({
type: 'agent/inbox/spliced',
data: {
inserted: [
message(userId, 'user', 'user'),
{ ...message(userId, 'user', 'malformed inbox'), source: null },
],
},
}),
JSON.stringify({ type: 'user/message', data: message(userId, 'user', 'user') }),
JSON.stringify({ type: 'assistant/message', data: { message: message(assistantId, 'assistant', 'assistant') } }),
JSON.stringify({ type: 'tool/result', data: { message: message(toolId, 'tool', 'tool') } }),
JSON.stringify({ type: 'turn/start', data: { id: userId } }),
JSON.stringify({ type: 'steering/message', data: message(userId, 'user', 'obsolete') }),
JSON.stringify({ type: 'user/message', data: { ...message(userId, 'user', 'malformed'), source: null } }),
JSON.stringify({ type: 'user/message', data: message(malformedId, 'user', 'non-UUID') }),
JSON.stringify({ type: 'assistant/message', data: null }),
JSON.stringify({ type: 42, data: message(userId, 'user', 'non-string type') }),
'',
].join('\n')
const stable = stabilizeFixtureMessageIds(
[log(ids.freshUser, ids.freshAssistant, ids.freshTool, 'not-a-uuid')],
[log(ids.oldUser, ids.oldAssistant, ids.oldTool, ids.oldMalformed)],
)[0] as string
const records = stable.trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
const inserted = ((records[1]?.data as { inserted: Array<{ id: string }> }).inserted)
expect(inserted[0]?.id).toBe(ids.oldUser)
expect(inserted[1]?.id).toBe(ids.freshUser)
expect((records[2]?.data as { id: string }).id).toBe(ids.oldUser)
expect((records[3]?.data as { message: { id: string } }).message.id).toBe(ids.oldAssistant)
expect((records[4]?.data as { message: { id: string } }).message.id).toBe(ids.oldTool)
expect((records[5]?.data as { id: string }).id).toBe(ids.freshUser)
expect((records[6]?.data as { id: string }).id).toBe(ids.freshUser)
expect((records[7]?.data as { id: string }).id).toBe(ids.freshUser)
expect((records[8]?.data as { id: string }).id).toBe('not-a-uuid')
})
it('matches cwd-bearing messages only after the fresh log reaches fixture-ready form', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const existingId = '22222222-2222-4222-8222-222222222222'
const freshCwd = '/tmp/acp-snapshot-fresh-cwd'
const message = (id: string, path: string): Record<string, unknown> => ({
type: 'user/message',
data: {
id,
role: 'user',
content: [{ type: 'text', text: `read ${path}/input.txt` }],
source: { kind: 'user' },
},
})
const fresh = tokenizeSessionFixtureCwd([
JSON.stringify({ type: 'session', id: 'fresh', cwd: freshCwd }),
JSON.stringify(message(freshId, freshCwd)),
'',
].join('\n'))
const existing = [
JSON.stringify({ type: 'session', id: 'old', cwd: '{{cwd}}' }),
JSON.stringify(message(existingId, '{{cwd}}')),
'',
].join('\n')
expect(stabilizeFixtureMessageIds([fresh], [existing])[0]).toContain(`"id":"${existingId}"`)
})
it('rejects a fingerprint connected to an id that also identifies different content', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const conflictingId = '22222222-2222-4222-8222-222222222222'
const competingId = '33333333-3333-4333-8333-333333333333'
const message = (id: string, text: string): string => JSON.stringify({
type: 'user/message',
data: { id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } },
})
const fresh = `${message(freshId, 'shared')}\n`
const existing = [
message(conflictingId, 'shared'),
message(conflictingId, 'different'),
message(competingId, 'shared'),
'',
].join('\n')
expect(stabilizeFixtureMessageIds([fresh], [existing])).toEqual([fresh])
})
it('leaves fresh fixtures unchanged when no committed counterpart exists', () => {
const fresh = '{"type":"session","id":"new"}\n'
expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh])
@@ -726,76 +830,28 @@ describe('refreshFixtureReplacements', () => {
])
})
it('maps one inherited message id across parent and child logs', () => {
it('leaves complete message ids out of the literal refresh replacement list', () => {
const freshMessageId = '11111111-1111-4111-8111-111111111111'
const existingMessageId = '22222222-2222-4222-8222-222222222222'
const content = [{ type: 'text', text: 'inherited' }]
const log = (sessionId: string, messageId: string): string => [
JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }),
JSON.stringify({
type: 'user/message',
data: { role: 'user', content, source: { kind: 'user' }, id: messageId },
data: {
id: messageId,
role: 'user',
content: [{ type: 'text', text: 'same' }],
source: { kind: 'user' },
},
}),
'',
].join('\n')
const harvested = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content })
const replacements = refreshFixtureReplacements(
[harvested(log('fresh-parent', freshMessageId)), harvested(log('fresh-child', freshMessageId))],
[log('old-parent', existingMessageId), log('old-child', existingMessageId)],
[{ id: 'diagnostic', createdAt: 1, content: log('fresh', freshMessageId) }],
[log('old', existingMessageId)],
)
expect(replacements.filter(replacement => replacement.from === freshMessageId)).toEqual([
{ from: freshMessageId, to: existingMessageId },
])
})
it('keeps fresh ids for new, changed, and ambiguous messages', () => {
const ids = {
new: '11111111-1111-4111-8111-111111111111',
changed: '22222222-2222-4222-8222-222222222222',
ambiguousA: '33333333-3333-4333-8333-333333333333',
ambiguousB: '44444444-4444-4444-8444-444444444444',
oldChanged: '55555555-5555-4555-8555-555555555555',
oldAmbiguous: '66666666-6666-4666-8666-666666666666',
stable: '77777777-7777-4777-8777-777777777777',
} as const
const message = (id: string, text: string): Record<string, unknown> => ({
type: 'user/message',
data: { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, id },
})
const log = (messages: Record<string, unknown>[]): string => [
JSON.stringify({ type: 'session', id: 'same', cwd: '/same' }),
...messages.map(record => JSON.stringify(record)),
'',
].join('\n')
const fresh = log([
message(ids.new, 'new'),
message(ids.changed, 'changed'),
message(ids.changed, 'changed again'),
message(ids.ambiguousA, 'duplicate'),
message(ids.ambiguousB, 'duplicate'),
message(ids.stable, 'stable'),
])
const existing = log([
message(ids.oldChanged, 'before'),
message(ids.oldAmbiguous, 'duplicate'),
message(ids.stable, 'stable'),
])
const replacements = refreshFixtureReplacements(
[{ id: 'diagnostic', createdAt: 1, content: fresh }],
[existing],
)
const replacedIds = replacements.map(replacement => replacement.from)
for (const id of [
ids.new,
ids.changed,
ids.ambiguousA,
ids.ambiguousB,
ids.stable,
]) expect(replacedIds).not.toContain(id)
expect(replacements).toEqual([{ from: 'fresh', to: 'old' }])
})
})
@@ -936,13 +992,38 @@ describe('stabilizeRefreshLog', () => {
[{ id: 'diagnostic', createdAt: 1, content: fresh }],
[existing],
)
const output = stabilize(fresh, existing, replacements).trim().split('\n')
const refreshed = stabilize(fresh, existing, replacements)
const intermediate = refreshed.trim().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
expect((intermediate[1]?.data as { id: string }).id).toBe(freshUserId)
expect(((intermediate[3]?.data as { message: { id: string } }).message).id).toBe(freshAssistantId)
const output = (stabilizeFixtureMessageIds([refreshed], [existing])[0] as string).trim().split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
expect((output[1]?.data as { id: string }).id).toBe(existingUserId)
expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId)
})
it('leaves an aligned complete message id to the fixture-ready structural pass', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const existingId = '22222222-2222-4222-8222-222222222222'
const log = (id: string): string => [
JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }),
JSON.stringify({
type: 'user/message',
data: { id, role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' } },
}),
'',
].join('\n')
const fresh = log(freshId)
const existing = log(existingId)
const refreshed = stabilize(fresh, existing)
expect(refreshed).toContain(`"id":"${freshId}"`)
expect(stabilizeFixtureMessageIds([refreshed], [existing])[0]).toContain(`"id":"${existingId}"`)
})
it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
const fresh = [
'{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',