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/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts
T
xcsandClaude Opus 5 fbd5867206 feat(loop-engine): 拍首跑配方 —— 确定的活由引擎做完,剩下的才问模型
liveStepOf 从 turn-runner 提纯成纯函数(带 repetitionRule 的项收了仍 Enabled,
不跳过已收掉的就整单钉在第一步 —— 这条实测栽过)。
有卡在等人时配方照样跑但只算只写:人在件上按了保存,该铺的行就铺出来。
老活动一个字不用改:剧本里没有 auto,这一段整个不进。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 04:28:18 +08:00

2294 lines
109 KiB
TypeScript

/**
* loop 的「提交放行」三修(2026-07-28,Mastra 化前置):
* ① 提交/判齐 = 发**验收卡**挂起等裁决 —— 从前只返回 submitted,任务原地重跑到预算耗尽;
* ② onEvent **只记不跑** —— 从前自跑一拍、tick 再跑一拍,每次回传多耗一次不计预算的模型决策;
* ③ 任务自身最近轨迹喂回叙事面 —— 从前 Agent 每拍失忆。
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
import type { CaseRuntime } from '../internal/domain/lifecycle.ts';
const TASK = 't-1' as TaskId;
/** 空案例模型:必做项全无 + autoComplete → 一进 runTurn 就「判齐」 */
const READY_MODEL: CaseModel = { name: '空案', autoComplete: true, items: [], sentries: [] } as never;
const READY_RUNTIME: CaseRuntime = { items: [] } as never;
/** 一个未完成必做项:走 Agent 决断路径(叙事面测试用) */
const OPEN_MODEL: CaseModel = {
name: '有活没干完',
autoComplete: true,
items: [{ id: 'i1', name: '干活', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
sentries: [],
} as never;
const OPEN_RUNTIME: CaseRuntime = { items: [{ id: 'i1', state: 'Available' }] } as never;
type Harness = {
runner: ReturnType<typeof createTurnRunner>;
readonly issued: Array<{ workOrder: string; card: Record<string, unknown> }>;
readonly saves: Array<{ pending: PendingAcceptance | null | undefined }>;
readonly decided: string[];
readonly written: Array<{ domain: string; object: string; key: Record<string, unknown> }>;
readonly dropped: Array<{ domain: string; object: string; key: Record<string, unknown> }>;
readonly pushed: string[];
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
readonly writesAtPush: number[];
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
readonly appended: Array<{ kind: string; title: string }>;
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
readonly toolCalls: string[];
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
readonly listened: Array<{ eventType: string }>;
envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance };
};
function harness(
over: {
parsed?: CaseModel;
runtime?: CaseRuntime;
pending?: PendingAcceptance;
trail?: Array<{ kind: string; title: string; detail?: Record<string, unknown> }>;
scope?: Record<string, unknown>;
action?: Record<string, unknown>;
actions?: Array<Record<string, unknown>>;
/** 这个活动声明了哪些视图模版(没给就当它恰有 case-overview) */
templates?: string[];
/** 步骤剧本(没给就是「没写剧本的活动」那条老路) */
steps?: ReadonlyArray<Record<string, unknown>>;
/** 这一拍附带的那句交代 */
say?: { text: string; tag?: 'guidance' | 'criterion' | 'conclusion'; view?: string };
writeRejects?: string;
writeNoop?: boolean;
/** 撤行时草稿层里根本没有那一行(主键写法对不上) */
dropMisses?: boolean;
/** doneWhen 表达式求值的桩(不给就恒真,同旧行为) */
sfeelCondition?: (expr: string, scope: Record<string, unknown>) => boolean;
/** 动态任务视图:按「此刻已写下几行」现算 —— 测「先写后 finish 的 doneWhen 用当下值」要它 */
viewFn?: (written: Harness['written']) => Record<string, unknown>;
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
unanswered?: ReadonlyArray<{ id: string; title: string }>;
} = {},
): Harness {
const issued: Harness['issued'] = [];
const saves: Harness['saves'] = [];
const decided: string[] = [];
const written: Harness['written'] = [];
const dropped: Harness['dropped'] = [];
const pushed: string[] = [];
const writesAtPush: number[] = [];
const appended: Harness['appended'] = [];
const toolCalls: string[] = [];
const listened: Array<{ eventType: string }> = [];
const h: Harness = {
issued,
saves,
decided,
written,
dropped,
pushed,
writesAtPush,
appended,
toolCalls,
listened,
runner: undefined as never,
envelope: undefined as never,
};
h.envelope = {
model: { cmmnXml: '<xml/>', narrative: '说明书', ...(over.steps === undefined ? {} : { steps: over.steps }) },
parsed: over.parsed ?? READY_MODEL,
runtime: over.runtime ?? READY_RUNTIME,
...(over.pending === undefined ? {} : { pending: over.pending }),
};
const deps: TurnRunnerDeps = {
state: {
save: async (_t, model, parsed, runtime, pending) => {
saves.push({ pending });
h.envelope = { model, parsed, runtime, ...(pending == null ? {} : { pending }) };
},
load: async () => h.envelope as never,
},
sfeel: { condition: over.sfeelCondition ?? (() => true), expression: () => undefined } as never,
data: {
view: async () => (over.viewFn === undefined ? (over.scope ?? {}) : over.viewFn(written)),
writable: async () => [
{ domain: 'quote_intake', object: 'record', label: '统一受理记录', keys: ['pn'], fields: [{ name: 'eau', label: 'EAU' }] },
],
draftWrite: async (input: { domain: string; object: string; key: Record<string, unknown> }) => {
if (over.writeRejects !== undefined) throw new Error(over.writeRejects);
written.push({ domain: input.domain, object: input.object, key: input.key });
return { changed: over.writeNoop === true ? false : true };
},
draftDrop: async (input: { domain: string; object: string; key: Record<string, unknown> }) => {
dropped.push({ domain: input.domain, object: input.object, key: input.key });
return { dropped: over.dropMisses !== true };
},
} as never,
agent: {
decide: async (input: { narrative: string }) => {
decided.push(input.narrative);
return {
// 单行动的旧测试照跑:包成单元素串;要测批就传 over.actions
actions: over.actions ?? [over.action ?? { kind: 'submit', rationale: '我判齐了' }],
...(over.say === undefined ? {} : { say: over.say }),
} as never;
},
} as never,
analyze: { run: async () => ({ object: {} }) } as never,
tools: {
invoke: async (input: { tool: string }) => {
toolCalls.push(input.tool);
return { ok: true };
},
isWriteEffect: async () => false,
registerIntent: async () => undefined,
busOf: async () => [
{ id: 'crm.history-price-read', description: '读历史价', input: { type: 'object' }, effect: 'read' as const },
],
} as never,
cards: {
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
issued.push({ workOrder: input.workOrder, card: input.card });
return { id: `card-${issued.length}` };
},
ask: async (input: { ask: string; fields: ReadonlyArray<{ key: string }> }) => {
issued.push({ workOrder: 'elicitation', card: { ask: input.ask, fields: input.fields.map((f) => f.key) } });
return { id: `card-${issued.length}` };
},
decide: async (input: { ask: string; itemId?: string; variant?: string; checklist?: ReadonlyArray<string> }) => {
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
return { id: `card-${issued.length}` };
},
unanswered: async () => over.unanswered ?? [],
} as never,
listeners: {
register: async (input: { eventType: string }) => {
listened.push({ eventType: input.eventType });
return { id: 'lsn-1' };
},
declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }],
} as never,
memory: {
append: async (entry: { kind: string; title: string }) => {
appended.push({ kind: entry.kind, title: entry.title });
},
loadActivityMemory: async () => [],
window: async () => over.trail ?? [],
} as never,
views: {
push: async (input: { template: string }) => {
pushed.push(input.template);
// 推这一帧时已经写下了几行 —— 帧推早了就是 0(见「推在行动之后」那条)
writesAtPush.push(written.length);
return { pushed: (over.templates ?? ['case-overview']).includes(input.template), templates: over.templates ?? ['case-overview'] };
},
} as never,
skills: { index: async () => [], stamp: async () => [] } as never,
tasks: { of: async () => ({ taskId: TASK, activityId: 'a-1', ownerId: 's-1', humanCallCap: 9 }) } as never,
clock: { now: () => new Date('2026-07-28T00:00:00Z') } as never,
};
h.runner = createTurnRunner(deps);
return h;
}
describe('提交放行 —— 终局唯放行,卡只发一张', () => {
it('判齐即发验收卡挂起(不再返回 submitted 空转),在途验收入信封', async () => {
const h = harness();
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toEqual([
{ workOrder: 'acceptance', card: { title: '收口验收', basis: [{ k: '提交理由', v: '必做项全部完成,无进行中项' }] } },
]);
expect(h.envelope.pending?.acceptanceCardId).toBe('card-1');
expect(h.decided).toHaveLength(0); // 判齐不问 Agent
});
it('在途验收未裁决时再唤起:继续等,不发第二张卡', async () => {
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toHaveLength(0);
});
it('Agent 主动 submit 也走同一条路:发卡一张、状态 awaiting', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toHaveLength(1);
expect(h.issued[0]!.workOrder).toBe('acceptance');
});
/*
* 在途验收**必须活过这一拍**。信封的规矩是「pending 省参 = 清空」,
* 而回合末还有一次兜底 save —— 不打招呼就会把 raiseAcceptance 刚记下的在途验收擦掉。
* 实测踩过:人签了「通过」,任务纹丝不动,工位上多出第二张一模一样的验收单,
* 而且每拍再多一张 —— 人的裁决永远落在一张已被遗忘的卡上。
*/
it('主动 submit 后在途验收留在信封里 —— 擦掉它,人的裁决就永远落不到实处', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME });
await h.runner.runTurn(TASK);
expect(h.envelope.pending?.acceptanceCardId).toBe('card-1');
// 再唤起一拍:看见在途验收就继续等,不发第二张
const again = await h.runner.runTurn(TASK);
expect(again.status).toBe('awaiting');
expect(h.issued).toHaveLength(1);
});
it('裁决 pass → finished released,选口带回,信封清空', async () => {
const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'pass', selectedPorts: { 议价结果: 'won' } } });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('finished');
expect(outcome.released).toBe(true);
expect(outcome.selectedPorts).toEqual({ 议价结果: 'won' });
expect(h.saves.at(-1)?.pending).toBeNull();
});
/*
* **裁决非 pass = 打回重做,不是终局**(2026-08-22,柴浩实测:E04 一单点了「重做」,
* 引擎把在途验收消费成 null、任务重新挂回 awaiting-human —— 人已经说了话,
* 却再没有任何事件会把它叫醒,一单就此卡死)。重做的语义是「带着意见退回去再干一遍」:
* 意见用人话进轨迹、在途验收清掉,**同一拍照常问模型** —— 模型带着意见改成果、
* 改完重新 submit,新发的那张验收卡才是下一次裁决的对象。
*/
it('裁决 redo(非 pass)→ 不终局:意见进轨迹、清在途验收、同一拍照常问模型', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
pending: { acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次,优先级排得不对' },
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).not.toBe('finished');
expect(h.decided).toHaveLength(1);
// 在途验收第一时间清掉 —— 留着它,下一拍还会按「已裁决」再走一遍终局
expect(h.saves[0]?.pending).toBeNull();
expect(h.appended.some((e) => e.title.includes('打回重做') && e.title.includes('重做一次,优先级排得不对'))).toBe(true);
});
/*
* **打回重做之后,一个字没改就再提交 = 原样递回,当场拒**(2026-08-22,柴浩实测:
* 点「重做」写了「重做一下」,6 秒后同一份成果原样又递上来 —— 三条裁量判据重判一遍、
* 台账一个字没动。人看见的是「我说了重做,它当没听见」)。
* 案例模型的项这时全是 Completed,模型除了重新 submit 无事可做 —— **措辞治不了的结构治**:
* 提交这道口子自己看一眼「打回之后改过东西没有」,没改就不放行。
* 与「重发已答过的卡」同族的软硬两段:头一次拒(把路指明:改台账、或问清楚要改什么),
* 已经拒过一次还坚持原样提交就放行 —— 人还能再打回,不至于把单子锁死。
*/
const REDO_NOTICE = { kind: 'result', title: '验收被打回重做:优先级排得不对' };
const WROTE = { kind: 'action', title: '写草稿:crm_coverage.plan 1 行(1 行有变化)', detail: { ledger: 'crm_coverage.plan' } };
const REFUSED = { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' };
it('打回后零修改就 submit:当场拒,不发新验收卡', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE] });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('一个字没改'))).toBe(true);
});
it('打回后改过台账再 submit:照常发卡', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, WROTE] });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
});
it('已经因此拒过一次:再提交就放行 —— 墙要配路,不把单子锁死', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [REDO_NOTICE, REFUSED] });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('判齐快道同一道闸:打回后没改,引擎也不替模型原样再递一次', async () => {
// READY_MODEL 判齐恒真 —— 这条路不经模型,从前它会在意见没人读时自己再发一张卡
const h = harness({ trail: [REDO_NOTICE] });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(outcome.status).toBe('running');
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(true);
});
it('没有打回过的常路:提交照常,这道闸不出手', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: [WROTE] });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('判齐恒真的局面里收到 redo:这一拍不走判齐快道重发卡 —— 先让模型看见意见', async () => {
// READY_MODEL 必做项全无 + autoComplete → 判齐恒真;从前这条快道会不问模型直接再发一张卡
const h = harness({
pending: { acceptanceCardId: 'card-9', verdict: 'reject' },
action: { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] },
});
await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(1);
// 这一拍引擎没有自己发新验收卡(模型看完意见后自己 submit 才发)
expect(h.issued).toHaveLength(0);
// 没写意见也要说明白:让模型自己复核,不是让它猜
expect(h.appended.some((e) => e.title.includes('打回重做'))).toBe(true);
});
});
/**
* **发卡的授权面是调人预算,不是计划项**(2026-08-23,E04 回归实测撞出来的):
* 依赖断点那一拍「当下可为」是空的,归一层给兜底卡兜出来的 `itemId` 就是空串,
* 于是引擎自己造的那张「需要你判断下一步」被自己的门拦下 ——
* **最后的活路自己被拦住,这一单就真没路了**(墙要配路,而这里连路本身都砌了墙)。
* 与「写的授权面是台账声明」「调工具的授权面是白名单」同族:项号只是记账,
* 挂不上照发,留痕注明。人工激活那道 worldTouched 门不受影响 ——
* 挂不上就没标到任何项上,那道门照旧要求「真给这一项发过卡」。
*/
describe('发卡 —— 项号挂不上不拦', () => {
const CARD = { kind: 'human', itemId: '', card: { title: '需要你判断下一步', ask: '接着走哪一项?' }, workOrder: 'execution' };
it('项号为空:卡照发,留痕注明挂不上', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CARD });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
expect(h.appended.some((e) => e.title.includes('未挂到计划项'))).toBe(true);
});
it('项号编的:同样照发 —— 卡是给人的,不该因为记账挂不上就发不出去', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...CARD, itemId: '我编的项' } });
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('项号挂得上:照旧挂上并点亮(worldTouched 那道门要的就是这个)', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: { ...CARD, itemId: 'GATED' } });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
});
});
/**
* **立监听的授权面是活动清单的 `declarations.listeners`**(2026-08-23,柴浩实测:
* E04 一单在 S4 审批过后卡在依赖断点 —— 模型没写 `plan_approved_total` 那面旗,
* S5 的入场哨兵永不成立;它于是临场「立一个 data 监听等执行回执」,而 E04 声明的
* 监听是**空的**、这一单也不会有任何 data 事件进来,单子就此永久挂在 awaiting-event,
* 15 分钟一动不动)。与「写的授权面是台账声明」「调工具的授权面是白名单」同一条:
* **没声明过的监听立不得** —— 立了也没人来叫醒它,那不是等待,是失联。
*/
describe('立监听 —— 授权面是清单声明', () => {
const LISTEN = { kind: 'listen', itemId: 'i1', eventType: 'data', keyFilter: { kind: 'receipt' }, deadline: 'P3D' };
it('活动一个监听都没声明:当场拒,并指出该走哪条路', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: LISTEN, declaredListeners: [] });
const outcome = await h.runner.runTurn(TASK);
expect(h.listened).toHaveLength(0);
expect(outcome.status).toBe('running');
expect(h.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('没声明'))).toBe(true);
});
it('声明过这一类:照立不拦', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: LISTEN,
declaredListeners: [{ eventType: 'data', keyFilter: { kind: 'receipt' } }],
});
const outcome = await h.runner.runTurn(TASK);
expect(h.listened).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
});
it('声明的是别的类(只声明了 time,却要立 data):照样拒', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: LISTEN,
declaredListeners: [{ eventType: 'time', keyFilter: { kind: 'deadline' } }],
});
await h.runner.runTurn(TASK);
expect(h.listened).toHaveLength(0);
});
});
describe('onEvent —— 只记不跑', () => {
it('验收裁决到达:落进信封即返,不跑模型(推进归 tick,预算一个口径)', async () => {
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-9', payload: { verdict: 'pass', selectedPorts: { 议价结果: 'won' } } });
expect(h.decided).toHaveLength(0);
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9', verdict: 'pass', selectedPorts: { 议价结果: 'won' } });
});
it('无关人卡回传不动在途验收', async () => {
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-other', payload: { values: { x: 1 } } });
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9' });
expect(h.decided).toHaveLength(0);
});
it('redo 裁决连意见一起落进信封 —— 重做那一拍模型要看得见人写了什么', async () => {
const h = harness({ pending: { acceptanceCardId: 'card-9' } });
await h.runner.onEvent(TASK, { kind: 'human', ref: 'card-9', payload: { verdict: 'redo', comment: '重做一次' } });
expect(h.envelope.pending).toEqual({ acceptanceCardId: 'card-9', verdict: 'reject', comment: '重做一次' });
});
});
describe('叙事面 —— 任务自身轨迹喂回', () => {
it('窗口有轨迹时,Agent 收到【本任务最近轨迹】段', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [
{ kind: 'thought', title: '上一拍我调了历史价' },
{ kind: 'result', title: '查得 3 条锚点' },
],
});
await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(1);
expect(h.decided[0]).toContain('【本任务最近轨迹】');
expect(h.decided[0]).toContain('- [thought] 上一拍我调了历史价');
expect(h.decided[0]).toContain('- [result] 查得 3 条锚点');
});
});
/**
* 写台账草稿 —— 活动过程写的唯一形态(铁律②)。
*
* 这条路从前**根本不存在**:`TaskDataGateway.draftWrite` 有端口无调用方,
* Agent 只会调工具、调人、立监听 —— 干出来的结论没有一处能放。
* 后果不是「少个功能」,而是**必交付物永远是零行**、验收判据求值取不到值、
* 出口载荷推不出字段:活动跑得再热闹,放行那一刻什么都交不出来。
*/
const WRITE_ACTION = {
kind: 'write',
itemId: 'i1',
address: 'quote_intake.record',
rows: [
{ key: { pn: 'LM358DR' }, patch: { eau: 12000 } },
{ key: { pn: 'NE555P' }, patch: { eau: 5000 } },
],
};
describe('写台账草稿 —— 成果就是台账里的行', () => {
it('一拍写多行 —— 一份 BOM 一次写完,不一行一拍烧一次模型', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE_ACTION });
const outcome = await h.runner.runTurn(TASK);
expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM358DR' }, { pn: 'NE555P' }]);
expect(outcome.status).toBe('running');
});
it('写完**不收场** —— 一项常要几个动作才交得出成果,收了就再也动不了它', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE_ACTION });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Active');
});
it('rows 为空即拒 —— 一拍什么也没发生就不该记一笔「写过了」', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...WRITE_ACTION, rows: [] } });
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(0);
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Available');
expect(outcome.status).toBe('running'); // 拒了不崩:理由喂回下一拍
});
it('地址/列名不合规(装配层拒写)→ 行动被拒,回合不崩、项不前进', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: WRITE_ACTION,
writeRejects: '台账地址未声明:other.thing',
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Available');
});
});
describe('叙事面 —— 干了什么、拿回了什么,都要看得见', () => {
/*
* 只给标题的话,「crm.history-price-read 返回」读起来像事情办成了,而它到底返回了什么
* Agent 一个字看不见 —— 于是它照想象往台账里写。实测:三条料的历史价全被写成
* 「无历史 9999 / 生命周期 Unknown」,而 CRM 替身里三条都有真数据;
* 打标于是全成了 B —— 该判 A 的直通没了、该判 C 的选型也没了。**一步看不见,整单判错。**
*/
it('工具出参随轨迹进上下文 —— 不给,它就照自己的想象往台账里写', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [
{
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { rows: [{ pn: 'LM358DR', historyDays: 30, marketStable: true, lifecycle: '在产' }] } },
},
],
});
await h.runner.runTurn(TASK);
expect(h.decided[0]).toContain('出参:');
expect(h.decided[0]).toContain('LM358DR');
expect(h.decided[0]).toContain('在产');
});
it('工具失败的原话也要带上 —— 不然它只会一遍遍重试同一个错', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [{ kind: 'result', title: 'mail.send 失败', detail: { error: '端点返回 502' } }],
});
await h.runner.runTurn(TASK);
expect(h.decided[0]).toContain('错误:端点返回 502');
});
it('出参太长就截断并留记号 —— 一次批量调用的出参能把局面挤出上下文', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [{ kind: 'result', title: 'x 返回', detail: { output: { blob: 'y'.repeat(6000) } } }],
});
await h.runner.runTurn(TASK);
expect(h.decided[0]).toContain('(已截断)');
expect(h.decided[0]!.length).toBeLessThan(7000);
});
/*
* 批量出参**要给足**(2026-07-31 实测逼出来的)。
*
* 一单九颗料查历史价,回参九组判据、900 多字。从前一律截到 600 字:
* 模型看见前五颗的真判据、后四颗被截掉,于是**把剩下的编了出来** ——
* 给客户点名要替代的那颗写「在产 · 15 天」,判成 A 直接快答。
* 编出来的判据比没有判据坏得多:它看起来很像真的,而验收只看结论。
*/
it('最近一拍的批量出参不截 —— 截掉的那半截,模型会自己编回来', async () => {
// 九颗料的判据约 900 字:旧阈值(600)会砍掉后小半
const rows = Array.from({ length: 9 }, (_, i) => ({
pn: `PN-BATCH-${i}`,
historyDays: i * 10,
marketStable: true,
lifecycle: '在产',
}));
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [{ kind: 'result', title: 'crm.history-price-read 返回', detail: { output: { rows } } }],
});
await h.runner.runTurn(TASK);
// 最后一颗也得看得见 —— 看不见的那几颗正是被编出来的那几颗
expect(h.decided[0]).toContain('PN-BATCH-8');
expect(h.decided[0]).not.toContain('(已截断)');
});
it('回传值随轨迹进上下文;不给的话 Agent 既写不进台账,也只能再问一遍', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [{ kind: 'card-reply', title: '执行工单回传:追问补全', detail: { by: 's-1', values: { eau: '12000', lead_time: '8周' } } }],
});
await h.runner.runTurn(TASK);
expect(h.decided[0]).toContain('回填:eau=12000;lead_time=8周');
});
it('没有回传值的轨迹行照旧只有标题 —— 不给上下文塞空话', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
trail: [{ kind: 'thought', title: '上一拍我调了历史价', detail: { by: 's-1' } }],
});
await h.runner.runTurn(TASK);
expect(h.decided[0]).toContain('- [thought] 上一拍我调了历史价');
expect(h.decided[0]).not.toContain('回填:');
});
});
/**
* 引擎自己该收的两件事 —— 都曾让案子**永远判不齐**,而症状看着像业务没干完。
*
* ① **里程碑没人按**:CMMN 的里程碑语义是「一个前提被满足」,没有执行体。
* 从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西发这种事件 ——
* 于是里程碑永远 Available:既是必做项(堵死 autoComplete),又常驻「当下可为」
* 与「目标缺口」,让 Agent 每拍都看到自己按不动的东西。
* ② **事件监听器不是交付物**:它是等待岗。摆进缺口里,Agent 会以为局面还差一大截;
* 算进必做项里,一个「客户改需求」就让案子永远收不了。
*/
const MILESTONE_MODEL: CaseModel = {
name: '带里程碑与监听器',
autoComplete: true,
items: [
{ id: 'T1', name: '干活', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
{
id: 'M1',
name: '活干完了',
type: 'milestone',
discretionary: false,
entryCriteria: [
{ id: 'ec_m1', onParts: [{ kind: 'file', ref: 'done_total', event: 'update' }], ifPart: 'done_total > 0', semantic: false },
],
exitCriteria: [],
},
{ id: 'UL1', name: '客户改需求', type: 'userEventListener', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const MILESTONE_RUNTIME: CaseRuntime = {
state: 'Active',
items: [
{ id: 'T1', state: 'Completed', instance: 1 },
{ id: 'M1', state: 'Available', instance: 1 },
{ id: 'UL1', state: 'Available', instance: 1 },
],
milestones: [],
} as never;
describe('里程碑与监听器 —— 引擎自己该收的两件事', () => {
it('前提满足的里程碑由引擎达成 —— 不必谁去按', async () => {
const h = harness({ parsed: MILESTONE_MODEL, runtime: MILESTONE_RUNTIME, scope: { done_total: 3 } });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'M1')?.state).toBe('Completed');
expect(outcome.runtime.milestones).toContain('M1');
});
it('件还没到就不达成 —— 达成是前提说的,不是引擎好心', async () => {
// 任务视图里没有 done_total → onPart 判「件未到」→ 前提不满足
const h = harness({ parsed: MILESTONE_MODEL, runtime: MILESTONE_RUNTIME, scope: {} });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'M1')?.state).toBe('Available');
});
it('事件监听器不进目标缺口 —— 它是等待岗,不是成品', async () => {
const h = harness({ parsed: MILESTONE_MODEL, runtime: MILESTONE_RUNTIME, scope: { done_total: 3 } });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.enabled.gaps).not.toContain('客户改需求');
});
it('必做项干完 + 里程碑达成 → 判齐发验收卡,一个监听器挡不住结案', async () => {
const h = harness({ parsed: MILESTONE_MODEL, runtime: MILESTONE_RUNTIME, scope: { done_total: 3 } });
const outcome = await h.runner.runTurn(TASK);
// 这一拍先达成里程碑;下一拍必做项全绿即判齐
const next = await h.runner.runTurn(TASK);
expect([outcome.status, next.status]).toContain('awaiting');
expect(h.issued.some((c) => c.workOrder === 'acceptance')).toBe(true);
});
});
/**
* 「干活前先站上场」—— 拓扑不许 `Available → Completed`,中间那一步谁走?
*
* 实测踩过:模型反复挑同一个没 start 的项去调工具,每拍都被拓扑拒回来 ——
* 18 拍烧掉 98k token,一件事没干成。所以分情况:标了人工激活的必须 Agent 自己 start
* (那条纪律管的是触达世界与关键交付),没标的引擎就地过一下。
*/
const STAGE_MODEL: CaseModel = {
name: '两种项',
autoComplete: true,
items: [
{ id: 'FREE', name: '读类活', type: 'humanTask', manualActivation: false, discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'GATED', name: '碰外界的活', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'STG', name: '一个阶段', type: 'stage', manualActivation: false, discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const READY_RUNTIME2: CaseRuntime = {
state: 'Active',
items: [
{ id: 'FREE', state: 'Available', instance: 1 },
{ id: 'GATED', state: 'Available', instance: 1 },
{ id: 'STG', state: 'Available', instance: 1 },
],
milestones: [],
} as never;
const writeTo = (itemId: string) => ({
kind: 'write',
itemId,
address: 'quote_intake.record',
rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }],
});
describe('干活前先站上场', () => {
it('没标人工激活的项:引擎就地过 Active,这一拍就能干活', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('FREE') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Active');
expect(h.written).toHaveLength(1);
});
/*
* **写的授权面是台账声明,不是计划项** —— itemId 只是记账,挂不上不拦写。
* 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed,
* 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来,
* 连拒五拍熔断交人,而那几行数据本身合法合规。
* 2026-08-02 拆墙后,人工激活的项也**挂得上**了(动它就算启动):
* 写照落不变,记账从「挂不上、留痕注明」变成「挂上、点亮进行中」—— 更诚实。
*/
it('标了人工激活的项:写照落、挂上点亮 —— 授权面仍是台账声明', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Active');
expect(outcome.status).toBe('running');
});
it('阶段当落点:写照落、阶段不动(容器不挂活)', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('STG') });
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'STG')?.state).toBe('Available');
});
it('项不存在:写照落、不挂项 —— 台账声明那道门才是授权', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('我编的项') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(h.written).toHaveLength(1);
});
it('回归 · 项已 Completed 后补写:照落不拒 —— 活没干完不等于没有地方写', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: [{ id: 'FREE', state: 'Completed', instance: 1 }, { id: 'GATED', state: 'Available', instance: 1 }, { id: 'STG', state: 'Available', instance: 1 }] } as never,
action: writeTo('FREE'),
});
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
expect(outcome.status).toBe('running');
});
});
/**
* 宣告完成 —— **完成由 Agent 自己说,不是某个动作的副作用**。
*
* 这是上面「写完不收场」的另一半:一项要几个动作才交得出成果(先逐条写记录、再结算总数),
* 所以收场必须是一次独立的宣告。实测踩过反面:动作一做完就把项收了,那一项该产出的
* 另一半永远缺着,而环在「当下可为 ∩ 目标所需 = 空」里打转 —— 20 拍烧掉 120k token。
*/
describe('启动 —— 按钮按两下不该报错', () => {
it('已在进行中的项再 start:无事发生,不判非法迁移', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: [{ id: 'FREE', state: 'Active', instance: 1 }] } as never,
action: { kind: 'start', itemId: 'FREE' },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Active');
});
});
/*
* 空转要说出来。值和现有草稿一模一样的写不是错误,但它没有推进局面 ——
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算大半淌在这儿。
* 轨迹里看得见「一行都没变」,Agent 才有机会换个动作。
*/
describe('空转的写当场拒', () => {
const WRITE = { kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] };
/*
* 先只是如实留痕(「这一拍没有推进局面」),指望 Agent 看见了自己换个动作;
* 实测它照写不误 —— 同一张表连写六拍,30 万 token 淌在原地。
* 留痕拦不住,判据才拦得住:一行都没变的写不是一个行动。
*/
it('一行都没变 → 行动被拒,项不前进,理由喂回下一拍', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE, writeNoop: true });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Available');
});
it('有变化就照实报几行 —— 不夸大也不含糊', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(h.written).toHaveLength(1);
});
/*
* **空转写身后还有别的行动:无事发生,串不废**(2026-08-22,E04 活单熔断实测):
* 模型每拍计划「写记账旗 + 发 S5 执行卡」两个动作的串 —— 旗的值没变,第 1 个动作
* 被拒,而「被拒停在谁那儿」把整串弃权,排在后面的发卡从没执行过;连续五拍如此,
* 熔断交人。提示词的 world-touch 事实行与拒回的 blockedOnWorld 指路**都在**,
* 模型照样先写旗 —— 措辞已到头,结构治(设计律第六条):值没变的写什么也没改,
* 后面的行动前提未变,不该陪葬。
*/
it('空转写身后还有行动:无事发生不废串 —— 排在后面的发卡照发', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
actions: [
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'human', itemId: 'GATED', card: { title: '执行回执工单', ask: '请执行并回填' }, workOrder: 'execution' },
],
writeNoop: true,
});
const outcome = await h.runner.runTurn(TASK);
expect(h.issued.filter((c) => c.workOrder === 'execution')).toHaveLength(1);
expect(outcome.status).toBe('awaiting');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
// 无事发生也要说出来 —— 模型下一拍得看见「值已在台账里」这个事实
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('写了等于没写'))).toBe(true);
});
/*
* **调工具的授权面是工具白名单,不是计划项**(2026-08-22,E04 实测:模型调
* rank-key-persons 带了个空 itemId,被「没给计划项号」拒回,下一拍补上项号
* 原样重调 —— 那次拒回什么都没保护,白烧一拍。与写行动同款:挂不上不拦,
* 留痕注明,记账挂项是尽力而为,不是门。
*/
it('调工具项号为空/挂不上:照调不拦 —— 授权面是工具白名单,不是计划项', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: { kind: 'tool', itemId: '', tool: 'crm.history-price-read', input: { pns: ['A'] } },
});
const outcome = await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
expect(outcome.status).toBe('running');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
});
/*
* **同一份入参的读工具,原地重调是空转**(2026-08-22,E04 实测:S6 写回前模型把
* recompute-score 用一字不差的入参连调 10 次 —— 分数没照它预期涨,就反复重调赌变卦;
* 出参每次原样摆在轨迹里它照调不误,26 次调用烧穿预算的前科在案,而 stalledTurns
* 只数「行动被拒」,成功的重调没有任何兜底)。与「重发已答过的卡」同族的软硬两段:
* 重调的头几次照调、出参标题携带点名警告(不算被拒,模型不撞墙);第 5 次identical
* 才拒(真卡死了,熔断兜底要看得见)。写过/撤过之后世界变了,计数随写清零。
*/
it('同一份入参的重调:出参标题带点名警告 —— 不拒,但话要说到', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
const echo = {
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
};
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: CALL,
trail: [echo, { kind: 'observation', title: '这一拍在往下办事' }, { ...echo }],
});
await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
expect(h.appended.some((e) => e.kind === 'result' && e.title.includes('第 3 次调用') && e.title.includes('别再调了'))).toBe(true);
});
it('同一份入参连调第 5 次(期间没写过东西):当场拒 —— 真卡死了,熔断要看得见', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
const echo = () => ({
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
});
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: CALL,
trail: [echo(), echo(), echo(), echo()],
});
await h.runner.runTurn(TASK);
expect(h.toolCalls).toHaveLength(0);
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
});
it('两次识别之间写过台账:世界变了,重调照放行', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } };
const echo = {
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
};
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: CALL,
trail: [echo, { ...echo }, { kind: 'action', title: '写草稿:d.o 1 行(1 行有变化)', detail: { ledger: 'd.o' } }],
});
await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
});
it('入参不同的重调不算原地打转:照放行', async () => {
const CALL = { kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['B'] } };
const echo = {
kind: 'result',
title: 'crm.history-price-read 返回',
detail: { output: { ok: 1 }, inputHash: toolInputHash({ pns: ['A'] }) },
};
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: CALL, trail: [echo, { ...echo }] });
await h.runner.runTurn(TASK);
expect(h.toolCalls).toContain('crm.history-price-read');
});
/* 串尾/独苗的空转写仍当场拒 —— 熔断检测(stalledTurns 数「行动被拒」)靠它兜底 */
it('空转写是独苗:照旧当场拒,熔断检测看得见', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE, writeNoop: true });
await h.runner.runTurn(TASK);
expect(h.appended.some((e) => e.kind === 'result' && e.title.startsWith('行动被拒'))).toBe(true);
});
});
describe('宣告完成', () => {
const finish = (itemId: string) => ({ kind: 'finish', itemId, rationale: '记录与总数都写齐了' });
it('在进行中的项可以收 —— 留痕带上理由', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: [{ id: 'FREE', state: 'Active', instance: 1 }] } as never,
action: finish('FREE'),
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
/*
* 没站上场的**普通项**就地过 Active 再收:活常在同一串里已经干了(write 落了行),
* 只是记账没挂上 —— 为「按一下」单烧一拍模型是纯仪式(实测 finish S6 被拒后,
* 下一拍 start S6 + finish 原样重来,白花一拍)。
*/
it('没站上场的普通项:就地过 Active 再收,一拍完事', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('FREE') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
/*
* **人工激活的项:动它就算启动**(2026-08-02 拆墙)。
* 从前这儿钉的是「要先 start,不发就拒」;实测那道墙保护不了任何东西
* (start 是模型自己的动作,同一串连发即过),却多出一种死法:A1 一单
* 连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回都写着,
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
*/
it('标了人工激活的项:已经真触达过世界,模型显式动它 = 启动它,就地过 Active 再收', async () => {
// worldTouched 模拟「更早的拍已经发过卡 / 立过监听」——这里只测「不必再补一次 start」那半条
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, worldTouched: true } : i)) } as never,
action: finish('GATED'),
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
});
/*
* **触达世界的项,没真触达过就不许 finish**(2026-08-21,E04 实测逼出来的:
* S5_execute_receipt 从没给人发过执行回执工单,模型直接 finish 掉了它,
* 人根本没被问过完成状态,台账拿空回执重算了覆盖度)。`manualActivation`
* 声明的正是「完成与否不该由数据形状推出来」——如果 finish 不查有没有真的
* human/entry/decide/listen 之一发生过,这条声明形同虚设,模型可以随口
* 编一个「完成」。
*/
it('标了人工激活的项:从没真触达过世界,finish 被拒——不许凭空宣布人已经办完了', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
});
it('编出来的项收不了', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('我编的项') });
expect((await h.runner.runTurn(TASK)).status).toBe('running');
});
/*
* **声明了 doneWhen 的项,算出来是假就不许 finish**(2026-08-22,E04 实测逼出来的:
* S1 该写两行候选客户,模型只写成一行就直接 finish——doneWhen 明明为假,这一步照样
* 被记成「完成」,漏掉的那颗客户从此在这一单里凭空消失,一路悄悄漏到 S5 提交闸才第一次
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
* 不是让模型自己判断,是让基座去核。
*/
it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
sfeelCondition: () => false,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
});
/*
* **早收一次不算撞墙,第二次才算**(2026-08-27 W05 e2e:五案例里三例各白吃一次被拒 ——
* 模型把「写行 · 调工具 · finish」排在同一串,工具还没轮到调 finish 先到了,下一拍它自己补上)。
* 与「重发已答过的卡头两次无事发生」同一把尺;真在原地打转的单仍看得见(第二次起照旧拒,喂熔断计数)。
*/
it('同一项第二次早收:照旧被拒 —— 熔断要看得见真卡死的单', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0' }],
sfeelCondition: () => false,
trail: [{ kind: 'observation', title: earlyFinishTitleOf('FREE') }],
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(h.appended.some((m) => m.kind === 'result' && m.title.includes('行动被拒'))).toBe(true);
});
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0 and some_missing = 0' }],
sfeelCondition: () => true,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
/*
* **同一串里先写后 finish,doneWhen 要按写完之后的草稿算**(2026-08-22,E04 实测:
* S1 一拍连做「写候选行 + finish」——写已经落进草稿,finish 却按拍首快照判 doneWhen
* 为假被拒;下一拍引擎又用同一份数据自动收步。同一份数据两个裁决,每单白吃一次被拒,
* 而「先写后 finish 一拍连做」正是提示词鼓励的省拍打法,不该被快照误伤。
*/
it('同一串先写后 finish:doneWhen 按写完后的草稿算,不按拍首快照', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
actions: [
{ kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'finish', itemId: 'FREE', rationale: '行已写齐' },
],
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'record_rows > 0' }],
viewFn: (written) => ({ record_rows: written.length }),
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
expect(h.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
});
it('doneWhen 表达式算错了(抛错)—— 不拖垮这一拍,原样交给模型的 finish 判断', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: '这不是一个合法表达式(((' }],
sfeelCondition: () => {
throw new Error('写坏了');
},
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
it('没声明 doneWhen 的项:finish 不受这道新门槛影响,照旧收', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('FREE'), sfeelCondition: () => false });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
});
/**
* 对人说话 + 换画面 —— 两个洞的回归。
*
* ① Agent **不会说话**:它会写台账、调工具、调人、立监听,但没有一型行动是「对人说一句」。
* 实测 A1 跑十几拍,负责人的对话区从头到尾空白。
* ② 画面**每一帧都被悄悄丢掉**:引擎每拍推一个硬编码的 `case-overview`,
* 而 A1 声明的四张模版一个都不叫这个名字 —— 模版查不到就静默 return,
* 工作区从头到尾空白,日志里连一个字都没有。静默失败让「没画面」像「没产出」。
*/
describe('对人说话 —— 每一拍的附带,不是一型行动', () => {
/*
* 一开始把它做成 `kind: 'say'`:它占一整拍又不推进局面,预算下的 Agent 理所当然
* 跳过它 —— 对话区照旧空白(实测)。让说话与干活抢拍,等于把「必须说」
* 设计成了「有空才说」。所以它是每一拍的附带字段。
*/
it('说的话与这一拍的行动一起来 —— 不占额外的拍', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: WRITE_ACTION,
say: { text: '三条已归一,缺 1 个交期', tag: 'guidance' },
});
const outcome = await h.runner.runTurn(TASK);
// 该干的活照干:交代不挤占行动
expect(h.written).toHaveLength(2);
expect(outcome.status).toBe('running');
});
it('顺手换画面:say 带 view 就推那一张', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
templates: ['intake-overview', 'intake-release'],
action: WRITE_ACTION,
say: { text: '打标完了', view: 'intake-release' },
});
await h.runner.runTurn(TASK);
expect(h.pushed).toContain('intake-release');
});
it('没给交代就不硬编一句 —— 宁可没有,不替它说话', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: WRITE_ACTION });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
});
});
/**
* 计划项号认简写 —— 模型在说明书里读到的是「S4 查历史价」,报 itemId 时常只写前半截。
* 从前一律拒回「计划项不存在」白烧一拍(实测三轮里出现两次)。
* 只在**歧义为零**时认:恰好一个项以它开头才算,多于一个照旧拒 —— 宽进的是称呼,不是权限。
*/
describe('计划项号认简写', () => {
const TWO = {
name: '两项',
autoComplete: true,
items: [
{ id: 'S4_history', name: '查历史价', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'S5_classify', name: '打标', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const READY = { items: [{ id: 'S4_history', state: 'Available' }, { id: 'S5_classify', state: 'Available' }] } as never;
it('唯一前缀 → 认得出,照旧干活', async () => {
const h = harness({
parsed: TWO,
runtime: READY,
action: { kind: 'write', itemId: 'S4', address: 'quote_intake.type_table', rows: [{ key: { pn: 'X' }, patch: { history_days: 9999 } }] },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(h.written).toHaveLength(1);
});
it('前缀撞上两项 → 照旧拒,不替它选一个', async () => {
const h = harness({
parsed: TWO,
runtime: READY,
action: { kind: 'write', itemId: 'S', address: 'quote_intake.type_table', rows: [{ key: { pn: 'X' }, patch: { history_days: 1 } }] },
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1); // 写照旧落(挂不上不拦),但项没被猜出来
});
});
/**
* 交代不复读 —— **「一字不差」拦不住换个说法再说一遍**。
*
* 用户实测那一段对话区里连着五条「阶段结论」说的是同一件事:
* 「9颗料都打上标了:7颗A…」「9颗料都打标完了:5颗A…」「这单9颗料都打标完了…」
* 字面各不相同,读到的却是同一句被复读了五遍。
*/
describe('交代不复读', () => {
it('换个说法说同一件事 → 算同一句', () => {
expect(
sameGist('9颗料都打上标了:7颗A类直接报,2颗B类要重估。', '这单9颗料都打标完了:7颗A类直接报,2颗B类要重估'),
).toBe(true);
});
it('一字不差自然算同一句', () => {
expect(sameGist('三颗料都拆出来了,交期用量都齐', '三颗料都拆出来了,交期用量都齐')).toBe(true);
});
it('**说的是两件事就不许拦** —— 拦错一句比多说一句更坏', () => {
expect(sameGist('我去查这9颗料的历史价,拿回判据就落表', '历史价查回来了:7颗有近期成交价,2颗没有历史记录')).toBe(false);
expect(sameGist('客户认出来了,是老客户 Itelma,接下来拆料', '三颗料都定了去向:1颗转替代、1颗要询价')).toBe(false);
});
it('太短的句子不比重合度 —— 两三个字随便撞上就成了同一句', () => {
expect(sameGist('好的', '好的呀')).toBe(false);
expect(sameGist('查完了', '写完了')).toBe(false);
});
it('空句不算重复', () => {
expect(sameGist('', '')).toBe(false);
});
});
/**
* **一轮一句,不是一拍一句**(2026-08-01 用户定)。
*
* 用户实测那一段对话区,连着五条气泡里有四条在播报自己下一步要动哪张表:
* 「我这就把审定结论落账,收掉这一步,往下走查历史价。」
* 「我去查这三颗料的历史价,拿到判据就写进分类表。」
* 「…我这就把标写进分类表和受理记录,收掉这一步。」
* 「…我这就把两张请求单和去向旗写好,收掉这一步。」
* 原话:「太密集了」。那些不是给人的话,是工序播报 —— 人既不用知道也帮不上忙,
* 那一段该由「AI 工作中」的办事条覆盖。
*
* 口径:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次。
* 气泡出在**轮到人的那一拍**。
*/
describe('说话按轮不按拍:只有交出控制权的那一拍才进对话区', () => {
it('埋头办事的拍不出气泡 —— 写台账 / 调工具 / 收计划项都不算一轮收口', () => {
expect(handsOff([{ kind: 'write' }])).toBe(false);
expect(handsOff([{ kind: 'tool' }])).toBe(false);
expect(handsOff([{ kind: 'write' }, { kind: 'finish' }, { kind: 'start' }])).toBe(false);
expect(handsOff([{ kind: 'judge' }, { kind: 'plan' }, { kind: 'disable' }])).toBe(false);
});
it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => {
for (const kind of ['human', 'decide', 'listen', 'submit']) {
expect(handsOff([{ kind }]), kind).toBe(true);
}
});
it('一串里只要有一个挂起型就算收口 —— 挂起型只能排在最后,前面是这一轮的活', () => {
expect(handsOff([{ kind: 'write' }, { kind: 'finish' }, { kind: 'human' }])).toBe(true);
expect(handsOff([{ kind: 'judge' }, { kind: 'judge' }, { kind: 'submit' }])).toBe(true);
});
it('空串不出气泡 —— 什么都没打算做,更没有一轮可收', () => {
expect(handsOff([])).toBe(false);
});
});
describe('画面不再静默丢帧', () => {
it('活动没有 case-overview → 退到它声明的第一张,而不是什么都不推', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
templates: ['intake-overview', 'intake-gaps'],
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual(['case-overview', 'intake-overview']);
});
it('活动一张模版都没声明 → 确实无画面可推,不硬塞', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
templates: [],
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual(['case-overview']);
});
/*
* 有剧本的活动:推的是**当前这一步该看的那张**,而不是「声明的第一张」。
*
* 这条是拿归档件换来的:人签字前那一下由 `raiseAcceptance` 把放行页摆正了,
* 可**人签完字之后引擎还会再跑一拍** —— 那一拍按老规矩推 `case-overview`,
* 退到第一张 = 阶段二的执行件,把放行页冲掉。于是「工作成果」里定稿归档的那份件
* 不是他签的那份(实测 A1)。
*/
it('有剧本 → 推当前这一步声明的那张', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
templates: ['intake-overview', 'intake-release'],
steps: [{ item: 'i1', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'intake-release' }],
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual(['intake-release']);
});
/*
* **画面推在行动之后** —— 投影就得投最新的那一刻。
*
* 这一下从前在行动之前,于是工作区那份件永远比数据早一拍:S2 那一拍把三条料
* 写进草稿,而那一帧是写之前推的,清单一行都没有;偏偏这一拍以「发卡问客户」收尾,
* 整单挂在那儿等人 —— 人打开工作台看到一张空表,合理地判断「PN 没抽出来」。
*/
it('帧里带得上这一拍刚写下的行 —— 推在 write 之后', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
templates: ['intake-overview'],
steps: [{ item: 'i1', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'intake-overview' }],
action: WRITE_ACTION,
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual(['intake-overview']);
// **顺序断言**:推那一帧时,这一拍的两行已经落了地。推早了这里就是 0
expect(h.written.length).toBe(2);
expect(h.writesAtPush).toEqual([2]);
});
/*
* **这一拍把上一步收了、下一步的卡也发了 —— 画面得跟着走到下一步**
* (2026-08-23 E04 实测:S4 计划审批收掉、S5 执行卡当场发出,而工作区还摆着
* 审批那份只读件。人拿到的卡写着「把回执填到件上」,屏上一个可填的格子都没有,
* 提交闸如实拦住,他无处可填 —— 墙拦住却不给路)。
* 病根与 `liveScope` 那条同源:**拿拍首的局面去决定拍尾的事**。
*/
it('一串行动把案子推到下一步 → 推的是下一步的画面,不是拍首那一步的', async () => {
const TWO: CaseModel = {
name: '两步',
autoComplete: true,
items: [
{ id: 'i1', name: '第一步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'i2', name: '第二步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const h = harness({
parsed: TWO,
runtime: { items: [{ id: 'i1', state: 'Active' }, { id: 'i2', state: 'Available' }] } as never,
templates: ['step1-view', 'step2-view'],
steps: [
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
],
actions: [{ kind: 'finish', itemId: 'i1' }],
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual(['step2-view']);
});
it('两步同时 Active、这一拍发的是第二步的卡 → 推第二步的画面:件跟着卡走,不跟「第一步没干完的」', async () => {
const TWO: CaseModel = {
name: '两步',
autoComplete: true,
items: [
{ id: 'i1', name: '数据工单 P-08', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'i2', name: '数据工单 P-10', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const h = harness({
parsed: TWO,
// 模型没收掉第一步(剧本让它先 finish,它没照办)—— 第一步的卡人已答过,第二步刚站上场
runtime: { items: [{ id: 'i1', state: 'Active', worldTouched: true }, { id: 'i2', state: 'Active' }] } as never,
templates: ['step1-view', 'step2-view'],
steps: [
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
],
actions: [{ kind: 'human', itemId: 'i2', workOrder: 'execution', card: { title: '数据工单 · 原厂技术资料创建(P-10)', ask: '请补资料' } }],
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
expect(h.pushed).toEqual(['step2-view']);
});
/*
* **人工激活的项:人答过卡之后照常求 doneWhen**(2026-08-27 晚,用户:「根因修了吗?不要只修表象」)。
* W05 两张数据工单实测:卡人答了、数据入库了,模型没 finish 上一步就发了下一张 —— 两步同时 Active,
* 件停在上一步。办完了没有是引擎手里现成的事实,不该指望模型记得收。门槛仍是 worldTouched:
* 卡没发过的人工激活项,doneWhen 再真也不收 —— 那才是替人签字。
*/
it('人工激活的项:卡真发过(worldTouched)且 doneWhen 为真 → 拍首引擎自己收,不问模型', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: { ...READY_RUNTIME2, items: READY_RUNTIME2.items.map((i) => (i.id === 'GATED' ? { ...i, state: 'Active', worldTouched: true } : i)) } as never,
steps: [{ item: 'GATED', intent: '派单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'landed_rows > 0 and landed_missing = 0' }],
sfeelCondition: () => true,
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
expect(h.appended.some((e) => e.title.startsWith('自动收步:GATED'))).toBe(true);
});
it('人工激活的项:卡从没发过 → doneWhen 为真也不收(引擎不替人签字)', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
steps: [{ item: 'GATED', intent: '派单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'landed_rows > 0' }],
sfeelCondition: () => true,
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).not.toBe('Completed');
expect(h.appended.some((e) => e.title.startsWith('自动收步:GATED'))).toBe(false);
});
it('拍尾也扫:这一拍写完使上一步(人答过卡)的 doneWhen 为真、并发了下一步的卡 → 上一步当场收掉,下一步的卡不被误收', async () => {
const TWO: CaseModel = {
name: '两张数据工单',
autoComplete: true,
items: [
{ id: 'i1', name: 'P-08', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
{ id: 'i2', name: 'P-10', type: 'humanTask', manualActivation: true, discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const h = harness({
parsed: TWO,
runtime: { items: [{ id: 'i1', state: 'Active', worldTouched: true }, { id: 'i2', state: 'Available' }] } as never,
templates: ['step1-view', 'step2-view'],
steps: [
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view', doneWhen: 'record_rows > 0' },
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view', doneWhen: 'record_rows > 0' },
],
actions: [
{ kind: 'write', itemId: 'i1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'human', itemId: 'i2', workOrder: 'execution', card: { title: '数据工单 · 原厂技术资料创建(P-10)', ask: '请补资料' } },
],
viewFn: (written) => ({ record_rows: written.length }),
sfeelCondition: (_expr, scope) => Number(scope['record_rows'] ?? 0) > 0,
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Completed');
// 这一拍刚发卡的那一步:doneWhen 同样为真,但卡还没人答 —— 不许收
expect(outcome.runtime.items.find((i) => i.id === 'i2')?.state).toBe('Active');
expect(h.pushed).toEqual(['step2-view']);
});
/*
* **人填的表模型不许写**(2026-08-28,用户送来的实测单):数据工单要人在件上补的表,值只可能从人那儿来。
* 实测模型把台账里原 PN 那一行的规格书逐字抄到候选料号头上 —— 行找对了、料找错了,而这份假证据
* 会直接喂进差异比对当「硬约束有证据支持」。
*/
it('剧本声明 humanWrites 的台账:write 当场拒,拒回指向「去发那张卡」', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
steps: [{ item: 'i1', intent: '派数据工单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', humanWrites: ['parts.document'] }],
action: { kind: 'write', itemId: 'i1', address: 'parts.document', rows: [{ key: { internal_pn: 'X' }, patch: { document_ref: '抄来的' } }] },
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(0);
const rejected = h.appended.find((e) => e.title.startsWith('行动被拒'));
expect(rejected?.title).toMatch(/归人填/);
expect(rejected?.title).toMatch(/数据工单发出去/);
});
it('没声明的表照写;drop 不拦(写错了得撤得掉)', async () => {
const steps = [{ item: 'i1', intent: '派数据工单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', humanWrites: ['parts.document'] }];
const ok = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
steps,
action: { kind: 'write', itemId: 'i1', address: 'parts.profile', rows: [{ key: { internal_pn: 'X' }, patch: { brand_name: 'TI' } }] },
});
await ok.runner.runTurn(TASK);
expect(ok.written).toHaveLength(1);
expect(ok.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
const dropped = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
steps,
action: { kind: 'drop', itemId: 'i1', address: 'parts.document', key: { internal_pn: 'X' } },
});
await dropped.runner.runTurn(TASK);
// drop 可能因「本来就没有这一行」被拒,但**不该**是被这道墙拒的
expect(dropped.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('归人填'))).toBe(false);
});
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: { items: [{ id: 'i1', state: 'Completed' }] } as never,
templates: ['intake-overview', 'intake-release'],
steps: [{ item: 'i1', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'intake-overview' }],
action: { kind: 'judge', criterion: 'x', satisfied: true, rationale: 'y' },
});
await h.runner.runTurn(TASK);
expect(h.pushed).toEqual([]);
});
});
/**
* **补全卡已全局退役**(2026-08-02 用户定)—— 引擎侧这个行动型不再发卡:
* 归一层(agent-runner)会把 `ask` 原地转成零输入执行工单,所以正常到不了这里;
* 别的调用方直发就如实拒并指对路(human + execution)。
*/
describe('补全卡退役:引擎不再发 elicitation 卡', () => {
const ASK = {
kind: 'ask',
itemId: 'i1',
ask: '这两条缺交期,麻烦补一下',
fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }],
};
it('直发 ask 被拒:一张卡都不发,理由指向执行工单', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK });
const outcome = await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
});
});
/**
* **死循环该被熔断** —— 用户实测报的「无限死循环」。
*
* 预算会烧完,但那是二三十万 token 之后的事;这期间界面上只是「进行中」一动不动 ——
* 那不是在办,是卡死了。一个模型反复提同一个非法行动、每拍被拓扑拒回来,
* 再问一遍还是同一个答案。Harness 的活就是在这时候把它交回给人,并把**理由原话**摆上:
* 是它跑偏、是行动型少了一种、还是案例模型建得不对 —— 三种处置完全不同。
*/
/**
* 同一件事不许问第二遍 —— **熔断管连续被拒,管不了「换个说法再问一遍」**。
*
* 实测一单里同一个缺口连问三遍:人答了、它看了答案觉得不对、再问一遍;
* 一模一样的卡在工作台上堆了三张,四次调人的预算就此耗尽,单子落 exhausted。
*/
describe('同一件事不许问第二遍', () => {
const HUMAN = {
kind: 'human',
itemId: 'i1',
workOrder: 'execution',
card: { title: 'DAG2400 年用量缺失,请向客户确认', ask: '请填年用量' },
};
const answered = (title: string) => [
{ kind: 'card-sent', title },
{ kind: 'card-reply', title },
];
it('问过且人答过 → 拒,并指出答案就在轨迹里', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: HUMAN as never,
trail: answered('DAG2400 年用量缺失,请向客户确认') as never,
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
});
it('标点/全半角不一样也算同一件事', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: HUMAN as never,
trail: answered('DAG2400 年用量缺失,请向客户确认') as never,
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(0);
});
it('**发过但人还没答 → 照发**(那由卡自己的去重管,不是这道门的事)', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: HUMAN as never,
trail: [{ kind: 'card-sent', title: 'DAG2400 年用量缺失,请向客户确认' }] as never,
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
it('**问的是另一件事 → 照发**:不做模糊匹配,拦错比多问一遍更坏', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
action: HUMAN as never,
trail: answered('UMW AO6604 缺制造商,请向客户确认') as never,
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
});
});
describe('卡死了交回给人', () => {
const rejected = (n: number) =>
Array.from({ length: n }, (_, i) => [
{ kind: 'thought', title: '装配技能:x' },
{ kind: 'narration', title: '我再试一次' },
{ kind: 'result', title: `行动被拒:计划项不存在:我编的项${i}` },
]).flat();
it('数尾部连续被拒:thought / narration 不当分隔符(否则永远数出 1)', () => {
expect(stalledTurns(rejected(5))).toBe(5);
});
it('中间有一次真推进就重新计数 —— 它没卡死,只是绕了一下', () => {
expect(stalledTurns([...rejected(3), { kind: 'action', title: '写草稿:x 1 行' }, ...rejected(2)])).toBe(2);
});
/*
* **同一堵墙,前缀里的序号不算不同的墙**(2026-08-01,实测账)。
*
* 一拍一串行动:同一个理由拒回来时,前缀带着它在串里的位置(「第 2 个」「第 5 个」),
* 从前拿整条标题比,33 次被拒成了 33 堵不同的墙 —— 熔断永不触发,
* 40 万 token 烧到 exhausted 才停,而那期间界面上只是「进行中」一动不动。
*/
it('同一理由、不同串位 → 仍算同一堵墙', () => {
const wall = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「必填齐」[L1] 判据 missing_key_total = 0';
const trail = [
{ kind: 'result', title: `行动被拒(这一串的第 2 个):${wall}` },
{ kind: 'narration', title: '我再试一次' },
{ kind: 'evaluation', title: '语义性前提评估:x → 满足' },
{ kind: 'result', title: `行动被拒(这一串的第 5 个):${wall}` },
{ kind: 'observation', title: '放行页:推「intake-release」成功' },
{ kind: 'result', title: `行动被拒:${wall}` },
{ kind: 'evaluation', title: '语义性前提评估:y → 满足' },
{ kind: 'result', title: `行动被拒(这一串的第 3 个):${wall}` },
{ kind: 'result', title: `行动被拒(这一串的第 4 个):${wall}` },
];
expect(stalledTurns(trail)).toBeGreaterThanOrEqual(5);
});
it('理由不同就不是同一堵墙 —— 它在换着法子试,不该被熔断', () => {
const trail = [
{ kind: 'result', title: '行动被拒(这一串的第 1 个):计划项不存在:A' },
{ kind: 'action', title: '写草稿:x 1 行' },
{ kind: 'result', title: '行动被拒(这一串的第 2 个):写了等于没写' },
];
expect(stalledTurns(trail)).toBe(1);
});
it('连续 5 拍推不动 → 发执行卡挂起,卡上带齐拒绝理由原话', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: rejected(5) as never });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.issued).toHaveLength(1);
const card = h.issued[0]!.card;
expect(String(card['title'])).toContain('推不动了');
expect((card['basis'] as Array<{ v: string }>).map((b) => b.v)).toHaveLength(5);
// 卡死时不再问模型 —— 再问也是同一个答案,只是烧钱
expect(h.decided).toHaveLength(0);
});
it('还没到 5 拍就照常问模型 —— 绕一两下不算卡死', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, trail: rejected(4) as never });
await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(1);
});
});
/**
* 一拍一串行动 —— **拍是按模型调用计费的**。
*
* 实测账:一拍只许一个行动时,A1 六步的活恰好 21 拍,其中 19 拍是该约束下的
* 最短路径(每个 write / finish / start 都单烧一遍完整上下文)。收成一串后
* 「写记录、写结算、finish」同拍完成。这里锁三条执行纪律:
* ① 按序执行,谁被拒停在谁那儿 —— 前缀已生效,不回滚;
* ② tool 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
* ③ 挂起型(submit / ask / human / listen)落地即收拍。
*/
describe('一拍一串行动', () => {
const w = (itemId: string, pn: string) => ({
kind: 'write',
itemId,
address: 'quote_intake.record',
rows: [{ key: { pn }, patch: { eau: 1 } }],
});
it('写两笔再 finish —— 三个行动一拍干完,项收场', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
actions: [w('i1', 'A'), w('i1', 'B'), { kind: 'finish', itemId: 'i1', rationale: '记录与结算都写齐' }],
});
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(2);
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Completed');
expect(outcome.status).toBe('running');
expect(h.decided).toHaveLength(1); // 只问了一次模型 —— 这正是批的意义
});
it('中途被拒:前缀已生效,后面的不再执行', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
actions: [
w('i1', 'A'),
{ kind: 'finish', itemId: '我编的项', rationale: 'x' }, // 被拒
w('i1', 'C'), // 不该执行
],
});
const outcome = await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
// 前缀的写生效了:i1 已站上场
expect(outcome.runtime.items.find((i) => i.id === 'i1')?.state).toBe('Active');
expect(outcome.status).toBe('running');
});
it('tool 之后截断 —— 排在工具后面的行动作废,下一拍看着出参再定', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
actions: [
{ kind: 'tool', itemId: 'i1', tool: 'crm.history-price-read', input: { pns: ['A'] } },
w('i1', 'A'), // 闭着眼计划的 —— 必须作废
],
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(0);
});
it('submit 收拍:串里它后面的行动不执行,在途验收留在信封里', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
actions: [
w('i1', 'A'),
{ kind: 'finish', itemId: 'i1', rationale: '齐了' },
{ kind: 'submit', rationale: '交卷' },
w('i1', 'Z'), // 不该执行
],
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('awaiting');
expect(h.written).toHaveLength(1);
expect(h.issued.filter((c) => c.workOrder === 'acceptance')).toHaveLength(1);
expect(h.envelope.pending?.acceptanceCardId).toBeDefined();
});
it('超出封顶的行动丢弃(留痕)—— 静默截断会让模型以为后面的都做了', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
actions: [
w('i1', 'P1'), w('i1', 'P2'), w('i1', 'P3'), w('i1', 'P4'), w('i1', 'P5'), w('i1', 'P6'),
w('i1', 'P7'), // 第七个 —— 超出封顶
],
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(6);
});
});
/**
* **写错了得有退路**(2026-08-01 加 `drop`)。
*
* 实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
* 13 次拒回、23 拍、234k token 才熔断。
* **门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。**
*/
describe('撤草稿:写错一行能撤掉', () => {
const drop = (over: Record<string, unknown> = {}) => ({
kind: 'drop',
itemId: 'i1',
address: 'quote_intake.record',
keys: [{ rfq_ref: 'R1', pn: 'XTSD04GLGEAQG-I' }],
reason: '客户明说本轮跳过,不该进有效清单',
...over,
});
it('撤掉那一行,局面照旧往下走(不收场)', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: drop() });
const outcome = await h.runner.runTurn(TASK);
expect(h.dropped).toEqual([
{ domain: 'quote_intake', object: 'record', key: { rfq_ref: 'R1', pn: 'XTSD04GLGEAQG-I' } },
]);
expect(outcome.status).not.toBe('awaiting');
});
it('一行都没撤到 → 当场拒,并说清是主键对不上', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: drop(), dropMisses: true });
await h.runner.runTurn(TASK);
// 拒了不掀翻回合:局面照旧存下来,理由喂回下一拍
expect(h.saves.length).toBeGreaterThan(0);
});
it('keys 一个都没给 → 拒 —— 撤行得说撤哪几行', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: drop({ keys: [] }) });
await h.runner.runTurn(TASK);
expect(h.dropped).toHaveLength(0);
});
it('地址不是「域.对象」→ 拒,一行也不撤', async () => {
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: drop({ address: 'record' }) });
await h.runner.runTurn(TASK);
expect(h.dropped).toHaveLength(0);
});
it('**撤行不算收口** —— 它是埋头办事的一拍,不出气泡', () => {
expect(handsOff([{ kind: 'drop' }])).toBe(false);
});
});
/**
* **办完的项别把模型指回一条走不通的路**(2026-08-01,连跑逼出来的)。
*
* 从前一句话打发所有状态:「要先 start 再干活 —— 当下是 Completed」。
* 而 `start` 一个 Completed 项同样被拒(非法迁移)—— 模型照这句话去做,撞第二堵墙,
* 再回来撞第一堵,连拒五拍熔断(实测:S3_confirm 收完之后模型又想往它上面发一张卡)。
* **拒回的话是模型下一拍的行动指南;指错了方向,比不给指引更糟。**
*/
describe('办完的项:话要说对,按一下不报错', () => {
const DONE_MODEL: CaseModel = {
name: '收过一步的案子',
autoComplete: false,
items: [
{ id: 'S3', name: '人审定', type: 'humanTask', discretionary: false, manualActivation: true, entryCriteria: [], exitCriteria: [] },
{ id: 'S4', name: '接着办', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
],
sentries: [],
} as never;
const DONE_RUNTIME: CaseRuntime = {
items: [{ id: 'S3', state: 'Completed' }, { id: 'S4', state: 'Enabled' }],
} as never;
it('往收掉的项上发卡 → 拒,但话说的是「已办完,挂到当下这一步」并列出可选', async () => {
const h = harness({
parsed: DONE_MODEL,
runtime: DONE_RUNTIME,
action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: 't', ask: 'a' } },
});
await h.runner.runTurn(TASK);
// 拒了就没发出卡 —— 世界上不会留下一张谁也不知道怎么答的单
expect(h.issued).toHaveLength(0);
});
/*
* **A1 基线熔断实测(2026-08-03)**:人答完之后那一步已经收掉,模型又发了一遍同一张卡。
* 从前 `readyToWork` 走在重发判断之前 —— Completed 项当场抛「不能再往它上面挂活」,
* 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要它的时候够不着,
* 同一条拒回重复五遍熔断(13 拍 124k,发口一个都没有)。顺序换过来才叫墙要配路。
*/
it('重发**已答过**的卡,哪怕那一步已经收掉 → 无事发生,不撞「已办完」的墙', async () => {
const h = harness({
parsed: DONE_MODEL,
runtime: DONE_RUNTIME,
action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: '请审定这份清单', ask: 'a' } },
trail: [
{ kind: 'card-sent', title: '请审定这份清单' },
{ kind: 'card-reply', title: '请审定这份清单' },
] as never,
});
const outcome = await h.runner.runTurn(TASK);
// 没发第二张卡(人不被打扰、humanCalls 不烧),也没被拒
expect(h.issued).toHaveLength(0);
expect(outcome.status).not.toBe('awaiting');
// 收掉的项原样收着 —— 无事发生不该顺手把它推回场上
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => {
const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } });
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).not.toBe('awaiting');
// 局面没变:收掉的项不该被重新打开,那会让「办完了」这件事失效
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
});
});
/**
* **重发已答过的卡:执行工单与决策卡同一把尺**(2026-08-03,A4 实测逼出来的)。
*
* 从前只有执行工单那一路有兜底,决策卡一道都没有 —— 同一个岔路口连发四张,
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
describe('「这张卡人已经答过了」摆成每拍都在的事实行', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
it('**决策卡:把人选的那个值摆出来**(2026-08-27 W05 e2e:五个案例四个死在这里 —— 剧本让模型自己去轨迹里翻回传,而那条痕会被挤走)', () => {
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
expect(line).toContain('人已经答过了');
expect(line).toContain('他选的是「approve」');
expect(line).toContain('通过才收步');
});
it('**custom: 的答复不是通过**(2026-08-27 W05:人两次答「重做一次」,旧话术「把结论落进台账再 finish」把模型推去写了通过旗,一路推到放行)', () => {
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'custom:重做一次' } }]);
expect(line).toContain('写了自己的话:「重做一次」');
expect(line).toContain('这不是通过');
expect(line).toContain('换一种问法');
expect(line).toContain('不许 finish');
expect(line).not.toContain('把结论落进台账');
});
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
const line = answeredCardFact([sent('评估输入确认'), { kind: 'card-reply', title: '评估输入确认' }]);
expect(line).toContain('空答复 = 照原样确认');
expect(line).not.toContain('他选的是');
});
it('回传之后又发了**别的**卡 → 有新问题在等,这条不摆', () => {
const line = answeredCardFact([
sent('第一问'),
{ kind: 'card-reply', title: '第一问', detail: { value: 'approve' } },
sent('另一件完全不同的事'),
]);
expect(line).toBe('');
});
it('一张卡都没答过 → 空', () => {
expect(answeredCardFact([sent('还没答的卡')])).toBe('');
expect(answeredCardFact([])).toBe('');
});
});
describe('doneWhen 为假的拒回:差在哪要说得出口', () => {
it('**缺的是一次工具调用就直说** —— 2026-08-27 W05 e2e:五个案例里三个撞上,拒回只教它去查「行与列」,而缺的是 recommend 没调过', () => {
const why = unmetWhy('rows > 0 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 0 });
expect(why).toContain('缺的不是行也不是列,是一次工具调用');
expect(why).toContain('tool_w05_recommend_calls');
expect(why).toContain('tool_w05_recommend_calls=0');
});
it('工具调过了(读数 > 0)→ 不点它的名,只把判据与读数摆出来', () => {
const why = unmetWhy('rows = 10 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 2 });
expect(why).not.toContain('缺的不是行也不是列');
expect(why).toContain('rows=9');
});
it('判据里压根没有工具那一档 → 照旧只摆判据与读数', () => {
const why = unmetWhy('a_rows = b_rows', { a_rows: 3, b_rows: 4 });
expect(why).toContain('a_rows=3');
expect(why).toContain('b_rows=4');
expect(why).not.toContain('工具调用');
});
});
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
const echoed = (answered: string) => ({ kind: 'observation', title: echoTitleOf(answered) });
it('没发过这一问 → 照发', () => {
expect(resendVerdict([sent('别的事'), replied('别的事')], '这一问').kind).toBe('send');
});
it('发过但**人还没答** → 照发 —— 未答的重发由卡自己去重管,不归这里', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
it('**人选的那个值当场摆出来**(2026-08-27 W05 e2e:人点了通过,拒回只给「值在轨迹里」这个指针,模型对不准那一条,连发五次同一张卡熔断)', () => {
const win = [sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: 'approve', note: '试跑代答' } }];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问', chose: 'approve' });
const rejected = resendVerdict([...win, echoed('这一问'), echoed('这一问')], '这一问');
expect(rejected).toEqual({ kind: 'reject', answered: '这一问', chose: 'approve' });
});
it('回传痕里没有值(空串 / 缺 detail)→ 不编,退回原来那句指针话术', () => {
expect(resendVerdict([sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: ' ' } }], '这一问')).toEqual({
kind: 'echo',
answered: '这一问',
});
expect(resendVerdict([sent('这一问'), replied('这一问')], '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
});
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
expect(resendVerdict([...win, echoed('这一问')], '这一问').kind).toBe('echo');
});
it('第三次起当被拒 —— 那时是真卡死了,熔断兜底要看得见它', () => {
const win = [sent('这一问'), replied('这一问'), echoed('这一问'), echoed('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'reject', answered: '这一问' });
});
it('问句只少个尾巴也算同一问 —— 变体重发同样烧预算(与 sameQuestion 同源)', () => {
const win = [sent('请审定这份受理需求清单,确认无误后我就往下走'), replied('请审定这份受理需求清单,确认无误后我就往下走')];
expect(resendVerdict(win, '请审定这份受理需求清单').kind).toBe('echo');
});
it('空问句不拦 —— 认不出身份的卡不该被这道兜底吃掉', () => {
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
});
});
/**
* **发过卡这件事要落住**(2026-08-24,柴浩那一单熔断逼出来的一组回归)。
*
* `worldTouched` 是 finish 那道门唯一的证据:人工激活的项完成与否不该由数据形状推出来,
* 只认「真给它发过一张卡」。那一单里这个标记不见了 —— 于是 finish 说「从没发过卡」、
* 重发说「人已答过」,两堵墙对着撞,五拍熔断。
* 查下来标记本身在这三条路上都落得住(下面三条钉死),丢是丢在**两个后端并发写同一份
* 局面**上(见 `workers/task-claim.ts`)—— 这三条测试保证的是:这一侧别再出新的洞。
*/
describe('worldTouched 要落进保存的局面', () => {
it('给人工激活的项发一张卡 → 保存下来的那一项标着 worldTouched', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
});
await h.runner.runTurn(TASK);
expect(h.issued).toHaveLength(1);
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, 'GATED 上没有 worldTouched —— 发过的卡没被记住').toBe(true);
});
it('**再跑一拍之后仍然记得**(柴浩那一单:卡发过了,下一拍 finish 却说「从没发过卡」)', async () => {
// 桩按调用时读 over.action —— 留着这个引用,拍与拍之间换动作
const over: { parsed: CaseModel; runtime: CaseRuntime; action: Record<string, unknown> } = {
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
};
const h = harness(over);
await h.runner.runTurn(TASK);
expect(h.envelope.runtime.items.find((i) => i.id === 'GATED')?.worldTouched).toBe(true);
// 第二拍:干点别的(写一笔)—— 它会把局面重新存一遍
over.action = { kind: 'write', itemId: 'FREE', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 2 } }] };
await h.runner.runTurn(TASK);
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, '第二拍存完之后,发过卡这件事被抹掉了').toBe(true);
});
it('**人把卡答回来之后仍然记得** —— 回音落库那一下也存局面', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: { kind: 'human', itemId: 'GATED', card: { title: '回执工单', ask: '请回填' }, workOrder: 'execution' },
});
await h.runner.runTurn(TASK);
const cardId = 'card-1';
await h.runner.onEvent(TASK, { kind: 'human', ref: cardId, payload: { values: {} } });
const gated = h.envelope.runtime.items.find((i) => i.id === 'GATED');
expect(gated?.worldTouched, '人答回来之后,发过卡这件事被抹掉了').toBe(true);
});
});
/*
* 审核卡的面孔由引擎按剧本定(2026-08-27 用户:「这个验收卡又变成需要你定了」):
* 从前组装层只按行动里的 itemId 查剧本的 `cardVariant`,模型漏了 itemId(或给的项挂不上)
* 卡就退成「需要你定」。现在引擎把面孔递过去:点名的项优先,点不到就按当下在办的那一步。
*/
describe('决策卡的面孔按剧本定,不靠模型给对 itemId', () => {
const decide = (itemId?: string) => ({
kind: 'decide',
...(itemId === undefined ? {} : { itemId }),
ask: '请审核',
options: [
{ value: 'approve', label: '通过' },
{ value: 'reject', label: '驳回' },
],
});
const reviewStep = {
item: 'FREE',
intent: '审核',
guide: '做法',
writes: [],
reads: [],
tools: [],
view: 'x',
cardVariant: 'review',
acceptanceChecklist: ['核对甲', '核对乙'],
};
it('模型把卡挂到了别的项上(那一步没声明面孔):按当下在办的那一步 —— 剧本说 review 就是验收卡', async () => {
const plainStep = { item: 'GATED', intent: '别的活', guide: '做法', writes: [], reads: [], tools: [], view: 'y' };
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: decide('GATED'), steps: [reviewStep, plainStep] });
const out = await h.runner.runTurn(TASK);
const card = h.issued.find((c) => c.workOrder === 'decision');
expect(card, JSON.stringify({ status: out.status, appended: h.appended.slice(-3) })).toBeDefined();
expect(card?.card['variant']).toBe('review');
});
it('模型点名了那一项:照它查;这一步剧本没声明 cardVariant 就不给面孔(普通决策卡)', async () => {
const named = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: decide('FREE'), steps: [reviewStep] });
await named.runner.runTurn(TASK);
expect(named.issued.find((c) => c.workOrder === 'decision')?.card['variant']).toBe('review');
// 人工验收清单随面孔一起按剧本带上卡
expect(named.issued.find((c) => c.workOrder === 'decision')?.card['checklist']).toEqual(['核对甲', '核对乙']);
const plain = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: decide('FREE'),
steps: [{ ...reviewStep, cardVariant: undefined }],
});
await plain.runner.runTurn(TASK);
expect(plain.issued.find((c) => c.workOrder === 'decision')?.card['variant']).toBeUndefined();
});
});
/*
* 叫了人就等人(2026-08-27,用户:「你叫了人我都没填你就直接推进了,不能再有叫了人又继续自动推进的问题」):
* 有卡没交回,这一拍不问模型 —— 迟到的回传事件、另一台后端的推进器、手动 wake 都推不动它。
*/
describe('有卡等人交回:这一拍不问模型', () => {
const finishFree = { kind: 'finish', itemId: 'FREE', rationale: '写齐了' };
it('卡没答:不跑模型、不写、不发新卡,留一条内部痕,状态仍是等人', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finishFree,
unanswered: [{ id: 'wo-1', title: '评估输入与资料补齐确认' }],
});
const out = await h.runner.runTurn(TASK);
expect(out.status).toBe('awaiting');
expect(h.decided).toEqual([]);
expect(h.written).toEqual([]);
expect(h.issued).toEqual([]);
expect(out.runtime.items.find((i) => i.id === 'FREE')?.state).not.toBe('Completed');
expect(h.appended.some((e) => e.kind === 'observation' && e.title.includes('等人交回:评估输入与资料补齐确认'))).toBe(true);
});
it('卡答完了(没有在等的卡):照常问模型', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finishFree, unanswered: [] });
const out = await h.runner.runTurn(TASK);
expect(out.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
});
});
/*
* 放行那一步不发工单(2026-08-27 W05 实测):模型在 T_release 先发了一张执行工单「评审放行与落账确认」,
* 人答完它才 submit,验收卡又来一张 —— 同一件事问人两遍。认法:剧本给它声明了人工验收清单且不是审核卡那一步。
*/
describe('终局放行那一步:human 被拒并指向 submit', () => {
const releaseStep = {
item: 'FREE',
intent: '提交放行',
guide: '做法',
writes: [],
reads: [],
tools: [],
view: 'x',
acceptanceChecklist: ['核对甲', '核对乙'],
};
const humanCard = {
kind: 'human',
itemId: 'FREE',
workOrder: 'execution',
card: { title: '评审放行与落账确认', ask: '请确认', basis: [{ k: '来源', v: '收口' }] },
};
it('对放行步发执行工单:行动被拒,拒回点名 submit;一张卡都不发', async () => {
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: humanCard, steps: [releaseStep] });
await h.runner.runTurn(TASK);
expect(h.issued).toEqual([]);
const rejected = h.appended.find((e) => e.kind === 'result' && e.title.startsWith('行动被拒'));
expect(rejected?.title).toContain('submit');
expect(rejected?.title).toContain('问人两遍');
});
it('审核卡那一步(cardVariant review)不算放行步 —— 照旧能发卡', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: humanCard,
steps: [{ ...releaseStep, cardVariant: 'review' }],
});
await h.runner.runTurn(TASK);
expect(h.issued.length).toBe(1);
});
});
describe('auto 步:配方在拍首跑', () => {
/** 一步的活动:那一步声明 auto */
const AUTO_MODEL: CaseModel = {
name: '一步的活动',
autoComplete: false,
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
sentries: [],
} as never;
const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;
it('配方发了卡 → 这一拍一次都不问模型,状态 awaiting', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(0);
expect(out.status).toBe('awaiting');
expect(h.issued.map((i) => i.workOrder)).toEqual(['execution']);
});
it('配方只写不发卡、这一步因此办完 → 收步,还是不问模型', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: (_expr, scope) => Number(scope['quote_intake_record_rows'] ?? 0) > 0,
viewFn: (written) => ({ quote_intake_record_rows: written.length }),
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
doneWhen: 'quote_intake_record_rows > 0',
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'LM2596S-5.0', eau: 1000 } }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(0);
expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM2596S-5.0' }]);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
unanswered: [{ id: 'card-9', title: '补数据' }],
sfeelCondition: () => false,
viewFn: (written) => ({ n: written.length }),
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: {
do: [
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
{ kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
],
},
},
],
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(h.issued).toHaveLength(0);
});
it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
steps: [
{
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
},
],
});
const out = await h.runner.runTurn(TASK);
expect(h.decided).toHaveLength(0);
expect(out.status).toBe('awaiting');
expect(h.appended.some((a) => a.title.includes('nope.nope'))).toBe(true);
});
it('这一步有 guide:配方跑完照常问模型(半 auto)', async () => {
const h = harness({
parsed: OPEN_MODEL,
runtime: OPEN_RUNTIME,
sfeelCondition: () => false,
viewFn: (written) => ({ n: written.length }),
steps: [
{
item: 'i1',
guide: '把结论写实',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }] },
},
],
/*
* brief 原文这里写的是 `{ kind: 'idle', rationale: '看过了' }` ——
* `AgentAction` 判别联合里没有 `'idle'` 这一档(全仓 grep 零命中),
* `applyAction` 的 switch 又没有 default 分支,不认得的 kind 会让它
* 隐式 return undefined,下一行 `applied.runtime` 当场抛 TypeError。
* 这条用例要测的是「配方写完之后模型仍被正常问了一次」,与模型具体
* 回了哪个动作无关 —— 换成场上已有测试反复验证过的 `finish`(收掉
* i1,i1 没声明 manualActivation / doneWhen,收得干净)。
*/
action: { kind: 'finish', itemId: 'i1', rationale: '看过了' },
});
await h.runner.runTurn(TASK);
expect(h.written).toHaveLength(1);
expect(h.decided).toHaveLength(1);
});
});