dsh-memu-memory 1.1.3 → 1.2.1
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/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-cons-ledger-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const ledger = await import('../lib/consolidation-ledger.js')
|
|
11
|
+
const receipt = await import('../lib/consolidation-receipt.js')
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
let counter = 0
|
|
18
|
+
function binding(patch = {}) {
|
|
19
|
+
counter += 1
|
|
20
|
+
return {
|
|
21
|
+
scopeKey: 'd:/proj',
|
|
22
|
+
libraryId: 'lib_test',
|
|
23
|
+
domain: 'rp',
|
|
24
|
+
useLibrary: true,
|
|
25
|
+
useGlobal: false,
|
|
26
|
+
memoryOwnerSessionId: 'owner-' + counter,
|
|
27
|
+
memoryContext: { channelId: 'ch-' + counter },
|
|
28
|
+
...patch,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function snapshot(through = 20, from = 0) {
|
|
33
|
+
return { hash: 'hash-' + through + '-' + from, fromSeq: from, throughSeq: through, eventCount: through - from + 1, source: 'lease-live' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// C 包第 5 条:checked/pending/inflight 三个边界分开记,一个失败不能把三个一起推错。
|
|
37
|
+
|
|
38
|
+
test('a job holds a lease and a second job is refused while it is live', () => {
|
|
39
|
+
const sessionId = 'led-lease'
|
|
40
|
+
const first = ledger.beginJob({ sessionId, jobId: 'job-1', snapshot: snapshot(20), binding: binding() })
|
|
41
|
+
assert.equal(first.ok, true)
|
|
42
|
+
assert.ok(first.job.leaseUntil > Date.now())
|
|
43
|
+
assert.equal(first.job.scopeGeneration.length, 8)
|
|
44
|
+
const second = ledger.beginJob({ sessionId, jobId: 'job-2', snapshot: snapshot(20), binding: binding() })
|
|
45
|
+
assert.equal(second.ok, false)
|
|
46
|
+
assert.equal(second.reasonCode, 'lease-held')
|
|
47
|
+
assert.equal(second.jobId, 'job-1')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('evidence advances the pending boundary only, never the checked one', () => {
|
|
51
|
+
const sessionId = 'led-pending'
|
|
52
|
+
const first = ledger.noteEvidence({ sessionId, fromSeq: 0, throughSeq: 12 })
|
|
53
|
+
assert.equal(first.changed, true)
|
|
54
|
+
assert.equal(first.pending.throughSeq, 12)
|
|
55
|
+
ledger.noteEvidence({ sessionId, fromSeq: 0, throughSeq: 30 })
|
|
56
|
+
const state = ledger.sessionState(sessionId)
|
|
57
|
+
assert.equal(state.pending.throughSeq, 30)
|
|
58
|
+
assert.equal(state.checked, null)
|
|
59
|
+
// 回退不会发生:更小的边界不会把 pending 拉回去。
|
|
60
|
+
ledger.noteEvidence({ sessionId, fromSeq: 0, throughSeq: 10 })
|
|
61
|
+
assert.equal(ledger.sessionState(sessionId).pending.throughSeq, 30)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('a completed job advances checked and clears pending', () => {
|
|
65
|
+
const sessionId = 'led-complete'
|
|
66
|
+
ledger.noteEvidence({ sessionId, throughSeq: 18 })
|
|
67
|
+
ledger.beginJob({ sessionId, jobId: 'job-c', snapshot: snapshot(20), binding: binding() })
|
|
68
|
+
const done = ledger.completeJob({
|
|
69
|
+
sessionId, jobId: 'job-c', snapshot: snapshot(20),
|
|
70
|
+
covered: { fromSeq: 0, throughSeq: 20 },
|
|
71
|
+
status: 'completed', outcome: 'created', counts: { created: 2 },
|
|
72
|
+
receipt: { status: 'completed', outcome: 'created', reasonCode: null },
|
|
73
|
+
})
|
|
74
|
+
assert.equal(done.advanced, true)
|
|
75
|
+
assert.equal(done.checked.throughSeq, 20)
|
|
76
|
+
assert.equal(done.checked.status, 'completed')
|
|
77
|
+
assert.equal(done.checked.snapshotHash, 'hash-20-0')
|
|
78
|
+
assert.equal(done.pending, null, 'the whole pending range was covered')
|
|
79
|
+
const state = ledger.sessionState(sessionId)
|
|
80
|
+
assert.equal(state.inflight, null)
|
|
81
|
+
assert.equal(state.attempts, 0)
|
|
82
|
+
assert.equal(state.history.at(-1).status, 'completed')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a failed job keeps the range pending and backs off', () => {
|
|
86
|
+
const sessionId = 'led-fail'
|
|
87
|
+
ledger.beginJob({ sessionId, jobId: 'job-f', snapshot: snapshot(20), binding: binding() })
|
|
88
|
+
const failed = ledger.failJob({ sessionId, jobId: 'job-f', reasonCode: 'fork-turn-failed', error: 'boom' })
|
|
89
|
+
assert.equal(failed.pending.throughSeq, 20)
|
|
90
|
+
assert.ok(failed.retryAt, 'a retryable failure schedules a retry')
|
|
91
|
+
const state = ledger.sessionState(sessionId)
|
|
92
|
+
assert.equal(state.checked, null, 'a failure never advances the checked cursor')
|
|
93
|
+
assert.equal(state.lastReasonCode, 'fork-turn-failed')
|
|
94
|
+
assert.equal(state.attempts, 1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('only completed and reasoned skipped receipts may advance', () => {
|
|
98
|
+
for (const [status, reasonCode, expected] of [['completed', null, true], ['skipped', 'no-value', true], ['skipped', null, false], ['failed', 'receipt-missing', false], ['deferred', 'open-turn', false]]) {
|
|
99
|
+
const sessionId = 'led-status-' + status + '-' + String(reasonCode)
|
|
100
|
+
ledger.beginJob({ sessionId, jobId: 'job-s', snapshot: snapshot(9), binding: binding() })
|
|
101
|
+
const done = ledger.completeJob({ sessionId, jobId: 'job-s', snapshot: snapshot(9), covered: { fromSeq: 0, throughSeq: 9 }, status, outcome: 'no_value', reasonCode, retryable: true })
|
|
102
|
+
assert.equal(done.advanced, expected, status + '/' + String(reasonCode) + ' advance=' + expected)
|
|
103
|
+
const state = ledger.sessionState(sessionId)
|
|
104
|
+
assert.equal(state.checked === null, !expected)
|
|
105
|
+
if (!expected) assert.equal(state.pending.throughSeq, 9, 'the range stays pending for the next attempt')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a receipt cannot claim more than the snapshot covered', () => {
|
|
110
|
+
const sessionId = 'led-clamp'
|
|
111
|
+
ledger.beginJob({ sessionId, jobId: 'job-k', snapshot: snapshot(20), binding: binding() })
|
|
112
|
+
const done = ledger.completeJob({
|
|
113
|
+
sessionId, jobId: 'job-k', snapshot: snapshot(20),
|
|
114
|
+
covered: { fromSeq: 0, throughSeq: 99 },
|
|
115
|
+
status: 'completed', outcome: 'no_value',
|
|
116
|
+
})
|
|
117
|
+
assert.equal(done.advanced, false, 'out-of-range coverage is rejected, never clamped')
|
|
118
|
+
assert.equal(done.checked, null)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a partial cover keeps the rest pending', () => {
|
|
122
|
+
const sessionId = 'led-partial'
|
|
123
|
+
ledger.noteEvidence({ sessionId, throughSeq: 40 })
|
|
124
|
+
ledger.beginJob({ sessionId, jobId: 'job-p', snapshot: snapshot(40), binding: binding() })
|
|
125
|
+
const done = ledger.completeJob({ sessionId, jobId: 'job-p', snapshot: snapshot(40), covered: { fromSeq: 0, throughSeq: 25 }, status: 'completed', outcome: 'no_value' })
|
|
126
|
+
assert.equal(done.advanced, false)
|
|
127
|
+
assert.equal(done.checked, null, 'partial coverage must not advance checked')
|
|
128
|
+
assert.equal(done.pending.throughSeq, 40)
|
|
129
|
+
assert.equal(done.pending.fromSeq, 0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('an expired lease is recovered as a failure, not as a completed run', () => {
|
|
133
|
+
const sessionId = 'led-zombie'
|
|
134
|
+
const started = Date.now() - 60_000
|
|
135
|
+
ledger.beginJob({ sessionId, jobId: 'job-z', snapshot: snapshot(12), binding: binding(), now: started, leaseMs: 1_000 })
|
|
136
|
+
const recovered = ledger.recoverStaleJobs({ now: Date.now() })
|
|
137
|
+
assert.ok(recovered.some((row) => row.sessionId === sessionId))
|
|
138
|
+
const state = ledger.sessionState(sessionId)
|
|
139
|
+
assert.equal(state.inflight, null)
|
|
140
|
+
assert.equal(state.checked, null, 'an interrupted job proves nothing')
|
|
141
|
+
assert.equal(state.pending.throughSeq, 12, 'its range is pending again')
|
|
142
|
+
assert.equal(state.lastReasonCode, 'lease-expired')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('a live lease is not recovered', () => {
|
|
146
|
+
const sessionId = 'led-live-lease'
|
|
147
|
+
ledger.beginJob({ sessionId, jobId: 'job-live', snapshot: snapshot(4), binding: binding() })
|
|
148
|
+
ledger.recoverStaleJobs({ now: Date.now() })
|
|
149
|
+
assert.ok(ledger.sessionState(sessionId).inflight, 'a job inside its lease keeps the session')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('the operation key is stable and records what was settled', () => {
|
|
153
|
+
const args = { sessionId: 'led-op', libraryId: 'lib_test', snapshotHash: 'hash-20-0' }
|
|
154
|
+
const key = ledger.operationKey(args)
|
|
155
|
+
assert.equal(key, ledger.operationKey({ ...args }))
|
|
156
|
+
assert.notEqual(key, ledger.operationKey({ ...args, snapshotHash: 'hash-21-0' }))
|
|
157
|
+
assert.equal(ledger.findOperation('led-op', key), null)
|
|
158
|
+
ledger.recordOperation({ sessionId: 'led-op', key, outcome: 'completed', ids: ['mem-1'] })
|
|
159
|
+
const found = ledger.findOperation('led-op', key)
|
|
160
|
+
assert.equal(found.outcome, 'completed')
|
|
161
|
+
assert.deepEqual(found.ids, ['mem-1'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('two sessions do not overwrite each other in the ledger', () => {
|
|
165
|
+
ledger.beginJob({ sessionId: 'led-a', jobId: 'job-a', snapshot: snapshot(3), binding: binding() })
|
|
166
|
+
ledger.beginJob({ sessionId: 'led-b', jobId: 'job-b', snapshot: snapshot(7), binding: binding() })
|
|
167
|
+
assert.equal(ledger.sessionState('led-a').inflight.jobId, 'job-a')
|
|
168
|
+
assert.equal(ledger.sessionState('led-b').inflight.jobId, 'job-b')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('the scope generation changes with the parent authorization', () => {
|
|
172
|
+
const base = binding()
|
|
173
|
+
const first = ledger.scopeGenerationOf(base)
|
|
174
|
+
assert.equal(first, ledger.scopeGenerationOf({ ...base }))
|
|
175
|
+
assert.notEqual(first, ledger.scopeGenerationOf({ ...base, libraryId: 'lib_other' }))
|
|
176
|
+
assert.notEqual(first, ledger.scopeGenerationOf({ ...base, useLibrary: false }))
|
|
177
|
+
assert.notEqual(first, ledger.scopeGenerationOf({ ...base, memoryContext: { channelId: 'other' } }))
|
|
178
|
+
assert.equal(ledger.scopeGenerationOf(null), null)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('a corrupt ledger blocks writes instead of silently replacing it', () => {
|
|
182
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-cons-corrupt-'))
|
|
183
|
+
const previous = process.env.DSH_MEMU_HOME
|
|
184
|
+
process.env.DSH_MEMU_HOME = other
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(path.join(other, 'consolidation.json'), '{ not json', 'utf8')
|
|
187
|
+
assert.throws(() => ledger.beginJob({ sessionId: 'led-corrupt', jobId: 'job-x', snapshot: snapshot(1), binding: binding() }), /refusing to overwrite|not valid JSON|busy/i)
|
|
188
|
+
assert.equal(ledger.sessionState('led-corrupt').inflight, null)
|
|
189
|
+
} finally {
|
|
190
|
+
process.env.DSH_MEMU_HOME = previous
|
|
191
|
+
try { fs.rmSync(other, { recursive: true, force: true }) } catch {}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// ---- 结构化回执 ----
|
|
196
|
+
|
|
197
|
+
function fence(json) {
|
|
198
|
+
const tick = String.fromCharCode(96)
|
|
199
|
+
return [tick + tick + tick + receipt.RECEIPT_TAG, JSON.stringify(json, null, 2), tick + tick + tick].join('\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('a receipt without its fence block is missing, never guessed', () => {
|
|
203
|
+
const parsed = receipt.parseReceipt('我整理完了,写了两条记忆。')
|
|
204
|
+
assert.equal(parsed.ok, false)
|
|
205
|
+
assert.equal(parsed.reasonCode, 'receipt-missing')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('malformed JSON and invalid shapes are distinguishable failures', () => {
|
|
209
|
+
const tick = String.fromCharCode(96) + String.fromCharCode(96) + String.fromCharCode(96)
|
|
210
|
+
const broken = [tick + receipt.RECEIPT_TAG, '{ "status": "completed", ', tick].join('\n')
|
|
211
|
+
assert.equal(receipt.parseReceipt(broken).reasonCode, 'receipt-malformed')
|
|
212
|
+
const badStatus = receipt.parseReceipt(fence({ status: 'done', outcome: 'created', createdIds: ['mem-1'] }))
|
|
213
|
+
assert.equal(badStatus.reasonCode, 'receipt-invalid')
|
|
214
|
+
assert.match(badStatus.detail, /status must be one of/)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('created without ids, or skipped without a reasonCode, is invalid', () => {
|
|
218
|
+
assert.equal(receipt.parseReceipt(fence({ status: 'completed', outcome: 'created', createdIds: [] })).reasonCode, 'receipt-invalid')
|
|
219
|
+
assert.equal(receipt.parseReceipt(fence({ status: 'skipped', outcome: 'no_value' })).reasonCode, 'receipt-invalid')
|
|
220
|
+
const ok = receipt.parseReceipt(fence({ status: 'skipped', outcome: 'no_value', reasonCode: 'no-value', checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
221
|
+
assert.equal(ok.ok, true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('no_value cannot smuggle in written ids', () => {
|
|
225
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'no_value', createdIds: ['mem-1'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
226
|
+
assert.equal(parsed.reasonCode, 'receipt-invalid')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('the receipt is reconciled against the library, not taken on trust', () => {
|
|
230
|
+
const index = { entries: { 'mem-1': { status: 'active' }, 'mem-held': { status: 'pending_review' } } }
|
|
231
|
+
const parsed = receipt.parseReceipt(fence({
|
|
232
|
+
status: 'completed', outcome: 'created', createdIds: ['mem-1', 'mem-ghost'], checkedRange: { fromSeq: 0, throughSeq: 8 },
|
|
233
|
+
}))
|
|
234
|
+
assert.equal(parsed.ok, true)
|
|
235
|
+
const reconciled = receipt.reconcileReceipt({ receipt: parsed.receipt, index, beforeIds: new Set() })
|
|
236
|
+
assert.equal(reconciled.ok, false)
|
|
237
|
+
assert.match(reconciled.problems.join(' '), /mem-ghost/)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('rewriting an existing id is an update, not a new memory', () => {
|
|
241
|
+
const index = { entries: { 'mem-1': { status: 'active', content: 'after' } } }
|
|
242
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'created', createdIds: ['mem-1'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
243
|
+
const reconciled = receipt.reconcileReceipt({ receipt: parsed.receipt, index, beforeIds: new Set(['mem-1']), beforeEntries: { 'mem-1': receipt.entryFingerprint({ status: 'active', content: 'before' }) } })
|
|
244
|
+
assert.equal(reconciled.ok, true)
|
|
245
|
+
assert.deepEqual(reconciled.createdIds, [])
|
|
246
|
+
assert.deepEqual(reconciled.duplicateIds, ['mem-1'])
|
|
247
|
+
assert.equal(reconciled.outcome, 'updated')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('an unchanged existing id cannot prove an update', () => {
|
|
251
|
+
const index = { entries: { 'mem-unchanged': { status: 'active', content: 'same' } } }
|
|
252
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'updated', updatedIds: ['mem-unchanged'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
253
|
+
const reconciled = receipt.reconcileReceipt({
|
|
254
|
+
receipt: parsed.receipt,
|
|
255
|
+
index,
|
|
256
|
+
beforeIds: new Set(['mem-unchanged']),
|
|
257
|
+
beforeEntries: { 'mem-unchanged': receipt.entryFingerprint(index.entries['mem-unchanged']) },
|
|
258
|
+
})
|
|
259
|
+
assert.equal(reconciled.ok, false)
|
|
260
|
+
assert.match(reconciled.problems.join(' '), /no verified post-worker change/)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('a held id must really be pending_review', () => {
|
|
264
|
+
const index = { entries: { 'mem-1': { status: 'active' } } }
|
|
265
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'no_value', heldIds: ['mem-1'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
266
|
+
const reconciled = receipt.reconcileReceipt({ receipt: parsed.receipt, index })
|
|
267
|
+
assert.equal(reconciled.ok, false)
|
|
268
|
+
assert.match(reconciled.problems.join(' '), /not pending_review/)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('decideCompletion: proof advances, guesswork does not', () => {
|
|
272
|
+
const index = { entries: { 'mem-7': { status: 'active' } } }
|
|
273
|
+
const good = receipt.decideCompletion({
|
|
274
|
+
text: fence({ status: 'completed', outcome: 'created', createdIds: ['mem-7'], checkedRange: { fromSeq: 0, throughSeq: 9 } }),
|
|
275
|
+
wrote: 1, index, beforeIds: new Set(),
|
|
276
|
+
})
|
|
277
|
+
assert.equal(good.advance, true)
|
|
278
|
+
assert.equal(good.outcome, 'created')
|
|
279
|
+
assert.deepEqual(good.counted, { created: 1, updated: 0, held: 0 })
|
|
280
|
+
const unverified = receipt.decideCompletion({ text: fence({ status: 'completed', outcome: 'created', createdIds: ['mem-404'], checkedRange: { fromSeq: 0, throughSeq: 9 } }), wrote: 0, index, beforeIds: new Set() })
|
|
281
|
+
assert.equal(unverified.advance, false)
|
|
282
|
+
assert.equal(unverified.reasonCode, 'receipt-unverified')
|
|
283
|
+
const silent = receipt.decideCompletion({ text: '我做了不少事情。', wrote: 0, index, beforeIds: new Set() })
|
|
284
|
+
assert.equal(silent.advance, false)
|
|
285
|
+
assert.equal(silent.reasonCode, 'receipt-missing')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('decideCompletion never settles a silent worker on its own', () => {
|
|
289
|
+
// 结算空正文的规则已经上移到 private.js 的调用点:那里知道「worker 一个字没说、库里也没
|
|
290
|
+
// 多出条目」这一种情形(见 tests/consolidation-fork.test.mjs 与 tests/cycle.test.mjs)。
|
|
291
|
+
// 模块层只保留一个方向的保证:没有回执就不给凭证。
|
|
292
|
+
const silent = receipt.decideCompletion({ text: '', wrote: 0 })
|
|
293
|
+
assert.equal(silent.advance, false)
|
|
294
|
+
assert.equal(silent.reasonCode, 'receipt-missing')
|
|
295
|
+
const unattributed = receipt.decideCompletion({ text: '', wrote: 2 })
|
|
296
|
+
assert.equal(unattributed.advance, false)
|
|
297
|
+
assert.match(unattributed.error, /receipt|cannot settle/i)
|
|
298
|
+
})
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
const { captureSessionSnapshot, safePrefix, toolPairing, lastClosedTurnSeq, snapshotHash } = await import('../lib/consolidation-snapshot.js')
|
|
5
|
+
const { probeToolSurface, lastHeaderTools, buildWorkerBrief } = await import('../lib/consolidation-worker.js')
|
|
6
|
+
|
|
7
|
+
// C 包第 1、2 条:安全前缀必须可解释。这里逐条钉住「什么算完整」,
|
|
8
|
+
// 因为把半步喂给 worker 的代价是它对没发生的事编出结论。
|
|
9
|
+
|
|
10
|
+
function turn(turnNo, step, extra = {}) {
|
|
11
|
+
return [
|
|
12
|
+
{ seq: 0, type: 'turn/start', data: { turn: turnNo } },
|
|
13
|
+
{ seq: 1, type: 'step/start', data: { turn: turnNo, step } },
|
|
14
|
+
{ seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '我把 API key 放到 1password 了' }] } },
|
|
15
|
+
{ seq: 3, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '收到' }] } } },
|
|
16
|
+
{ seq: 4, type: 'step/end', data: { turn: turnNo, step } },
|
|
17
|
+
{ seq: 5, type: 'turn/end', data: { turn: turnNo, reason: { kind: 'completed' } } },
|
|
18
|
+
].map((event) => ({ ...event, ...extra }))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function closeTurn(events, turnNo, base) {
|
|
22
|
+
return [
|
|
23
|
+
...events,
|
|
24
|
+
{ seq: base, type: 'turn/start', data: { turn: turnNo } },
|
|
25
|
+
{ seq: base + 1, type: 'step/start', data: { turn: turnNo, step: 1 } },
|
|
26
|
+
{ seq: base + 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第二轮:别删那条记忆' }] } },
|
|
27
|
+
{ seq: base + 3, type: 'step/end', data: { turn: turnNo, step: 1 } },
|
|
28
|
+
{ seq: base + 4, type: 'turn/end', data: { turn: turnNo, reason: { kind: 'completed' } } },
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('a live lease is cut at the last closed turn', () => {
|
|
33
|
+
const events = [
|
|
34
|
+
...turn(1, 1),
|
|
35
|
+
// 第二轮只开了头:turn/start + step/start + 一条真人消息,没有 turn/end。
|
|
36
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
37
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
38
|
+
{ seq: 8, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这句话还没答完' }] } },
|
|
39
|
+
]
|
|
40
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
41
|
+
assert.equal(safe.ok, true)
|
|
42
|
+
assert.equal(safe.throughSeq, 5, 'the open second turn is not part of the snapshot')
|
|
43
|
+
assert.equal(safe.eventCount, 6)
|
|
44
|
+
assert.equal(safe.dropped, 3)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('a live transcript with no closed turn is deferred, not consolidated', () => {
|
|
48
|
+
const events = [
|
|
49
|
+
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
50
|
+
{ seq: 1, type: 'step/start', data: { turn: 1, step: 1 } },
|
|
51
|
+
]
|
|
52
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
53
|
+
assert.equal(safe.ok, false)
|
|
54
|
+
assert.equal(safe.reasonCode, 'no-closed-turn')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a tool call without its result is a half step and is trimmed', () => {
|
|
58
|
+
const events = [
|
|
59
|
+
...turn(1, 1),
|
|
60
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
61
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
62
|
+
{ seq: 8, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', id: 'call-1', name: 'read' }] } } },
|
|
63
|
+
{ seq: 9, type: 'tool/call', data: { turn: 2, step: 1, callId: 'call-1', name: 'read', arguments: '{}' } },
|
|
64
|
+
{ seq: 10, type: 'step/end', data: { turn: 2, step: 1 } },
|
|
65
|
+
{ seq: 11, type: 'turn/end', data: { turn: 2, reason: { kind: 'aborted' } } },
|
|
66
|
+
]
|
|
67
|
+
const pairing = toolPairing(events)
|
|
68
|
+
assert.equal(pairing.unpaired.length, 1)
|
|
69
|
+
assert.equal(pairing.firstUnpairedSeq, 8)
|
|
70
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
71
|
+
assert.equal(safe.ok, true)
|
|
72
|
+
assert.equal(safe.throughSeq, 5, 'the turn whose call never resolved is dropped whole')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('a paired tool call keeps its turn', () => {
|
|
76
|
+
const events = [
|
|
77
|
+
...turn(1, 1),
|
|
78
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
79
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
80
|
+
{ seq: 8, type: 'tool/call', data: { turn: 2, step: 1, callId: 'call-1', name: 'read', arguments: '{}' } },
|
|
81
|
+
{ seq: 9, type: 'tool/result', data: { turn: 2, step: 1, message: { toolCallId: 'call-1', source: { kind: 'tool', callId: 'call-1' }, content: [{ type: 'text', text: 'ok' }], isError: false } } },
|
|
82
|
+
{ seq: 10, type: 'step/end', data: { turn: 2, step: 1 } },
|
|
83
|
+
{ seq: 11, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } },
|
|
84
|
+
]
|
|
85
|
+
const safe = safePrefix(events, { requireClosedTurn: true })
|
|
86
|
+
assert.equal(safe.ok, true)
|
|
87
|
+
assert.equal(safe.throughSeq, 11)
|
|
88
|
+
assert.equal(safe.pairing.unpaired, 0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('host tool result identities pair in both live and validated snapshot modes', async () => {
|
|
92
|
+
for (const message of [
|
|
93
|
+
{ toolCallId: 'host-call', source: { kind: 'tool', callId: 'host-call' } },
|
|
94
|
+
{ source: { kind: 'tool', callId: 'host-call' } },
|
|
95
|
+
{ callId: 'host-call' },
|
|
96
|
+
]) {
|
|
97
|
+
const events = [
|
|
98
|
+
{ seq: 0, type: 'turn/start', data: { turn: 1 } },
|
|
99
|
+
{ seq: 1, type: 'step/start', data: { turn: 1, step: 1 } },
|
|
100
|
+
{ seq: 2, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', id: 'host-call', name: 'read' }] } } },
|
|
101
|
+
{ seq: 3, type: 'tool/call', data: { callId: 'host-call', name: 'read' } },
|
|
102
|
+
{ seq: 4, type: 'tool/result', data: { message: { ...message, content: [{ type: 'text', text: 'ok' }] } } },
|
|
103
|
+
{ seq: 5, type: 'step/end', data: { turn: 1, step: 1 } },
|
|
104
|
+
{ seq: 6, type: 'turn/end', data: { turn: 1 } },
|
|
105
|
+
]
|
|
106
|
+
assert.equal(toolPairing(events).unpaired.length, 0)
|
|
107
|
+
for (const requireClosedTurn of [true, false]) {
|
|
108
|
+
const safe = safePrefix(events, { requireClosedTurn })
|
|
109
|
+
assert.equal(safe.ok, true)
|
|
110
|
+
assert.equal(safe.throughSeq, 6)
|
|
111
|
+
}
|
|
112
|
+
const captured = await captureSessionSnapshot(leaseHost({ events }), 'host-pair')
|
|
113
|
+
assert.equal(captured.ok, true)
|
|
114
|
+
assert.equal(captured.snapshot.throughSeq, 6)
|
|
115
|
+
const unmatched = events.filter(event => event.type !== 'tool/result')
|
|
116
|
+
assert.equal(toolPairing(unmatched).firstUnpairedSeq, 2)
|
|
117
|
+
assert.equal(safePrefix(unmatched, { requireClosedTurn: true }).ok, false)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a validated log keeps its full range but still drops a trailing step/start', () => {
|
|
122
|
+
const events = [
|
|
123
|
+
...turn(1, 1),
|
|
124
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
125
|
+
{ seq: 7, type: 'step/start', data: { turn: 2, step: 1 } },
|
|
126
|
+
]
|
|
127
|
+
const safe = safePrefix(events, { requireClosedTurn: false })
|
|
128
|
+
assert.equal(safe.ok, true)
|
|
129
|
+
assert.equal(safe.throughSeq, 5, 'an open step at the tail is not part of the prefix')
|
|
130
|
+
assert.equal(lastClosedTurnSeq(events), 5)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('an empty transcript has nothing to snapshot', () => {
|
|
134
|
+
assert.equal(safePrefix([], { requireClosedTurn: true }).reasonCode, 'no-events')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('the snapshot hash is stable for the same range and changes with content', () => {
|
|
138
|
+
const events = turn(1, 1)
|
|
139
|
+
const a = snapshotHash(events, 0, 5)
|
|
140
|
+
const b = snapshotHash(events, 0, 5)
|
|
141
|
+
const c = snapshotHash([...events.slice(0, 5), { seq: 5, type: 'turn/end', data: { turn: 1, reason: { kind: 'error' } } }], 0, 5)
|
|
142
|
+
const equalLength = snapshotHash(events.map((event) => event.type === 'user/message' ? { ...event, data: { ...event.data, content: [{ type: 'text', text: '同样长度' }] } } : event), 0, 5)
|
|
143
|
+
assert.equal(a, b)
|
|
144
|
+
assert.notEqual(a, c, 'a different last event means a different snapshot identity')
|
|
145
|
+
assert.notEqual(a, equalLength, 'different equal-length event bodies must have different identities')
|
|
146
|
+
assert.equal(a.length, 64, 'content-addressed sha256 of the canonical events')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ---- captureSessionSnapshot:只用公开 API,缺能力就明确失败 ----
|
|
150
|
+
|
|
151
|
+
function leaseHost({ events, source = 'live', cursor = null, dispose = null, onObserve = null }) {
|
|
152
|
+
const rec = { disposed: 0, projectionMode: null, readSessionCalls: 0 }
|
|
153
|
+
return {
|
|
154
|
+
rec,
|
|
155
|
+
on: () => () => {},
|
|
156
|
+
get: (name) => {
|
|
157
|
+
if (name !== 'sessionQuery') return undefined
|
|
158
|
+
return {
|
|
159
|
+
observeSession: async (id, options) => {
|
|
160
|
+
rec.projectionMode = options?.projectionMode ?? null
|
|
161
|
+
if (onObserve) onObserve(id, options)
|
|
162
|
+
return {
|
|
163
|
+
source,
|
|
164
|
+
header: { cwd: 'D:/proj', agentPreset: 'standard' },
|
|
165
|
+
inheritedEventCount: 0,
|
|
166
|
+
events,
|
|
167
|
+
cursor: cursor === null ? (events.at(-1)?.seq ?? -1) : cursor,
|
|
168
|
+
retain: () => ({ [Symbol.dispose]: () => {} }),
|
|
169
|
+
[Symbol.dispose]: () => { rec.disposed += 1; dispose?.() },
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
readSession: async () => { rec.readSessionCalls += 1; return { events, session: { cwd: 'D:/proj' } } },
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test('captureSessionSnapshot uses the public lease, pins the cut and disposes the lease', async () => {
|
|
179
|
+
const events = turn(1, 1)
|
|
180
|
+
const ctx = leaseHost({ events })
|
|
181
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-lease')
|
|
182
|
+
assert.equal(captured.ok, true)
|
|
183
|
+
assert.equal(captured.snapshot.source, 'lease-live')
|
|
184
|
+
assert.equal(captured.snapshot.throughSeq, 5)
|
|
185
|
+
assert.equal(captured.snapshot.eventCount, 6)
|
|
186
|
+
assert.equal(captured.snapshot.cursor, 5)
|
|
187
|
+
assert.equal(ctx.rec.projectionMode, 'none', 'raw events, not the model-visible projection')
|
|
188
|
+
assert.equal(ctx.rec.disposed, 1, 'the lease is always released')
|
|
189
|
+
assert.equal(ctx.rec.readSessionCalls, 0, 'a lease read must not fall back to a full log read')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('a prepared lease is a balanced log, so it does not need a closed turn', async () => {
|
|
193
|
+
const events = [
|
|
194
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '我更喜欢简短回答' }] } },
|
|
195
|
+
{ seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '好' }] } } },
|
|
196
|
+
]
|
|
197
|
+
const ctx = leaseHost({ events, source: 'prepared' })
|
|
198
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-prepared')
|
|
199
|
+
assert.equal(captured.ok, true)
|
|
200
|
+
assert.equal(captured.snapshot.source, 'lease-prepared')
|
|
201
|
+
assert.equal(captured.snapshot.throughSeq, 1)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('a live lease with an open turn is deferred instead of consolidated', async () => {
|
|
205
|
+
const ctx = leaseHost({ events: [{ seq: 0, type: 'turn/start', data: { turn: 1 } }] })
|
|
206
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-open')
|
|
207
|
+
assert.equal(captured.ok, false)
|
|
208
|
+
assert.equal(captured.code, 'deferred')
|
|
209
|
+
assert.equal(captured.reasonCode, 'no-closed-turn')
|
|
210
|
+
assert.equal(ctx.rec.disposed, 1, 'a deferred read still releases the lease')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a host with readSession only is used as a validated log', async () => {
|
|
214
|
+
const ctx = { on: () => () => {}, get: (name) => (name === 'sessionQuery'
|
|
215
|
+
? { readSession: async () => ({ events: turn(1, 1), session: { cwd: 'D:/p' }, inheritedEventCount: 0 }) }
|
|
216
|
+
: undefined) }
|
|
217
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-legacy')
|
|
218
|
+
assert.equal(captured.ok, true)
|
|
219
|
+
assert.equal(captured.snapshot.source, 'validated-log')
|
|
220
|
+
assert.equal(captured.snapshot.throughSeq, 5)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('a host without any snapshot API is unavailable, not an empty success', async () => {
|
|
224
|
+
const none = await captureSessionSnapshot({ on: () => () => {}, get: () => undefined }, 'sess-none')
|
|
225
|
+
assert.equal(none.ok, false)
|
|
226
|
+
assert.equal(none.code, 'unavailable')
|
|
227
|
+
assert.equal(none.reasonCode, 'no-session-query')
|
|
228
|
+
const partial = await captureSessionSnapshot({ on: () => () => {}, get: () => ({}) }, 'sess-partial')
|
|
229
|
+
assert.equal(partial.reasonCode, 'no-snapshot-api')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('a failing observeSession is a retryable failure with the host message', async () => {
|
|
233
|
+
const ctx = { on: () => () => {}, get: () => ({ observeSession: async () => { throw new Error('SESSION_QUERY_PERSISTENCE_FAILED: disk gone') } }) }
|
|
234
|
+
const captured = await captureSessionSnapshot(ctx, 'sess-boom')
|
|
235
|
+
assert.equal(captured.ok, false)
|
|
236
|
+
assert.equal(captured.code, 'failed')
|
|
237
|
+
assert.match(captured.error, /disk gone/)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ---- 工具面:标准 / PTC / 未知 三种都要能分辨 ----
|
|
241
|
+
|
|
242
|
+
const MEMU_TOOLS = [
|
|
243
|
+
{ name: 'memu_private_remember' },
|
|
244
|
+
{ name: 'memu_private_update' },
|
|
245
|
+
{ name: 'memu_private_recall' },
|
|
246
|
+
{ name: 'read' },
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
test('a standard header that lists memu_* tools proves the worker can write', () => {
|
|
250
|
+
const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: MEMU_TOOLS } } }]
|
|
251
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
252
|
+
assert.equal(probe.ok, true)
|
|
253
|
+
assert.equal(probe.hasWrite, true)
|
|
254
|
+
assert.equal(probe.source, 'header-event')
|
|
255
|
+
assert.deepEqual(probe.tools, ['memu_private_remember', 'memu_private_update', 'memu_private_recall', 'read'])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a header without memory tools is unavailable rather than a silent wasted fork', () => {
|
|
259
|
+
const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'read' }, { name: 'write' }] } } }]
|
|
260
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
261
|
+
assert.equal(probe.ok, false)
|
|
262
|
+
assert.equal(probe.reasonCode, 'worker-tools-missing')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('a PTC-only turn is not a refusal: the probe reports ptc and the SDK surface is checked at fork time', () => {
|
|
266
|
+
// 2026-10-31 口径变更:PTC 模式下 header 只列 transport(这里 ptc_exec,真实宿主为
|
|
267
|
+
// run_code),其余工具走 run_code 程序里的 SDK 绑定 —— header 名单不承载"能不能写",
|
|
268
|
+
// 不再产生 worker-tools-ptc-only 拒绝;fork 后由 attachWorkerTools 挂载读写工具。
|
|
269
|
+
const events = [
|
|
270
|
+
{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
|
|
271
|
+
{ seq: 1, type: 'tool/ptc-dispatch', data: { name: 'ptc_exec' } },
|
|
272
|
+
]
|
|
273
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
274
|
+
assert.equal(probe.ok, true, 'a PTC surface can never disprove write ability')
|
|
275
|
+
assert.equal(probe.source, 'ptc-header')
|
|
276
|
+
assert.equal(probe.ptcObserved, true)
|
|
277
|
+
assert.equal(probe.hasWrite, null)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('a run_code header alone (no dispatch events yet) is still recognized as PTC', () => {
|
|
281
|
+
// 新 PTC 会话一次 run_code 都没跑过时也要认得出来:run_code 出现在名单里就是证据。
|
|
282
|
+
const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }] } } }]
|
|
283
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
284
|
+
assert.equal(probe.ok, true)
|
|
285
|
+
assert.equal(probe.source, 'ptc-header')
|
|
286
|
+
assert.equal(probe.ptcObserved, true)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a mixed surface (run_code beside native memu_*) proves write ability and keeps the PTC shape', () => {
|
|
290
|
+
// 混合面:PTC transport 与原生 memu_* 同时出现在 header 名单(preset 部分列出本插件、
|
|
291
|
+
// 或 PTC 与原生工具共存的会话)。probe 既不能当纯 PTC(hasWrite:null 会丢掉"名单里
|
|
292
|
+
// 明摆着有写工具"的证据),也不能当普通名单(ptcObserved:false 会丢掉 PTC transport)。
|
|
293
|
+
// hasWrite:true + ptcObserved:true 是钉住的口径;任务书(buildWorkerBrief)在混合面下
|
|
294
|
+
// 既要列原生名单、又要带 run_code 的 SDK 调用形态——两种 transport 都覆盖。
|
|
295
|
+
const events = [{
|
|
296
|
+
seq: 0,
|
|
297
|
+
type: 'request/header',
|
|
298
|
+
data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }, ...MEMU_TOOLS] } },
|
|
299
|
+
}]
|
|
300
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
301
|
+
assert.equal(probe.ok, true)
|
|
302
|
+
assert.equal(probe.hasWrite, true, 'a native write tool in the list still proves write ability')
|
|
303
|
+
assert.equal(probe.ptcObserved, true, 'run_code in the list is PTC evidence even beside native tools')
|
|
304
|
+
assert.equal(probe.source, 'header-event')
|
|
305
|
+
const brief = buildWorkerBrief({
|
|
306
|
+
snapshot: { fromSeq: 0, throughSeq: 5, eventCount: 6, hash: 'mix001' },
|
|
307
|
+
probe,
|
|
308
|
+
contract: 'MIXED-CONTRACT',
|
|
309
|
+
})
|
|
310
|
+
assert.match(brief, /run_code, memu_private_remember, memu_private_update/, 'the brief lists the native names')
|
|
311
|
+
assert.match(brief, /await tools\.memu_private_remember/, 'the brief keeps the PTC SDK invocation shape on a mixed surface')
|
|
312
|
+
assert.match(brief, /MIXED-CONTRACT/)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('a missing tool list is unknown, not a refusal', () => {
|
|
316
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events: [{ seq: 0, type: 'request/header', data: { header: { config: {} } } }] })
|
|
317
|
+
assert.equal(probe.ok, true)
|
|
318
|
+
assert.equal(probe.source, 'unknown-tools')
|
|
319
|
+
assert.equal(probe.tools, null)
|
|
320
|
+
assert.equal(probeToolSurface({ ctx: null, sessionId: 's', events: [] }).source, 'unknown')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('the live request header wins over an older logged one', () => {
|
|
324
|
+
const ctx = {
|
|
325
|
+
get: (name) => (name === 'agents' ? { get: () => ({ session: { requestHeader: () => ({ config: {}, tools: MEMU_TOOLS }) } }) } : undefined),
|
|
326
|
+
}
|
|
327
|
+
const events = [{ seq: 9, type: 'request/header', data: { header: { config: {}, tools: [{ name: 'read' }] } } }]
|
|
328
|
+
const probe = probeToolSurface({ ctx, sessionId: 'live', events })
|
|
329
|
+
assert.equal(probe.ok, true)
|
|
330
|
+
assert.equal(probe.source, 'live-header')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('lastHeaderTools folds to the latest header only', () => {
|
|
334
|
+
const events = [
|
|
335
|
+
{ seq: 0, type: 'request/header', data: { header: { tools: [{ name: 'old' }] } } },
|
|
336
|
+
{ seq: 1, type: 'request/header', data: { header: { tools: MEMU_TOOLS } } },
|
|
337
|
+
]
|
|
338
|
+
const folded = lastHeaderTools(events)
|
|
339
|
+
assert.equal(folded.found, true)
|
|
340
|
+
assert.ok(folded.tools.includes('memu_private_remember'))
|
|
341
|
+
assert.equal(folded.tools.includes('old'), false)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('the worker brief carries the range, the tools and the receipt contract', () => {
|
|
345
|
+
const brief = buildWorkerBrief({
|
|
346
|
+
snapshot: { fromSeq: 0, throughSeq: 41, eventCount: 42, hash: 'abc123' },
|
|
347
|
+
probe: { tools: ['memu_private_remember', 'memu_private_recall'] },
|
|
348
|
+
contract: 'RECEIPT-CONTRACT-MARKER',
|
|
349
|
+
reason: 'manual',
|
|
350
|
+
jobId: 'cons-1',
|
|
351
|
+
})
|
|
352
|
+
assert.match(brief, /不是这场对话里的角色/)
|
|
353
|
+
assert.match(brief, /第 0 到第 41 条事件/)
|
|
354
|
+
assert.match(brief, /快照 abc123/)
|
|
355
|
+
assert.match(brief, /memu_private_remember/)
|
|
356
|
+
assert.match(brief, /不写全局记忆/)
|
|
357
|
+
assert.match(brief, /RECEIPT-CONTRACT-MARKER/)
|
|
358
|
+
assert.match(brief, /手动/)
|
|
359
|
+
assert.match(brief, /tools-unavailable/)
|
|
360
|
+
})
|