diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e5e9439713..bb63d82010 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -36,7 +36,7 @@ The selected pi-ai catalog descriptor supplies the protocol implementation. This Successful assistant responses store a versioned, lossless-JSON replay state beside their durable provider/model provenance. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response. -If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`. +If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provenance provider/model mismatches, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`. ## Vocabulary differences diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 665b236bfe..4e4fe679a5 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -156,6 +156,9 @@ function foreignAssistant(message: Message): AssistantMessage { /** Recombine durable Harness content with validated pi-ai replay metadata. */ function replayedAssistant(message: Message, rawState: unknown): AssistantMessage { const state = readReplayState(rawState) + const provenance = message.provenance + if (state.provider !== provenance?.provider) return invalidReplay('provider does not match assistant provenance') + if (state.model !== provenance.model) return invalidReplay('model does not match assistant provenance') if (state.blocks.length !== message.content.length) return invalidReplay('block count does not match assistant content') const content: AssistantMessage['content'] = message.content.map((block, index) => { const replay = state.blocks[index] diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index cf8e07de5c..50955f0607 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -255,7 +255,7 @@ describe('toPiContext', () => { { type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' }, ], - provenance: { provider: 'deepseek', model: 'old-model', replayState: state }, + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state }, }], }) @@ -302,7 +302,7 @@ describe('toPiContext', () => { messages: [{ role: 'assistant', content: [{ type: 'reasoning', text: 'done' }], - provenance: { provider: 'deepseek', model: 'old', replayState: state }, + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state }, }], })).toThrow(/block 0 does not match assistant content/) }) @@ -315,7 +315,7 @@ describe('toPiContext', () => { messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }], - provenance: { provider: 'deepseek', model: 'old', replayState: state }, + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state }, }], })).toThrow(/block count does not match assistant content/) }) @@ -330,6 +330,28 @@ describe('toPiContext', () => { blocks: [{ type: 'text' }], } + it.each([ + ['provider', { ...validReplay, provider: 'openai' }], + ['model', { ...validReplay, model: 'deepseek-v4-pro' }], + ])('rejects replay metadata whose %s differs from assistant provenance', (field, replayState) => { + try { + toPiContext({ + provider: 'deepseek', + model: 'next-model', + messages: [{ + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState }, + }], + }) + expect.fail('expected invalid replay state') + } catch (error: unknown) { + expect(error).toBeInstanceOf(LlmError) + expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE') + expect((error as Error).message).toContain(`${field} does not match assistant provenance`) + } + }) + it.each([ ['number state', 1, 'expected an object'], ['null state', null, 'expected an object'], @@ -355,7 +377,7 @@ describe('toPiContext', () => { messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }], - provenance: { provider: 'deepseek', model: 'old', replayState }, + provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState }, }], })).toThrow(message) })