Files
openmaic/OpenMAIC/tests/lib/orchestration/apply-generated-agents.test.ts
2026-08-16 14:58:47 +08:00

219 lines
8.0 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
// Give this module graph a working window.localStorage BEFORE the registry
// store is imported: the zustand persist middleware resolves its default
// storage from `window.localStorage` at store creation and disables itself
// (including the whole `persist` API) when that throws, which would make the
// persistence-exclusion behavior under test unreachable in the node env.
vi.hoisted(() => {
const backing = new Map<string, string>();
const localStorageStub: Storage = {
get length() {
return backing.size;
},
clear: () => backing.clear(),
getItem: (key: string) => backing.get(key) ?? null,
key: (index: number) => [...backing.keys()][index] ?? null,
removeItem: (key: string) => {
backing.delete(key);
},
setItem: (key: string, value: string) => {
backing.set(key, value);
},
};
Object.defineProperty(globalThis, 'localStorage', {
value: localStorageStub,
configurable: true,
});
Object.defineProperty(globalThis, 'window', {
value: globalThis,
configurable: true,
});
});
afterAll(() => {
// Globals are worker-scoped: undo the stubs so other files keep a clean env.
delete (globalThis as { window?: unknown }).window;
delete (globalThis as { localStorage?: unknown }).localStorage;
});
vi.mock('@/lib/audio/agent-voice', () => ({
warmUpAgentVoices: vi.fn(),
}));
import {
applyGeneratedAgentsToRegistry,
useAgentRegistry,
} from '@/lib/orchestration/registry/store';
import type { AgentConfig } from '@/lib/orchestration/registry/types';
import type { GeneratedAgentConfig } from '@/lib/types/stage';
function makeConfig(id: string, extra: Partial<GeneratedAgentConfig> = {}): GeneratedAgentConfig {
return {
id,
name: `Agent ${id}`,
role: 'teacher',
persona: 'Teach',
avatar: 'A',
color: '#000',
priority: 1,
...extra,
};
}
function generatedAgents() {
return useAgentRegistry
.getState()
.listAgents()
.filter((agent) => agent.isGenerated);
}
beforeEach(() => {
// Reset: drop any generated agents left over from a previous test.
applyGeneratedAgentsToRegistry('reset', []);
});
describe('applyGeneratedAgentsToRegistry', () => {
it('adds the roster as stage-bound generated agents and returns the ids', () => {
const voiceDesign = { identity: 'warm', texture: 'low', delivery: 'calm' };
const ids = applyGeneratedAgentsToRegistry('stage-1', [
makeConfig('gen-a', {
voiceDesign,
voiceConfig: { providerId: 'qwen-tts', voiceId: 'voice-1' },
}),
makeConfig('gen-b', { role: 'student' }),
]);
expect(ids).toEqual(['gen-a', 'gen-b']);
const agentA = useAgentRegistry.getState().getAgent('gen-a');
expect(agentA).toMatchObject({
isGenerated: true,
isDefault: false,
boundStageId: 'stage-1',
voiceDesign,
voiceConfig: { providerId: 'qwen-tts', voiceId: 'voice-1' },
});
const agentB = useAgentRegistry.getState().getAgent('gen-b');
expect(agentB?.voiceConfig).toBeUndefined();
// Role-based action grants: students get whiteboard-only actions.
expect(agentA?.allowedActions).toContain('spotlight');
expect(agentB?.allowedActions).not.toContain('spotlight');
});
it('accepts a custom-provider voice binding (open namespace)', () => {
applyGeneratedAgentsToRegistry('stage-1', [
makeConfig('gen-a', { voiceConfig: { providerId: 'custom-tts-mine', voiceId: 'v1' } }),
]);
expect(useAgentRegistry.getState().getAgent('gen-a')?.voiceConfig).toEqual({
providerId: 'custom-tts-mine',
voiceId: 'v1',
});
});
it('drops a voice binding whose provider is unknown, keeping the agent and its voiceDesign', () => {
// The contract keeps providerId an open string; a document (e.g. from an
// imported ZIP) can carry junk. It must not reach the TTS path via a cast.
const voiceDesign = { identity: 'warm', texture: 'low', delivery: 'calm' };
const ids = applyGeneratedAgentsToRegistry('stage-1', [
makeConfig('gen-a', {
voiceDesign,
voiceConfig: { providerId: 'definitely-not-a-tts-provider', voiceId: 'v1' },
}),
]);
expect(ids).toEqual(['gen-a']);
const agent = useAgentRegistry.getState().getAgent('gen-a');
expect(agent?.voiceConfig).toBeUndefined();
expect(agent?.voiceDesign).toEqual(voiceDesign);
});
it('drops a voice binding whose provider id is a prototype-chain key', () => {
// `'toString' in TTS_PROVIDERS` is true via Object.prototype; the guard
// must use own-property semantics or crafted zip manifests keep exactly
// the junk the whitelist was built to stop.
for (const junk of ['toString', 'constructor']) {
applyGeneratedAgentsToRegistry('stage-1', [
makeConfig('gen-a', { voiceConfig: { providerId: junk, voiceId: 'v1' } }),
]);
expect(useAgentRegistry.getState().getAgent('gen-a')?.voiceConfig).toBeUndefined();
}
});
it('replaces previously applied generated agents while keeping defaults', () => {
applyGeneratedAgentsToRegistry('stage-1', [makeConfig('gen-old')]);
applyGeneratedAgentsToRegistry('stage-2', [makeConfig('gen-new')]);
expect(useAgentRegistry.getState().getAgent('gen-old')).toBeUndefined();
expect(useAgentRegistry.getState().getAgent('gen-new')).toMatchObject({
boundStageId: 'stage-2',
});
expect(useAgentRegistry.getState().getAgent('default-1')?.isDefault).toBe(true);
});
it('clears generated agents when applying an empty roster', () => {
applyGeneratedAgentsToRegistry('stage-1', [makeConfig('gen-a')]);
const ids = applyGeneratedAgentsToRegistry('stage-2', []);
expect(ids).toEqual([]);
expect(generatedAgents()).toEqual([]);
expect(useAgentRegistry.getState().getAgent('default-1')).toBeDefined();
});
});
describe('registry persistence excludes generated agents', () => {
function makeCustomAgent(id: string): AgentConfig {
return {
id,
name: `Custom ${id}`,
role: 'student',
persona: 'Custom persona',
avatar: 'C',
color: '#111',
allowedActions: [],
priority: 1,
isDefault: false,
createdAt: new Date(0),
updatedAt: new Date(0),
};
}
it('serializes no generated agents to localStorage (single durable home)', () => {
useAgentRegistry.getState().addAgent(makeCustomAgent('custom-1'));
try {
applyGeneratedAgentsToRegistry('stage-1', [makeConfig('gen-a')]);
const raw = localStorage.getItem('agent-registry-storage');
expect(raw).not.toBeNull();
const persisted = JSON.parse(raw!) as { state: { agents: Record<string, AgentConfig> } };
// The roster's only durable home is the stage document.
expect(persisted.state.agents['gen-a']).toBeUndefined();
expect(Object.values(persisted.state.agents).some((agent) => agent.isGenerated)).toBe(false);
// Custom and default agents still persist.
expect(persisted.state.agents['custom-1']).toBeDefined();
expect(persisted.state.agents['default-1']).toBeDefined();
} finally {
useAgentRegistry.getState().deleteAgent('custom-1');
}
});
it('merge drops persisted generated agents on rehydration (defense in depth)', () => {
// Pre-partialize snapshots may still carry generated agents; the merge
// filter is the backstop that keeps them from leaking across sessions.
const { merge } = useAgentRegistry.persist.getOptions();
expect(merge).toBeDefined();
const persisted = {
agents: {
'gen-old': { ...makeCustomAgent('gen-old'), isGenerated: true },
'custom-1': makeCustomAgent('custom-1'),
'default-1': { ...makeCustomAgent('default-1'), name: 'stale cached teacher' },
},
};
const merged = merge!(persisted, useAgentRegistry.getState()) as {
agents: Record<string, AgentConfig>;
};
expect(merged.agents['gen-old']).toBeUndefined();
expect(merged.agents['custom-1']).toBeDefined();
// Default agents always use code-defined values, not the cached copy.
expect(merged.agents['default-1'].name).toBe('AI teacher');
});
});