dsh-memu-memory 1.1.3 → 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 +1 -1
- package/README.md +31 -2
- package/README.zh.md +30 -2
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +1 -1
- package/docs/emotion-model-spec.md +281 -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 +395 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +1 -1
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +33 -11
- 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/cycle.js +40 -6
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +221 -19
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +335 -40
- 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 +9 -1
- package/lib/state.js +29 -12
- package/lib/tools.js +49 -2
- package/lib/vector-profile.js +27 -0
- package/package.json +2 -1
- 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/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,393 @@
|
|
|
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
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import sharp from 'sharp'
|
|
8
|
+
|
|
9
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-inbound-'))
|
|
10
|
+
process.env.DSH_MEMU_HOME = home
|
|
11
|
+
const state = await import('../lib/state.js')
|
|
12
|
+
const inbound = await import('../lib/inbound.js')
|
|
13
|
+
const inject = await import('../lib/inject.js')
|
|
14
|
+
const memory = await import('../lib/memory.js')
|
|
15
|
+
const cycle = await import('../lib/cycle.js')
|
|
16
|
+
const budget = await import('../lib/budget.js')
|
|
17
|
+
test.after(() => { state.flushState(); fs.rmSync(home, { recursive: true, force: true }) })
|
|
18
|
+
|
|
19
|
+
function fixture(name) {
|
|
20
|
+
const cwd = path.join(home, name)
|
|
21
|
+
const binding = state.ensureSession(name, { cwd, agentPreset: 'coding' })
|
|
22
|
+
const session = { header: { id: name, cwd, agentPreset: 'coding' } }
|
|
23
|
+
const agent = { session, status: 'running' }
|
|
24
|
+
state.setSessionBinding(name, { injectedIds: ['__anchor__'] })
|
|
25
|
+
return { id: name, binding, session, agent, exec: { agent } }
|
|
26
|
+
}
|
|
27
|
+
function batch(id, text = '部署脚本在书店那台电脑上', extra = {}) {
|
|
28
|
+
return { type: inbound.INBOUND_TYPE, version: 1, producer: 'transport.example', batchId: 'batch-' + id,
|
|
29
|
+
messages: [{ id, role: 'user', content: [{ type: 'text', text }], ...extra }] }
|
|
30
|
+
}
|
|
31
|
+
function result(callId, value, meta) {
|
|
32
|
+
return { type: 'tool/result', data: { turn: 1, step: 2, message: { role: 'tool', source: { kind: 'tool', callId }, toolCallId: callId,
|
|
33
|
+
content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }] }, ...(meta ? { meta } : {}) } }
|
|
34
|
+
}
|
|
35
|
+
async function continueStep(f, step = 2, options = {}) {
|
|
36
|
+
return inject.injectForStep({ agent: f.agent, decision: { kind: 'enter', messages: [] }, turn: 1, step, ...options })
|
|
37
|
+
}
|
|
38
|
+
function textOf(decision) { return decision.messages.flatMap(message => message.content || []).map(block => block.text || '').join('\n') }
|
|
39
|
+
async function saved(f, title = '部署脚本') {
|
|
40
|
+
return memory.privateRemember({ exec: f.exec, title, content: '部署脚本放在 tools/deploy.ps1,传入 staging token。' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('a declared tool-result message batch recalls on step 2 without another user turn or model call', async () => {
|
|
44
|
+
const f = fixture('meta')
|
|
45
|
+
const entry = await saved(f)
|
|
46
|
+
inbound.observeInboundEvent({}, f.session, result('wait-call', 'opaque presentation', { memoryInbound: batch('msg-1', '部署脚本在哪里') }))
|
|
47
|
+
const out = await continueStep(f)
|
|
48
|
+
assert.ok(textOf(out).includes(`[${entry.id}]`))
|
|
49
|
+
assert.equal(out.messages[0].source.kind, 'plugin:dsh-memu-memory')
|
|
50
|
+
assert.equal(out.messages[0].source.form, 'recall')
|
|
51
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 0)
|
|
52
|
+
assert.equal(state.getSessionBinding(f.id).lastInboundOutcome.injectedCount, 1)
|
|
53
|
+
assert.ok(budget.estTokens(textOf(out)) <= state.getSettings().turnTokens)
|
|
54
|
+
assert.deepEqual((await continueStep(f, 3)).messages, [], 'no new external input means no per-step injection')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('inbound injection honors injectionTotalTokens even when turnTokens is larger', async () => {
|
|
58
|
+
const f = fixture('total-budget')
|
|
59
|
+
const previous = state.getSettings()
|
|
60
|
+
state.updateSettings({ turnTokens: 800, injectionTotalTokens: 160, anchorTokens: 800, relevanceThreshold: 0, hybridRecall: false })
|
|
61
|
+
try {
|
|
62
|
+
const entry = await memory.privateRemember({
|
|
63
|
+
exec: f.exec,
|
|
64
|
+
title: '部署脚本',
|
|
65
|
+
content: '部署脚本放在 tools/deploy.ps1。' + '这是一段很长的背景说明,用来确保召回内容远远超过总注入预算。'.repeat(40),
|
|
66
|
+
})
|
|
67
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('budget', '部署脚本在哪里') })
|
|
68
|
+
const text = textOf(await continueStep(f, 2))
|
|
69
|
+
assert.ok(text.includes('[' + entry.id + ']'), text)
|
|
70
|
+
assert.ok(budget.estTokens(text) <= 160, 'used ' + budget.estTokens(text) + ' estimated tokens')
|
|
71
|
+
} finally {
|
|
72
|
+
state.updateSettings({
|
|
73
|
+
turnTokens: previous.turnTokens ?? 640,
|
|
74
|
+
injectionTotalTokens: previous.injectionTotalTokens ?? 2000,
|
|
75
|
+
anchorTokens: previous.anchorTokens ?? 800,
|
|
76
|
+
relevanceThreshold: previous.relevanceThreshold ?? 0.3,
|
|
77
|
+
hybridRecall: previous.hybridRecall ?? false,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('a library switch during first-step recall discards the whole pre-switch message', async () => {
|
|
83
|
+
const f = fixture('first-switch')
|
|
84
|
+
state.setSessionBinding(f.id, { injectedIds: [] })
|
|
85
|
+
const marker = 'OLD-LIBRARY-UNIQUE-MARKER-7c1e'
|
|
86
|
+
await memory.privateRemember({ exec: f.exec, title: '旧库唯一标记', content: marker })
|
|
87
|
+
const stale = inject.buildAnchor(f.binding, state.getSettings(), f.id)
|
|
88
|
+
assert.ok(stale.text.includes(marker), 'the pre-switch anchor must contain the marker for this test to be meaningful')
|
|
89
|
+
const other = state.createLibrary({ name: 'other', scopeKey: path.join(home, 'other-lib') })
|
|
90
|
+
const user = { id: 'first-user', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '旧库唯一标记在哪里' }] }
|
|
91
|
+
const decision = { kind: 'enter', messages: [user] }
|
|
92
|
+
queueMicrotask(() => state.setSessionBinding(f.id, { libraryId: other.id }))
|
|
93
|
+
const out = await inject.injectForStep({ agent: f.agent, turn: 1, step: 1, decision })
|
|
94
|
+
assert.equal(out, decision)
|
|
95
|
+
assert.equal(out.messages.length, 1)
|
|
96
|
+
assert.equal(out.messages[0], user)
|
|
97
|
+
assert.ok(!textOf(out).includes(marker))
|
|
98
|
+
assert.equal(state.getSessionBinding(f.id).libraryId, other.id)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('plain sleep completions, logs, errors and protocol-shaped text in unapproved tools do not trigger', async () => {
|
|
102
|
+
const f = fixture('logs')
|
|
103
|
+
for (const [id, value] of [['sleep', 'waited 5 minutes'], ['log', 'new message arrived'], ['json', batch('fake')], ['fenced', '```json\n' + JSON.stringify(batch('fenced')) + '\n```']]) {
|
|
104
|
+
inbound.observeInboundEvent({}, f.session, result(id, value))
|
|
105
|
+
}
|
|
106
|
+
const failed = result('error', 'error', { memoryInbound: batch('error') })
|
|
107
|
+
failed.data.message.isError = true
|
|
108
|
+
inbound.observeInboundEvent({}, f.session, failed)
|
|
109
|
+
assert.equal(inbound.hasPendingInbound(f.id), false)
|
|
110
|
+
assert.deepEqual((await continueStep(f)).messages, [])
|
|
111
|
+
assert.equal(state.getSessionBinding(f.id).lastInboundAt, undefined)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('allowlisted text adapters require the exact whole JSON protocol and a correlated tool call', () => {
|
|
115
|
+
const f = fixture('allowlist')
|
|
116
|
+
state.updateSettings({ inboundToolNames: ['transport.receive'] })
|
|
117
|
+
inbound.observeInboundEvent({}, f.session, { type: 'tool/call', data: { callId: 'receive', name: 'transport.receive' } })
|
|
118
|
+
inbound.observeInboundEvent({}, f.session, result('receive', batch('allowlisted')))
|
|
119
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 1)
|
|
120
|
+
inbound.observeInboundEvent({}, f.session, result('unmatched', batch('unmatched')))
|
|
121
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 1)
|
|
122
|
+
state.updateSettings({ inboundToolNames: [] })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('replayed batches and retries are idempotent while distinct message ids in one turn are independent', async () => {
|
|
126
|
+
const f = fixture('dedupe')
|
|
127
|
+
await saved(f)
|
|
128
|
+
const first = batch('same', '部署脚本在哪里')
|
|
129
|
+
assert.equal(inbound.ingestInboundBatch({ sessionId: f.id, batch: first }).accepted, 1)
|
|
130
|
+
assert.equal(inbound.ingestInboundBatch({ sessionId: f.id, batch: first }).accepted, 0)
|
|
131
|
+
await continueStep(f)
|
|
132
|
+
assert.equal(inbound.ingestInboundBatch({ sessionId: f.id, batch: { ...first, batchId: 'different-batch-id' } }).accepted, 0)
|
|
133
|
+
assert.equal(inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('second', '部署脚本在哪里') }).accepted, 1)
|
|
134
|
+
await continueStep(f, 3)
|
|
135
|
+
assert.equal(state.getSessionBinding(f.id).inbound.clock, 2)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('dedupe expires by logical message batches even when the physical turn stays 1', async () => {
|
|
139
|
+
const f = fixture('clock')
|
|
140
|
+
const entry = await saved(f)
|
|
141
|
+
state.updateSettings({ dedupeTurns: 2, globalEveryNTurns: 2 })
|
|
142
|
+
try {
|
|
143
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('a', '部署脚本在哪里') })
|
|
144
|
+
assert.ok(textOf(await continueStep(f, 2)).includes(entry.id))
|
|
145
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('b', '部署脚本在哪里') })
|
|
146
|
+
assert.ok(!textOf(await continueStep(f, 3)).includes(entry.id))
|
|
147
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('c', '部署脚本在哪里') })
|
|
148
|
+
assert.ok(textOf(await continueStep(f, 4)).includes(entry.id))
|
|
149
|
+
} finally { state.updateSettings({ dedupeTurns: 16, globalEveryNTurns: 6 }) }
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('real user inbox messages admitted on a later step work without a tool adapter', async () => {
|
|
153
|
+
const f = fixture('native')
|
|
154
|
+
const entry = await saved(f)
|
|
155
|
+
const user = { id: 'late-user', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '部署脚本在哪里' }] }
|
|
156
|
+
const decision = { kind: 'enter', messages: [user] }
|
|
157
|
+
const out = await inject.injectForStep({ agent: f.agent, turn: 1, step: 7, decision })
|
|
158
|
+
assert.equal(out.messages[0], user, 'do not rewrite or disguise the producer message')
|
|
159
|
+
assert.ok(textOf(out).includes(entry.id))
|
|
160
|
+
const again = await inject.injectForStep({ agent: f.agent, turn: 1, step: 8, decision })
|
|
161
|
+
assert.equal(again, decision)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('normal first-step input is acknowledged and not replayed as an external batch', async () => {
|
|
165
|
+
const f = fixture('first')
|
|
166
|
+
const user = { id: 'normal-user', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '部署脚本在哪里' }] }
|
|
167
|
+
inbound.observeInboundEvent({}, f.session, { seq: 1, type: 'user/message', data: user })
|
|
168
|
+
await inject.injectForStep({ agent: f.agent, turn: 1, step: 1, decision: { kind: 'enter', messages: [user] } })
|
|
169
|
+
assert.equal(inbound.hasPendingInbound(f.id), false)
|
|
170
|
+
assert.deepEqual((await continueStep(f)).messages, [])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('continuation image references are saved and disclosed before the next model step without fabricating a user event', async () => {
|
|
174
|
+
const f = fixture('image')
|
|
175
|
+
const data = await sharp({ create: { width: 16, height: 16, channels: 3, background: '#48996e' } }).png().toBuffer()
|
|
176
|
+
const sha = crypto.createHash('sha256').update(data).digest('hex')
|
|
177
|
+
const ref = { attachmentId: 'opaque-host-id', mediaType: 'image/png', bytes: data.length, width: 16, height: 16 }
|
|
178
|
+
let reads = 0
|
|
179
|
+
const ctx = { get: name => name === 'attachments' ? { readImage: async () => { reads++; return { data, ref } } } : undefined }
|
|
180
|
+
const input = batch('photo', '这张图是绿色色卡', { content: [{ type: 'text', text: '这张图是绿色色卡' }, { type: 'image', attachment: ref }] })
|
|
181
|
+
inbound.ingestInboundBatch({ sessionId: f.id, session: f.session, ctx, batch: input })
|
|
182
|
+
const out = await continueStep(f, 4, { ctx })
|
|
183
|
+
assert.ok(fs.existsSync(path.join(home, 'attachments', f.binding.libraryId, sha + '.png')))
|
|
184
|
+
assert.ok(textOf(out).includes(sha))
|
|
185
|
+
assert.ok(textOf(out).includes('</memory-media>'))
|
|
186
|
+
await continueStep(f, 5, { ctx })
|
|
187
|
+
assert.equal(reads, 1, 'pending-media capture and pre-step draining share the same event id')
|
|
188
|
+
const note = await memory.privateRemember({ exec: f.exec, title: '色卡', content: '收到绿色色卡。', attachments: [{ sha256: sha }] })
|
|
189
|
+
assert.equal(note.entry.attachments[0].missing, false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('channel/world boundaries cannot be widened and assistant/log records cannot be declared as humans', () => {
|
|
193
|
+
const f = fixture('scope')
|
|
194
|
+
state.setSessionBinding(f.id, { memoryContext: { channelId: 'room-a', worldId: 'story-a' } })
|
|
195
|
+
assert.throws(() => inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('wrong', 'text', { context: { channelId: 'room-b' } }) }), /outside/)
|
|
196
|
+
assert.throws(() => inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('assistant', 'text', { role: 'assistant' }) }), /human/)
|
|
197
|
+
assert.equal(inbound.hasPendingInbound(f.id), false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('same message ids from distinct explicit speakers are not collapsed', () => {
|
|
201
|
+
const f = fixture('speakers')
|
|
202
|
+
const input = batch('same-id')
|
|
203
|
+
input.messages = ['a', 'b'].map(speakerId => ({ ...input.messages[0], context: { speakerId } }))
|
|
204
|
+
assert.equal(inbound.ingestInboundBatch({ sessionId: f.id, batch: input }).accepted, 2)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('queues are bounded and overflow does not silently discard messages', () => {
|
|
208
|
+
const f = fixture('bounded')
|
|
209
|
+
const input = batch('q')
|
|
210
|
+
input.messages = Array.from({ length: 32 }, (_, i) => ({ ...input.messages[0], id: 'id-' + i }))
|
|
211
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: input })
|
|
212
|
+
assert.throws(() => inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('overflow') }), /queue is full/)
|
|
213
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 32)
|
|
214
|
+
assert.throws(() => inbound.normalizeInboundBatch(batch('long', 'x'.repeat(4001))), /4000/)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('disabled memory does not capture image bytes or inject on continuation steps', async () => {
|
|
218
|
+
const f = fixture('disabled')
|
|
219
|
+
state.setSessionBinding(f.id, { useLibrary: false, useGlobal: false })
|
|
220
|
+
let reads = 0
|
|
221
|
+
const ctx = { get: () => ({ readImage: () => { reads++; throw new Error('must not read') } }) }
|
|
222
|
+
inbound.ingestInboundBatch({ sessionId: f.id, ctx, batch: batch('off', '', { content: [{ type: 'image', attachment: {
|
|
223
|
+
attachmentId: 'disabled-image', mediaType: 'image/png', bytes: 10, width: 8, height: 8,
|
|
224
|
+
} }] }) })
|
|
225
|
+
assert.deepEqual((await continueStep(f, 2, { ctx })).messages, [])
|
|
226
|
+
assert.equal(reads, 0)
|
|
227
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 0)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('retrieval errors stay fail-open, preserve the batch and use a retry delay', async () => {
|
|
231
|
+
const f = fixture('retry')
|
|
232
|
+
state.updateSettings({ memoryBackend: 'api' })
|
|
233
|
+
try {
|
|
234
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('retry', '部署脚本在哪里') })
|
|
235
|
+
assert.deepEqual((await continueStep(f)).messages, [])
|
|
236
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 1)
|
|
237
|
+
assert.ok(state.getSessionBinding(f.id).lastRecallError)
|
|
238
|
+
assert.equal(inbound.hasPendingInbound(f.id), false)
|
|
239
|
+
state.updateSettings({ memoryBackend: 'agent' })
|
|
240
|
+
const current = state.getSessionBinding(f.id)
|
|
241
|
+
state.setSessionBinding(f.id, { inbound: { ...current.inbound, retryAt: null } })
|
|
242
|
+
await saved(f)
|
|
243
|
+
const out = await continueStep(f, 3)
|
|
244
|
+
assert.ok(textOf(out).includes('tools/deploy.ps1'))
|
|
245
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 0)
|
|
246
|
+
} finally { state.updateSettings({ memoryBackend: 'agent' }) }
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('rejected steps, cancelled requests and subagents leave queued input untouched', async () => {
|
|
250
|
+
const f = fixture('admission')
|
|
251
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('pending') })
|
|
252
|
+
const rejected = { kind: 'reject' }
|
|
253
|
+
assert.equal(await inject.injectForStep({ agent: f.agent, decision: rejected, step: 2 }), rejected)
|
|
254
|
+
assert.deepEqual((await continueStep(f, 2, { signal: { aborted: true } })).messages, [])
|
|
255
|
+
const child = { session: { header: { ...f.session.header, origin: 'subagent' } } }
|
|
256
|
+
assert.deepEqual((await inject.injectForStep({ agent: child, decision: { kind: 'enter', messages: [] }, step: 2 })).messages, [])
|
|
257
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 1)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('a long-running tool prevents sleep consolidation even after the idle threshold', async () => {
|
|
261
|
+
const f = fixture('waiting')
|
|
262
|
+
state.updateSettings({ sleepConsolidation: true, sleepAfterIdleMinutes: 8 })
|
|
263
|
+
state.setSessionBinding(f.id, { lastTurnAt: new Date(Date.now() - 60 * 60_000).toISOString() })
|
|
264
|
+
const out = await cycle.runSleepPass({ get: name => name === 'agents' ? { get: id => id === f.id ? f.agent : null } : null })
|
|
265
|
+
assert.equal(out.ran, 0)
|
|
266
|
+
assert.equal(state.getSessionBinding(f.id).lastSleepAt, undefined)
|
|
267
|
+
assert.equal(state.getSessionBinding(f.id).sleepAttempts, undefined, 'must not try to fork while the parent is running')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('real inbound activity refreshes the idle clock, but empty polls and replay do not', () => {
|
|
271
|
+
const f = fixture('activity')
|
|
272
|
+
const at = Date.now() - 60_000
|
|
273
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('active'), now: at })
|
|
274
|
+
const stamp = state.getSessionBinding(f.id).lastTurnAt
|
|
275
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: { ...batch('empty'), messages: [] }, now: at + 1000 })
|
|
276
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('active'), now: at + 2000 })
|
|
277
|
+
assert.equal(state.getSessionBinding(f.id).lastTurnAt, stamp)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('different producers may reuse an external message id without losing either message', () => {
|
|
281
|
+
const f = fixture('producers')
|
|
282
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('shared-id') })
|
|
283
|
+
assert.equal(inbound.ingestInboundBatch({ sessionId: f.id, batch: { ...batch('shared-id'), producer: 'other.transport' } }).accepted, 1)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('messages received during media draining stay queued for the next admission', async () => {
|
|
287
|
+
const f = fixture('concurrent')
|
|
288
|
+
const data = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#48996e' } }).png().toBuffer()
|
|
289
|
+
let release
|
|
290
|
+
const gate = new Promise(resolve => { release = resolve })
|
|
291
|
+
const ref = { attachmentId: 'concurrent-image', mediaType: 'image/png', bytes: data.length, width: 8, height: 8 }
|
|
292
|
+
const ctx = { get: () => ({ readImage: async () => { await gate; return { data, ref } } }) }
|
|
293
|
+
inbound.ingestInboundBatch({ sessionId: f.id, session: f.session, ctx, batch: batch('first-image', '', { content: [{ type: 'image', attachment: ref }] }) })
|
|
294
|
+
const first = continueStep(f, 2, { ctx })
|
|
295
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('later-text') })
|
|
296
|
+
release()
|
|
297
|
+
await first
|
|
298
|
+
assert.deepEqual(state.getSessionBinding(f.id).inbound.pending.map(item => item.id), ['later-text'])
|
|
299
|
+
await continueStep(f, 3, { ctx })
|
|
300
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 0)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('restored pending batches drain on a first step even with no native user message', async () => {
|
|
304
|
+
const f = fixture('restore')
|
|
305
|
+
const entry = await saved(f)
|
|
306
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('restored', '部署脚本在哪里') })
|
|
307
|
+
state.flushState()
|
|
308
|
+
state.clearCaches()
|
|
309
|
+
const out = await continueStep(f, 1)
|
|
310
|
+
assert.ok(textOf(out).includes(entry.id))
|
|
311
|
+
assert.equal(state.getSessionBinding(f.id).inbound.pending.length, 0)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('global cadence advances by external batches and not by tool-loop steps', async () => {
|
|
315
|
+
const f = fixture('global-clock')
|
|
316
|
+
state.setSessionBinding(f.id, { useLibrary: false, domainOverride: 'coding' })
|
|
317
|
+
state.updateSettings({ globalEveryNTurns: 2 })
|
|
318
|
+
state.saveGlobalIndex({ entries: {
|
|
319
|
+
'gmem-transport': { title: 'Unique Neptune Global', content: 'Neptune build convention', domain: 'coding', status: 'active' },
|
|
320
|
+
} })
|
|
321
|
+
try {
|
|
322
|
+
for (const [id, query, step] of [['g1', 'unrelated ordinary words', 2], ['g2', 'Unique Neptune Global', 3]]) {
|
|
323
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch(id, query) })
|
|
324
|
+
const out = await continueStep(f, step)
|
|
325
|
+
if (step === 3) assert.ok(textOf(out).includes('gmem-transport'))
|
|
326
|
+
}
|
|
327
|
+
assert.equal(state.getSessionBinding(f.id).inbound.clock, 2)
|
|
328
|
+
await continueStep(f, 4)
|
|
329
|
+
assert.equal(state.getSessionBinding(f.id).inbound.clock, 2)
|
|
330
|
+
} finally {
|
|
331
|
+
state.saveGlobalIndex({ entries: {} })
|
|
332
|
+
state.updateSettings({ globalEveryNTurns: 6 })
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('public event hooks recognize declared result metadata and record idle transitions', async () => {
|
|
337
|
+
const f = fixture('hooks')
|
|
338
|
+
const entry = await saved(f)
|
|
339
|
+
const handlers = new Map()
|
|
340
|
+
const ctx = { on: (name, handler) => { handlers.set(name, handler); return () => {} }, logger: { warn() {} }, inject() {} }
|
|
341
|
+
const dispose = inject.registerInjection(ctx)
|
|
342
|
+
try {
|
|
343
|
+
await handlers.get('session/event')(f.session, result('hook-tool', 'result', { memoryInbound: batch('hook-message', '部署脚本在哪里') }))
|
|
344
|
+
const decision = { kind: 'enter', messages: [] }
|
|
345
|
+
const out = await handlers.get('agent/pre-step')({ agent: f.agent, turn: 1, step: 2 }, async () => decision)
|
|
346
|
+
assert.ok(textOf(out).includes(entry.id))
|
|
347
|
+
handlers.get('agent/status')({ agent: f.agent, status: 'idle' })
|
|
348
|
+
assert.ok(state.getSessionBinding(f.id).lastAgentIdleAt)
|
|
349
|
+
await handlers.get('session/event')(f.session, result('bad-tool', 'bad', { memoryInbound: { type: inbound.INBOUND_TYPE, version: 1 } }))
|
|
350
|
+
assert.ok(state.getSessionBinding(f.id).lastInboundError, 'a malformed producer contract is visible but does not crash the host')
|
|
351
|
+
} finally { dispose() }
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('sidebar snapshots never ship pending human text or the dedupe ledger', async () => {
|
|
355
|
+
const f = fixture('sidebar-projection')
|
|
356
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
357
|
+
const phrase = 'pending human body must not ship in a sidebar snapshot'
|
|
358
|
+
inbound.ingestInboundBatch({ sessionId: f.id, batch: batch('private-pending', phrase) })
|
|
359
|
+
const result = await dispatch({}, 'getSidebarState', { sessionId: f.id })
|
|
360
|
+
assert.equal(result.binding.inbound.pendingCount, 1)
|
|
361
|
+
assert.equal(result.binding.inbound.pending, undefined)
|
|
362
|
+
assert.equal(result.binding.inbound.seen, undefined)
|
|
363
|
+
assert.ok(!JSON.stringify(result).includes(phrase))
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('the UI RPC persists the tool allowlist and rejects invalid names', async () => {
|
|
367
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
368
|
+
try {
|
|
369
|
+
await dispatch({}, 'setSettings', { patch: { inboundToolNames: ['transport.receive', 'notifications.poll'] } })
|
|
370
|
+
state.flushState()
|
|
371
|
+
state.clearCaches()
|
|
372
|
+
assert.deepEqual(state.getSettings().inboundToolNames, ['transport.receive', 'notifications.poll'])
|
|
373
|
+
await assert.rejects(dispatch({}, 'setSettings', { patch: { inboundToolNames: ['bad tool name'] } }), /invalid inbound tool/)
|
|
374
|
+
} finally { state.updateSettings({ inboundToolNames: [] }) }
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('switching libraries while image collection waits cannot publish old-batch memory in the new library', async () => {
|
|
378
|
+
const f = fixture('switch-library')
|
|
379
|
+
const data = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#48996e' } }).png().toBuffer()
|
|
380
|
+
let release
|
|
381
|
+
const gate = new Promise(resolve => { release = resolve })
|
|
382
|
+
const ref = { attachmentId: 'old-library-image', mediaType: 'image/png', bytes: data.length, width: 8, height: 8 }
|
|
383
|
+
const ctx = { get: () => ({ readImage: async () => { await gate; return { data, ref } } }) }
|
|
384
|
+
inbound.ingestInboundBatch({ sessionId: f.id, session: f.session, ctx, batch: batch('old-image', '', { content: [{ type: 'image', attachment: ref }] }) })
|
|
385
|
+
const collecting = continueStep(f, 2, { ctx })
|
|
386
|
+
const other = state.createLibrary({ name: 'other', cwd: path.join(home, 'other-library') })
|
|
387
|
+
state.setSessionBinding(f.id, { libraryId: other.id })
|
|
388
|
+
release()
|
|
389
|
+
assert.deepEqual((await collecting).messages, [])
|
|
390
|
+
assert.ok(!(state.getSessionBinding(f.id).pendingMedia || []).some(item => item.sourceAttachmentId === ref.attachmentId))
|
|
391
|
+
assert.deepEqual((await continueStep(f, 3, { ctx })).messages, [])
|
|
392
|
+
assert.equal(state.getSessionBinding(f.id).lastInboundOutcome.reason, 'scope')
|
|
393
|
+
})
|