fix(pi): recover from Works prompt rejection

This commit is contained in:
2026-08-25 14:50:10 +08:00
parent 4580568384
commit 5f0ef761ea
9 changed files with 439 additions and 31 deletions

View File

@@ -519,6 +519,49 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
});
it('reenables the same Conversation after a definite prompt rejection', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.submit
.mockRejectedValueOnce(new Error('所选模型当前不可用,请重新选择。'))
.mockImplementationOnce(async (input: {
conversationId: string;
clientRequestId: string;
mode: 'prompt';
}) => ({
accepted: true,
conversationId: input.conversationId,
clientRequestId: input.clientRequestId,
runId: 'run-retry',
mode: input.mode,
}));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(conversation.id));
fireEvent.change(textbox, { target: { value: 'First prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(textbox).toHaveValue('First prompt'));
expect(await screen.findByText('所选模型当前不可用,请重新选择。')).toBeInTheDocument();
expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.error)
.toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument();
fireEvent.change(textbox, { target: { value: 'Retry prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2));
});
it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });

View File

@@ -465,11 +465,14 @@ describe('coding Conversation store', () => {
});
it('restores draft attachments and marks the optimistic node on definite rejection', async () => {
const submitPrompt = vi.fn(async () => {
throw new AppError('RUNTIME', 'Model unavailable', undefined, {
const submitPrompt = vi.fn()
.mockRejectedValueOnce(new AppError('RUNTIME', 'Model unavailable', undefined, {
backendCode: 'CODING_MODEL_UNAVAILABLE',
});
});
}))
.mockImplementationOnce(async (input) => acceptance(
input.conversationId,
input.clientRequestId,
));
const store = createCodingConversationStore({
getSnapshot: vi.fn(),
openEvents: vi.fn(),
@@ -477,11 +480,13 @@ describe('coding Conversation store', () => {
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
store.getState().setDraft('conversation-a', 'Try again', [{
attachmentId: 'attachment-1',
mime: 'image/png',
previewUrl: 'blob:preview',
}]);
store.getState().setDraft('conversation-b', 'Other conversation draft');
await expect(store.getState().submitPrompt({
conversationId: 'conversation-a',
@@ -494,8 +499,21 @@ describe('coding Conversation store', () => {
});
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
.toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' });
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
expect(store.getState().entriesByConversationId['conversation-b']).toMatchObject({
loadState: 'live',
error: null,
});
expect(store.getState().draftsByConversationId['conversation-b'].text)
.toBe('Other conversation draft');
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
.toMatchObject({ id: 'node-1', status: 'error' });
store.getState().setDraft('conversation-a', 'Retry now');
await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
expect(submitPrompt).toHaveBeenCalledTimes(2);
expect(store.getState().requestsByConversationId['conversation-a']['request-2'])
.toMatchObject({ status: 'accepted' });
});
it('keeps attachment preparation failures out of runtime recovery and permits a direct retry', async () => {
@@ -574,6 +592,7 @@ describe('coding Conversation store', () => {
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
.toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' });
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
.toMatchObject({ id: 'node-1', status: 'optimistic' });
expect(submitPrompt).toHaveBeenCalledTimes(1);

View File

@@ -54,6 +54,10 @@ describe('Pi Provider catalog', () => {
'google-generative-ai',
'openai-completions',
]);
expect(catalog.descriptors.slice(0, 4).map((descriptor) => (
descriptor.models[0]?.compat?.supportsDeveloperRole
))).toEqual([undefined, undefined, undefined, undefined]);
expect(catalog.descriptors.at(-1)?.models[0]?.compat?.supportsDeveloperRole).toBeUndefined();
expect(catalog.descriptors.at(-1)?.models[0]?.compat).toEqual({
thinkingFormat: 'openrouter',
sessionAffinityFormat: 'openrouter',
@@ -105,7 +109,10 @@ describe('Pi Provider catalog', () => {
input: ['text', 'image'],
contextWindow: 1_000_000,
maxOutputTokens: 65_536,
compat: { supportsDeveloperRole: false },
});
expect(catalog.modelsFile.providers[descriptor.runtimeProviderId]?.models[0]?.compat)
.toMatchObject({ supportsDeveloperRole: false });
expect(serialized).not.toContain('private-tenant-header');
expect(serialized).not.toContain('OLD_GATEWAY_TOKEN');
@@ -146,6 +153,7 @@ describe('Pi Provider catalog', () => {
expect(resolveCredential).not.toHaveBeenCalled();
expect(Object.values(projection.env)).toContain('current-worker-host-token');
expect(Object.values(projection.env)).not.toContain('stale-stored-host-token');
expect(descriptor.models[0]?.compat?.supportsDeveloperRole).toBe(false);
});
it('uses account-scoped model capability metadata and rejects unavailable models', () => {

View File

@@ -0,0 +1,28 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('Pi packaged release proof wiring', () => {
it('keeps extension and proxy Provider scenarios assigned to their own proof entrypoints', async () => {
const source = await readFile(
resolve('electron/coding-runtime/pi/release-proof.ts'),
'utf8',
);
const extensionStart = source.indexOf('export async function runFinalAsarExtensionProof');
const proxyStart = source.indexOf('export async function startFinalAsarProxyCompositionProof');
const resilienceStart = source.indexOf('export async function startFinalAsarResilienceProof');
expect(extensionStart).toBeGreaterThan(-1);
expect(proxyStart).toBeGreaterThan(extensionStart);
expect(resilienceStart).toBeGreaterThan(proxyStart);
expect(source.slice(extensionStart, proxyStart))
.toContain("startLocalProofProvider('subagent')");
expect(source.slice(extensionStart, proxyStart))
.not.toContain("startLocalProofProvider('submit-rejection')");
expect(source.slice(proxyStart, resilienceStart))
.toContain("startLocalProofProvider('submit-rejection')");
expect(source.slice(proxyStart, resilienceStart))
.not.toContain("startLocalProofProvider('subagent')");
});
});