@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28

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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +47 -3
  4. package/dist/capabilities/anything.js +26 -0
  5. package/dist/capabilities/doctor.js +11 -4
  6. package/dist/capabilities/ground-transfer.js +168 -1
  7. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  8. package/dist/capabilities/route-provider-conformance.js +341 -0
  9. package/dist/scripts/anything-path-measure-tests.js +133 -0
  10. package/dist/scripts/anything-path-measure.js +150 -0
  11. package/dist/scripts/anything-tests.js +136 -2
  12. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  13. package/dist/scripts/bootstrap-tests.js +71 -1
  14. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  15. package/dist/scripts/doctor-tests.js +37 -1
  16. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  17. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  18. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  19. package/dist/scripts/dsh-llm-tests.js +437 -0
  20. package/dist/scripts/g5-guard-tests.js +20 -1
  21. package/dist/scripts/g5-guard.js +5 -1
  22. package/dist/scripts/m3-cohort-tests.js +714 -0
  23. package/dist/scripts/m3-cohort.js +310 -0
  24. package/dist/scripts/nightly-evidence-tests.js +44 -2
  25. package/dist/scripts/nightly-evidence.js +21 -4
  26. package/dist/scripts/outcome-projection-tests.js +427 -0
  27. package/dist/scripts/persona-sim-tests.js +603 -0
  28. package/dist/scripts/persona-sim.js +1081 -0
  29. package/dist/scripts/replay-async.js +2 -0
  30. package/dist/scripts/replay.js +2 -0
  31. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  32. package/dist/scripts/session-zone-crash.js +49 -0
  33. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  34. package/dist/scripts/session-zone-observation-tests.js +492 -0
  35. package/dist/scripts/session-zone-observe.js +135 -0
  36. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  37. package/dist/scripts/session-zones-tests.js +17 -1
  38. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  39. package/dist/scripts/state-cli.js +16 -0
  40. package/dist/scripts/time-eval-tests.js +2 -0
  41. package/dist/scripts/unified-tests.js +37 -1
  42. package/dist/scripts/z3-race-tests.js +6 -1
  43. package/dist/src/city-scenario-tier.js +378 -0
  44. package/dist/src/dsh-llm.js +84 -14
  45. package/dist/src/index.js +160 -1
  46. package/dist/src/loop.js +16 -5
  47. package/dist/src/m3-cohort.js +1577 -0
  48. package/dist/src/outcome-projection.js +746 -0
  49. package/dist/src/session-zone-ledger.js +312 -0
  50. package/dist/src/session-zone-observation.js +354 -0
  51. package/dist/src/session-zone-wiring.js +658 -0
  52. package/dist/src/session-zones.js +7 -4
  53. package/dist/src/state-ledger.js +293 -40
  54. package/dist/src/unified.js +69 -5
  55. package/extension/manifest.json +2 -2
  56. package/package.json +1 -1
  57. package/ts/capabilities/anything.ts +27 -0
  58. package/ts/scripts/state-cli.ts +18 -0
  59. package/ts/src/contracts.ts +4 -0
  60. package/ts/src/dsh-llm.ts +111 -9
  61. package/ts/src/index.ts +175 -2
  62. package/ts/src/loop.ts +15 -5
  63. package/ts/src/state-ledger.ts +294 -40
  64. package/ts/src/unified.ts +91 -18
@@ -0,0 +1,603 @@
1
+ /**
2
+ * LLM-persona simulation harness tests (issue #22).
3
+ *
4
+ * Everything here runs offline and deterministically: both the product model and the
5
+ * persona model are served by a local fixture OpenAI-compatible endpoint, and a fetch spy
6
+ * asserts that no request in the whole suite leaves 127.0.0.1.
7
+ *
8
+ * Covers: deck contract, the no-credential stop rule (zero spend, zero writes), the full
9
+ * offline pipeline (session -> delivered plan -> finalize -> NPS -> fact-gate claim lock ->
10
+ * m3-cohort records -> scorer), byte determinism, synthetic labelling, the budget gate,
11
+ * fail-closed pricing, bounded turns and owned-temp cleanup.
12
+ *
13
+ * Run: cd ts && npx tsx scripts/persona-sim-tests.ts
14
+ */
15
+ import assert from 'node:assert/strict';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { CONTRADICTION_KINDS, DEFAULT_DECK_PATH, deliveredPlanMarkdown, FIXTURE_HMAC_KEY, loadPersonaDeck, parsePersonaDeck, PersonaSimError, personaSystemPrompt, poiAuditFromGateReport, promptDigest, runPersonaBatch, scrubSessionEnv, summarizeFunnel, } from './persona-sim.js';
22
+ import { enrollParticipant, exportM3Cohort, initM3Cohort, recordNps, recordPlanDelivered, recordPlanFinalized, recordPoiLock, verifyExportedEvidence, } from '../src/m3-cohort.js';
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const TS_ROOT = resolve(__dirname, '..');
25
+ const TSX = join(TS_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
26
+ const SENTINEL = 'PRIVACY_SENTINEL_PERSONA@example.com';
27
+ const FROZEN_CLOCK = '2026-10-05T00:00:00.000Z';
28
+ const roots = [];
29
+ function newRoot() {
30
+ const root = mkdtempSync(join(tmpdir(), 'gotry-persona-tests-'));
31
+ roots.push(root);
32
+ return root;
33
+ }
34
+ function frozenClock() {
35
+ let ms = Date.parse(FROZEN_CLOCK);
36
+ return { now: () => { ms += 1_000; return new Date(ms); } };
37
+ }
38
+ let n = 0;
39
+ async function pass(name, body) {
40
+ await body();
41
+ console.log(` ${++n}. ${name} OK`);
42
+ }
43
+ async function expectCode(code, body) {
44
+ try {
45
+ await body();
46
+ }
47
+ catch (error) {
48
+ assert.ok(error instanceof PersonaSimError, `expected PersonaSimError, got ${String(error)}`);
49
+ assert.equal(error.code, code, error.message);
50
+ return;
51
+ }
52
+ assert.fail(`expected PersonaSimError ${code}`);
53
+ }
54
+ function allFileText(root) {
55
+ const parts = [];
56
+ function walk(path) {
57
+ if (!existsSync(path))
58
+ return;
59
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
60
+ const child = join(path, entry.name);
61
+ if (entry.isDirectory())
62
+ walk(child);
63
+ else
64
+ parts.push(readFileSync(child, 'utf8'));
65
+ }
66
+ }
67
+ walk(root);
68
+ return parts.join('\n');
69
+ }
70
+ function withEnv(patch, body) {
71
+ const saved = new Map();
72
+ for (const [key, value] of Object.entries(patch)) {
73
+ saved.set(key, process.env[key]);
74
+ if (value === undefined)
75
+ delete process.env[key];
76
+ else
77
+ process.env[key] = value;
78
+ }
79
+ try {
80
+ return body();
81
+ }
82
+ finally {
83
+ for (const [key, value] of saved) {
84
+ if (value === undefined)
85
+ delete process.env[key];
86
+ else
87
+ process.env[key] = value;
88
+ }
89
+ }
90
+ }
91
+ /** Like withEnv, but the environment stays patched until the async body settles. */
92
+ async function withEnvAsync(patch, body) {
93
+ const saved = new Map();
94
+ for (const [key, value] of Object.entries(patch)) {
95
+ saved.set(key, process.env[key]);
96
+ if (value === undefined)
97
+ delete process.env[key];
98
+ else
99
+ process.env[key] = value;
100
+ }
101
+ try {
102
+ return await body();
103
+ }
104
+ finally {
105
+ for (const [key, value] of saved) {
106
+ if (value === undefined)
107
+ delete process.env[key];
108
+ else
109
+ process.env[key] = value;
110
+ }
111
+ }
112
+ }
113
+ /**
114
+ * Only 127.0.0.1 may be contacted anywhere in this suite, and the spy ENFORCES it rather
115
+ * than recording it: a regression that reached a live endpoint would carry whatever
116
+ * LLM_API_KEY the operator has exported, so the request must never leave the process.
117
+ */
118
+ const contactedHosts = [];
119
+ const realFetch = globalThis.fetch;
120
+ globalThis.fetch = (async (input, init) => {
121
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
122
+ let hostname;
123
+ try {
124
+ hostname = new URL(url).hostname;
125
+ }
126
+ catch {
127
+ hostname = `unparsable:${String(url).slice(0, 40)}`;
128
+ }
129
+ contactedHosts.push(hostname);
130
+ if (hostname !== '127.0.0.1') {
131
+ throw new Error(`offline test suite attempted a request to ${hostname} — blocked before any credential could leave`);
132
+ }
133
+ return realFetch(input, init);
134
+ });
135
+ function writeDeck(root, personas, description = 'test deck') {
136
+ const path = join(root, 'deck.json');
137
+ writeFileSync(path, JSON.stringify({ schema_version: 'gotry_persona_sim_deck_v1', description, personas }, null, 2), 'utf8');
138
+ return path;
139
+ }
140
+ function baseCard(overrides) {
141
+ return {
142
+ title: 'test persona',
143
+ grounding: 'test only',
144
+ goals: ['go somewhere'],
145
+ hard_constraints: ['nothing booked yet'],
146
+ patience: { max_user_turns: 3, abandon_after_questions: 3 },
147
+ answering_style: 'terse',
148
+ finalize_rule: 'finalize at the first plan',
149
+ rating_rule: 'rate 8',
150
+ opening_message: '帮我规划一下。',
151
+ expected_outcome: 'interview_only',
152
+ dry_run: {
153
+ product_facts: [{}],
154
+ product_skeleton: null,
155
+ product_slots: null,
156
+ persona_turns: [{ decision: 'continue', message: '继续。' }],
157
+ },
158
+ ...overrides,
159
+ };
160
+ }
161
+ async function main() {
162
+ console.log('persona-sim tests');
163
+ await pass('the shipped deck satisfies the closed card contract', () => {
164
+ const deck = loadPersonaDeck();
165
+ assert.ok(deck.length >= 6, `deck needs at least 6 personas, got ${deck.length}`);
166
+ assert.deepEqual(deck.map(card => card.persona_id), [...deck.map(card => card.persona_id)].sort(), 'deck order is deterministic');
167
+ assert.equal(new Set(deck.map(card => card.persona_id)).size, deck.length);
168
+ // Diversity is part of the contract: a deck that is all one style or all one outcome
169
+ // cannot exercise the funnel branches.
170
+ assert.ok(new Set(deck.map(card => card.answering_style)).size >= 2);
171
+ assert.deepEqual([...new Set(deck.map(card => card.expected_outcome))].sort(), ['interview_only', 'plan_delivered_and_finalized', 'plan_delivered_not_finalized'], 'the deck must cover finalize, no-finalize and interview-only outcomes');
172
+ for (const card of deck) {
173
+ assert.match(promptDigest(card), /^[0-9a-f]{64}$/);
174
+ assert.ok(personaSystemPrompt(card).includes(card.persona_id));
175
+ assert.ok(personaSystemPrompt(card).includes('You are a simulation, not a real traveller') || personaSystemPrompt(card).includes('simulation, not a real traveller'));
176
+ }
177
+ });
178
+ await pass('deck mutations are refused fail-closed', () => {
179
+ const raw = JSON.parse(readFileSync(DEFAULT_DECK_PATH, 'utf8'));
180
+ const mutate = (fn) => {
181
+ const copy = JSON.parse(JSON.stringify(raw));
182
+ fn(copy);
183
+ assert.throws(() => parsePersonaDeck(copy), (error) => {
184
+ assert.ok(error instanceof PersonaSimError);
185
+ assert.equal(error.code, 'invalid_persona_deck');
186
+ return true;
187
+ });
188
+ };
189
+ mutate(deck => { deck['personas'].length = 5; });
190
+ mutate(deck => { deck['schema_version'] = 'gotry_persona_sim_deck_v2'; });
191
+ mutate(deck => { deck['personas'][0]['persona_id'] = 'Not A Valid Id'; });
192
+ mutate(deck => { deck['personas'][1]['persona_id'] = deck['personas'][0]['persona_id']; });
193
+ mutate(deck => { deck['personas'][0]['surprise'] = 'extra field'; });
194
+ mutate(deck => { delete deck['personas'][0]['finalize_rule']; });
195
+ mutate(deck => { deck['personas'][0]['answering_style'] = 'sarcastic'; });
196
+ mutate(deck => {
197
+ const dryRun = deck['personas'][0]['dry_run'];
198
+ dryRun['persona_turns'][0] = { decision: 'continue' };
199
+ });
200
+ mutate(deck => {
201
+ const dryRun = deck['personas'][0]['dry_run'];
202
+ dryRun['persona_turns'][0] = { decision: 'abandon', nps: 11 };
203
+ });
204
+ });
205
+ await pass('no credential and no --dry-run: waiting_external_evidence, zero writes, zero spend', async () => {
206
+ const root = newRoot();
207
+ const evidenceRoot = join(root, 'evidence');
208
+ const before = contactedHosts.length;
209
+ const result = await withEnv({ LLM_API_KEY: undefined, DEEPSEEK_API_KEY: undefined }, () => runPersonaBatch({
210
+ dryRun: false,
211
+ stateRoot: root,
212
+ evidenceRoot,
213
+ clock: frozenClock(),
214
+ }));
215
+ assert.equal(result.state, 'waiting_external_evidence');
216
+ assert.equal(result.real_llm, false);
217
+ assert.equal(result.cost_usd, 0);
218
+ assert.equal(result.real_spend_usd, 0);
219
+ assert.equal(result.personas.length, 0);
220
+ assert.equal(result.evidence_root, null);
221
+ assert.equal(result.scorer_summary, null);
222
+ assert.equal(existsSync(evidenceRoot), false, 'the waiting state must write nothing');
223
+ assert.equal(existsSync(join(root, 'capture')), false);
224
+ assert.equal(contactedHosts.length, before, 'the waiting state must contact nothing');
225
+ });
226
+ await pass('a real batch must name both roots so its paid evidence survives', async () => {
227
+ const before = contactedHosts.length;
228
+ await expectCode('bad_args', async () => withEnv({ LLM_API_KEY: 'placeholder-never-used', GOTRY_PERSONA_CONSENT: 'test', GOTRY_M3_COHORT_HMAC_KEY: '0123456789abcdef0123456789abcdef' }, () => runPersonaBatch({
229
+ dryRun: false,
230
+ clock: frozenClock(),
231
+ })));
232
+ await expectCode('bad_args', async () => withEnv({ LLM_API_KEY: 'placeholder-never-used', GOTRY_PERSONA_CONSENT: 'test', GOTRY_M3_COHORT_HMAC_KEY: '0123456789abcdef0123456789abcdef' }, () => runPersonaBatch({
233
+ dryRun: false,
234
+ stateRoot: newRoot(),
235
+ clock: frozenClock(),
236
+ })));
237
+ assert.equal(contactedHosts.length, before, 'the guard must run before any request');
238
+ });
239
+ let dryRunDigest = '';
240
+ await pass('offline dry run drives the whole pipeline through to the scorer', async () => {
241
+ const root = newRoot();
242
+ const result = await runPersonaBatch({ dryRun: true, stateRoot: root, clock: frozenClock() });
243
+ assert.equal(result.state, 'dry_run_complete');
244
+ assert.equal(result.evidence_kind, 'synthetic_fixture');
245
+ assert.equal(result.real_llm, false);
246
+ assert.equal(result.real_spend_usd, 0);
247
+ assert.ok(result.cost_usd > 0, 'the price path must be exercised even though nothing is spent');
248
+ const deck = loadPersonaDeck();
249
+ assert.equal(result.personas.length, deck.length);
250
+ // Every card's own declared outcome must be what the pipeline produced.
251
+ for (const persona of result.personas) {
252
+ const card = deck.find(item => item.persona_id === persona.persona_id);
253
+ assert.equal(persona.error, null, `${persona.persona_id}: ${JSON.stringify(persona.error)}`);
254
+ assert.ok(persona.user_turns >= 1 && persona.user_turns <= card.patience.max_user_turns);
255
+ if (card.expected_outcome === 'interview_only') {
256
+ assert.equal(persona.plan_delivered, false, `${persona.persona_id} must stay in the interview`);
257
+ assert.equal(persona.poi_audit, null);
258
+ }
259
+ else {
260
+ assert.equal(persona.plan_delivered, true, `${persona.persona_id} must receive a plan`);
261
+ assert.ok(persona.poi_audit, 'a delivered plan must carry a claim audit');
262
+ assert.match(persona.plan_markdown_sha256, /^[0-9a-f]{64}$/);
263
+ }
264
+ assert.equal(persona.finalized, card.expected_outcome === 'plan_delivered_and_finalized');
265
+ }
266
+ // The fixture classified every product prompt: an unclassified one means src/dsh-llm.ts
267
+ // changed its system prompts and the harness is no longer driving the real seam.
268
+ const calls = result.fixture_calls;
269
+ assert.equal(calls['unclassified'], 0, `fixture could not classify a product prompt: ${JSON.stringify(calls)}`);
270
+ for (const kind of ['facts', 'skeleton', 'slots', 'polish', 'persona']) {
271
+ assert.ok((calls[kind] ?? 0) > 0, `fixture never served a ${kind} call: ${JSON.stringify(calls)}`);
272
+ }
273
+ // Capture -> export -> scorer. Every simulated participant is enrolled
274
+ // test_or_staff=true, so the scorer excludes all of them at record level: its eligible
275
+ // set is empty and its funnel fields are unavailable, which is the point.
276
+ const summary = result.scorer_summary;
277
+ assert.equal(summary.evidence_kind, 'synthetic_fixture');
278
+ assert.equal(summary.business_pass, false);
279
+ assert.equal(summary.business_pass_reason, 'evidence_kind=synthetic_fixture cannot prove business pass');
280
+ const delivered = result.personas.filter(persona => persona.plan_delivered).length;
281
+ const finalized = result.personas.filter(persona => persona.finalized).length;
282
+ assert.equal(summary.sample.participants, 0, 'no simulated participant may enter the eligible set');
283
+ assert.equal(summary.exclusions['test_or_staff'], delivered, 'every simulated record is excluded as test_or_staff');
284
+ assert.equal(summary.finalization.rate, null, 'an empty eligible set is unavailable, never 0%');
285
+ assert.equal(summary.nps.score, null);
286
+ assert.equal(summary.poi_hallucination.rate, null);
287
+ assert.equal(result.verification.record_count, delivered);
288
+ assert.equal(result.verification.simulated_record_count, delivered, 'every synthetic record carries provenance');
289
+ // The funnel a reader wants is computed by the harness over its own outcomes.
290
+ const funnel = result.funnel;
291
+ assert.equal(funnel.schema, 'gotry_persona_sim_funnel.v1');
292
+ assert.equal(funnel.personas_run, deck.length);
293
+ assert.equal(funnel.delivered, delivered);
294
+ assert.equal(funnel.finalized, finalized);
295
+ assert.equal(funnel.finalization_rate, Number((finalized / delivered).toFixed(6)));
296
+ assert.equal(funnel.errored, 0);
297
+ const scores = result.personas.filter(persona => persona.plan_delivered && persona.nps !== null).map(persona => persona.nps);
298
+ assert.equal(funnel.nps_responses, scores.length);
299
+ assert.equal(funnel.promoters, scores.filter(score => score >= 9).length);
300
+ assert.equal(funnel.detractors, scores.filter(score => score <= 6).length);
301
+ // With an empty fact registry nothing is adjudicable, so the audited denominator is 0
302
+ // and the POI rate is unavailable rather than a flattering 0%.
303
+ assert.ok(funnel.claims_extracted > 0, 'the gate must have extracted claims from the plans');
304
+ assert.equal(funnel.locked_claims, 0, 'an empty registry can audit nothing');
305
+ assert.equal(funnel.invalid_claims, 0);
306
+ assert.equal(funnel.poi_rate, null, 'nothing audited means unavailable, not 0%');
307
+ // The exported provenance must name the persona and digest the prompt that drove it.
308
+ const evidenceRoot = result.evidence_root;
309
+ const provenance = readFileSync(join(evidenceRoot, 'provenance.jsonl'), 'utf8')
310
+ .split('\n').filter(line => line.trim() !== '').map(line => JSON.parse(line));
311
+ assert.equal(provenance.length, delivered);
312
+ for (const row of provenance) {
313
+ assert.equal(row['kind'], 'llm_persona');
314
+ const card = deck.find(item => item.persona_id === row['persona_id']);
315
+ assert.equal(row['prompt_digest'], promptDigest(card));
316
+ assert.equal(row['product_model'], result.product_model);
317
+ assert.equal(row['persona_model'], result.persona_model);
318
+ }
319
+ assert.equal(verifyExportedEvidence(evidenceRoot, FIXTURE_HMAC_KEY).evidence_kind, 'synthetic_fixture');
320
+ for (const record of readFileSync(join(evidenceRoot, 'cohort.jsonl'), 'utf8')
321
+ .split('\n').filter(line => line.trim() !== '').map(line => JSON.parse(line))) {
322
+ assert.equal(record['test_or_staff'], true, 'every simulated record must be labelled test_or_staff');
323
+ assert.equal(record['poi_audit']['locked_claims'], 0);
324
+ }
325
+ dryRunDigest = readFileSync(join(evidenceRoot, 'cohort.jsonl'), 'utf8');
326
+ assert.ok(dryRunDigest.length > 0);
327
+ });
328
+ await pass('evidence_kind alone still blocks business_pass when records are not test_or_staff', async () => {
329
+ // The record-level exclusion is one half of the guarantee; this proves the other half
330
+ // independently, by scoring a synthetic manifest over records that are NOT excluded.
331
+ const root = newRoot();
332
+ const evidenceRoot = join(root, 'not-excluded');
333
+ const clock = frozenClock();
334
+ const captureRoot = join(root, 'capture');
335
+ const captureCommon = { stateRoot: captureRoot, consent: 'test consent', hmacKey: '0123456789abcdef0123456789abcdef' };
336
+ const simulation = {
337
+ schema_version: 'gotry_m3_simulation_provenance_v1',
338
+ kind: 'llm_persona',
339
+ persona_id: 'erhai-weekend-unwind',
340
+ product_model: 'MiniMax-M2',
341
+ persona_model: 'MiniMax-M2',
342
+ prompt_digest: 'a'.repeat(64),
343
+ };
344
+ initM3Cohort({
345
+ ...captureCommon,
346
+ cohort: 'not-excluded',
347
+ evidenceKind: 'synthetic_fixture',
348
+ windowStartAt: '2026-10-01T00:00:00.000Z',
349
+ windowEndAt: '2026-12-31T23:59:59.000Z',
350
+ timezone: 'UTC',
351
+ allowedAttribution: ['gotry_primary'],
352
+ }, clock);
353
+ for (let index = 0; index < 50; index += 1) {
354
+ const participant = `sim-${String(index).padStart(3, '0')}`;
355
+ const plan = `${participant}-plan`;
356
+ enrollParticipant({ ...captureCommon, participant, invited: true, participantConsent: true, testOrStaff: false, simulation }, clock);
357
+ recordPlanDelivered({ ...captureCommon, participant, plan, attribution: 'gotry_primary' }, clock);
358
+ recordPlanFinalized({ ...captureCommon, participant, plan }, clock);
359
+ recordNps({ ...captureCommon, participant, plan, score: 10 }, clock);
360
+ recordPoiLock({ ...captureCommon, participant, plan, lockedClaims: 10, invalidClaims: 0 }, clock);
361
+ }
362
+ const { payload } = exportM3Cohort({ ...captureCommon, evidenceRoot }, clock);
363
+ assert.equal(payload.cohort.length, 50);
364
+ assert.equal(payload.cohort.every(record => record.test_or_staff === false), true);
365
+ const scorer = spawnSync(TSX, ['scripts/product-metrics.ts', '--evidence-root', evidenceRoot, '--format', 'json'], {
366
+ cwd: TS_ROOT, encoding: 'utf8', timeout: 120_000,
367
+ });
368
+ assert.equal(scorer.status, 0, `${scorer.stderr}\n${scorer.stdout}`);
369
+ const summary = JSON.parse(scorer.stdout);
370
+ assert.equal(summary.sample.participants, 50, 'these records DO enter the eligible set');
371
+ assert.equal(summary.sample.pass, true);
372
+ assert.equal(summary.business_pass, false, 'evidence_kind alone must still block business_pass');
373
+ assert.equal(summary.business_pass_reason, 'evidence_kind=synthetic_fixture cannot prove business pass');
374
+ });
375
+ await pass('the offline dry run is byte-deterministic', async () => {
376
+ const root = newRoot();
377
+ const result = await runPersonaBatch({ dryRun: true, stateRoot: root, clock: frozenClock() });
378
+ const repeat = readFileSync(join(result.evidence_root, 'cohort.jsonl'), 'utf8');
379
+ assert.equal(repeat, dryRunDigest, 'the same frozen clock must produce the same cohort records');
380
+ });
381
+ await pass('budget gate trips between turns, stops the batch and still exports the spend', async () => {
382
+ const root = newRoot();
383
+ const result = await runPersonaBatch({ dryRun: true, stateRoot: root, clock: frozenClock(), budgetUsd: 0.000001 });
384
+ assert.equal(result.cost_over_budget, true);
385
+ assert.ok(result.reason?.includes('exceeds GOTRY_PERSONA_BUDGET_USD'));
386
+ assert.equal(result.personas.length, 1, 'the batch must stop after the first over-budget persona');
387
+ assert.ok(result.cost_usd > 0.000001);
388
+ // The first session is cut mid-flight rather than being allowed to run to completion.
389
+ assert.equal(result.personas[0].error?.code, 'budget_exceeded');
390
+ // The already-paid spend is still exported: the records are facts about money spent.
391
+ assert.ok(result.evidence_root && existsSync(result.evidence_root), 'an over-budget batch still writes its evidence');
392
+ assert.equal(verifyExportedEvidence(result.evidence_root, FIXTURE_HMAC_KEY).ok, true);
393
+ });
394
+ await pass('an unsafe --state-root is refused before any directory is created', async () => {
395
+ const forbidden = join(TS_ROOT, 'dsh-runtime', 'persona-sim-should-never-exist');
396
+ assert.equal(existsSync(forbidden), false, 'precondition: the forbidden path must not exist yet');
397
+ await expectCode('unsafe_state_root', async () => runPersonaBatch({
398
+ dryRun: true,
399
+ stateRoot: forbidden,
400
+ evidenceRoot: join(forbidden, 'evidence'),
401
+ clock: frozenClock(),
402
+ }));
403
+ assert.equal(existsSync(forbidden), false, 'the harness must not mkdir inside a forbidden state root');
404
+ assert.equal(existsSync(join(forbidden, 'empty-bin')), false);
405
+ assert.equal(existsSync(join(forbidden, 'capture')), false);
406
+ });
407
+ await pass('the funnel summary is pure and never divides by an empty denominator', () => {
408
+ const empty = summarizeFunnel([]);
409
+ assert.equal(empty.delivered, 0);
410
+ assert.equal(empty.finalization_rate, null);
411
+ assert.equal(empty.nps_score, null);
412
+ assert.equal(empty.poi_rate, null);
413
+ });
414
+ await pass('an unpriced model is refused before any spend', async () => {
415
+ const root = newRoot();
416
+ const evidenceRoot = join(root, 'evidence');
417
+ const before = contactedHosts.length;
418
+ await expectCode('missing_price_entry', async () => withEnv({ LLM_MODEL: 'model-with-no-sealed-price' }, () => runPersonaBatch({
419
+ dryRun: true,
420
+ stateRoot: root,
421
+ evidenceRoot,
422
+ clock: frozenClock(),
423
+ })));
424
+ assert.equal(existsSync(evidenceRoot), false);
425
+ assert.equal(contactedHosts.length, before, 'the price gate must run before the first request');
426
+ });
427
+ await pass('a provider that omits usage makes cost unprovable: fail-closed', async () => {
428
+ const root = newRoot();
429
+ const evidenceRoot = join(root, 'evidence');
430
+ await expectCode('cost_unprovable', async () => runPersonaBatch({
431
+ dryRun: true,
432
+ stateRoot: root,
433
+ evidenceRoot,
434
+ clock: frozenClock(),
435
+ omitFixtureUsage: true,
436
+ personaFilter: ['phuket-workation-multileg'],
437
+ }));
438
+ assert.equal(existsSync(evidenceRoot), false, 'no evidence may be written when cost is unprovable');
439
+ });
440
+ await pass('turn count is bounded by the card patience even when the persona never stops', async () => {
441
+ const root = newRoot();
442
+ const deckPath = writeDeck(root, Array.from({ length: 6 }, (_unused, index) => baseCard({
443
+ persona_id: `endless-${index}`,
444
+ patience: { max_user_turns: 2, abandon_after_questions: 2 },
445
+ dry_run: {
446
+ product_facts: [{}],
447
+ product_skeleton: null,
448
+ product_slots: null,
449
+ persona_turns: [{ decision: 'continue', message: '再说说。' }],
450
+ },
451
+ })));
452
+ const result = await runPersonaBatch({ dryRun: true, deckPath, stateRoot: root, clock: frozenClock() });
453
+ assert.equal(result.personas.length, 6);
454
+ for (const persona of result.personas) {
455
+ assert.equal(persona.user_turns, 2, 'the loop must stop at max_user_turns');
456
+ assert.equal(persona.plan_delivered, false);
457
+ }
458
+ assert.equal(result.verification.record_count, 0, 'an interview-only batch produces no cohort record');
459
+ assert.equal(result.funnel.delivered, 0);
460
+ assert.equal(result.funnel.finalization_rate, null, 'an empty denominator is unavailable, not 0%');
461
+ assert.equal(result.scorer_summary.sample.participants, 0);
462
+ });
463
+ await pass('a PII sentinel in a persona utterance never reaches the evidence root', async () => {
464
+ const root = newRoot();
465
+ const deckPath = writeDeck(root, Array.from({ length: 6 }, (_unused, index) => baseCard({
466
+ persona_id: `sentinel-${index}`,
467
+ opening_message: `我叫 ${SENTINEL},帮我规划普吉两周的 workation。`,
468
+ expected_outcome: 'plan_delivered_and_finalized',
469
+ dry_run: {
470
+ product_facts: [
471
+ { profile: { workWindow: { vacation: true }, bookedResources: [] } },
472
+ ],
473
+ product_skeleton: { scenario: 'workation', segments: [{ id: 'f1', role: 'choice' }] },
474
+ product_slots: null,
475
+ persona_turns: [{ decision: 'finalize', message: `好的,${SENTINEL}`, nps: 9 }],
476
+ },
477
+ })));
478
+ const result = await runPersonaBatch({ dryRun: true, deckPath, stateRoot: root, clock: frozenClock() });
479
+ assert.ok(result.funnel.delivered > 0, 'the sentinel personas must actually reach a plan');
480
+ const evidenceText = allFileText(result.evidence_root);
481
+ assert.equal(evidenceText.includes(SENTINEL), false, 'sentinel leaked into the evidence root');
482
+ assert.equal(evidenceText.includes('PRIVACY_SENTINEL'), false);
483
+ assert.equal(allFileText(join(root, 'capture')).includes(SENTINEL), false, 'sentinel leaked into the capture store');
484
+ assert.equal(JSON.stringify(result).includes(SENTINEL), false, 'sentinel leaked into the run summary');
485
+ });
486
+ await pass('an owned temp root is removed; a caller-supplied root is kept', async () => {
487
+ const result = await runPersonaBatch({ dryRun: true, clock: frozenClock(), personaFilter: ['erhai-weekend-unwind'] });
488
+ assert.ok(result.evidence_root);
489
+ assert.equal(existsSync(result.evidence_root), false, 'an owned batch root must be cleaned up');
490
+ const root = newRoot();
491
+ const kept = await runPersonaBatch({ dryRun: true, stateRoot: root, clock: frozenClock(), personaFilter: ['erhai-weekend-unwind'] });
492
+ assert.equal(existsSync(kept.evidence_root), true);
493
+ });
494
+ await pass('the audited denominator counts only adjudicable claims', () => {
495
+ // An entirely unverified plan must not look like a clean one: nothing was audited, so
496
+ // the denominator is 0 and the scorer reports unavailable rather than a 0% POI rate.
497
+ const unverified = poiAuditFromGateReport({
498
+ verdict: 'blocked',
499
+ claims_checked: 7,
500
+ traceable: 0,
501
+ presentation: 'verified_label_forbidden',
502
+ violations: Array.from({ length: 7 }, () => ({ kind: 'route_unqueried' })),
503
+ });
504
+ assert.equal(unverified.claims_extracted, 7);
505
+ assert.equal(unverified.locked_claims, 0, 'nothing adjudicable means nothing audited');
506
+ assert.equal(unverified.invalid_claims, 0, 'unverified is not hallucinated');
507
+ assert.equal(unverified.unverified_claims, 7);
508
+ assert.equal(unverified.gate_verdict, 'blocked');
509
+ const mixed = poiAuditFromGateReport({
510
+ verdict: 'blocked',
511
+ claims_checked: 4,
512
+ traceable: 1,
513
+ presentation: 'verified_label_forbidden',
514
+ violations: [{ kind: 'not_in_source' }, { kind: 'contradicted' }, { kind: 'route_unqueried' }],
515
+ });
516
+ assert.equal(mixed.locked_claims, 3, 'one traceable plus two contradicted were adjudicated');
517
+ assert.equal(mixed.invalid_claims, 2);
518
+ assert.equal(mixed.unverified_claims, 1);
519
+ assert.ok(CONTRADICTION_KINDS.has('not_in_source') && !CONTRADICTION_KINDS.has('route_unqueried'));
520
+ const clean = poiAuditFromGateReport({ verdict: 'pass', claims_checked: 3, traceable: 3, presentation: 'verified_itinerary_allowed', violations: [] });
521
+ assert.deepEqual({ locked: clean.locked_claims, invalid: clean.invalid_claims, verdict: clean.gate_verdict }, { locked: 3, invalid: 0, verdict: 'pass' });
522
+ });
523
+ await pass('no plan means no delivered artifact', () => {
524
+ assert.equal(deliveredPlanMarkdown({ calendar: { year: 2026, assertedWeekdays: {} }, profile: {}, gates: [], wishes: [] }), null);
525
+ assert.equal(deliveredPlanMarkdown({
526
+ calendar: { year: 2026, assertedWeekdays: {} }, profile: {}, gates: [], wishes: [],
527
+ solve: { answer_md: '# 候选对比\n...' },
528
+ }), '# 候选对比\n...');
529
+ });
530
+ await pass('CLI: offline dry run exits 0 with a machine-readable synthetic result', () => {
531
+ const run = spawnSync(TSX, ['scripts/persona-sim.ts', '--dry-run', '--format', 'json', '--persona', 'erhai-weekend-unwind'], {
532
+ cwd: TS_ROOT,
533
+ encoding: 'utf8',
534
+ timeout: 300_000,
535
+ env: { ...process.env, LLM_API_KEY: undefined, DEEPSEEK_API_KEY: undefined },
536
+ });
537
+ assert.equal(run.status, 0, `${run.stderr}\n${run.stdout}`);
538
+ const result = JSON.parse(run.stdout);
539
+ assert.equal(result.state, 'dry_run_complete');
540
+ assert.equal(result.evidence_kind, 'synthetic_fixture');
541
+ assert.equal(result.scorer_summary.business_pass, false);
542
+ });
543
+ await pass('session env: GOTRY_HBCLI_LIVE defaults to 0, an explicit operator value is kept, and the env is restored (issue #617)', async () => {
544
+ const sandbox = newRoot();
545
+ for (const [operator, expected] of [[undefined, '0'], ['1', '1'], ['0', '0'], ['off', 'off']]) {
546
+ await withEnvAsync({ GOTRY_HBCLI_LIVE: operator }, async () => {
547
+ const restore = scrubSessionEnv({}, sandbox);
548
+ assert.equal(process.env['GOTRY_HBCLI_LIVE'], expected, `operator ${String(operator)} -> sessions see ${expected}`);
549
+ assert.equal(process.env['PATH'], join(sandbox, 'empty-bin'), 'PATH stays scrubbed whatever the operator set');
550
+ assert.equal(process.env['HOME'], sandbox);
551
+ restore();
552
+ assert.equal(process.env['GOTRY_HBCLI_LIVE'], operator, 'the operator value is restored exactly');
553
+ });
554
+ }
555
+ });
556
+ await pass('a dry-run batch never spawns hbcli by default; an explicit operator value is not overridden (issue #617)', async () => {
557
+ // The batch sets HOME=<state root> and PATH=<empty dir>, so the HOME-relative known-install location is the
558
+ // only place the POI probe could ever find a binary. A counting fake lives there (shell builtins only, so it
559
+ // also runs under the empty PATH); the explicit `1` run is the positive control proving it is reachable.
560
+ // Each run gets its own root: a batch refuses to export over an existing evidence directory.
561
+ const batch = async (operator) => {
562
+ const root = newRoot();
563
+ const fakeDir = join(root, '.local', 'bin');
564
+ mkdirSync(fakeDir, { recursive: true });
565
+ const callsLog = join(root, 'hbcli-calls.log');
566
+ writeFileSync(join(fakeDir, 'hbcli'), `#!/bin/sh\nprintf 'call\\n' >> '${callsLog}'\nprintf '%s' '{"candidates":[]}'\n`, { mode: 0o755 });
567
+ // A bare place name is what probePoi turns into an anything search on the first user turn.
568
+ const deckPath = writeDeck(root, Array.from({ length: 6 }, (_unused, index) => baseCard({
569
+ persona_id: `poi-probe-${index}`,
570
+ opening_message: '大理',
571
+ })));
572
+ const result = await withEnvAsync({ GOTRY_HBCLI_LIVE: operator }, async () => {
573
+ const outcome = await runPersonaBatch({ dryRun: true, deckPath, stateRoot: root, clock: frozenClock(), personaFilter: ['poi-probe-0'] });
574
+ assert.equal(process.env['GOTRY_HBCLI_LIVE'], operator, 'the batch restores the operator value');
575
+ return outcome;
576
+ });
577
+ const calls = existsSync(callsLog) ? readFileSync(callsLog, 'utf8').split('\n').filter(Boolean).length : 0;
578
+ return { calls, error: result.personas[0]?.error ?? null, sessions: result.personas.length };
579
+ };
580
+ const byDefault = await batch(undefined);
581
+ assert.equal(byDefault.sessions, 1);
582
+ assert.equal(byDefault.error, null, 'the probe path must not break the session');
583
+ assert.equal(byDefault.calls, 0, 'default dry-run: the fake hbcli must be invoked ZERO times');
584
+ const explicitOff = await batch('0');
585
+ assert.equal(explicitOff.calls, 0, 'explicit 0 stays offline');
586
+ const explicitOn = await batch('1');
587
+ assert.ok(explicitOn.calls >= 1, `explicit GOTRY_HBCLI_LIVE=1 must not be overridden: the POI probe should reach the fake (calls=${explicitOn.calls})`);
588
+ });
589
+ await pass('the whole suite contacted nothing outside 127.0.0.1', () => {
590
+ assert.ok(contactedHosts.length > 0, 'the fixture provider must actually have been called');
591
+ const offHost = [...new Set(contactedHosts)].filter(host => host !== '127.0.0.1');
592
+ assert.deepEqual(offHost, [], `off-host requests: ${offHost.join(', ')}`);
593
+ });
594
+ console.log(`persona-sim tests: ${n} checks OK (${contactedHosts.length} fixture requests, all 127.0.0.1)`);
595
+ }
596
+ try {
597
+ await main();
598
+ }
599
+ finally {
600
+ globalThis.fetch = realFetch;
601
+ for (const root of roots)
602
+ rmSync(root, { recursive: true, force: true });
603
+ }