@jjh32456/dsh-longlive 1.6.7 → 1.8.0
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/CHANGELOG.md +70 -1508
- package/LICENSE +1 -1
- package/QUICKSTART.md +34 -13
- package/README.en.md +18 -7
- package/README.md +34 -9
- package/SPEC.md +5 -5
- package/cordis.patch.yml +23 -9
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +168 -35
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +96 -28
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +4 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +47 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +760 -0
- package/src/activity/model.js +351 -0
- package/src/activity/store.js +644 -0
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +171 -47
- package/src/deps.js +6 -4
- package/src/features.js +182 -6
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/live.js +22 -2
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +105 -16
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/queue.mjs +1 -1
- package/src/router/service/service.mjs +27 -11
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -385
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -204
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -568
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1020
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -182
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1044
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -299
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -179
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- package/test/v4-hardening.test.mjs +0 -427
package/test/cli-guard.test.mjs
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
// @local/dsh-longlive — the CLI's own "is this session still running?" gate
|
|
2
|
-
//
|
|
3
|
-
// scripts/truncate-cli.mjs is spawned by the orchestrator after the app was
|
|
4
|
-
// supposed to exit. It has no tool context, so it must answer the live-target
|
|
5
|
-
// question itself. These cases pin that contract:
|
|
6
|
-
// * the CLI source does not import the tool layer (the gate cannot be the
|
|
7
|
-
// tool's guard reached through a second door);
|
|
8
|
-
// * a fresh live-registry row refuses with SESSION_IS_RUNNING and leaves
|
|
9
|
-
// every byte alone;
|
|
10
|
-
// * an inherited $DSH_SESSION_ID naming the target refuses the same way;
|
|
11
|
-
// * the exclusive-handle probe is wired in (held -> running);
|
|
12
|
-
// * with no live signal the CLI cuts normally.
|
|
13
|
-
//
|
|
14
|
-
// Fixtures are synthetic logs under TMP; nothing here reads ~/.dsh/sessions.
|
|
15
|
-
|
|
16
|
-
import fs from 'node:fs';
|
|
17
|
-
import path from 'node:path';
|
|
18
|
-
import crypto from 'node:crypto';
|
|
19
|
-
import { spawnSync } from 'node:child_process';
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
21
|
-
|
|
22
|
-
import { writeFixture } from './truncate-visible.test.mjs';
|
|
23
|
-
import { detectLiveTarget, LIVE_TARGET_CODES, sessionIdOfLogFile } from '../src/live-target.js';
|
|
24
|
-
import { liveUpdate } from '../src/live.js';
|
|
25
|
-
import { ERROR_CODES } from '../src/truncate.js';
|
|
26
|
-
|
|
27
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
-
const ROOT = path.resolve(HERE, '..');
|
|
29
|
-
const CLI = path.join(ROOT, 'scripts', 'truncate-cli.mjs');
|
|
30
|
-
|
|
31
|
-
function sha256(file) {
|
|
32
|
-
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function runCli(args, envOverrides = {}) {
|
|
36
|
-
const env = { ...process.env, ...envOverrides };
|
|
37
|
-
for (const [key, value] of Object.entries(envOverrides)) {
|
|
38
|
-
if (value === undefined) delete env[key];
|
|
39
|
-
}
|
|
40
|
-
const run = spawnSync(process.execPath, [CLI, ...args], {
|
|
41
|
-
cwd: ROOT,
|
|
42
|
-
env,
|
|
43
|
-
encoding: 'utf8',
|
|
44
|
-
timeout: 120_000,
|
|
45
|
-
});
|
|
46
|
-
const lines = String(run.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
47
|
-
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{')) ?? null;
|
|
48
|
-
let json = null;
|
|
49
|
-
if (jsonLine) {
|
|
50
|
-
try { json = JSON.parse(jsonLine); } catch { json = null; }
|
|
51
|
-
}
|
|
52
|
-
return {
|
|
53
|
-
status: run.status,
|
|
54
|
-
json,
|
|
55
|
-
stdout: String(run.stdout ?? ''),
|
|
56
|
-
stderr: String(run.stderr ?? ''),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function visibleArgs(sessionPath, memoryRoot) {
|
|
61
|
-
return [
|
|
62
|
-
'--session', sessionPath,
|
|
63
|
-
'--keep-turns', '5',
|
|
64
|
-
'--keep-tokens', '1000000',
|
|
65
|
-
'--confirm', 'TRUNCATE-VISIBLE',
|
|
66
|
-
'--memory-root', memoryRoot,
|
|
67
|
-
];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function runCliGuardSuite({ runCase, assert, TMP }) {
|
|
71
|
-
const base = path.join(TMP, 'cli-guard');
|
|
72
|
-
fs.mkdirSync(base, { recursive: true });
|
|
73
|
-
// rc.2: truncate-cli.mjs only writes under <DSH_HOME>/sessions; fixtures
|
|
74
|
-
// already live at <base>/home/sessions, so point the child at that home.
|
|
75
|
-
const cli = (args, extra = {}) => runCli(args, { DSH_HOME: path.join(base, 'home'), ...extra });
|
|
76
|
-
|
|
77
|
-
runCase('CLI1', 'truncate-cli 不 import 工具层,活体判定由 detectLiveTarget 自己完成', () => {
|
|
78
|
-
const source = fs.readFileSync(CLI, 'utf8');
|
|
79
|
-
assert(!/src[\\/]tools\.js/.test(source), 'CLI source still references src/tools.js');
|
|
80
|
-
assert(source.includes('detectLiveTarget'), 'CLI does not call detectLiveTarget');
|
|
81
|
-
assert(source.includes('SESSION_IS_RUNNING'), 'CLI does not name the refusal code');
|
|
82
|
-
assert(source.includes('--memory-root'), 'CLI cannot receive the memory root');
|
|
83
|
-
return 'CLI self-checks via live-target.js only';
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
runCase('CLI2', 'detectLiveTarget:注册表/独占句柄/$DSH_SESSION_ID 任一命中即 running', () => {
|
|
87
|
-
const { file } = writeFixture(base, 'guard-unit');
|
|
88
|
-
const targetId = sessionIdOfLogFile(file).id;
|
|
89
|
-
|
|
90
|
-
const held = detectLiveTarget(file, {
|
|
91
|
-
env: {},
|
|
92
|
-
probe: () => {
|
|
93
|
-
const error = new Error('held by the app');
|
|
94
|
-
error.code = ERROR_CODES.FILE_BUSY;
|
|
95
|
-
throw error;
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
assert(held.running === true && held.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING,
|
|
99
|
-
`held handle -> running=${held.running} code=${held.code}`);
|
|
100
|
-
assert(held.reasons.some((item) => item.signal === 'exclusive-handle'),
|
|
101
|
-
'held handle produced no exclusive-handle reason');
|
|
102
|
-
|
|
103
|
-
const free = detectLiveTarget(file, { env: {}, probe: () => {} });
|
|
104
|
-
assert(free.running === false, `free handle -> running=${free.running}`);
|
|
105
|
-
assert(free.checks.some((item) => item.name === 'exclusive-handle' && item.status === 'free'),
|
|
106
|
-
'free handle was not recorded');
|
|
107
|
-
|
|
108
|
-
const envHit = detectLiveTarget(file, {
|
|
109
|
-
env: { DSH_SESSION_ID: targetId.toUpperCase() },
|
|
110
|
-
probe: () => {},
|
|
111
|
-
});
|
|
112
|
-
assert(envHit.running === true, 'inherited $DSH_SESSION_ID was not treated as running');
|
|
113
|
-
assert(envHit.reasons.some((item) => item.signal === 'env-session-id'),
|
|
114
|
-
'env hit produced no env-session-id reason');
|
|
115
|
-
assert(envHit.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING, `env hit code ${envHit.code}`);
|
|
116
|
-
|
|
117
|
-
const memRoot = path.join(base, 'unit-memory');
|
|
118
|
-
fs.mkdirSync(memRoot, { recursive: true });
|
|
119
|
-
liveUpdate({ sessionId: targetId, state: 'running' }, { memoryRoot: memRoot });
|
|
120
|
-
const registryHit = detectLiveTarget(file, { memoryRoot: memRoot, env: {}, probe: () => {} });
|
|
121
|
-
assert(registryHit.running === true, 'fresh live-registry row was not treated as running');
|
|
122
|
-
assert(registryHit.reasons.some((item) => item.signal === 'live-registry'),
|
|
123
|
-
'registry hit produced no live-registry reason');
|
|
124
|
-
return 'all three signals refuse';
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
runCase('CLI3', 'CLI 在写前看到 running 注册表行:exit 2 + SESSION_IS_RUNNING,字节不变', () => {
|
|
128
|
-
const { file } = writeFixture(base, 'guard-registry');
|
|
129
|
-
const targetId = sessionIdOfLogFile(file).id;
|
|
130
|
-
const memRoot = path.join(base, 'registry-memory');
|
|
131
|
-
fs.mkdirSync(memRoot, { recursive: true });
|
|
132
|
-
liveUpdate({ sessionId: targetId, state: 'running' }, { memoryRoot: memRoot });
|
|
133
|
-
|
|
134
|
-
const before = sha256(file);
|
|
135
|
-
const beforeFiles = fs.readdirSync(path.dirname(file)).sort();
|
|
136
|
-
const result = cli(visibleArgs(file, memRoot), { DSH_SESSION_ID: undefined });
|
|
137
|
-
assert(result.status === 2, `exit ${result.status} (stderr ${result.stderr.slice(0, 200)})`);
|
|
138
|
-
assert(result.json && result.json.ok === false, 'refusal emitted no ok:false JSON');
|
|
139
|
-
assert(result.json.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING,
|
|
140
|
-
`refusal code ${result.json && result.json.code}`);
|
|
141
|
-
assert(sha256(file) === before, 'refused CLI run changed the session bytes');
|
|
142
|
-
assert(fs.readdirSync(path.dirname(file)).sort().join('|') === beforeFiles.join('|'),
|
|
143
|
-
'refused CLI run created files beside the session');
|
|
144
|
-
return 'registry row blocks the write';
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
runCase('CLI4', 'CLI 继承 $DSH_SESSION_ID 指向目标会话:exit 2 + SESSION_IS_RUNNING', () => {
|
|
148
|
-
const { file } = writeFixture(base, 'guard-env');
|
|
149
|
-
const targetId = sessionIdOfLogFile(file).id;
|
|
150
|
-
const memRoot = path.join(base, 'env-memory');
|
|
151
|
-
fs.mkdirSync(memRoot, { recursive: true });
|
|
152
|
-
|
|
153
|
-
const before = sha256(file);
|
|
154
|
-
const result = cli(visibleArgs(file, memRoot), { DSH_SESSION_ID: targetId });
|
|
155
|
-
assert(result.status === 2, `exit ${result.status}`);
|
|
156
|
-
assert(result.json && result.json.code === LIVE_TARGET_CODES.SESSION_IS_RUNNING,
|
|
157
|
-
`refusal code ${result.json && result.json.code}`);
|
|
158
|
-
const reasons = Array.isArray(result.json.reasons) ? result.json.reasons : [];
|
|
159
|
-
assert(reasons.some((item) => item.signal === 'env-session-id'),
|
|
160
|
-
'refusal did not name the env-session-id signal');
|
|
161
|
-
assert(sha256(file) === before, 'refused CLI run changed the session bytes');
|
|
162
|
-
return 'inherited session id blocks the write';
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
runCase('CLI6', 'CLI 拒绝 sessions 根外的 --session:exit 2 + E_SESSION_PATH_OUTSIDE,字节不变', () => {
|
|
166
|
-
const outsideDir = path.join(base, 'outside');
|
|
167
|
-
fs.mkdirSync(outsideDir, { recursive: true });
|
|
168
|
-
const outside = path.join(outsideDir, 'session.v4.jsonl.zstd');
|
|
169
|
-
fs.copyFileSync(writeFixture(base, 'guard-outside-src').file, outside);
|
|
170
|
-
const before = sha256(outside);
|
|
171
|
-
const result = cli(visibleArgs(outside, path.join(base, 'outside-memory')), { DSH_SESSION_ID: undefined });
|
|
172
|
-
assert(result.status === 2, `exit ${result.status} code=${result.json && result.json.code}`);
|
|
173
|
-
assert(result.json && result.json.ok === false, 'outside path emitted no ok:false JSON');
|
|
174
|
-
assert(result.json.code === 'E_SESSION_PATH_OUTSIDE', `outside code ${result.json && result.json.code}`);
|
|
175
|
-
assert(sha256(outside) === before, 'refused outside path changed the file');
|
|
176
|
-
assert(!fs.existsSync(path.join(outsideDir, 'session.v4.jsonl.zstd.bak'))
|
|
177
|
-
&& !fs.existsSync(path.join(outsideDir, 'session.v4.jsonl.zstd.archive')),
|
|
178
|
-
'refused outside path created side files');
|
|
179
|
-
return 'containment refusal keeps a concrete code';
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
runCase('CLI5', 'CLI 无任何活体信号:正常截断(exit 0,ok true,字节改变)', () => {
|
|
183
|
-
const { file } = writeFixture(base, 'guard-control');
|
|
184
|
-
const memRoot = path.join(base, 'control-memory');
|
|
185
|
-
fs.mkdirSync(memRoot, { recursive: true });
|
|
186
|
-
|
|
187
|
-
const before = sha256(file);
|
|
188
|
-
const result = cli(visibleArgs(file, memRoot), { DSH_SESSION_ID: undefined });
|
|
189
|
-
assert(result.status === 0,
|
|
190
|
-
`exit ${result.status} code=${result.json && result.json.code} stdout=${result.stdout.slice(0, 300)}`);
|
|
191
|
-
assert(result.json && result.json.ok === true, 'control run did not report ok:true');
|
|
192
|
-
assert(result.json.status === 'truncated', `control run status ${result.json && result.json.status}`);
|
|
193
|
-
assert(sha256(file) !== before, 'control run did not change the session bytes');
|
|
194
|
-
return `control cut ok (${result.json.bytes.before} -> ${result.json.bytes.after} bytes)`;
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export const CLI_GUARD_CASES = ['CLI1', 'CLI2', 'CLI3', 'CLI4', 'CLI5', 'CLI6'];
|
package/test/conclusion.test.mjs
DELETED
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
// A1 (conclusion write-back) + A2 (shared-board lastConclusion) tests.
|
|
2
|
-
//
|
|
3
|
-
// Everything writes under the runner's TMP directory only: the P6 gate in
|
|
4
|
-
// test/run.mjs fails the suite if the package tree changes during a run.
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
bridgeMarkerFile,
|
|
11
|
-
classifyConclusionText,
|
|
12
|
-
conclusionCardFile,
|
|
13
|
-
createConclusionBridge,
|
|
14
|
-
judgeConclusion,
|
|
15
|
-
readBridgeMarker,
|
|
16
|
-
} from '../src/conclusion.js';
|
|
17
|
-
import {
|
|
18
|
-
LAST_CONCLUSION_MAX,
|
|
19
|
-
boardHashOf,
|
|
20
|
-
createBoardBuilder,
|
|
21
|
-
lastConclusionOf,
|
|
22
|
-
readBridgeConclusions,
|
|
23
|
-
} from '../src/router/service/board.mjs';
|
|
24
|
-
import { renderHint } from '../src/router/service/hint.mjs';
|
|
25
|
-
import { liveUpdate } from '../src/live.js';
|
|
26
|
-
|
|
27
|
-
const NOW_MS = Date.parse('2026-11-05T12:00:00.000Z');
|
|
28
|
-
const NOW = new Date(NOW_MS);
|
|
29
|
-
|
|
30
|
-
// test/run.mjs passes a plain assert(cond, message) function; keep a small
|
|
31
|
-
// object-shaped adapter so the cases below read naturally.
|
|
32
|
-
function makeAssert(check) {
|
|
33
|
-
const api = (cond, message) => check(cond, message);
|
|
34
|
-
api.ok = (value, message) => check(value, message || `expected truthy, got ${String(value)}`);
|
|
35
|
-
api.equal = (actual, expected, message) => {
|
|
36
|
-
check(actual === expected, message || `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
37
|
-
};
|
|
38
|
-
api.notEqual = (actual, expected, message) => {
|
|
39
|
-
check(actual !== expected, message || `did not expect ${JSON.stringify(expected)}`);
|
|
40
|
-
};
|
|
41
|
-
api.deepEqual = (actual, expected, message) => {
|
|
42
|
-
check(JSON.stringify(actual) === JSON.stringify(expected),
|
|
43
|
-
message || `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
44
|
-
};
|
|
45
|
-
return api;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function userEvent(text, extra = {}) {
|
|
49
|
-
return {
|
|
50
|
-
type: 'user/message',
|
|
51
|
-
data: {
|
|
52
|
-
content: [{ type: 'text', text }],
|
|
53
|
-
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
|
|
54
|
-
role: 'user',
|
|
55
|
-
id: `u-${Math.random().toString(16).slice(2)}`,
|
|
56
|
-
...extra,
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function injectedUserEvent(text) {
|
|
62
|
-
return {
|
|
63
|
-
type: 'user/message',
|
|
64
|
-
data: {
|
|
65
|
-
content: [{ type: 'text', text }],
|
|
66
|
-
source: { kind: 'user' },
|
|
67
|
-
role: 'user',
|
|
68
|
-
id: 'u-injected',
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function assistantEvent(turn, blocks) {
|
|
74
|
-
return {
|
|
75
|
-
type: 'assistant/message',
|
|
76
|
-
data: {
|
|
77
|
-
turn,
|
|
78
|
-
step: 1,
|
|
79
|
-
message: {
|
|
80
|
-
role: 'assistant',
|
|
81
|
-
content: blocks,
|
|
82
|
-
source: { kind: 'model' },
|
|
83
|
-
id: `a-${turn}`,
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function turnStart(turn) {
|
|
90
|
-
return { type: 'turn/start', data: { turn } };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function turnEnd(turn, kind = 'completed') {
|
|
94
|
-
return { type: 'turn/end', data: { turn, reason: { kind } } };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function writeJson(file, value) {
|
|
98
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
-
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function runConclusionSuite({ runCase, assert: rawAssert, TMP }) {
|
|
103
|
-
const assert = makeAssert(rawAssert);
|
|
104
|
-
|
|
105
|
-
const fixture = (name) => {
|
|
106
|
-
const root = path.join(TMP, `conclusion-${name}`);
|
|
107
|
-
fs.mkdirSync(root, { recursive: true });
|
|
108
|
-
return root;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const session = { id: 'session-alpha' };
|
|
112
|
-
|
|
113
|
-
function feedThreeTurns(bridge) {
|
|
114
|
-
bridge.onSessionEvent(session, turnStart(1));
|
|
115
|
-
bridge.onSessionEvent(session, userEvent('先看看现在的情况'));
|
|
116
|
-
bridge.onSessionEvent(session, assistantEvent(1, [
|
|
117
|
-
{ type: 'text', text: '现在有两个方案,A 和 B,各有取舍,需要你定一个。' },
|
|
118
|
-
]));
|
|
119
|
-
bridge.onSessionEvent(session, turnEnd(1));
|
|
120
|
-
|
|
121
|
-
bridge.onSessionEvent(session, turnStart(2));
|
|
122
|
-
bridge.onSessionEvent(session, userEvent('A 方案改起来麻烦吗'));
|
|
123
|
-
bridge.onSessionEvent(session, assistantEvent(2, [
|
|
124
|
-
{ type: 'reasoning', text: 'REASONING-内部推理不该进卡' },
|
|
125
|
-
{ type: 'text', text: 'A 方案改动面大一些,B 方案更稳,两种都能做。' },
|
|
126
|
-
]));
|
|
127
|
-
bridge.onSessionEvent(session, turnEnd(2));
|
|
128
|
-
|
|
129
|
-
bridge.onSessionEvent(session, turnStart(3));
|
|
130
|
-
bridge.onSessionEvent(session, userEvent('改成用 A 方案吧'));
|
|
131
|
-
bridge.onSessionEvent(session, assistantEvent(3, [
|
|
132
|
-
{ type: 'reasoning', text: 'REASONING-最终轮推理' },
|
|
133
|
-
{ type: 'text', text: '好,按这个来,以后都走 A 方案生成。' },
|
|
134
|
-
]));
|
|
135
|
-
bridge.onSessionEvent(session, turnEnd(3));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
runCase('K1', 'local decision/fact rules classify without an LLM', () => {
|
|
139
|
-
assert.equal(classifyConclusionText('定了,就用 B 方案'), 'decision');
|
|
140
|
-
assert.equal(classifyConclusionText('结论是走 A 路线'), 'decision');
|
|
141
|
-
assert.equal(classifyConclusionText('结论:走 A 路线'), 'fact');
|
|
142
|
-
assert.equal(classifyConclusionText('以后都按这个来'), 'decision');
|
|
143
|
-
assert.equal(classifyConclusionText('今天天气不错'), null);
|
|
144
|
-
assert.equal(classifyConclusionText(''), null);
|
|
145
|
-
assert.equal(judgeConclusion({ userText: '改成 A', assistantText: '好。' }), null);
|
|
146
|
-
assert.equal(judgeConclusion({ userText: '随便', assistantText: '窗外的树在动。' }), null);
|
|
147
|
-
const judged = judgeConclusion({
|
|
148
|
-
userText: '改成用 A 方案',
|
|
149
|
-
assistantText: '好,按这个来,以后都走 A 方案生成。',
|
|
150
|
-
});
|
|
151
|
-
assert.equal(judged.kind, 'decision');
|
|
152
|
-
assert.ok(Array.from(judged.body).length <= 500);
|
|
153
|
-
assert.ok(Array.from(judged.title).length <= 60);
|
|
154
|
-
assert.ok(judged.body.includes('用户:'));
|
|
155
|
-
assert.ok(judged.body.includes('助手:'));
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
runCase('K2', 'three turns write one card; reasoning blocks never reach it', () => {
|
|
159
|
-
const root = fixture('card');
|
|
160
|
-
const bridge = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
|
|
161
|
-
feedThreeTurns(bridge);
|
|
162
|
-
const file = conclusionCardFile(root, 'session-alpha', 3);
|
|
163
|
-
assert.ok(fs.existsSync(file), 'card should exist for turn 3');
|
|
164
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
165
|
-
assert.ok(text.startsWith('---\n'));
|
|
166
|
-
assert.ok(text.includes('kind: decision'));
|
|
167
|
-
assert.ok(text.includes('source_session: session-alpha'));
|
|
168
|
-
assert.ok(text.includes('source_turn: 3'));
|
|
169
|
-
assert.ok(text.includes('written_at: 2026-11-05'));
|
|
170
|
-
assert.ok(!text.includes('REASONING'));
|
|
171
|
-
assert.ok(!text.includes('今天天气'));
|
|
172
|
-
const body = text.split('\n---\n')[1] ?? '';
|
|
173
|
-
assert.ok(Array.from(body).length <= 500, `body too long: ${Array.from(body).length}`);
|
|
174
|
-
const marker = readBridgeMarker(root, 'session-alpha');
|
|
175
|
-
assert.equal(marker.lastConclusion.turn, 3);
|
|
176
|
-
assert.equal(marker.lastConclusion.kind, 'decision');
|
|
177
|
-
assert.ok(marker.lastConclusion.text.includes('按这个来'));
|
|
178
|
-
assert.ok(Array.from(marker.lastConclusion.text).length <= LAST_CONCLUSION_MAX);
|
|
179
|
-
assert.deepEqual(Object.keys(marker.turns), ['3']);
|
|
180
|
-
assert.equal(bridge.stats.written, 1);
|
|
181
|
-
assert.equal(bridge.stats.skipped, 2);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
runCase('K3', 'same session + turn is idempotent (marker survives a new bridge)', () => {
|
|
185
|
-
const root = fixture('idempotent');
|
|
186
|
-
const first = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
|
|
187
|
-
feedThreeTurns(first);
|
|
188
|
-
feedThreeTurns(first);
|
|
189
|
-
assert.equal(first.stats.duplicates, 1);
|
|
190
|
-
const second = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
|
|
191
|
-
feedThreeTurns(second);
|
|
192
|
-
assert.equal(second.stats.duplicates, 1);
|
|
193
|
-
const files = fs.readdirSync(path.join(root, 'topics'));
|
|
194
|
-
assert.equal(files.filter((name) => name.startsWith('conclusion-')).length, 1);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
runCase('K4', 'privacy gate blocks the card; a throwing gate fails closed', () => {
|
|
198
|
-
const root = fixture('privacy');
|
|
199
|
-
const skipped = createConclusionBridge({
|
|
200
|
-
enabled: true,
|
|
201
|
-
memoryRoot: root,
|
|
202
|
-
now: () => NOW,
|
|
203
|
-
privacyScan: () => ({ verdict: 'local_only', spans: [], reasons: ['test'] }),
|
|
204
|
-
});
|
|
205
|
-
feedThreeTurns(skipped);
|
|
206
|
-
assert.equal(skipped.stats.privacy, 1);
|
|
207
|
-
assert.equal(fs.existsSync(conclusionCardFile(root, 'session-alpha', 3)), false);
|
|
208
|
-
|
|
209
|
-
const root2 = fixture('privacy-throw');
|
|
210
|
-
const failed = createConclusionBridge({
|
|
211
|
-
enabled: true,
|
|
212
|
-
memoryRoot: root2,
|
|
213
|
-
now: () => NOW,
|
|
214
|
-
privacyScan: () => { throw new Error('gate down'); },
|
|
215
|
-
});
|
|
216
|
-
feedThreeTurns(failed);
|
|
217
|
-
assert.equal(failed.stats.privacy, 1);
|
|
218
|
-
assert.equal(fs.existsSync(conclusionCardFile(root2, 'session-alpha', 3)), false);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
runCase('K5', 'non-completed turns and injected user text never write', () => {
|
|
222
|
-
const root = fixture('gates');
|
|
223
|
-
const bridge = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
|
|
224
|
-
bridge.onSessionEvent(session, turnStart(1));
|
|
225
|
-
bridge.onSessionEvent(session, injectedUserEvent('<system-reminder>注入块</system-reminder> 改成 A'));
|
|
226
|
-
bridge.onSessionEvent(session, assistantEvent(1, [{ type: 'text', text: '好,按这个来,以后都走 A 方案。' }]));
|
|
227
|
-
bridge.onSessionEvent(session, turnEnd(1, 'aborted'));
|
|
228
|
-
assert.equal(fs.existsSync(conclusionCardFile(root, 'session-alpha', 1)), false);
|
|
229
|
-
assert.equal(bridge.stats.skipped, 1);
|
|
230
|
-
// reasoning-only assistant messages leave no text to judge
|
|
231
|
-
bridge.onSessionEvent(session, turnStart(2));
|
|
232
|
-
bridge.onSessionEvent(session, userEvent('改成 A 吧'));
|
|
233
|
-
bridge.onSessionEvent(session, assistantEvent(2, [{ type: 'reasoning', text: '只是想一想' }]));
|
|
234
|
-
bridge.onSessionEvent(session, turnEnd(2));
|
|
235
|
-
assert.equal(bridge.stats.skipped, 2);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
runCase('K6', 'a write failure is swallowed and never throws', () => {
|
|
239
|
-
const root = fixture('failure');
|
|
240
|
-
// Occupy the topics path with a file: mkdirSync/write must fail inside the
|
|
241
|
-
// bridge, which has to count an error instead of throwing.
|
|
242
|
-
fs.writeFileSync(path.join(root, 'topics'), 'not a directory\n', 'utf8');
|
|
243
|
-
const bridge = createConclusionBridge({ enabled: true, memoryRoot: root, now: () => NOW });
|
|
244
|
-
feedThreeTurns(bridge);
|
|
245
|
-
assert.equal(bridge.stats.written, 0);
|
|
246
|
-
assert.ok(bridge.stats.errors >= 1);
|
|
247
|
-
assert.equal(bridge.stats.privacy, 0);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
runCase('K7', 'lastConclusionOf accepts only clean strings or {text}', () => {
|
|
251
|
-
assert.equal(lastConclusionOf(undefined), null);
|
|
252
|
-
assert.equal(lastConclusionOf(null), null);
|
|
253
|
-
assert.equal(lastConclusionOf(''), null);
|
|
254
|
-
assert.equal(lastConclusionOf(' '), null);
|
|
255
|
-
assert.equal(lastConclusionOf(42), null);
|
|
256
|
-
assert.equal(lastConclusionOf([]), null);
|
|
257
|
-
assert.equal(lastConclusionOf({ text: 42 }), null);
|
|
258
|
-
assert.equal(lastConclusionOf({ text: '' }), null);
|
|
259
|
-
assert.equal(lastConclusionOf(' a\n b\u0000c '), 'a b c');
|
|
260
|
-
const long = lastConclusionOf('x'.repeat(200));
|
|
261
|
-
assert.equal(Array.from(long).length, LAST_CONCLUSION_MAX);
|
|
262
|
-
assert.ok(long.endsWith('…'));
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
runCase('K8', 'board focus carries A1 conclusion and boardHash follows it', () => {
|
|
266
|
-
const root = fixture('board');
|
|
267
|
-
liveUpdate({
|
|
268
|
-
sessionId: 'session-alpha',
|
|
269
|
-
state: 'running',
|
|
270
|
-
goal: '写 A1',
|
|
271
|
-
last_action: { kind: 'edit', detail: '改了 board.mjs', at: NOW.toISOString() },
|
|
272
|
-
}, { memoryRoot: root, now: NOW_MS });
|
|
273
|
-
writeJson(path.join(root, '.longlive', 'bridge', 'session-alpha.json'), {
|
|
274
|
-
v: 1,
|
|
275
|
-
sessionId: 'session-alpha',
|
|
276
|
-
updatedAt: NOW.toISOString(),
|
|
277
|
-
lastConclusion: {
|
|
278
|
-
text: '好,按这个来,以后都走 A 方案生成。',
|
|
279
|
-
kind: 'decision',
|
|
280
|
-
turn: 3,
|
|
281
|
-
at: NOW.toISOString(),
|
|
282
|
-
},
|
|
283
|
-
turns: { 3: NOW.toISOString() },
|
|
284
|
-
});
|
|
285
|
-
const builder = createBoardBuilder({
|
|
286
|
-
memoryRoot: root,
|
|
287
|
-
config: {},
|
|
288
|
-
now: () => NOW,
|
|
289
|
-
paths: { bridgeDir: path.join(root, '.longlive', 'bridge') },
|
|
290
|
-
});
|
|
291
|
-
const { board } = builder.build({});
|
|
292
|
-
assert.equal(board.focus.length, 1);
|
|
293
|
-
assert.equal(board.focus[0].lastConclusion, '好,按这个来,以后都走 A 方案生成。');
|
|
294
|
-
assert.equal(readBridgeConclusions(path.join(root, '.longlive', 'bridge')).get('session-alpha'),
|
|
295
|
-
'好,按这个来,以后都走 A 方案生成。');
|
|
296
|
-
const without = { ...board, focus: [{ ...board.focus[0], lastConclusion: null }] };
|
|
297
|
-
assert.notEqual(boardHashOf(board), boardHashOf(without));
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
runCase('K9', 'board falls back to last_action.detail and ignores dirty markers', () => {
|
|
301
|
-
const root = fixture('board-fallback');
|
|
302
|
-
liveUpdate({
|
|
303
|
-
sessionId: 'session-beta',
|
|
304
|
-
state: 'running',
|
|
305
|
-
goal: '写 A2',
|
|
306
|
-
last_action: { kind: 'edit', detail: 'turn 结束 #7', at: NOW.toISOString() },
|
|
307
|
-
}, { memoryRoot: root, now: NOW_MS });
|
|
308
|
-
writeJson(path.join(root, '.longlive', 'bridge', 'session-beta.json'), {
|
|
309
|
-
v: 1,
|
|
310
|
-
sessionId: 'session-beta',
|
|
311
|
-
lastConclusion: { text: ' ' },
|
|
312
|
-
});
|
|
313
|
-
const builder = createBoardBuilder({ memoryRoot: root, config: {}, now: () => NOW, paths: {} });
|
|
314
|
-
const { board } = builder.build({});
|
|
315
|
-
assert.equal(board.focus[0].lastConclusion, 'turn 结束 #7');
|
|
316
|
-
|
|
317
|
-
// A row with no explicit action only carries `state=...`; that lifecycle
|
|
318
|
-
// detail must not be rendered as a conclusion.
|
|
319
|
-
liveUpdate({ sessionId: 'session-delta', state: 'running', goal: '无动作' },
|
|
320
|
-
{ memoryRoot: root, now: NOW_MS });
|
|
321
|
-
const withDelta = builder.build({}).board;
|
|
322
|
-
const delta = withDelta.focus.find((item) => item.sessionId === 'session-delta');
|
|
323
|
-
assert.ok(delta, 'session-delta should be on the board');
|
|
324
|
-
assert.equal(delta.lastConclusion, null);
|
|
325
|
-
|
|
326
|
-
// Junk JSON and unknown shapes are skipped, not rendered.
|
|
327
|
-
writeJson(path.join(root, '.longlive', 'bridge', 'session-gamma.json'), {
|
|
328
|
-
v: 1,
|
|
329
|
-
sessionId: 'session-gamma',
|
|
330
|
-
lastConclusion: { text: { nested: true } },
|
|
331
|
-
});
|
|
332
|
-
fs.writeFileSync(path.join(root, '.longlive', 'bridge', 'broken.json'), '{not json', 'utf8');
|
|
333
|
-
const conclusions = readBridgeConclusions(path.join(root, '.longlive', 'bridge'));
|
|
334
|
-
assert.equal(conclusions.has('session-gamma'), false);
|
|
335
|
-
assert.equal(conclusions.has('session-beta'), false);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
runCase('K10', 'hint renders 目标 / 最近 / 结论 and B sees A', () => {
|
|
339
|
-
const root = fixture('hint');
|
|
340
|
-
liveUpdate({
|
|
341
|
-
sessionId: 'session-a',
|
|
342
|
-
state: 'running',
|
|
343
|
-
goal: '写 A1 回流',
|
|
344
|
-
last_action: { kind: 'edit', detail: '改 conclusion.js', at: NOW.toISOString() },
|
|
345
|
-
}, { memoryRoot: root, now: NOW_MS });
|
|
346
|
-
writeJson(path.join(root, '.longlive', 'bridge', 'session-a.json'), {
|
|
347
|
-
v: 1,
|
|
348
|
-
sessionId: 'session-a',
|
|
349
|
-
lastConclusion: { text: '决定:结论回流先默认关,等验收后再开。', kind: 'decision', turn: 4, at: NOW.toISOString() },
|
|
350
|
-
});
|
|
351
|
-
liveUpdate({
|
|
352
|
-
sessionId: 'session-b',
|
|
353
|
-
state: 'running',
|
|
354
|
-
goal: '写 A2 看板',
|
|
355
|
-
}, { memoryRoot: root, now: NOW_MS });
|
|
356
|
-
const builder = createBoardBuilder({ memoryRoot: root, config: {}, now: () => NOW, paths: {} });
|
|
357
|
-
const { board } = builder.build({});
|
|
358
|
-
const hint = renderHint(board, { tier: 'full', now: NOW_MS, selfSessionId: 'session-b' });
|
|
359
|
-
assert.ok(hint.text.includes('目标「写 A1 回流」'));
|
|
360
|
-
assert.ok(hint.text.includes('最近:edit 改 conclusion.js'));
|
|
361
|
-
assert.ok(hint.text.includes('结论:决定:结论回流先默认关,等验收后再开。'));
|
|
362
|
-
assert.ok(!hint.text.includes('注入'), 'hint must not leak unrelated text');
|
|
363
|
-
assert.ok(hint.tokens <= 700);
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
runCase('K11', 'board write/read keeps lastConclusion in the persisted JSON', () => {
|
|
367
|
-
const root = fixture('persist');
|
|
368
|
-
liveUpdate({
|
|
369
|
-
sessionId: 'session-a',
|
|
370
|
-
state: 'idle',
|
|
371
|
-
goal: '收尾',
|
|
372
|
-
}, { memoryRoot: root, now: NOW_MS });
|
|
373
|
-
writeJson(path.join(root, '.longlive', 'bridge', 'session-a.json'), {
|
|
374
|
-
v: 1,
|
|
375
|
-
sessionId: 'session-a',
|
|
376
|
-
lastConclusion: { text: '就这样,A2 用 board 现有字段,不新增配置键。', kind: 'decision', turn: 9 },
|
|
377
|
-
});
|
|
378
|
-
const vault = path.join(root, '.longlive', 'router');
|
|
379
|
-
const builder = createBoardBuilder({
|
|
380
|
-
memoryRoot: root,
|
|
381
|
-
config: {},
|
|
382
|
-
now: () => NOW,
|
|
383
|
-
paths: {
|
|
384
|
-
boardFile: path.join(vault, 'board.json'),
|
|
385
|
-
rosterFile: path.join(vault, 'index', 'roster.json'),
|
|
386
|
-
proposalsDir: path.join(vault, 'proposals'),
|
|
387
|
-
bridgeDir: path.join(root, '.longlive', 'bridge'),
|
|
388
|
-
},
|
|
389
|
-
});
|
|
390
|
-
const { board } = builder.build({});
|
|
391
|
-
const written = builder.write({ board, roster: [] });
|
|
392
|
-
assert.equal(written.ok, true);
|
|
393
|
-
const persisted = JSON.parse(fs.readFileSync(path.join(vault, 'board.json'), 'utf8'));
|
|
394
|
-
assert.equal(persisted.focus[0].lastConclusion, board.focus[0].lastConclusion);
|
|
395
|
-
const readBack = builder.read();
|
|
396
|
-
assert.equal(readBack.hash, written.board.hash);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
runCase('K12', 'disabled bridge is a no-op', () => {
|
|
400
|
-
const root = fixture('disabled');
|
|
401
|
-
const bridge = createConclusionBridge({ enabled: false, memoryRoot: root, now: () => NOW });
|
|
402
|
-
feedThreeTurns(bridge);
|
|
403
|
-
assert.equal(bridge.enabled, false);
|
|
404
|
-
assert.equal(bridge.stats.written, 0);
|
|
405
|
-
assert.equal(fs.existsSync(path.join(root, 'topics')), false);
|
|
406
|
-
assert.equal(fs.existsSync(bridgeMarkerFile(root, 'session-alpha')), false);
|
|
407
|
-
});
|
|
408
|
-
}
|