Files
thu-maic--openmaic/tests/pbl/v2/instructor-empty-output.test.ts
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

174 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Instructor — empty-output fallback fires on a "silent dead turn" (reviewer #593, point 1).
*
* The reviewer flagged that suppressing the empty-output error on ANY tool call
* was too broad: a turn that only called an internal bookkeeping tool, with no
* acknowledgment text, left the learner with NOTHING — no chat bubble, no
* error, no retry. The page just sat there.
*
* Fix under test (flow level): the empty-output error is keyed on real
* user-perceivable output (scenario auto-completion, committed text, or a
* difficulty ack), not on "a tool was called", so a genuinely silent turn
* surfaces the retry fallback instead of dead air.
*
* Note on ordering: the client aborts the whole SSE stream on the first `error`
* frame (assertNotStreamError). Emitting the empty error before a later patch
* would drop that patch on the client, so it is emitted after project patches.
*/
import { describe, expect, it } from 'vitest';
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
import { runInstructorTurn } from '@/lib/pbl/v2/agents/instructor';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
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_TOOLS = {
type: 'finish' as const,
finishReason: { unified: 'tool-calls' as const, raw: 'tool-calls' },
usage: USAGE,
};
function toolCallStep(toolName: string, input: Record<string, unknown>): StreamPart[] {
return [
{ type: 'stream-start', warnings: [] },
{ type: 'tool-call', toolCallId: `tc-${toolName}`, toolName, input: JSON.stringify(input) },
FINISH_TOOLS,
];
}
function scriptedModel(steps: StreamPart[][]): MockLanguageModelV3 {
let i = 0;
const fallback: StreamPart[] = [
{ type: 'stream-start', warnings: [] },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: USAGE },
];
return new MockLanguageModelV3({
doStream: async () => ({ stream: convertArrayToReadableStream(steps[i++] ?? fallback) }),
});
}
function makeProject(): PBLProjectV2 {
const now = '2026-06-10T00:00:00.000Z';
return {
uiPhase: 'workspace',
title: 'Build a HashMap Playground',
description: 'A small interactive HashMap tool.',
learningObjective: 'Learn HashMap operations by building a toy tool.',
proficiency: 'intermediate',
language: 'zh-CN',
tags: ['hashmap'],
status: 'active',
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
milestones: [
{
id: 'ms-1',
title: 'Model the core HashMap behavior',
order: 0,
status: 'active',
microtasks: [
{
id: 'mt-1',
title: 'Implement lookup',
description: 'Use a key to find the right bucket and return the value.',
status: 'in_progress',
assignee: 'user',
hints: [],
order: 0,
},
],
documents: [],
},
],
submissions: [],
evaluations: [],
threads: [{ agentId: 'role-i', messages: [] }],
engagementEvents: [],
createdAt: now,
updatedAt: now,
};
}
async function runTurn(model: MockLanguageModelV3, userMessage: string): Promise<PBLSSEEvent[]> {
const events: PBLSSEEvent[] = [];
for await (const ev of runInstructorTurn({
project: makeProject(),
userMessage,
phase: 'instructing',
languageModel: model as never,
})) {
events.push(ev);
}
return events;
}
function committedMessages(events: PBLSSEEvent[]): string[] {
return events
.filter(
(e): e is Extract<PBLSSEEvent, { type: 'project_patch' }> =>
e.type === 'project_patch' && e.patch.kind === 'message',
)
.map((e) => (e.patch as { message: { content: string } }).message.content);
}
function emptyOutputIndex(events: PBLSSEEvent[]): number {
return events.findIndex(
(e) => e.type === 'error' && (e as { code?: string }).code === 'EMPTY_LLM_OUTPUT',
);
}
describe('Instructor — empty-output fallback on a silent dead turn (#593 point 1)', () => {
it('reports EMPTY_LLM_OUTPUT when record_observation ran and the model wrote no text', async () => {
// record_observation is internal bookkeeping. No text, no scenario
// auto-completion, no difficulty ack → previously the `toolCalled` guard
// swallowed the error and the learner saw nothing.
const events = await runTurn(
scriptedModel([
toolCallStep('record_observation', {
kind: 'question',
signature: 'lookup_question',
label: 'lookup question',
}),
]),
'这里为什么要先算 hash',
);
expect(emptyOutputIndex(events)).toBeGreaterThanOrEqual(0);
// Nothing user-facing was committed.
expect(committedMessages(events)).toHaveLength(0);
});
it('emits the empty-output error LAST (after any project_patch frame) so the client cannot drop a later patch', async () => {
const events = await runTurn(
scriptedModel([
toolCallStep('record_observation', {
kind: 'question',
signature: 'lookup_question',
label: 'lookup question',
}),
]),
'这里为什么要先算 hash',
);
const errIdx = emptyOutputIndex(events);
expect(errIdx).toBeGreaterThanOrEqual(0);
const lastPatchIdx = events.reduce((acc, e, i) => (e.type === 'project_patch' ? i : acc), -1);
// The empty-output error must come after the last project_patch (if any).
expect(errIdx).toBeGreaterThan(lastPatchIdx);
});
it('still reports empty output on a totally silent turn (no tool, no text)', async () => {
const events = await runTurn(scriptedModel([]), '在吗');
expect(emptyOutputIndex(events)).toBeGreaterThanOrEqual(0);
});
});