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