项目文件夹

文件
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 行
6.0 KiB
TypeScript

/**
* 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);
});
});