/** * v0.8.0 P0-5: 引擎流结束契约测试 —— "模型思考着会话停止"根治回归防线。 * * 钉住 chatStreamWithRetry 的流结束契约(P0-2)与 finish_reason 贯通(P0-1): * 1. 零产出流 → EmptyResponseError → 退避重试 → 重试耗尽 ERROR; * 2. reasoning-only + finish_reason=length → 一次性降级重试(thinkingEnabled=false) * → 降级后成功 COMPLETED; * 3. 降级重试仍 length → OutputTruncatedError → ERROR 事件携带 * MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED; * 4. 有正文 + length → COMPLETED 且最终 DONE 事件携带 finishReason='length'; * 5. 引擎最终 DONE 贯通最后一轮 step.finishReason。 */ import { describe, it, expect } from 'vitest'; import { AgentLoopEngine } from '../engine'; import type { AgentLoopOutput } from '../types'; import type { MetonaRequest, MetonaStreamEvent, IMetonaProviderAdapter, MetonaResponse, } from '../../types'; import { MetonaStreamEventType, MetonaErrorCode } from '../../types'; const SYSTEM_PROMPT = { roleDefinition: 'test', outputConstraints: '', safetyGuidelines: '', }; function makeAdapter( script: (request: MetonaRequest) => MetonaStreamEvent[], ): IMetonaProviderAdapter & { calls: MetonaRequest[] } { const calls: MetonaRequest[] = []; const adapter = { providerId: 'mock' as const, supportedModels: ['m'], supportsToolCalling: true, supportsThinking: true, calls, getContextWindow(): number { return 128_000; }, async send(_request: MetonaRequest): Promise { throw new Error('send() not expected in this test'); }, async *sendStream(request: MetonaRequest): AsyncIterable { calls.push(request); for (const ev of script(request)) { yield ev; } }, }; return adapter as unknown as IMetonaProviderAdapter & { calls: MetonaRequest[] }; } function makeEngine(adapter: IMetonaProviderAdapter, retryCount = 1): AgentLoopEngine { return new AgentLoopEngine( { maxIterations: 3, totalTimeoutMs: 10_000, retryCount, thinkingEnabled: true, thinkingEffort: 'high', contextWindow: 128_000, }, adapter, ); } function textDelta(t: string): MetonaStreamEvent { return { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r', sessionId: 's', iteration: 1, seq: 0, timestamp: Date.now(), delta: t, }; } function reasoningDelta(t: string): MetonaStreamEvent { return { type: MetonaStreamEventType.REASONING_DELTA, requestId: 'r', sessionId: 's', iteration: 1, seq: 0, timestamp: Date.now(), delta: t, }; } function doneEvent(finishReason?: string): MetonaStreamEvent { return { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: 's', iteration: 1, seq: 0, timestamp: Date.now(), ...(finishReason ? { finishReason } : {}), }; } describe('engine stream-end contract (v0.8.0 P0-2)', () => { it('scenario 2: reasoning-only + length → one degraded retry with thinking disabled → COMPLETED', async () => { let call = 0; const adapter = makeAdapter((req) => { call++; if (call === 1) { // 第一次:思考烧满输出预算(reasoning-only + length 截断) expect(req.params.thinkingEnabled).toBe(true); return [reasoningDelta('thinking...'), doneEvent('length')]; } // 降级重试:thinkingEnabled 必须已被引擎置 false expect(req.params.thinkingEnabled).toBe(false); return [textDelta('answer'), doneEvent('stop')]; }); const engine = makeEngine(adapter); const events: MetonaStreamEvent[] = []; engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e)); const output = await engine.runStream( { role: 'user', content: 'hi', timestamp: Date.now() }, 's1', [], SYSTEM_PROMPT, ); expect(output.terminationReason).toBe('completed'); expect(output.finalAnswer).toBe('answer'); expect(adapter.calls.length).toBe(2); // 降级重试经过 RETRY 事件(引擎消费后向渲染层转 STREAM_RESET) expect(events.some((e) => e.type === MetonaStreamEventType.STREAM_RESET)).toBe(true); }); it('scenario 2b: degraded retry still length → OUTPUT_LENGTH_EXCEEDED error (no infinite retry)', async () => { const adapter = makeAdapter(() => [reasoningDelta('thinking...'), doneEvent('length')]); const engine = makeEngine(adapter); const events: MetonaStreamEvent[] = []; engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e)); const output: AgentLoopOutput = await engine.runStream( { role: 'user', content: 'hi', timestamp: Date.now() }, 's1', [], SYSTEM_PROMPT, ); // 恰好两次调用(原始 + 一次性降级重试),随后终止 expect(adapter.calls.length).toBe(2); expect(output.terminationReason).toBe('error'); const errorEvent = events.find((e) => e.type === MetonaStreamEventType.ERROR); expect(errorEvent).toBeDefined(); expect(errorEvent?.error?.code).toBe(MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED); }); it('scenario 1: zero-content stream → retryable → retry exhausted → ERROR', async () => { const adapter = makeAdapter(() => []); const engine = makeEngine(adapter, 1); const events: MetonaStreamEvent[] = []; engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e)); const output = await engine.runStream( { role: 'user', content: 'hi', timestamp: Date.now() }, 's1', [], SYSTEM_PROMPT, ); expect(output.terminationReason).toBe('error'); expect(adapter.calls.length).toBe(2); // 原始 1 次 + 重试 1 次(retryCount=1) const errorEvent = events.find((e) => e.type === MetonaStreamEventType.ERROR); expect(errorEvent?.error?.message).toContain('empty stream'); }); it('scenario 4: text + length → COMPLETED with DONE.finishReason=length (no retry)', async () => { const adapter = makeAdapter(() => [textDelta('partial answer'), doneEvent('length')]); const engine = makeEngine(adapter); const events: MetonaStreamEvent[] = []; engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e)); const output = await engine.runStream( { role: 'user', content: 'hi', timestamp: Date.now() }, 's1', [], SYSTEM_PROMPT, ); expect(output.terminationReason).toBe('completed'); expect(output.finalAnswer).toBe('partial answer'); expect(adapter.calls.length).toBe(1); // 有产出不重试 const done = events.find((e) => e.type === MetonaStreamEventType.DONE); expect(done?.finishReason).toBe('length'); }); it('P0-1: normal stop → DONE carries finishReason=stop from adapter', async () => { const adapter = makeAdapter(() => [textDelta('ok'), doneEvent('stop')]); const engine = makeEngine(adapter); const events: MetonaStreamEvent[] = []; engine.on('streamEvent', (e: MetonaStreamEvent) => events.push(e)); const output = await engine.runStream( { role: 'user', content: 'hi', timestamp: Date.now() }, 's1', [], SYSTEM_PROMPT, ); expect(output.terminationReason).toBe('completed'); const done = events.find((e) => e.type === MetonaStreamEventType.DONE); expect(done?.finishReason).toBe('stop'); }); it('reasoning-only + natural stop (no length) → retryable empty response path', async () => { const adapter = makeAdapter(() => [reasoningDelta('hmm'), doneEvent('stop')]); const engine = makeEngine(adapter, 1); const output = await engine.runStream( { role: 'user', content: 'hi', timestamp: Date.now() }, 's1', [], SYSTEM_PROMPT, ); // 无产出且非 length 截断 → EmptyResponseError(可重试)→ 重试耗尽 ERROR expect(output.terminationReason).toBe('error'); expect(adapter.calls.length).toBe(2); }); });