/** * 输出不成形的就地重问(2026-08-03 用户实测送来的单逼出来的)。 * * 病:关思考的模型偶发把一拍输出写坏(整串行动一个都不认识),从前一次没认出 * 就直接发「需要你判断下一步」的兜底卡,还把 JSON 原话整段摆在卡面上 —— * 人打开工单看到的是一坨代码(用户原话:「卡片里又是乱七八糟的东西」)。 * * 法:第一次没认出 → 原文经 `note` 存进工作日志(内部痕),附「只输出 JSON」 * 就地再问一次;第二次仍不成形才交人,卡面只说人话、不摆原话。 * 这里用假网关钉死三件事:重问只有一次、原文进痕、好输出不白重问。 * (ModelGateway 是外界 —— LLM 厂商 —— 的缝,录制回放替身合规。) */ import { describe, expect, it } from 'vitest'; import type { EnabledSet } from '@modules/loop-engine'; import type { ModelCall, ModelGateway, ModelResult } from '@modules/llm'; import type { TaskId } from '@contracts'; import { createAgentRunner } from '../agent-runner.ts'; const ENABLED: EnabledSet = { entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺记录'], activeStages: [], }; /** 按序回放给定出参的假网关 —— 超出名单就重复最后一个 */ function gatewayOf(outputs: Array<{ object: unknown; text: string }>): { gateway: ModelGateway; calls: ModelCall[] } { const calls: ModelCall[] = []; const gateway: ModelGateway = { mode: 'replay', resolve: () => Promise.reject(new Error('测试里用不到 resolve')), generate: (call) => { calls.push(call); const next = outputs[Math.min(calls.length - 1, outputs.length - 1)]; if (next === undefined) return Promise.reject(new Error('名单空着')); const result: ModelResult = { text: next.text, object: next.object, usage: { inputTokens: 10, outputTokens: 5, latencyMs: 1 }, binding: { name: 'fake', provider: 'deepseek', model: 'fake' }, mode: 'replay', }; return Promise.resolve(result); }, withMode: () => gateway, }; return { gateway, calls }; } function runnerOf(outputs: Array<{ object: unknown; text: string }>) { const { gateway, calls } = gatewayOf(outputs); const notes: Array<{ title: string; raw: string }> = []; const runner = createAgentRunner({ gateway, modelOf: () => Promise.resolve('fake'), onUsage: () => Promise.resolve(), note: (_taskId, title, raw) => { notes.push({ title, raw }); return Promise.resolve(); }, }); const decide = () => runner.decide({ taskId: 'task-1' as TaskId, narrative: '(空)', enabled: ENABLED, scope: {}, writable: [], tools: [], }); return { decide, calls, notes }; } const GOOD = { object: { say: '收工', actions: [{ kind: 'finish', itemId: 'T1' }] }, text: '{"say":"收工"}' }; const BAD = { object: { say: '', actions: [{ kind: '胡编的' }] }, text: '再连贯的散文也不是行动串' }; describe('输出不成形先就地重问,别一抖就叫人', () => { it('第一拍坏、重问好 → 两次调用、原文进痕一条、返回的是好行动', async () => { const { decide, calls, notes } = runnerOf([BAD, GOOD]); const turn = await decide(); expect(calls).toHaveLength(2); // 重问那一次要把「只输出 JSON」的硬提示带上 —— 不带等于原样再撞一次 expect(calls[1]?.prompt).toContain('不是合法的行动 JSON'); expect(notes).toHaveLength(1); expect(notes[0]?.raw).toContain('散文'); expect(turn.actions[0]?.kind).toBe('finish'); }); it('两拍都坏 → 就收手交人,不无限重问;卡面不摆模型原话', async () => { const { decide, calls } = runnerOf([BAD, BAD]); const turn = await decide(); expect(calls).toHaveLength(2); const action = turn.actions[0]; expect(action?.kind).toBe('human'); if (action?.kind !== 'human') return; expect(action.card['modelReply']).toBeUndefined(); expect(String(action.card['reason'])).toContain('工作日志'); }); it('第一拍就是好的 → 只调一次,不白烧重问', async () => { const { decide, calls, notes } = runnerOf([GOOD]); const turn = await decide(); expect(calls).toHaveLength(1); expect(notes).toHaveLength(0); expect(turn.actions[0]?.kind).toBe('finish'); }); });