Files
openmaic/OpenMAIC/tests/generation/scene-api-retry-boundary.test.ts
2026-08-16 14:58:47 +08:00

366 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SceneOutline } from '@/lib/types/generation';
import { legacyPBLSceneFixture } from '@/tests/fixtures/pbl-v1-scene';
const mocks = vi.hoisted(() => ({
callLLM: vi.fn(),
resolveModelFromRequest: vi.fn(),
applyOutlineFallbacks: vi.fn(),
generateSceneContent: vi.fn(),
generateSceneActions: vi.fn(),
buildCompleteScene: vi.fn(),
buildVisionUserContent: vi.fn(),
resolveVocationalActive: vi.fn(),
}));
vi.mock('@/lib/ai/llm', () => ({
callLLM: mocks.callLLM,
}));
vi.mock('@/lib/server/resolve-model', () => ({
resolveModelFromRequest: mocks.resolveModelFromRequest,
}));
vi.mock('@/lib/config/feature-flags', () => ({
resolveVocationalActive: mocks.resolveVocationalActive,
}));
vi.mock('@openmaic/generation', async (importOriginal) => ({
...(await importOriginal<typeof import('@openmaic/generation')>()),
applyOutlineFallbacks: mocks.applyOutlineFallbacks,
generateSceneContent: mocks.generateSceneContent,
generateSceneActions: mocks.generateSceneActions,
buildCompleteScene: mocks.buildCompleteScene,
buildVisionUserContent: mocks.buildVisionUserContent,
}));
vi.mock('@/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
const outline = {
id: 'outline-1',
type: 'slide',
title: 'Retry Boundary',
description: 'Keep retries controlled by the outer scene retry helper.',
keyPoints: ['no retry multiplication'],
order: 1,
} as SceneOutline;
const pblOutline = {
id: 'outline-pbl-1',
type: 'pbl',
title: legacyPBLSceneFixture.title,
description: 'Continue a stored legacy PBL project.',
keyPoints: ['garden measurements'],
order: 1,
pblConfig: {
projectTopic: legacyPBLSceneFixture.title,
projectDescription: 'Continue a stored legacy PBL project.',
targetSkills: ['data analysis'],
issueCount: 2,
},
} as SceneOutline;
describe('scene API retry boundary', () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) {
mock.mockReset();
}
mocks.resolveModelFromRequest.mockResolvedValue({
model: { id: 'language-model' },
modelInfo: { outputWindow: 4096, capabilities: {} },
modelString: 'test:model',
thinkingConfig: undefined,
});
mocks.applyOutlineFallbacks.mockImplementation((value) => value);
mocks.callLLM.mockResolvedValue({ text: 'ok' });
mocks.resolveVocationalActive.mockReturnValue(false);
});
it('disables AI SDK retries for scene-content model calls', async () => {
vi.resetModules();
mocks.generateSceneContent.mockImplementation(async (_outline, aiCall) => {
await aiCall('system', 'user');
return { elements: [], remark: 'ok' };
});
const { POST } = await import('@/app/api/generate/scene-content/route');
const response = await POST(mockRequest());
const body = await response.json();
expect(body.success).toBe(true);
expect(mocks.callLLM.mock.calls[0][0].maxRetries).toBe(0);
});
it('disables AI SDK retries for scene-actions model calls', async () => {
vi.resetModules();
mocks.generateSceneActions.mockImplementation(async (_outline, _content, aiCall) => {
await aiCall('system', 'user');
return [];
});
mocks.buildCompleteScene.mockReturnValue({
id: 'scene-1',
type: 'slide',
title: outline.title,
order: outline.order,
content: { elements: [], remark: 'ok' },
actions: [],
});
const { POST } = await import('@/app/api/generate/scene-actions/route');
const response = await POST(
mockRequest({
content: { elements: [], remark: 'ok' },
}),
);
const body = await response.json();
expect(body.success).toBe(true);
expect(mocks.callLLM.mock.calls[0][0].maxRetries).toBe(0);
});
it('normalizes stored legacy PBL content before generating actions and building the scene', async () => {
vi.resetModules();
mocks.generateSceneActions.mockImplementation(async (_outline, content, aiCall) => {
await aiCall('system', 'user');
return 'projectV2' in content
? [{ id: 'action-1', type: 'speech', title: 'Welcome', text: 'Let us continue.' }]
: [];
});
mocks.buildCompleteScene.mockImplementation((_outline, content, actions, stageId) => {
if (!('projectV2' in content)) return null;
return {
id: 'scene-pbl-1',
stageId,
type: 'pbl',
title: pblOutline.title,
order: pblOutline.order,
content: { type: 'pbl', projectV2: content.projectV2 },
actions,
};
});
const { POST } = await import('@/app/api/generate/scene-actions/route');
const response = await POST(
mockRequest({
outline: pblOutline,
allOutlines: [pblOutline],
content: structuredClone(legacyPBLSceneFixture.content),
}),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.scene.actions).toHaveLength(1);
expect(mocks.generateSceneActions.mock.calls[0][1]).toMatchObject({
type: 'pbl',
projectV2: { title: legacyPBLSceneFixture.title },
});
expect(mocks.buildCompleteScene.mock.calls[0][1]).toBe(
mocks.generateSceneActions.mock.calls[0][1],
);
});
it('builds damaged hybrid PBL content from the upgraded legacy project', async () => {
vi.resetModules();
mocks.generateSceneActions.mockResolvedValue([
{ id: 'action-1', type: 'speech', title: 'Welcome', text: 'Let us continue.' },
]);
mocks.buildCompleteScene.mockImplementation((_outline, content, actions, stageId) => {
if (!('projectV2' in content)) return null;
return {
id: 'scene-pbl-1',
stageId,
type: 'pbl',
title: pblOutline.title,
order: pblOutline.order,
content: { type: 'pbl', projectV2: content.projectV2 },
actions,
};
});
const damagedHybrid = structuredClone(legacyPBLSceneFixture.content);
Reflect.set(damagedHybrid, 'projectV2', { title: 'broken' });
const { POST } = await import('@/app/api/generate/scene-actions/route');
const response = await POST(
mockRequest({
outline: pblOutline,
allOutlines: [pblOutline],
content: damagedHybrid,
}),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.scene.content.projectV2).toMatchObject({
title: 'Community Garden Data Project',
milestones: [{ title: 'Inspect the measurements' }, { title: 'Recommend a watering plan' }],
});
expect(body.scene.content.projectV2).not.toEqual({ title: 'broken' });
expect(mocks.buildCompleteScene.mock.calls[0][1]).toBe(
mocks.generateSceneActions.mock.calls[0][1],
);
});
it('keeps a title-only legacy shell on the existing empty-content error path', async () => {
vi.resetModules();
mocks.generateSceneActions.mockResolvedValue([]);
mocks.buildCompleteScene.mockImplementation((_outline, content) =>
'projectV2' in content ? { content } : null,
);
const legacyContent = structuredClone(legacyPBLSceneFixture.content);
if (legacyContent.type !== 'pbl' || !legacyContent.projectConfig) {
throw new Error('expected legacy PBL content');
}
const projectConfig = legacyContent.projectConfig;
projectConfig.agents = [];
projectConfig.issueboard.issues = [];
projectConfig.issueboard.current_issue_id = null;
projectConfig.chat.messages = [];
projectConfig.selectedRole = null;
const { POST } = await import('@/app/api/generate/scene-actions/route');
const response = await POST(
mockRequest({
outline: pblOutline,
allOutlines: [pblOutline],
content: { type: 'pbl', projectConfig },
}),
);
const body = await response.json();
expect(response.status).toBe(500);
expect(body).toMatchObject({
success: false,
error: 'Failed to build scene: Community Garden Data Project',
});
expect(mocks.generateSceneActions.mock.calls[0][1]).toEqual({ type: 'pbl', projectConfig });
});
it('preserves an upstream 401 from the scene-content route', async () => {
vi.resetModules();
const unauthorized = Object.assign(new Error('provider key rejected'), { statusCode: 401 });
mocks.generateSceneContent.mockImplementation(async (_outline, aiCall) => {
await aiCall('system', 'user');
return { elements: [], remark: 'ok' };
});
mocks.callLLM.mockRejectedValueOnce(unauthorized);
const { POST } = await import('@/app/api/generate/scene-content/route');
const response = await POST(mockRequest());
const body = await response.json();
expect(response.status).toBe(401);
expect(body).toMatchObject({
success: false,
errorCode: 'UPSTREAM_ERROR',
error: 'Upstream authentication or authorization failed.',
});
});
it('preserves an upstream 503 from the scene-content route', async () => {
vi.resetModules();
const unavailable = Object.assign(new Error('provider overloaded'), { statusCode: 503 });
mocks.generateSceneContent.mockImplementation(async (_outline, aiCall) => {
await aiCall('system', 'user');
return { elements: [], remark: 'ok' };
});
mocks.callLLM.mockRejectedValueOnce(unavailable);
const { POST } = await import('@/app/api/generate/scene-content/route');
const response = await POST(mockRequest());
const body = await response.json();
expect(response.status).toBe(503);
expect(body).toMatchObject({
success: false,
errorCode: 'UPSTREAM_ERROR',
error: 'Upstream model provider is temporarily unavailable. Please try again.',
});
});
it('preserves the provider status carried by a PBLGenerationError', async () => {
vi.resetModules();
const providerError = Object.assign(new Error('provider rate limited'), { statusCode: 429 });
const { PBLGenerationError } = await import('@openmaic/generation');
mocks.generateSceneContent.mockRejectedValueOnce(
new PBLGenerationError('PBL planners failed', {
cause: providerError,
statusCode: 429,
}),
);
const { POST } = await import('@/app/api/generate/scene-content/route');
const response = await POST(mockRequest({ outline: pblOutline, allOutlines: [pblOutline] }));
const body = await response.json();
expect(response.status).toBe(429);
expect(body).toMatchObject({
success: false,
errorCode: 'RATE_LIMITED',
error: 'Upstream rate limit reached. Please try again shortly.',
});
});
it('preserves an upstream 401 from the scene-actions route', async () => {
vi.resetModules();
const unauthorized = Object.assign(new Error('provider key rejected'), { statusCode: 401 });
mocks.generateSceneActions.mockImplementation(async (_outline, _content, aiCall) => {
await aiCall('system', 'user');
return [];
});
mocks.callLLM.mockRejectedValueOnce(unauthorized);
const { POST } = await import('@/app/api/generate/scene-actions/route');
const response = await POST(mockRequest({ content: { elements: [], remark: 'ok' } }));
const body = await response.json();
expect(response.status).toBe(401);
expect(body).toMatchObject({
success: false,
errorCode: 'UPSTREAM_ERROR',
error: 'Upstream authentication or authorization failed.',
});
});
it('preserves an upstream 503 from the scene-actions route', async () => {
vi.resetModules();
const unavailable = Object.assign(new Error('provider overloaded'), { statusCode: 503 });
mocks.generateSceneActions.mockImplementation(async (_outline, _content, aiCall) => {
await aiCall('system', 'user');
return [];
});
mocks.callLLM.mockRejectedValueOnce(unavailable);
const { POST } = await import('@/app/api/generate/scene-actions/route');
const response = await POST(mockRequest({ content: { elements: [], remark: 'ok' } }));
const body = await response.json();
expect(response.status).toBe(503);
expect(body).toMatchObject({
success: false,
errorCode: 'UPSTREAM_ERROR',
error: 'Upstream model provider is temporarily unavailable. Please try again.',
});
});
});
function mockRequest(extraBody: Record<string, unknown> = {}) {
return {
json: async () => ({
outline,
allOutlines: [outline],
stageId: 'stage-1',
stageInfo: { name: 'Retry Course' },
...extraBody,
}),
} as unknown as Parameters<typeof import('@/app/api/generate/scene-content/route').POST>[0];
}