Archived
harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条
human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、 data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task (ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest; 迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。 逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -71,6 +71,8 @@ function harness(
|
||||
viewFn?: (written: Harness['written']) => Record<string, unknown>;
|
||||
/** 这个活动清单声明过哪些监听(不给就当它声明了 data —— 老测试照跑) */
|
||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
|
||||
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -157,6 +159,11 @@ function harness(
|
||||
issued.push({ workOrder: 'elicitation', card: { ask: input.ask, fields: input.fields.map((f) => f.key) } });
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
decide: async (input: { ask: string; itemId?: string; variant?: string; checklist?: ReadonlyArray<string> }) => {
|
||||
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
unanswered: async () => over.unanswered ?? [],
|
||||
} as never,
|
||||
listeners: {
|
||||
register: async (input: { eventType: string }) => {
|
||||
@@ -1333,6 +1340,96 @@ describe('画面不再静默丢帧', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
@@ -1886,3 +1983,129 @@ describe('worldTouched 要落进保存的局面', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user