Archived
资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。
**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算
**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」
**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。
验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } 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';
|
||||
@@ -37,6 +37,12 @@ type Harness = {
|
||||
readonly pushed: string[];
|
||||
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
|
||||
readonly writesAtPush: number[];
|
||||
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
|
||||
readonly appended: Array<{ kind: string; title: string }>;
|
||||
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
|
||||
readonly toolCalls: string[];
|
||||
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
|
||||
readonly listened: Array<{ eventType: string }>;
|
||||
envelope: { model: unknown; parsed: CaseModel; runtime: CaseRuntime; pending?: PendingAcceptance };
|
||||
};
|
||||
|
||||
@@ -59,6 +65,12 @@ function harness(
|
||||
writeNoop?: boolean;
|
||||
/** 撤行时草稿层里根本没有那一行(主键写法对不上) */
|
||||
dropMisses?: boolean;
|
||||
/** doneWhen 表达式求值的桩(不给就恒真,同旧行为) */
|
||||
sfeelCondition?: (expr: string, scope: Record<string, unknown>) => boolean;
|
||||
/** 动态任务视图:按「此刻已写下几行」现算 —— 测「先写后 finish 的 doneWhen 用当下值」要它 */
|
||||
viewFn?: (written: Harness['written']) => Record<string, unknown>;
|
||||
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
|
||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -68,6 +80,9 @@ function harness(
|
||||
const dropped: Harness['dropped'] = [];
|
||||
const pushed: string[] = [];
|
||||
const writesAtPush: number[] = [];
|
||||
const appended: Harness['appended'] = [];
|
||||
const toolCalls: string[] = [];
|
||||
const listened: Array<{ eventType: string }> = [];
|
||||
const h: Harness = {
|
||||
issued,
|
||||
saves,
|
||||
@@ -76,6 +91,9 @@ function harness(
|
||||
dropped,
|
||||
pushed,
|
||||
writesAtPush,
|
||||
appended,
|
||||
toolCalls,
|
||||
listened,
|
||||
runner: undefined as never,
|
||||
envelope: undefined as never,
|
||||
};
|
||||
@@ -93,9 +111,9 @@ function harness(
|
||||
},
|
||||
load: async () => h.envelope as never,
|
||||
},
|
||||
sfeel: { condition: () => true, expression: () => undefined } as never,
|
||||
sfeel: { condition: over.sfeelCondition ?? (() => true), expression: () => undefined } as never,
|
||||
data: {
|
||||
view: async () => over.scope ?? {},
|
||||
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' }] },
|
||||
],
|
||||
@@ -120,7 +138,10 @@ function harness(
|
||||
},
|
||||
} as never,
|
||||
tools: {
|
||||
invoke: async () => ({ ok: true }),
|
||||
invoke: async (input: { tool: string }) => {
|
||||
toolCalls.push(input.tool);
|
||||
return { ok: true };
|
||||
},
|
||||
isWriteEffect: async () => false,
|
||||
registerIntent: async () => undefined,
|
||||
busOf: async () => [
|
||||
@@ -137,9 +158,17 @@ function harness(
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
} as never,
|
||||
listeners: { register: async () => ({ id: 'lsn-1' }) } 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 () => undefined,
|
||||
append: async (entry: { kind: string; title: string }) => {
|
||||
appended.push({ kind: entry.kind, title: entry.title });
|
||||
},
|
||||
loadActivityMemory: async () => [],
|
||||
window: async () => over.trail ?? [],
|
||||
} as never,
|
||||
@@ -212,11 +241,166 @@ describe('提交放行 —— 终局唯放行,卡只发一张', () => {
|
||||
expect(h.saves.at(-1)?.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('裁决 reject → finished 不放行', async () => {
|
||||
const h = harness({ pending: { acceptanceCardId: 'card-9', verdict: 'reject' } });
|
||||
/*
|
||||
* **裁决非 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).toBe('finished');
|
||||
expect(outcome.released).toBe(false);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +418,12 @@ describe('onEvent —— 只记不跑', () => {
|
||||
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('叙事面 —— 任务自身轨迹喂回', () => {
|
||||
@@ -605,6 +795,131 @@ describe('空转的写当场拒', () => {
|
||||
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('宣告完成', () => {
|
||||
@@ -639,16 +954,111 @@ describe('宣告完成', () => {
|
||||
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
|
||||
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
|
||||
*/
|
||||
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -873,6 +1283,37 @@ describe('画面不再静默丢帧', () => {
|
||||
expect(h.writesAtPush).toEqual([2]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一拍把上一步收了、下一步的卡也发了 —— 画面得跟着走到下一步**
|
||||
* (2026-08-23 E04 实测:S4 计划审批收掉、S5 执行卡当场发出,而工作区还摆着
|
||||
* 审批那份只读件。人拿到的卡写着「把回执填到件上」,屏上一个可填的格子都没有,
|
||||
* 提交闸如实拦住,他无处可填 —— 墙拦住却不给路)。
|
||||
* 病根与 `liveScope` 那条同源:**拿拍首的局面去决定拍尾的事**。
|
||||
*/
|
||||
it('一串行动把案子推到下一步 → 推的是下一步的画面,不是拍首那一步的', async () => {
|
||||
const TWO: CaseModel = {
|
||||
name: '两步',
|
||||
autoComplete: true,
|
||||
items: [
|
||||
{ id: 'i1', name: '第一步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||
{ id: 'i2', name: '第二步', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] },
|
||||
],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const h = harness({
|
||||
parsed: TWO,
|
||||
runtime: { items: [{ id: 'i1', state: 'Active' }, { id: 'i2', state: 'Available' }] } as never,
|
||||
templates: ['step1-view', 'step2-view'],
|
||||
steps: [
|
||||
{ item: 'i1', intent: '办第一步', guide: '做法', writes: [], reads: [], tools: [], view: 'step1-view' },
|
||||
{ item: 'i2', intent: '办第二步', guide: '做法', writes: [], reads: [], tools: [], view: 'step2-view' },
|
||||
],
|
||||
actions: [{ kind: 'finish', itemId: 'i1' }],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.pushed).toEqual(['step2-view']);
|
||||
});
|
||||
|
||||
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
|
||||
Reference in New Issue
Block a user