/** * 模型出参归一 —— **宽进到什么程度**,这里说死。 * * 每一条宽进都对应一次实测踩坑:模型把字段叫成另一个名字、把地址整串塞进一格、 * 把行数组写成半个形状。这些差异吃掉的是一整拍(有的还白发一张卡给人), * 而它们全都不涉及「行动合不合规」—— 合规照旧由引擎拒。 * * 反过来也要守住:看不懂就转人,**不许猜着往世界上动手**。 */ import { describe, expect, it } from 'vitest'; import type { EnabledSet } from '@modules/loop-engine'; import { normalizeAction, normalizeTurn } from '../agent-runner.ts'; const ENABLED: EnabledSet = { entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺记录'], activeStages: [], }; describe('行动型的叫法', () => { it('`kind` 也认 `action` —— 实测模型这么写过,其余字段一字不差', () => { const action = normalizeAction( { action: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] }, ENABLED, ); expect(action.kind).toBe('write'); }); it('两个都给时以 `kind` 为准 —— 契约字段优先,别名只是兜底', () => { expect(normalizeAction({ kind: 'submit', action: 'write', rationale: 'r' }, ENABLED).kind).toBe('submit'); }); }); describe('台账地址的写法', () => { const rows = [{ key: { pn: 'X' }, patch: { eau: 1 } }]; const addressOf = (raw: Record) => { const action = normalizeAction({ kind: 'write', itemId: 'T1', rows, ...raw }, ENABLED); return action.kind === 'write' ? action.address : '(不是 write)'; }; it('规范写法:address 一个字符串', () => { expect(addressOf({ address: 'quote_intake.record' })).toBe('quote_intake.record'); }); it('整串塞进 object 里(实测第一拍就这么错)', () => { expect(addressOf({ object: 'quote_intake.record' })).toBe('quote_intake.record'); }); it('整串塞进 domain 里', () => { expect(addressOf({ domain: 'quote_intake.record' })).toBe('quote_intake.record'); }); it('老老实实拆成两格也认', () => { expect(addressOf({ domain: 'quote_intake', object: 'record' })).toBe('quote_intake.record'); }); it('什么都没给 → 空地址,交引擎当场拒(不猜)', () => { expect(addressOf({})).toBe(''); }); }); describe('写行的形状', () => { const rowsOf = (raw: unknown) => { const action = normalizeAction({ kind: 'write', itemId: 'T1', address: 'd.o', rows: raw }, ENABLED); return action.kind === 'write' ? action.rows : []; }; it('形状不对的行**丢掉而不是猜** —— 猜错就往台账落一行来路不明的数', () => { expect(rowsOf([{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null, 7])).toEqual([ { key: { pn: 'X' }, patch: { eau: 1 } }, ]); }); it('主键值归一为字符串或数字 —— 键要能稳定比对', () => { expect(rowsOf([{ key: { pn: 'X', qty: 3, flag: true }, patch: {} }])).toEqual([ { key: { pn: 'X', qty: 3, flag: 'true' }, patch: {} }, ]); }); }); describe('看不懂就转人,不猜着动手', () => { it('行动型不认识 → 发执行卡,卡面只说人话 —— JSON 原话归工作日志,不上卡', () => { const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED); expect(action.kind).toBe('human'); if (action.kind !== 'human') return; // 2026-08-03 实测:原话上卡,人看到的是一坨代码(「卡片里又是乱七八糟的东西」) expect(action.card['modelReply']).toBeUndefined(); expect(String(action.card['reason'])).toContain('工作日志'); expect(action.card['kindSeen']).toBe('我自己想的行动'); }); it('立监听没给时限 → 兜底 P3D,绝不留幽灵等待', () => { const action = normalizeAction({ kind: 'listen', itemId: 'T1', eventType: 'data' }, ENABLED); expect(action.kind === 'listen' && action.deadline).toBe('P3D'); }); }); /** * **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,执行卡完全就是 * 包含补全语义的人类工具」)—— 归一层把旧输出里的 `ask` 原地转成零输入执行工单: * 问句上卡,字段名并进指导书(人补在工作区的件上),`fields` 不上卡。 * 拒回只会让模型换着花样再发,归一才是把路铺平。 */ describe('补全卡退役:ask 原地转成零输入执行工单', () => { const norm = (raw: Record) => normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED); it('转成 human + execution,问句在 ask,字段名进指导书', () => { const a = norm({ ask: '缺交期,补一下', fields: [{ key: 'lead_time', label: '交期' }] }); expect(a.kind).toBe('human'); if (a.kind !== 'human') return; expect(a.workOrder).toBe('execution'); const card = a.card as Record; expect(card['ask']).toBe('缺交期,补一下'); expect(JSON.stringify(card['steps'])).toContain('交期'); // 零输入:fields 不上卡 expect(card['fields']).toBeUndefined(); }); it('fields 落在 card 里也认(旧模型这么写过)—— 一样进指导书', () => { const a = norm({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } }); expect(a.kind).toBe('human'); if (a.kind !== 'human') return; expect(JSON.stringify((a.card as Record)['steps'])).toContain('年用量'); }); it('连问句都没有:兜一个标题,不吃掉整拍', () => { const a = norm({}); expect(a.kind).toBe('human'); if (a.kind !== 'human') return; expect(String((a.card as Record)['title'])).not.toBe(''); }); }); /** * 验收报告的**可修订字段** —— PRD:「一张工单 → 卡组一(HTML 报告 · AI 已填好、可编辑)」。 * * 两样都得有才落得下去:`ai` 是「已填好」的那个值,`ledger` 是改完落回哪个台账地址。 * 少了 ai 就只能看不能改;少了 ledger,人改了也只留在工单留痕里,落地用的还是活动的原值。 */ describe('提交放行带的可修订字段', () => { const fieldsOf = (raw: Record) => { const a = normalizeAction({ kind: 'submit', rationale: 'r', ...raw }, ENABLED); return a.kind === 'submit' ? (a.fields ?? []) : []; }; it('带 ai 与 ledger 一起过来', () => { expect( fieldsOf({ fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }] }), ).toEqual([{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }]); }); it('落在 card 里也认', () => { expect(fieldsOf({ card: { fields: [{ key: 'x', label: 'X', ai: '1' }] } })).toEqual([ { key: 'x', label: 'X', ai: '1' }, ]); }); it('**不替它补 ledger** —— 猜出来的地址会把值写到别的对象上', () => { expect(fieldsOf({ fields: [{ key: 'x', label: 'X', ai: '1' }] })[0]).not.toHaveProperty('ledger'); }); it('没给字段就不带 —— 验收报告只看不改也是一种合法形态', () => { const a = normalizeAction({ kind: 'submit', rationale: 'r' }, ENABLED); expect(a.kind === 'submit' && a.fields).toBeUndefined(); }); }); /** * 一拍出参归一 —— 三种来路都认:{actions:[…]}(规范形)、裸行动对象(旧形)、顶层数组。 * 串里不认识的项丢掉;整串一个都不认识才退化为单张转人卡 —— * 不能让串中途一个写坏的项给人甩一张卡。 */ describe('一拍一串行动的归一', () => { it('规范形:actions 数组 + 拍级 say', () => { const turn = normalizeTurn( { say: '登记完毕,顺手收场', tag: 'conclusion', actions: [ { kind: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] }, { kind: 'finish', itemId: 'T1', rationale: '齐了' }, ], }, ENABLED, ); expect(turn.actions.map((a) => a.kind)).toEqual(['write', 'finish']); expect(turn.say?.text).toBe('登记完毕,顺手收场'); }); it('旧形:裸行动对象包成单元素串 —— 换了拍形,旧模型输出不作废', () => { const turn = normalizeTurn({ kind: 'submit', rationale: 'r', say: '交卷' }, ENABLED); expect(turn.actions.map((a) => a.kind)).toEqual(['submit']); expect(turn.say?.text).toBe('交卷'); }); it('串里的垃圾项丢掉,认识的留下 —— 不在批中途甩卡', () => { const turn = normalizeTurn( { actions: [{ kind: '胡编的' }, { kind: 'finish', itemId: 'T1' }, null, 7] }, ENABLED, ); expect(turn.actions.map((a) => a.kind)).toEqual(['finish']); }); it('整串一个都不认识 → 标 unrecognized(decide 先就地重问)+ 单张转人卡兜底', () => { const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED); expect(turn.actions).toHaveLength(1); expect(turn.actions[0]?.kind).toBe('human'); expect(turn.unrecognized).toBe(true); }); it('认出了行动就不标 unrecognized —— 别把好拍拿去白重问一遍', () => { const turn = normalizeTurn({ actions: [{ kind: 'finish', itemId: 'T1' }] }, ENABLED); expect(turn.unrecognized).toBeUndefined(); }); it('超出封顶截到 6 个 —— 引擎侧还会再守一道', () => { const many = Array.from({ length: 9 }, (_, i) => ({ kind: 'finish', itemId: `T${i}` })); expect(normalizeTurn({ actions: many }, ENABLED).actions).toHaveLength(6); }); });