fix(pi): recover from Works prompt rejection
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
28
tests/unit/pi-release-proof-wiring.test.ts
Normal file
28
tests/unit/pi-release-proof-wiring.test.ts
Normal 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')");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user