fix(llm): align replay state with assembled content and degrade unusable state

A max-tokens response that included a tool call persisted assembler-transformed
content next to replay metadata projected from the untransformed native message,
so the next request died in history reconstruction with INVALID_REPLAY_STATE and
the session stayed permanently stuck.

Write side: the finish chunk's replayState becomes a typed ReplayEnvelope —
opaque response-level metadata plus optional per-block entries aligned with the
emitted block sequence. BlockAssembler computes one keep/drop decision for
blocks and entries together, so stored metadata always describes stored content
and retained blocks keep their signatures. pi-ai splits its state into a
version-2 response half and per-block signature entries.

Read side: durable content is authoritative. toPiAssistant degrades any
unusable state — foreign kind, other versions (including the flat v1 form
already on disk), malformed metadata, or content/block mismatches — to the
existing provider-neutral conversion with an onReplayDegrade diagnostic instead
of failing the request, which un-bricks sessions poisoned before this change.

Covered by assembler and replay unit tests, an agent-loop continuation
regression, keyless real-composition continuation tests (native pruned-envelope
replay and legacy flat-state degrade), and the authored keyless snapshot
scenario max-tokens-continue through the assembled ACP app.
This commit is contained in:
Yichen Jiang
2026-08-15 16:07:30 +08:00
parent 5bb600f9fb
commit 7e95a00c8a
33 changed files with 782 additions and 186 deletions

View File

@@ -1192,15 +1192,29 @@ describe('agent loop', () => {
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
{
type: 'finish',
reason: { kind: 'max-tokens' },
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta', 'tool-meta'] },
},
], textResponse('continued')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
send(agent, 'continue')
await waitForIdle(ctx, agent)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
// The follow-up request replays the truncated message with its replay
// metadata pruned in step with the dropped tool call.
expect(adapter.requests[1]?.messages[1]?.source).toEqual({
kind: 'model',
provider: 'mock',
model: 'mock',
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] },
})
expect(agent.session.deriveMessages()).toEqual([
{
id: expect.any(String) as unknown,
@@ -1212,6 +1226,23 @@ describe('agent loop', () => {
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'partial text' }],
source: {
kind: 'model',
provider: 'mock',
model: 'mock',
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] },
},
},
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'continue' }],
source: { kind: 'user' },
},
{
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'continued' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
])