Archived
**I6 · 配方只认 `liveStep`**:`current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`), 它不过滤 `settledIds`。当初这只影响提示词内容;配方接进来之后它驱动**副作用** —— 再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。兜底继续只服务提示词。 同处 `settledIds` 改取 sweep 之后的 `stateRuntime`(`liveStepOf` 吃的是 sweep 之后的 `stateEnabled`,两边得同一份局面)。 **注**:这一形状(既已收掉又在场)我没能在当前 lifecycle 里构造出可达来路 —— `publishEnabled` 把 Completed/Terminated 挡在 entries 与 inProgress 之外, `reenter` 又是就地改状态、不留旧实例。用例用的是 `live-step.ts` 注释描述的那种局面 (同 id 两份实例),守的是不变式;详见最终报告。 **F1 · `afterSave` 只在有未答的卡时才 `wake`**:`wake` 对 awaiting-event / awaiting-human 一律拨成 running,推进器随即推一拍并**记一步预算**。而现有九个活动包一个 auto 都没有: 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human (等人裁决)时被叫醒,那一拍结束还会把它写成 awaiting-event —— 从「等人办事」降级成 「等外部回音」,而等的明明还是人。**这是界面撒谎。**「保存即铺格」的场景恰恰都有卡在场。 用既有的 `humanTool.pendingCards`,不新造。`wake` 失败不再 `.catch(() => false)` 静默 —— 它是「保存即铺格」的唯一触发器,吞掉等于人按了保存、格子没出现、系统一个字不说。 **F3 · `prune` 不在人填的时候撤行**:人在件上「加一行」加出来的行,配方的数据源里当然没有 —— 编辑中途把它悄悄撤掉是最不能接受的一种静默数据丢失。有卡在等人(quiet)那几拍只铺不撤; 同一张表既被 prune 又列在该步 `humanWrites` 里,装包报 warn;规范 §4.6 纪律补两条 (prune 的表不该是人能加行的表 · card 之后那几笔要等人交回之后那一拍才跑)。 I6 2 测(做过变异验证)· F1 5 测 · F3 2+1 测。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2676 lines
126 KiB
TypeScript
2676 lines
126 KiB
TypeScript
/**
|
|
* loop 的「提交放行」三修(2026-07-28,Mastra 化前置):
|
|
* ① 提交/判齐 = 发**验收卡**挂起等裁决 —— 从前只返回 submitted,任务原地重跑到预算耗尽;
|
|
* ② onEvent **只记不跑** —— 从前自跑一拍、tick 再跑一拍,每次回传多耗一次不计预算的模型决策;
|
|
* ③ 任务自身最近轨迹喂回叙事面 —— 从前 Agent 每拍失忆。
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { TaskId } from '@contracts';
|
|
import { deriveSide, structuredLog } from '@modules/memory';
|
|
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
|
|
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;
|
|
|
|
/** 合成的信封字段 —— 与 `task-memory.test.ts` 的 `entry()` 工厂同一套写法 */
|
|
const STAMP: VersionStamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] };
|
|
const GOVERNANCE: GovernanceMark = { masked: false, learnable: true };
|
|
|
|
/**
|
|
* **把捕获到的一笔真实留痕包成完整 `MemoryEntry`,喂给 `structuredLog` 验收**(2026-08-29,R10)。
|
|
* 与 `auto-runner.test.ts` 的同名助手同一套写法 —— 见那边的注释。
|
|
*/
|
|
function asLogEntry(captured: { kind: string; title: string; detail: Record<string, unknown> | undefined }): MemoryEntry {
|
|
const kind = captured.kind as EntryKind;
|
|
return {
|
|
id: 'e-1',
|
|
taskId: TASK,
|
|
seq: 1,
|
|
at: '2026-08-29T00:00:00.000Z',
|
|
kind,
|
|
side: deriveSide(kind),
|
|
title: captured.title,
|
|
stamp: STAMP,
|
|
governance: GOVERNANCE,
|
|
...(captured.detail === undefined ? {} : { detail: captured.detail }),
|
|
};
|
|
}
|
|
|
|
/** 空案例模型:必做项全无 + 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; detail: Record<string, unknown> | undefined }>;
|
|
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
|
|
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; detail?: Record<string, unknown> }) => {
|
|
appended.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
|
|
},
|
|
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、没发卡、doneWhen 不成立)→ 交人,且这条消息真的到得了工作日志(R10 第六处)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
// say 不写不撤、不发卡 —— 配方跑完仍落到「没算办完,交人」那条分支
|
|
auto: { do: [{ kind: 'say', text: '已核对,尚缺关键信息' }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.decided).toHaveLength(0);
|
|
expect(out.status).toBe('awaiting');
|
|
const line = h.appended.find((a) => a.title.includes('还没算办完'));
|
|
expect(line).toBeDefined();
|
|
/*
|
|
* **这一条是五处里最不能隐身的**(2026-08-29,coordinator 裁定):它是交人的那一句 ——
|
|
* 配方没做到位、单子就停在这儿了,人打开工作日志想知道的第一件事就是「为什么停了」。
|
|
* 从前 kind: 'result' 不在 SKELETON 里,这一笔永远进不了工作日志,单子看着像凭空卡住。
|
|
*/
|
|
const lines = structuredLog([asLogEntry(line!)]);
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]?.who).toBe('system');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('卡答过了、这一步又没有 doneWhen → 收步(人已经签了,收步是照实记录)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
|
unanswered: [],
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.issued).toHaveLength(0);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
|
/*
|
|
* **配方留痕真的到得了工作日志**(2026-08-29,R10,spec §5.9 验收)。
|
|
* 上一轮(Task 12)只测过 actorOf 这个纯函数;这里把「自动收步」这一笔捕获到的
|
|
* 真实 {kind, title, detail} 喂给 structuredLog 全程 —— 从前它带着 `internal: true`,
|
|
* 会被 `machinery()` 挡在骨架之外,`actorOf` 判得再对也没用。
|
|
*/
|
|
const settled = h.appended.find((e) => e.title.startsWith('自动收步:'));
|
|
expect(settled).toBeDefined();
|
|
const lines = structuredLog([asLogEntry(settled!)]);
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]?.who).toBe('system');
|
|
});
|
|
|
|
/*
|
|
* 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 trace 出它验的其实是 Task 7
|
|
* 既有的「还没发过就发卡」那条路(:1998 `auto.issuedCardId !== undefined` 提前返回),
|
|
* 从没走到 Task 8 新加的 `carded` 门(:2019 起那一段)。名字改准,逻辑不动 ——
|
|
* 它仍是一条有效回归(证明配方在没发过卡时确实把卡发了出去),只是不再冒充「验过 carded 门」。
|
|
*/
|
|
it('还没发过卡 → 这一拍把卡发出去(Task 7 既有路径,不是 carded 门 —— 见下一条)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.issued).toHaveLength(1);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
|
});
|
|
|
|
/*
|
|
* **真正把执行推到 :2019 且 carded === false 的用例**(评审 Important)。
|
|
*
|
|
* 配方两笔:`project` 无条件照写(worldTouched 不受它影响)、`card` 带 `when` 这一拍算假、
|
|
* 被跳过 —— 卡**没有**发出去,`autoRunner.run` 回的是 `issuedCardId: undefined`。
|
|
* `auto.wrote > 0` 触发一次 doneWhen 重扫,可这一步没声明 doneWhen,重扫不改变局面,
|
|
* 于是**第一次**真的落到收步判断那一段:`recipeIssuesCard(current.auto)` 因为配方里
|
|
* 结构上确实有一笔 `card`(不看这一拍它有没有被 `when` 挡住)算出 true,`carded` 却是假
|
|
* ——这道门就是靠 `carded` 才没把它错收。`carded &&` 这个条件若被误删,这条会变红。
|
|
*/
|
|
it('配方这一拍卡被 when 挡住没发、只有别的笔照写 → carded 是假,这一步不该被收', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
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',
|
|
when: '恒假',
|
|
as: 'decide',
|
|
variant: 'review',
|
|
title: '结论审核',
|
|
options: [{ value: 'approve', label: '确认通过' }],
|
|
basis: [{ k: 'a', v: 'b' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
// project 照写,card 被 when 挡掉没发
|
|
expect(h.written).toHaveLength(1);
|
|
expect(h.issued).toHaveLength(0);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
|
});
|
|
|
|
/*
|
|
* **R9**(基于评审 Minor #1 的裁定):半 auto(声明了非空 `guide`)的步,卡答过了也不该
|
|
* 被这段收掉 —— 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」,人答完卡之后
|
|
* 那份判断(读人写的自由文本、落到具体的行上)恰恰是模型该干的活。
|
|
* 用默认的 `submit` 动作(harness 不传 `action` 时的缺省值):它只发验收卡、不碰项状态,
|
|
* 这样「S1 没被收成 Completed」这条断言测的确实是「没被这段收掉」,不是被模型自己收掉的假阳性。
|
|
*/
|
|
it('R9:半 auto(有 guide)的步卡也答过了 —— 不该被收在模型之前,那份判断仍是模型的活', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
|
unanswered: [],
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '把结论落进台账,再判断该不该收步',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.decided).toHaveLength(1);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **配方 submit 的三条**(2026-08-29 终审 C3)。
|
|
*
|
|
* 从前 `raiseAcceptance` 在配方那一路是**裸着**调的:`engine.advance` 不捕获、
|
|
* `task-service.tick` 也不捕获(且 `addConsumption` 排在 advance 之后,一步都不记)、
|
|
* `task-advancer` 只写 stderr 就 break、任务留在 `running` —— 下一个 interval 再来一遍:
|
|
* 无限热循环,不烧预算、不换状态、界面纹丝不动。而它**必然**会抛:重做闸认的
|
|
* 「改过台账」只看 `kind:'action'`,配方的写留痕是 `observation` + `detail.auto`。
|
|
*/
|
|
describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
|
const AUTO_RELEASE: CaseModel = {
|
|
name: '一步放行',
|
|
autoComplete: false,
|
|
items: [{ id: 'T_release', name: '放行', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
|
sentries: [],
|
|
} as never;
|
|
const AUTO_RELEASE_RUNTIME: CaseRuntime = { items: [{ id: 'T_release', state: 'Available' }] } as never;
|
|
const RELEASE_STEP = {
|
|
item: 'T_release',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
acceptanceChecklist: ['逐条核对'],
|
|
auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] },
|
|
};
|
|
const REDO = { kind: 'result', title: '验收被打回重做:结论写得太糊' };
|
|
/** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger`(auto-runner 的真实形状) */
|
|
const AUTO_WROTE = {
|
|
kind: 'observation',
|
|
title: '差异明细:写下 2 行',
|
|
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item' },
|
|
};
|
|
|
|
it('打回后配方原样重提 → 留痕拒回、**状态 running**(不抛,推进器不再空转热循环)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_RELEASE,
|
|
runtime: AUTO_RELEASE_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
trail: [REDO],
|
|
steps: [RELEASE_STEP],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(out.status).toBe('running');
|
|
expect(h.issued).toHaveLength(0);
|
|
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
|
expect(refused?.title).toContain('一个字没改');
|
|
});
|
|
|
|
it('那条拒回痕就是解锁凭据 —— 下一拍再提交就放行(墙要配路,不把单子锁死)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_RELEASE,
|
|
runtime: AUTO_RELEASE_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
trail: [REDO, { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' }],
|
|
steps: [RELEASE_STEP],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.issued.map((i) => i.workOrder)).toEqual(['acceptance']);
|
|
expect(out.status).toBe('awaiting');
|
|
});
|
|
|
|
it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger 也解得开重做闸', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_RELEASE,
|
|
runtime: AUTO_RELEASE_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
trail: [REDO, AUTO_WROTE],
|
|
steps: [RELEASE_STEP],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.issued.map((i) => i.workOrder)).toEqual(['acceptance']);
|
|
expect(out.status).toBe('awaiting');
|
|
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
|
*
|
|
* 从前失败只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
|
* `running` 的单:卡已答完、没立监听、没有时限,再没有任何东西会叫醒它。
|
|
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来。
|
|
*/
|
|
describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
|
|
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;
|
|
const BROKEN = {
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
|
|
};
|
|
|
|
it('发一张卡给负责人 + 立人事件监听 —— 原话摆在卡上,不再伪装成「等外部回音」', async () => {
|
|
const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => false, steps: [BROKEN] });
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(out.status).toBe('awaiting');
|
|
expect(h.decided).toHaveLength(0);
|
|
expect(h.issued).toHaveLength(1);
|
|
expect(h.issued[0]?.workOrder).toBe('execution');
|
|
expect(String(h.issued[0]?.card['title'])).toContain('配方办不下去');
|
|
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
|
|
expect(basis.find((b) => b.k === '这一步')?.v).toBe('S1');
|
|
expect(basis.find((b) => b.k === '办不下去的原因')?.v).toContain('nope.nope');
|
|
// 立了人事件监听,人一答就叫得醒
|
|
expect(h.listened).toEqual([{ eventType: 'human' }]);
|
|
});
|
|
|
|
it('这一单还有卡没交回时不重复发 —— 叫了人就等人(runTurn 开头那道门先把住)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
unanswered: [{ id: 'card-9', title: '配方办不下去:「S1」第 1 笔' }],
|
|
steps: [BROKEN],
|
|
});
|
|
await h.runner.runTurn(TASK);
|
|
expect(h.issued).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**(2026-08-29 终审 I6)。
|
|
*
|
|
* `current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`),它**不过滤 settledIds**。
|
|
* 当初这只影响提示词内容(读错一段无非白费点上下文);配方接进来之后它驱动**副作用**:
|
|
* 再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。
|
|
*
|
|
* 两者要分岔,得有一个项**既在 settledIds 里、又在场**。局面形状取自 `live-step.ts`
|
|
* 那条注释描述的那一种:同一个 id 留着一份收掉的实例、又有一份重新入场的
|
|
* (`publishEnabled` 的 `byId` 取后者 → 在场;`settledIds` 见到前者 → 已收掉)。
|
|
* **注:这一形状在当前 lifecycle 里我没能构造出可达的来路**(`reenter` 是就地改状态、
|
|
* 不留旧实例),所以这几条守的是**不变式**而不是复现过的事故 —— 见最终报告 I6 那一段。
|
|
*/
|
|
describe('auto 步 · 只认 liveStep(I6)', () => {
|
|
const REPEAT_MODEL: CaseModel = {
|
|
name: '收了还在场',
|
|
autoComplete: false,
|
|
items: [{ id: 'S1', name: '归一', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
|
sentries: [],
|
|
} as never;
|
|
/** 收掉的那份实例 + 重新入场的那份 —— 「既已收掉又在场」 */
|
|
const SETTLED_YET_ONSTAGE: CaseRuntime = {
|
|
items: [
|
|
{ id: 'S1', state: 'Completed', instance: 1 },
|
|
{ id: 'S1', state: 'Enabled', instance: 2 },
|
|
],
|
|
} as never;
|
|
const STEP = {
|
|
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' }] },
|
|
],
|
|
},
|
|
};
|
|
|
|
it('既已收掉又在场的步:配方不跑 —— 不重写台账、不再发一张卡', async () => {
|
|
const h = harness({ parsed: REPEAT_MODEL, runtime: SETTLED_YET_ONSTAGE, sfeelCondition: () => false, steps: [STEP] });
|
|
await h.runner.runTurn(TASK);
|
|
expect(h.written).toHaveLength(0);
|
|
expect(h.issued.some((i) => String(i.card['title']) === '再来一张')).toBe(false);
|
|
});
|
|
|
|
it('同一个局面下,没收掉的那一步照跑 —— 这道门拦的是「已收掉」,不是「所有兜底」', async () => {
|
|
const h = harness({
|
|
parsed: REPEAT_MODEL,
|
|
runtime: { items: [{ id: 'S1', state: 'Enabled', instance: 1 }] } as never,
|
|
sfeelCondition: () => false,
|
|
steps: [STEP],
|
|
});
|
|
await h.runner.runTurn(TASK);
|
|
expect(h.written).toHaveLength(1);
|
|
});
|
|
});
|