Files
openmaic/OpenMAIC/tests/lib/agent/tools/regenerate-scene-actions.test.ts
2026-08-16 14:58:47 +08:00

238 lines
9.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@openmaic/generation', () => ({
generateSceneActions: vi.fn(async () => [{ type: 'speech', id: 'a1', title: 'hi', text: 'hi' }]),
}));
import {
makeRegenerateSceneActionsTool,
type SceneContext,
} from '@/lib/agent/tools/regenerate-scene-actions';
import { generateSceneActions } from '@openmaic/generation';
import type { SlideContent } from '@/lib/types/stage';
import type { PPTElement, Slide } from '@openmaic/dsl';
import { legacyPBLSceneFixture } from '@/tests/fixtures/pbl-v1-scene';
/** Minimal SceneOutline stub */
const stubOutline = (id: string, title: string, order = 1) => ({
id,
type: 'slide' as const,
title,
description: '',
keyPoints: [],
order,
});
const stubPblOutline = (id: string, title: string, order = 1) => ({
id,
type: 'pbl' as const,
title,
description: '',
keyPoints: [],
order,
pblConfig: {
projectTopic: title,
projectDescription: '',
targetSkills: [],
issueCount: 2,
},
});
/** Minimal SlideContent stub — runtime DSL shape with canvas wrapping elements */
const stubSlideContent: SlideContent = {
type: 'slide',
canvas: {
id: 'slide1',
viewportSize: 1000,
viewportRatio: 0.5625,
elements: [
{
id: 'el1',
type: 'text',
content: 'Hello',
left: 0,
top: 0,
width: 100,
height: 50,
} as unknown as PPTElement,
],
background: { type: 'solid', color: '#ffffff' },
} as unknown as Slide,
};
/** Build a deps object with a single scene context entry */
function makeDeps(sceneId: string, extra?: Partial<SceneContext>) {
const ctx: SceneContext = {
outline: stubOutline(sceneId, 'T'),
allOutlines: [stubOutline(sceneId, 'T')],
content: stubSlideContent,
stageId: 'stage1',
...extra,
};
return {
aiCall: async () => '',
getSceneContext: (id: string) => (id === sceneId ? ctx : undefined),
};
}
const mockGen = vi.mocked(generateSceneActions);
describe('regenerate_scene_actions', () => {
beforeEach(() => {
mockGen.mockReset();
mockGen.mockResolvedValue([{ type: 'speech', id: 'a1', title: 'hi', text: 'hi' } as never]);
});
it('returns regenerated actions for the scene in details', async () => {
const tool = makeRegenerateSceneActionsTool(makeDeps('s1'));
const res = await tool.execute('tc1', { sceneId: 's1' });
expect(res.details).toMatchObject({ sceneId: 's1' });
expect(Array.isArray((res.details as { actions: unknown[] }).actions)).toBe(true);
});
// ── Bug 1 regression: SlideContent shape conversion ──────────────────────────
// The runtime scene stores SlideContent = { type:'slide', canvas: Slide }
// but generateSceneActions expects GeneratedSlideContent = { elements, background? }.
// Without conversion, 'elements' in content is false → returns [] immediately.
it('converts SlideContent (runtime) to GeneratedSlideContent before calling the generator', async () => {
const tool = makeRegenerateSceneActionsTool(makeDeps('s1'));
await tool.execute('tc-shape', { sceneId: 's1' });
expect(mockGen).toHaveBeenCalledOnce();
const [, passedContent] = mockGen.mock.lastCall ?? [];
// The generator must receive the flattened shape with 'elements' at top level
expect(passedContent).toHaveProperty('elements');
expect(Array.isArray((passedContent as { elements: unknown }).elements)).toBe(true);
// Must NOT receive the raw canvas wrapper
expect(passedContent).not.toHaveProperty('canvas');
expect(passedContent).not.toHaveProperty('type');
});
it('upgrades legacy-only PBL content before calling the generator', async () => {
const content = structuredClone(legacyPBLSceneFixture.content);
if (content.type !== 'pbl') throw new Error('expected legacy PBL content');
const tool = makeRegenerateSceneActionsTool(
makeDeps('legacy-pbl', {
outline: stubPblOutline('legacy-pbl', legacyPBLSceneFixture.title),
allOutlines: [stubPblOutline('legacy-pbl', legacyPBLSceneFixture.title)],
content,
}),
);
await tool.execute('tc-legacy-pbl', { sceneId: 'legacy-pbl' });
expect(mockGen).toHaveBeenCalledOnce();
const [, passedContent] = mockGen.mock.lastCall ?? [];
expect(passedContent).toMatchObject({
type: 'pbl',
projectV2: { title: 'Community Garden Data Project' },
});
});
it('upgrades damaged hybrid PBL content from the usable legacy project', async () => {
const content = structuredClone(legacyPBLSceneFixture.content);
if (content.type !== 'pbl') throw new Error('expected legacy PBL content');
Reflect.set(content, 'projectV2', { title: 'broken' });
const tool = makeRegenerateSceneActionsTool(
makeDeps('damaged-hybrid-pbl', {
outline: stubPblOutline('damaged-hybrid-pbl', legacyPBLSceneFixture.title),
allOutlines: [stubPblOutline('damaged-hybrid-pbl', legacyPBLSceneFixture.title)],
content,
}),
);
await tool.execute('tc-damaged-hybrid-pbl', { sceneId: 'damaged-hybrid-pbl' });
expect(mockGen).toHaveBeenCalledOnce();
const [, passedContent] = mockGen.mock.lastCall ?? [];
expect(passedContent).toMatchObject({
type: 'pbl',
projectV2: {
title: 'Community Garden Data Project',
milestones: [{ title: 'Inspect the measurements' }, { title: 'Recommend a watering plan' }],
},
});
expect(passedContent).not.toMatchObject({ projectV2: { title: 'broken' } });
});
it('leaves a title-only legacy shell unnormalized for downstream empty handling', async () => {
const content = structuredClone(legacyPBLSceneFixture.content);
if (content.type !== 'pbl' || !content.projectConfig) {
throw new Error('expected legacy PBL content');
}
content.projectConfig.agents = [];
content.projectConfig.issueboard.issues = [];
content.projectConfig.issueboard.current_issue_id = null;
content.projectConfig.chat.messages = [];
content.projectConfig.selectedRole = null;
const tool = makeRegenerateSceneActionsTool(
makeDeps('title-only-pbl', {
outline: stubPblOutline('title-only-pbl', legacyPBLSceneFixture.title),
allOutlines: [stubPblOutline('title-only-pbl', legacyPBLSceneFixture.title)],
content,
}),
);
await tool.execute('tc-title-only-pbl', { sceneId: 'title-only-pbl' });
const [, passedContent] = mockGen.mock.lastCall ?? [];
expect(passedContent).toEqual(content);
expect(passedContent).not.toHaveProperty('projectV2');
});
it('includes the action count in the content text', async () => {
const multiCtx: SceneContext = {
outline: stubOutline('s2', 'Quiz', 2),
allOutlines: [stubOutline('s1', 'T', 1), stubOutline('s2', 'Quiz', 2)],
content: { type: 'quiz', questions: [] },
stageId: 'stage1',
};
const deps = {
aiCall: async () => '',
getSceneContext: (id: string) => (id === 's2' ? multiCtx : undefined),
};
const tool = makeRegenerateSceneActionsTool(deps);
const res = await tool.execute('tc2', { sceneId: 's2' });
expect(res.content[0].type).toBe('text');
expect((res.content[0] as { type: string; text: string }).text).toContain('1');
});
it('passes previousSpeeches to the generator', async () => {
const tool = makeRegenerateSceneActionsTool(makeDeps('s1'));
await tool.execute('tc3', { sceneId: 's1', previousSpeeches: ['hello'] });
expect(mockGen).toHaveBeenCalledOnce();
// Verify via the last call's options arg (4th positional param)
const [, , , options] = mockGen.mock.lastCall ?? [];
expect(options?.ctx?.previousSpeeches).toEqual(['hello']);
});
it('returns an error result when sceneId is not in the context map', async () => {
const tool = makeRegenerateSceneActionsTool(makeDeps('s1'));
const res = await tool.execute('tc4', { sceneId: 'unknown' });
// Should return isError and empty actions, not throw
expect((res as { isError?: boolean }).isError).toBe(true);
expect((res.details as { actions: unknown[] }).actions).toHaveLength(0);
});
// ── Bug 1 & Bug 2: empty generation is surfaced as an error ──────────────────
it('returns isError when generateSceneActions yields an empty array', async () => {
mockGen.mockResolvedValueOnce([]);
const tool = makeRegenerateSceneActionsTool(makeDeps('s1'));
const res = await tool.execute('tc-empty', { sceneId: 's1' });
expect((res as { isError?: boolean }).isError).toBe(true);
expect((res.details as { actions: unknown[] }).actions).toHaveLength(0);
const text = (res.content[0] as { type: string; text: string }).text;
expect(text).toMatch(/no actions|no action|0 action/i);
});
it('tool has expected metadata', () => {
const tool = makeRegenerateSceneActionsTool(makeDeps('s1'));
expect(tool.name).toBe('regenerate_scene_actions');
expect(typeof tool.label).toBe('string');
expect(typeof tool.description).toBe('string');
});
});