feat: update Makelore modules and conversations
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-07-31 10:08:41 +08:00
parent b8ca3f8eea
commit 80e8386fa6
175 changed files with 8036 additions and 10581 deletions

View File

@@ -1,264 +1,133 @@
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { mkdtemp, readFile, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProjectConfig, projectTemplates, validateAgentNames } from '../../shared/project-config';
import { createInitialProjectConfig, normalizeProjectConfig, readProjectConfig, writeProjectConfig } from '../../electron/opencode/project-config';
import {
createProjectConfig,
validateAgentConfigs,
type ProjectAgentConfig,
} from '../../shared/project-config';
import {
createInitialProjectConfig,
normalizeProjectConfig,
readProjectConfig,
writeProjectConfig,
} from '../../electron/opencode/project-config';
describe('project-owned configuration', () => {
it('exposes the replacement templates with four standard and five game Agents', () => {
expect(projectTemplates.map((template) => template.id)).toEqual(['standard-development', 'game-development']);
expect(projectTemplates.find((template) => template.id === 'standard-development')?.agents).toHaveLength(4);
const gameAgents = projectTemplates.find((template) => template.id === 'game-development')?.agents ?? [];
expect(gameAgents).toHaveLength(5);
expect(gameAgents[gameAgents.length - 1]).toMatchObject({ id: 'game-promotion', roleName: '运营宣传角色' });
expect(projectTemplates.flatMap((template) => template.agents).every((agent) => agent.builtIn)).toBe(true);
expect(projectTemplates.flatMap((template) => template.agents).every((agent) => agent.skillIds.includes('youth-plain-language'))).toBe(true);
});
function createContact(overrides: Partial<ProjectAgentConfig> = {}): ProjectAgentConfig {
const now = '2026-07-11T00:00:00.000Z';
return {
id: 'agent-test',
avatarId: 'avatar-01',
roleName: '项目伙伴',
name: '小明',
builtIn: false,
enabled: true,
model: 'openai/gpt-4o-mini',
skillIds: [],
responsibility: {
mission: '把用户的想法整理成清晰的下一步。',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: now,
updatedAt: now,
...overrides,
};
}
it('creates an incomplete config and validates unique required names', () => {
const config = createProjectConfig('standard-development', '2026-07-11T00:00:00.000Z');
expect(config.initialized).toBe(false);
expect(config.superpowersEnabled).toBe(true);
expect(config.knowledgeDirectory).toBe('knowledge');
expect(validateAgentNames(config.agents)).toContain('product-planning:name-required');
config.agents.forEach((agent, index) => { agent.name = `伙伴${index + 1}`; });
expect(validateAgentNames(config.agents)).toEqual([]);
config.agents[1]!.name = config.agents[0]!.name;
expect(validateAgentNames(config.agents)).toContain(`${config.agents[1]!.id}:name-duplicate`);
const custom = { ...structuredClone(config.agents[0]!), id: 'custom-agent', builtIn: false, name: '' };
expect(validateAgentNames([...config.agents, custom])).toContain('custom-agent:name-required');
});
it('uses template defaults for Superpowers and restores them when the field is absent', () => {
const gameConfig = createProjectConfig('game-development');
expect(gameConfig.superpowersEnabled).toBe(false);
expect(normalizeProjectConfig({ ...gameConfig, superpowersEnabled: undefined })).toMatchObject({
templateId: 'game-development',
describe('project-owned contact configuration', () => {
it('creates an empty, template-free project config', () => {
const config = createProjectConfig('2026-07-11T00:00:00.000Z');
expect(config).toMatchObject({
initialized: false,
superpowersEnabled: false,
agents: [],
knowledgeDirectory: 'knowledge',
});
expect('templateId' in config).toBe(false);
});
it('requires a unique name, preset avatar, model, and responsibility for every contact', () => {
expect(validateAgentConfigs([])).toEqual([]);
expect(validateAgentConfigs([createContact({ name: '' })])).toContain('agent-test:name-required');
expect(validateAgentConfigs([createContact({ avatarId: 'upload-me' })])).toContain('agent-test:avatar-required');
expect(validateAgentConfigs([createContact({ model: null })])).toContain('agent-test:model-required');
expect(validateAgentConfigs([createContact({ responsibility: { mission: '', owns: [], boundaries: [], collaborators: [], principles: [] } })])).toContain('agent-test:responsibility-required');
expect(validateAgentConfigs([
createContact(),
createContact({ id: 'agent-test-2', name: '小明' }),
])).toContain('agent-test-2:name-duplicate');
expect(validateAgentConfigs([createContact()])).toEqual([]);
});
it('normalizes a manually configured contact without injecting skills or a built-in role', () => {
const config = createProjectConfig();
const normalized = normalizeProjectConfig({
...config,
initialized: true,
agents: [createContact()],
superpowersEnabled: undefined,
});
expect(normalized).toMatchObject({
superpowersEnabled: false,
agents: [{ builtIn: false, prompt: '', skillIds: [], model: 'openai/gpt-4o-mini' }],
});
expect('templateId' in normalized).toBe(false);
});
it('preserves the legacy built-in marker when reading an existing project', () => {
const config = createProjectConfig();
const normalized = normalizeProjectConfig({
...config,
initialized: true,
agents: [createContact({ builtIn: true })],
});
const standardConfig = createProjectConfig('standard-development');
expect(standardConfig.superpowersEnabled).toBe(true);
expect(normalizeProjectConfig({ ...standardConfig, superpowersEnabled: undefined })).toMatchObject({
templateId: 'standard-development',
superpowersEnabled: true,
expect(normalized.agents[0]?.builtIn).toBe(true);
});
it('materializes a custom contact with its exact model and prompt', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-config-'));
const initial = await createInitialProjectConfig(projectPath);
const saved = await writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createContact()],
});
expect(saved.agents).toHaveLength(1);
const markdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'agent-test.md'), 'utf8');
expect(markdown).toContain('model: "openai/gpt-4o-mini"');
expect(markdown).toContain('description: "小明"');
expect(markdown).toContain('把用户的想法整理成清晰的下一步');
expect(markdown).not.toContain('youth-plain-language: allow');
expect(markdown).toContain('"*": deny');
});
it('materializes project-only runtime prompts after initialization', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-'));
const initial = await createInitialProjectConfig(projectPath, 'game-development');
initial.agents.forEach((agent, index) => { agent.name = `游戏伙伴${index + 1}`; });
initial.agents[1]!.enabled = false;
expect((await readProjectConfig(projectPath)).status).toBe('valid');
const saved = await writeProjectConfig(projectPath, { ...initial, initialized: true });
expect(saved.initialized).toBe(true);
expect(saved.agents.every((agent) => agent.enabled)).toBe(true);
const markdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'game-art.md'), 'utf8');
const worksSquareIndex = markdown.indexOf('https://square.nianxx.cn/api/assets');
const kenneyIndex = markdown.indexOf('Kenney');
expect(worksSquareIndex).toBeGreaterThanOrEqual(0);
expect(kenneyIndex).toBeGreaterThan(worksSquareIndex);
expect(markdown).toContain('limit');
expect(markdown).toContain('offset');
expect(markdown).toContain('# 游戏伙伴2 · 游戏素材');
expect(markdown).toContain('你的名字是「游戏伙伴2」');
expect(markdown).toContain('Kenney');
expect(markdown).toContain('OpenGameArt');
expect(markdown).toContain('ASSET_PLAN.md');
expect(markdown).toContain('ART_GUIDE.md');
expect(markdown).toContain('项目目录是所有 Agent 共享的长期上下文');
expect(markdown).toContain('聊天里的口头总结不能代替项目产物');
expect(markdown).toContain('缺少上游产物时不要把它当作阶段门禁');
expect(markdown).toContain('不依赖自动派发或跨会话消息');
expect(markdown).not.toContain('selectionStatus');
expect(markdown).not.toContain('必须停止');
expect(markdown).toContain('game-design');
expect(markdown).toContain('knowledge/');
expect(markdown).toContain('nianxxgame-skill: allow');
expect(markdown).toContain('game-assets: allow');
expect(markdown).toContain('bash: allow');
expect(markdown).toContain('youth-plain-language: allow');
expect(markdown).toContain('10 至 16 岁青少年能直接看懂');
expect(markdown).toContain('每次开始工作前读取 `VERSION.md` 和 `TASKS.md`');
expect(markdown).toContain('所有 Agent 共同维护的当前版本任务中枢');
expect(markdown).toContain('Document Revision');
expect(markdown).toContain('只能在 `TASKS.md` 提议 PATCH、MINOR 或 MAJOR');
const version = await readFile(path.join(projectPath, 'VERSION.md'), 'utf8');
const tasks = await readFile(path.join(projectPath, 'TASKS.md'), 'utf8');
const gdd = await readFile(path.join(projectPath, 'GDD.md'), 'utf8');
const assetPlan = await readFile(path.join(projectPath, 'ASSET_PLAN.md'), 'utf8');
const artGuide = await readFile(path.join(projectPath, 'ART_GUIDE.md'), 'utf8');
expect(version).toContain('Current: v0.1.0');
expect(version).toContain('Version Owner: game-design');
expect(version).toContain('Status: active');
expect(tasks).toContain('Project Version: v0.1.0');
expect(tasks).toContain('所有 Agent 共同读取和维护本文件');
expect(tasks).toContain('TASK-002 建立素材清单和接入策略');
expect(tasks).toContain('TASK-003 确认 2D/3D 渲染路径');
expect(tasks).toContain('## Version Proposals');
expect(gdd).toContain('首个可试玩结果');
expect(gdd).toContain('2D默认或 3D');
const techStack = await readFile(path.join(projectPath, 'TECH_STACK.md'), 'utf8');
expect(assetPlan).toContain('game-assets Skill');
expect(assetPlan).toContain('config-status');
expect(assetPlan).toContain('assets/generated/meowa/<run>/');
expect(assetPlan).toContain('generation.meta.json');
expect(assetPlan).toContain('"assets": []');
expect(assetPlan).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/);
expect(assetPlan).toContain('不把 2D 生成图伪装成 3D 模型');
expect(assetPlan).toContain('GLB/glTF');
expect(techStack).toContain('Three.js + TypeScript');
expect(techStack).toContain('3D 模型不走 Meowa 生成链路');
expect(artGuide).toContain('nearest-neighbor');
expect(artGuide).toContain('GLB/glTF');
expect(artGuide).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度不足/);
expect(await readdir(path.join(projectPath, 'assets'))).toEqual(expect.arrayContaining(['generated', 'review-previews']));
expect(await readdir(path.join(projectPath, 'assets', 'generated'))).toContain('meowa');
expect(await readdir(path.join(projectPath, 'assets'))).toContain('models');
expect(await readdir(path.join(projectPath, 'public'))).toContain('assets');
expect(await readdir(path.join(projectPath, 'public'))).toContain('models');
const ownerMarkdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'game-design.md'), 'utf8');
expect(ownerMarkdown).toContain('## 版本负责人权限');
expect(ownerMarkdown).toContain('只归档本版本变化的文档');
expect(ownerMarkdown).toContain('未完成任务带来源迁移到下一版本');
const promotionMarkdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'game-promotion.md'), 'utf8');
expect(promotionMarkdown).toContain('# 游戏伙伴5 · 运营宣传角色');
expect(promotionMarkdown).toContain('PRODUCT_OVERVIEW.md');
expect(promotionMarkdown).toContain('不是投放排期、预算表或项目任务计划');
expect(promotionMarkdown).toContain('marketing-launch-story: allow');
});
it('gives every game Agent explicit shared-artifact inputs and owned outputs', () => {
const agents = createProjectConfig('game-development').agents;
const promptById = Object.fromEntries(agents.map((agent) => [agent.id, agent.prompt]));
const releaseAgent = agents.find((agent) => agent.id === 'game-test-release');
const promotionAgent = agents.find((agent) => agent.id === 'game-promotion');
expect(releaseAgent?.skillIds).toContain('deploy-publish-check');
expect(promotionAgent?.skillIds).toEqual(expect.arrayContaining(['nianxxgame-skill', 'marketing-launch-story', 'youth-plain-language']));
expect(promptById['game-design']).toContain('每次策划工作都更新 GDD.md');
expect(promptById['game-design']).toContain('2D/3D 渲染选择');
expect(promptById['game-design']).toContain('读取 TECH_STACK.md');
expect(promptById['game-design']).toContain('不直接运行 game-assets 或请求 Meowa');
expect(promptById['game-art']).toContain('每次素材工作都更新 ASSET_PLAN.md');
expect(promptById['game-art']).toContain('game-assets');
expect(promptById['game-art']).toContain('唯一直接调用 game-assets 的 Agent');
expect(promptById['game-art']).toContain('读取 TECH_STACK.md');
expect(promptById['game-art']).toContain('3D 模型改走项目、用户或许可证明确的外部来源');
expect(promptById['game-art']).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/);
expect(promptById['game-development']).toContain('每次开发工作都更新代码与 TASKS.md');
expect(promptById['game-development']).toContain('不直接运行 game-assets 或绕过素材审核');
expect(promptById['game-development']).toContain('2D Phaser 或 3D Three.js 路径');
expect(promptById['game-development']).toContain('不得把 3D 需求静默降级为 2D');
expect(promptById['game-test-release']).toContain('每次测试发布工作都更新 TEST_REPORT.md');
expect(promptById['game-test-release']).toContain('不为缺失素材自行调用 game-assets 或生成替代品');
expect(promptById['game-test-release']).toContain('按 TECH_STACK.md 检查 2D 或 3D 路径');
expect(promptById['game-promotion']).toContain('每次工作都更新 PRODUCT_OVERVIEW.md');
expect(promptById['game-promotion']).toContain('产品运营介绍');
expect(promptById['game-promotion']).toContain('不是投放排期、预算表或项目任务计划');
expect(promptById['game-promotion']).toContain('不自行宣称游戏已上线');
expect(promptById['game-promotion']).not.toContain('game-assets');
expect(promptById['game-promotion']).not.toContain('Meowa');
for (const prompt of Object.values(promptById)) {
expect(prompt).toContain('项目目录是所有 Agent 共享的长期上下文');
expect(prompt).toContain('聊天里的口头总结不能代替项目产物');
}
});
it('documents the Meowa handoff boundary in the bundled game Skills', async () => {
const gameSkill = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'nianxxgame-skill', 'SKILL.md'), 'utf8');
const assetSkill = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'game-assets', 'SKILL.md'), 'utf8');
const nituSource = await readFile(path.join(process.cwd(), 'src', 'pages', 'NiTu', 'index.tsx'), 'utf8');
expect(gameSkill).toContain('game-art` is the only role that directly uses');
expect(gameSkill).toContain('game-development` integrates only assets whose project-local review state is `approved`');
expect(gameSkill).toContain('The product-operations role only describes assets');
expect(gameSkill).toContain('Three.js with TypeScript');
expect(gameSkill).toContain('references/web3d-standards.md');
expect(gameSkill).toContain('Meowa `game-assets` currently generates 2D pixel/HD raster assets only');
expect(assetSkill).toContain('`game-art` 是唯一直接调用本 Skill 的 Agent');
expect(assetSkill).toContain('`game-promotion` 不属于素材生成链路');
expect(assetSkill).toContain('当前 Meowa 适配器只生成 2D 像素/高清光栅素材,不生成 3D 模型');
expect(assetSkill).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/);
expect(nituSource).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度/);
});
it('runs the bundled Meowa adapter with NianCode managed Python', async () => {
const assetSkill = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'game-assets', 'SKILL.md'), 'utf8');
const adapterGuide = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'game-assets', 'meowart_api.md'), 'utf8');
expect(assetSkill).toContain('"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI"');
expect(assetSkill).not.toMatch(/\bpython3\s+"\$MEOWA_GAME_ASSETS_CLI"/);
expect(adapterGuide).toContain('"$NIANCODE_PYTHON_PATH" meowart_api.py');
expect(adapterGuide).not.toMatch(/^python3 meowart_api\.py/gm);
expect(adapterGuide).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/);
});
it('does not backfill the promotion Agent into an existing saved game config', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-no-migration-'));
const initial = await createInitialProjectConfig(projectPath, 'game-development');
const legacyAgents = initial.agents.filter((agent) => agent.id !== 'game-promotion');
await writeProjectConfig(projectPath, { ...initial, agents: legacyAgents, initialized: false });
it('does not install default Agent files or template documents in a new project', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-config-empty-'));
const initial = await createInitialProjectConfig(projectPath);
const loaded = await readProjectConfig(projectPath);
expect(loaded.status).toBe('valid');
if (loaded.status !== 'valid') throw new Error('Expected a valid project config');
expect(loaded.config.agents.map((agent) => agent.id)).toEqual(['game-design', 'game-art', 'game-development', 'game-test-release']);
expect(loaded).toMatchObject({ status: 'valid', config: { agents: [], initialized: false } });
await expect(readdir(path.join(projectPath, '.opencode', 'agent'))).rejects.toMatchObject({ code: 'ENOENT' });
expect(initial.agents).toEqual([]);
expect(await readFile(path.join(projectPath, 'VERSION.md'), 'utf8')).toContain('Version Owner: user');
expect(await readFile(path.join(projectPath, 'TASKS.md'), 'utf8')).toContain('Owner: user');
});
it('does not backfill game starter documents when an existing project is saved', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-no-document-migration-'));
const initial = await createInitialProjectConfig(projectPath, 'game-development');
await rm(path.join(projectPath, 'ASSET_PLAN.md'));
await writeProjectConfig(projectPath, { ...initial, initialized: false });
await expect(readFile(path.join(projectPath, 'ASSET_PLAN.md'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
});
it('binds the deployment check Skill to standard and game release Agents', () => {
const standardRelease = createProjectConfig('standard-development').agents.find((agent) => agent.id === 'release-maintenance');
const gameRelease = createProjectConfig('game-development').agents.find((agent) => agent.id === 'game-test-release');
expect(standardRelease?.skillIds).toContain('deploy-publish-check');
expect(gameRelease?.skillIds).toContain('deploy-publish-check');
expect(standardRelease?.prompt).toContain('静态安全检查或真实执行失败时不允许云端提交');
expect(gameRelease?.prompt).toContain('真实失败输出 BLOCKED');
expect(standardRelease?.prompt).toContain('SKIPPED');
expect(gameRelease?.prompt).toContain('任何失败输出 BLOCKED');
expect(gameRelease?.prompt).toContain('SKIPPED');
});
it('refuses to initialize invalid built-in Agent names', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-invalid-'));
const initial = await createInitialProjectConfig(projectPath, 'standard-development');
await expect(writeProjectConfig(projectPath, { ...initial, initialized: true })).rejects.toThrow('invalid Agent names');
});
it('restores the required youth language Skill when saved config omits it', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-required-language-skill-'));
const initial = await createInitialProjectConfig(projectPath, 'standard-development');
initial.agents.forEach((agent, index) => {
agent.name = `通俗表达伙伴${index + 1}`;
agent.skillIds = [];
});
const saved = await writeProjectConfig(projectPath, { ...initial, initialized: true });
expect(saved.agents.every((agent) => agent.skillIds.includes('youth-plain-language'))).toBe(true);
expect(await readFile(path.join(projectPath, '.opencode', 'agent', 'development.md'), 'utf8')).toContain('youth-plain-language: allow');
});
it('uses product planning as the standard project version owner', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-standard-version-'));
const initial = await createInitialProjectConfig(projectPath, 'standard-development');
initial.agents.forEach((agent, index) => { agent.name = `标准伙伴${index + 1}`; });
await writeProjectConfig(projectPath, { ...initial, initialized: true });
expect(await readFile(path.join(projectPath, 'VERSION.md'), 'utf8')).toContain('Version Owner: product-planning');
expect(await readFile(path.join(projectPath, '.opencode', 'agent', 'product-planning.md'), 'utf8')).toContain('## 版本负责人权限');
expect(await readFile(path.join(projectPath, '.opencode', 'agent', 'development.md'), 'utf8')).not.toContain('## 版本负责人权限');
it('rejects an initialized contact with missing required basics at save time', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-config-invalid-'));
const initial = await createInitialProjectConfig(projectPath);
await expect(writeProjectConfig(projectPath, {
...initial,
initialized: true,
agents: [createContact({ model: null })],
})).rejects.toThrow('model-required');
});
});