070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
158 lines
5.4 KiB
TypeScript
158 lines
5.4 KiB
TypeScript
import { promises as fsp } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { distillAnnotationsToMemory } from '../src/memory-llm.js';
|
|
import {
|
|
memoryDir,
|
|
readMemoryEntry,
|
|
readMemoryIndex,
|
|
writeMemoryConfig,
|
|
memoryEvents,
|
|
} from '../src/memory.js';
|
|
import { __resetExtractionsForTests } from '../src/memory-extractions.js';
|
|
|
|
const dataDir = path.join(
|
|
process.env.OD_DATA_DIR ?? process.cwd(),
|
|
'memory-annotations-test',
|
|
);
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
// Helper: stub the OpenAI chat-completions response the distiller's provider
|
|
// path will hit (the extraction override below routes through callOpenAI).
|
|
function mockOpenAiEntries(entries: unknown[]): void {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
choices: [{ message: { content: JSON.stringify({ entries }) } }],
|
|
}),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
),
|
|
) as typeof fetch;
|
|
}
|
|
|
|
describe('annotation → memory distillation', () => {
|
|
beforeEach(async () => {
|
|
await fsp.rm(memoryDir(dataDir), { recursive: true, force: true });
|
|
__resetExtractionsForTests();
|
|
await writeMemoryConfig(dataDir, {
|
|
extraction: { provider: 'openai', apiKey: 'sk-test', model: 'gpt-4o-mini' },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('auto-keeps feedback + rule memory from preview comments, no manual Keep', async () => {
|
|
mockOpenAiEntries([
|
|
{
|
|
type: 'rule',
|
|
name: 'Primary buttons use brand green',
|
|
description: 'Standing colour rule for primary actions',
|
|
body: 'Assertion: Primary action buttons use the brand green.\nCheck: Inspect primary buttons and confirm their background is the brand green token.',
|
|
},
|
|
{
|
|
type: 'feedback',
|
|
name: 'Minimal decoration',
|
|
description: 'Keep visuals calm',
|
|
body: 'The user dislikes busy, over-decorated layouts and prefers at most two accent colors.',
|
|
},
|
|
]);
|
|
|
|
const events: Array<{ kind?: string; source?: string; count?: number }> = [];
|
|
const onChange = (e: { kind?: string; source?: string; count?: number }) =>
|
|
events.push(e);
|
|
memoryEvents.on('change', onChange);
|
|
|
|
let written;
|
|
try {
|
|
written = await distillAnnotationsToMemory(
|
|
dataDir,
|
|
{
|
|
annotations: [
|
|
{
|
|
comment: '这个按钮太花了,主按钮应该用品牌绿',
|
|
label: 'Hero CTA button',
|
|
currentText: 'Get started',
|
|
selectionKind: 'element',
|
|
},
|
|
],
|
|
userMessage: '调整一下首屏',
|
|
},
|
|
{ projectRoot: process.cwd() },
|
|
);
|
|
} finally {
|
|
memoryEvents.off('change', onChange);
|
|
}
|
|
|
|
expect(written).toHaveLength(2);
|
|
|
|
// Both entries land on disk (auto-keep) — no Keep round-trip.
|
|
const rule = await readMemoryEntry(dataDir, 'rule_primary_buttons_use_brand_green');
|
|
const feedback = await readMemoryEntry(dataDir, 'feedback_minimal_decoration');
|
|
expect(rule?.type).toBe('rule');
|
|
expect(rule?.body).toContain('Assertion:');
|
|
expect(feedback?.type).toBe('feedback');
|
|
|
|
// Linked into MEMORY.md so they actually get injected / enforced.
|
|
const index = await readMemoryIndex(dataDir);
|
|
expect(index).toContain('rule_primary_buttons_use_brand_green.md');
|
|
expect(index).toContain('feedback_minimal_decoration.md');
|
|
|
|
// The non-blocking toast signal carries the annotation source.
|
|
const extract = events.find((e) => e.kind === 'extract');
|
|
expect(extract).toMatchObject({ source: 'annotation', count: 2 });
|
|
|
|
// The model saw the distiller system prompt and the user's comment.
|
|
const fetchMock = vi.mocked(globalThis.fetch);
|
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
const body = JSON.parse(String((init as RequestInit)?.body));
|
|
expect(body.messages[0].content).toContain('memory distiller');
|
|
expect(body.messages[1].content).toContain('主按钮应该用品牌绿');
|
|
});
|
|
|
|
it('skips annotations that carry no comment (no provider call)', async () => {
|
|
mockOpenAiEntries([]);
|
|
const written = await distillAnnotationsToMemory(
|
|
dataDir,
|
|
{ annotations: [{ comment: '', label: 'A card', selectionKind: 'element' }] },
|
|
{ projectRoot: process.cwd() },
|
|
);
|
|
expect(written).toEqual([]);
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drops non-feedback/rule candidates the model returns from a critique', async () => {
|
|
mockOpenAiEntries([
|
|
{
|
|
type: 'project',
|
|
name: 'Redesign sprint',
|
|
description: 'transient',
|
|
body: 'A one-off project note that should not come from a design critique.',
|
|
},
|
|
{
|
|
type: 'feedback',
|
|
name: 'Denser hierarchy',
|
|
description: 'prefers density',
|
|
body: 'The user prefers higher information density with a clear visual hierarchy.',
|
|
},
|
|
]);
|
|
|
|
const written = await distillAnnotationsToMemory(
|
|
dataDir,
|
|
{
|
|
annotations: [
|
|
{ comment: 'too sparse, pack more in', label: 'Section', selectionKind: 'element' },
|
|
],
|
|
},
|
|
{ projectRoot: process.cwd() },
|
|
);
|
|
|
|
expect(written).toHaveLength(1);
|
|
expect(written[0]).toMatchObject({ type: 'feedback', name: 'Denser hierarchy' });
|
|
await expect(readMemoryEntry(dataDir, 'project_redesign_sprint')).resolves.toBeNull();
|
|
});
|
|
});
|