fix: preserve Pi projection continuity

This commit is contained in:
2026-08-23 08:40:58 +08:00
parent 3599064bc4
commit 4bbe3f8b1e
7 changed files with 248 additions and 9 deletions

View File

@@ -121,3 +121,45 @@ export const PI_084_TEXT_TURN = {
tokens: { input: 12, output: 4, cacheRead: 2, cacheWrite: 0, total: 18 },
},
} as const;
export const PI_084_AUTO_RETRY_FAILURE_EVENTS = [
{
type: 'auto_retry_start',
attempt: 3,
maxAttempts: 3,
delayMs: 1000,
errorMessage: 'sensitive provider retry detail',
},
{
type: 'auto_retry_end',
success: false,
attempt: 3,
finalError: 'sensitive final provider detail',
},
{ type: 'agent_settled' },
] as const;
export const PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS = [
{ type: 'compaction_start', reason: 'threshold' },
{
type: 'summarization_retry_scheduled',
attempt: 1,
maxAttempts: 2,
delayMs: 500,
errorMessage: 'sensitive summary provider detail',
},
{
type: 'summarization_retry_attempt_start',
source: 'compaction',
reason: 'threshold',
},
{ type: 'summarization_retry_finished' },
{
type: 'compaction_end',
reason: 'threshold',
aborted: false,
willRetry: false,
errorMessage: 'sensitive exhausted summary detail',
},
{ type: 'agent_settled' },
] as const;

View File

@@ -203,6 +203,12 @@ describe('Pi Conversation runtime', () => {
},
});
await Promise.all(inputs.slice(0, 2).map((input) => runtime.prepare(input)));
const leftGenerationOneSeqs: number[] = [];
const unsubscribe = runtime.subscribe((envelope) => {
if (envelope.conversationId === left.id && envelope.workerGeneration === 1) {
leftGenerationOneSeqs.push(envelope.seq);
}
});
const releasePromptAcceptance = workers.get(left.id)!.holdNext('prompt');
let acceptanceResolved = false;
@@ -280,6 +286,8 @@ describe('Pi Conversation runtime', () => {
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
const settledNodes = (await runtime.getSnapshot(left.id)).nodes;
expect(settledNodes).toEqual(checkpoint.nodes);
expect(leftGenerationOneSeqs).toEqual(leftGenerationOneSeqs.map((_, index) => index + 1));
expect((await runtime.getSnapshot(left.id)).cursor.seq).toBe(leftGenerationOneSeqs.at(-1));
const changed = await runtime.setModel({
conversationId: left.id,
@@ -452,5 +460,6 @@ describe('Pi Conversation runtime', () => {
await expect(runtime.getSnapshot(forkTarget.id)).rejects.toMatchObject({
publicError: { code: 'CODING_CONVERSATION_NOT_FOUND' },
});
unsubscribe();
});
});

View File

@@ -10,6 +10,10 @@ import {
reduceConversationPatch,
} from '../../electron/coding-runtime/conversation-reducer';
import { PiEventProjector } from '../../electron/coding-runtime/pi/event-projector';
import {
PI_084_AUTO_RETRY_FAILURE_EVENTS,
PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS,
} from '../fixtures/pi-0.84.2-projector-fixtures';
function emptySnapshot(): ConversationSnapshot {
return {
@@ -312,6 +316,67 @@ describe('Pi event projector', () => {
expect(snapshot.queue.items).toEqual([]);
});
it('preserves a redacted failed auto-retry terminal through agent_settled', async () => {
const ids = ['retry-boundary-a'];
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
let snapshot = emptySnapshot();
for (const event of PI_084_AUTO_RETRY_FAILURE_EVENTS) {
snapshot = apply(snapshot, await projector.project(snapshot, structuredClone(event)));
}
expect(snapshot.run).toMatchObject({
status: 'idle',
terminalReason: 'failed',
error: {
code: 'CODING_RUNTIME_START_FAILED',
recoverable: true,
},
});
expect(JSON.stringify(snapshot)).not.toContain('sensitive');
expect(projector.getDiagnostics()).not.toContainEqual(expect.objectContaining({
eventType: 'auto_retry_end',
}));
});
it('maps all summary-retry events and keeps exhausted compaction failure redacted', async () => {
const ids = ['compaction-a', 'summary-retry-boundary-a'];
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
let snapshot = emptySnapshot();
for (const event of PI_084_SUMMARIZATION_RETRY_FAILURE_EVENTS) {
snapshot = apply(snapshot, await projector.project(snapshot, structuredClone(event)));
if (event.type === 'summarization_retry_scheduled') {
expect(snapshot.run).toMatchObject({
status: 'retrying',
retry: { attempt: 1, delayMs: 500 },
});
}
if (event.type === 'summarization_retry_attempt_start'
|| event.type === 'summarization_retry_finished') {
expect(snapshot.run.status).toBe('compacting');
expect(snapshot.run.retry).toBeUndefined();
}
}
expect(snapshot.nodes).toContainEqual(expect.objectContaining({
kind: 'boundary',
id: 'summary-retry-boundary-a',
boundary: 'retry',
attempt: 1,
delayMs: 500,
}));
expect(snapshot.run).toMatchObject({
status: 'idle',
terminalReason: 'failed',
error: { code: 'CODING_RUNTIME_START_FAILED', recoverable: true },
});
expect(JSON.stringify(snapshot)).not.toContain('sensitive');
expect(projector.getDiagnostics()).not.toContainEqual(expect.objectContaining({
eventType: expect.stringMatching(/^summarization_retry_/),
}));
});
it('reconciles the optimistic user node and projects image bytes through an attachment hook', async () => {
const projectedImages: unknown[] = [];
const projector = new PiEventProjector({

View File

@@ -130,6 +130,19 @@ describe('Pi session projector', () => {
expect(JSON.stringify(snapshot)).not.toContain('Abandoned answer');
});
it('preserves seq for same-generation checkpoint hydration', async () => {
const live = baseSnapshot();
live.cursor.seq = 7;
const snapshot = await projectPiSessionSnapshot({
snapshot: live,
workerGeneration: 1,
state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false },
entries: { entries: [], leafId: null },
});
expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 });
});
it('applies retained-tail compaction and reconciles durable entries without replacing live IDs', async () => {
const live: ConversationSnapshot = {
...baseSnapshot(),