/** * 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 | 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; 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[]; /** 每次推帧时携带的 scope(`showView` 现读的 `deps.data.view()`)—— 断言「画面里真的有新行」走这里 */ readonly pushedScopes: Array>; /** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */ readonly appended: Array<{ kind: string; title: string; detail: Record | 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 }>; 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 }>; /** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */ unanswered?: ReadonlyArray<{ id: string; title: string }>; /** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */ issueRejects?: string; /** * **只让 `workOrder:'acceptance'` 那一句抛**(2026-08-30 终审 I4)——模拟落地预检 * 拒发验收卡(`raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})` * 抛出的那种);与 `issueRejects`(不分 workOrder 一律抛)分开,好让「验收卡发不出去、 * 但兜底的执行工单发得出去」这个场景测得出来。 */ acceptanceIssueRejects?: 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 pushedScopes: Harness['pushedScopes'] = []; const appended: Harness['appended'] = []; const toolCalls: string[] = []; const listened: Array<{ eventType: string }> = []; const h: Harness = { issued, saves, decided, written, dropped, pushed, writesAtPush, pushedScopes, 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, 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 }) => { if (over.issueRejects !== undefined) throw new Error(over.issueRejects); if (over.acceptanceIssueRejects !== undefined && input.workOrder === 'acceptance') { throw new Error(over.acceptanceIssueRejects); } 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 }) => { 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 }) => { 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; scope?: Record }) => { pushed.push(input.template); // 推这一帧时已经写下了几行 —— 帧推早了就是 0(见「推在行动之后」那条) writesAtPush.push(written.length); pushedScopes.push(input.scope ?? {}); 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 = {}) => ({ 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); }); }); /* * 审核卡的面孔由引擎按剧本定(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); }); /* * **这一拍真写下过东西,收拍前要把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查实测: * 人给候选料选好芯片类型、按保存,配方铺出 7 行新参数、红带也点名要填这 7 格 —— 而这条路 * 从不推帧,任务停在 `awaiting-event`,人刷新页面看到的仍是保存前那份快照(件是服务端渲好 * 推来的快照,重放不重渲),被点名的格子屏上根本不存在,走查到此卡死。 * 上一条用例(不发新卡)只断言了 `written`/`issued`,从没问过画面有没有跟上;这一条补上。 */ it('有卡在等人交回:配方写下新行 → 收拍前重推画面,画面里带着刚写的那一行', async () => { const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, unanswered: [{ id: 'card-9', title: '补数据' }], sfeelCondition: () => false, templates: ['input-supplement'], // 画面的 scope 现读「已写下的那几行」—— 与 `showView` 实际调用 `deps.data.view()` 同一条路 viewFn: (written) => ({ pns: written.map((w) => w.key['pn']) }), steps: [ { item: 'S1', guide: '', writes: [], reads: [], tools: [], view: 'input-supplement', auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'MP2596-5.0-WALK', eau: 1 } }], }, }, ], }); const out = await h.runner.runTurn(TASK); expect(h.written).toHaveLength(1); expect(out.status).toBe('awaiting'); // 画面真被重推了一次 —— 不是「一帧不推,留着保存前那份快照」 expect(h.pushed).toEqual(['input-supplement']); // 推的时候真带着刚写的那一行,不是拍首、写之前的那份旧 scope expect(h.pushedScopes.at(-1)?.['pns']).toEqual(['MP2596-5.0-WALK']); }); 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'); }); /* * **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」 * 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决 * 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是 * `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交 * `custom:` 走**对话卡族**,不是 `approve`)。`card-rules.ts` 的 `checkChatCardReply` * 认这个答复合法(`allowCustom` 对审核卡默认真,见 `engines.ts:1507` * `allowCustom: input.allowCustom ?? reviewFace`)、`worldTouched` 照样落 true —— * 于是这条「卡答过了就收步」的判断(:2179,四个前提:`noDoneWhen && carded && * recipeIssuesCard(recipe) && current.guide.trim() === ''`)一个字都不看 * `reply.value`/`chosen.value` 是 `approve` 还是 `custom:...`,一样收步、一样往前推。 * * **为什么不能靠活动侧加一个 `reject` 选项治**:上面那四个前提里没有一条查了选项或 * 选中的值,给卡加选项不会改变这个判断的走向——同一张卡不管选中哪一个,只要 * `worldTouched` 变真,这一步就被收。 * * **为什么 2026-08-27 那次「chosenGuidance 分岔」治不了这一条**:那次治的是**半 auto** * 路径(`guide` 非空、真的会去问模型的那条)—— `answeredCardFact`/`chosenGuidance` * 拼进的是**喂给模型的提示词**(:2255 `staged` 数组),而这里根本不问模型 * (`current.guide.trim() === ''` 正是触发这条近道的四个前提之一)。**没有模型可问, * 就没有提示词可以说服** —— 同一症状("审核那一步被收掉、一路推到放行", * `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上 * 重演,而那次的修法结构性地够不到这条路径。 * * **建议修法方向**(未实现,留给专门的一次改动):line 2179 那道门至少要能分清 * 「答的是不是 approve 选项」——但这只是半个修法:即便挡住了误收,S3_review 的 * `card` 动作在 `context.carded === true` 时会**直接跳过、不再发新卡** * (`auto-runner.ts` 的 card 分支),重做之后没有任何机制会把 `worldTouched` 重置、 * 也没有任何机制会再发一张新的审核卡 —— 单子会卡死在「答过但没收步」的死胡同, * 与 T_release 那条**已经做好**的重做闭环(`raiseAcceptance`,2026-08-22/23:意见落轨迹、 * 在途验收清掉、同一拍照常问模型)不是同一件事、也没有对应机制。真正修好需要给 * 这条 auto-card 路径补一个类似 `raiseAcceptance` 的「重做即重开」闭环,不是一行判断。 */ it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => { const h = harness({ parsed: AUTO_MODEL, runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never, unanswered: [], sfeelCondition: () => false, // 人真答的是"重做",理由写在 note 里 —— 这条 trail 证明"裁决是拒答"这件事**在系统里查得到** trail: [{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:结论算错了,请重算', by: 's-1' } }], 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); /* * **这就是 Important 2 的病灶**:trail 里明明白白记着人写的是"结论算错了,请重算" * (一次拒答),这一步却仍然被判 Completed —— 与「人按了确认通过」会得到的结果 * 完全相同。`out.runtime` 对这条 trail 里的裁决视而不见。 */ expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); }); /* * 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 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` + `detail.changed:true`(auto-runner 的真实形状) */ const AUTO_WROTE = { kind: 'observation', title: '差异明细:写下 2 行', detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: true }, }; /** * **空转的那一笔**(2026-08-29 复审 M1/M2 二审):`localExisting`/`localUnkeyed` 也会留痕 * (M1/M2 的「一行没写也要出声」),而台账其实一个字没动 —— `detail.changed` 是 `false`。 */ const AUTO_NOOP = { kind: 'observation', title: '差异明细:写下 0 行,2 行已在库、这一笔不用建', detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: false }, }; 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 + detail.changed:true 也解得开重做闸', 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 复审 M1/M2 二审,复审跟到底的那条): * 人点了「重做」,配方重跑、一个字没改(全 skipExisting / 主键算不出来),只要留了 * M1/M2 那条「出声」的痕,从前的 `revised` 判据一样为真 —— 闸放行了原样重提。 */ it('空转的那一笔(全在库/主键算不出来)解不开重做闸 —— 拒回原样再来一遍', async () => { const h = harness({ parsed: AUTO_RELEASE, runtime: AUTO_RELEASE_RUNTIME, sfeelCondition: () => false, trail: [REDO, AUTO_NOOP], 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('一个字没改'); }); /* * **Important 4(2026-08-30 全分支终审)**:上面四条测的都是「打回重做」那条路 * (`trail` 里有 `REDO_MARK`)。这三条测**从没被打回过、纯粹是验收判据(L1/L2 机检) * 没过**的那条路——这一步没有 guide(全 auto),没有模型会读「提交放行被拒」这句话, * 台账在更早一步就已经收口、没有活的编辑面能让人就地改:下一拍重跑只会拿到同一份 * 数据、撞回同一堵墙,纯粹烧步数预算直到 exhausted(task-service 的 markProgress * 每拍都记,stallAfterMs 那道熔断因此不会提前触发)。用 `acceptanceIssueRejects` * 模拟 `raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})` * 被落地预检拒发(这正是 W05 一颗 UNKNOWN 差异行漏填去向时,`every-param-judged`[L2] * 会真实抛出的那种错误)。 */ it('Important 4 · 头一次撞验收判据的墙(不是打回重做):留痕、状态仍 running —— 给它一次机会(软化那半段)', async () => { const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …'; const h = harness({ parsed: AUTO_RELEASE, runtime: AUTO_RELEASE_RUNTIME, sfeelCondition: () => false, acceptanceIssueRejects: reason, steps: [RELEASE_STEP], }); const out = await h.runner.runTurn(TASK); expect(out.status).toBe('running'); expect(h.issued).toHaveLength(0); expect(h.listened).toHaveLength(0); const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒')); expect(refused?.title).toContain('每条核心参数都有明确状态'); }); it('Important 4 · 同一堵墙第二次(trail 里已有同一句拒回痕)→ 不再空转到 exhausted,发兜底执行工单交人、立监听', async () => { const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …'; const h = harness({ parsed: AUTO_RELEASE, runtime: AUTO_RELEASE_RUNTIME, sfeelCondition: () => false, acceptanceIssueRejects: reason, // 模拟"上一拍已经在这堵墙上留过一条一模一样的痕" trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }], steps: [RELEASE_STEP], }); const out = await h.runner.runTurn(TASK); expect(out.status).toBe('awaiting'); /* 发的是兜底的**执行工单**,不是 workOrder:'acceptance'(那正是刚撞过的那堵墙,再发一遍只会再撞一次) */ expect(h.issued).toHaveLength(1); expect(h.issued[0]?.workOrder).toBe('execution'); expect(String(h.issued[0]?.card['title'])).toContain('T_release'); const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>; expect(basis.find((b) => b.k === '这一步')?.v).toBe('T_release'); expect(basis.find((b) => b.k === '拒回原话')?.v).toContain('每条核心参数都有明确状态'); // 立了人事件监听,人一答就叫得醒(不是伪装成"等外部回音"却没人会来) expect(h.listened).toEqual([{ eventType: 'human' }]); }); it('Important 4 · 兜底执行工单自己也发不出去(如调人预算触顶):不抛、状态仍 awaiting、留痕两层原因', async () => { const reason = '调人预算已经触顶(3/3)'; const h = harness({ parsed: AUTO_RELEASE, runtime: AUTO_RELEASE_RUNTIME, sfeelCondition: () => false, // issueRejects 对任何 workOrder 都抛 —— 验收卡(raiseAcceptance 内部)与 // 兜底执行工单(下面新增的那一支)两次尝试都会撞上同一句话 issueRejects: reason, trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }], steps: [RELEASE_STEP], }); const out = await h.runner.runTurn(TASK); expect(out.status).toBe('awaiting'); expect(h.issued).toHaveLength(0); expect(h.listened).toHaveLength(0); const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去')); expect(note).toBeDefined(); expect(note?.title).toContain('调人预算已经触顶(3/3)'); }); }); /** * **配方办不下去 = 交人,而「交人」得真有一张卡**(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); }); /** * **兜底卡自己也会抛 —— 精确复现 C3 那种无限热循环**(2026-08-29 复审 I1 二审)。 * * `deps.cards.issue` 经 `human-tool-service.assertBudget`,调人预算触顶时抛 * `HumanCallBudgetExceededError`。这一句从前**裸着**调:抛出去 `runTurn` 崩、 * `engine.advance` / `task-service.tick` 都不捕获、`task-advancer` 只写 stderr 就 * break,任务停在 `running`,下一个 2 秒 interval 再来一遍 —— 不烧预算、不换状态、 * 界面纹丝不动。这条用例断言 `runTurn` **不抛**、状态落 `awaiting`、留了一条痕。 */ it('连兜底卡都发不出去(如调人预算触顶):不抛、状态落 awaiting、留痕', async () => { const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => false, steps: [BROKEN], issueRejects: '调人预算已经触顶(3/3)', }); const out = await h.runner.runTurn(TASK); expect(out.status).toBe('awaiting'); expect(h.issued).toHaveLength(0); expect(h.listened).toHaveLength(0); const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去')); expect(note).toBeDefined(); expect(note?.title).toContain('调人预算已经触顶(3/3)'); expect(String(note?.detail?.['hint'])).toContain('nope.nope'); }); }); /** * **配方由 `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); }); });