This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/__tests__/agent-action-normalize.test.ts
T
ChaiHaoandClaude Opus 5 ade0fa5a94 A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 ——
契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、
入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、
三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。

基座(共享面,改一处影响所有活动,故每处都带回归):
· 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」,
  于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断;
· 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒;
· 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面;
· 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具;
· 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松);
· 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。

A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、
卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。

契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml;
api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了,
本次予以恢复而非提交删除。

门禁:typecheck / lint / depcruise / test(1366)全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:28:31 +08:00

223 lines
9.5 KiB
TypeScript

/**
* 模型出参归一 —— **宽进到什么程度**,这里说死。
*
* 每一条宽进都对应一次实测踩坑:模型把字段叫成另一个名字、把地址整串塞进一格、
* 把行数组写成半个形状。这些差异吃掉的是一整拍(有的还白发一张卡给人),
* 而它们全都不涉及「行动合不合规」—— 合规照旧由引擎拒。
*
* 反过来也要守住:看不懂就转人,**不许猜着往世界上动手**。
*/
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<string, unknown>) => {
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<string, unknown>) => 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<string, unknown>;
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<string, unknown>)['steps'])).toContain('年用量');
});
it('连问句都没有:兜一个标题,不吃掉整拍', () => {
const a = norm({});
expect(a.kind).toBe('human');
if (a.kind !== 'human') return;
expect(String((a.card as Record<string, unknown>)['title'])).not.toBe('');
});
});
/**
* 验收报告的**可修订字段** —— PRD:「一张工单 → 卡组一(HTML 报告 · AI 已填好、可编辑)」。
*
* 两样都得有才落得下去:`ai` 是「已填好」的那个值,`ledger` 是改完落回哪个台账地址。
* 少了 ai 就只能看不能改;少了 ledger,人改了也只留在工单留痕里,落地用的还是活动的原值。
*/
describe('提交放行带的可修订字段', () => {
const fieldsOf = (raw: Record<string, unknown>) => {
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);
});
});