项目文件夹

文件
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

253 行
9.2 KiB
TypeScript

/**
* Tests for the evaluator agent.
*
* Strategy mirrors planner.test.ts in PR 2: full LLM streaming
* (MockLanguageModelV3) is deferred to PR 8. PR 6.2 covers what we
* can test without the LLM:
*
* 1. Public API surface (the entry points the route imports).
* 2. Error paths when the project / milestone / microtask isn't
* found — the generator must yield an error + done, NOT throw.
*
* The persistence logic (`persistEvaluation` inside evaluator.ts) is
* already covered indirectly by the eval-tail-parser + eval-prompts
* tests in 6.1 — those exercise every normalize / build path. A
* dedicated wiring-with-fake-stream test will land in 6.7 once we
* write a small MockLanguageModelV3 helper for both Instructor and
* Evaluator together.
*/
import { describe, expect, it } from 'vitest';
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
import {
runFinalEvaluation,
runMilestoneEvaluation,
runTaskEvaluation,
} from '@/lib/pbl/v2/agents/evaluator';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import { addSubmission } from '@/lib/pbl/v2/operations/submission';
import type { PBLSSEEvent } from '@/lib/pbl/v2/api/sse';
type DoStreamConfig = NonNullable<
NonNullable<ConstructorParameters<typeof MockLanguageModelV3>[0]>['doStream']
>;
type StreamResult = Extract<DoStreamConfig, { stream: unknown }>;
type StreamPart = StreamResult['stream'] extends ReadableStream<infer P> ? P : never;
const USAGE = {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
};
const FINISH_STOP = {
type: 'finish' as const,
finishReason: { unified: 'stop' as const, raw: 'stop' },
usage: USAGE,
};
function textStep(text: string): StreamPart[] {
return [
{ type: 'stream-start', warnings: [] },
{ type: 'text-start', id: 'p1' },
{ type: 'text-delta', id: 'p1', delta: text },
{ type: 'text-end', id: 'p1' },
FINISH_STOP,
];
}
function scriptedModel(text: string): MockLanguageModelV3 {
return new MockLanguageModelV3({
doStream: async () => ({ stream: convertArrayToReadableStream(textStep(text)) }),
});
}
async function collectEvents(gen: AsyncGenerator<PBLSSEEvent, void, void>): Promise<PBLSSEEvent[]> {
const events: PBLSSEEvent[] = [];
for await (const ev of gen) events.push(ev);
return events;
}
function evaluationPatch(events: PBLSSEEvent[]) {
return events.find(
(ev): ev is Extract<PBLSSEEvent, { type: 'project_patch' }> =>
ev.type === 'project_patch' && ev.patch.kind === 'evaluation',
)?.patch;
}
function mkProject(): PBLProjectV2 {
return {
uiPhase: 'workspace',
title: 't',
description: 'd',
proficiency: 'intermediate',
language: 'zh-CN',
tags: [],
status: 'active',
roles: [],
milestones: [
{
id: 'ms1',
title: 'M1',
status: 'completed',
order: 0,
microtasks: [
{
id: 't1',
title: 'T1',
status: 'completed',
assignee: 'user',
hints: [],
order: 0,
},
],
documents: [],
},
],
submissions: [],
evaluations: [],
threads: [],
engagementEvents: [],
createdAt: 'ts',
updatedAt: 'ts',
};
}
describe('Evaluator agent — public API surface', () => {
it('exports three async-generator entry points', () => {
expect(typeof runTaskEvaluation).toBe('function');
expect(typeof runMilestoneEvaluation).toBe('function');
expect(typeof runFinalEvaluation).toBe('function');
});
});
describe('Evaluator agent — error paths (no LLM needed)', () => {
it('runTaskEvaluation yields NOT_FOUND when milestone is missing', async () => {
const gen = runTaskEvaluation({
project: mkProject(),
milestoneId: 'does-not-exist',
microtaskId: 't1',
languageModel: undefined as never,
});
const events: { type: string; code?: string }[] = [];
for await (const ev of gen) {
events.push(ev as { type: string; code?: string });
if (ev.type === 'done') break;
}
expect(events[0]?.type).toBe('error');
expect((events[0] as { code?: string }).code).toBe('NOT_FOUND');
expect(events[events.length - 1]?.type).toBe('done');
});
it('runTaskEvaluation yields NOT_FOUND when microtask is missing', async () => {
const gen = runTaskEvaluation({
project: mkProject(),
milestoneId: 'ms1',
microtaskId: 'nope',
languageModel: undefined as never,
});
const events: { type: string; code?: string }[] = [];
for await (const ev of gen) {
events.push(ev as { type: string; code?: string });
if (ev.type === 'done') break;
}
expect(events[0]?.type).toBe('error');
expect((events[0] as { code?: string }).code).toBe('NOT_FOUND');
});
it('runMilestoneEvaluation yields NOT_FOUND when milestone is missing', async () => {
const gen = runMilestoneEvaluation({
project: mkProject(),
milestoneId: 'nope',
languageModel: undefined as never,
});
const events: { type: string; code?: string }[] = [];
for await (const ev of gen) {
events.push(ev as { type: string; code?: string });
if (ev.type === 'done') break;
}
expect(events[0]?.type).toBe('error');
expect((events[0] as { code?: string }).code).toBe('NOT_FOUND');
});
});
describe('Evaluator agent — task JSON-only output', () => {
it('persists task feedback from JSON and does not stream raw JSON tokens', async () => {
const project = mkProject();
addSubmission(project, {
microtaskId: 't1',
milestoneId: 'ms1',
kind: 'text',
content: 'System.out.println(map.get("apple"));',
});
const model = scriptedModel(
'{"feedback":"这次提交展示了 HashMap 查询输出,已经达到继续要求。","strengths":["能用 key 查询值"],"improvements":["补充不存在 key 的样例"],"score":76}',
);
const events = await collectEvents(
runTaskEvaluation({
project,
milestoneId: 'ms1',
microtaskId: 't1',
languageModel: model,
}),
);
expect(events.some((ev) => ev.type === 'token')).toBe(false);
const patch = evaluationPatch(events);
expect(patch?.kind).toBe('evaluation');
if (patch?.kind !== 'evaluation') throw new Error('missing evaluation patch');
expect(patch.evaluation.feedback).toBe('这次提交展示了 HashMap 查询输出,已经达到继续要求。');
expect(patch.evaluation.strengths).toEqual(['能用 key 查询值']);
expect(patch.evaluation.improvements).toEqual(['补充不存在 key 的样例']);
expect(patch.evaluation.score).toBe(76);
});
});
describe('Evaluator agent — milestone/final JSON-only output', () => {
it('persists milestone feedback from JSON and does not stream raw JSON tokens', async () => {
const project = mkProject();
const model = scriptedModel(
'{"feedback":"这一阶段你完成了 HashMap 查询的核心练习,也能解释 key 如何找到对应值。点击 Continue 后再进入下一阶段。","learned":["用 key 查询对应值","用输出验证查询结果"],"performance":"你能把查询结果和任务目标对应起来。","stars":4.5}',
);
const events = await collectEvents(
runMilestoneEvaluation({
project,
milestoneId: 'ms1',
languageModel: model,
}),
);
expect(events.some((ev) => ev.type === 'token')).toBe(false);
const patch = evaluationPatch(events);
expect(patch?.kind).toBe('evaluation');
if (patch?.kind !== 'evaluation') throw new Error('missing evaluation patch');
expect(patch.evaluation.feedback).toContain('这一阶段你完成了 HashMap 查询');
expect(patch.evaluation.strengths).toEqual(['用 key 查询对应值', '用输出验证查询结果']);
expect(patch.evaluation.improvements).toEqual(['你能把查询结果和任务目标对应起来。']);
expect(patch.evaluation.stars).toBe(4.5);
});
it('persists final report feedback from JSON and does not stream raw JSON tokens', async () => {
const project = mkProject();
const model = scriptedModel(
'{"feedback":"你完成了 HashMap Playground,并把查询过程做成了可验证的小工具。整合检查里你把 key 到值的关系说清楚了,这是很好的收束。","stars":4.5,"what_you_built":["HashMap 查询小工具"],"what_you_learned":["用 key 找到对应值"],"whats_next":"下一步可以加入新增和删除键值对的功能。"}',
);
const events = await collectEvents(
runFinalEvaluation({
project,
languageModel: model,
}),
);
expect(events.some((ev) => ev.type === 'token')).toBe(false);
const patch = evaluationPatch(events);
expect(patch?.kind).toBe('evaluation');
if (patch?.kind !== 'evaluation') throw new Error('missing evaluation patch');
expect(patch.evaluation.feedback).toContain('你完成了 HashMap Playground');
expect(patch.evaluation.whatYouBuilt).toEqual(['HashMap 查询小工具']);
expect(patch.evaluation.whatYouLearned).toEqual(['用 key 找到对应值']);
expect(patch.evaluation.whatsNext).toBe('下一步可以加入新增和删除键值对的功能。');
expect(patch.evaluation.stars).toBe(4.5);
});
});