diff --git a/tests/fixtures/legacy-anthropic-0200.json b/tests/fixtures/legacy-anthropic-0200.json new file mode 100644 index 0000000000..589d6a80e8 --- /dev/null +++ b/tests/fixtures/legacy-anthropic-0200.json @@ -0,0 +1,175 @@ +{ + "provenance": "Synthetic body decoded by the pinned 0.1.200 kernel; no private corpus content", + "body": { + "messages": [ + { + "role": "user", + "content": "Summarize this synthetic lookup." + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "synthetic-call-1", + "name": "lookup", + "input": { + "key": "invented" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "synthetic-call-1", + "content": "invented lookup result" + } + ] + }, + { + "role": "user", + "content": "Synthetic tail 0." + }, + { + "role": "assistant", + "content": "Synthetic tail 1." + }, + { + "role": "user", + "content": "Synthetic tail 2." + }, + { + "role": "assistant", + "content": "Synthetic tail 3." + }, + { + "role": "user", + "content": "Synthetic tail 4." + }, + { + "role": "assistant", + "content": "Synthetic tail 5." + }, + { + "role": "user", + "content": "Synthetic tail 6." + }, + { + "role": "assistant", + "content": "Synthetic tail 7." + }, + { + "role": "user", + "content": "Synthetic tail 8." + }, + { + "role": "assistant", + "content": "Synthetic tail 9." + } + ] + }, + "legacyMessages": [ + { + "id": "h_dd37bf0d01f1f2c2", + "role": "user", + "contentType": "text", + "text": "Summarize this synthetic lookup.", + "wireIndex": 0 + }, + { + "id": "h_ad375fbf578d70f4", + "role": "assistant", + "contentType": "tool-call", + "toolName": "lookup", + "toolCallId": "synthetic-call-1", + "text": "{\"key\":\"invented\"}", + "wireIndex": 1 + }, + { + "id": "h_5acf06bd84245dbd", + "role": "tool", + "contentType": "tool-result", + "toolCallId": "synthetic-call-1", + "text": "invented lookup result", + "toolName": "lookup", + "wireIndex": 2 + }, + { + "id": "h_dd7c34c8f76f8de0", + "role": "user", + "contentType": "text", + "text": "Synthetic tail 0.", + "wireIndex": 3 + }, + { + "id": "h_ecbe503530c507a1", + "role": "assistant", + "contentType": "text", + "text": "Synthetic tail 1.", + "wireIndex": 4 + }, + { + "id": "h_91cd90107009cd4c", + "role": "user", + "contentType": "text", + "text": "Synthetic tail 2.", + "wireIndex": 5 + }, + { + "id": "h_cc36cc6011d0bf55", + "role": "assistant", + "contentType": "text", + "text": "Synthetic tail 3.", + "wireIndex": 6 + }, + { + "id": "h_39dc1e2dc520545c", + "role": "user", + "contentType": "text", + "text": "Synthetic tail 4.", + "wireIndex": 7 + }, + { + "id": "h_fa8ed6aa56f9e474", + "role": "assistant", + "contentType": "text", + "text": "Synthetic tail 5.", + "wireIndex": 8 + }, + { + "id": "h_f5b2ff5752bc2354", + "role": "user", + "contentType": "text", + "text": "Synthetic tail 6.", + "wireIndex": 9 + }, + { + "id": "h_3309f696e463ee67", + "role": "assistant", + "contentType": "text", + "text": "Synthetic tail 7.", + "wireIndex": 10 + }, + { + "id": "h_377cfa6ea7c6261e", + "role": "user", + "contentType": "text", + "text": "Synthetic tail 8.", + "wireIndex": 11 + }, + { + "id": "h_ec73340115ef30cb", + "role": "assistant", + "contentType": "text", + "text": "Synthetic tail 9.", + "wireIndex": 12 + } + ], + "coveredIds": [ + "h_ad375fbf578d70f4", + "h_5acf06bd84245dbd" + ] +} \ No newline at end of file diff --git a/tests/legacy-anthropic-identity-replay.test.ts b/tests/legacy-anthropic-identity-replay.test.ts new file mode 100644 index 0000000000..c632c9ce2b --- /dev/null +++ b/tests/legacy-anthropic-identity-replay.test.ts @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { anthropicToCore, type AnthropicRequestBody } from 'acp-kernel/wire'; +import { createInitialState, prune, type CoreMessage } from 'acp-kernel'; +import { normalizedIdentity, normalizedIdentityNoToolCallId, reconcileFoldCoverage } from '../src/fold-reconcile.js'; +import type { Session } from '../src/session.js'; + +const fixture = JSON.parse(readFileSync(new URL('./fixtures/legacy-anthropic-0200.json', import.meta.url), 'utf8')) as { + body: AnthropicRequestBody; + legacyMessages: CoreMessage[]; + coveredIds: string[]; +}; + +function legacySession(): Session { + // A valid legacy coverage shape with no surviving anchors for this old pair. + // The 13-message resend clears the auxiliary-side-request guard. Persisted + // lastMessages is empty, as in the affected legacy envelope. No private + // messages, anchors, summaries or identifiers are included in this fixture. + const state = createInitialState(); + state.blocks.push({ + blockId: 'b1', runId: 'synthetic-fold', tier: 1, + summary: 'The synthetic lookup completed.', + directMessageIds: [...fixture.coveredIds], + effectiveMessageIds: [...fixture.coveredIds], directBlockIds: [], + compressedTokens: 20, createdAt: 1700000000000, + survivedCount: 0, generation: 'old', active: true, + }); + return { + id: 'synthetic-legacy-replay', + state, + metadata: { foldAnchorOrder: fixture.legacyMessages.map(message => message.id), foldAnchors: {} }, + lastMessages: [], + } as unknown as Session; +} + +test('legacy IDs still match persisted coverage without surviving anchors', () => { + const session = legacySession(); + const messages = structuredClone(fixture.legacyMessages); + const result = reconcileFoldCoverage(session, messages, { mode: 'repair' }); + assert.equal(result.missing, 0); + assert.equal(result.unmatched, 0); + assert.equal(prune(messages, session.state).filter(message => message.toolCallId === 'synthetic-call-1').length, 0); +}); + +test('synthetic old and current decoders preserve the same message payloads', () => { + const current = anthropicToCore(structuredClone(fixture.body)).msgs; + const withoutIds = (messages: CoreMessage[]) => messages.map(({ id: _id, ...payload }) => payload); + assert.deepEqual(withoutIds(current), withoutIds(fixture.legacyMessages)); + assert.equal(current.length, 13); + assert.equal(fixture.coveredIds.length, 2); +}); + +test('surviving tool anchors recover the same current replay', () => { + const session = legacySession(); + session.metadata.foldAnchors = Object.fromEntries(fixture.legacyMessages.map(message => [message.id, { + n: normalizedIdentity(message), r: message.role, b: message.text?.length ?? 0, + ...(message.toolCallId ? { t: message.toolCallId, m: normalizedIdentityNoToolCallId(message) } : {}), + }])); + const current = anthropicToCore(structuredClone(fixture.body)).msgs; + const result = reconcileFoldCoverage(session, current, { mode: 'repair' }); + assert.equal(result.unmatched, 0); + assert.equal(prune(current, session.state).filter(message => message.toolCallId === 'synthetic-call-1').length, 0); +}); + +test('unchanged Anthropic replay keeps legacy folded tool coverage after upgrade', () => { + const session = legacySession(); + const current = anthropicToCore(structuredClone(fixture.body)).msgs; + const result = reconcileFoldCoverage(session, current, { mode: 'repair' }); + assert.equal(prune(current, session.state).filter(message => message.toolCallId === 'synthetic-call-1').length, 0, + 'already-folded tool messages must stay folded on unchanged replay'); + assert.equal(result.unmatched, 0, + 'unchanged tool-call/result payloads must not re-enter the wire unfolded after upgrading persisted legacy coverage'); +});