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.
Files changed (184) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +39 -6
  3. package/README.zh.md +39 -7
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +3 -3
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +8 -0
  9. package/docs/host-api.md +2 -2
  10. package/docs/memory-semantics.md +12 -10
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +396 -395
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +23 -6
  15. package/lib/binding-diagnostics.js +8 -3
  16. package/lib/client.js +335 -35
  17. package/lib/consolidation-ledger.js +347 -0
  18. package/lib/consolidation-receipt.js +284 -0
  19. package/lib/consolidation-snapshot.js +274 -0
  20. package/lib/consolidation-worker.js +144 -0
  21. package/lib/curator.js +86 -31
  22. package/lib/cycle.js +40 -6
  23. package/lib/graph.js +139 -5
  24. package/lib/index.js +33 -1
  25. package/lib/inject.js +104 -76
  26. package/lib/local-embedding-manager.js +24 -6
  27. package/lib/memory.js +534 -88
  28. package/lib/mood.js +268 -39
  29. package/lib/preset-scope.js +1 -1
  30. package/lib/private.js +430 -43
  31. package/lib/prompts/private-consolidation.coding.md +1 -0
  32. package/lib/prompts/private-consolidation.md +1 -0
  33. package/lib/prompts/private-consolidation.mixed.md +1 -0
  34. package/lib/prompts/private-consolidation.rp.md +1 -0
  35. package/lib/prompts.js +4 -4
  36. package/lib/remote-summary.js +51 -25
  37. package/lib/rpc.js +54 -5
  38. package/lib/state.js +29 -12
  39. package/lib/text-similarity.js +106 -0
  40. package/lib/tools.js +113 -10
  41. package/lib/vector-migration.js +28 -3
  42. package/lib/vector-profile.js +51 -0
  43. package/package.json +4 -2
  44. package/scripts/acceptance-local.mjs +2 -1
  45. package/scripts/active-memory-ab-live.mjs +655 -0
  46. package/scripts/active-memory-ab.mjs +1302 -0
  47. package/scripts/chain-matrix.mjs +11 -1
  48. package/scripts/check-all.mjs +10 -1
  49. package/scripts/replay.mjs +573 -0
  50. package/tests/activation.test.mjs +89 -0
  51. package/tests/active-memory-ab.test.mjs +745 -0
  52. package/tests/active-memory.test.mjs +129 -0
  53. package/tests/activity-log.test.mjs +126 -0
  54. package/tests/activity-wiring.test.mjs +103 -0
  55. package/tests/appendix-b.test.mjs +216 -0
  56. package/tests/appendix-b2.test.mjs +166 -0
  57. package/tests/audit-regression.test.mjs +272 -0
  58. package/tests/audit-safety.test.mjs +227 -0
  59. package/tests/backend-compensation.test.mjs +272 -0
  60. package/tests/backup.test.mjs +793 -0
  61. package/tests/binding-diagnostics.test.mjs +720 -0
  62. package/tests/browser.test.mjs +366 -0
  63. package/tests/client-render.test.mjs +892 -0
  64. package/tests/coalesce.test.mjs +113 -0
  65. package/tests/config.test.mjs +159 -0
  66. package/tests/consolidation-credential.test.mjs +331 -0
  67. package/tests/consolidation-feed.test.mjs +96 -0
  68. package/tests/consolidation-fork.test.mjs +489 -0
  69. package/tests/consolidation-ledger.test.mjs +298 -0
  70. package/tests/consolidation-snapshot.test.mjs +360 -0
  71. package/tests/consolidation-value.test.mjs +89 -0
  72. package/tests/context-scope.test.mjs +40 -0
  73. package/tests/cue.test.mjs +44 -0
  74. package/tests/curator-commit.test.mjs +277 -0
  75. package/tests/curator-probe.test.mjs +433 -0
  76. package/tests/curator.test.mjs +150 -0
  77. package/tests/cycle.test.mjs +411 -0
  78. package/tests/disclosure.test.mjs +231 -0
  79. package/tests/doc-drift.test.mjs +222 -0
  80. package/tests/embedding-catalog.test.mjs +266 -0
  81. package/tests/embedding-download.test.mjs +391 -0
  82. package/tests/embedding-runtime.test.mjs +389 -0
  83. package/tests/emotion-chain.test.mjs +84 -0
  84. package/tests/emotion-contract.test.mjs +445 -0
  85. package/tests/engine.test.mjs +35 -0
  86. package/tests/fixtures/active-memory-ab/README.md +56 -0
  87. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  88. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  89. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  90. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  92. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  93. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  94. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  95. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  96. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  97. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  98. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  99. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  100. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  101. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  102. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  103. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  104. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  105. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  106. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  107. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  108. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  109. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  110. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  111. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  112. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  113. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  114. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  115. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  116. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  117. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  118. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  119. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  120. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  121. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  122. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  123. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  124. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  125. package/tests/graph-cascade.test.mjs +190 -0
  126. package/tests/graph-view.test.mjs +187 -0
  127. package/tests/graph.test.mjs +94 -0
  128. package/tests/hook-order.test.mjs +72 -0
  129. package/tests/humanlike.test.mjs +232 -0
  130. package/tests/hybrid-recall.test.mjs +122 -0
  131. package/tests/inbound.test.mjs +393 -0
  132. package/tests/inject-guide.test.mjs +364 -0
  133. package/tests/inject.test.mjs +222 -0
  134. package/tests/injection-budget-settings.test.mjs +35 -0
  135. package/tests/injection-budget.test.mjs +64 -0
  136. package/tests/injection-chain.test.mjs +191 -0
  137. package/tests/loader-register.mjs +3 -0
  138. package/tests/loader.mjs +12 -0
  139. package/tests/local-embedding-entry.test.mjs +95 -0
  140. package/tests/local-embedding-manager.test.mjs +476 -0
  141. package/tests/local-embedding-protocol.test.mjs +95 -0
  142. package/tests/local-embedding-worker.test.mjs +346 -0
  143. package/tests/managed-memory.test.mjs +591 -0
  144. package/tests/managed-runtime.test.mjs +40 -0
  145. package/tests/media.test.mjs +132 -0
  146. package/tests/migration-payload.test.mjs +42 -0
  147. package/tests/mood.test.mjs +109 -0
  148. package/tests/multiprocess.test.mjs +29 -0
  149. package/tests/new-session-gate.test.mjs +361 -0
  150. package/tests/package-shipping.test.mjs +84 -0
  151. package/tests/preset-scope.test.mjs +212 -0
  152. package/tests/prompts.test.mjs +250 -0
  153. package/tests/react-snapshot.test.mjs +23 -0
  154. package/tests/recall-bench.test.mjs +306 -0
  155. package/tests/recall-dedup.test.mjs +417 -0
  156. package/tests/remote.test.mjs +140 -0
  157. package/tests/rename.test.mjs +30 -0
  158. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  159. package/tests/repetition-eval-live.test.mjs +177 -0
  160. package/tests/repetition-eval.test.mjs +373 -0
  161. package/tests/salient-date.test.mjs +142 -0
  162. package/tests/settings-beat.test.mjs +221 -0
  163. package/tests/short-term.test.mjs +166 -0
  164. package/tests/sidebar-beat.test.mjs +212 -0
  165. package/tests/sidebar-refresh.test.mjs +123 -0
  166. package/tests/sidebar.test.mjs +70 -0
  167. package/tests/snippet.test.mjs +47 -0
  168. package/tests/source-kind.test.mjs +52 -0
  169. package/tests/state.test.mjs +150 -0
  170. package/tests/stats.test.mjs +112 -0
  171. package/tests/storage.test.mjs +125 -0
  172. package/tests/stubs/dsh-llm.js +5 -0
  173. package/tests/stubs/dsh-session.js +7 -0
  174. package/tests/stubs/dsh-tools.js +101 -0
  175. package/tests/tool-schema.test.mjs +73 -0
  176. package/tests/tools.test.mjs +184 -0
  177. package/tests/ui-defaults.test.mjs +57 -0
  178. package/tests/update-fields.test.mjs +138 -0
  179. package/tests/vector-migration.test.mjs +790 -0
  180. package/tests/vector-profile.test.mjs +425 -0
  181. package/tests/visibility-migration.test.mjs +152 -0
  182. package/tests/webui-auth.test.mjs +454 -0
  183. package/tests/webui.test.mjs +165 -0
  184. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,96 @@
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-consolidation-feed-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+ process.env.DSH_MEMU_MEMORY_HOME = home
10
+
11
+ const state = await import('../lib/state.js')
12
+ const { record, resetActivityLogCache, listEvents, clearEvents } = await import('../lib/activity-log.js')
13
+ const { dispatch } = await import('../lib/rpc.js')
14
+
15
+ test.after(() => {
16
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
17
+ })
18
+
19
+ // 侧边栏"整理提醒"与日志页的数据链路:跨会话取整理事件,服务端把会话标题与文件夹名
20
+ // 拼进每一行(客户端只回退到裸 id)。标题走宿主 readTitleSnapshots(批量一次)。
21
+ const ctx = {
22
+ get: (name) => {
23
+ if (name === 'sessionQuery') {
24
+ return {
25
+ readTitleSnapshots: async (ids) => ids.map((id) => ({
26
+ sessionId: id,
27
+ status: id === 'session-titled' ? 'fulfilled' : 'rejected',
28
+ ...(id === 'session-titled' ? { value: { title: { title: ' princess 的深夜备份会话 ' } } } : {}),
29
+ })),
30
+ }
31
+ }
32
+ return undefined
33
+ },
34
+ }
35
+
36
+ test('getConsolidationFeed splits failures and successes, decorates titles, caps each column', async () => {
37
+ const library = state.createLibrary({ cwd: path.join(home, 'feed-proj'), name: '夜备份工作区' })
38
+ resetActivityLogCache()
39
+ // 8 条失败 + 8 条成功,交错落盘;另有别的 kind 的失败(不得混进来)。
40
+ for (let index = 0; index < 8; index += 1) {
41
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: index === 7 ? 'session-titled' : 'session-plain-' + index, libraryId: library.id, message: '整理失败 · 空闲 · case-' + index, detail: { reasonCode: 'fork-turn-failed' } })
42
+ record({ kind: 'consolidate', scope: 'private', ok: true, sessionId: 'session-titled', libraryId: library.id, message: '整理完成 · 空闲', detail: { count: index } })
43
+ }
44
+ record({ kind: 'recall', scope: 'private', ok: false, sessionId: 'session-titled', message: '召回失败不进整理提醒' })
45
+
46
+ const feed = await dispatch(ctx, 'getConsolidationFeed', { limit: 6 })
47
+ assert.equal(feed.failures.length, 6, 'failures capped at the requested column size')
48
+ assert.equal(feed.successes.length, 6, 'successes capped at the requested column size')
49
+ assert.ok(feed.failures.every(row => row.ok === false), 'the failure column holds only failures')
50
+ assert.ok(feed.successes.every(row => row.ok !== false), 'the success column holds only successes')
51
+ // 最新在前:失败列第一条是最后落盘的那条失败。
52
+ assert.match(feed.failures[0].message, /case-7/)
53
+ // 会话标题 + 文件夹名由服务端拼好;查不到标题的行回退 null(客户端用短 id)。
54
+ assert.equal(feed.failures[0].sessionTitle, 'princess 的深夜备份会话')
55
+ assert.equal(feed.failures[0].libraryName, '夜备份工作区')
56
+ const plain = feed.failures.find(row => row.sessionId === 'session-plain-6')
57
+ assert.equal(plain.sessionTitle, null)
58
+ // 行已瘦身:回执 detail 不整包透传,只留提醒需要的字段。
59
+ assert.equal(feed.failures[0].reasonCode, 'fork-turn-failed')
60
+ assert.equal(feed.failures[0].detail, undefined)
61
+ })
62
+
63
+ test('getActivityLog decorates consolidation rows with session title and library name', async () => {
64
+ const library = state.createLibrary({ cwd: path.join(home, 'feed-proj-2'), name: '第二个工作区' })
65
+ resetActivityLogCache()
66
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 空闲 · worker-tools-ptc-only', detail: { reasonCode: 'worker-tools-ptc-only', toolSurface: ['run_code'] } })
67
+
68
+ const data = await dispatch(ctx, 'getActivityLog', { kind: 'consolidate', limit: 10 })
69
+ const row = data.events.find(item => item.message.includes('worker-tools-ptc-only'))
70
+ assert.ok(row, 'the consolidation row is returned')
71
+ assert.equal(row.sessionTitle, 'princess 的深夜备份会话')
72
+ assert.equal(row.libraryName, '第二个工作区')
73
+ })
74
+
75
+ test('clearConsolidationFailures deletes only failed consolidate rows, everything else stays', async () => {
76
+ const library = state.createLibrary({ cwd: path.join(home, 'feed-clear'), name: '清除测试区' })
77
+ resetActivityLogCache()
78
+ // 同一个临时 home 累积了前面用例的日志:先全清,种进去的才是本用例的全部世界。
79
+ clearEvents()
80
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 其一', detail: { reasonCode: 'timeout' } })
81
+ record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 其二', detail: { reasonCode: 'timeout' } })
82
+ record({ kind: 'consolidate', scope: 'private', ok: true, sessionId: 'session-titled', libraryId: library.id, message: '整理完成' })
83
+ record({ kind: 'recall', scope: 'private', ok: false, sessionId: 'session-titled', message: '召回失败不是整理失败' })
84
+
85
+ const result = await dispatch(ctx, 'clearConsolidationFailures', { limit: 6 })
86
+ assert.equal(result.cleared, 2, 'exactly the two failed consolidate rows are removed')
87
+ assert.equal((result.feed.failures || []).length, 0, 'the failure column comes back empty')
88
+ assert.equal((result.feed.successes || []).length, 1, 'the success flow survives')
89
+ // 其它 kind 的失败行与成功行都不受影响。
90
+ const kinds = listEvents({ limit: 50 })
91
+ assert.ok(kinds.some(r => r.kind === 'recall' && r.ok === false), 'non-consolidate failures stay')
92
+ assert.ok(kinds.some(r => r.kind === 'consolidate' && r.ok !== false), 'consolidate successes stay')
93
+ // 落盘一致:重读缓存后计数不变(rewrite 是权威,不是只在内存里删)。
94
+ resetActivityLogCache()
95
+ assert.equal(listEvents({ kind: 'consolidate', level: 'error', limit: 50 }).length, 0, 'the rewrite reached disk')
96
+ })
@@ -0,0 +1,489 @@
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-fork-'))
8
+ process.env.DSH_MEMU_HOME = home
9
+
10
+ const state = await import('../lib/state.js')
11
+ const activity = await import('../lib/activity-log.js')
12
+ const { requestPrivateConsolidation, isConsolidating } = await import('../lib/private.js')
13
+
14
+ test.after(() => {
15
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
16
+ })
17
+
18
+ // The manual path (the sidebar button, RPC generatePrivate) shares one entry point
19
+ // with the idle sweep. Both now fork; neither may queue anything on the live agent,
20
+ // because a queued followup is what the host renders as a pending draft above the
21
+ // user's composer and what ends up as a permanent housekeeping turn in their chat.
22
+ // The host contract this plugin must satisfy, mirrored from the runtime:
23
+ // packages/core/session SessionStore.prepare() rebuilds a session header from a
24
+ // fixed meta whitelist and `Session` then throws "seeded session requires an
25
+ // inherited event count" when the count was not passed as a TOP-LEVEL option.
26
+ // The host drops an unknown meta key silently; this fake refuses it loudly, so a
27
+ // plugin that misplaces the field fails the test instead of dying in production.
28
+ const META_KEYS = ['cwd', 'parentSession', 'isSeeded', 'origin', 'delegationDepth', 'agentPreset']
29
+
30
+ function assertHostCreateAccepts(spec = {}) {
31
+ const meta = spec.meta ?? {}
32
+ const count = spec.inheritedEventCount
33
+ for (const key of Object.keys(meta)) {
34
+ if (!META_KEYS.includes(key)) {
35
+ throw new Error(`session meta "${key}" is not a header field: SessionStore.prepare() drops it`)
36
+ }
37
+ }
38
+ if (meta.isSeeded === true) {
39
+ if (spec.seed === undefined) throw new Error('seeded session requires an explicit constructor seed')
40
+ if (count === undefined) throw new Error('seeded session requires an inherited event count')
41
+ } else if (count !== undefined && count !== 0) {
42
+ throw new Error('unseeded session inherited event count must be 0')
43
+ }
44
+ // buildForkSeed() returns a seed object, not a bare array, so the length guard
45
+ // only applies to an array seed.
46
+ if (Array.isArray(spec.seed) && count !== undefined && count > spec.seed.length) {
47
+ throw new Error('session inherited event count exceeds its event log')
48
+ }
49
+ if (spec.agentOptions !== undefined) {
50
+ if (!spec.agentOptions || typeof spec.agentOptions.provider !== 'string' || typeof spec.agentOptions.model !== 'string') {
51
+ throw new Error('agentOptions must carry the parent provider and model')
52
+ }
53
+ }
54
+ }
55
+
56
+ function host({ live = false, whenIdle = null, parentOptions = { provider: 'test-provider', model: 'test-model' }, requestConfig = null, extraEvents = [], transcriptSize = 0, turnReason = { kind: 'completed' }, emitTurnEnd = true, headerTools = null, childCtxFactory = null } = {}) {
57
+ const rec = { liveFollowups: [], created: [], forkFollowups: [], disposed: [], sessionHandlers: [], workerToolRegistrations: [], workerToolDisposals: [] }
58
+ const childContexts = new Map()
59
+ const tick = String.fromCharCode(96)
60
+ const receiptFence = (range, identity) => [tick + tick + tick + 'memu-consolidation-receipt', JSON.stringify({ version: 1, ...identity, status: 'completed', outcome: 'no_value', checkedRange: range, createdIds: [], updatedIds: [], heldIds: [], reasonCode: null, retryable: false }), tick + tick + tick].join('\n')
61
+ // 真实长会话的规模:补齐不产生证据的事件,只影响 fork 成本判定。
62
+ const padding = Math.max(0, transcriptSize - 3 - extraEvents.length)
63
+ const filler = Array.from({ length: padding }, (_, i) => ({ seq: 3 + extraEvents.length + i, type: 'todo/write', data: {} }))
64
+ const ctx = {
65
+ rec,
66
+ on: (name, handler) => {
67
+ if (name !== 'session/event') return () => {}
68
+ rec.sessionHandlers.push(handler)
69
+ return () => {
70
+ const index = rec.sessionHandlers.indexOf(handler)
71
+ if (index >= 0) rec.sessionHandlers.splice(index, 1)
72
+ }
73
+ },
74
+ get: (name) => {
75
+ if (name === 'agents') {
76
+ return {
77
+ get: (id) => {
78
+ // attachWorkerTools 用 childContexts 模拟"宿主在 create 后暴露的 agent ctx"。
79
+ if (childContexts.has(id)) return { ctx: childContexts.get(id) }
80
+ return (live
81
+ ? {
82
+ followup: (message) => rec.liveFollowups.push({ id, message }),
83
+ ...(parentOptions ? { options: parentOptions } : {}),
84
+ ...(requestConfig || headerTools ? { session: { requestHeader: () => ({
85
+ ...(requestConfig ? { config: requestConfig } : {}),
86
+ ...(headerTools ? { tools: headerTools.map((toolName) => ({ name: toolName })) } : {}),
87
+ }) } } : {}),
88
+ }
89
+ : undefined)
90
+ },
91
+ create: async (spec) => {
92
+ assertHostCreateAccepts(spec)
93
+ rec.created.push(spec)
94
+ if (childCtxFactory) childContexts.set(spec.sessionId, childCtxFactory())
95
+ const childSession = { id: spec.sessionId }
96
+ return {
97
+ agent: {
98
+ session: childSession,
99
+ followup: (message) => {
100
+ rec.forkFollowups.push({ child: spec.sessionId, message })
101
+ if (emitTurnEnd) {
102
+ if (turnReason?.kind === 'completed') {
103
+ const throughSeq = 2 + extraEvents.length + padding
104
+ for (const handler of rec.sessionHandlers) {
105
+ handler(childSession, { type: 'assistant/message', seq: 99, data: { message: { content: [{ type: 'text', text: receiptFence({ fromSeq: 0, throughSeq }, state.getSessionBinding(spec.sessionId)) }] } } })
106
+ }
107
+ }
108
+ for (const handler of rec.sessionHandlers) {
109
+ handler(childSession, { type: 'turn/end', seq: 100, data: { turn: 1, reason: turnReason } })
110
+ }
111
+ }
112
+ },
113
+ whenIdle: whenIdle || (async () => {}),
114
+ },
115
+ dispose: async () => { rec.disposed.push(spec.sessionId) },
116
+ }
117
+ },
118
+ }
119
+ }
120
+ if (name === 'sessionQuery') {
121
+ return {
122
+ readSession: async () => ({
123
+ events: [
124
+ { seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这个 fork 为什么一直建不起来,帮我看下' }] } },
125
+ { seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', name: 'read', arguments: '{}' }] } } },
126
+ { seq: 2, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '看到了' }] } } },
127
+ ...extraEvents,
128
+ ...filler,
129
+ ],
130
+ session: { cwd: path.join(home, 'proj'), agentPreset: 'coding' },
131
+ }),
132
+ }
133
+ }
134
+ return undefined
135
+ },
136
+ }
137
+ return ctx
138
+ }
139
+
140
+ test('enabling local or remote vectors does not opt into remote summary', async () => {
141
+ const originalFetch = globalThis.fetch
142
+ let fetches = 0
143
+ globalThis.fetch = async () => { fetches += 1; throw new Error('summary API must not be called') }
144
+ try {
145
+ for (const vectorSource of ['local', 'api']) {
146
+ state.updateSettings({ memoryBackend: 'api', vectorSource })
147
+ const id = 'fork-vector-' + vectorSource
148
+ state.ensureSession(id, { cwd: path.join(home, id), agentPreset: 'coding' })
149
+ const ctx = host({ live: true })
150
+ const result = await requestPrivateConsolidation(ctx, id)
151
+ assert.equal(result.ok, true)
152
+ assert.equal(ctx.rec.created.length, 1, 'vector enable still delegates extraction to the conversation Agent')
153
+ assert.equal(ctx.rec.forkFollowups.length, 1)
154
+ }
155
+ assert.equal(fetches, 0)
156
+ } finally {
157
+ globalThis.fetch = originalFetch
158
+ state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
159
+ }
160
+ })
161
+
162
+ test('a live session is consolidated in a fork, not by queueing on itself', async () => {
163
+ const binding = state.ensureSession('fork-live', { cwd: path.join(home, 'proj'), agentPreset: 'coding' })
164
+ const parentOptions = { provider: 'test-provider', model: 'test-model', reasoningEffort: 'max', maxTokens: 262144 }
165
+ const ctx = host({ live: true, parentOptions })
166
+ const result = await requestPrivateConsolidation(ctx, 'fork-live')
167
+ assert.equal(result.ok, true)
168
+ assert.equal(result.settled, true)
169
+ assert.equal(ctx.rec.liveFollowups.length, 0, 'no queued draft on the live agent')
170
+ assert.equal(ctx.rec.created.length, 1, 'exactly one scratch fork')
171
+ assert.equal(ctx.rec.forkFollowups.length, 1, 'the fork does the work')
172
+ assert.equal(ctx.rec.disposed.length, 1, 'the fork is disposed')
173
+ const spec = ctx.rec.created[0]
174
+ assert.equal(spec.meta.parentSession, 'fork-live')
175
+ assert.equal(spec.meta.origin, 'subagent')
176
+ assert.equal(spec.meta.isSeeded, true)
177
+ assert.equal(spec.inheritedEventCount, 3, 'inherits the whole transcript as a top-level option')
178
+ assert.equal(Object.hasOwn(spec.meta, 'inheritedEventCount'), false, 'must not hide in the session meta: prepare() drops it')
179
+ assert.deepEqual(spec.agentOptions, parentOptions, 'the fork starts on the parent model route')
180
+ const child = state.getSessionBinding(spec.sessionId)
181
+ assert.equal(child, null, 'disposed worker binding is removed')
182
+ assert.equal(state.getSessionBinding('fork-live').lastSleepSeq, 2, 'only a completed turn advances the sleep cursor')
183
+ assert.equal(result.scanned, 3, 'report how much of the transcript was scanned')
184
+ assert.equal(result.fresh, 3, 'report how many events were new since the last consolidation')
185
+ assert.equal(result.wrote, 0, 'the fake fork writes nothing, but the metric must still be measured')
186
+ })
187
+ test('a live session inherits the route of its latest turn, not stale creation options', async () => {
188
+ state.ensureSession('fork-route', { cwd: path.join(home, 'proj-route'), agentPreset: 'coding' })
189
+ const ctx = host({
190
+ live: true,
191
+ parentOptions: { provider: 'creation', model: 'old-model' },
192
+ requestConfig: { provider: 'request', model: 'new-model', reasoningEffort: 'high', maxTokens: 32768 },
193
+ })
194
+ const result = await requestPrivateConsolidation(ctx, 'fork-route')
195
+ assert.equal(result.ok, true)
196
+ assert.deepEqual(ctx.rec.created[0].agentOptions, {
197
+ provider: 'request',
198
+ model: 'new-model',
199
+ reasoningEffort: 'high',
200
+ maxTokens: 32768,
201
+ }, 'requestHeader() is the route that served the parent, options can be stale')
202
+ })
203
+
204
+ test('a closed session inherits the last request/header event', async () => {
205
+ state.ensureSession('fork-closed', { cwd: path.join(home, 'proj-closed'), agentPreset: 'coding' })
206
+ const ctx = host({
207
+ live: false,
208
+ extraEvents: [
209
+ { seq: 3, type: 'request/header', data: { header: { config: { provider: 'old', model: 'old-model' } } } },
210
+ { seq: 4, type: 'request/header', data: { header: { config: { provider: 'latest', model: 'latest-model', reasoningEffort: 'low', maxTokens: 4096 } } } },
211
+ ],
212
+ })
213
+ const result = await requestPrivateConsolidation(ctx, 'fork-closed')
214
+ assert.equal(result.ok, true)
215
+ const spec = ctx.rec.created[0]
216
+ assert.equal(spec.inheritedEventCount, 5, 'the header events are part of the inherited log')
217
+ assert.deepEqual(spec.agentOptions, { provider: 'latest', model: 'latest-model', reasoningEffort: 'low', maxTokens: 4096 })
218
+ })
219
+
220
+ test('a session with no model source is refused before a doomed fork', async () => {
221
+ state.ensureSession('fork-no-model', { cwd: path.join(home, 'proj-no-model'), agentPreset: 'coding' })
222
+ const ctx = host({ live: true, parentOptions: null })
223
+ const result = await requestPrivateConsolidation(ctx, 'fork-no-model')
224
+ assert.equal(result.ok, false)
225
+ assert.match(result.error, /model\/provider/)
226
+ assert.equal(ctx.rec.created.length, 0, 'never create a fork that cannot resolve {{model}}')
227
+ })
228
+
229
+ test('a failed fork turn is reported as failure and does not advance the sleep cursor', async () => {
230
+ state.ensureSession('fork-turn-error', { cwd: path.join(home, 'proj-turn-error'), agentPreset: 'coding' })
231
+ const ctx = host({
232
+ live: true,
233
+ turnReason: { kind: 'error', error: { message: 'prompt variable "{{model}}" has no value for this assembly', code: 'UNKNOWN' } },
234
+ })
235
+ const result = await requestPrivateConsolidation(ctx, 'fork-turn-error')
236
+ assert.equal(result.ok, false)
237
+ assert.equal(result.settled, false)
238
+ assert.match(result.error, /fork turn ended error/)
239
+ assert.match(result.error, /prompt variable/)
240
+ assert.equal(state.getSessionBinding('fork-turn-error').lastSleepSeq, undefined, 'a failed turn must be retried, not marked asleep')
241
+ assert.equal(ctx.rec.disposed.length, 1, 'the failed fork is still disposed')
242
+ assert.equal(ctx.rec.sessionHandlers.length, 0, 'the failed fork listener is removed')
243
+ })
244
+
245
+ test('a fork that never reports turn/end is a failure, not a success', async () => {
246
+ state.ensureSession('fork-no-turn-end', { cwd: path.join(home, 'proj-no-turn-end'), agentPreset: 'coding' })
247
+ const ctx = host({ live: true, emitTurnEnd: false })
248
+ const result = await requestPrivateConsolidation(ctx, 'fork-no-turn-end')
249
+ assert.equal(result.ok, false)
250
+ assert.equal(result.settled, false)
251
+ assert.match(result.error, /no turn\/end/)
252
+ assert.equal(state.getSessionBinding('fork-no-turn-end').lastSleepSeq, undefined, 'without a turn/end event the cursor must not advance')
253
+ assert.equal(ctx.rec.disposed.length, 1, 'the unmatched fork is still disposed')
254
+ assert.equal(ctx.rec.sessionHandlers.length, 0, 'the listener is removed even when no turn/end arrives')
255
+ })
256
+
257
+ test('a fork still running at the deadline reports a timeout instead of silence', async () => {
258
+ state.ensureSession('fork-timeout', { cwd: path.join(home, 'proj-timeout'), agentPreset: 'coding' })
259
+ const ctx = host({ live: true, whenIdle: () => new Promise(() => {}) })
260
+ const result = await requestPrivateConsolidation(ctx, 'fork-timeout', { maxWaitMs: 30 })
261
+ assert.equal(result.ok, false)
262
+ assert.equal(result.timedOut, true)
263
+ assert.equal(result.settled, false)
264
+ // The settings button renders result.error and nothing else; an empty string
265
+ // there is why two clicks in a row looked like the button did nothing.
266
+ assert.match(result.error, /timed out after \d+s/)
267
+ assert.equal(state.getSessionBinding('fork-timeout').lastSleepSeq, undefined, 'a timeout must not advance the sleep cursor')
268
+ assert.equal(ctx.rec.disposed.length, 1, 'the timed-out fork is stopped')
269
+ assert.equal(ctx.rec.sessionHandlers.length, 0, 'the listener is removed on timeout')
270
+ })
271
+
272
+ test('a session already being consolidated is not forked twice', async () => {
273
+ state.ensureSession('fork-busy', { cwd: path.join(home, 'proj2'), agentPreset: 'coding' })
274
+ let release = null
275
+ const gate = new Promise((resolve) => { release = resolve })
276
+ const ctx = host({ live: true, whenIdle: () => gate })
277
+ const first = requestPrivateConsolidation(ctx, 'fork-busy')
278
+ await new Promise((resolve) => setTimeout(resolve, 20))
279
+ assert.equal(isConsolidating('fork-busy'), true)
280
+ const second = await requestPrivateConsolidation(ctx, 'fork-busy')
281
+ assert.equal(second.ok, false)
282
+ assert.equal(second.running, true)
283
+ assert.equal(ctx.rec.created.length, 1, 'the second call must not fork again')
284
+ release()
285
+ const done = await first
286
+ assert.equal(done.ok, true)
287
+ assert.equal(isConsolidating('fork-busy'), false)
288
+ })
289
+
290
+ test('two calls in the same tick fork once, not twice', async () => {
291
+ // The guard used to be checked before the first await and claimed only after
292
+ // readSession() and agents.create() resolved, so this exact pair raced past it
293
+ // and forked the same transcript twice on the real host.
294
+ state.ensureSession('fork-race', { cwd: path.join(home, 'proj3'), agentPreset: 'coding' })
295
+ let release = null
296
+ const gate = new Promise((resolve) => { release = resolve })
297
+ const ctx = host({ live: true, whenIdle: () => gate })
298
+ const both = Promise.all([
299
+ requestPrivateConsolidation(ctx, 'fork-race'),
300
+ requestPrivateConsolidation(ctx, 'fork-race'),
301
+ ])
302
+ await new Promise((resolve) => setTimeout(resolve, 20))
303
+ release()
304
+ const [a, b] = await both
305
+ assert.equal(ctx.rec.created.length, 1, 'exactly one scratch fork for the pair')
306
+ const outcomes = [a, b].map((r) => r.running === true ? 'running' : r.ok === true ? 'ok' : 'error').sort()
307
+ assert.deepEqual(outcomes, ['ok', 'running'], 'the loser is told the session is already being consolidated')
308
+ })
309
+
310
+ test('an unbound session is refused before any fork is attempted', async () => {
311
+ const ctx = host({ live: true })
312
+ const result = await requestPrivateConsolidation(ctx, 'fork-unbound')
313
+ assert.equal(result.ok, false)
314
+ assert.match(result.error, /not bound/)
315
+ assert.equal(ctx.rec.created.length, 0)
316
+ assert.equal(ctx.rec.liveFollowups.length, 0)
317
+ })
318
+ test('thin evidence holds the fork back until the starvation threshold', async () => {
319
+ state.ensureSession('fork-thin', { cwd: path.join(home, 'proj-thin'), agentPreset: 'coding' })
320
+ // 空闲 5 分钟:还在"短空闲"区间,证据又薄(一条短消息 + 一个工具调用),不唤醒。
321
+ state.setSessionBinding('fork-thin', { lastTurnAt: new Date(Date.now() - 5 * 60_000).toISOString() })
322
+ const ctx = host({ live: true })
323
+ const result = await requestPrivateConsolidation(ctx, 'fork-thin')
324
+ assert.equal(result.ok, true)
325
+ assert.equal(result.skipped, 'low-score')
326
+ assert.equal(ctx.rec.created.length, 0, 'a thin short-idle session must not pay for a fork')
327
+ })
328
+
329
+ test('starvation wakes a thin session so it can never be starved', async () => {
330
+ state.ensureSession('fork-starved', { cwd: path.join(home, 'proj-starved'), agentPreset: 'coding' })
331
+ state.setSessionBinding('fork-starved', { lastTurnAt: new Date(Date.now() - 40 * 60_000).toISOString() })
332
+ const ctx = host({ live: true })
333
+ const result = await requestPrivateConsolidation(ctx, 'fork-starved')
334
+ assert.equal(result.ok, true)
335
+ assert.equal(ctx.rec.created.length, 1, 'past the starvation threshold the check runs regardless of score')
336
+ })
337
+
338
+ test('a large transcript with nothing new since the cursor does not refork', async () => {
339
+ // 真机会话 5913 条事件、26MB 转录。游标停在末尾附近时,待整理区间只剩几条纯轮询:
340
+ // 没有新的真人回合,就没有可整理的证据,不该为此把整段转录重新种进 fork。
341
+ // (桩把填充事件全放在末尾,所以「大会话 + 末尾一句轻话」这种形态表达不出来;
342
+ // 另一侧「从未整理过就必须整理」由下一条用例覆盖。)
343
+ state.ensureSession('fork-large-thin', { cwd: path.join(home, 'proj-large'), agentPreset: 'coding' })
344
+ state.setSessionBinding('fork-large-thin', {
345
+ lastTurnAt: new Date(Date.now() - 40 * 60_000).toISOString(),
346
+ lastSleepSeq: 5905,
347
+ })
348
+ const ctx = host({ live: true, transcriptSize: 5913 })
349
+ const result = await requestPrivateConsolidation(ctx, 'fork-large-thin')
350
+ assert.equal(result.ok, true)
351
+ assert.equal(result.skipped, 'no-human-turn', 'the increment carries no human turn to consolidate')
352
+ assert.equal(result.settled, false, 'nothing was consolidated, so the cursor must not move')
353
+ assert.equal(ctx.rec.created.length, 0, 'no fork when the pending increment has no evidence')
354
+ assert.equal(state.getSessionBinding('fork-large-thin').lastSleepSeq, 5905, 'the cursor must not advance')
355
+ })
356
+
357
+ test('a large transcript that was never consolidated does fork, because the backlog is real', async () => {
358
+ // 同一条会话,但没有整理过:整段 5913 条事件都躺在 pending 里。
359
+ // 守卫要躲的是「为一句轻话重种整段转录」,不是「把从没整理过的大会话永远挡在门外」;
360
+ // 没有这条上界,长会话早期的决定永远进不了记忆——那不是省 token,是丢失。
361
+ state.ensureSession('fork-large-owed', { cwd: path.join(home, 'proj-large-owed'), agentPreset: 'coding' })
362
+ state.setSessionBinding('fork-large-owed', { lastTurnAt: new Date(Date.now() - 40 * 60_000).toISOString() })
363
+ const ctx = host({ live: true, transcriptSize: 5913 })
364
+ const result = await requestPrivateConsolidation(ctx, 'fork-large-owed')
365
+ assert.equal(result.ok, true, 'the backlog bound lets this one through')
366
+ assert.equal(ctx.rec.created.length, 1, 'a large unconsolidated backlog is worth exactly one fork')
367
+ })
368
+ // ---------------------------------------------------------------- PTC 兼容
369
+ // 现场(2026-10-31,yuike-ptc 会话):PTC 模式下 request/header 的工具表只有 run_code,
370
+ // probe 曾据此判 unavailable(worker-tools-ptc-only),PTC 会话永远不被整理。宿主的
371
+ // PTC executor 明确 "only run_code is directly callable",其余工具走 run_code 程序里的
372
+ // SDK 绑定(tools.schemas(agent, toolSet) 枚举可见面)—— header 名单在 PTC 会话里
373
+ // 不承载"能不能写记忆"。修复后:PTC 不构成否证 + fork 时把读写四件挂进 worker 自己的
374
+ // scope(注册表按 scope 链取可见面,不扩散到其它会话)。
375
+
376
+ test('a PTC session (run_code only header) is consolidated and the worker gets write tools on its own scope', async () => {
377
+ const registered = []
378
+ const disposals = []
379
+ state.ensureSession('fork-ptc', { cwd: path.join(home, 'proj-ptc'), agentPreset: 'yuike-ptc' })
380
+ const ctx = host({
381
+ live: true,
382
+ headerTools: ['run_code'],
383
+ extraEvents: [
384
+ { seq: 3, type: 'tool/ptc-dispatch-start', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {} } },
385
+ { seq: 4, type: 'tool/ptc-dispatch', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {}, isError: false, content: [] } },
386
+ ],
387
+ childCtxFactory: () => ({
388
+ tools: {
389
+ register: (tool) => {
390
+ registered.push(tool?.name)
391
+ return () => { disposals.push(tool?.name) }
392
+ },
393
+ },
394
+ }),
395
+ })
396
+ const result = await requestPrivateConsolidation(ctx, 'fork-ptc')
397
+ assert.equal(result.ok, true, 'a run_code header must not block consolidation: ' + String(result.error || ''))
398
+ assert.equal(result.settled, true)
399
+ assert.equal(ctx.rec.created.length, 1, 'the fork is created for the PTC conversation')
400
+ // 只挂读写四件;archive/delete/unpin 这些破坏性工具不得进 worker 的工具面。
401
+ assert.deepEqual([...registered].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
402
+ // 整理结束(finally)必须卸载挂载,不能给这条一次性 worker 留常驻注册。
403
+ assert.deepEqual([...disposals].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
404
+ // 全量挂载的活动元数据:attached=实际子集(此处=完整 wanted)、requested=完整 wanted、
405
+ // partial=false。三件一起钉住"成功路径不再含糊"。
406
+ const started = startedEntryFor(ctx, 'fork-ptc')
407
+ assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
408
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
409
+ assert.equal(started.detail.workerToolsPartial, false, 'a full mount is not partial')
410
+ const followup = ctx.rec.forkFollowups[0]?.message
411
+ const brief = Array.isArray(followup?.content)
412
+ ? followup.content.map((block) => block?.text || '').join('\n')
413
+ : String(followup || '')
414
+ assert.match(brief, /run_code/, 'the brief names the PTC transport')
415
+ assert.match(brief, /tools\.memu_private_remember/, 'the brief shows the SDK invocation shape')
416
+ })
417
+
418
+ // "整理已开始"那条活动日志:按 sessionId + phase=started 取,childId 对上这次的 fork。
419
+ function startedEntryFor(ctx, sessionId) {
420
+ const childId = ctx.rec.created[0]?.sessionId
421
+ const entry = activity.listEvents({ kind: 'consolidate', sessionId })
422
+ .find((row) => row.detail?.phase === 'started' && row.detail?.childId === childId)
423
+ assert.ok(entry, 'the consolidation-started activity entry exists')
424
+ return entry
425
+ }
426
+
427
+ test('a host registry refusing one write tool leaves a structured partial mount in the activity log', async () => {
428
+ // 宿主注册表对单件工具说"不"(重名、scope 锁定)是真实的部分挂载路径:registerTools
429
+ // 逐件 fail-open,其余三件照常挂上。活动日志必须记实际子集 + requested + partial,
430
+ // 而不是把 wanted 全量误报成已挂载(旧口径的缺陷)。
431
+ const registered = []
432
+ const disposals = []
433
+ state.ensureSession('fork-ptc-partial', { cwd: path.join(home, 'proj-ptc-partial'), agentPreset: 'yuike-ptc' })
434
+ const ctx = host({
435
+ live: true,
436
+ headerTools: ['run_code'],
437
+ childCtxFactory: () => ({
438
+ tools: {
439
+ register: (tool) => {
440
+ if (tool?.name === 'memu_private_remember') throw new Error('registry refused: name already bound')
441
+ registered.push(tool?.name)
442
+ return () => { disposals.push(tool?.name) }
443
+ },
444
+ },
445
+ }),
446
+ })
447
+ const result = await requestPrivateConsolidation(ctx, 'fork-ptc-partial')
448
+ assert.equal(result.ok, true, String(result.error || ''))
449
+ assert.equal(result.settled, true)
450
+ assert.deepEqual([...registered].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
451
+ 'exactly the tools that actually registered are mounted')
452
+ assert.deepEqual([...disposals].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
453
+ 'dispose unmounts exactly what was mounted')
454
+ const started = startedEntryFor(ctx, 'fork-ptc-partial')
455
+ assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
456
+ 'the log records the actual subset, not the full wanted list')
457
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
458
+ assert.equal(started.detail.workerToolsPartial, true)
459
+ // 挂载为部分子集时任务书仍带 PTC 调用形态:worker 知道写记忆走 run_code 的 SDK 绑定。
460
+ const followup = ctx.rec.forkFollowups[0]?.message
461
+ const brief = Array.isArray(followup?.content)
462
+ ? followup.content.map((block) => block?.text || '').join('\n')
463
+ : String(followup || '')
464
+ assert.match(brief, /await tools\.memu_private_remember/, 'the brief keeps the PTC invocation shape on a partial mount')
465
+ })
466
+
467
+ test('a fork context without a tools service records a null mount, the request and the partial flag', async () => {
468
+ // 宿主不暴露 agent ctx(或 ctx 上没有 tools 服务)→ attachWorkerTools 的现有 warn 路径。
469
+ // 语义:workerToolsAttached=null = 挂载没有完成(与"挂全了"严格区分);requested 仍记
470
+ // 完整 wanted;workerToolsPartial=true —— "未证明拿到完整 wanted 集",一件都没有
471
+ // 是它最极端的形态。若这里标 false,false 会被一眼读成"全量挂载",元数据又不准了。
472
+ state.ensureSession('fork-ptc-notools', { cwd: path.join(home, 'proj-ptc-notools'), agentPreset: 'yuike-ptc' })
473
+ const ctx = host({ live: true, headerTools: ['run_code'] })
474
+ const result = await requestPrivateConsolidation(ctx, 'fork-ptc-notools')
475
+ assert.equal(result.ok, true, String(result.error || ''))
476
+ const started = startedEntryFor(ctx, 'fork-ptc-notools')
477
+ assert.equal(started.detail.workerToolsAttached, null, 'no mount completed, so the log must not claim one')
478
+ assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
479
+ assert.equal(started.detail.workerToolsPartial, true)
480
+ })
481
+
482
+ test('a non-PTC header without write tools still refuses to fork', async () => {
483
+ state.ensureSession('fork-notools', { cwd: path.join(home, 'proj-notools'), agentPreset: 'coding' })
484
+ const ctx = host({ live: true, headerTools: ['read', 'shell', 'edit'] })
485
+ const result = await requestPrivateConsolidation(ctx, 'fork-notools')
486
+ assert.equal(result.ok, false)
487
+ assert.equal(result.reasonCode, 'worker-tools-missing')
488
+ assert.equal(ctx.rec.created.length, 0, 'no fork when the model surface provably lacks the write tools')
489
+ })