@jjh32456/dsh-longlive 1.6.8 → 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 +69 -1539
- package/LICENSE +1 -1
- package/QUICKSTART.md +33 -12
- package/README.en.md +17 -7
- package/README.md +29 -10
- package/SPEC.md +5 -5
- package/cordis.patch.yml +17 -12
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +143 -37
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +66 -29
- 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 +3 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +42 -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 +129 -18
- package/src/activity/model.js +11 -1
- package/src/activity/store.js +22 -3
- 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 +145 -48
- package/src/deps.js +6 -4
- package/src/features.js +158 -8
- 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/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 +1 -1
- 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/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/queue.mjs +1 -1
- 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/activity-realtime.test.mjs +0 -959
- package/test/activity-stream.test.mjs +0 -512
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -399
- 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 -210
- 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 -585
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/activity-append-child.mjs +0 -29
- 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 -1023
- 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 -184
- 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 -1076
- 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 -315
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -181
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/state-truth.test.mjs +0 -937
- 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/version-consistency.test.mjs +0 -286
|
@@ -1,1349 +0,0 @@
|
|
|
1
|
-
// Hot truncate (cut-only compaction) contract tests.
|
|
2
|
-
//
|
|
3
|
-
// The host is mocked down to the seams the engine actually uses:
|
|
4
|
-
// MockSession — an append-only event log with the real surface replace
|
|
5
|
-
// contract (a replace must name every removed node in
|
|
6
|
-
// sourceEventSeqs, and the replacement takes the range's
|
|
7
|
-
// position);
|
|
8
|
-
// MockMeter — fixed price per surface event, so retention windows are exact;
|
|
9
|
-
// entry — loaded with fixture host packages through the documented
|
|
10
|
-
// env overrides, to exercise import/construct fallback,
|
|
11
|
-
// mode=off, live-compact deference and no-op degradation.
|
|
12
|
-
//
|
|
13
|
-
// What is pinned here:
|
|
14
|
-
// * an open turn is never cut; a closed aborted turn is cut whole with the
|
|
15
|
-
// old prefix (1.6.6: it used to be a barrier that pinned everything);
|
|
16
|
-
// * only turns older than retainTurns AND retainTokens are cut;
|
|
17
|
-
// * the cut point and later surface text are untouched (only a prefix shrinks);
|
|
18
|
-
// * the archive contains every original event of the cut turns with a
|
|
19
|
-
// verifiable sha256 and exact event count, append-only and atomic;
|
|
20
|
-
// * the pointer text names N/M, session_original(turn=…), and the archive;
|
|
21
|
-
// * failures leave the session unchanged and fall back to stock;
|
|
22
|
-
// * live-compact wins by default (package/profile/gate/ctx probes);
|
|
23
|
-
// * three consecutive cuts are idempotent.
|
|
24
|
-
|
|
25
|
-
import { test, after } from 'node:test';
|
|
26
|
-
import assert from 'node:assert/strict';
|
|
27
|
-
import crypto from 'node:crypto';
|
|
28
|
-
import fs from 'node:fs';
|
|
29
|
-
import os from 'node:os';
|
|
30
|
-
import path from 'node:path';
|
|
31
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
|
-
|
|
33
|
-
import {
|
|
34
|
-
ARCHIVE_SCHEMA,
|
|
35
|
-
PROVIDER,
|
|
36
|
-
buildPointerText,
|
|
37
|
-
createCutEngine,
|
|
38
|
-
normalizeOptions,
|
|
39
|
-
writeArchive,
|
|
40
|
-
} from '../src/compaction/engine-core.js';
|
|
41
|
-
import { foldSurface } from '../src/codec.js';
|
|
42
|
-
|
|
43
|
-
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
-
const ROOT = path.resolve(HERE, '..');
|
|
45
|
-
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'hot-truncate-'));
|
|
46
|
-
|
|
47
|
-
after(() => {
|
|
48
|
-
try {
|
|
49
|
-
fs.rmSync(TMP, { recursive: true, force: true });
|
|
50
|
-
} catch {
|
|
51
|
-
// Best effort on Windows.
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
let caseId = 0;
|
|
56
|
-
function caseDir(name) {
|
|
57
|
-
caseId += 1;
|
|
58
|
-
const dir = path.join(TMP, `${caseId}-${name}`);
|
|
59
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
-
return dir;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
// Host mock
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
class MockManualCompactionError extends Error {
|
|
68
|
-
constructor(code, message, options) {
|
|
69
|
-
super(message, options);
|
|
70
|
-
this.name = 'ManualCompactionError';
|
|
71
|
-
this.code = code;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
class MockCompactionEngine {
|
|
76
|
-
constructor(ctx) {
|
|
77
|
-
this.ctx = ctx;
|
|
78
|
-
this.name = 'compaction';
|
|
79
|
-
this.kind = 'mock';
|
|
80
|
-
ctx.reflect.store[Symbol.for(`compaction:${Math.random()}`)] = this;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
class MockSession {
|
|
85
|
-
constructor(id = 'session-hot-truncate', { hideEvents = false } = {}) {
|
|
86
|
-
this.id = id;
|
|
87
|
-
this._events = [];
|
|
88
|
-
this._surface = [];
|
|
89
|
-
this._generation = 0;
|
|
90
|
-
// The real host Session has no `events` property. Tests that set
|
|
91
|
-
// hideEvents keep the mock surface API but make the legacy array
|
|
92
|
-
// unreadable, so only snapshotEvents()/eventAt() can serve the engine.
|
|
93
|
-
this.hideEvents = hideEvents;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
get events() {
|
|
97
|
-
return this.hideEvents ? undefined : this._events;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
get surface() {
|
|
101
|
-
return { nodes: this._surface, replaceGeneration: this._generation };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
get seq() {
|
|
105
|
-
return this._events.length;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
eventAt(seq) {
|
|
109
|
-
return this._events[seq];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
append(type, data, options = {}) {
|
|
113
|
-
const event = { type, seq: this._events.length, time: 1_700_000_000_000 + this._events.length, data: structuredClone(data) };
|
|
114
|
-
if (options.surfaceOp !== undefined) {
|
|
115
|
-
event.surfaceOp = structuredClone(options.surfaceOp);
|
|
116
|
-
if (options.sourceEventSeqs !== undefined) event.sourceEventSeqs = [...options.sourceEventSeqs];
|
|
117
|
-
if (options.surfaceOp === 'append') {
|
|
118
|
-
this._surface.push(event.seq);
|
|
119
|
-
} else {
|
|
120
|
-
// Pin the host's exact replace contract: three own keys named
|
|
121
|
-
// op/startSeq/endSeq (the host rejects `start`/`end`).
|
|
122
|
-
assert.deepEqual(Object.keys(options.surfaceOp).sort(), ['endSeq', 'op', 'startSeq'],
|
|
123
|
-
'surfaceOp must be the host replace shape {op, startSeq, endSeq}');
|
|
124
|
-
const { startSeq: start, endSeq: end } = options.surfaceOp;
|
|
125
|
-
const startIndex = this._surface.indexOf(start);
|
|
126
|
-
const endIndex = this._surface.indexOf(end);
|
|
127
|
-
assert.ok(startIndex >= 0, `replace start ${start} is on the surface`);
|
|
128
|
-
assert.ok(endIndex >= startIndex, `replace end ${end} follows start ${start}`);
|
|
129
|
-
const removed = this._surface.slice(startIndex, endIndex + 1);
|
|
130
|
-
for (const seq of removed) {
|
|
131
|
-
assert.ok(event.sourceEventSeqs.includes(seq), `sourceEventSeqs cites shadowed node ${seq}`);
|
|
132
|
-
}
|
|
133
|
-
this._surface.splice(startIndex, removed.length, event.seq);
|
|
134
|
-
this._generation += 1;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
this._events.push(event);
|
|
138
|
-
return event;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
snapshotEvents() {
|
|
142
|
-
return this._events.slice();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
deriveEventMessage(event) {
|
|
146
|
-
if (event.type === 'user/message') return event.data;
|
|
147
|
-
if (event.type === 'assistant/message') return event.data.message;
|
|
148
|
-
if (event.type === 'tool/result') return event.data.message;
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
class MockCtx {
|
|
154
|
-
constructor(meter) {
|
|
155
|
-
this.tokenMeter = meter;
|
|
156
|
-
this.sessions = {
|
|
157
|
-
flushed: 0,
|
|
158
|
-
flush: async () => {
|
|
159
|
-
this.sessions.flushed += 1;
|
|
160
|
-
return true;
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
this.store = {};
|
|
164
|
-
this.reflect = { store: this.store };
|
|
165
|
-
this.fiber = { store: {} };
|
|
166
|
-
this.services = {};
|
|
167
|
-
this.handlers = new Map();
|
|
168
|
-
this.lines = [];
|
|
169
|
-
const push = (level) => (message) => this.lines.push({ level, message: String(message) });
|
|
170
|
-
this.logger = { info: push('info'), warn: push('warn'), debug: () => {}, error: push('error') };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
on(name, handler) {
|
|
174
|
-
if (!this.handlers.has(name)) this.handlers.set(name, []);
|
|
175
|
-
this.handlers.get(name).push(handler);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
get(name) {
|
|
179
|
-
return this.services[name];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
text(level = null) {
|
|
183
|
-
return this.lines.filter((line) => level === null || line.level === level).map((line) => line.message).join('\n');
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function makeMeter(perEvent = 500) {
|
|
188
|
-
return {
|
|
189
|
-
measure(session) {
|
|
190
|
-
const nodes = session.surface.nodes.map((seq) => ({ seq, tokens: perEvent }));
|
|
191
|
-
return { totalTokens: nodes.length * perEvent, nodes };
|
|
192
|
-
},
|
|
193
|
-
estimateMessage(message) {
|
|
194
|
-
return Math.ceil(JSON.stringify(message).length / 2);
|
|
195
|
-
},
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function makeAgent(session) {
|
|
200
|
-
return {
|
|
201
|
-
session,
|
|
202
|
-
status: 'idle',
|
|
203
|
-
runMaintenance: (task) => task(new AbortController().signal),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function addTurn(session, turn, { completed = true, open = false, surface = true } = {}) {
|
|
208
|
-
session.append('turn/start', { turn });
|
|
209
|
-
if (surface) {
|
|
210
|
-
session.append('user/message', {
|
|
211
|
-
id: `user-${turn}`,
|
|
212
|
-
role: 'user',
|
|
213
|
-
content: [{ type: 'text', text: `第 ${turn} 轮用户消息` }],
|
|
214
|
-
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
|
|
215
|
-
}, { surfaceOp: 'append' });
|
|
216
|
-
session.append('assistant/message', {
|
|
217
|
-
turn,
|
|
218
|
-
step: 1,
|
|
219
|
-
message: {
|
|
220
|
-
id: `assistant-${turn}`,
|
|
221
|
-
role: 'assistant',
|
|
222
|
-
source: { kind: 'model', provider: 'test', model: 'test' },
|
|
223
|
-
content: [{ type: 'text', text: `第 ${turn} 轮回答` }],
|
|
224
|
-
},
|
|
225
|
-
}, { surfaceOp: 'append' });
|
|
226
|
-
}
|
|
227
|
-
if (!open) session.append('turn/end', { turn, reason: { kind: completed ? 'completed' : 'aborted' } });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** A turn whose first surface node is the host's system prompt (surface 0). */
|
|
231
|
-
function addTurnWithSystemHead(session, turn, options = {}) {
|
|
232
|
-
session.append('turn/start', { turn });
|
|
233
|
-
session.append('system/message', {
|
|
234
|
-
turn,
|
|
235
|
-
step: 1,
|
|
236
|
-
message: {
|
|
237
|
-
id: `system-${turn}`,
|
|
238
|
-
role: 'system',
|
|
239
|
-
source: { kind: 'system-prompt' },
|
|
240
|
-
content: [{ type: 'text', text: 'You are a protected system prompt.' }],
|
|
241
|
-
},
|
|
242
|
-
}, { surfaceOp: 'append' });
|
|
243
|
-
session.append('user/message', {
|
|
244
|
-
id: `user-${turn}`,
|
|
245
|
-
role: 'user',
|
|
246
|
-
content: [{ type: 'text', text: `第 ${turn} 轮用户消息` }],
|
|
247
|
-
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
|
|
248
|
-
}, { surfaceOp: 'append' });
|
|
249
|
-
session.append('assistant/message', {
|
|
250
|
-
turn,
|
|
251
|
-
step: 1,
|
|
252
|
-
message: {
|
|
253
|
-
id: `assistant-${turn}`,
|
|
254
|
-
role: 'assistant',
|
|
255
|
-
source: { kind: 'model', provider: 'test', model: 'test' },
|
|
256
|
-
content: [{ type: 'text', text: `第 ${turn} 轮回答` }],
|
|
257
|
-
},
|
|
258
|
-
}, { surfaceOp: 'append' });
|
|
259
|
-
if (!options.open) session.append('turn/end', { turn, reason: { kind: options.completed === false ? 'aborted' : 'completed' } });
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/** A turn whose system prompt sits after its user message (mid-span barrier). */
|
|
263
|
-
function addTurnWithSystemInside(session, turn, options = {}) {
|
|
264
|
-
session.append('turn/start', { turn });
|
|
265
|
-
session.append('user/message', {
|
|
266
|
-
id: `user-${turn}`,
|
|
267
|
-
role: 'user',
|
|
268
|
-
content: [{ type: 'text', text: `第 ${turn} 轮用户消息` }],
|
|
269
|
-
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
|
|
270
|
-
}, { surfaceOp: 'append' });
|
|
271
|
-
session.append('system/message', {
|
|
272
|
-
turn,
|
|
273
|
-
step: 2,
|
|
274
|
-
message: {
|
|
275
|
-
id: `system-${turn}`,
|
|
276
|
-
role: 'system',
|
|
277
|
-
source: { kind: 'system-prompt' },
|
|
278
|
-
content: [{ type: 'text', text: 'A later system message.' }],
|
|
279
|
-
},
|
|
280
|
-
}, { surfaceOp: 'append' });
|
|
281
|
-
session.append('assistant/message', {
|
|
282
|
-
turn,
|
|
283
|
-
step: 2,
|
|
284
|
-
message: {
|
|
285
|
-
id: `assistant-${turn}`,
|
|
286
|
-
role: 'assistant',
|
|
287
|
-
source: { kind: 'model', provider: 'test', model: 'test' },
|
|
288
|
-
content: [{ type: 'text', text: `第 ${turn} 轮回答` }],
|
|
289
|
-
},
|
|
290
|
-
}, { surfaceOp: 'append' });
|
|
291
|
-
if (!options.open) session.append('turn/end', { turn, reason: { kind: options.completed === false ? 'aborted' : 'completed' } });
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function buildSession(turns, { openLast = true, lastCompleted = true, perTurn = null, hideEvents = false } = {}) {
|
|
295
|
-
const session = new MockSession('session-hot-truncate', { hideEvents });
|
|
296
|
-
for (let turn = 1; turn <= turns; turn += 1) {
|
|
297
|
-
if (openLast && turn === turns) addTurn(session, turn, { open: true });
|
|
298
|
-
else addTurn(session, turn, { completed: lastCompleted });
|
|
299
|
-
if (typeof perTurn === 'function') perTurn(turn);
|
|
300
|
-
}
|
|
301
|
-
return session;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function engineFor({ perEvent = 500, archiveDir, engineDeps = {}, useDefaults = false, ...options } = {}) {
|
|
305
|
-
const meter = makeMeter(perEvent);
|
|
306
|
-
const ctx = new MockCtx(meter);
|
|
307
|
-
const Engine = createCutEngine({
|
|
308
|
-
CompactionEngine: MockCompactionEngine,
|
|
309
|
-
CompactionId: (id) => id,
|
|
310
|
-
ManualCompactionError: MockManualCompactionError,
|
|
311
|
-
compactCheckpointSource: (compactionId, sourceCommandId) => ({
|
|
312
|
-
kind: 'plugin', plugin: 'compact', compactionId, ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
|
|
313
|
-
}),
|
|
314
|
-
isCompactCheckpointSource: (source) => source?.kind === 'plugin' && source?.plugin === 'compact',
|
|
315
|
-
createUserMessage: (input) => ({
|
|
316
|
-
id: `msg-${Math.random().toString(16).slice(2)}`,
|
|
317
|
-
role: 'user',
|
|
318
|
-
content: input.content,
|
|
319
|
-
source: input.source,
|
|
320
|
-
}),
|
|
321
|
-
toolPairingBalancedBefore: () => true,
|
|
322
|
-
toolPairingBalancedAfter: () => true,
|
|
323
|
-
...engineDeps,
|
|
324
|
-
});
|
|
325
|
-
const engine = new Engine(ctx, {
|
|
326
|
-
hotTruncate: {
|
|
327
|
-
mode: 'cut',
|
|
328
|
-
// Most historical cases pin the pre-1.6.4 window/threshold explicitly;
|
|
329
|
-
// useDefaults drops those pins so the test exercises the new
|
|
330
|
-
// DEFAULT_OPTIONS (4 turns / 300000 tokens).
|
|
331
|
-
...(useDefaults ? {} : { retainTurns: 6, retainTokens: 0, autoMinTokens: 0 }),
|
|
332
|
-
archiveDir: archiveDir !== undefined ? archiveDir : caseDir('archive'),
|
|
333
|
-
...options,
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
return { engine, ctx, meter };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function pointerEvents(session) {
|
|
340
|
-
return session._events.filter((event) => event.type === 'compaction/summary');
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function readArchive(file) {
|
|
344
|
-
const text = fs.readFileSync(file, 'utf8');
|
|
345
|
-
const lines = text.split('\n').filter((line) => line.length > 0);
|
|
346
|
-
const header = JSON.parse(lines[0]);
|
|
347
|
-
const footer = JSON.parse(lines[lines.length - 1]);
|
|
348
|
-
const bodyLines = lines.slice(1, -1);
|
|
349
|
-
const body = bodyLines.map((line) => `${line}\n`).join('');
|
|
350
|
-
const sha256 = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
|
|
351
|
-
return { header, footer, records: bodyLines.map((line) => JSON.parse(line)), bodyLines, sha256, text };
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// ---------------------------------------------------------------------------
|
|
355
|
-
// Selection and safety
|
|
356
|
-
// ---------------------------------------------------------------------------
|
|
357
|
-
|
|
358
|
-
test('H1 open turn is never cut; only completed turns older than the window go', async () => {
|
|
359
|
-
const session = buildSession(9, { openLast: true });
|
|
360
|
-
const { engine, ctx } = engineFor({ archiveDir: caseDir('archive') });
|
|
361
|
-
const before = session.surface.nodes.length;
|
|
362
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
363
|
-
|
|
364
|
-
assert.ok(result, 'cut happened');
|
|
365
|
-
assert.deepEqual(result.shadowedSeqs.length, 4, 'turns 1-2 have two surface nodes each');
|
|
366
|
-
assert.equal(pointerEvents(session).length, 1);
|
|
367
|
-
assert.equal(session.surface.nodes[0], result.summarySeq + 1, 'pointer is the first surface node');
|
|
368
|
-
assert.equal(session.surface.nodes.length, before - result.shadowedSeqs.length + 1);
|
|
369
|
-
// The open turn's own messages are still on the surface.
|
|
370
|
-
const openUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-9');
|
|
371
|
-
assert.ok(session.surface.nodes.includes(openUser.seq), 'open turn untouched');
|
|
372
|
-
// And a session with fewer than retainTurns completed turns cannot cut.
|
|
373
|
-
const small = buildSession(5, { openLast: true });
|
|
374
|
-
const second = await engine.compactIfNeeded(makeAgent(small), 'pressure', new AbortController().signal);
|
|
375
|
-
assert.equal(second, null);
|
|
376
|
-
assert.equal(pointerEvents(small).length, 0);
|
|
377
|
-
assert.ok(ctx.lines.length >= 0);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
test('H2 retainTurns and retainTokens both gate the cut', async () => {
|
|
381
|
-
const turns = buildSession(10, { openLast: false });
|
|
382
|
-
const { engine } = engineFor({ perEvent: 500, archiveDir: caseDir('archive'), retainTurns: 3, retainTokens: 2500 });
|
|
383
|
-
const result = await engine.compactNow(makeAgent(turns), new AbortController().signal);
|
|
384
|
-
assert.ok(result);
|
|
385
|
-
assert.equal(result.shadowedSeqs.length, 14, 'turns 1-7 cut');
|
|
386
|
-
// Surface: pointer + turns 8,9,10 (2 nodes each).
|
|
387
|
-
assert.equal(turns.surface.nodes.length, 1 + 6);
|
|
388
|
-
const pointer = turns.events[result.summarySeq + 1];
|
|
389
|
-
assert.match(pointer.data.content[0].text, /保留窗口:最近 3 个已完成轮、至少 2500 tokens/);
|
|
390
|
-
|
|
391
|
-
const tokenDominant = buildSession(10, { openLast: false });
|
|
392
|
-
const second = engineFor({ perEvent: 500, archiveDir: caseDir('archive'), retainTurns: 1, retainTokens: 4500 });
|
|
393
|
-
const result2 = await second.engine.compactNow(makeAgent(tokenDominant), new AbortController().signal);
|
|
394
|
-
assert.ok(result2);
|
|
395
|
-
assert.equal(result2.shadowedSeqs.length, 10, 'turns 1-5 cut by the token floor');
|
|
396
|
-
assert.equal(tokenDominant.surface.nodes.length, 1 + 10);
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test('H3 closed aborted turns are cut with the old prefix and never freeze later cuts', async () => {
|
|
400
|
-
const session = new MockSession();
|
|
401
|
-
for (let turn = 1; turn <= 3; turn += 1) addTurn(session, turn, { completed: false });
|
|
402
|
-
for (let turn = 4; turn <= 9; turn += 1) addTurn(session, turn, { completed: true });
|
|
403
|
-
addTurn(session, 10, { open: true });
|
|
404
|
-
// 1.6.6 hotfix: 1.6.5 still skipped past aborted turns, so when aborted turns
|
|
405
|
-
// recur near the tail the span collapsed to "the one completed turn after the
|
|
406
|
-
// last aborted turn" (the 396k-token bug). A turn with turn/end is closed and
|
|
407
|
-
// cuttable as a whole, so the prefix is removed in one plan.
|
|
408
|
-
const dir = caseDir('archive');
|
|
409
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
410
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
411
|
-
assert.ok(result, 'aborted turns must not block the prefix cut');
|
|
412
|
-
assert.equal(result.shadowedSeqs.length, 10, 'turns 1-5 (2 surface nodes each) are cut');
|
|
413
|
-
const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
|
|
414
|
-
assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5]);
|
|
415
|
-
assert.equal(pointerEvents(session).length, 1);
|
|
416
|
-
// Only the four retained completed turns and the open turn stay.
|
|
417
|
-
assert.equal(session.surface.nodes.length, 1 + 8 + 2);
|
|
418
|
-
const summary = pointerEvents(session)[0];
|
|
419
|
-
const pointer = session.events[summary.seq + 1];
|
|
420
|
-
assert.match(pointer.data.content[0].text, /第 1–5 轮/);
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
// ---------------------------------------------------------------------------
|
|
424
|
-
// Archive
|
|
425
|
-
// ---------------------------------------------------------------------------
|
|
426
|
-
|
|
427
|
-
test('H3b a mid-turn system node is archived with its old prefix instead of freezing the plan', async () => {
|
|
428
|
-
// 1.6.6: 1.6.5 kept the whole turn containing a host-injected system node in
|
|
429
|
-
// place and started the span after it. When such injections land inside
|
|
430
|
-
// several completed turns (every host restart re-emits the prompt mid-turn),
|
|
431
|
-
// that rule pinned ≈100k tokens permanently: real session 2026-11-06 turns
|
|
432
|
-
// 66/72/75. The protected copy is the leading one at surface 0 (see H28); a
|
|
433
|
-
// later injected copy is host-tolerated inside a replacement range, so it is
|
|
434
|
-
// archived with the prefix it belongs to.
|
|
435
|
-
const session = new MockSession();
|
|
436
|
-
for (let turn = 1; turn <= 3; turn += 1) addTurn(session, turn, { completed: false });
|
|
437
|
-
session.append('turn/start', { turn: 4 });
|
|
438
|
-
session.append('assistant/message', {
|
|
439
|
-
turn: 4,
|
|
440
|
-
step: 1,
|
|
441
|
-
message: {
|
|
442
|
-
id: 'assistant-4',
|
|
443
|
-
role: 'assistant',
|
|
444
|
-
source: { kind: 'model', provider: 'test', model: 'test' },
|
|
445
|
-
content: [{ type: 'text', text: '第 4 轮回答(前面是重启中断)' }],
|
|
446
|
-
},
|
|
447
|
-
}, { surfaceOp: 'append' });
|
|
448
|
-
session.append('system/message', {
|
|
449
|
-
turn: 4,
|
|
450
|
-
step: 2,
|
|
451
|
-
message: {
|
|
452
|
-
id: 'system-4',
|
|
453
|
-
role: 'system',
|
|
454
|
-
source: { kind: 'system-prompt' },
|
|
455
|
-
content: [{ type: 'text', text: 'A host-injected system message mid-turn.' }],
|
|
456
|
-
},
|
|
457
|
-
}, { surfaceOp: 'append' });
|
|
458
|
-
session.append('user/message', {
|
|
459
|
-
id: 'user-4b',
|
|
460
|
-
role: 'user',
|
|
461
|
-
content: [{ type: 'text', text: '第 4 轮用户追问' }],
|
|
462
|
-
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
|
|
463
|
-
}, { surfaceOp: 'append' });
|
|
464
|
-
session.append('turn/end', { turn: 4, reason: { kind: 'completed' } });
|
|
465
|
-
for (let turn = 5; turn <= 10; turn += 1) addTurn(session, turn, { completed: true });
|
|
466
|
-
addTurn(session, 11, { open: true });
|
|
467
|
-
const dir = caseDir('archive');
|
|
468
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
469
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
470
|
-
assert.ok(result, 'a mid-turn system node must not freeze the prefix cut');
|
|
471
|
-
assert.equal(result.shadowedSeqs.length, 13, 'turns 1-6 (2+2+2+3+2+2 surface nodes) are cut');
|
|
472
|
-
const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
|
|
473
|
-
assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5, 6]);
|
|
474
|
-
assert.equal(session.surface.nodes.length, 1 + 8 + 2, 'pointer + four retained turns + open turn');
|
|
475
|
-
const summary = pointerEvents(session)[0];
|
|
476
|
-
const pointer = session.events[summary.seq + 1];
|
|
477
|
-
assert.match(pointer.data.content[0].text, /第 1–6 轮/);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
test('H30 a mid-surface aborted turn does not collapse the plan to one turn (396k regression)', async () => {
|
|
481
|
-
// Real session 2026-11-06 shape: the log has many completed turns, one
|
|
482
|
-
// restart-aborted turn near the tail, and the retention boundary right after
|
|
483
|
-
// it. 1.6.5 moved the span past the aborted turn, so the cut became "one
|
|
484
|
-
// completed turn / 6-18k tokens" while the whole older prefix stayed. The
|
|
485
|
-
// new planner must cut the entire prefix, aborted turn included, so that
|
|
486
|
-
// only the four retained completed turns and the open turn are left.
|
|
487
|
-
const session = new MockSession();
|
|
488
|
-
for (let turn = 1; turn <= 10; turn += 1) addTurn(session, turn, { completed: true });
|
|
489
|
-
addTurn(session, 11, { completed: false }); // aborted, closed
|
|
490
|
-
for (let turn = 12; turn <= 20; turn += 1) addTurn(session, turn, { completed: true });
|
|
491
|
-
addTurn(session, 21, { open: true });
|
|
492
|
-
const dir = caseDir('archive');
|
|
493
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
494
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
495
|
-
assert.ok(result);
|
|
496
|
-
assert.equal(result.shadowedSeqs.length, 32, '16 turns x 2 surface nodes, aborted turn included');
|
|
497
|
-
const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
|
|
498
|
-
assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
|
499
|
-
// pointer + turns 17-20 (8 nodes) + open turn 21 (2 nodes)
|
|
500
|
-
assert.equal(session.surface.nodes.length, 1 + 8 + 2);
|
|
501
|
-
const abortedUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-11');
|
|
502
|
-
assert.equal(session.surface.nodes.includes(abortedUser.seq), false, 'the aborted turn left the surface');
|
|
503
|
-
const openUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-21');
|
|
504
|
-
assert.equal(session.surface.nodes.includes(openUser.seq), true, 'the open turn is never cut');
|
|
505
|
-
assert.match(result.summary[0].text, /第 1–16 轮/);
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
test('H30b real-session shape: head system + mid-surface injected system + aborted turns', async () => {
|
|
509
|
-
// Mirrors session-e219dedf on 2026-11-06: the real system prompt sits at
|
|
510
|
-
// surface 0 and must stay; a restart injects a second system/message in the
|
|
511
|
-
// middle of the surface (inside turn 12); several turns are aborted; the
|
|
512
|
-
// current turn is open. A single cut must remove the whole old prefix and
|
|
513
|
-
// leave exactly the four retained completed turns plus the open turn.
|
|
514
|
-
const session = new MockSession();
|
|
515
|
-
addTurnWithSystemHead(session, 1);
|
|
516
|
-
for (let turn = 2; turn <= 20; turn += 1) {
|
|
517
|
-
if (turn === 12) {
|
|
518
|
-
session.append('turn/start', { turn: 12 });
|
|
519
|
-
session.append('user/message', {
|
|
520
|
-
id: 'user-12', role: 'user', content: [{ type: 'text', text: '第 12 轮用户消息' }],
|
|
521
|
-
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
|
|
522
|
-
}, { surfaceOp: 'append' });
|
|
523
|
-
session.append('assistant/message', {
|
|
524
|
-
turn: 12, step: 1,
|
|
525
|
-
message: {
|
|
526
|
-
id: 'assistant-12', role: 'assistant',
|
|
527
|
-
source: { kind: 'model', provider: 'test', model: 'test' },
|
|
528
|
-
content: [{ type: 'text', text: '第 12 轮回答' }],
|
|
529
|
-
},
|
|
530
|
-
}, { surfaceOp: 'append' });
|
|
531
|
-
session.append('system/message', {
|
|
532
|
-
turn: 12, step: 2,
|
|
533
|
-
message: {
|
|
534
|
-
id: 'system-12', role: 'system', source: { kind: 'system-prompt' },
|
|
535
|
-
content: [{ type: 'text', text: 'You are an AI agent powered by DeepSeek Harness.' }],
|
|
536
|
-
},
|
|
537
|
-
}, { surfaceOp: 'append' });
|
|
538
|
-
session.append('turn/end', { turn: 12, reason: { kind: 'completed' } });
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
if (turn === 8 || turn === 15) addTurn(session, turn, { completed: false });
|
|
542
|
-
else addTurn(session, turn, { completed: true });
|
|
543
|
-
}
|
|
544
|
-
addTurn(session, 21, { open: true });
|
|
545
|
-
const systemEvent = session._events.find((event) => event.type === 'system/message');
|
|
546
|
-
const injected = session._events.filter((event) => event.type === 'system/message')[1];
|
|
547
|
-
assert.ok(systemEvent && injected);
|
|
548
|
-
const dir = caseDir('archive');
|
|
549
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
550
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
551
|
-
assert.ok(result, 'the 396k shape must produce a plan');
|
|
552
|
-
const archive = readArchive(path.join(dir, fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'))));
|
|
553
|
-
assert.deepEqual(archive.header.cutTurns.map((entry) => entry.turn), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
|
554
|
-
assert.equal(result.shadowedSeqs.includes(systemEvent.seq), false, 'the head system prompt is never cut');
|
|
555
|
-
assert.equal(result.shadowedSeqs.includes(injected.seq), true, 'the injected copy goes with the old prefix');
|
|
556
|
-
assert.equal(session.surface.nodes[0], systemEvent.seq, 'head system prompt stays at surface 0');
|
|
557
|
-
// head system + pointer + four retained turns (8 nodes) + open turn (2 nodes)
|
|
558
|
-
assert.equal(session.surface.nodes.length, 1 + 1 + 8 + 2);
|
|
559
|
-
assert.equal(session.surface.nodes.includes(injected.seq), false, 'injected copy left the surface');
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
// ---------------------------------------------------------------------------
|
|
563
|
-
// 1.6.7 hardening: barriers must never disable hot truncate silently
|
|
564
|
-
// ---------------------------------------------------------------------------
|
|
565
|
-
|
|
566
|
-
/** Append a message-shaped event that belongs to no turn (no turn/start). */
|
|
567
|
-
function orphanMessage(session, type, id, text) {
|
|
568
|
-
const data = type === 'assistant/message'
|
|
569
|
-
? { message: { id, role: 'assistant', content: [{ type: 'text', text }] } }
|
|
570
|
-
: { role: type === 'system/message' ? 'system' : 'user', id, content: [{ type: 'text', text }] };
|
|
571
|
-
return session.append(type, data, { surfaceOp: 'append' });
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
test('H35 head truncated mid-turn (orphan messages before the system prompt) still cuts', async () => {
|
|
575
|
-
// Red team E1: raw log truncation drops the head turn/start, leaving orphan
|
|
576
|
-
// user/assistant surface nodes before the re-appended system prompt. 1.6.6
|
|
577
|
-
// treated the turn===0 group as open => plan null forever. Now the orphan
|
|
578
|
-
// block/head instructions are kept in place and the closed turns after them
|
|
579
|
-
// are cut (red team E1: null -> [1,2]).
|
|
580
|
-
const session = new MockSession();
|
|
581
|
-
const orphanU = orphanMessage(session, 'user/message', 'orphan-u', 'orphan-mid-turn');
|
|
582
|
-
orphanMessage(session, 'assistant/message', 'orphan-a', 'orphan answer');
|
|
583
|
-
const headSystem = orphanMessage(session, 'system/message', 'head-sys', 'HEAD-SYSTEM');
|
|
584
|
-
for (let turn = 1; turn <= 7; turn += 1) {
|
|
585
|
-
if (turn === 7) addTurn(session, turn, { open: true });
|
|
586
|
-
else addTurn(session, turn, { completed: true });
|
|
587
|
-
}
|
|
588
|
-
const dir = caseDir('archive');
|
|
589
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
590
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
591
|
-
assert.ok(result, 'the head barrier must not disable the planner (E1 was null before)');
|
|
592
|
-
const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
|
|
593
|
-
const parsed = readArchive(path.join(dir, file));
|
|
594
|
-
assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2]);
|
|
595
|
-
assert.equal(result.shadowedSeqs.includes(headSystem.seq), false, 'head system instruction is never cut');
|
|
596
|
-
assert.equal(session.surface.nodes.includes(headSystem.seq), true, 'head system instruction stays on the surface');
|
|
597
|
-
assert.equal(session.surface.nodes.includes(orphanU.seq), true, 'orphan head block stays in place');
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
test('H36 a stray user/message between turns does not pin the prefix (red team E3)', async () => {
|
|
601
|
-
const session = new MockSession();
|
|
602
|
-
const headSystem = orphanMessage(session, 'system/message', 'head-sys', 'HEAD');
|
|
603
|
-
addTurn(session, 1, { completed: true });
|
|
604
|
-
const stray = session.append('user/message', {
|
|
605
|
-
id: 'between-turns', role: 'user', content: [{ type: 'text', text: 'between-turns' }],
|
|
606
|
-
}, { surfaceOp: 'append' });
|
|
607
|
-
for (let turn = 2; turn <= 7; turn += 1) {
|
|
608
|
-
if (turn === 7) addTurn(session, turn, { open: true });
|
|
609
|
-
else addTurn(session, turn, { completed: true });
|
|
610
|
-
}
|
|
611
|
-
const dir = caseDir('archive');
|
|
612
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
613
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
614
|
-
assert.ok(result, 'the stray turn-0 group must not freeze the cut (E3 cut only [1] before)');
|
|
615
|
-
const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
|
|
616
|
-
const parsed = readArchive(path.join(dir, file));
|
|
617
|
-
assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2]);
|
|
618
|
-
assert.equal(result.shadowedSeqs.includes(stray.seq), true, 'the stray message is inside the cut span');
|
|
619
|
-
assert.equal(session.surface.nodes.includes(stray.seq), false, 'the stray message left the surface');
|
|
620
|
-
assert.equal(session.surface.nodes.includes(headSystem.seq), true, 'the head system instruction stays');
|
|
621
|
-
assert.equal(parsed.records.some((record) => record.seq === stray.seq), true, 'the stray message is recoverable from the archive');
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
test('H37 a mid-log turn missing turn/end does not block later cuts (red team E2)', async () => {
|
|
625
|
-
const session = new MockSession();
|
|
626
|
-
orphanMessage(session, 'system/message', 'head-sys', 'HEAD');
|
|
627
|
-
addTurn(session, 1, { completed: true });
|
|
628
|
-
const abandonedStart = session.events.length;
|
|
629
|
-
addTurn(session, 2, { open: true }); // turn/end lost by log truncation
|
|
630
|
-
const abandonedEnd = session.events.length - 1;
|
|
631
|
-
for (let turn = 3; turn <= 6; turn += 1) addTurn(session, turn, { completed: true });
|
|
632
|
-
addTurn(session, 7, { open: true }); // the real current turn
|
|
633
|
-
const currentUser = session.events.find((event) => event.type === 'user/message' && event.data.id === 'user-7');
|
|
634
|
-
const dir = caseDir('archive');
|
|
635
|
-
const { engine } = engineFor({ archiveDir: dir, perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
636
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
637
|
-
assert.ok(result, 'an abandoned mid-log unclosed turn must not freeze the planner');
|
|
638
|
-
const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
|
|
639
|
-
const parsed = readArchive(path.join(dir, file));
|
|
640
|
-
assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2], 'turns before the retention window are cut');
|
|
641
|
-
const abandoned = parsed.header.cutTurns.find((entry) => entry.turn === 2);
|
|
642
|
-
assert.ok(abandoned.endSeq > abandoned.startSeq, 'abandoned turn archive range is inferred, not empty');
|
|
643
|
-
assert.equal(parsed.records.some((record) => record.turn === 2), true, 'abandoned turn events are recoverable');
|
|
644
|
-
assert.equal(session.surface.nodes.includes(currentUser.seq), true, 'the current open turn is never cut');
|
|
645
|
-
assert.equal(session.surface.nodes.some((seq) => seq >= abandonedStart && seq <= abandonedEnd), false, 'the abandoned turn left the surface');
|
|
646
|
-
assert.equal(result.shadowedSeqs.includes(currentUser.seq), false);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
test('H38 "cut or say why": a blocked prefix logs a structured refusal instead of silence', async () => {
|
|
650
|
-
// Normal case: not enough completed turns yet.
|
|
651
|
-
const small = new MockSession();
|
|
652
|
-
addTurn(small, 1, { completed: true });
|
|
653
|
-
addTurn(small, 2, { completed: false });
|
|
654
|
-
addTurn(small, 3, { open: true });
|
|
655
|
-
const { engine, ctx } = engineFor({ archiveDir: caseDir('archive'), perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
656
|
-
const none = await engine.compactIfNeeded(makeAgent(small), 'pressure', new AbortController().signal);
|
|
657
|
-
assert.equal(none, null);
|
|
658
|
-
assert.match(ctx.text('warn'), /未切(not-enough-turns)/);
|
|
659
|
-
|
|
660
|
-
// Blocked prefix: the only groups older than the boundary are a turn===0
|
|
661
|
-
// system zone, so there is nothing whole to cut. The reason must be visible.
|
|
662
|
-
const blocked = new MockSession();
|
|
663
|
-
orphanMessage(blocked, 'system/message', 'head-sys', 'HEAD');
|
|
664
|
-
for (let turn = 1; turn <= 5; turn += 1) {
|
|
665
|
-
if (turn === 5) addTurn(blocked, turn, { open: true });
|
|
666
|
-
else addTurn(blocked, turn, { completed: true });
|
|
667
|
-
}
|
|
668
|
-
const second = engineFor({ archiveDir: caseDir('archive'), perEvent: 5000, retainTurns: 4, retainTokens: 0, autoMinTokens: 0 });
|
|
669
|
-
const blockedResult = await second.engine.compactIfNeeded(makeAgent(blocked), 'pressure', new AbortController().signal);
|
|
670
|
-
assert.equal(blockedResult, null, 'nothing cuttable yet');
|
|
671
|
-
assert.match(second.ctx.text('warn'), /未切(blocked-prefix)/);
|
|
672
|
-
assert.match(second.ctx.text('warn'), /head-sys|blocked-prefix/);
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
test('H4 archive: atomic JSONL, exact event count, sha256, append-only', async () => {
|
|
676
|
-
const dir = caseDir('archive');
|
|
677
|
-
const session = buildSession(9, { openLast: false });
|
|
678
|
-
const { engine } = engineFor({ archiveDir: dir });
|
|
679
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
680
|
-
assert.ok(result);
|
|
681
|
-
|
|
682
|
-
const files = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
|
|
683
|
-
assert.equal(files.length, 1);
|
|
684
|
-
assert.match(files[0], /^\d{8}T\d{9}Z-[0-9a-f]{8}\.jsonl$/);
|
|
685
|
-
const parsed = readArchive(path.join(dir, files[0]));
|
|
686
|
-
assert.equal(parsed.header.kind, 'hot-truncate/header');
|
|
687
|
-
assert.equal(parsed.header.schema, ARCHIVE_SCHEMA);
|
|
688
|
-
assert.equal(parsed.header.compactionId, result.compactionId);
|
|
689
|
-
assert.equal(parsed.header.provider, PROVIDER);
|
|
690
|
-
assert.equal(parsed.footer.kind, 'hot-truncate/footer');
|
|
691
|
-
assert.equal(parsed.header.eventCount, parsed.records.length);
|
|
692
|
-
assert.equal(parsed.footer.eventCount, parsed.records.length);
|
|
693
|
-
assert.equal(parsed.header.bodySha256, parsed.sha256);
|
|
694
|
-
assert.equal(parsed.footer.bodySha256, parsed.sha256);
|
|
695
|
-
// Turns 1-3: turn/start, user, assistant, turn/end = 12 original events.
|
|
696
|
-
assert.equal(parsed.records.length, 12);
|
|
697
|
-
assert.deepEqual([...new Set(parsed.records.map((record) => record.turn))], [1, 2, 3]);
|
|
698
|
-
for (const record of parsed.records) {
|
|
699
|
-
const original = session.events[record.seq];
|
|
700
|
-
assert.ok(original, `seq ${record.seq} exists in the log`);
|
|
701
|
-
assert.equal(record.type, original.type);
|
|
702
|
-
assert.deepEqual(record.data, original.data, `seq ${record.seq} data is the original`);
|
|
703
|
-
assert.ok('seq' in record && 'turn' in record, 'each line carries seq/turn');
|
|
704
|
-
}
|
|
705
|
-
assert.deepEqual(parsed.header.cutTurns, [
|
|
706
|
-
{ turn: 1, firstSeq: 1, lastSeq: 2, startSeq: 0, endSeq: 3, surfaceEvents: 2, tokens: 1000 },
|
|
707
|
-
{ turn: 2, firstSeq: 5, lastSeq: 6, startSeq: 4, endSeq: 7, surfaceEvents: 2, tokens: 1000 },
|
|
708
|
-
{ turn: 3, firstSeq: 9, lastSeq: 10, startSeq: 8, endSeq: 11, surfaceEvents: 2, tokens: 1000 },
|
|
709
|
-
]);
|
|
710
|
-
const headerBefore = parsed.sha256;
|
|
711
|
-
|
|
712
|
-
// A later cut must add a second file and leave the first byte-identical.
|
|
713
|
-
addTurn(session, 10, { completed: true });
|
|
714
|
-
addTurn(session, 11, { open: true });
|
|
715
|
-
const second = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
716
|
-
assert.ok(second);
|
|
717
|
-
const filesAfter = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')).sort();
|
|
718
|
-
assert.equal(filesAfter.length, 2, 'append-only: a new archive file, never a rewrite');
|
|
719
|
-
assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, filesAfter.find((name) => name !== files[0])), 'utf8')).digest('hex').length, 64);
|
|
720
|
-
assert.equal(readArchive(path.join(dir, files[0])).sha256, headerBefore, 'first archive untouched');
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
// ---------------------------------------------------------------------------
|
|
724
|
-
// Pointer
|
|
725
|
-
// ---------------------------------------------------------------------------
|
|
726
|
-
|
|
727
|
-
test('H5 pointer text carries N/M, session_original(turn=…), and the archive path', async () => {
|
|
728
|
-
const dir = caseDir('archive');
|
|
729
|
-
const session = buildSession(9, { openLast: false });
|
|
730
|
-
const { engine } = engineFor({ archiveDir: dir });
|
|
731
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
732
|
-
assert.ok(result);
|
|
733
|
-
const text = result.summary[0].text;
|
|
734
|
-
assert.match(text, /本会话共 9 轮,其中 3 轮已压缩归档;需要原文用 session_original\(turn=…\) 或读归档路径:/);
|
|
735
|
-
assert.ok(text.includes(dir), 'archive directory is in the pointer');
|
|
736
|
-
assert.match(text, /只切已结束且早于保留窗口的轮/);
|
|
737
|
-
assert.match(text, /保留窗口:最近 6 个已完成轮、至少 0 tokens(本次保留 6 轮)/);
|
|
738
|
-
|
|
739
|
-
const pointerEvent = session.events[result.summarySeq + 1];
|
|
740
|
-
assert.equal(pointerEvent.type, 'user/message');
|
|
741
|
-
assert.deepEqual(pointerEvent.surfaceOp, { op: 'replace', startSeq: result.shadowedRange.start, endSeq: result.shadowedRange.end });
|
|
742
|
-
assert.equal(pointerEvent.data.source.kind, 'plugin');
|
|
743
|
-
assert.equal(pointerEvent.data.source.plugin, 'compact');
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
// ---------------------------------------------------------------------------
|
|
747
|
-
// Host contract pins (0.2.2-rc.1)
|
|
748
|
-
// ---------------------------------------------------------------------------
|
|
749
|
-
|
|
750
|
-
test('H26 host Session has no `events`: selection and commit use snapshotEvents()', async () => {
|
|
751
|
-
const session = buildSession(9, { openLast: true, hideEvents: true });
|
|
752
|
-
assert.equal(session.events, undefined, 'the mock hides the legacy array exactly like the host');
|
|
753
|
-
const { engine } = engineFor({ archiveDir: caseDir('archive') });
|
|
754
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
755
|
-
assert.ok(result, 'the engine must still plan and commit through snapshotEvents()');
|
|
756
|
-
assert.equal(pointerEvents(session).length, 1);
|
|
757
|
-
assert.equal(session.surface.nodes[0], result.summarySeq + 1, 'pointer landed on the surface');
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
test('H27 surfaceOp is exactly the host shape {op, startSeq, endSeq}', async () => {
|
|
761
|
-
const session = buildSession(9, { openLast: false });
|
|
762
|
-
const { engine } = engineFor({ archiveDir: caseDir('archive') });
|
|
763
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
764
|
-
assert.ok(result);
|
|
765
|
-
const pointer = session.events[result.summarySeq + 1];
|
|
766
|
-
assert.deepEqual(Object.keys(pointer.surfaceOp).sort(), ['endSeq', 'op', 'startSeq']);
|
|
767
|
-
assert.equal(pointer.surfaceOp.op, 'replace');
|
|
768
|
-
assert.equal(pointer.surfaceOp.startSeq, result.shadowedRange.start);
|
|
769
|
-
assert.equal(pointer.surfaceOp.endSeq, result.shadowedRange.end);
|
|
770
|
-
assert.equal(Object.hasOwn(pointer.surfaceOp, 'start'), false);
|
|
771
|
-
assert.equal(Object.hasOwn(pointer.surfaceOp, 'end'), false);
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
test('H28 a system prompt at surface 0 is never cut; the pointer lands at position 1', async () => {
|
|
775
|
-
const session = new MockSession();
|
|
776
|
-
addTurnWithSystemHead(session, 1);
|
|
777
|
-
for (let turn = 2; turn <= 9; turn += 1) addTurn(session, turn, { open: turn === 9 });
|
|
778
|
-
const systemEvent = session._events.find((event) => event.type === 'system/message');
|
|
779
|
-
assert.ok(systemEvent);
|
|
780
|
-
const firstUser = session._events.find((event) => event.type === 'user/message');
|
|
781
|
-
const dir = caseDir('archive');
|
|
782
|
-
const { engine } = engineFor({ archiveDir: dir });
|
|
783
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
784
|
-
assert.ok(result);
|
|
785
|
-
assert.equal(result.shadowedSeqs.includes(systemEvent.seq), false, 'system seq is outside the cut span');
|
|
786
|
-
assert.equal(session.surface.nodes[0], systemEvent.seq, 'system stays at surface position 0');
|
|
787
|
-
assert.equal(session.surface.nodes[1], result.summarySeq + 1, 'pointer is the second surface node');
|
|
788
|
-
const pointer = session._events[result.summarySeq + 1];
|
|
789
|
-
assert.equal(pointer.surfaceOp.startSeq, firstUser.seq, 'cut starts at the first user message');
|
|
790
|
-
assert.equal(pointer.sourceEventSeqs.includes(systemEvent.seq), false, 'system is not cited as shadowed');
|
|
791
|
-
const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
|
|
792
|
-
const parsed = readArchive(path.join(dir, file));
|
|
793
|
-
assert.equal(parsed.header.cutTurns[0].firstSeq, firstUser.seq, 'turn 1 is cut from its first non-system node');
|
|
794
|
-
assert.equal(parsed.header.cutTurns[0].startSeq, 0, 'the archive still covers the whole turn');
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
test('H29 a mid-span host-injected system message is archived with the old prefix', async () => {
|
|
798
|
-
// 1.6.6: the head system prompt is still never cut (H28). A host-injected
|
|
799
|
-
// system/message in the middle of the surface is a duplicate re-emitted by a
|
|
800
|
-
// restart; the host tolerates it inside a replacement range, and keeping it
|
|
801
|
-
// outside pinned every following turn forever. It is archived with the old
|
|
802
|
-
// prefix instead of stopping the cut before its turn.
|
|
803
|
-
const session = new MockSession();
|
|
804
|
-
addTurn(session, 1);
|
|
805
|
-
addTurnWithSystemInside(session, 2);
|
|
806
|
-
for (let turn = 3; turn <= 9; turn += 1) addTurn(session, turn, { open: turn === 9 });
|
|
807
|
-
const systems = session._events.filter((event) => event.type === 'system/message');
|
|
808
|
-
assert.equal(systems.length, 1);
|
|
809
|
-
const dir = caseDir('archive');
|
|
810
|
-
const { engine } = engineFor({ archiveDir: dir });
|
|
811
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
812
|
-
assert.ok(result, 'the old prefix is cuttable');
|
|
813
|
-
assert.equal(result.shadowedSeqs.includes(systems[0].seq), true, 'the injected copy is archived with the prefix');
|
|
814
|
-
const file = fs.readdirSync(dir).find((name) => name.endsWith('.jsonl'));
|
|
815
|
-
const parsed = readArchive(path.join(dir, file));
|
|
816
|
-
assert.deepEqual(parsed.header.cutTurns.map((entry) => entry.turn), [1, 2], 'turn 1 and the injected turn 2 are cut');
|
|
817
|
-
assert.equal(session.surface.nodes.includes(systems[0].seq), false, 'the injected copy left the surface with its turn');
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
test('H6 successive cuts update the cumulative archived count and fold the old pointer', async () => {
|
|
821
|
-
const session = buildSession(14, { openLast: false });
|
|
822
|
-
const { engine } = engineFor({ archiveDir: caseDir('archive') });
|
|
823
|
-
const first = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
824
|
-
assert.ok(first);
|
|
825
|
-
assert.equal(first.shadowedSeqs.length, 16, 'turns 1-8');
|
|
826
|
-
const firstText = first.summary[0].text;
|
|
827
|
-
assert.match(firstText, /本会话共 14 轮,其中 8 轮已压缩归档/);
|
|
828
|
-
|
|
829
|
-
// Three more completed turns and a fresh open turn: the next cut starts at
|
|
830
|
-
// the old pointer (position 0) and includes turns 9-11.
|
|
831
|
-
for (let turn = 15; turn <= 17; turn += 1) addTurn(session, turn, { completed: true });
|
|
832
|
-
addTurn(session, 18, { open: true });
|
|
833
|
-
const second = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
834
|
-
assert.ok(second);
|
|
835
|
-
assert.equal(second.shadowedSeqs[0], first.summary[0] === undefined ? -1 : session.events[first.summarySeq + 1].seq, 'old pointer is inside the new span');
|
|
836
|
-
assert.equal(second.shadowedSeqs.length, 1 + 6, 'old pointer + turns 9-11');
|
|
837
|
-
assert.match(second.summary[0].text, /本会话共 18 轮,其中 11 轮已压缩归档/);
|
|
838
|
-
assert.equal(pointerEvents(session).length, 2);
|
|
839
|
-
});
|
|
840
|
-
|
|
841
|
-
test('H6b cumulative M is carried on the pointer, not re-derived from summary seqs', async () => {
|
|
842
|
-
const session = buildSession(9, { openLast: true }); // 8 completed + open turn 9
|
|
843
|
-
const { engine } = engineFor({ archiveDir: caseDir('archive') });
|
|
844
|
-
const agent = makeAgent(session);
|
|
845
|
-
|
|
846
|
-
// First cut happens while turn 9 is open: the pointer's seq belongs to turn 9.
|
|
847
|
-
const first = await engine.compactIfNeeded(agent, 'pressure', new AbortController().signal);
|
|
848
|
-
assert.ok(first);
|
|
849
|
-
assert.match(first.summary[0].text, /其中 2 轮已压缩归档/);
|
|
850
|
-
session.append('turn/end', { turn: 9, reason: { kind: 'completed' } });
|
|
851
|
-
for (let turn = 10; turn <= 14; turn += 1) addTurn(session, turn, { completed: true });
|
|
852
|
-
addTurn(session, 15, { open: true });
|
|
853
|
-
|
|
854
|
-
// Second cut folds pointer #1 into its span while keeping turn 9 on the
|
|
855
|
-
// surface. A seq-based archived count would now wrongly count turn 9.
|
|
856
|
-
const second = await engine.compactIfNeeded(agent, 'pressure', new AbortController().signal);
|
|
857
|
-
assert.ok(second);
|
|
858
|
-
assert.match(second.summary[0].text, /其中 8 轮已压缩归档/);
|
|
859
|
-
|
|
860
|
-
// Third cut folds pointer #2 in; M must still be the number of really
|
|
861
|
-
// archived turns (8 + 4), never 8 + turn 9 + 4.
|
|
862
|
-
session.append('turn/end', { turn: 15, reason: { kind: 'completed' } });
|
|
863
|
-
for (let turn = 16; turn <= 18; turn += 1) addTurn(session, turn, { completed: true });
|
|
864
|
-
addTurn(session, 19, { open: true });
|
|
865
|
-
const third = await engine.compactIfNeeded(agent, 'pressure', new AbortController().signal);
|
|
866
|
-
assert.ok(third);
|
|
867
|
-
assert.match(third.summary[0].text, /本会话共 19 轮,其中 12 轮已压缩归档/);
|
|
868
|
-
const pointer = session.events[third.summarySeq + 1];
|
|
869
|
-
assert.equal(pointer.data.source.hotTruncate.archivedTurns, 12);
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
// ---------------------------------------------------------------------------
|
|
873
|
-
// Failure safety
|
|
874
|
-
// ---------------------------------------------------------------------------
|
|
875
|
-
|
|
876
|
-
test('H7 a failed archive write leaves the session byte-identical (auto) ', async () => {
|
|
877
|
-
const session = buildSession(9, { openLast: true });
|
|
878
|
-
const before = session.events.length;
|
|
879
|
-
const surfaceBefore = [...session.surface.nodes];
|
|
880
|
-
const { engine } = engineFor({
|
|
881
|
-
archiveDir: caseDir('archive'),
|
|
882
|
-
engineDeps: {
|
|
883
|
-
archiveWriter: () => {
|
|
884
|
-
throw new Error('disk full');
|
|
885
|
-
},
|
|
886
|
-
},
|
|
887
|
-
});
|
|
888
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
889
|
-
assert.equal(result, null);
|
|
890
|
-
assert.equal(session.events.length, before, 'no compaction events appended');
|
|
891
|
-
assert.deepEqual(session.surface.nodes, surfaceBefore);
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
test('H8 a failed manual cut reports ManualCompactionError and still leaves no partial log', async () => {
|
|
895
|
-
const session = buildSession(9, { openLast: false });
|
|
896
|
-
const before = session.events.length;
|
|
897
|
-
const { engine } = engineFor({
|
|
898
|
-
archiveDir: caseDir('archive'),
|
|
899
|
-
engineDeps: {
|
|
900
|
-
archiveWriter: () => {
|
|
901
|
-
throw new Error('permission denied');
|
|
902
|
-
},
|
|
903
|
-
},
|
|
904
|
-
});
|
|
905
|
-
await assert.rejects(
|
|
906
|
-
engine.compactNow(makeAgent(session), new AbortController().signal),
|
|
907
|
-
(error) => error instanceof MockManualCompactionError && error.code === 'summary',
|
|
908
|
-
);
|
|
909
|
-
assert.equal(session.events.length, before);
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
test('H9 an unbalanced host boundary blocks the cut before any write', async () => {
|
|
913
|
-
const session = buildSession(9, { openLast: true });
|
|
914
|
-
const before = session.events.length;
|
|
915
|
-
const { engine } = engineFor({
|
|
916
|
-
archiveDir: caseDir('archive'),
|
|
917
|
-
engineDeps: { toolPairingBalancedAfter: () => false },
|
|
918
|
-
});
|
|
919
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'pressure', new AbortController().signal);
|
|
920
|
-
assert.equal(result, null);
|
|
921
|
-
assert.equal(session.events.length, before);
|
|
922
|
-
assert.equal(pointerEvents(session).length, 0);
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
// ---------------------------------------------------------------------------
|
|
926
|
-
// Idempotency
|
|
927
|
-
// ---------------------------------------------------------------------------
|
|
928
|
-
|
|
929
|
-
test('H10 three consecutive manual cuts: one cut, then two honest nulls', async () => {
|
|
930
|
-
const dir = caseDir('archive');
|
|
931
|
-
const session = buildSession(7, { openLast: false });
|
|
932
|
-
const { engine } = engineFor({ archiveDir: dir });
|
|
933
|
-
const agent = makeAgent(session);
|
|
934
|
-
|
|
935
|
-
const first = await engine.compactNow(agent, new AbortController().signal);
|
|
936
|
-
assert.ok(first);
|
|
937
|
-
const afterFirstEvents = session.events.length;
|
|
938
|
-
const afterFirstSurface = [...session.surface.nodes];
|
|
939
|
-
const second = await engine.compactNow(agent, new AbortController().signal);
|
|
940
|
-
const third = await engine.compactNow(agent, new AbortController().signal);
|
|
941
|
-
assert.equal(second, null);
|
|
942
|
-
assert.equal(third, null);
|
|
943
|
-
assert.equal(session.events.length, afterFirstEvents, 'no-op cuts append nothing');
|
|
944
|
-
assert.deepEqual(session.surface.nodes, afterFirstSurface);
|
|
945
|
-
assert.equal(pointerEvents(session).length, 1);
|
|
946
|
-
assert.equal(fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')).length, 1);
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
test('H11 automatic compaction refuses to run between turns', async () => {
|
|
950
|
-
const session = buildSession(9, { openLast: false });
|
|
951
|
-
const { engine } = engineFor({ archiveDir: caseDir('archive') });
|
|
952
|
-
const result = await engine.compactIfNeeded(makeAgent(session), 'context-overflow', new AbortController().signal);
|
|
953
|
-
assert.equal(result, null);
|
|
954
|
-
assert.equal(pointerEvents(session).length, 0);
|
|
955
|
-
// The same session is still cuttable manually (idle, standalone transaction).
|
|
956
|
-
const manual = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
957
|
-
assert.ok(manual);
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
// ---------------------------------------------------------------------------
|
|
961
|
-
// Entry fallback / coexistence
|
|
962
|
-
// ---------------------------------------------------------------------------
|
|
963
|
-
|
|
964
|
-
const HOST_DIR = path.join(TMP, 'host-stubs');
|
|
965
|
-
fs.mkdirSync(HOST_DIR, { recursive: true });
|
|
966
|
-
|
|
967
|
-
const fixtureCompaction = path.join(HOST_DIR, 'compaction.mjs');
|
|
968
|
-
fs.writeFileSync(fixtureCompaction, `
|
|
969
|
-
export class CompactionEngine {
|
|
970
|
-
constructor(ctx) {
|
|
971
|
-
if (process.env.DSH_HT_BASE_FAIL === '1') throw new Error('fixture base refused');
|
|
972
|
-
this.ctx = ctx;
|
|
973
|
-
this.name = 'compaction';
|
|
974
|
-
ctx.reflect.store[Symbol.for('fixture-compaction')] = this;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
export const CompactionId = (id) => id;
|
|
978
|
-
export class ManualCompactionError extends Error {
|
|
979
|
-
constructor(code, message, options) { super(message, options); this.name = 'ManualCompactionError'; this.code = code; }
|
|
980
|
-
}
|
|
981
|
-
export const compactCheckpointSource = (compactionId, sourceCommandId) => ({
|
|
982
|
-
kind: 'plugin', plugin: 'compact', compactionId, ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
|
|
983
|
-
});
|
|
984
|
-
export const isCompactCheckpointSource = (source) => source?.kind === 'plugin' && source?.plugin === 'compact';
|
|
985
|
-
export const toolPairingBalancedBefore = () => true;
|
|
986
|
-
export const toolPairingBalancedAfter = () => true;
|
|
987
|
-
`, 'utf8');
|
|
988
|
-
|
|
989
|
-
const fixtureLlm = path.join(HOST_DIR, 'llm.mjs');
|
|
990
|
-
fs.writeFileSync(fixtureLlm, `
|
|
991
|
-
export const createUserMessage = (input) => ({ id: 'fixture-user', role: 'user', content: input.content, source: input.source });
|
|
992
|
-
`, 'utf8');
|
|
993
|
-
|
|
994
|
-
const fixtureStock = path.join(HOST_DIR, 'stock.mjs');
|
|
995
|
-
fs.writeFileSync(fixtureStock, `
|
|
996
|
-
export class BasicCompactionEngine {
|
|
997
|
-
constructor(ctx, config) {
|
|
998
|
-
if (process.env.DSH_HT_STOCK_FAIL === '1') throw new Error('fixture stock refused');
|
|
999
|
-
this.ctx = ctx;
|
|
1000
|
-
this.name = 'compaction';
|
|
1001
|
-
this.kind = 'stock';
|
|
1002
|
-
this.config = config;
|
|
1003
|
-
ctx.reflect.store.compaction = this;
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
`, 'utf8');
|
|
1007
|
-
|
|
1008
|
-
process.env.DSH_HOT_TRUNCATE_HOST_COMPACTION = pathToFileURL(fixtureCompaction).href;
|
|
1009
|
-
process.env.DSH_HOT_TRUNCATE_HOST_LLM = pathToFileURL(fixtureLlm).href;
|
|
1010
|
-
process.env.DSH_HOT_TRUNCATE_HOST_STOCK = pathToFileURL(fixtureStock).href;
|
|
1011
|
-
process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT = '0';
|
|
1012
|
-
|
|
1013
|
-
const ENTRY_URL = pathToFileURL(path.join(ROOT, 'src', 'compaction', 'entry.js')).href;
|
|
1014
|
-
let entryCase = 0;
|
|
1015
|
-
async function loadEntry() {
|
|
1016
|
-
entryCase += 1;
|
|
1017
|
-
return import(`${ENTRY_URL}?case=${entryCase}`);
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
function freshCtx() {
|
|
1021
|
-
return new MockCtx(makeMeter(500));
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
test('H12 entry: an engine constructor throw releases ctx.compaction and falls back to stock', async () => {
|
|
1025
|
-
process.env.DSH_HT_BASE_FAIL = '1';
|
|
1026
|
-
try {
|
|
1027
|
-
const { default: Entry } = await loadEntry();
|
|
1028
|
-
const ctx = freshCtx();
|
|
1029
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'cut', retainTurns: 6, retainTokens: 0 } });
|
|
1030
|
-
assert.equal(instance.kind, 'stock');
|
|
1031
|
-
assert.equal(ctx.store.compaction, instance);
|
|
1032
|
-
assert.match(ctx.text('warn'), /回退 stock 压缩/);
|
|
1033
|
-
} finally {
|
|
1034
|
-
delete process.env.DSH_HT_BASE_FAIL;
|
|
1035
|
-
}
|
|
1036
|
-
});
|
|
1037
|
-
|
|
1038
|
-
test('H13 entry: an unloadable engine also falls back to stock', async () => {
|
|
1039
|
-
process.env.DSH_HOT_TRUNCATE_ENGINE_FAIL = 'import';
|
|
1040
|
-
try {
|
|
1041
|
-
const { default: Entry } = await loadEntry();
|
|
1042
|
-
const ctx = freshCtx();
|
|
1043
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'cut' } });
|
|
1044
|
-
assert.equal(instance.kind, 'stock');
|
|
1045
|
-
assert.match(ctx.text('warn'), /回退 stock 压缩/);
|
|
1046
|
-
} finally {
|
|
1047
|
-
delete process.env.DSH_HOT_TRUNCATE_ENGINE_FAIL;
|
|
1048
|
-
}
|
|
1049
|
-
});
|
|
1050
|
-
|
|
1051
|
-
test('H14 entry: mode=off hands the session back to stock', async () => {
|
|
1052
|
-
const { default: Entry } = await loadEntry();
|
|
1053
|
-
const ctx = freshCtx();
|
|
1054
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'off' } });
|
|
1055
|
-
assert.equal(instance.kind, 'stock');
|
|
1056
|
-
assert.equal(instance.config.auto, true, 'stock automatic compaction stays on');
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
test('H15 entry: live-compact wins by default (gate service probe)', async () => {
|
|
1060
|
-
const previous = process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1061
|
-
delete process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1062
|
-
try {
|
|
1063
|
-
const { default: Entry } = await loadEntry();
|
|
1064
|
-
const ctx = freshCtx();
|
|
1065
|
-
ctx.services.liveCompactPresetGate = {};
|
|
1066
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'cut' } });
|
|
1067
|
-
assert.equal(ctx.store.compaction, undefined, 'nothing took the compaction slot');
|
|
1068
|
-
assert.notEqual(instance?.kind, 'stock');
|
|
1069
|
-
assert.match(ctx.text(), /检测到 live-compact/);
|
|
1070
|
-
assert.match(ctx.text(), /热截断让位/);
|
|
1071
|
-
} finally {
|
|
1072
|
-
if (previous === undefined) delete process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1073
|
-
else process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT = previous;
|
|
1074
|
-
}
|
|
1075
|
-
});
|
|
1076
|
-
|
|
1077
|
-
test('H16 entry: a profile mentioning compaction-spine is detected even when it is not loaded yet', async () => {
|
|
1078
|
-
const home = caseDir('fake-home');
|
|
1079
|
-
fs.mkdirSync(path.join(home, 'profiles', 'web'), { recursive: true });
|
|
1080
|
-
fs.writeFileSync(path.join(home, 'profiles', 'web', 'cordis.patch.yml'), '- id: compaction-spine\n', 'utf8');
|
|
1081
|
-
const previousHome = process.env.DSH_HOME;
|
|
1082
|
-
const previousOverride = process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1083
|
-
process.env.DSH_HOME = home;
|
|
1084
|
-
delete process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1085
|
-
try {
|
|
1086
|
-
const { default: Entry } = await loadEntry();
|
|
1087
|
-
const ctx = freshCtx();
|
|
1088
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'cut' } });
|
|
1089
|
-
assert.equal(ctx.store.compaction, undefined);
|
|
1090
|
-
assert.notEqual(instance?.kind, 'stock');
|
|
1091
|
-
assert.match(ctx.text(), /检测到 live-compact/);
|
|
1092
|
-
} finally {
|
|
1093
|
-
if (previousHome === undefined) delete process.env.DSH_HOME;
|
|
1094
|
-
else process.env.DSH_HOME = previousHome;
|
|
1095
|
-
if (previousOverride === undefined) process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT = '0';
|
|
1096
|
-
else process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT = previousOverride;
|
|
1097
|
-
}
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
test('H17 entry: stock also failing degrades to a no-op plugin, never a throw', async () => {
|
|
1101
|
-
process.env.DSH_HT_BASE_FAIL = '1';
|
|
1102
|
-
process.env.DSH_HT_STOCK_FAIL = '1';
|
|
1103
|
-
try {
|
|
1104
|
-
const { default: Entry } = await loadEntry();
|
|
1105
|
-
const ctx = freshCtx();
|
|
1106
|
-
assert.doesNotThrow(() => {
|
|
1107
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'cut' } });
|
|
1108
|
-
assert.equal(ctx.store.compaction, undefined);
|
|
1109
|
-
assert.ok(instance);
|
|
1110
|
-
});
|
|
1111
|
-
assert.match(ctx.text('warn'), /不接管压缩/);
|
|
1112
|
-
} finally {
|
|
1113
|
-
delete process.env.DSH_HT_BASE_FAIL;
|
|
1114
|
-
delete process.env.DSH_HT_STOCK_FAIL;
|
|
1115
|
-
}
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
// ---------------------------------------------------------------------------
|
|
1119
|
-
// Pure helpers
|
|
1120
|
-
// ---------------------------------------------------------------------------
|
|
1121
|
-
|
|
1122
|
-
test('H18 config normalization rejects impossible windows and keeps the documented defaults', () => {
|
|
1123
|
-
assert.equal(normalizeOptions({}).retainTurns, 4);
|
|
1124
|
-
assert.equal(normalizeOptions({}).retainTokens, 8000);
|
|
1125
|
-
assert.equal(normalizeOptions({}).autoMinTokens, 300000);
|
|
1126
|
-
assert.equal(normalizeOptions({}).mode, 'cut');
|
|
1127
|
-
assert.equal(normalizeOptions({ hotTruncate: { mode: 'auto' } }).mode, 'auto');
|
|
1128
|
-
assert.throws(() => normalizeOptions({ hotTruncate: { mode: 'sometimes' } }), /mode must be one of/);
|
|
1129
|
-
assert.throws(() => normalizeOptions({ hotTruncate: { retainTurns: -1 } }), /retainTurns/);
|
|
1130
|
-
assert.throws(() => normalizeOptions({ hotTruncate: { retainTokens: 1.5 } }), /retainTokens/);
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
test('H19 pointer text is stable and contains the three required facts', () => {
|
|
1134
|
-
const text = buildPointerText({
|
|
1135
|
-
totalTurns: 12, archivedTurns: 4, firstTurn: 1, lastTurn: 2, cutTurnCount: 2,
|
|
1136
|
-
shadowedTokenCount: 900, surfaceEventCount: 4, eventCount: 10,
|
|
1137
|
-
archivePath: '/memory/.longlive/hot-truncate/x.jsonl', sha256: 'a'.repeat(64),
|
|
1138
|
-
retainTurns: 6, retainTokens: 8000, retainedTurns: 8,
|
|
1139
|
-
});
|
|
1140
|
-
assert.match(text, /本会话共 12 轮,其中 4 轮已压缩归档/);
|
|
1141
|
-
assert.match(text, /session_original\(turn=…\)/);
|
|
1142
|
-
assert.match(text, /\/memory\/\.longlive\/hot-truncate\/x\.jsonl/);
|
|
1143
|
-
assert.equal(text, buildPointerText({
|
|
1144
|
-
totalTurns: 12, archivedTurns: 4, firstTurn: 1, lastTurn: 2, cutTurnCount: 2,
|
|
1145
|
-
shadowedTokenCount: 900, surfaceEventCount: 4, eventCount: 10,
|
|
1146
|
-
archivePath: '/memory/.longlive/hot-truncate/x.jsonl', sha256: 'a'.repeat(64),
|
|
1147
|
-
retainTurns: 6, retainTokens: 8000, retainedTurns: 8,
|
|
1148
|
-
}));
|
|
1149
|
-
});
|
|
1150
|
-
|
|
1151
|
-
test('H25 overflow recovery retries only after the surface actually changed', async () => {
|
|
1152
|
-
const session = buildSession(9, { openLast: true });
|
|
1153
|
-
const { engine, ctx } = engineFor({ archiveDir: caseDir('archive') });
|
|
1154
|
-
const handler = ctx.handlers.get('agent/request-error')?.[0];
|
|
1155
|
-
assert.ok(handler, 'request-error recovery handler registered');
|
|
1156
|
-
const next = () => 'next';
|
|
1157
|
-
const agent = makeAgent(session);
|
|
1158
|
-
const signal = new AbortController().signal;
|
|
1159
|
-
const first = await handler({ agent, failure: { code: 'CONTEXT_WINDOW_EXCEEDED' }, signal }, next);
|
|
1160
|
-
assert.deepEqual(first, { kind: 'retry' });
|
|
1161
|
-
assert.equal(pointerEvents(session).length, 1);
|
|
1162
|
-
// Nothing left beyond the retention window: no surface change, preserve the
|
|
1163
|
-
// host's original error by falling through.
|
|
1164
|
-
const second = await handler({ agent, failure: { code: 'CONTEXT_WINDOW_EXCEEDED' }, signal }, next);
|
|
1165
|
-
assert.equal(second, 'next');
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
test('H24 mode=auto registers an idle trigger and only cuts above autoMinTokens', async () => {
|
|
1169
|
-
const session = buildSession(9, { openLast: false });
|
|
1170
|
-
const { engine, ctx } = engineFor({ mode: 'auto', autoMinTokens: 0, archiveDir: caseDir('archive') });
|
|
1171
|
-
assert.ok((ctx.handlers.get('agent/status')?.length ?? 0) >= 1, 'idle handler registered');
|
|
1172
|
-
await engine.autoCut(makeAgent(session));
|
|
1173
|
-
assert.equal(pointerEvents(session).length, 1);
|
|
1174
|
-
|
|
1175
|
-
const below = buildSession(9, { openLast: false });
|
|
1176
|
-
const second = engineFor({ mode: 'auto', autoMinTokens: 10_000_000, archiveDir: caseDir('archive') }).engine;
|
|
1177
|
-
await second.autoCut(makeAgent(below));
|
|
1178
|
-
assert.equal(pointerEvents(below).length, 0, 'below autoMinTokens the idle path is a no-op');
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
test('H21 the archive directory follows memoryRoot when archiveDir is unset', async () => {
|
|
1182
|
-
const root = caseDir('memory-root');
|
|
1183
|
-
const session = buildSession(9, { openLast: false });
|
|
1184
|
-
const { engine } = engineFor({ archiveDir: '', memoryRoot: root });
|
|
1185
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
1186
|
-
assert.ok(result);
|
|
1187
|
-
const dir = path.join(root, '.longlive', 'hot-truncate');
|
|
1188
|
-
const files = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
|
|
1189
|
-
assert.equal(files.length, 1);
|
|
1190
|
-
assert.ok(result.summary[0].text.includes(dir), 'pointer names the memory-root archive');
|
|
1191
|
-
});
|
|
1192
|
-
|
|
1193
|
-
test('H22 entry: an existing compaction registration is never stolen', async () => {
|
|
1194
|
-
const previous = process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1195
|
-
delete process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1196
|
-
try {
|
|
1197
|
-
const { default: Entry } = await loadEntry();
|
|
1198
|
-
const ctx = freshCtx();
|
|
1199
|
-
const existing = { name: 'compaction', kind: 'someone-else' };
|
|
1200
|
-
ctx.services.compaction = existing;
|
|
1201
|
-
const instance = new Entry(ctx, { hotTruncate: { mode: 'cut' } });
|
|
1202
|
-
assert.equal(ctx.services.compaction, existing);
|
|
1203
|
-
assert.notEqual(instance?.kind, 'stock');
|
|
1204
|
-
assert.match(ctx.text(), /已有压缩引擎/);
|
|
1205
|
-
} finally {
|
|
1206
|
-
if (previous === undefined) delete process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT;
|
|
1207
|
-
else process.env.DSH_HOT_TRUNCATE_LIVE_COMPACT = previous;
|
|
1208
|
-
}
|
|
1209
|
-
});
|
|
1210
|
-
|
|
1211
|
-
test('H23 longlive foldSurface resolves replacement spans positionally (inverted seqs)', () => {
|
|
1212
|
-
// A replace inserts its node at the first node's POSITION, so after one
|
|
1213
|
-
// replacement the next span can name start > end numerically. The old
|
|
1214
|
-
// numeric-range fold would leave the first pointer visible.
|
|
1215
|
-
const events = [
|
|
1216
|
-
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
1217
|
-
{ seq: 1, type: 'user/message', surfaceOp: 'append', data: {} },
|
|
1218
|
-
{ seq: 2, type: 'assistant/message', surfaceOp: 'append', data: {} },
|
|
1219
|
-
{ seq: 3, type: 'turn/end', data: { turn: 1 } },
|
|
1220
|
-
{ seq: 4, type: 'user/message', surfaceOp: { op: 'replace', start: 1, end: 2 }, data: {} },
|
|
1221
|
-
{ seq: 5, type: 'user/message', surfaceOp: 'append', data: {} },
|
|
1222
|
-
{ seq: 6, type: 'user/message', surfaceOp: { op: 'replace', start: 4, end: 5 }, data: {} },
|
|
1223
|
-
];
|
|
1224
|
-
assert.deepEqual(foldSurface(events).map((node) => node.seq), [6]);
|
|
1225
|
-
});
|
|
1226
|
-
|
|
1227
|
-
test('H20 direct writeArchive is atomic and leaves no tmp file', () => {
|
|
1228
|
-
const dir = caseDir('archive-direct');
|
|
1229
|
-
const session = buildSession(3, { openLast: false });
|
|
1230
|
-
const table = {
|
|
1231
|
-
events: session.events,
|
|
1232
|
-
bySeq: new Map(session.events.map((event) => [event.seq, event])),
|
|
1233
|
-
turnOfSeq: new Map(session.events.map((event) => [event.seq, 1])),
|
|
1234
|
-
};
|
|
1235
|
-
const plan = {
|
|
1236
|
-
archiveEvents: session.events.slice(0, 4),
|
|
1237
|
-
shadowedSeqs: [1, 2],
|
|
1238
|
-
cutTurns: [{ turn: 1, firstSeq: 1, lastSeq: 2, startSeq: 0, endSeq: 3, surfaceEvents: 2, tokens: 10 }],
|
|
1239
|
-
};
|
|
1240
|
-
const result = writeArchive({ dir, table, plan, compactionId: 'abc12345-0000', sessionId: 's-1', now: Date.UTC(2026, 10, 6, 1, 2, 3, 4) });
|
|
1241
|
-
assert.ok(fs.existsSync(result.path));
|
|
1242
|
-
assert.equal(path.dirname(result.path), dir);
|
|
1243
|
-
assert.equal(fs.readdirSync(dir).filter((name) => name.includes('.tmp-')).length, 0);
|
|
1244
|
-
const parsed = readArchive(result.path);
|
|
1245
|
-
assert.equal(parsed.header.sessionId, 's-1');
|
|
1246
|
-
assert.equal(parsed.records.length, 4);
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
// ---------------------------------------------------------------------------
|
|
1250
|
-
// 1.6.4 frequency defaults: 300k trigger / keep 4 turns
|
|
1251
|
-
// ---------------------------------------------------------------------------
|
|
1252
|
-
|
|
1253
|
-
test('H30 default autoMinTokens: 299k does not cut, 300k does', async () => {
|
|
1254
|
-
// 20 completed turns x 2 surface nodes = 40 priced nodes.
|
|
1255
|
-
const below = buildSession(20, { openLast: false });
|
|
1256
|
-
const under = engineFor({ useDefaults: true, mode: 'auto', perEvent: 7475, archiveDir: caseDir('archive') });
|
|
1257
|
-
await under.engine.autoCut(makeAgent(below));
|
|
1258
|
-
assert.equal(pointerEvents(below).length, 0, '299000 < 300000: the idle gate is a no-op');
|
|
1259
|
-
|
|
1260
|
-
const at = buildSession(20, { openLast: false });
|
|
1261
|
-
const dir = caseDir('archive');
|
|
1262
|
-
const reach = engineFor({ useDefaults: true, mode: 'auto', perEvent: 7500, archiveDir: dir });
|
|
1263
|
-
await reach.engine.autoCut(makeAgent(at));
|
|
1264
|
-
assert.equal(pointerEvents(at).length, 1, '300000 >= 300000: exactly one cut');
|
|
1265
|
-
assert.equal(fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl')).length, 1);
|
|
1266
|
-
});
|
|
1267
|
-
|
|
1268
|
-
test('H34 default pressure gate: 299k does not cut, 300k cuts', async () => {
|
|
1269
|
-
// 20 completed turns x 2 surface nodes, plus an open turn with no surface
|
|
1270
|
-
// nodes so the automatic (pre-step) path is allowed to run.
|
|
1271
|
-
const below = buildSession(20, { openLast: false });
|
|
1272
|
-
addTurn(below, 21, { open: true, surface: false });
|
|
1273
|
-
const under = engineFor({ useDefaults: true, perEvent: 7475, archiveDir: caseDir('archive') });
|
|
1274
|
-
const skipped = await under.engine.compactIfNeeded(makeAgent(below), 'pressure', new AbortController().signal);
|
|
1275
|
-
assert.equal(skipped, null, '299000 < 300000: the per-step path is a no-op');
|
|
1276
|
-
assert.equal(pointerEvents(below).length, 0);
|
|
1277
|
-
|
|
1278
|
-
const at = buildSession(20, { openLast: false });
|
|
1279
|
-
addTurn(at, 21, { open: true, surface: false });
|
|
1280
|
-
const reach = engineFor({ useDefaults: true, perEvent: 7500, archiveDir: caseDir('archive') });
|
|
1281
|
-
const cut = await reach.engine.compactIfNeeded(makeAgent(at), 'pressure', new AbortController().signal);
|
|
1282
|
-
assert.ok(cut, '300000 >= 300000: the per-step path cuts');
|
|
1283
|
-
assert.equal(pointerEvents(at).length, 1);
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
test('H31 default retainTurns=4 keeps exactly the last four completed turns', async () => {
|
|
1287
|
-
const session = buildSession(12, { openLast: false });
|
|
1288
|
-
const { engine } = engineFor({ useDefaults: true, perEvent: 1500, archiveDir: caseDir('archive') });
|
|
1289
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
1290
|
-
assert.ok(result);
|
|
1291
|
-
const pointer = session.events[result.summarySeq + 1];
|
|
1292
|
-
assert.equal(pointer.data.source.hotTruncate.cutTurns.length, 8);
|
|
1293
|
-
assert.equal(pointer.data.source.hotTruncate.retainTurns, 4);
|
|
1294
|
-
assert.equal(session.surface.nodes.length, 1 + 4 * 2, 'pointer + the last four completed turns');
|
|
1295
|
-
assert.match(pointer.data.content[0].text, /本次保留 4 轮/);
|
|
1296
|
-
assert.match(pointer.data.content[0].text, /保留窗口:最近 4 个已完成轮、至少 8000 tokens/);
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
test('H32 one cut writes one archive whose cutTurns spans several turns', async () => {
|
|
1300
|
-
const session = buildSession(12, { openLast: false });
|
|
1301
|
-
const dir = caseDir('archive');
|
|
1302
|
-
const { engine } = engineFor({ useDefaults: true, perEvent: 1500, archiveDir: dir });
|
|
1303
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
1304
|
-
assert.ok(result);
|
|
1305
|
-
assert.equal(pointerEvents(session).length, 1, 'one cut = one pointer');
|
|
1306
|
-
const files = fs.readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
|
|
1307
|
-
assert.equal(files.length, 1, 'one cut = one archive');
|
|
1308
|
-
const pointer = session.events[result.summarySeq + 1];
|
|
1309
|
-
const cutTurns = pointer.data.source.hotTruncate.cutTurns;
|
|
1310
|
-
assert.deepEqual(cutTurns, [1, 2, 3, 4, 5, 6, 7, 8], 'a single cut spans several turns');
|
|
1311
|
-
const parsed = readArchive(path.join(dir, files[0]));
|
|
1312
|
-
assert.equal(parsed.header.cutTurns.length, 8, 'the archive header records every cut turn');
|
|
1313
|
-
assert.equal(parsed.header.eventCount, parsed.records.length);
|
|
1314
|
-
});
|
|
1315
|
-
|
|
1316
|
-
test('H33 explicit legacy retainTurns=6 / autoMinTokens=30000 still win over the defaults', async () => {
|
|
1317
|
-
const explicit = normalizeOptions({ hotTruncate: { retainTurns: 6, autoMinTokens: 30000 } });
|
|
1318
|
-
assert.equal(explicit.retainTurns, 6);
|
|
1319
|
-
assert.equal(explicit.autoMinTokens, 30000);
|
|
1320
|
-
assert.equal(explicit.retainTokens, 8000);
|
|
1321
|
-
assert.equal(explicit.mode, 'cut');
|
|
1322
|
-
|
|
1323
|
-
const session = buildSession(12, { openLast: false });
|
|
1324
|
-
const { engine } = engineFor({
|
|
1325
|
-
retainTurns: 6,
|
|
1326
|
-
autoMinTokens: 30000,
|
|
1327
|
-
perEvent: 1500,
|
|
1328
|
-
archiveDir: caseDir('archive'),
|
|
1329
|
-
});
|
|
1330
|
-
const result = await engine.compactNow(makeAgent(session), new AbortController().signal);
|
|
1331
|
-
assert.ok(result);
|
|
1332
|
-
const pointer = session.events[result.summarySeq + 1];
|
|
1333
|
-
assert.equal(pointer.data.source.hotTruncate.retainTurns, 6);
|
|
1334
|
-
assert.match(pointer.data.content[0].text, /本次保留 6 轮/);
|
|
1335
|
-
assert.equal(session.surface.nodes.length, 1 + 6 * 2, 'explicit retainTurns=6 keeps six turns');
|
|
1336
|
-
|
|
1337
|
-
// The explicit 30k gate also re-opens automatic cutting for a 30k session
|
|
1338
|
-
// that the new 300k default deliberately ignores.
|
|
1339
|
-
const small = buildSession(20, { openLast: false });
|
|
1340
|
-
const auto = engineFor({
|
|
1341
|
-
mode: 'auto',
|
|
1342
|
-
retainTurns: 6,
|
|
1343
|
-
autoMinTokens: 30000,
|
|
1344
|
-
perEvent: 750,
|
|
1345
|
-
archiveDir: caseDir('archive'),
|
|
1346
|
-
});
|
|
1347
|
-
await auto.engine.autoCut(makeAgent(small));
|
|
1348
|
-
assert.equal(pointerEvents(small).length, 1, 'explicit autoMinTokens=30000 cuts a 30k session');
|
|
1349
|
-
});
|