fix(pi): settle delayed compact ownership
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers';
|
||||
import type { ConversationSnapshot } from '@/types/coding-conversation';
|
||||
|
||||
const interactionApi = vi.hoisted(() => ({
|
||||
respond: vi.fn(),
|
||||
@@ -275,6 +276,95 @@ describe('PI-130 feature-complete Coding UI', () => {
|
||||
expect(screen.queryByText(/分享|回滚|待办/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears a compact confirmation uncertainty when the authoritative run settles', async () => {
|
||||
interactionApi.compact.mockRejectedValueOnce(Object.assign(
|
||||
new Error('请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。'),
|
||||
{ details: { backendCode: 'CODING_REQUEST_UNCERTAIN' } },
|
||||
));
|
||||
const conversation = {
|
||||
id: 'conversation-compact-uncertain',
|
||||
agentId: 'agent-1',
|
||||
title: 'Compact uncertainty',
|
||||
archivedAt: null,
|
||||
unread: false,
|
||||
createdAt: '2026-08-25T00:00:00.000Z',
|
||||
updatedAt: '2026-08-25T00:00:00.000Z',
|
||||
model: { accountId: 'account-1', modelId: 'model-a', thinkingLevel: 'medium' as const },
|
||||
modelResolution: 'resolved' as const,
|
||||
};
|
||||
const snapshot = (run: ConversationSnapshot['run'], seq = 1): ConversationSnapshot => ({
|
||||
schemaVersion: 1,
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
projectId: 'project-1',
|
||||
agentId: conversation.agentId,
|
||||
title: conversation.title,
|
||||
model: { model: conversation.model, modelResolution: 'resolved' },
|
||||
},
|
||||
nodes: [],
|
||||
run,
|
||||
queue: { items: [] },
|
||||
context: { usedTokens: 200, contextWindow: 1000, compaction: 'idle' },
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'ready', generation: 1 },
|
||||
cursor: { workerGeneration: 1, seq },
|
||||
});
|
||||
const callbacks = {
|
||||
onRename: vi.fn(async () => undefined),
|
||||
onArchive: vi.fn(async () => undefined),
|
||||
onToggleUnread: vi.fn(async () => undefined),
|
||||
onFork: vi.fn(async () => undefined),
|
||||
onRefresh: vi.fn(async () => undefined),
|
||||
onRecover: vi.fn(async () => undefined),
|
||||
onOpenInspector: vi.fn(),
|
||||
};
|
||||
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
|
||||
const { rerender } = render(
|
||||
<CodingConversationHeader
|
||||
conversation={conversation}
|
||||
snapshot={snapshot({ status: 'idle' })}
|
||||
connectionState="live"
|
||||
{...callbacks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '整理上下文' }));
|
||||
expect(await screen.findByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
|
||||
rerender(
|
||||
<CodingConversationHeader
|
||||
conversation={conversation}
|
||||
snapshot={snapshot({
|
||||
status: 'compacting',
|
||||
runId: 'run-compact-uncertain',
|
||||
error: {
|
||||
code: 'CODING_REQUEST_UNCERTAIN',
|
||||
message: '请求确认延迟,可能仍在执行。',
|
||||
recoverable: true,
|
||||
},
|
||||
}, 2)}
|
||||
connectionState="live"
|
||||
{...callbacks}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/请求确认延迟,可能仍在执行/)).toBeInTheDocument();
|
||||
rerender(
|
||||
<CodingConversationHeader
|
||||
conversation={conversation}
|
||||
snapshot={snapshot({
|
||||
status: 'idle',
|
||||
runId: 'run-compact-uncertain',
|
||||
settledAt: 12_000,
|
||||
terminalReason: 'completed',
|
||||
}, 3)}
|
||||
connectionState="live"
|
||||
{...callbacks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/请求确认延迟,可能仍在执行/))
|
||||
.not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('blocks overlapping mutations but keeps abort and recover available while confirmation is uncertain', async () => {
|
||||
interactionApi.abort.mockResolvedValue(undefined);
|
||||
const recover = vi.fn(async () => undefined);
|
||||
|
||||
@@ -36,6 +36,8 @@ class LifecycleFakeWorker implements PiConversationWorker {
|
||||
private readonly events = new Set<(event: PiRpcEvent) => void>();
|
||||
private readonly invalidations = new Set<(error: PiProcessError) => void>();
|
||||
private timeoutType: string | null = null;
|
||||
private deferredType: string | null = null;
|
||||
private deferredReject: ((error: unknown) => void) | null = null;
|
||||
private compacted = false;
|
||||
private lateResult: PiRpcRequestOptions['onLateResult'];
|
||||
|
||||
@@ -57,6 +59,12 @@ class LifecycleFakeWorker implements PiConversationWorker {
|
||||
)), 10_000);
|
||||
});
|
||||
}
|
||||
if (this.deferredType === command.type) {
|
||||
this.deferredType = null;
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
this.deferredReject = reject;
|
||||
});
|
||||
}
|
||||
const data = command.type === 'get_state'
|
||||
? {
|
||||
sessionId: `session-${this.id}`,
|
||||
@@ -132,6 +140,15 @@ class LifecycleFakeWorker implements PiConversationWorker {
|
||||
this.timeoutType = type;
|
||||
}
|
||||
|
||||
deferNext(type: string): void {
|
||||
this.deferredType = type;
|
||||
}
|
||||
|
||||
rejectDeferred(error: unknown): void {
|
||||
this.deferredReject?.(error);
|
||||
this.deferredReject = null;
|
||||
}
|
||||
|
||||
completeLateSuccess(): void {
|
||||
this.lateResult?.({
|
||||
response: { type: 'response', id: 'late-proof', success: true },
|
||||
@@ -354,7 +371,7 @@ describe('Pi run background lifecycle lease', () => {
|
||||
willRetry: false,
|
||||
});
|
||||
expect((await runtime.getSnapshot(runningConversation.id)).context.compaction).toBe('idle');
|
||||
workersByConversation.get(runningConversation.id)!.emit({ type: 'agent_settled' });
|
||||
workersByConversation.get(runningConversation.id)!.completeLateSuccess();
|
||||
await expect.poll(async () => (
|
||||
await runtime.getSnapshot(runningConversation.id)
|
||||
).run.status).toBe('idle');
|
||||
@@ -363,6 +380,31 @@ describe('Pi run background lifecycle lease', () => {
|
||||
);
|
||||
expect(controller.getLeaseCount()).toBe(0);
|
||||
|
||||
workersByConversation.get(runningConversation.id)!.deferNext('compact');
|
||||
const rejectedCompact = runtime.compact(runningConversation.id);
|
||||
void rejectedCompact.catch(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
workersByConversation.get(runningConversation.id)!.emit({
|
||||
type: 'compaction_start',
|
||||
reason: 'manual',
|
||||
});
|
||||
workersByConversation.get(runningConversation.id)!.emit({
|
||||
type: 'compaction_end',
|
||||
reason: 'manual',
|
||||
aborted: false,
|
||||
willRetry: false,
|
||||
errorMessage: 'Compaction failed: controlled rejection',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect((await runtime.getSnapshot(runningConversation.id)).run.status).toBe('error');
|
||||
workersByConversation.get(runningConversation.id)!.rejectDeferred(new PiProcessError(
|
||||
'PI_RPC_RESPONSE_ERROR',
|
||||
'controlled compact rejection',
|
||||
{ generation: 1 },
|
||||
));
|
||||
await expect(rejectedCompact).rejects.toBeInstanceOf(PiProcessError);
|
||||
expect(controller.getLeaseCount()).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(onSleep).toHaveBeenCalledTimes(1);
|
||||
expect(onStopRuntime).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -510,8 +510,8 @@ describe('Pi Conversation runtime', () => {
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('error');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
await runtime.compact(right.id);
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
||||
expect(activeBackgroundLeases).toBe(1);
|
||||
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
expect(workers.get(right.id)!.requests.at(-1)).toEqual({ type: 'compact' });
|
||||
expect((await runtime.getSnapshot(left.id)).run.status).toBe('idle');
|
||||
const rightDurable = {
|
||||
@@ -529,7 +529,7 @@ describe('Pi Conversation runtime', () => {
|
||||
durableSessions.set(right.id, rightDurable);
|
||||
workers.get(right.id)!.setSessionData(rightDurable);
|
||||
workers.get(right.id)!.emit({ type: 'agent_end' });
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('compacting');
|
||||
expect((await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||
workers.get(right.id)!.emit({ type: 'agent_settled' });
|
||||
await expect.poll(async () => (await runtime.getSnapshot(right.id)).run.status).toBe('idle');
|
||||
expect(activeBackgroundLeases).toBe(0);
|
||||
|
||||
@@ -40,6 +40,9 @@ describe('Pi packaged release proof wiring', () => {
|
||||
expect(scriptSource).toContain('setMainWindowVisible(electronApplication, false)');
|
||||
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.dispose-target')");
|
||||
expect(proofSource).toContain('const PROOF_MUTATION_CONFIRMATION_DELAY_MS = 12_000;');
|
||||
expect(proofSource).toContain('keepRecentTokens: 1');
|
||||
expect(proofSource).toContain("mode === 'resilience' && delayedCompactionArmed");
|
||||
expect(mainSource).toContain("userDataDir: app.getPath('userData')");
|
||||
expect(proofSource).toContain('delayNextTopLevelConfirmationForProof');
|
||||
expect(mainSource).toContain("'resilience.arm-compact-delay'");
|
||||
expect(mainSource).toContain("'resilience.arm-prompt-delay'");
|
||||
@@ -47,5 +50,19 @@ describe('Pi packaged release proof wiring', () => {
|
||||
expect(scriptSource).toContain("evaluateProof(electronApplication, 'resilience.arm-prompt-delay')");
|
||||
expect(scriptSource).toContain("status?.target?.errorCode === 'CODING_REQUEST_UNCERTAIN'");
|
||||
expect(scriptSource).toContain('status?.target?.completedCompactions >= 1');
|
||||
const releaseParents = scriptSource.indexOf(
|
||||
"await evaluateProof(electronApplication, 'resilience.release-parents')",
|
||||
);
|
||||
const restoreVisible = scriptSource.indexOf(
|
||||
'await setMainWindowVisible(electronApplication, true)',
|
||||
releaseParents,
|
||||
);
|
||||
const awaitSettlement = scriptSource.indexOf(
|
||||
'await waitForResilienceProof(',
|
||||
releaseParents,
|
||||
);
|
||||
expect(releaseParents).toBeGreaterThan(-1);
|
||||
expect(restoreVisible).toBeGreaterThan(releaseParents);
|
||||
expect(restoreVisible).toBeLessThan(awaitSettlement);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PiWorkerPool,
|
||||
type PiConversationWorker,
|
||||
type PiWorkerOpenResult,
|
||||
type PiWorkerPoolEvent,
|
||||
} from '../../electron/coding-runtime/pi/worker-pool';
|
||||
import type { PiProcessError } from '../../electron/coding-runtime/pi/process-errors';
|
||||
import { PiProcessError as PiProcessFailure } from '../../electron/coding-runtime/pi/process-errors';
|
||||
@@ -224,6 +225,34 @@ describe('Pi worker pool', () => {
|
||||
expect(pool.getResilienceProofDiagnostics().runs).toEqual({ active: 0, waiting: 0 });
|
||||
});
|
||||
|
||||
it('settles compact from its authoritative RPC success without agent_settled', async () => {
|
||||
const worker = new FakeWorker('worker-target');
|
||||
const events: PiWorkerPoolEvent[] = [];
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 2,
|
||||
openWorker: async () => ({
|
||||
worker,
|
||||
session: { piSessionId: 'session-target', sessionKey: 'key-target' },
|
||||
}),
|
||||
});
|
||||
pool.subscribe((event) => events.push(event));
|
||||
await pool.prepare(conversation('conversation-target'));
|
||||
|
||||
const ticket = pool.startTopLevel({
|
||||
conversationId: 'conversation-target',
|
||||
runId: 'run-compact-success',
|
||||
command: { type: 'compact' },
|
||||
});
|
||||
|
||||
await expect(ticket.accepted).resolves.toMatchObject({ success: true });
|
||||
expect(pool.getActiveRun('conversation-target')).toBeNull();
|
||||
expect(pool.getState('conversation-target')?.state).toBe('idle');
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
type: 'top-level.settled',
|
||||
runId: 'run-compact-success',
|
||||
}));
|
||||
});
|
||||
|
||||
it('treats agent settlement as authoritative when the RPC confirmation is still pending', async () => {
|
||||
const worker = new FakeWorker('worker-target');
|
||||
const pool = new PiWorkerPool({
|
||||
|
||||
Reference in New Issue
Block a user