dsh-memu-memory 1.1.2 → 1.2.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/AGENTS.md +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- 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 +115 -0
- package/lib/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- 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 +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -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 +212 -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 +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -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-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-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -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 +536 -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/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 +208 -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 +733 -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 +164 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,113 @@
|
|
|
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-coalesce-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
|
|
12
|
+
const stateFile = path.join(home, 'state.json')
|
|
13
|
+
|
|
14
|
+
test.after(() => {
|
|
15
|
+
try { state.flushState() } catch {}
|
|
16
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function countWrites(match) {
|
|
20
|
+
const original = fs.writeFileSync
|
|
21
|
+
const hits = []
|
|
22
|
+
fs.writeFileSync = function (file, ...rest) {
|
|
23
|
+
if (String(file).includes(match)) hits.push(String(file))
|
|
24
|
+
return original.call(this, file, ...rest)
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
hits,
|
|
28
|
+
stop() { fs.writeFileSync = original },
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('a turn of bookkeeping updates collapses into one disk write', () => {
|
|
33
|
+
state.ensureSession('coalesce', { cwd: path.join(home, 'coalesce') })
|
|
34
|
+
state.flushState()
|
|
35
|
+
|
|
36
|
+
const spy = countWrites('state.json.tmp-')
|
|
37
|
+
try {
|
|
38
|
+
for (let i = 0; i < 10; i++) state.touchSession('coalesce')
|
|
39
|
+
assert.equal(spy.hits.length, 0, 'nothing is written while the timer is pending')
|
|
40
|
+
state.flushState()
|
|
41
|
+
assert.equal(spy.hits.length, 1, 'all ten updates land in a single write')
|
|
42
|
+
} finally {
|
|
43
|
+
spy.stop()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('every coalesced change is present on disk afterwards', () => {
|
|
48
|
+
state.ensureSession('coalesce-all', { cwd: path.join(home, 'coalesce-all') })
|
|
49
|
+
state.markInjected('coalesce-all', ['mem-a', 'mem-b'], 3)
|
|
50
|
+
state.setSessionMood('coalesce-all', { valence: 0.5, arousal: 0.6 })
|
|
51
|
+
state.flushState()
|
|
52
|
+
|
|
53
|
+
const raw = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
|
|
54
|
+
const binding = raw.sessions['coalesce-all']
|
|
55
|
+
assert.ok(binding)
|
|
56
|
+
assert.ok(binding.injectedIds.includes('mem-a'))
|
|
57
|
+
assert.ok(binding.injectedIds.includes('mem-b'))
|
|
58
|
+
assert.equal(binding.lastInjectedTurn, 3)
|
|
59
|
+
assert.equal(binding.mood.valence, 0.5)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('memory indexes are never coalesced', () => {
|
|
63
|
+
const binding = state.ensureSession('no-coalesce', { cwd: path.join(home, 'no-coalesce') })
|
|
64
|
+
state.flushState()
|
|
65
|
+
|
|
66
|
+
const spy = countWrites('.json.tmp-')
|
|
67
|
+
try {
|
|
68
|
+
state.savePrivateIndex(binding.libraryId, { version: 1, entries: { a: { id: 'a' } } })
|
|
69
|
+
state.savePrivateIndex(binding.libraryId, { version: 1, entries: { a: { id: 'a' }, b: { id: 'b' } } })
|
|
70
|
+
const indexWrites = spy.hits.filter((file) => file.includes(binding.libraryId))
|
|
71
|
+
assert.equal(indexWrites.length, 2, 'a memory write hits disk immediately, every time')
|
|
72
|
+
} finally {
|
|
73
|
+
spy.stop()
|
|
74
|
+
}
|
|
75
|
+
assert.deepEqual(Object.keys(state.loadPrivateIndex(binding.libraryId).entries).sort(), ['a', 'b'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('a read-modify-write after an await keeps what landed during it', async () => {
|
|
79
|
+
const binding = state.ensureSession('await-merge', { cwd: path.join(home, 'await-merge') })
|
|
80
|
+
|
|
81
|
+
// A caller that reads first and awaits (a MemU commit, a consolidation job).
|
|
82
|
+
const slowCaller = (async () => {
|
|
83
|
+
const early = state.loadPrivateIndex(binding.libraryId)
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
85
|
+
return early
|
|
86
|
+
})()
|
|
87
|
+
|
|
88
|
+
// Another writer lands inside that window.
|
|
89
|
+
state.updatePrivateIndex(binding.libraryId, (index) => {
|
|
90
|
+
index.entries['mem-other'] = { id: 'mem-other', content: 'other' }
|
|
91
|
+
})
|
|
92
|
+
const early = await slowCaller
|
|
93
|
+
assert.equal(early.entries['mem-other'], undefined, 'the early snapshot really is stale')
|
|
94
|
+
|
|
95
|
+
// The correct pattern re-reads instead of saving the stale snapshot.
|
|
96
|
+
state.updatePrivateIndex(binding.libraryId, (index) => {
|
|
97
|
+
index.entries['mem-mine'] = { id: 'mem-mine', content: 'mine' }
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const after = state.loadPrivateIndex(binding.libraryId)
|
|
101
|
+
assert.deepEqual(Object.keys(after.entries).sort(), ['mem-mine', 'mem-other'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('an externally modified state.json is picked up instead of the cache', () => {
|
|
105
|
+
state.ensureSession('external', { cwd: path.join(home, 'external') })
|
|
106
|
+
state.flushState()
|
|
107
|
+
|
|
108
|
+
const raw = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
|
|
109
|
+
raw.sessions['external'].useGlobal = false
|
|
110
|
+
fs.writeFileSync(stateFile, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
|
|
111
|
+
|
|
112
|
+
assert.equal(state.getSessionBinding('external').useGlobal, false)
|
|
113
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
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-config-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
process.env.MEMU_CONFIG = path.join(home, 'config.env')
|
|
10
|
+
|
|
11
|
+
const config = await import('../lib/memu-config.js')
|
|
12
|
+
const remote = await import('../lib/remote-summary.js')
|
|
13
|
+
|
|
14
|
+
test.after(() => {
|
|
15
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('writes and reads embedding config without leaking the key', () => {
|
|
19
|
+
const saved = config.saveMemuConfig({
|
|
20
|
+
provider: 'jina',
|
|
21
|
+
model: 'jina-embeddings-v3',
|
|
22
|
+
baseUrl: 'https://api.jina.ai/v1',
|
|
23
|
+
apiKey: 'secret-key',
|
|
24
|
+
})
|
|
25
|
+
assert.equal(saved.provider, 'jina')
|
|
26
|
+
assert.equal(saved.model, 'jina-embeddings-v3')
|
|
27
|
+
assert.equal(saved.hasKey, true)
|
|
28
|
+
assert.ok(!JSON.stringify(saved).includes('secret-key'))
|
|
29
|
+
const text = fs.readFileSync(process.env.MEMU_CONFIG, 'utf8')
|
|
30
|
+
assert.ok(text.includes('MEMU_EMBED_PROVIDER=jina'))
|
|
31
|
+
assert.ok(text.includes('MEMU_API_KEY=secret-key'))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('clearKey removes the key', () => {
|
|
35
|
+
config.saveMemuConfig({ clearKey: true })
|
|
36
|
+
assert.equal(config.getMemuConfig().hasKey, false)
|
|
37
|
+
const text = fs.readFileSync(process.env.MEMU_CONFIG, 'utf8')
|
|
38
|
+
assert.ok(!text.includes('MEMU_API_KEY='))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('source follows vectorSource independently of the saved API endpoint', async () => {
|
|
42
|
+
const state = await import('../lib/state.js')
|
|
43
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource: 'api' })
|
|
44
|
+
config.saveMemuConfig({ provider: 'openai', baseUrl: 'http://localhost:1234/v1', model: 'custom-local-api' })
|
|
45
|
+
assert.equal(config.getMemuConfig().source, 'api')
|
|
46
|
+
assert.equal(state.effectiveVectorRoute(), 'remote-api')
|
|
47
|
+
state.updateSettings({ vectorSource: 'local' })
|
|
48
|
+
config.saveMemuConfig({ baseUrl: 'https://api.openai.com/v1' })
|
|
49
|
+
assert.equal(config.getMemuConfig().source, 'local')
|
|
50
|
+
assert.equal(state.effectiveVectorRoute(), 'managed-local')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('local/api source round trips preserve the complete saved API configuration', async () => {
|
|
54
|
+
const state = await import('../lib/state.js')
|
|
55
|
+
config.saveMemuConfig({ provider: 'voyage', baseUrl: 'https://api.voyageai.com/v1', apiKey: 'keep-me', model: 'voyage-3' })
|
|
56
|
+
const before = fs.readFileSync(process.env.MEMU_CONFIG, 'utf8')
|
|
57
|
+
for (const source of ['local', 'api', 'local', 'remote']) {
|
|
58
|
+
const normalized = source === 'remote' ? 'api' : source
|
|
59
|
+
const after = config.setEmbeddingSource(source)
|
|
60
|
+
assert.equal(after.source, normalized)
|
|
61
|
+
assert.equal(state.getSettings().vectorSource, normalized)
|
|
62
|
+
assert.equal(after.provider, 'voyage')
|
|
63
|
+
assert.equal(after.baseUrl, 'https://api.voyageai.com/v1')
|
|
64
|
+
assert.equal(after.model, 'voyage-3')
|
|
65
|
+
assert.equal(after.hasKey, true)
|
|
66
|
+
assert.ok(!JSON.stringify(after).includes('keep-me'))
|
|
67
|
+
assert.equal(fs.readFileSync(process.env.MEMU_CONFIG, 'utf8'), before)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('an API localhost endpoint remains configured without a vendor key', () => {
|
|
72
|
+
config.setEmbeddingSource('api')
|
|
73
|
+
config.saveMemuConfig({
|
|
74
|
+
provider: 'openai',
|
|
75
|
+
model: 'custom-local-api',
|
|
76
|
+
baseUrl: 'http://localhost:1234/v1',
|
|
77
|
+
clearKey: true,
|
|
78
|
+
})
|
|
79
|
+
const local = config.getMemuConfig()
|
|
80
|
+
assert.equal(local.source, 'api')
|
|
81
|
+
assert.equal(local.baseUrl, 'http://localhost:1234/v1')
|
|
82
|
+
assert.equal(local.hasKey, false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('an unknown source is rejected without modifying saved settings or configuration', async () => {
|
|
86
|
+
const state = await import('../lib/state.js')
|
|
87
|
+
const before = fs.readFileSync(process.env.MEMU_CONFIG, 'utf8')
|
|
88
|
+
const source = state.getSettings().vectorSource
|
|
89
|
+
assert.throws(() => config.setEmbeddingSource('sideways'), /local or api/)
|
|
90
|
+
assert.equal(state.getSettings().vectorSource, source)
|
|
91
|
+
assert.equal(fs.readFileSync(process.env.MEMU_CONFIG, 'utf8'), before)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('storage mode follows the setting instead of being pinned to local', async () => {
|
|
95
|
+
const state = await import('../lib/state.js')
|
|
96
|
+
state.updateSettings({ memoryMode: 'cloud' })
|
|
97
|
+
config.saveMemuConfig({ provider: 'openai' })
|
|
98
|
+
assert.equal(config.getMemuConfig().mode, 'cloud')
|
|
99
|
+
state.updateSettings({ memoryMode: 'local' })
|
|
100
|
+
config.saveMemuConfig({ provider: 'openai' })
|
|
101
|
+
assert.equal(config.getMemuConfig().mode, 'local')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('summary config is stored and read without leaking its key', () => {
|
|
105
|
+
config.saveMemuConfig({
|
|
106
|
+
summaryModel: 'deepseek-chat',
|
|
107
|
+
summaryBaseUrl: 'https://api.deepseek.com/v1/',
|
|
108
|
+
summaryApiKey: 'summary-secret',
|
|
109
|
+
})
|
|
110
|
+
const summary = config.getSummaryConfig()
|
|
111
|
+
assert.equal(summary.model, 'deepseek-chat')
|
|
112
|
+
assert.equal(summary.baseUrl, 'https://api.deepseek.com/v1')
|
|
113
|
+
assert.equal(summary.hasKey, true)
|
|
114
|
+
assert.equal(summary.ready, true)
|
|
115
|
+
assert.ok(!JSON.stringify(summary).includes('summary-secret'))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('remote summary parses model-written memories from chat completions', async () => {
|
|
119
|
+
config.saveMemuConfig({
|
|
120
|
+
summaryModel: 'deepseek-chat',
|
|
121
|
+
summaryBaseUrl: 'https://api.deepseek.com/v1',
|
|
122
|
+
summaryApiKey: 'summary-secret',
|
|
123
|
+
})
|
|
124
|
+
const originalFetch = globalThis.fetch
|
|
125
|
+
let seen = null
|
|
126
|
+
globalThis.fetch = async (url, init) => {
|
|
127
|
+
seen = { url, init, body: JSON.parse(init.body) }
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
status: 200,
|
|
131
|
+
text: async () => JSON.stringify({
|
|
132
|
+
choices: [{
|
|
133
|
+
message: {
|
|
134
|
+
content: JSON.stringify({
|
|
135
|
+
memories: [
|
|
136
|
+
{ title: '香菜', content: '他记得我不吃香菜。', tags: ['food'], open_loop: true, confidence: 0.7 },
|
|
137
|
+
{ title: '', content: '', tags: [] },
|
|
138
|
+
],
|
|
139
|
+
}),
|
|
140
|
+
},
|
|
141
|
+
}],
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const memories = await remote.summarizeRemote({
|
|
147
|
+
transcript: '用户说他不吃香菜', libraryName: '测试库', domain: 'general',
|
|
148
|
+
})
|
|
149
|
+
assert.equal(seen.url, 'https://api.deepseek.com/v1/chat/completions')
|
|
150
|
+
assert.equal(seen.init.headers.authorization, 'Bearer summary-secret')
|
|
151
|
+
assert.equal(seen.body.model, 'deepseek-chat')
|
|
152
|
+
assert.equal(memories.length, 1)
|
|
153
|
+
assert.equal(memories[0].title, '香菜')
|
|
154
|
+
assert.equal(memories[0].openLoop, true)
|
|
155
|
+
assert.equal(memories[0].confidence, 0.7)
|
|
156
|
+
} finally {
|
|
157
|
+
globalThis.fetch = originalFetch
|
|
158
|
+
}
|
|
159
|
+
})
|
|
@@ -0,0 +1,320 @@
|
|
|
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-credential-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
const ledger = await import('../lib/consolidation-ledger.js')
|
|
12
|
+
const receipt = await import('../lib/consolidation-receipt.js')
|
|
13
|
+
const { requestPrivateConsolidation, isConsolidating } = await import('../lib/private.js')
|
|
14
|
+
|
|
15
|
+
test.after(() => {
|
|
16
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function fence(json) {
|
|
20
|
+
const tick = String.fromCharCode(96)
|
|
21
|
+
return [tick + tick + tick + receipt.RECEIPT_TAG, JSON.stringify(json, null, 2), tick + tick + tick].join('\n')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeMemory(libraryId, id) {
|
|
25
|
+
state.updatePrivateIndex(libraryId, (index) => {
|
|
26
|
+
index.entries[id] = {
|
|
27
|
+
title: '记忆 ' + id,
|
|
28
|
+
content: '正文 ' + id,
|
|
29
|
+
layer: 'long_term',
|
|
30
|
+
status: 'active',
|
|
31
|
+
createdAt: new Date().toISOString(),
|
|
32
|
+
updatedAt: new Date().toISOString(),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 真实宿主的四件事都在这里:agents.create 的顶层 inheritedEventCount、公共 session/event
|
|
38
|
+
// 事件流、request/header 里的工具表、以及可选的 observeSession 租约。
|
|
39
|
+
function host({
|
|
40
|
+
live = true,
|
|
41
|
+
requestConfig = { provider: 'test-provider', model: 'test-model' },
|
|
42
|
+
headerTools = null,
|
|
43
|
+
workerText = '',
|
|
44
|
+
workerWrites = [],
|
|
45
|
+
whenIdle = null,
|
|
46
|
+
turnReason = { kind: 'completed' },
|
|
47
|
+
events = null,
|
|
48
|
+
observe = null,
|
|
49
|
+
emitTurnEnd = true,
|
|
50
|
+
} = {}) {
|
|
51
|
+
const rec = { created: [], handlers: [], followedUp: [], disposed: [] }
|
|
52
|
+
const transcript = events || [
|
|
53
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '记住:我不吃香菜,另外别再提那件事' }] } },
|
|
54
|
+
{ seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '记住了' }] } } },
|
|
55
|
+
{ seq: 2, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
56
|
+
...(headerTools ? [{ seq: 3, type: 'request/header', data: { header: { config: requestConfig, tools: headerTools } } }] : []),
|
|
57
|
+
]
|
|
58
|
+
const emit = (session, event) => { for (const handler of [...rec.handlers]) handler(session, event) }
|
|
59
|
+
const parentLive = live ? { id: 'parent', session: { requestHeader: () => ({ config: requestConfig, ...(headerTools ? { tools: headerTools } : {}) }) } } : undefined
|
|
60
|
+
const ctx = {
|
|
61
|
+
rec,
|
|
62
|
+
emit,
|
|
63
|
+
parentLive,
|
|
64
|
+
on: (name, handler) => {
|
|
65
|
+
if (name !== 'session/event') return () => {}
|
|
66
|
+
rec.handlers.push(handler)
|
|
67
|
+
return () => {
|
|
68
|
+
const index = rec.handlers.indexOf(handler)
|
|
69
|
+
if (index >= 0) rec.handlers.splice(index, 1)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
get: (name) => {
|
|
73
|
+
if (name === 'agents') {
|
|
74
|
+
return {
|
|
75
|
+
get: () => parentLive,
|
|
76
|
+
create: async (spec) => {
|
|
77
|
+
rec.created.push(spec)
|
|
78
|
+
const childSession = { id: spec.sessionId }
|
|
79
|
+
return {
|
|
80
|
+
agent: {
|
|
81
|
+
session: childSession,
|
|
82
|
+
followup: (message) => {
|
|
83
|
+
rec.followedUp.push(message)
|
|
84
|
+
// 子会话的 binding 是在 create 之后才写入的,这里必须现取。
|
|
85
|
+
const childBinding = state.getSessionBinding(spec.sessionId)
|
|
86
|
+
for (const id of workerWrites) writeMemory(childBinding.libraryId, id)
|
|
87
|
+
if (workerText) {
|
|
88
|
+
const text = typeof workerText === 'function' ? workerText(childBinding) : workerText
|
|
89
|
+
emit(childSession, { type: 'assistant/message', seq: 90, data: { message: { content: [{ type: 'text', text }] } } })
|
|
90
|
+
}
|
|
91
|
+
if (emitTurnEnd) emit(childSession, { type: 'turn/end', seq: 91, data: { turn: 1, reason: turnReason } })
|
|
92
|
+
},
|
|
93
|
+
whenIdle: whenIdle || (async () => {}),
|
|
94
|
+
},
|
|
95
|
+
dispose: async () => { rec.disposed.push(spec.sessionId) },
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (name === 'sessionQuery') {
|
|
101
|
+
const query = {
|
|
102
|
+
readSession: async () => ({ events: transcript, session: { cwd: path.join(home, 'proj'), agentPreset: 'standard' } }),
|
|
103
|
+
}
|
|
104
|
+
if (observe) query.observeSession = async () => observe
|
|
105
|
+
return query
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
return ctx
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// C 包第 6 条:写前重验父授权、凭证只认对账过的结果。
|
|
114
|
+
|
|
115
|
+
test('a verified receipt becomes the completion credential and advances the checked cursor', async () => {
|
|
116
|
+
const sessionId = 'cred-ok'
|
|
117
|
+
const binding = state.ensureSession(sessionId, { cwd: path.join(home, 'proj'), agentPreset: 'standard' })
|
|
118
|
+
const ctx = host({
|
|
119
|
+
workerWrites: ['mem-cred-1'],
|
|
120
|
+
workerText: (child) => fence({ jobId: child.jobId, snapshotHash: child.snapshotHash, status: 'completed', outcome: 'created', createdIds: ['mem-cred-1'], updatedIds: [], heldIds: [], checkedRange: { fromSeq: 0, throughSeq: 2 } }),
|
|
121
|
+
})
|
|
122
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
123
|
+
assert.equal(result.ok, true)
|
|
124
|
+
assert.equal(result.settled, true)
|
|
125
|
+
assert.equal(result.status, 'completed')
|
|
126
|
+
assert.equal(result.outcome, 'created')
|
|
127
|
+
assert.deepEqual(result.createdIds, ['mem-cred-1'])
|
|
128
|
+
assert.equal(result.receipt.status, 'completed')
|
|
129
|
+
assert.equal(result.credential.checked.throughSeq, 2)
|
|
130
|
+
assert.equal(result.wrote, 1)
|
|
131
|
+
assert.equal(state.getSessionBinding(sessionId).lastSleepSeq, 2)
|
|
132
|
+
assert.equal(ledger.sessionState(sessionId).checked.throughSeq, 2)
|
|
133
|
+
assert.equal(ledger.sessionState(sessionId).inflight, null, 'a finished job releases its lease')
|
|
134
|
+
assert.equal(state.getSessionBinding(ctx.rec.created[0].sessionId), null, 'disposed worker leaves no persistent child binding')
|
|
135
|
+
assert.equal(ctx.rec.followedUp.length, 1)
|
|
136
|
+
assert.equal(ctx.rec.disposed.length, 1)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('a worker that reports but gives no receipt is a failure, not a success', async () => {
|
|
140
|
+
const sessionId = 'cred-no-receipt'
|
|
141
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-nr'), agentPreset: 'standard' })
|
|
142
|
+
const ctx = host({ workerText: '我把能记的都记下来了。' })
|
|
143
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
144
|
+
assert.equal(result.ok, false)
|
|
145
|
+
assert.equal(result.settled, false)
|
|
146
|
+
assert.equal(result.reasonCode, 'receipt-missing')
|
|
147
|
+
assert.match(result.error, /receipt/)
|
|
148
|
+
assert.equal(state.getSessionBinding(sessionId).lastSleepSeq, undefined, 'without a credential the cursor must not advance')
|
|
149
|
+
const job = ledger.sessionState(sessionId)
|
|
150
|
+
assert.equal(job.checked, null)
|
|
151
|
+
assert.equal(job.pending.throughSeq, 2, 'the range stays pending for the next attempt')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('a receipt that cites a memory nobody wrote is rejected', async () => {
|
|
155
|
+
const sessionId = 'cred-ghost'
|
|
156
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-ghost'), agentPreset: 'standard' })
|
|
157
|
+
const ctx = host({
|
|
158
|
+
workerText: (child) => fence({ jobId: child.jobId, snapshotHash: child.snapshotHash, status: 'completed', outcome: 'created', createdIds: ['mem-does-not-exist'], checkedRange: { fromSeq: 0, throughSeq: 2 } }),
|
|
159
|
+
})
|
|
160
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
161
|
+
assert.equal(result.ok, false)
|
|
162
|
+
assert.equal(result.reasonCode, 'receipt-unverified')
|
|
163
|
+
assert.match(result.error, /mem-does-not-exist/)
|
|
164
|
+
assert.equal(state.getSessionBinding(sessionId).lastSleepSeq, undefined)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('no closed turn on a live session defers instead of forking', async () => {
|
|
168
|
+
const sessionId = 'cred-open-turn'
|
|
169
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-open'), agentPreset: 'standard' })
|
|
170
|
+
const ctx = host({
|
|
171
|
+
events: [
|
|
172
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这轮还没答完' }] } },
|
|
173
|
+
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
174
|
+
],
|
|
175
|
+
observe: {
|
|
176
|
+
source: 'live',
|
|
177
|
+
header: { cwd: path.join(home, 'proj-open') },
|
|
178
|
+
inheritedEventCount: 0,
|
|
179
|
+
events: [
|
|
180
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这轮还没答完' }] } },
|
|
181
|
+
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
182
|
+
],
|
|
183
|
+
cursor: 1,
|
|
184
|
+
[Symbol.dispose]: () => {},
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
188
|
+
assert.equal(result.ok, true)
|
|
189
|
+
assert.equal(result.settled, false)
|
|
190
|
+
assert.equal(result.skipped, 'no-closed-turn')
|
|
191
|
+
assert.equal(ctx.rec.created.length, 0, 'a half turn must not be seeded into a worker')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('a tool surface without memory tools is unavailable, and nothing is forked', async () => {
|
|
195
|
+
for (const [name, tools, reasonCode] of [
|
|
196
|
+
['missing', [{ name: 'read' }, { name: 'write' }], 'worker-tools-missing'],
|
|
197
|
+
]) {
|
|
198
|
+
const sessionId = 'cred-tools-' + name
|
|
199
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-tools-' + name), agentPreset: 'standard' })
|
|
200
|
+
const ctx = host({ headerTools: tools })
|
|
201
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
202
|
+
assert.equal(result.ok, false)
|
|
203
|
+
assert.equal(result.code, 'unavailable')
|
|
204
|
+
assert.equal(result.reasonCode, reasonCode)
|
|
205
|
+
assert.match(result.error, /memu_private_remember/)
|
|
206
|
+
assert.equal(ctx.rec.created.length, 0, 'no doomed fork is created')
|
|
207
|
+
assert.equal(state.getSessionBinding(sessionId).lastSleepSeq, undefined)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('a PTC-only tool surface is reported as PTC and not as a generic miss', async () => {
|
|
212
|
+
const sessionId = 'cred-tools-ptc'
|
|
213
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-ptc'), agentPreset: 'standard' })
|
|
214
|
+
const ctx = host({
|
|
215
|
+
headerTools: [{ name: 'ptc_exec' }],
|
|
216
|
+
events: [
|
|
217
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '用 PTC 跑一下' }] } },
|
|
218
|
+
{ seq: 1, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
219
|
+
{ seq: 2, type: 'tool/ptc-dispatch', data: {} },
|
|
220
|
+
{ seq: 3, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
|
|
221
|
+
],
|
|
222
|
+
})
|
|
223
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
224
|
+
assert.equal(result.ok, false)
|
|
225
|
+
assert.equal(result.reasonCode, 'worker-tools-ptc-only')
|
|
226
|
+
assert.equal(result.ptc, true)
|
|
227
|
+
assert.equal(ctx.rec.created.length, 0)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('a parent that changes its library mid-run voids the credential', async () => {
|
|
231
|
+
const sessionId = 'cred-scope-change'
|
|
232
|
+
const binding = state.ensureSession(sessionId, { cwd: path.join(home, 'proj-scope'), agentPreset: 'standard' })
|
|
233
|
+
const other = state.ensureLibraryForCwd(path.join(home, 'proj-scope-other'))
|
|
234
|
+
let release = null
|
|
235
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
236
|
+
const ctx = host({
|
|
237
|
+
whenIdle: () => gate,
|
|
238
|
+
workerWrites: ['mem-scope-1'],
|
|
239
|
+
workerText: fence({ status: 'completed', outcome: 'created', createdIds: ['mem-scope-1'], checkedRange: { fromSeq: 0, throughSeq: 2 } }),
|
|
240
|
+
})
|
|
241
|
+
const running = requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
242
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
243
|
+
assert.equal(isConsolidating(sessionId), true)
|
|
244
|
+
// 用户在整理期间把会话切到另一个库:父会话自己发的 turn/start 就是这次的信号。
|
|
245
|
+
state.setSessionBinding(sessionId, { libraryId: other.id })
|
|
246
|
+
ctx.emit(ctx.parentLive.session, { type: 'turn/start', data: { turn: 2 } })
|
|
247
|
+
release()
|
|
248
|
+
const result = await running
|
|
249
|
+
assert.equal(result.ok, false)
|
|
250
|
+
assert.equal(result.reasonCode, 'parent-scope-changed')
|
|
251
|
+
assert.equal(result.retryable, false)
|
|
252
|
+
assert.equal(state.getSessionBinding(sessionId).lastSleepSeq, undefined)
|
|
253
|
+
const job = ledger.sessionState(sessionId)
|
|
254
|
+
assert.equal(job.checked, null, 'a credential from a stale authorization is worth nothing')
|
|
255
|
+
assert.ok(binding.libraryId !== other.id)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('a lease held in the ledger blocks a second run across a restart', async () => {
|
|
259
|
+
const sessionId = 'cred-lease-held'
|
|
260
|
+
const binding = state.ensureSession(sessionId, { cwd: path.join(home, 'proj-lease'), agentPreset: 'standard' })
|
|
261
|
+
ledger.beginJob({ sessionId, jobId: 'ghost-job', snapshot: { hash: 'h', fromSeq: 0, throughSeq: 2, eventCount: 3 }, binding })
|
|
262
|
+
const ctx = host({})
|
|
263
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
264
|
+
assert.equal(result.ok, false)
|
|
265
|
+
assert.equal(result.running, true)
|
|
266
|
+
assert.equal(result.reasonCode, 'lease-held')
|
|
267
|
+
assert.equal(ctx.rec.created.length, 0)
|
|
268
|
+
assert.equal(ledger.sessionState(sessionId).inflight.jobId, 'ghost-job')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('a ledger persistence failure never creates a worker', async () => {
|
|
272
|
+
const sessionId = 'cred-ledger-unavailable'
|
|
273
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-ledger-failure'), agentPreset: 'standard' })
|
|
274
|
+
const file = ledger.ledgerPath()
|
|
275
|
+
const original = fs.existsSync(file) ? fs.readFileSync(file) : null
|
|
276
|
+
const ctx = host({})
|
|
277
|
+
try {
|
|
278
|
+
fs.writeFileSync(file, '{broken-json')
|
|
279
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
280
|
+
assert.equal(result.ok, false)
|
|
281
|
+
assert.equal(result.reasonCode, 'ledger-unavailable')
|
|
282
|
+
assert.equal(result.retryable, true)
|
|
283
|
+
assert.equal(result.settled, false)
|
|
284
|
+
assert.equal(ctx.rec.created.length, 0)
|
|
285
|
+
assert.equal(state.getSessionBinding(sessionId).lastSleepSeq, undefined)
|
|
286
|
+
} finally {
|
|
287
|
+
if (original) fs.writeFileSync(file, original)
|
|
288
|
+
else fs.rmSync(file, { force: true })
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('a timeout leaves the range pending instead of pretending it was consolidated', async () => {
|
|
293
|
+
const sessionId = 'cred-timeout'
|
|
294
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-timeout'), agentPreset: 'standard' })
|
|
295
|
+
const ctx = host({ whenIdle: () => new Promise(() => {}), emitTurnEnd: false })
|
|
296
|
+
const result = await requestPrivateConsolidation(ctx, sessionId, { force: true, maxWaitMs: 30 })
|
|
297
|
+
assert.equal(result.ok, false)
|
|
298
|
+
assert.equal(result.timedOut, true)
|
|
299
|
+
const job = ledger.sessionState(sessionId)
|
|
300
|
+
assert.equal(job.inflight, null)
|
|
301
|
+
assert.equal(job.checked, null)
|
|
302
|
+
assert.equal(job.pending.throughSeq, 2)
|
|
303
|
+
assert.equal(job.lastReasonCode, 'timeout')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('the same snapshot is not consolidated twice', async () => {
|
|
307
|
+
const sessionId = 'cred-idempotent'
|
|
308
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'proj-idem'), agentPreset: 'standard' })
|
|
309
|
+
const ctx = host({
|
|
310
|
+
workerWrites: ['mem-idem-1'],
|
|
311
|
+
workerText: (child) => fence({ jobId: child.jobId, snapshotHash: child.snapshotHash, status: 'completed', outcome: 'created', createdIds: ['mem-idem-1'], checkedRange: { fromSeq: 0, throughSeq: 2 } }),
|
|
312
|
+
})
|
|
313
|
+
const first = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
314
|
+
assert.equal(first.ok, true)
|
|
315
|
+
const second = await requestPrivateConsolidation(ctx, sessionId, { force: true })
|
|
316
|
+
assert.equal(second.ok, true)
|
|
317
|
+
assert.equal(second.skipped, 'already-consolidated')
|
|
318
|
+
assert.equal(second.settled, true)
|
|
319
|
+
assert.equal(ctx.rec.created.length, 1, 'an unchanged snapshot is not paid for twice')
|
|
320
|
+
})
|