@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话双区记忆会话接线单测(P4-3,design/session-dual-zone-memory-design.md §5):
|
|
3
|
+
* - 总闸默认关:工具清单与注入面输出逐字节不变、零账本事件、读回空串(未知值同样关);
|
|
4
|
+
* - 捕获缝:白名单闭集内工具的结果信封只投影形状字段(verdict/条数/价格带),
|
|
5
|
+
* 零名称零 URL零自由文本;证据只走指针(摘要 session_ref + 观察序号);
|
|
6
|
+
* - 负面清单端到端:证件号/手机号/URL/凭证形态的载荷永不进任何一区;
|
|
7
|
+
* - 首访读回为空;有活笔记后读回有界(条数上界 + 字符上界);
|
|
8
|
+
* - 晋升:缺 owner 原话引用拒收;模型只可提议(propose 零落账);过期源拒收;
|
|
9
|
+
* - 路由规则(§3):preference→动机闸 / trip_fact→时间线闸 / 同行人约束→同行人闸,
|
|
10
|
+
* 缺路由载荷 routing_required 拒收(笔记本绝不私存该语义);既有闸拒绝即整笔回滚;
|
|
11
|
+
* - 更正即弃(drop)。
|
|
12
|
+
*
|
|
13
|
+
* 全离线:真实 apply() 注册面 + mkdtemp 隔离 stateRoot;零网络、零真实 LLM、
|
|
14
|
+
* 零对 ts/dsh-runtime/gotry-state 的写入(用前后快照断言)。
|
|
15
|
+
* 运行:cd ts && npx tsx scripts/session-zone-wiring-tests.ts
|
|
16
|
+
*/
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { apply } from '../src/index.js';
|
|
22
|
+
import { ensureLedger, openLedgerIfExists } from '../src/state-ledger.js';
|
|
23
|
+
import { readZoneLog } from '../src/session-zone-ledger.js';
|
|
24
|
+
import { ZONE_BRIEF_MAX_CHARS, ZONE_OBSERVABLE_TOOLS, classifyPromotionRouting, observeToolResult, projectToolObservation, promoteWithRouting, renderSessionZoneBrief, renderZoneBrief, resetZoneSessionBindingForTests, resolveZoneSwitch, routingEscapeViolation, zoneSessionRef, } from '../src/session-zone-wiring.js';
|
|
25
|
+
import { ZONE_EVENT_KINDS, readWorkingZone } from '../src/session-zones.js';
|
|
26
|
+
let n = 0;
|
|
27
|
+
function pass(name, body) {
|
|
28
|
+
return Promise.resolve(body()).then(() => {
|
|
29
|
+
console.log(` ${++n}. ${name} OK`);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const roots = [];
|
|
33
|
+
function freshRoot(tag) {
|
|
34
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-zone-wire-${tag}-`));
|
|
35
|
+
roots.push(root);
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 离线检索夹具:注入 apply() 的 effect 缝,保证**绝不**触达真实 FlyAI
|
|
40
|
+
* (真实路径会从 homedir/env 读 key 并可能 spawn 真实 CLI,结果随机器而变)。
|
|
41
|
+
*/
|
|
42
|
+
const fixtureEffect = (async (fx) => {
|
|
43
|
+
if (fx.effect === 'FLYAI_SEARCH') {
|
|
44
|
+
return {
|
|
45
|
+
result: {
|
|
46
|
+
ok: true,
|
|
47
|
+
via: 'fixture',
|
|
48
|
+
evidence: '[fixture:zone-wiring] offline',
|
|
49
|
+
latencyMs: 0,
|
|
50
|
+
verdict: 'hit',
|
|
51
|
+
kind: fx.params?.kind ?? 'flight',
|
|
52
|
+
options: [
|
|
53
|
+
{ no: 'MU5111', name: '东方航空', depDateTime: '2027-11-11T07:35:00+08:00', arrDateTime: '2027-11-11T10:05:00+08:00', price: 880, jumpUrl: 'https://flights.example.com/a' },
|
|
54
|
+
{ no: 'CA1234', name: '国航', depDateTime: '2027-11-11T12:35:00+08:00', arrDateTime: '2027-11-11T15:05:00+08:00', price: 1260, jumpUrl: 'https://flights.example.com/b' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
trace: { effect: fx.effect, channel: 'fixture', attempts: 1, backoffMs: 0, breaker: 'off', evidence: ['[fixture:zone-wiring]'] },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { result: null, trace: { effect: fx.effect, channel: 'fixture', attempts: 0, backoffMs: 0, breaker: 'off', evidence: ['[fixture:declined]'] } };
|
|
61
|
+
});
|
|
62
|
+
/** 真实 apply() 注册面(与 smoke 同款极简 ctx:只收工具与注入变量) */
|
|
63
|
+
function harness(cfg) {
|
|
64
|
+
const tools = [];
|
|
65
|
+
const variables = {};
|
|
66
|
+
const ctx = {
|
|
67
|
+
tools: { register: (t) => tools.push(t) },
|
|
68
|
+
systemPrompt: { variable: (name, provider) => { variables[name] = provider; } },
|
|
69
|
+
get: () => undefined,
|
|
70
|
+
on: () => () => { },
|
|
71
|
+
};
|
|
72
|
+
apply(ctx, {
|
|
73
|
+
timeoutMs: 30_000,
|
|
74
|
+
hbcliBin: 'hbcli-not-on-path',
|
|
75
|
+
sessionAccess: 'off',
|
|
76
|
+
...cfg,
|
|
77
|
+
}, { effect: fixtureEffect });
|
|
78
|
+
return {
|
|
79
|
+
tools,
|
|
80
|
+
variables,
|
|
81
|
+
tool: (name) => {
|
|
82
|
+
const t = tools.find(x => x.name === name);
|
|
83
|
+
if (!t)
|
|
84
|
+
throw new Error(`tool ${name} 未注册`);
|
|
85
|
+
return t;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const SESSION_ID = 'dsh-session-abcdef-0123456789';
|
|
90
|
+
/** scope 键 = dsh agent-loop 的 ScopeKey(agent 对象身份);exec.agent 是同一对象 */
|
|
91
|
+
const AGENT_A = { session: { id: SESSION_ID } };
|
|
92
|
+
const EXEC = { agent: AGENT_A };
|
|
93
|
+
const SESS = zoneSessionRef(SESSION_ID);
|
|
94
|
+
const SESSION_ID_B = 'dsh-session-999999-9876543210';
|
|
95
|
+
const AGENT_B = { session: { id: SESSION_ID_B } };
|
|
96
|
+
const EXEC_B = { agent: AGENT_B };
|
|
97
|
+
const SESS_B = zoneSessionRef(SESSION_ID_B);
|
|
98
|
+
const QUOTE = '对,我以后都不坐红眼航班,记着';
|
|
99
|
+
function zoneEventCount(root) {
|
|
100
|
+
const ledger = openLedgerIfExists(root);
|
|
101
|
+
if (!ledger)
|
|
102
|
+
return 0;
|
|
103
|
+
return ZONE_EVENT_KINDS.reduce((sum, k) => sum + ledger.readEvents(k, 10_000).length, 0);
|
|
104
|
+
}
|
|
105
|
+
/** 产品真实状态目录快照:本套件必须对它零写入 */
|
|
106
|
+
const RUNTIME_STATE = join(process.cwd(), 'dsh-runtime', 'gotry-state');
|
|
107
|
+
function runtimeSnapshot() {
|
|
108
|
+
if (!existsSync(RUNTIME_STATE))
|
|
109
|
+
return 'ABSENT';
|
|
110
|
+
return readdirSync(RUNTIME_STATE).sort().map(f => {
|
|
111
|
+
const s = statSync(join(RUNTIME_STATE, f));
|
|
112
|
+
return `${f}:${s.size}:${s.mtimeMs}`;
|
|
113
|
+
}).join('|');
|
|
114
|
+
}
|
|
115
|
+
const runtimeBefore = runtimeSnapshot();
|
|
116
|
+
async function main() {
|
|
117
|
+
// ---- 总闸语义 ----
|
|
118
|
+
await pass('总闸:仅明示 on 开启;缺省/未知值/大小写变体一律 fail-closed 关', () => {
|
|
119
|
+
assert.equal(resolveZoneSwitch('on'), 'on');
|
|
120
|
+
for (const raw of [undefined, null, '', 'off', 'ON', 'true', '1', 'enabled', 0, {}]) {
|
|
121
|
+
assert.equal(resolveZoneSwitch(raw), 'off', `未知值必须关:${JSON.stringify(raw)}`);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
await pass('会话引用:单向摘要、不透明有界、绝不含宿主 session id 原文;无身份即无引用', () => {
|
|
125
|
+
assert.ok(SESS.startsWith('s-'));
|
|
126
|
+
assert.ok(SESS.length <= 64);
|
|
127
|
+
assert.equal(SESS.includes(SESSION_ID), false, '摘要不得回带原文');
|
|
128
|
+
assert.equal(zoneSessionRef(SESSION_ID), SESS, '同 id 同引用(确定性)');
|
|
129
|
+
assert.notEqual(zoneSessionRef('other-session'), SESS);
|
|
130
|
+
for (const bad of [undefined, null, '', ' ', 42])
|
|
131
|
+
assert.equal(zoneSessionRef(bad), null);
|
|
132
|
+
});
|
|
133
|
+
// ---- 关闸惰性 ----
|
|
134
|
+
await pass('关闸惰性:工具清单与注入面变量清单都与基线逐项一致(关闸与 main 不可区分)', async () => {
|
|
135
|
+
const offRoot = freshRoot('off');
|
|
136
|
+
const off = harness({ stateRoot: offRoot });
|
|
137
|
+
const onRoot = freshRoot('on');
|
|
138
|
+
const on = harness({ stateRoot: onRoot, sessionZones: 'on' });
|
|
139
|
+
const offNames = off.tools.map(t => t.name);
|
|
140
|
+
const onNames = on.tools.map(t => t.name);
|
|
141
|
+
assert.equal(offNames.includes('gotry_session_zone_note'), false, '关闸时模型看不到分区入口');
|
|
142
|
+
assert.equal(offNames.includes('gotry_session_zone_promote'), false);
|
|
143
|
+
assert.deepEqual(onNames.filter(x => !x.startsWith('gotry_session_zone')), offNames, '开关只增两件分区工具,其他工具清单逐项不变');
|
|
144
|
+
// 注入面:关闸时**连变量名都不得多出**——多一个名字就是可观测差异
|
|
145
|
+
// (§48 benchmark-environment-bridge-tests 对产品模式变量清单逐项断言)。
|
|
146
|
+
assert.deepEqual(Object.keys(off.variables), ['current_date', 'time_anchor_card', 'motivation_brief', 'channel_routing_card'], '关闸注入面与 main 逐项一致(session_zone_brief 未注册)');
|
|
147
|
+
assert.equal('session_zone_brief' in off.variables, false, '关闸不得注册分区读回变量');
|
|
148
|
+
assert.equal(typeof on.variables['session_zone_brief'], 'function', '开闸才注册分区读回变量');
|
|
149
|
+
assert.deepEqual(Object.keys(on.variables).filter(k => k !== 'session_zone_brief'), Object.keys(off.variables), '开闸只增 session_zone_brief 一个变量名,其余注入面顺序与内容不变');
|
|
150
|
+
assert.equal(existsSync(join(offRoot, 'gotry-state', 'gotry-state.db')), false, '关闸不得建库');
|
|
151
|
+
// 其他既有注入面输出不受影响(逐字节)。channel_routing_card 内含「卡生成于 <ISO 时间戳>」,两次渲染相隔数毫秒就不同——
|
|
152
|
+
// 先把 ISO 时间戳归一化再逐字节比较,否则这条断言取决于两次渲染是否落在同一毫秒(间歇性红,CI 上实测过)。
|
|
153
|
+
const stripStamp = (text) => text.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, '<ts>');
|
|
154
|
+
for (const key of ['current_date', 'time_anchor_card', 'motivation_brief', 'channel_routing_card']) {
|
|
155
|
+
assert.equal(stripStamp(off.variables[key]()), stripStamp(on.variables[key]()), `既有注入面 ${key} 输出逐字节不变(时间戳归一化后)`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
await pass('关闸惰性:白名单工具 execute 不被包裹——跑一次检索后账本零分区事件', async () => {
|
|
159
|
+
const root = freshRoot('off-observe');
|
|
160
|
+
const off = harness({ stateRoot: root });
|
|
161
|
+
const search = off.tool('gotry_flyai_search');
|
|
162
|
+
await search.execute({ kind: 'flight', from: '上海', to: '大理', date: '2027-11-11' }, EXEC);
|
|
163
|
+
assert.equal(zoneEventCount(root), 0, '关闸时检索不产生任何分区事件');
|
|
164
|
+
const ledger = openLedgerIfExists(root);
|
|
165
|
+
if (ledger) {
|
|
166
|
+
const kinds = new Set(ledger.readEvents(undefined, 200).map(e => e.kind));
|
|
167
|
+
for (const k of ZONE_EVENT_KINDS)
|
|
168
|
+
assert.equal(kinds.has(k), false, `关闸不得出现 ${k}`);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// ---- 捕获缝投影纯度 ----
|
|
172
|
+
await pass('捕获缝:白名单闭集外零捕获;投影只含形状字段(零名称/零 URL/零自由文本)', () => {
|
|
173
|
+
const base = { session_ref: SESS, turn: 3, ts: new Date().toISOString() };
|
|
174
|
+
assert.deepEqual(projectToolObservation({ ...base, tool: 'gotry_motivation_save', result: { ok: true } }), [], '白名单外工具零捕获');
|
|
175
|
+
assert.deepEqual(projectToolObservation({ ...base, tool: 'gotry_flyai_search', result: null }), []);
|
|
176
|
+
assert.deepEqual(projectToolObservation({ ...base, tool: 'gotry_flyai_search', result: 'string' }), []);
|
|
177
|
+
const result = {
|
|
178
|
+
ok: true,
|
|
179
|
+
verdict: 'hit',
|
|
180
|
+
summary: '用户说想去大理,联系人 13800138000',
|
|
181
|
+
evidence: '[实时API:flyai] https://example.com/trace?token=abc',
|
|
182
|
+
options: [
|
|
183
|
+
{ no: 'MU5111', name: '东方航空', price: 880, jumpUrl: 'https://flights.example.com/a?uid=42' },
|
|
184
|
+
{ no: 'CA1234', name: '国航', price: 1260, jumpUrl: 'https://flights.example.com/b' },
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
const captures = projectToolObservation({ ...base, tool: 'gotry_flyai_search', result });
|
|
188
|
+
assert.equal(captures.length, 2, 'availability + price_band');
|
|
189
|
+
assert.deepEqual(captures.map(c => c.kind), ['availability', 'price_band']);
|
|
190
|
+
assert.deepEqual(captures.map(c => c.tier), ['resource', 'resource']);
|
|
191
|
+
assert.deepEqual(captures[0].payload, { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 });
|
|
192
|
+
assert.deepEqual(captures[1].payload, { tool: 'gotry_flyai_search', low: 880, high: 1260, samples: 2 });
|
|
193
|
+
const serialized = JSON.stringify(captures);
|
|
194
|
+
for (const forbidden of ['13800138000', 'https://', 'example.com', 'token=abc', '东方航空', '用户说想去大理']) {
|
|
195
|
+
assert.equal(serialized.includes(forbidden), false, `投影不得携带:${forbidden}`);
|
|
196
|
+
}
|
|
197
|
+
assert.deepEqual(captures.map(c => c.evidence_ref), [{ session_ref: SESS, turn: 3 }, { session_ref: SESS, turn: 3 }], '证据只走指针');
|
|
198
|
+
// 无价格的结果只产生 availability
|
|
199
|
+
const miss = projectToolObservation({ ...base, tool: 'gotry_session_search', result: { ok: true, verdict: 'miss', rates: [] } });
|
|
200
|
+
assert.equal(miss.length, 1);
|
|
201
|
+
assert.deepEqual(miss[0].payload, { tool: 'gotry_session_search', verdict: 'miss', option_count: 0 });
|
|
202
|
+
assert.deepEqual([...ZONE_OBSERVABLE_TOOLS].sort(), ['gotry_anything_search', 'gotry_flyai_search', 'gotry_hotel_search', 'gotry_session_search']);
|
|
203
|
+
});
|
|
204
|
+
await pass('开闸捕获:白名单工具跑完后 resource 层笔记落账,证据为指针,会话引用为摘要', async () => {
|
|
205
|
+
const root = freshRoot('on-observe');
|
|
206
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
207
|
+
const search = on.tool('gotry_flyai_search');
|
|
208
|
+
const out = await search.execute({ kind: 'flight', from: '上海', to: '大理', date: '2027-11-11' }, EXEC);
|
|
209
|
+
assert.ok(out, '工具返回值不被捕获缝改写');
|
|
210
|
+
const ledger = openLedgerIfExists(root);
|
|
211
|
+
const { state } = readZoneLog(ledger);
|
|
212
|
+
const notes = Object.values(state.hot);
|
|
213
|
+
assert.ok(notes.length >= 1, `应落至少一条 resource 笔记:${JSON.stringify(notes)}`);
|
|
214
|
+
for (const note of notes) {
|
|
215
|
+
assert.equal(note.tier, 'resource');
|
|
216
|
+
assert.equal(note.session_ref, SESS);
|
|
217
|
+
assert.equal(note.evidence_ref.session_ref, SESS);
|
|
218
|
+
assert.ok(Number.isInteger(note.evidence_ref.turn) && note.evidence_ref.turn >= 0);
|
|
219
|
+
assert.equal(JSON.stringify(note).includes(SESSION_ID), false, '分区不得出现宿主 session id 原文');
|
|
220
|
+
}
|
|
221
|
+
// 投影出的形状来自离线夹具(两条候选 + 880/1260 价格带),不依赖任何真实供应商
|
|
222
|
+
const payloads = notes.map(x => JSON.stringify(x.payload)).sort();
|
|
223
|
+
assert.ok(payloads.some(p => p.includes('"option_count":2')), `应有 availability 形状:${payloads.join(' | ')}`);
|
|
224
|
+
assert.ok(payloads.some(p => p.includes('"low":880') && p.includes('"high":1260')), `应有价格带形状:${payloads.join(' | ')}`);
|
|
225
|
+
const serialized = JSON.stringify(notes);
|
|
226
|
+
for (const forbidden of ['https://', 'example.com', '东方航空', '国航']) {
|
|
227
|
+
assert.equal(serialized.includes(forbidden), false, `落账的笔记不得携带:${forbidden}`);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
await pass('捕获缝续命:同一检索在 TTL 之后再跑一次 → 续 TTL 并回到读视图(不被 stale_rev 吞掉)', async () => {
|
|
231
|
+
const root = freshRoot('observe-touch');
|
|
232
|
+
const ledger = ensureLedger(root);
|
|
233
|
+
const t0 = new Date('2026-10-04T08:00:00.000Z');
|
|
234
|
+
const result = { ok: true, verdict: 'hit', options: [{ no: 'MU1', price: 880 }, { no: 'CA1', price: 1260 }] };
|
|
235
|
+
const first = observeToolResult(ledger, { tool: 'gotry_flyai_search', result, session_ref: SESS, ts: t0.toISOString() });
|
|
236
|
+
assert.deepEqual(first, { captured: 2, touched: 0, rejected: 0 }, '首次观察:两条形状笔记诞生');
|
|
237
|
+
const ids = Object.keys(readZoneLog(ledger).state.hot).sort();
|
|
238
|
+
assert.equal(ids.length, 2);
|
|
239
|
+
// 40 分钟后(resource 30min 已过期)同一检索再跑:必须续命,不得被默默丢掉
|
|
240
|
+
const later = new Date(t0.getTime() + 40 * 60_000).toISOString();
|
|
241
|
+
const second = observeToolResult(ledger, { tool: 'gotry_flyai_search', result, session_ref: SESS, ts: later });
|
|
242
|
+
assert.deepEqual(second, { captured: 0, touched: 2, rejected: 0 }, '同形观察 = 续命,不是 rejected');
|
|
243
|
+
assert.deepEqual(Object.keys(readZoneLog(ledger).state.hot).sort(), ids, '续命不新建主体');
|
|
244
|
+
const state = readZoneLog(ledger).state;
|
|
245
|
+
for (const id of ids) {
|
|
246
|
+
assert.equal(state.hot[id].rev, 2);
|
|
247
|
+
assert.equal(state.hot[id].last_touched_at, later);
|
|
248
|
+
}
|
|
249
|
+
assert.equal(readWorkingZone(state, { now: new Date(t0.getTime() + 41 * 60_000).toISOString(), session_ref: SESS }).length, 2, '续命后两条都回到读视图(未修复时这里是 0:工作区悄悄停供)');
|
|
250
|
+
});
|
|
251
|
+
// ---- 负面清单端到端 ----
|
|
252
|
+
await pass('负面清单端到端:证件号/手机号/URL/凭证形态的载荷永不进任何一区', async () => {
|
|
253
|
+
const root = freshRoot('negative');
|
|
254
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
255
|
+
const note = on.tool('gotry_session_zone_note');
|
|
256
|
+
const payloads = [
|
|
257
|
+
{ city: '大理', id_no: '310101199001011234' },
|
|
258
|
+
{ city: '大理', contact: '13800138000' },
|
|
259
|
+
{ city: '大理', src: 'https://booking.example.com/x' },
|
|
260
|
+
{ city: '大理', cred: 'api_key: sk-abcdefghijklmnop' },
|
|
261
|
+
{ city: '大理', bearer: 'Bearer abcdefghijklmnop' },
|
|
262
|
+
];
|
|
263
|
+
for (const payload of payloads) {
|
|
264
|
+
const r = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload }, EXEC);
|
|
265
|
+
assert.equal(r.ok, false, `应被拒:${JSON.stringify(payload)}`);
|
|
266
|
+
assert.equal(r.code, 'negative_list', `应为负面清单码:${JSON.stringify(r)}`);
|
|
267
|
+
}
|
|
268
|
+
assert.equal(zoneEventCount(root), 0, '全部拒收路径零落账');
|
|
269
|
+
// 合法片段照常落账
|
|
270
|
+
const good = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, EXEC);
|
|
271
|
+
assert.equal(good.ok, true);
|
|
272
|
+
assert.equal(zoneEventCount(root), 1);
|
|
273
|
+
});
|
|
274
|
+
// ---- 读回 ----
|
|
275
|
+
await pass('读回:首访空串;有活笔记后有界(条数上界 + 字符上界);关闸即空', async () => {
|
|
276
|
+
const root = freshRoot('brief');
|
|
277
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
278
|
+
assert.equal(on.variables['session_zone_brief']({ scope: AGENT_A }), '', '首访(无账本)读回空串');
|
|
279
|
+
const note = on.tool('gotry_session_zone_note');
|
|
280
|
+
for (const city of ['大理', '丽江', '香格里拉']) {
|
|
281
|
+
await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city } }, EXEC);
|
|
282
|
+
}
|
|
283
|
+
const brief = on.variables['session_zone_brief']({ scope: AGENT_A });
|
|
284
|
+
assert.ok(brief.includes('会话双区记忆'), `读回应含分区标题:${brief}`);
|
|
285
|
+
assert.ok(brief.includes('大理'));
|
|
286
|
+
assert.ok(brief.length <= ZONE_BRIEF_MAX_CHARS, '读回字符有界');
|
|
287
|
+
// 同一账本在关闸配置下:变量压根不注册;纯函数层即便被直接调用也返回空串
|
|
288
|
+
const offSame = harness({ stateRoot: root });
|
|
289
|
+
assert.equal('session_zone_brief' in offSame.variables, false, '关闸即便账本里有活笔记也不注册读回变量');
|
|
290
|
+
assert.equal(renderSessionZoneBrief({ ledger: openLedgerIfExists(root), now: new Date().toISOString(), sessionRef: SESS, zoneSwitch: 'off' }), '', '纯函数层的总闸守卫仍在(防御纵深:绕过注册点直调也读不出东西)');
|
|
291
|
+
});
|
|
292
|
+
await pass('读回按 scope 隔离:两个会话同进程时 B 读不到 A 的 resource 层笔记(设计 §1.1)', async () => {
|
|
293
|
+
const root = freshRoot('two-sessions');
|
|
294
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
295
|
+
const note = on.tool('gotry_session_zone_note');
|
|
296
|
+
// A 会话:一条 resource(本会话私有)+ 一条 intent(跨会话可读)
|
|
297
|
+
await note.execute({ action: 'capture', tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 7 } }, EXEC);
|
|
298
|
+
await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, EXEC);
|
|
299
|
+
// B 会话:自己的 resource
|
|
300
|
+
await note.execute({ action: 'capture', tier: 'resource', kind: 'availability', payload: { tool: 'gotry_session_search', verdict: 'miss', option_count: 0 } }, EXEC_B);
|
|
301
|
+
assert.notEqual(SESS, SESS_B);
|
|
302
|
+
const briefA = on.variables['session_zone_brief']({ scope: AGENT_A });
|
|
303
|
+
const briefB = on.variables['session_zone_brief']({ scope: AGENT_B });
|
|
304
|
+
assert.ok(briefA.includes('option_count=7'), `A 读到自己的 resource:${briefA}`);
|
|
305
|
+
assert.equal(briefB.includes('option_count=7'), false, 'B 绝不得读到 A 的 resource 层笔记');
|
|
306
|
+
assert.ok(briefB.includes('gotry_session_search'), `B 读到自己的 resource:${briefB}`);
|
|
307
|
+
assert.ok(briefA.includes('大理') && briefB.includes('大理'), 'intent 层按契约跨会话可读');
|
|
308
|
+
// 无 scope(轻量宿主/未绑定)→ 不出 resource 段,fail-closed
|
|
309
|
+
const briefNoScope = on.variables['session_zone_brief']();
|
|
310
|
+
assert.equal(briefNoScope.includes('本次会话查到的'), false, '取不到 scope 时不出 resource 段');
|
|
311
|
+
assert.ok(briefNoScope.includes('大理'), 'intent 段仍在(跨会话可读)');
|
|
312
|
+
});
|
|
313
|
+
await pass('读回纯函数:条数上界生效;无会话绑定时 resource 层不出现(intent 跨会话可读)', () => {
|
|
314
|
+
const now = new Date().toISOString();
|
|
315
|
+
const working = Array.from({ length: 20 }, (_, i) => ({
|
|
316
|
+
schema: 'session_zone_hot_note.v1',
|
|
317
|
+
note_id: `hn|${SESS}|intent|destination|${i}`,
|
|
318
|
+
zone: 'hot_context',
|
|
319
|
+
tier: 'intent',
|
|
320
|
+
kind: 'destination',
|
|
321
|
+
payload: { city: `城市${i}` },
|
|
322
|
+
evidence_ref: { session_ref: SESS, turn: i },
|
|
323
|
+
session_ref: SESS,
|
|
324
|
+
created_at: now,
|
|
325
|
+
last_touched_at: now,
|
|
326
|
+
ttl_expires_at: now,
|
|
327
|
+
rev: 1,
|
|
328
|
+
}));
|
|
329
|
+
const resourceNote = { ...working[0], note_id: 'hn|res', tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 } };
|
|
330
|
+
const bound = renderZoneBrief({ working: [...working, resourceNote], notebook: [], sessionRef: SESS });
|
|
331
|
+
assert.ok(bound.includes('本次会话查到的'), '绑定会话时 resource 层出现');
|
|
332
|
+
assert.ok(bound.length <= ZONE_BRIEF_MAX_CHARS);
|
|
333
|
+
const unbound = renderZoneBrief({ working: [...working, resourceNote], notebook: [], sessionRef: null });
|
|
334
|
+
assert.equal(unbound.includes('本次会话查到的'), false, '无会话绑定时 resource 层不出现');
|
|
335
|
+
assert.equal(renderZoneBrief({ working: [], notebook: [], sessionRef: SESS }), '', '无活笔记 = 空串');
|
|
336
|
+
});
|
|
337
|
+
// ---- 晋升纪律 ----
|
|
338
|
+
await pass('晋升:缺 owner 原话引用拒收;表面闭集外拒收;模型 propose 零落账', async () => {
|
|
339
|
+
const root = freshRoot('promote-gate');
|
|
340
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
341
|
+
const note = on.tool('gotry_session_zone_note');
|
|
342
|
+
const promote = on.tool('gotry_session_zone_promote');
|
|
343
|
+
const captured = await note.execute({ action: 'capture', tier: 'intent', kind: 'budget_stance', payload: { redeye: 'never' } }, EXEC);
|
|
344
|
+
const noteId = captured.note_id;
|
|
345
|
+
const before = zoneEventCount(root);
|
|
346
|
+
const proposed = await note.execute({ action: 'propose', noteId, promoteKind: 'preference' }, EXEC);
|
|
347
|
+
assert.equal(proposed.ok, true);
|
|
348
|
+
assert.equal(proposed.requires_owner_confirm, true, '提议必须回「要 owner 确认」');
|
|
349
|
+
assert.equal(zoneEventCount(root), before, '提议零落账:模型永不自晋升');
|
|
350
|
+
// owner 引用闸(代码层类型化拒绝)
|
|
351
|
+
for (const bad of [
|
|
352
|
+
{ noteId, kind: 'preference', ownerQuote: '', surface: 'user_reply' },
|
|
353
|
+
{ noteId, kind: 'preference', ownerQuote: ' ', surface: 'user_reply' },
|
|
354
|
+
{ noteId, kind: 'preference', ownerQuote: 'x'.repeat(201), surface: 'user_reply' },
|
|
355
|
+
]) {
|
|
356
|
+
const r = await promote.execute(bad, EXEC);
|
|
357
|
+
assert.equal(r.ok, false, `应被拒:${JSON.stringify(bad)}`);
|
|
358
|
+
assert.equal(r.code, 'owner_confirm', `应为 owner_confirm:${JSON.stringify(r)}`);
|
|
359
|
+
}
|
|
360
|
+
// 闭集违规在宿主权 schema 闸即被拒(入口更前置;代码层闭集守卫是第二道)
|
|
361
|
+
for (const bad of [
|
|
362
|
+
{ noteId, kind: 'preference', ownerQuote: QUOTE, surface: 'model_said_so' },
|
|
363
|
+
{ noteId, kind: 'habit', ownerQuote: QUOTE, surface: 'user_reply' },
|
|
364
|
+
]) {
|
|
365
|
+
const r = await promote.execute(bad, EXEC);
|
|
366
|
+
assert.equal(r.ok, false, `应被拒:${JSON.stringify(bad)}`);
|
|
367
|
+
assert.match(String(r.summary ?? ''), /invalid arguments|must be one of/, `宿主权 schema 闸应报闭集违规:${JSON.stringify(r)}`);
|
|
368
|
+
}
|
|
369
|
+
// 直调纯函数层:绕过宿主 schema 后闭集守卫仍在(第二道)
|
|
370
|
+
const direct = promoteWithRouting(openLedgerIfExists(root), { noteId, kind: 'habit', ownerQuote: QUOTE, surface: 'user_reply', ts: new Date().toISOString() });
|
|
371
|
+
assert.equal(direct.ok, false);
|
|
372
|
+
assert.equal(direct.code, 'closed_set');
|
|
373
|
+
const directSurface = promoteWithRouting(openLedgerIfExists(root), { noteId, kind: 'preference', ownerQuote: QUOTE, surface: 'model_said_so', ts: new Date().toISOString() });
|
|
374
|
+
assert.equal(directSurface.code, 'closed_set');
|
|
375
|
+
assert.equal(zoneEventCount(root), before, '全部拒收路径零落账');
|
|
376
|
+
});
|
|
377
|
+
await pass('路由分类(§3):preference→动机 / trip_fact→时间线 / 点名同行人的约束→同行人 / 其余→笔记本', () => {
|
|
378
|
+
assert.equal(classifyPromotionRouting('preference', {}), 'motivation');
|
|
379
|
+
assert.equal(classifyPromotionRouting('trip_fact', {}), 'timeline');
|
|
380
|
+
assert.equal(classifyPromotionRouting('constraint', { companion_label: '爸爸' }), 'companion');
|
|
381
|
+
assert.equal(classifyPromotionRouting('constraint', { companion: '妈妈' }), 'companion');
|
|
382
|
+
assert.equal(classifyPromotionRouting('constraint', { redeye: 'never' }), 'notebook');
|
|
383
|
+
assert.equal(classifyPromotionRouting('lesson', {}), 'notebook');
|
|
384
|
+
});
|
|
385
|
+
await pass('路由 fail-closed:命中既有语义但缺路由载荷 → routing_required,零落账', async () => {
|
|
386
|
+
const root = freshRoot('routing-required');
|
|
387
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
388
|
+
const note = on.tool('gotry_session_zone_note');
|
|
389
|
+
const promote = on.tool('gotry_session_zone_promote');
|
|
390
|
+
const pref = await note.execute({ action: 'capture', tier: 'intent', kind: 'budget_stance', payload: { redeye: 'never' } }, EXEC);
|
|
391
|
+
const trip = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理', visited: true } }, EXEC);
|
|
392
|
+
const comp = await note.execute({ action: 'capture', tier: 'intent', kind: 'party_size', payload: { companion_label: '爸爸', adults: 2 } }, EXEC);
|
|
393
|
+
const before = zoneEventCount(root);
|
|
394
|
+
for (const bad of [
|
|
395
|
+
{ noteId: pref.note_id, kind: 'preference', ownerQuote: QUOTE, surface: 'user_reply' },
|
|
396
|
+
{ noteId: pref.note_id, kind: 'preference', ownerQuote: QUOTE, surface: 'user_reply', motivation: {} },
|
|
397
|
+
{ noteId: trip.note_id, kind: 'trip_fact', ownerQuote: QUOTE, surface: 'user_reply' },
|
|
398
|
+
{ noteId: comp.note_id, kind: 'constraint', ownerQuote: QUOTE, surface: 'user_reply' },
|
|
399
|
+
]) {
|
|
400
|
+
const r = await promote.execute(bad, EXEC);
|
|
401
|
+
assert.equal(r.ok, false, `应被拒:${JSON.stringify(bad)}`);
|
|
402
|
+
assert.equal(r.code, 'routing_required', `应 routing_required:${JSON.stringify(r)}`);
|
|
403
|
+
}
|
|
404
|
+
assert.equal(zoneEventCount(root), before, '路由缺料零落账:笔记本不私存该语义');
|
|
405
|
+
const ledger = openLedgerIfExists(root);
|
|
406
|
+
assert.equal(Object.keys(readZoneLog(ledger).state.notebook).length, 0);
|
|
407
|
+
});
|
|
408
|
+
await pass('路由逃逸:换个 kind 或抹掉 companion_label 都绕不过既有闸(模型选的 kind 不构成豁免)', async () => {
|
|
409
|
+
const root = freshRoot('routing-escape');
|
|
410
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
411
|
+
const note = on.tool('gotry_session_zone_note');
|
|
412
|
+
const promote = on.tool('gotry_session_zone_promote');
|
|
413
|
+
const ledger = ensureLedger(root);
|
|
414
|
+
// ① 持久偏好声明成 lesson:源笔记 kind=budget_stance 属动机语义 → 拒
|
|
415
|
+
const pref = await note.execute({ action: 'capture', tier: 'intent', kind: 'budget_stance', payload: { redeye: 'never' } }, EXEC);
|
|
416
|
+
const asLesson = await promote.execute({ noteId: pref.note_id, kind: 'lesson', ownerQuote: QUOTE, surface: 'user_reply' }, EXEC);
|
|
417
|
+
assert.equal(asLesson.ok, false, '偏好换 kind=lesson 必须被拒');
|
|
418
|
+
assert.equal(asLesson.code, 'routing_required');
|
|
419
|
+
assert.match(String(asLesson.summary), /budget_stance|motivation/);
|
|
420
|
+
// ② 同行人约束抹掉 companion_label:载荷仍带 health → 拒
|
|
421
|
+
const comp = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, EXEC);
|
|
422
|
+
const strippedLabel = await promote.execute({ noteId: comp.note_id, kind: 'constraint', ownerQuote: QUOTE, surface: 'user_reply', payload: { health: ['晕车'] } }, EXEC);
|
|
423
|
+
assert.equal(strippedLabel.ok, false, '抹掉 companion_label 仍不得绕过同行人闸');
|
|
424
|
+
assert.equal(strippedLabel.code, 'routing_required');
|
|
425
|
+
// ③ 备了动机载荷却声明成 constraint:路由载荷与分类不符 → 拒
|
|
426
|
+
const mismatch = await promote.execute({ noteId: comp.note_id, kind: 'constraint', ownerQuote: QUOTE, surface: 'user_reply', payload: { quiet: true }, motivation: { hard: { wake_not_before: '07:00' } } }, EXEC);
|
|
427
|
+
assert.equal(mismatch.ok, false);
|
|
428
|
+
assert.equal(mismatch.code, 'routing_required');
|
|
429
|
+
assert.equal(Object.keys(readZoneLog(ledger).state.notebook).length, 0, '三条逃逸路径零笔记本条目');
|
|
430
|
+
assert.equal(ledger.readMotivation(), null, '零动机写入');
|
|
431
|
+
// 纯函数层同口径(无源权威语义的 lesson 正常通过)
|
|
432
|
+
assert.equal(routingEscapeViolation({ kind: 'lesson', authority: 'notebook', payload: { lesson: 'too_tight' }, sourceKind: 'availability' }), null);
|
|
433
|
+
assert.ok(routingEscapeViolation({ kind: 'lesson', authority: 'notebook', payload: { weights: { x: 1 } }, sourceKind: 'availability' }));
|
|
434
|
+
assert.equal(routingEscapeViolation({ kind: 'preference', authority: 'motivation', payload: { weights: { x: 1 } }, sourceKind: 'budget_stance' }), null, '已走权威路由的不算逃逸');
|
|
435
|
+
});
|
|
436
|
+
await pass('动机闸拒绝即整笔回滚:被拒的偏好晋升不得留下笔记本条目(saved 二义性不可信)', async () => {
|
|
437
|
+
const root = freshRoot('motivation-refused');
|
|
438
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
439
|
+
const note = on.tool('gotry_session_zone_note');
|
|
440
|
+
const promote = on.tool('gotry_session_zone_promote');
|
|
441
|
+
const ledger = ensureLedger(root);
|
|
442
|
+
// 先用同一条原话把 homeCity 写进画像:之后换城市但复用**同一条**原话 →
|
|
443
|
+
// mergeProfile 判为「无新证据」拒收,且 saved:false 不带任何 reason。
|
|
444
|
+
ledger.appendMotivationPatch({ homeCity: '上海', homeCityEvidence: QUOTE, evidence: [QUOTE] });
|
|
445
|
+
assert.equal(ledger.readMotivation()?.homeCityPreference?.value, '上海');
|
|
446
|
+
const captured = await note.execute({ action: 'capture', tier: 'intent', kind: 'budget_stance', payload: { home: 'moved' } }, EXEC);
|
|
447
|
+
const zoneBefore = zoneEventCount(root);
|
|
448
|
+
const r = await promote.execute({ noteId: captured.note_id, kind: 'preference', ownerQuote: QUOTE, surface: 'user_reply', motivation: { homeCity: '北京' } }, EXEC);
|
|
449
|
+
assert.equal(r.ok, false, `动机闸未落地该断言时必须拒绝整笔:${JSON.stringify(r)}`);
|
|
450
|
+
assert.equal(r.code, 'routed_rejected');
|
|
451
|
+
assert.equal(ledger.readMotivation()?.homeCityPreference?.value, '上海', '画像未被改写');
|
|
452
|
+
assert.equal(zoneEventCount(root), zoneBefore, '笔记本零新事件(同事务回滚)');
|
|
453
|
+
assert.equal(Object.keys(readZoneLog(ledger).state.notebook).length, 0);
|
|
454
|
+
// 带新证据的同一断言则正常落地
|
|
455
|
+
const freshQuote = '我搬到北京了,以后默认从北京出发';
|
|
456
|
+
const ok2 = await promote.execute({ noteId: captured.note_id, kind: 'preference', ownerQuote: freshQuote, surface: 'user_reply', motivation: { homeCity: '北京' } }, EXEC);
|
|
457
|
+
assert.equal(ok2.ok, true, JSON.stringify(ok2));
|
|
458
|
+
assert.equal(ok2.routed?.applied, true);
|
|
459
|
+
assert.equal(ledger.readMotivation()?.homeCityPreference?.value, '北京');
|
|
460
|
+
});
|
|
461
|
+
await pass('路由落地:preference 经动机闸 / trip_fact 经时间线闸 / 约束经同行人闸,笔记本同事务记断言+血缘', async () => {
|
|
462
|
+
const root = freshRoot('routing-apply');
|
|
463
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
464
|
+
const note = on.tool('gotry_session_zone_note');
|
|
465
|
+
const promote = on.tool('gotry_session_zone_promote');
|
|
466
|
+
const ledger = ensureLedger(root);
|
|
467
|
+
const pref = await note.execute({ action: 'capture', tier: 'intent', kind: 'budget_stance', payload: { redeye: 'never' } }, EXEC);
|
|
468
|
+
const prefOut = await promote.execute({ noteId: pref.note_id, kind: 'preference', ownerQuote: QUOTE, surface: 'user_reply', motivation: { hard: { wake_not_before: '07:00' } } }, EXEC);
|
|
469
|
+
assert.equal(prefOut.ok, true, JSON.stringify(prefOut));
|
|
470
|
+
assert.equal(prefOut.authority, 'motivation');
|
|
471
|
+
assert.equal(prefOut.routed?.applied, true, '动机闸真的落地(单一写权威)');
|
|
472
|
+
const profile = ledger.readMotivation();
|
|
473
|
+
assert.deepEqual(profile?.hard, { wake_not_before: '07:00' }, '硬约束进动机画像');
|
|
474
|
+
assert.ok(profile?.evidence?.includes(QUOTE), '动机证据 = owner 原话');
|
|
475
|
+
const trip = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理', visited: true } }, EXEC);
|
|
476
|
+
const tripOut = await promote.execute({ noteId: trip.note_id, kind: 'trip_fact', ownerQuote: QUOTE, surface: 'user_reply', trip: { destination: '大理', start: '2026-10-01', end: '2026-10-05' } }, EXEC);
|
|
477
|
+
assert.equal(tripOut.ok, true, JSON.stringify(tripOut));
|
|
478
|
+
assert.equal(tripOut.authority, 'timeline');
|
|
479
|
+
assert.equal(ledger.readTrips().length, 1, '行程进时间线');
|
|
480
|
+
assert.equal(ledger.readTrips()[0].evidence, QUOTE);
|
|
481
|
+
const comp = await note.execute({ action: 'capture', tier: 'intent', kind: 'party_size', payload: { companion_label: '爸爸', adults: 2 } }, EXEC);
|
|
482
|
+
const compOut = await promote.execute({ noteId: comp.note_id, kind: 'constraint', ownerQuote: QUOTE, surface: 'approval_card', companion: { label: '爸爸', constraints: { health: ['晕车'] } } }, EXEC);
|
|
483
|
+
assert.equal(compOut.ok, true, JSON.stringify(compOut));
|
|
484
|
+
assert.equal(compOut.authority, 'companion');
|
|
485
|
+
assert.deepEqual(ledger.readCompanions().map(c => c.label), ['爸爸'], '约束进同行人档案');
|
|
486
|
+
const notebook = Object.values(readZoneLog(ledger).state.notebook);
|
|
487
|
+
assert.equal(notebook.length, 3, '三条笔记本条目(断言 + 血缘)');
|
|
488
|
+
for (const entry of notebook) {
|
|
489
|
+
assert.equal(entry.owner_confirm.quote, QUOTE);
|
|
490
|
+
assert.equal(entry.origin.session_ref, SESS, '血缘指向源会话');
|
|
491
|
+
assert.ok(entry.origin.note_id, '血缘指向源笔记');
|
|
492
|
+
}
|
|
493
|
+
// 纯教训留在笔记本自身(源笔记 kind 无既有权威语义、载荷无权威形状键)
|
|
494
|
+
const lessonNote = await note.execute({ action: 'capture', tier: 'resource', kind: 'availability', payload: { lesson: 'too_tight' } }, EXEC);
|
|
495
|
+
const lessonOut = await promote.execute({ noteId: lessonNote.note_id, kind: 'lesson', ownerQuote: QUOTE, surface: 'user_reply' }, EXEC);
|
|
496
|
+
assert.equal(lessonOut.ok, true, JSON.stringify(lessonOut));
|
|
497
|
+
assert.equal(lessonOut.authority, 'notebook');
|
|
498
|
+
assert.equal(lessonOut.routed, null, 'notebook 路由无外部权威写');
|
|
499
|
+
});
|
|
500
|
+
await pass('路由拒绝即整笔回滚:时间线闸冲突 → 笔记本零条目、时间线零新行', async () => {
|
|
501
|
+
const root = freshRoot('routing-rollback');
|
|
502
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
503
|
+
const note = on.tool('gotry_session_zone_note');
|
|
504
|
+
const promote = on.tool('gotry_session_zone_promote');
|
|
505
|
+
const ledger = ensureLedger(root);
|
|
506
|
+
ledger.appendTripEvent({ destination: '大理', start: '2026-10-01', end: '2026-10-05', source: 'user-verbatim', evidence: '去年去过大理' });
|
|
507
|
+
const trips = ledger.readTrips().length;
|
|
508
|
+
const captured = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, EXEC);
|
|
509
|
+
const zoneBefore = zoneEventCount(root);
|
|
510
|
+
const r = await promote.execute({ noteId: captured.note_id, kind: 'trip_fact', ownerQuote: QUOTE, surface: 'user_reply', trip: { destination: '大理', start: '2026-10-03', end: '2026-10-08' } }, EXEC);
|
|
511
|
+
assert.equal(r.ok, false, `重叠行程应被时间线闸拒绝:${JSON.stringify(r)}`);
|
|
512
|
+
assert.equal(r.code, 'routed_rejected');
|
|
513
|
+
assert.equal(ledger.readTrips().length, trips, '时间线零新行');
|
|
514
|
+
assert.equal(zoneEventCount(root), zoneBefore, '笔记本零新事件(同事务回滚)');
|
|
515
|
+
assert.equal(Object.keys(readZoneLog(ledger).state.notebook).length, 0);
|
|
516
|
+
});
|
|
517
|
+
await pass('更正即弃:drop 后出读视图;过期源不得晋升(同一过期纯函数)', async () => {
|
|
518
|
+
const root = freshRoot('drop');
|
|
519
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
520
|
+
const note = on.tool('gotry_session_zone_note');
|
|
521
|
+
const captured = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, EXEC);
|
|
522
|
+
const dropped = await note.execute({ action: 'drop', noteId: captured.note_id }, EXEC);
|
|
523
|
+
assert.equal(dropped.ok, true);
|
|
524
|
+
assert.equal(dropped.appended, true);
|
|
525
|
+
const ledger = openLedgerIfExists(root);
|
|
526
|
+
assert.equal(Object.keys(readZoneLog(ledger).state.hot).length, 0, 'drop 后出状态');
|
|
527
|
+
// 过期源:注入未来时钟判定(晋升纯函数层的 source_expired)
|
|
528
|
+
const relive = await note.execute({ action: 'capture', tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 1 } }, EXEC);
|
|
529
|
+
const future = new Date(Date.now() + 61 * 60_000).toISOString();
|
|
530
|
+
const expired = promoteWithRouting(ledger, { noteId: relive.note_id, kind: 'lesson', ownerQuote: QUOTE, surface: 'user_reply', ts: future });
|
|
531
|
+
assert.equal(expired.ok, false);
|
|
532
|
+
assert.equal(expired.code, 'source_expired', `过期源必须拒收:${JSON.stringify(expired)}`);
|
|
533
|
+
});
|
|
534
|
+
await pass('CAS 修订经工具面:stale_rev 拒收;正确父 rev 推进到 rev 2', async () => {
|
|
535
|
+
const root = freshRoot('cas');
|
|
536
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
537
|
+
const note = on.tool('gotry_session_zone_note');
|
|
538
|
+
const captured = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, EXEC);
|
|
539
|
+
const stale = await note.execute({ action: 'revise', noteId: captured.note_id, expectedRev: 5, payload: { city: '丽江' } }, EXEC);
|
|
540
|
+
assert.equal(stale.ok, false);
|
|
541
|
+
assert.equal(stale.code, 'stale_rev');
|
|
542
|
+
const good = await note.execute({ action: 'revise', noteId: captured.note_id, expectedRev: 1, payload: { city: '丽江' } }, EXEC);
|
|
543
|
+
assert.equal(good.ok, true);
|
|
544
|
+
assert.equal(good.appended, true);
|
|
545
|
+
const ledger = openLedgerIfExists(root);
|
|
546
|
+
assert.equal(readZoneLog(ledger).state.hot[captured.note_id].rev, 2);
|
|
547
|
+
});
|
|
548
|
+
await pass('无会话身份:分区工具 fail-closed 拒绝(不记忆无主体片段),零落账', async () => {
|
|
549
|
+
const root = freshRoot('no-session');
|
|
550
|
+
const on = harness({ stateRoot: root, sessionZones: 'on' });
|
|
551
|
+
const note = on.tool('gotry_session_zone_note');
|
|
552
|
+
const r = await note.execute({ action: 'capture', tier: 'intent', kind: 'destination', payload: { city: '大理' } }, { agent: {} });
|
|
553
|
+
assert.equal(r.ok, false);
|
|
554
|
+
assert.equal(r.code, 'bad_session');
|
|
555
|
+
assert.equal(zoneEventCount(root), 0);
|
|
556
|
+
});
|
|
557
|
+
assert.equal(runtimeSnapshot(), runtimeBefore, '本套件必须对 ts/dsh-runtime/gotry-state 零写入(产品真实状态)');
|
|
558
|
+
console.log(`\nSESSION ZONE WIRING TESTS: ${n}/21 OK(P4-3 会话接线:默认关惰性/捕获缝形状投影/负面清单端到端/读回有界/propose 零落账/owner 引用闸/§3 路由 fail-closed 与同事务回滚;隔离 stateRoot,全离线)`);
|
|
559
|
+
}
|
|
560
|
+
main()
|
|
561
|
+
.catch(e => { console.error(e); process.exitCode = 1; })
|
|
562
|
+
.finally(() => {
|
|
563
|
+
resetZoneSessionBindingForTests();
|
|
564
|
+
for (const root of roots)
|
|
565
|
+
rmSync(root, { recursive: true, force: true });
|
|
566
|
+
});
|
|
@@ -328,6 +328,22 @@ pass('fold:重复事件(同 rev 重放)天然 no-op;幂等键遵循设计钉死
|
|
|
328
328
|
const rev = z.events[1];
|
|
329
329
|
assert.match(zoneIdemKey(cap), /^hotctx:.+:1$/);
|
|
330
330
|
assert.match(zoneIdemKey(rev), /^hotctx:.+:2$/);
|
|
331
|
+
// 同一事件重放 → 同一幂等键(落账本后由 UNIQUE 索引物理去重)
|
|
332
|
+
assert.equal(zoneIdemKey(cap), zoneIdemKey({ ...cap }));
|
|
333
|
+
});
|
|
334
|
+
pass('幂等键带生代:drop→重捕获的同 id 同 rev 不与旧生命周期碰撞(P4-2 物理化暴露)', () => {
|
|
335
|
+
const z = freshZone();
|
|
336
|
+
apply(z, captureHotNote(z.zone, captureInput()));
|
|
337
|
+
const noteId = Object.keys(z.zone.hot)[0];
|
|
338
|
+
const firstBirth = z.events[0];
|
|
339
|
+
apply(z, dropHotNote(z.zone, noteId, at(5 * MIN)));
|
|
340
|
+
apply(z, captureHotNote(z.zone, captureInput({ ts: at(6 * MIN) })));
|
|
341
|
+
const reborn = z.events[2];
|
|
342
|
+
assert.equal(reborn.note.note_id, noteId, '语义派生 id:重捕获同 id');
|
|
343
|
+
assert.equal(reborn.note.rev, 1, '新生命周期 rev 回到 1');
|
|
344
|
+
assert.notEqual(zoneIdemKey(reborn), zoneIdemKey(firstBirth), '同 id 同 rev 的两个生命周期必须是不同幂等键,否则 UNIQUE 索引会吞掉「更正后仍真」的重捕获');
|
|
345
|
+
// 同一生命周期内重放仍同键(去重不被生代破坏)
|
|
346
|
+
assert.equal(zoneIdemKey(reborn), zoneIdemKey({ ...reborn }));
|
|
331
347
|
});
|
|
332
348
|
pass('fold:drop→重捕获生命周期(更正后仍真);fold 不改入参(纯度)', () => {
|
|
333
349
|
const z = freshZone();
|
|
@@ -355,4 +371,4 @@ pass('契约原语:canonicalJson 键序规范化与 makeHotNoteId 稳定性', ()
|
|
|
355
371
|
assert.equal(a, b, '键序无关:同语义载荷同 id(重放天然幂等)');
|
|
356
372
|
assert.notEqual(a, makeHotNoteId({ session_ref: SESS_A, tier: 'intent', kind: 'destination', payload: { city: '丽江' } }));
|
|
357
373
|
});
|
|
358
|
-
console.log(`\nSESSION ZONES TESTS: ${n}/
|
|
374
|
+
console.log(`\nSESSION ZONES TESTS: ${n}/22 OK(P4-1 分区契约纯核:闭集/负面清单写侧/rev CAS/注入时钟分层 TTL/确定性 fold;全离线)`);
|
|
@@ -15,6 +15,9 @@ import { join } from 'node:path';
|
|
|
15
15
|
import { spawnBounded } from '../capabilities/spawn-bounded.js';
|
|
16
16
|
import { callHbcliJson, searchHotels } from '../capabilities/hbcli.js';
|
|
17
17
|
import { anythingSearch } from '../capabilities/anything.js';
|
|
18
|
+
// issue #617:本套件要真起 fake hbcli(进程树回收证明),而 `GOTRY_HBCLI_LIVE=0 ./scripts/run-all-tests.sh`
|
|
19
|
+
// 会把 anythingSearch 的离线开关带进来——此处显式开启,不让外部环境改变进程回收断言的对象。
|
|
20
|
+
delete process.env['GOTRY_HBCLI_LIVE'];
|
|
18
21
|
const root = mkdtempSync(join(tmpdir(), 'gotry-519-abort-'));
|
|
19
22
|
const ownedPids = new Set();
|
|
20
23
|
let activeWork;
|