dsh-config-manager 0.1.71 → 0.1.73

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 (596) hide show
  1. package/README.md +149 -98
  2. package/README.zh-CN.md +245 -88
  3. package/lib/adapters/agent-instructions.js +1 -1
  4. package/lib/adapters/file-collection.js +1 -1
  5. package/lib/adapters/index.d.ts +2 -1
  6. package/lib/adapters/index.js +6 -1
  7. package/lib/adapters/link-report.d.ts +1 -0
  8. package/lib/adapters/link-report.js +27 -3
  9. package/lib/adapters/messages.d.ts +21 -0
  10. package/lib/adapters/messages.js +57 -0
  11. package/lib/adapters/plugin-data.d.ts +114 -0
  12. package/lib/adapters/plugin-data.js +309 -0
  13. package/lib/adapters/plugin-files.js +3 -2
  14. package/lib/adapters/plugins.js +4 -3
  15. package/lib/adapters/prompts.d.ts +1 -0
  16. package/lib/adapters/prompts.js +7 -1
  17. package/lib/adapters/self.d.ts +8 -2
  18. package/lib/adapters/self.js +3 -2
  19. package/lib/adapters/sessions.d.ts +1 -0
  20. package/lib/adapters/sessions.js +4 -2
  21. package/lib/adapters/test-helpers.d.ts +9 -0
  22. package/lib/adapters/test-helpers.js +14 -0
  23. package/lib/adapters/ui.d.ts +2 -2
  24. package/lib/adapters/ui.js +25 -25
  25. package/lib/adapters/workspaces.d.ts +1 -0
  26. package/lib/adapters/workspaces.js +7 -3
  27. package/lib/cli/actions.d.ts +9 -1
  28. package/lib/cli/actions.js +5 -2
  29. package/lib/cli/help.js +23 -10
  30. package/lib/cli/index.d.ts +23 -0
  31. package/lib/cli/index.js +90 -32
  32. package/lib/cli/sessions-inspect.js +3 -1
  33. package/lib/cli/sessions-repair.js +25 -6
  34. package/lib/cli/web/page.d.ts +12 -0
  35. package/lib/cli/web/page.js +21 -12
  36. package/lib/cli/web/routes.js +2 -2
  37. package/lib/cli/web/server.js +4 -4
  38. package/lib/client.d.ts +1442 -494
  39. package/lib/client.js +15326 -8520
  40. package/lib/core/analyzer.d.ts +11 -2
  41. package/lib/core/analyzer.js +31 -8
  42. package/lib/core/backup-plan.d.ts +10 -0
  43. package/lib/core/backup-plan.js +51 -20
  44. package/lib/core/backup-verify.js +24 -23
  45. package/lib/core/backup.d.ts +25 -1
  46. package/lib/core/backup.js +99 -28
  47. package/lib/core/boot-rescue.js +4 -3
  48. package/lib/core/boot-safety.js +2 -1
  49. package/lib/core/command-rollback.d.ts +182 -0
  50. package/lib/core/command-rollback.js +293 -0
  51. package/lib/core/consult-source.js +7 -6
  52. package/lib/core/disk-usage.d.ts +74 -10
  53. package/lib/core/disk-usage.js +67 -4
  54. package/lib/core/engine-locale.d.ts +87 -0
  55. package/lib/core/engine-locale.js +125 -0
  56. package/lib/core/exporter.js +3 -3
  57. package/lib/core/fork-reconcile.d.ts +128 -0
  58. package/lib/core/fork-reconcile.js +150 -0
  59. package/lib/core/journal.js +6 -5
  60. package/lib/core/local-plugin-host.js +3 -2
  61. package/lib/core/local-plugin-pack.js +11 -6
  62. package/lib/core/messages-core.d.ts +294 -0
  63. package/lib/core/messages-core.js +619 -0
  64. package/lib/core/messages.d.ts +61 -2
  65. package/lib/core/messages.js +118 -13
  66. package/lib/core/migration-consult.js +21 -20
  67. package/lib/core/migration-history.js +19 -5
  68. package/lib/core/model-tools.d.ts +0 -22
  69. package/lib/core/model-tools.js +50 -27
  70. package/lib/core/phase3-host.js +30 -5
  71. package/lib/core/plugin-cli.js +18 -19
  72. package/lib/core/plugin-data-registry.d.ts +124 -0
  73. package/lib/core/plugin-data-registry.js +198 -0
  74. package/lib/core/reconcile.d.ts +2 -14
  75. package/lib/core/reconcile.js +87 -17
  76. package/lib/core/recovery-orchestrator.js +19 -18
  77. package/lib/core/reinstall.d.ts +4 -0
  78. package/lib/core/reinstall.js +26 -21
  79. package/lib/core/restore.js +20 -17
  80. package/lib/core/rollback-gate.d.ts +33 -0
  81. package/lib/core/rollback-gate.js +57 -0
  82. package/lib/core/rollback-ignore-rules.d.ts +31 -0
  83. package/lib/core/rollback-ignore-rules.js +140 -0
  84. package/lib/core/rollback-ledger.d.ts +248 -0
  85. package/lib/core/rollback-ledger.js +1282 -0
  86. package/lib/core/rollback-point.d.ts +1391 -0
  87. package/lib/core/rollback-point.js +3503 -0
  88. package/lib/core/rollback.js +6 -4
  89. package/lib/core/run-registry.js +6 -5
  90. package/lib/core/session-health.d.ts +15 -0
  91. package/lib/core/session-meta.d.ts +42 -2
  92. package/lib/core/session-meta.js +140 -1
  93. package/lib/core/session-title.d.ts +28 -0
  94. package/lib/core/session-title.js +185 -0
  95. package/lib/core/startup-barrier.d.ts +24 -0
  96. package/lib/core/startup-barrier.js +22 -0
  97. package/lib/core/transaction-coordinator.js +4 -3
  98. package/lib/core/types.d.ts +37 -1
  99. package/lib/core/validator.js +5 -4
  100. package/lib/core/verify-recovery.js +31 -30
  101. package/lib/foreign/bundle.js +3 -2
  102. package/lib/foreign/continue.js +2 -1
  103. package/lib/foreign/crush.js +2 -1
  104. package/lib/foreign/gemini.js +2 -1
  105. package/lib/foreign/goose.js +2 -1
  106. package/lib/foreign/kilocode.js +2 -1
  107. package/lib/foreign/kimi.js +2 -1
  108. package/lib/foreign/messages.d.ts +15 -0
  109. package/lib/foreign/messages.js +40 -0
  110. package/lib/foreign/mimocode.js +2 -1
  111. package/lib/foreign/openclaw.js +2 -1
  112. package/lib/foreign/opencode.js +2 -1
  113. package/lib/foreign/pi.js +2 -1
  114. package/lib/foreign/qoder.js +2 -1
  115. package/lib/foreign/qwen.js +2 -1
  116. package/lib/foreign/registry.d.ts +1 -1
  117. package/lib/foreign/registry.js +8 -7
  118. package/lib/foreign/teleagent.js +2 -1
  119. package/lib/foreign/workbuddy.js +2 -1
  120. package/lib/foreign/zcode.js +2 -1
  121. package/lib/foreign/zed.js +2 -1
  122. package/lib/index.d.ts +227 -7
  123. package/lib/index.js +799 -54
  124. package/lib/market/git-file-writer.js +20 -19
  125. package/lib/market/github-repos.js +35 -19
  126. package/lib/market/index-parser.js +31 -30
  127. package/lib/market/messages-market.d.ts +138 -0
  128. package/lib/market/messages-market.js +305 -0
  129. package/lib/market/my-repo.d.ts +2 -2
  130. package/lib/market/my-repo.js +32 -29
  131. package/lib/market/prepare.js +18 -15
  132. package/lib/market/security.js +20 -19
  133. package/lib/market/types.js +2 -1
  134. package/lib/market/url.js +2 -1
  135. package/lib/migrations/index.js +6 -5
  136. package/lib/migrations/messages-migrations.d.ts +32 -0
  137. package/lib/migrations/messages-migrations.js +58 -0
  138. package/lib/profiles/dsh-profile-launcher.js +2 -1
  139. package/lib/profiles/dsh-profile-runtime.js +2 -1
  140. package/lib/profiles/messages.d.ts +7 -0
  141. package/lib/profiles/messages.js +27 -0
  142. package/lib/routes/backup.js +18 -2
  143. package/lib/routes/import.js +3 -3
  144. package/lib/routes/index.d.ts +1 -1
  145. package/lib/routes/kit.d.ts +3 -2
  146. package/lib/routes/kit.js +6 -1
  147. package/lib/routes/market.js +1 -1
  148. package/lib/routes/messages.d.ts +43 -0
  149. package/lib/routes/messages.js +102 -0
  150. package/lib/routes/prefs.js +26 -3
  151. package/lib/routes/profiles.js +12 -6
  152. package/lib/routes/recovery.d.ts +21 -0
  153. package/lib/routes/recovery.js +478 -94
  154. package/lib/routes/snapshots.js +51 -4
  155. package/lib/routes/sync.js +79 -17
  156. package/lib/schema/config.d.ts +11 -1
  157. package/lib/schema/config.js +52 -20
  158. package/lib/schema/index.d.ts +8 -6
  159. package/lib/schema/index.js +8 -6
  160. package/lib/schema/manifest.js +19 -18
  161. package/lib/schema/messages.d.ts +47 -0
  162. package/lib/schema/messages.js +110 -0
  163. package/lib/schema/section-registry.d.ts +42 -15
  164. package/lib/schema/section-registry.js +67 -32
  165. package/lib/schema/types.d.ts +1 -1
  166. package/lib/schema/versions.js +6 -5
  167. package/lib/security/encryption.js +16 -15
  168. package/lib/security/integrity.js +8 -7
  169. package/lib/security/messages.d.ts +30 -0
  170. package/lib/security/messages.js +77 -0
  171. package/lib/security/redaction.js +12 -0
  172. package/lib/security/secret-scanner.js +16 -13
  173. package/lib/security/vault.js +2 -1
  174. package/lib/security/zip-security.js +4 -3
  175. package/lib/sync/ancestor.js +2 -1
  176. package/lib/sync/autosync-config.d.ts +11 -0
  177. package/lib/sync/autosync-config.js +12 -0
  178. package/lib/sync/autosync-scheduler.d.ts +11 -1
  179. package/lib/sync/autosync-scheduler.js +115 -76
  180. package/lib/sync/backup-files.js +3 -2
  181. package/lib/sync/backup-scheduler.js +6 -5
  182. package/lib/sync/blob-store.js +2 -1
  183. package/lib/sync/git/git-transport.d.ts +55 -0
  184. package/lib/sync/git/git-transport.js +152 -2
  185. package/lib/sync/github-auth.js +10 -9
  186. package/lib/sync/layout.js +13 -12
  187. package/lib/sync/messages.d.ts +63 -0
  188. package/lib/sync/messages.js +149 -0
  189. package/lib/sync/review-queue.js +6 -5
  190. package/lib/sync/s3/messages.d.ts +7 -0
  191. package/lib/sync/s3/messages.js +16 -0
  192. package/lib/sync/s3/s3-providers.d.ts +4 -1
  193. package/lib/sync/s3/s3-providers.js +5 -5
  194. package/lib/sync/s3/s3-transport.js +1 -1
  195. package/lib/sync/s3/sigv4.js +2 -1
  196. package/lib/sync/snapshot-crypto.js +6 -5
  197. package/lib/sync/snapshot-json.js +3 -2
  198. package/lib/sync/sync-engine.d.ts +58 -22
  199. package/lib/sync/sync-engine.js +119 -48
  200. package/lib/sync/sync-history.js +7 -6
  201. package/lib/sync/sync-selection.d.ts +15 -4
  202. package/lib/sync/sync-selection.js +17 -5
  203. package/lib/sync/ui-prefs.d.ts +22 -0
  204. package/lib/sync/ui-prefs.js +28 -0
  205. package/lib/sync/webdav/webdav-transport.d.ts +1 -0
  206. package/lib/sync/webdav/webdav-transport.js +4 -1
  207. package/lib/ui/artifact-view.js +4 -3
  208. package/lib/ui/commands.d.ts +1 -1
  209. package/lib/ui/commands.js +10 -0
  210. package/lib/ui/diff-view.d.ts +10 -0
  211. package/lib/ui/diff-view.js +17 -0
  212. package/lib/ui/disk-usage-view.d.ts +33 -4
  213. package/lib/ui/disk-usage-view.js +29 -1
  214. package/lib/ui/errors.d.ts +1 -1
  215. package/lib/ui/errors.js +4 -3
  216. package/lib/ui/export-flow.js +26 -20
  217. package/lib/ui/foreign-view.d.ts +5 -1
  218. package/lib/ui/foreign-view.js +26 -2
  219. package/lib/ui/i18n.d.ts +68 -1
  220. package/lib/ui/i18n.js +120 -2
  221. package/lib/ui/import-wizard.d.ts +7 -0
  222. package/lib/ui/import-wizard.js +13 -10
  223. package/lib/ui/progress.d.ts +2 -2
  224. package/lib/ui/progress.js +1 -2
  225. package/lib/ui/report.d.ts +5 -5
  226. package/lib/ui/report.js +6 -6
  227. package/lib/ui/rollback-view.d.ts +815 -0
  228. package/lib/ui/rollback-view.js +738 -0
  229. package/lib/ui/selection-model.d.ts +41 -0
  230. package/lib/ui/selection-model.js +30 -0
  231. package/lib/ui/session-inventory-view.d.ts +97 -3
  232. package/lib/ui/session-inventory-view.js +76 -2
  233. package/lib/ui/session-issue-labels.d.ts +43 -0
  234. package/lib/ui/session-issue-labels.js +48 -0
  235. package/lib/ui/sync-settings-view.d.ts +33 -0
  236. package/lib/ui/sync-settings-view.js +40 -0
  237. package/lib/ui/test-helpers.js +2 -1
  238. package/lib/ui/types.d.ts +8 -3
  239. package/lib/ui/types.js +1 -1
  240. package/lib/utils/atomic-write.js +6 -5
  241. package/lib/utils/bundle-scan.d.ts +11 -1
  242. package/lib/utils/bundle-scan.js +12 -1
  243. package/lib/utils/env-lock.d.ts +10 -0
  244. package/lib/utils/env-lock.js +73 -46
  245. package/lib/utils/json.js +6 -5
  246. package/lib/utils/messages-utils.d.ts +96 -0
  247. package/lib/utils/messages-utils.js +216 -0
  248. package/lib/utils/proxy.js +18 -5
  249. package/lib/utils/recursive-walk.d.ts +22 -0
  250. package/lib/utils/recursive-walk.js +75 -1
  251. package/lib/utils/session-log-repair.js +10 -9
  252. package/lib/utils/zip.js +27 -26
  253. package/package.json +12 -2
  254. package/src/adapters/agent-instructions.ts +1 -1
  255. package/src/adapters/file-collection.test.ts +1 -0
  256. package/src/adapters/file-collection.ts +1 -1
  257. package/src/adapters/files.test.ts +27 -0
  258. package/src/adapters/index.ts +6 -1
  259. package/src/adapters/link-report.test.ts +22 -2
  260. package/src/adapters/link-report.ts +29 -4
  261. package/src/adapters/messages.ts +61 -0
  262. package/src/adapters/plugin-data.test.ts +225 -0
  263. package/src/adapters/plugin-data.ts +351 -0
  264. package/src/adapters/plugin-files.ts +3 -2
  265. package/src/adapters/plugins.ts +4 -3
  266. package/src/adapters/prompts.ts +7 -1
  267. package/src/adapters/self.test.ts +26 -0
  268. package/src/adapters/self.ts +10 -3
  269. package/src/adapters/sessions.ts +4 -2
  270. package/src/adapters/settings.test.ts +38 -1
  271. package/src/adapters/test-helpers.ts +15 -0
  272. package/src/adapters/ui.test.ts +2 -2
  273. package/src/adapters/ui.ts +27 -26
  274. package/src/adapters/workspaces.ts +7 -3
  275. package/src/cli/actions.test.ts +28 -0
  276. package/src/cli/actions.ts +11 -2
  277. package/src/cli/help.ts +23 -10
  278. package/src/cli/index-guards.test.ts +193 -0
  279. package/src/cli/index.ts +106 -31
  280. package/src/cli/sessions-inspect.test.ts +15 -1
  281. package/src/cli/sessions-inspect.ts +3 -1
  282. package/src/cli/sessions-repair.test.ts +84 -0
  283. package/src/cli/sessions-repair.ts +33 -7
  284. package/src/cli/web/console-route-parity.test.ts +115 -0
  285. package/src/cli/web/page.ts +23 -12
  286. package/src/cli/web/routes.ts +2 -1
  287. package/src/cli/web/server.ts +4 -4
  288. package/src/cli/web/web.test.ts +2 -2
  289. package/src/client/ConfigManagerSection.tsx +30 -2
  290. package/src/client/about/AboutPanel.tsx +2 -2
  291. package/src/client/about/PluginUpdateDialog.tsx +1 -1
  292. package/src/client/about/ReleaseNotesDialog.tsx +2 -2
  293. package/src/client/about/about-view.test.ts +13 -3
  294. package/src/client/about/about-view.ts +10 -6
  295. package/src/client/about/release-notes-view.test.ts +14 -4
  296. package/src/client/about/release-notes-view.ts +4 -2
  297. package/src/client/api.ts +18 -6
  298. package/src/client/chat/ChatEntryView.tsx +411 -0
  299. package/src/client/chat/RollbackDiffTab.tsx +108 -0
  300. package/src/client/chat/RollbackFileIcon.tsx +96 -0
  301. package/src/client/chat/RollbackReviewDialog.tsx +607 -0
  302. package/src/client/chat/chat-entry-view.test.ts +1174 -0
  303. package/src/client/chat/chat-entry-view.ts +758 -0
  304. package/src/client/chat/chat-locales.ts +152 -0
  305. package/src/client/chat/chat-node-registration.ts +64 -0
  306. package/src/client/chat/chat-point-window.ts +183 -0
  307. package/src/client/chat/chat-slots.ts +107 -0
  308. package/src/client/chat/rollback-diff-tab.test.ts +105 -0
  309. package/src/client/chat/rollback-diff-tab.ts +174 -0
  310. package/src/client/chat/rollback-entry-view.test.ts +1469 -0
  311. package/src/client/chat/rollback-entry-view.ts +946 -0
  312. package/src/client/chat/sidebar-slots.ts +59 -0
  313. package/src/client/common/ConfirmDialog.tsx +7 -3
  314. package/src/client/common/ContentPicker.tsx +48 -10
  315. package/src/client/common/DiffTable.tsx +133 -0
  316. package/src/client/common/ErrorBanner.tsx +4 -4
  317. package/src/client/common/Icon.tsx +2 -0
  318. package/src/client/common/Modal.tsx +8 -1
  319. package/src/client/common/ProgressBar.tsx +8 -2
  320. package/src/client/common/ReportView.tsx +4 -4
  321. package/src/client/common/RunsCenter.tsx +1 -0
  322. package/src/client/common/client-route-parity.test.ts +84 -0
  323. package/src/client/common/info-hint-guard.test.ts +46 -13
  324. package/src/client/common/picker-lazy-units.test.ts +42 -0
  325. package/src/client/common/plan-text-redaction.test.ts +17 -6
  326. package/src/client/common/progress-view-edge.test.ts +8 -7
  327. package/src/client/common/progress-view.test.ts +9 -9
  328. package/src/client/common/progress-view.ts +2 -2
  329. package/src/client/common/routes.ts +38 -0
  330. package/src/client/common/section-labels.test.ts +1 -0
  331. package/src/client/common/section-labels.ts +2 -0
  332. package/src/client/common/ui.tsx +73 -10
  333. package/src/client/config-manager.module.css +1109 -31
  334. package/src/client/css-scale-guard.test.ts +30 -0
  335. package/src/client/environment/EnvironmentPanel.tsx +11 -4
  336. package/src/client/environment/maintenance/DiskUsageCard.tsx +23 -2
  337. package/src/client/export/ExportView.tsx +13 -8
  338. package/src/client/history/HistoryPanel.tsx +4 -3
  339. package/src/client/history/history-api.ts +2 -2
  340. package/src/client/history/history-locales.ts +5 -1
  341. package/src/client/home/HomePanel.tsx +29 -8
  342. package/src/client/import/ForeignImportView.tsx +4 -5
  343. package/src/client/import/ImportWizardView.tsx +5 -3
  344. package/src/client/import/import-wizard-split.test.ts +3 -1
  345. package/src/client/import/import-wizard-steps.tsx +9 -9
  346. package/src/client/index.ts +232 -3
  347. package/src/client/library/LibraryActions.tsx +32 -7
  348. package/src/client/library/LibraryPanel.tsx +23 -3
  349. package/src/client/library/RestorePlanView.tsx +9 -66
  350. package/src/client/locales.ts +62 -9
  351. package/src/client/lucide-icons.d.ts +1 -0
  352. package/src/client/market/MarketPanel.tsx +13 -2
  353. package/src/client/market/MyConfigsView.tsx +5 -4
  354. package/src/client/market/MyConfigsWizard.tsx +10 -8
  355. package/src/client/market/market-api.test.ts +9 -8
  356. package/src/client/market/market-api.ts +2 -2
  357. package/src/client/market/market-locales.ts +3 -0
  358. package/src/client/market/market-view.test.ts +6 -6
  359. package/src/client/market/market-view.ts +3 -3
  360. package/src/client/market/my-configs-api.ts +2 -2
  361. package/src/client/market/my-configs-view.test.ts +23 -22
  362. package/src/client/market/my-configs-view.ts +8 -8
  363. package/src/client/recovery/IncompleteCopiesSection.tsx +2 -1
  364. package/src/client/recovery/RecoveryPanel.tsx +442 -283
  365. package/src/client/recovery/RollbackCard.tsx +859 -0
  366. package/src/client/recovery/RollbackDiffPane.tsx +69 -0
  367. package/src/client/recovery/RollbackFileChanges.tsx +163 -0
  368. package/src/client/recovery/incident-api.ts +2 -2
  369. package/src/client/recovery/recovery-api.ts +144 -6
  370. package/src/client/recovery/recovery-locales.test.ts +299 -0
  371. package/src/client/recovery/recovery-locales.ts +520 -149
  372. package/src/client/recovery/recovery-panel-layout.test.ts +132 -2
  373. package/src/client/recovery/rollback-enabled.ts +41 -0
  374. package/src/client/recovery/rollback-file-changes.test.ts +114 -0
  375. package/src/client/recovery/session-dialog-mount.test.ts +54 -0
  376. package/src/client/run-store.ts +84 -21
  377. package/src/client/sync/AutosyncCard.tsx +25 -1
  378. package/src/client/sync/ChannelClearConfirmDialog.tsx +6 -3
  379. package/src/client/sync/ChannelConfigDialog.tsx +10 -10
  380. package/src/client/sync/DecryptPasswordCard.tsx +2 -2
  381. package/src/client/sync/SecurityOptionsCard.tsx +27 -17
  382. package/src/client/sync/SyncChannelEntryCard.tsx +14 -2
  383. package/src/client/sync/SyncConfirmView.tsx +46 -8
  384. package/src/client/sync/SyncLogList.tsx +1 -1
  385. package/src/client/sync/SyncPanel.tsx +31 -23
  386. package/src/client/sync/SyncRepositoryPicker.tsx +3 -5
  387. package/src/client/sync/SyncSectionPickerDialog.tsx +11 -2
  388. package/src/client/sync/SyncSettingsView.test.ts +20 -1
  389. package/src/client/sync/UnreadableSnapshotsBanner.tsx +1 -1
  390. package/src/client/sync/WorkDirCard.tsx +112 -0
  391. package/src/client/sync/history-model.test.ts +42 -9
  392. package/src/client/sync/history-model.ts +30 -15
  393. package/src/client/sync/sync-api.test.ts +57 -33
  394. package/src/client/sync/sync-api.ts +80 -6
  395. package/src/client/sync/sync-locales.ts +104 -3
  396. package/src/client/sync/sync-view-v2.test.ts +65 -13
  397. package/src/client/sync/sync-view.test.ts +107 -52
  398. package/src/client/sync/sync-view.ts +124 -41
  399. package/src/core/analyzer.ts +31 -9
  400. package/src/core/backup-plan.test.ts +48 -5
  401. package/src/core/backup-plan.ts +51 -20
  402. package/src/core/backup-verify.ts +24 -23
  403. package/src/core/backup.test.ts +76 -0
  404. package/src/core/backup.ts +105 -29
  405. package/src/core/boot-rescue.ts +4 -3
  406. package/src/core/boot-safety.ts +2 -1
  407. package/src/core/command-rollback.test.ts +536 -0
  408. package/src/core/command-rollback.ts +406 -0
  409. package/src/core/consult-source.ts +7 -6
  410. package/src/core/disk-usage.test.ts +257 -1
  411. package/src/core/disk-usage.ts +134 -14
  412. package/src/core/engine-locale.test.ts +593 -0
  413. package/src/core/engine-locale.ts +175 -0
  414. package/src/core/exporter.ts +3 -3
  415. package/src/core/fork-reconcile.test.ts +279 -0
  416. package/src/core/fork-reconcile.ts +234 -0
  417. package/src/core/journal.ts +6 -5
  418. package/src/core/local-plugin-host.ts +3 -2
  419. package/src/core/local-plugin-pack.ts +11 -6
  420. package/src/core/messages-core.ts +661 -0
  421. package/src/core/messages.ts +125 -17
  422. package/src/core/migration-consult.ts +21 -20
  423. package/src/core/migration-history.ts +19 -5
  424. package/src/core/model-tools.ts +28 -27
  425. package/src/core/phase3-host.ts +21 -5
  426. package/src/core/phase3-p2b-fingerprint.test.ts +22 -0
  427. package/src/core/plugin-cli.ts +18 -19
  428. package/src/core/plugin-data-registry.test.ts +113 -0
  429. package/src/core/plugin-data-registry.ts +261 -0
  430. package/src/core/reconcile.test.ts +117 -0
  431. package/src/core/reconcile.ts +85 -17
  432. package/src/core/recovery-orchestrator.ts +19 -18
  433. package/src/core/reinstall.ts +26 -21
  434. package/src/core/restore.ts +20 -17
  435. package/src/core/rollback-file-diff.test.ts +159 -0
  436. package/src/core/rollback-gate.test.ts +66 -0
  437. package/src/core/rollback-gate.ts +61 -0
  438. package/src/core/rollback-ignore-rules.ts +126 -0
  439. package/src/core/rollback-ledger.test.ts +655 -0
  440. package/src/core/rollback-ledger.ts +1314 -0
  441. package/src/core/rollback-point.test.ts +2853 -0
  442. package/src/core/rollback-point.ts +4340 -0
  443. package/src/core/rollback.ts +5 -3
  444. package/src/core/run-registry.ts +6 -5
  445. package/src/core/session-health.ts +15 -0
  446. package/src/core/session-meta.test.ts +117 -1
  447. package/src/core/session-meta.ts +149 -2
  448. package/src/core/session-title.test.ts +127 -0
  449. package/src/core/session-title.ts +189 -0
  450. package/src/core/startup-barrier.test.ts +69 -0
  451. package/src/core/startup-barrier.ts +27 -0
  452. package/src/core/transaction-coordinator.ts +4 -3
  453. package/src/core/types.ts +37 -1
  454. package/src/core/validator.ts +5 -4
  455. package/src/core/verify-recovery.ts +31 -30
  456. package/src/foreign/bundle.ts +3 -2
  457. package/src/foreign/continue.ts +2 -1
  458. package/src/foreign/crush.ts +2 -1
  459. package/src/foreign/file-budget.test.ts +2 -0
  460. package/src/foreign/gemini.ts +2 -1
  461. package/src/foreign/goose.ts +2 -1
  462. package/src/foreign/kilocode.ts +2 -1
  463. package/src/foreign/kimi.ts +2 -1
  464. package/src/foreign/messages.ts +44 -0
  465. package/src/foreign/mimocode.ts +2 -1
  466. package/src/foreign/openclaw.ts +2 -1
  467. package/src/foreign/opencode.ts +2 -1
  468. package/src/foreign/pi.ts +2 -1
  469. package/src/foreign/qoder.ts +2 -1
  470. package/src/foreign/qwen.ts +2 -1
  471. package/src/foreign/registry.test.ts +1 -1
  472. package/src/foreign/registry.ts +8 -7
  473. package/src/foreign/teleagent.ts +2 -1
  474. package/src/foreign/workbuddy.ts +2 -1
  475. package/src/foreign/zcode.ts +2 -1
  476. package/src/foreign/zed.ts +2 -1
  477. package/src/index.facade.test.ts +340 -1
  478. package/src/index.ts +876 -57
  479. package/src/market/git-file-writer.ts +20 -19
  480. package/src/market/github-repos.test.ts +23 -0
  481. package/src/market/github-repos.ts +37 -19
  482. package/src/market/index-parser.ts +31 -30
  483. package/src/market/messages-market.ts +325 -0
  484. package/src/market/my-repo.ts +32 -29
  485. package/src/market/prepare.ts +18 -15
  486. package/src/market/security.ts +20 -19
  487. package/src/market/types.ts +2 -1
  488. package/src/market/url.ts +2 -1
  489. package/src/migrations/index.ts +6 -5
  490. package/src/migrations/messages-migrations.ts +59 -0
  491. package/src/profiles/dsh-profile-launcher.ts +2 -1
  492. package/src/profiles/dsh-profile-runtime.ts +2 -1
  493. package/src/profiles/messages.ts +31 -0
  494. package/src/routes/backup.ts +18 -2
  495. package/src/routes/import.ts +3 -3
  496. package/src/routes/index.ts +1 -1
  497. package/src/routes/kit.ts +9 -3
  498. package/src/routes/market.ts +1 -1
  499. package/src/routes/messages.ts +106 -0
  500. package/src/routes/prefs.ts +31 -2
  501. package/src/routes/profiles.ts +12 -6
  502. package/src/routes/recovery.test.ts +405 -1
  503. package/src/routes/recovery.ts +477 -94
  504. package/src/routes/snapshots.ts +51 -4
  505. package/src/routes/sync.ts +82 -17
  506. package/src/schema/config.ts +53 -20
  507. package/src/schema/index.ts +7 -5
  508. package/src/schema/manifest-schema.test.ts +4 -4
  509. package/src/schema/manifest.ts +19 -18
  510. package/src/schema/messages.ts +120 -0
  511. package/src/schema/registry.test.ts +28 -7
  512. package/src/schema/section-registry.ts +96 -42
  513. package/src/schema/types.ts +2 -1
  514. package/src/schema/versions.ts +6 -5
  515. package/src/security/encryption.ts +16 -15
  516. package/src/security/integrity.ts +8 -7
  517. package/src/security/messages.ts +87 -0
  518. package/src/security/redaction.test.ts +17 -1
  519. package/src/security/redaction.ts +13 -0
  520. package/src/security/secret-scanner.ts +19 -12
  521. package/src/security/security.test.ts +47 -0
  522. package/src/security/vault.ts +2 -1
  523. package/src/security/zip-security.ts +4 -3
  524. package/src/sync/ancestor.ts +2 -1
  525. package/src/sync/autosync-config.ts +21 -0
  526. package/src/sync/autosync-scheduler.test.ts +63 -7
  527. package/src/sync/autosync-scheduler.ts +118 -78
  528. package/src/sync/backup-files.ts +3 -2
  529. package/src/sync/backup-scheduler.ts +6 -5
  530. package/src/sync/blob-store.ts +2 -1
  531. package/src/sync/git/git-transport.test.ts +170 -8
  532. package/src/sync/git/git-transport.ts +176 -2
  533. package/src/sync/github-auth.ts +10 -9
  534. package/src/sync/layout.ts +13 -12
  535. package/src/sync/messages.ts +153 -0
  536. package/src/sync/review-queue.ts +6 -5
  537. package/src/sync/s3/messages.ts +16 -0
  538. package/src/sync/s3/s3-providers.ts +9 -6
  539. package/src/sync/s3/s3-transport.ts +1 -1
  540. package/src/sync/s3/sigv4.ts +3 -1
  541. package/src/sync/snapshot-crypto.ts +6 -5
  542. package/src/sync/snapshot-json.ts +3 -2
  543. package/src/sync/sync-credentials.test.ts +119 -0
  544. package/src/sync/sync-engine-download-tombstone.test.ts +1 -1
  545. package/src/sync/sync-engine.test.ts +226 -8
  546. package/src/sync/sync-engine.ts +154 -52
  547. package/src/sync/sync-history.ts +7 -6
  548. package/src/sync/sync-selection.test.ts +12 -2
  549. package/src/sync/sync-selection.ts +18 -5
  550. package/src/sync/transport.test.ts +1 -0
  551. package/src/sync/ui-prefs.test.ts +44 -1
  552. package/src/sync/ui-prefs.ts +39 -0
  553. package/src/sync/webdav/webdav-transport.ts +4 -1
  554. package/src/ui/artifact-view.ts +4 -3
  555. package/src/ui/commands.test.ts +1 -0
  556. package/src/ui/commands.ts +11 -1
  557. package/src/ui/diff-view.test.ts +9 -1
  558. package/src/ui/diff-view.ts +22 -0
  559. package/src/ui/disk-usage-view.test.ts +113 -6
  560. package/src/ui/disk-usage-view.ts +63 -7
  561. package/src/ui/errors.ts +4 -3
  562. package/src/ui/export-flow.ts +26 -20
  563. package/src/ui/foreign-view.test.ts +43 -0
  564. package/src/ui/foreign-view.ts +30 -3
  565. package/src/ui/i18n.ts +131 -2
  566. package/src/ui/import-wizard.ts +18 -10
  567. package/src/ui/progress.ts +2 -2
  568. package/src/ui/report.ts +7 -6
  569. package/src/ui/rollback-view.test.ts +836 -0
  570. package/src/ui/rollback-view.ts +1405 -0
  571. package/src/ui/selection-model.test.ts +33 -1
  572. package/src/ui/selection-model.ts +53 -0
  573. package/src/ui/session-inventory-view.test.ts +109 -0
  574. package/src/ui/session-inventory-view.ts +138 -3
  575. package/src/ui/session-issue-labels.test.ts +51 -0
  576. package/src/ui/session-issue-labels.ts +70 -0
  577. package/src/ui/sync-settings-view.test.ts +36 -0
  578. package/src/ui/sync-settings-view.ts +49 -0
  579. package/src/ui/test-helpers.ts +2 -1
  580. package/src/ui/types.ts +10 -4
  581. package/src/utils/atomic-write.ts +6 -5
  582. package/src/utils/bundle-scan.test.ts +2 -2
  583. package/src/utils/bundle-scan.ts +12 -1
  584. package/src/utils/env-lock.ts +83 -49
  585. package/src/utils/json.ts +7 -5
  586. package/src/utils/messages-utils.ts +232 -0
  587. package/src/utils/proxy.test.ts +25 -0
  588. package/src/utils/proxy.ts +19 -5
  589. package/src/utils/recursive-walk.test.ts +43 -0
  590. package/src/utils/recursive-walk.ts +75 -1
  591. package/src/utils/session-log-repair.ts +10 -9
  592. package/src/utils/zip.ts +27 -26
  593. package/lib/core/checkpoint.d.ts +0 -362
  594. package/lib/core/checkpoint.js +0 -1309
  595. package/src/core/checkpoint.test.ts +0 -665
  596. package/src/core/checkpoint.ts +0 -1614
@@ -0,0 +1,1174 @@
1
+ /**
2
+ * T1 判定层测试:**每条用户消息一个回退入口**(node:test,零依赖)。
3
+ *
4
+ * 六条验收点逐条落地:
5
+ * ① 槽位接管:注册 { name:'conversation.chat.node', key:'user' }(2026-10-12:入口只剩「每条用户消息」一种形态,
6
+ * 旧的「整回合」档、偏好模块与回合尾入口已删除);steering 是**另一个** key,本插件从不注册它;
7
+ * ② 气泡能力面:文本(保留换行/空白)/ 图片(载荷逐字转发给 owner 的 renderMessageImages)/
8
+ * 文件卡(文件名 + 扩展名 + 体积)/ **rest 兜底**(未知块进 raw,绝不静默丢弃);
9
+ * ③ 按钮启用/禁用矩阵:六种禁用原因各自的判据(含「回合未收尾」与「未命中」);
10
+ * ④ 错误边界降级:投影抛错 → 仍能拿到**纯文本** + ASCII console.warn;
11
+ * ⑤ 回合号只认可核实字段(node.location.kind 必须是 turn/step),取不到 → 如实禁用;
12
+ * ⑥ 回滚点惰取的缓存与在途去重(每条消息一颗按钮,不能 N 个并发请求)。
13
+ *
14
+ * 纪律:本文件不 import React 组件(*.tsx 不能在 node 下被 type-strip 加载)——组件里能测的那部分
15
+ * 全部提炼在 chat-entry-view.ts / chat-node-registration.ts 两个纯模块里。
16
+ */
17
+ import test from 'node:test'
18
+ import assert from 'node:assert/strict'
19
+ import { readFileSync, readdirSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ import {
24
+ buildChatEntryBubble,
25
+ buildChatEntrySafeRow,
26
+ buildChatEntryView,
27
+ chatEntryButtonState,
28
+ chatEntryTurnPhase,
29
+ CHAT_ENTRY_DISABLED_KEYS,
30
+ CHAT_ENTRY_WARN_PREFIX,
31
+ CHAT_NODE_PRIORITY,
32
+ CHAT_NODE_SLOT,
33
+ CHAT_NODE_USER_KEY,
34
+ chatEntryCompactOf,
35
+ chatNodeKind,
36
+ chatNodeTurn,
37
+ fileExtensionOf,
38
+ fileSizeTextOf,
39
+ formatChatEntryClock,
40
+ lookupPointForTurn,
41
+ plainTextOf,
42
+ planChatEntryAttachments,
43
+ planChatNodeRegistration,
44
+ projectChatEntryText,
45
+ resetChatPointCache,
46
+ shouldRetryChatPoint,
47
+ CHAT_POINT_LOOKUP_WINDOW_MS,
48
+ CHAT_POINT_RETRY_DELAYS_MS,
49
+ type ChatEntryDisabledCode,
50
+ type ChatPointLookup,
51
+ } from './chat-entry-view.ts'
52
+ // T6:重试循环与「一个实例只跑一轮窗口」的状态机住在自己的模块里(可被 node 直接加载断言)
53
+ import {
54
+ ChatPointWindowController,
55
+ retryChatPointUntilHit,
56
+ type ChatPointWindowState,
57
+ } from './chat-point-window.ts'
58
+ import { declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
59
+ import { en as chatEn, zh as chatZh } from './chat-locales.ts'
60
+ import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
61
+
62
+ /* ------------------------------------------------------------ 夹具 */
63
+
64
+ /** 宿主节点夹具:location.kind='step' + turn.turn/status(与宿主 turnOf 的读法同源)。 */
65
+ function nodeOf(options: { kind?: string; turn?: number; status?: string; content?: unknown } = {}): unknown {
66
+ const kind = options.kind ?? 'user'
67
+ const turn = options.turn ?? 3
68
+ const status = options.status ?? 'closed'
69
+ return {
70
+ key: 'n1',
71
+ kind,
72
+ anchorSeq: 11,
73
+ location: { kind: 'step', turn: { turn, status, steps: [] }, step: { turn, step: 1, status } },
74
+ data: { content: options.content ?? [{ type: 'text', text: 'hello' }] },
75
+ }
76
+ }
77
+
78
+ const POINTS: RollbackPointSummary[] = [
79
+ { id: 'p-turn-3-guard', createdAt: 1, sessionId: 's1', turn: 3, kind: 'guard' },
80
+ { id: 'p-turn-3-auto', createdAt: 2, sessionId: 's1', turn: 3, kind: 'auto' },
81
+ { id: 'p-turn-2-auto', createdAt: 3, sessionId: 's1', turn: 2, kind: 'auto' },
82
+ { id: 'p-other-session', createdAt: 4, sessionId: 's2', turn: 3, kind: 'auto' },
83
+ ]
84
+
85
+ function fakePort(points: RollbackPointSummary[] = POINTS): { port: RollbackPointPort; calls: () => number } {
86
+ let calls = 0
87
+ const notUsed = async (): Promise<never> => { throw new Error('this port method must not be called in this test') }
88
+ const port: RollbackPointPort = {
89
+ rollbackPoints: async () => { calls += 1; return { points, storage: { available: true } } },
90
+ rollbackPointStorage: notUsed as RollbackPointPort['rollbackPointStorage'],
91
+ rollbackPoint: notUsed as RollbackPointPort['rollbackPoint'],
92
+ rollbackPointPreview: notUsed as RollbackPointPort['rollbackPointPreview'],
93
+ restoreRollbackPoint: notUsed as RollbackPointPort['restoreRollbackPoint'],
94
+ removeRollbackPoint: notUsed as RollbackPointPort['removeRollbackPoint'],
95
+ }
96
+ return { port, calls: () => calls }
97
+ }
98
+
99
+ /* ------------------------------------------------------------ ① 槽位接管:注册时机 */
100
+
101
+ test('slot constants: chat.node keyed slot, user key, priority -1 (shadowing rank below the host 0)', () => {
102
+ assert.equal(CHAT_NODE_SLOT, 'conversation.chat.node')
103
+ assert.equal(CHAT_NODE_USER_KEY, 'user')
104
+ assert.equal(CHAT_NODE_PRIORITY, -1)
105
+ })
106
+
107
+ test('registration plan: a keyed declaration registers; anything else is skipped, never thrown', () => {
108
+ assert.deepEqual(planChatNodeRegistration('keyed'), { kind: 'register' })
109
+ assert.deepEqual(planChatNodeRegistration(null), { kind: 'skip', reason: 'slot-kind-mismatch', declared: null })
110
+ assert.deepEqual(planChatNodeRegistration('list'), { kind: 'skip', reason: 'slot-kind-mismatch', declared: 'list' })
111
+ })
112
+
113
+ test('register shell: keyed declaration registers exactly once; non-keyed declaration is skipped, not thrown', () => {
114
+ const warn: string[] = []
115
+ let called = 0
116
+ registerChatNodeEntry({
117
+ probe: { spec: (key) => (key === CHAT_NODE_SLOT ? { kind: 'keyed', scope: 'session' } : undefined) },
118
+ register: () => { called += 1; return () => {} },
119
+ warn: (message) => { warn.push(message) },
120
+ })
121
+ assert.equal(called, 1)
122
+ // 注意用 length 而不是 deepEqual(warn, []):node:assert 的 asserts-signature 会把 warn 收窄成 never[]
123
+ assert.equal(warn.length, 0)
124
+
125
+ let second = 0
126
+ registerChatNodeEntry({
127
+ probe: { spec: () => ({ kind: 'chain', scope: 'session' }) },
128
+ register: () => { second += 1; return () => {} },
129
+ warn: (message) => { warn.push(message) },
130
+ })
131
+ assert.equal(second, 0, 'a non-keyed declaration must be skipped (registering with key/priority would throw)')
132
+ assert.ok(warn.some((line) => line.includes('"chain"')))
133
+
134
+ // 探测面本身读不到 / 抛错 → 一样只跳过这一个入口
135
+ assert.equal(declaredSlotKind(undefined), null)
136
+ assert.equal(declaredSlotKind({ spec: () => { throw new Error('boom') } }), null)
137
+ })
138
+
139
+ test('register shell: a throwing register() is contained (never takes the plugin down)', () => {
140
+ const warn: string[] = []
141
+ const disposer = registerChatNodeEntry({
142
+ probe: { spec: () => ({ kind: 'keyed', scope: 'session' }) },
143
+ register: () => { throw new Error('register exploded') },
144
+ warn: (message) => { warn.push(message) },
145
+ })
146
+ assert.equal(typeof disposer, 'function')
147
+ assert.equal(disposer(), undefined, 'the contained failure returns a no-op disposer')
148
+ assert.ok(warn.some((line) => line.includes('register exploded')))
149
+ })
150
+
151
+ test('steering is a DIFFERENT key: we never register it (host renderer unchanged)', () => {
152
+ const keys: string[] = []
153
+ registerChatNodeEntry({
154
+ probe: { spec: () => ({ kind: 'keyed', scope: 'session' }) },
155
+ register: () => { keys.push(CHAT_NODE_USER_KEY); return () => {} },
156
+ warn: () => {},
157
+ })
158
+ assert.deepEqual(keys, ['user'])
159
+ assert.ok(!keys.includes('steering'))
160
+ // 节点侧的同一件事:kind 读到 steering 时我们照样只按数据渲染,绝不因此改注册面
161
+ assert.equal(chatNodeKind(nodeOf({ kind: 'steering' })), 'steering')
162
+ assert.equal(chatNodeKind(nodeOf({ kind: 'user' })), 'user')
163
+ })
164
+
165
+ const HERE = fileURLToPath(new URL('.', import.meta.url))
166
+
167
+ /** 读一个组件源码(*.tsx 不能在 node 下 type-strip 加载,故走源码级断言)。 */
168
+ function sourceOf(file: string): string {
169
+ return readFileSync(join(HERE, file), 'utf8')
170
+ }
171
+
172
+ test('icon-only:两个入口都只画图标(无文字标签、无边框按钮、不 import Button)', () => {
173
+ for (const file of ['ChatEntryView.tsx']) {
174
+ const src = sourceOf(file)
175
+ assert.doesNotMatch(src, /from '\.\.\/common\/ui\.tsx'/, file + ':入口不得再 import Button(icon 级入口不用按钮组件)')
176
+ assert.doesNotMatch(src, /<Button\b/, file + ':不得渲染 Button')
177
+ // 图标复用 common/Icon.tsx 的 lucide 图标
178
+ assert.match(src, /RefreshIcon/, file + ':必须复用 common/Icon.tsx 的回退图标')
179
+ // 与复制图标同一个观感/触达区(无边框、无文字)
180
+ assert.match(src, /className=\{css\.chatActionBtn\}/, file + ':icon 入口必须用与复制图标同一颗 .chatActionBtn')
181
+ assert.match(src, /aria-label=\{t\('chat\.entry\.label'\)\}|aria-label=\{rt\('rollback\.preview\.turns'/, file + ':可见文案退到 aria-label,无障碍仍可读')
182
+ }
183
+ // 每条用户消息那颗按钮上不得再出现文字标签
184
+ const entry = sourceOf('ChatEntryView.tsx')
185
+ assert.doesNotMatch(entry, /\{t\('chat\.entry\.label'\)\}\s*<\/button>/, '不得把文案当可见子节点渲染')
186
+ assert.doesNotMatch(entry, /title=\{t\('chat\.entry\.label'\)\}>\s*<RefreshIcon[^/]*\/>\s*[^<]/, '按钮里除图标外不得有文字')
187
+ })
188
+
189
+ test('样式纪律:icon 入口与预览文件行只用既有 token/类,未引入第二套视觉体系', () => {
190
+ const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
191
+ // 新增的四个类都必须在唯一样式表里(组件里用了 css.* 却没定义就会渲染成 undefined)
192
+ const used = new Set<string>()
193
+ for (const file of readdirSync(HERE).filter((f) => f.endsWith('.tsx'))) {
194
+ const src = readFileSync(join(HERE, file), 'utf8')
195
+ for (const m of src.matchAll(/css\.([A-Za-z0-9_]+)/g)) used.add(m[1] as string)
196
+ }
197
+ // 2026-10-12:预览文件行搬进了共享组件(src/client/recovery/RollbackFileChanges.tsx)——
198
+ // 设置页回滚卡的预览弹窗要用同一份渲染,所以这几个类现在由那个文件用;扫描面跟着扩到那里。
199
+ const sharedFile = readFileSync(join(HERE, '..', 'recovery', 'RollbackFileChanges.tsx'), 'utf8')
200
+ for (const m of sharedFile.matchAll(/css\.([A-Za-z0-9_]+)/g)) used.add(m[1] as string)
201
+ for (const name of ['chatActionBtn', 'rollbackFileRow', 'rollbackFilePath', 'rollbackAdded', 'rollbackRemoved']) {
202
+ assert.ok(used.has(name), '本任务新增/复用的类必须真的被组件用到:' + name)
203
+ assert.ok(new RegExp('\\.' + name + '(?![A-Za-z0-9_-])').test(cssSheet), 'config-manager.module.css 缺类名:' + name)
204
+ }
205
+ })
206
+
207
+ /* ------------------------------------------------------------ ⑤ 回合号只认可核实字段 */
208
+
209
+ test('turn number: only node.location.kind turn|step counts (same read as the host turnTail)', () => {
210
+ assert.deepEqual(chatNodeTurn(nodeOf({ turn: 7, status: 'closed' })), { turn: 7, status: 'closed' })
211
+ assert.deepEqual(chatNodeTurn({ kind: 'user', location: { kind: 'turn', turn: { turn: 2, status: 'open' } } }), { turn: 2, status: 'open' })
212
+ // 不可核实的形状一律 null(绝不猜一个回合号)
213
+ assert.equal(chatNodeTurn({ kind: 'user', location: { kind: 'session' } }), null)
214
+ assert.equal(chatNodeTurn({ kind: 'user', location: { kind: 'unresolved' } }), null)
215
+ assert.equal(chatNodeTurn({ kind: 'user' }), null)
216
+ assert.equal(chatNodeTurn({ kind: 'user', location: { kind: 'step', turn: { status: 'closed' } } }), null)
217
+ assert.equal(chatNodeTurn({ kind: 'user', location: { kind: 'step', turn: { turn: '3' } } }), null)
218
+ assert.equal(chatNodeTurn(null), null)
219
+ assert.equal(chatNodeTurn(undefined), null)
220
+ })
221
+
222
+ test('turn phase: only closed/open are provable; anything else is unknown (not "not finished")', () => {
223
+ assert.equal(chatEntryTurnPhase({ turn: 1, status: 'closed' }), 'closed')
224
+ assert.equal(chatEntryTurnPhase({ turn: 1, status: 'open' }), 'open')
225
+ assert.equal(chatEntryTurnPhase({ turn: 1, status: 'unknown' }), 'unknown')
226
+ assert.equal(chatEntryTurnPhase({ turn: 1, status: null }), 'unknown')
227
+ assert.equal(chatEntryTurnPhase(null), 'unknown')
228
+ })
229
+
230
+ /* ------------------------------------------------------------ ③ 按钮启用/禁用矩阵 */
231
+
232
+ const CLOSED = { resolved: true, point: { id: 'p1', createdAt: 1, sessionId: 's1', turn: 3, kind: 'auto' } }
233
+ const MISSING = { resolved: true, point: null }
234
+ const UNRESOLVED = { resolved: false, point: null }
235
+
236
+ test('button matrix: every state maps to an enabled button or its own provable disabled reason', () => {
237
+ const cases: { name: string; node: unknown; sessionId: string; lookup: unknown; pending: boolean; retrying?: boolean; expect: string }[] = [
238
+ { name: 'closed + hit', node: nodeOf(), sessionId: 's1', lookup: CLOSED, pending: false, expect: 'enabled' },
239
+ { name: 'closed + no point', node: nodeOf(), sessionId: 's1', lookup: MISSING, pending: false, expect: 'noPoint' },
240
+ // 读失败(resolved=false 且不在途)→ 乐观可点:判据未知时**不**说「没有点」,让对话框走宿主的错误路径
241
+ { name: 'closed + read failed (unknown verdict)', node: nodeOf(), sessionId: 's1', lookup: UNRESOLVED, pending: false, expect: 'enabled' },
242
+ { name: 'closed + first paint before the read starts', node: nodeOf(), sessionId: 's1', lookup: null, pending: false, expect: 'enabled' },
243
+ { name: 'closed + reading in flight', node: nodeOf(), sessionId: 's1', lookup: null, pending: true, expect: 'pointsPending' },
244
+ // R1:列表已读到但**空** + 重试窗口还开着 ⇒ 暂态,不得说「没有回滚点」(窗口关后同一状态才是 noPoint)
245
+ { name: 'closed + empty list while the retry window is open', node: nodeOf(), sessionId: 's1', lookup: MISSING, pending: false, retrying: true, expect: 'pointsPending' },
246
+ // T9:窗口开着、**首读还在途**(lookup 仍为 null)同样是「还没结论」⇒ 正在读取,不得乐观放行
247
+ { name: 'closed + first read in flight while the window is open', node: nodeOf(), sessionId: 's1', lookup: null, pending: false, retrying: true, expect: 'pointsPending' },
248
+ { name: 'turn open', node: nodeOf({ status: 'open' }), sessionId: 's1', lookup: CLOSED, pending: false, expect: 'turnOpen' },
249
+ { name: 'turn status unknown', node: nodeOf({ status: 'unknown' }), sessionId: 's1', lookup: CLOSED, pending: false, expect: 'turnUnknown' },
250
+ { name: 'no verifiable turn number', node: { kind: 'user', data: { content: [] } }, sessionId: 's1', lookup: CLOSED, pending: false, expect: 'noTurn' },
251
+ { name: 'no sessionId', node: nodeOf(), sessionId: '', lookup: CLOSED, pending: false, expect: 'noSession' },
252
+ ]
253
+ for (const item of cases) {
254
+ const state = chatEntryButtonState({ node: item.node, sessionId: item.sessionId, lookup: item.lookup as never, pending: item.pending, retrying: item.retrying })
255
+ if (item.expect === 'enabled') assert.equal(state.kind, 'enabled', item.name)
256
+ else {
257
+ assert.equal(state.kind, 'disabled', item.name)
258
+ assert.equal(state.kind === 'disabled' ? state.code : 'enabled', item.expect, item.name)
259
+ }
260
+ if (state.kind === 'disabled') {
261
+ assert.equal(state.key, CHAT_ENTRY_DISABLED_KEYS[state.code], item.name + ': key must come from the single map')
262
+ assert.ok(chatZh[state.key as keyof typeof chatZh] !== undefined, item.name + ': zh dictionary missing ' + state.key)
263
+ assert.ok(chatEn[state.key as keyof typeof chatEn] !== undefined, item.name + ': en dictionary missing ' + state.key)
264
+ } else {
265
+ assert.equal(state.turn, 3, item.name + ': the enabled button carries the verifiable turn number')
266
+ }
267
+ }
268
+ })
269
+
270
+ test('disabled reasons are pairwise distinct and every key exists in zh+en', () => {
271
+ const codes = Object.keys(CHAT_ENTRY_DISABLED_KEYS) as ChatEntryDisabledCode[]
272
+ const keys = codes.map((code) => CHAT_ENTRY_DISABLED_KEYS[code])
273
+ assert.equal(new Set(keys).size, codes.length, 'two reasons must never share one sentence')
274
+ for (const key of keys) {
275
+ const zh = String(chatZh[key as keyof typeof chatZh])
276
+ const en = String(chatEn[key as keyof typeof chatEn])
277
+ assert.ok(zh !== '', 'zh dictionary is missing ' + key)
278
+ assert.ok(en !== '', 'en dictionary is missing ' + key)
279
+ }
280
+ })
281
+
282
+ /* ------------------------------------------------------------ ⑥ 回滚点惰取(缓存 / 去重) */
283
+
284
+ test('point lookup: the list is read once per session (N messages -> 1 request) and matched per turn', async () => {
285
+ resetChatPointCache()
286
+ const { port, calls } = fakePort()
287
+ const first = await lookupPointForTurn({ port, sessionId: 's1', turn: 3 })
288
+ assert.equal(first.resolved, true)
289
+ assert.equal(first.point?.id, 'p-turn-3-auto', 'kind=auto wins over guard for the same turn')
290
+ // 同一会话的其它回合 / 同一回合的重复读 / 另一个会话 —— 各自都只按会话取一次列表
291
+ assert.equal((await lookupPointForTurn({ port, sessionId: 's1', turn: 3 })).point?.id, 'p-turn-3-auto')
292
+ assert.equal((await lookupPointForTurn({ port, sessionId: 's1', turn: 2 })).point?.id, 'p-turn-2-auto')
293
+ assert.equal((await lookupPointForTurn({ port, sessionId: 's1', turn: 9 })).point, null, 'same session, absent turn -> null')
294
+ assert.equal(calls(), 1, 'one conversation = one list read, shared by every message button')
295
+ assert.equal((await lookupPointForTurn({ port, sessionId: 's3', turn: 3 })).point, null, 'another session never matches')
296
+ assert.equal(calls(), 2, 'a different session reads its own list')
297
+ // 过期的缓存必须重读(TTL 判据用注入的 now,不依赖真实时钟)
298
+ const later = await lookupPointForTurn({ port, sessionId: 's1', turn: 3, now: Date.now() + 60_000 })
299
+ assert.equal(later.resolved, true)
300
+ assert.equal(calls(), 3, 'an expired entry is refetched')
301
+ resetChatPointCache()
302
+ })
303
+
304
+ test('point lookup: concurrent reads dedupe into one request; a failed read stays unresolved (never "no point")', async () => {
305
+ resetChatPointCache()
306
+ let calls = 0
307
+ const pending = new Map<string, RollbackPointPort['rollbackPoints']>()
308
+ const slowPort = {
309
+ ...fakePort().port,
310
+ rollbackPoints: async () => {
311
+ calls += 1
312
+ await new Promise((resolve) => { setTimeout(resolve, 5) })
313
+ return { points: POINTS, storage: { available: true } }
314
+ },
315
+ } as RollbackPointPort
316
+ const [a, b, c] = await Promise.all([
317
+ lookupPointForTurn({ port: slowPort, sessionId: 's1', turn: 3 }),
318
+ lookupPointForTurn({ port: slowPort, sessionId: 's1', turn: 3 }),
319
+ lookupPointForTurn({ port: slowPort, sessionId: 's1', turn: 3 }),
320
+ ])
321
+ assert.equal(calls, 1, 'three concurrent buttons must share one request')
322
+ assert.equal(a.point?.id, 'p-turn-3-auto')
323
+ assert.equal(b.point?.id, 'p-turn-3-auto')
324
+ assert.equal(c.point?.id, 'p-turn-3-auto')
325
+ assert.equal(pending.size, 0)
326
+
327
+ resetChatPointCache()
328
+ const { port: failing } = fakePort()
329
+ const boom: RollbackPointPort = {
330
+ ...failing,
331
+ rollbackPoints: async () => { throw new Error('host down') },
332
+ } as RollbackPointPort
333
+ const failed = await lookupPointForTurn({ port: boom, sessionId: 's1', turn: 3 })
334
+ assert.deepEqual(failed, { point: null, resolved: false }, 'a failed read is "unknown", never a proven absence')
335
+ resetChatPointCache()
336
+ })
337
+
338
+ /* ---------------------------------------- ⑦ fork 子会话:继承回合沿父链解析(t9) */
339
+
340
+ /**
341
+ * 带**继承链**的宿主列表载荷。
342
+ *
343
+ * 引擎 `list()` 回传 `inheritLinks`(fork 子会话 id → 父会话 id);`src/ui/rollback-view.ts` 的
344
+ * `RollbackListResult` 类型**尚未登记**该字段(不在 t9 scope)⇒ 客户端按宽度接收读取,
345
+ * 这里也按同一姿势构造夹具(多出的字段经断言挂上去,运行时形状与宿主逐字相同)。
346
+ */
347
+ function forkPort(options: {
348
+ points?: readonly RollbackPointSummary[]
349
+ inheritLinks?: Record<string, string>
350
+ /** 继承链是否完整(false = 扫描被预算截断 ⇒ 未命中不得当成「确知没有」) */
351
+ inheritLinksComplete?: boolean
352
+ } = {}): RollbackPointPort {
353
+ const notUsed = async (): Promise<never> => { throw new Error('this port method must not be called in this test') }
354
+ const list = {
355
+ points: options.points ?? PARENT_POINTS,
356
+ storage: { available: true },
357
+ ...(options.inheritLinks !== undefined ? { inheritLinks: options.inheritLinks } : {}),
358
+ ...(options.inheritLinksComplete !== undefined ? { inheritLinksComplete: options.inheritLinksComplete } : {}),
359
+ } as unknown as RollbackListResult
360
+ return {
361
+ rollbackPoints: async () => list,
362
+ rollbackPointStorage: notUsed as RollbackPointPort['rollbackPointStorage'],
363
+ rollbackPoint: notUsed as RollbackPointPort['rollbackPoint'],
364
+ rollbackPointPreview: notUsed as RollbackPointPort['rollbackPointPreview'],
365
+ restoreRollbackPoint: notUsed as RollbackPointPort['restoreRollbackPoint'],
366
+ removeRollbackPoint: notUsed as RollbackPointPort['removeRollbackPoint'],
367
+ }
368
+ }
369
+
370
+ /** 父会话 p1 的回滚点(回合 1..3)—— 子会话自身一个点都没有。 */
371
+ const PARENT_POINTS: RollbackPointSummary[] = [
372
+ { id: 'p-p1-t1', createdAt: 1, sessionId: 'p1', turn: 1, kind: 'auto' },
373
+ { id: 'p-p1-t2', createdAt: 2, sessionId: 'p1', turn: 2, kind: 'auto' },
374
+ { id: 'p-p1-t3', createdAt: 3, sessionId: 'p1', turn: 3, kind: 'auto' },
375
+ ]
376
+
377
+ test('t9 复现:fork 子会话 replay 父会话回合、自身 0 点 ⇒ 沿父链拿到可用回退目标(修复前为 null)', async () => {
378
+ resetChatPointCache()
379
+ const port = forkPort({ inheritLinks: { c1: 'p1' } })
380
+ const hit = await lookupPointForTurn({ port, sessionId: 'c1', turn: 2 })
381
+ assert.equal(hit.resolved, true)
382
+ assert.equal(hit.point?.id, 'p-p1-t2', '本会话/本回合未命中 ⇒ 沿 parentSession 上溯到父会话同回合的点')
383
+ assert.equal(hit.point?.sessionId, 'p1', '目标仍是原会话(父)的点 —— 回退动作在原会话上做 fork,语义与今天一致')
384
+ assert.equal(hit.point?.turn, 2)
385
+ // 没有祖先点的回合仍然如实说「没有」(绝不猜最近的一个)
386
+ assert.equal((await lookupPointForTurn({ port, sessionId: 'c1', turn: 9 })).point, null)
387
+ // 父会话自己:命中自己的点(不走祖先路径)
388
+ assert.equal((await lookupPointForTurn({ port, sessionId: 'p1', turn: 3 })).point?.id, 'p-p1-t3')
389
+ })
390
+
391
+ test('t9 优先级:子会话自己有同回合的点 ⇒ 用自己那个,绝不越级拿祖先的', async () => {
392
+ resetChatPointCache()
393
+ const own: RollbackPointSummary[] = [
394
+ ...PARENT_POINTS,
395
+ { id: 'p-c1-t2', createdAt: 9, sessionId: 'c1', turn: 2, kind: 'auto' },
396
+ ]
397
+ const port = forkPort({ points: own, inheritLinks: { c1: 'p1' } })
398
+ assert.equal((await lookupPointForTurn({ port, sessionId: 'c1', turn: 2 })).point?.id, 'p-c1-t2')
399
+ })
400
+
401
+ test('t9 多代上溯:子 → 子 → 祖(同回合点在最上面)也能拿到;只上溯不横跳', async () => {
402
+ resetChatPointCache()
403
+ const port = forkPort({ inheritLinks: { c2: 'c1', c1: 'p1' } })
404
+ assert.equal((await lookupPointForTurn({ port, sessionId: 'c2', turn: 1 })).point?.id, 'p-p1-t1')
405
+ // 链外的会话(别的分支)不得被误命中
406
+ assert.equal((await lookupPointForTurn({ port, sessionId: 'other', turn: 1 })).point, null)
407
+ })
408
+
409
+ test('t9 环保护:自指与互相指都不挂起、不误命中(链不可信 ⇒ 判据未知,不得说「确知没有」)', async () => {
410
+ resetChatPointCache()
411
+ const self = forkPort({ inheritLinks: { a: 'a' } })
412
+ const selfHit = await lookupPointForTurn({ port: self, sessionId: 'a', turn: 1 })
413
+ assert.equal(selfHit.point, null)
414
+ assert.equal(selfHit.resolved, false, '自指链不可信 ⇒ resolved:false(未知),不是「确知没有」')
415
+
416
+ resetChatPointCache()
417
+ const ring = forkPort({ inheritLinks: { a: 'b', b: 'a' } })
418
+ const ringHit = await lookupPointForTurn({ port: ring, sessionId: 'a', turn: 1 })
419
+ assert.equal(ringHit.point, null)
420
+ assert.equal(ringHit.resolved, false)
421
+ })
422
+
423
+ test('t9 深度上限:超深链不挂起;上限内的祖先点命中,超出的不误命中', async () => {
424
+ resetChatPointCache()
425
+ // s0 → s1 → … → s12(12 跳);点只挂在 s12 上
426
+ const links: Record<string, string> = {}
427
+ for (let i = 0; i < 12; i += 1) links['s' + String(i)] = 's' + String(i + 1)
428
+ const deepPoints: RollbackPointSummary[] = [{ id: 'p-s12-t1', createdAt: 1, sessionId: 's12', turn: 1, kind: 'auto' }]
429
+ const deep = forkPort({ points: deepPoints, inheritLinks: links })
430
+ const beyond = await lookupPointForTurn({ port: deep, sessionId: 's0', turn: 1 })
431
+ assert.equal(beyond.point, null, '超过深度上限的祖先不得被当成命中')
432
+ assert.equal(beyond.resolved, false, '看不完的链 ⇒ 判据未知')
433
+
434
+ resetChatPointCache()
435
+ const nearPoints: RollbackPointSummary[] = [{ id: 'p-s3-t1', createdAt: 1, sessionId: 's3', turn: 1, kind: 'auto' }]
436
+ const near = forkPort({ points: nearPoints, inheritLinks: links })
437
+ assert.equal((await lookupPointForTurn({ port: near, sessionId: 's0', turn: 1 })).point?.id, 'p-s3-t1', '上限内的祖先点必须命中')
438
+ })
439
+
440
+ test('t9 旧宿主(载荷里没有继承链)⇒ 逐字保持今天的口径,不新增任何「未知」', async () => {
441
+ resetChatPointCache()
442
+ const port = forkPort({}) // 没有 inheritLinks 字段
443
+ const miss = await lookupPointForTurn({ port, sessionId: 'c1', turn: 2 })
444
+ assert.equal(miss.point, null)
445
+ assert.equal(miss.resolved, true, '没有继承链 = 与今天完全一致:读到列表且未命中 ⇒ 确知没有')
446
+ })
447
+
448
+ test('t9 继承链被截断(inheritLinksComplete=false)⇒ 未命中只能报「未知」,不得报「确知没有」', async () => {
449
+ resetChatPointCache()
450
+ const port = forkPort({ inheritLinks: { c1: 'p1' }, inheritLinksComplete: false })
451
+ const miss = await lookupPointForTurn({ port, sessionId: 'c1', turn: 9 })
452
+ assert.equal(miss.point, null)
453
+ assert.equal(miss.resolved, false, '扫描被截断 ⇒ 链可能缺环,absence 不可证明')
454
+ })
455
+
456
+ /* ------------------------------------------------------------ ⑥b 收尾竞态:有界重试 + 空结果不锁死(R1) */
457
+
458
+ const R1_HIT: ChatPointLookup = { point: { id: 'p-turn-3-auto', createdAt: 9, sessionId: 's1', turn: 3, kind: 'auto' }, resolved: true }
459
+ const R1_EMPTY: ChatPointLookup = { point: null, resolved: true }
460
+ const R1_UNRESOLVED: ChatPointLookup = { point: null, resolved: false }
461
+
462
+ /**
463
+ * 确定性排程器 + 假时钟(**与 chat-entry-mount 的注入式排程器同一套做法**):
464
+ * 每次 `schedule` 把回调放进队列、同时按该档推进假时钟;`flush()` 排空队列并让出事件循环,
465
+ * 使被 await 的重试循环继续跑 —— 所以用例既不用真睡 23 s,也能逐项断言「排了哪几次、等了多久」。
466
+ */
467
+ function retryHarness(startAt = 0) {
468
+ const queue: { fn: () => void; delayMs: number }[] = []
469
+ const delays: number[] = []
470
+ let clock = startAt
471
+ /** 让出一次事件循环 —— 被 await 的循环/状态机借此把下一轮排进来。 */
472
+ const tick = async (): Promise<void> => { await new Promise<void>((resolve) => { setImmediate(resolve) }) }
473
+ /** **时钟在回调触发时前进**(与真 setTimeout 同一因果顺序,别在排程时前进 —— 那会让「时钟没动」的护栏误触发)。 */
474
+ const fire = (entry: { fn: () => void; delayMs: number }): void => { clock += entry.delayMs; entry.fn() }
475
+ return {
476
+ now: () => clock,
477
+ retry: (fn: () => void, delayMs: number) => { delays.push(delayMs); queue.push({ fn, delayMs }) },
478
+ /**
479
+ * 排空调度队列;**若有给定 promise,则在它有界 tick 内 settle 之前一直驱动**(确定性)。
480
+ *
481
+ * 为什么要一起驱动:重试循环在被 await 的微任务链里推进,队列可能在一瞬间是空的
482
+ * (刚执行完一个回调、下一档还没排进来)—— 只看 `queue.length === 0` 就退出会**过早**。
483
+ * 既然循环承诺「要么命中、要么有界结束」,这里就用 tick 上限兜底:循环挂死时用例会失败,
484
+ * 而不是把测试一起拖死。
485
+ *
486
+ * @returns 该 promise 是否在预算内 settle(无 promise 时为 true)
487
+ */
488
+ /**
489
+ * 只推进 **`steps`** 个已排程的回调(不等到跑完)—— 用于「中途取消」这类用例:
490
+ * 先让循环跑一步(首读 + 第一次重试),再从外部取消。
491
+ */
492
+ pump: async (steps: number): Promise<void> => {
493
+ for (let i = 0; i < steps; i += 1) {
494
+ await tick()
495
+ const entry = queue.shift()
496
+ if (entry !== undefined) fire(entry)
497
+ await tick() // 让循环/状态机的微任务链跑完再返回(否则「刚触发」的状态读到一半)
498
+ }
499
+ },
500
+ flush: async (run?: Promise<unknown>, ticks = 300): Promise<boolean> => {
501
+ let settled = run === undefined
502
+ if (run !== undefined) void run.then(() => { settled = true }, () => { settled = true })
503
+ for (let i = 0; i < ticks; i += 1) {
504
+ await tick()
505
+ // 先看还有没有待执行的排程(settled 可能在这一步的微任务里刚刚变 true,但队列里还有回调)
506
+ const entry = queue.shift()
507
+ if (entry !== undefined) { fire(entry); continue }
508
+ // 队列空了才算真的跑完(循环要么在该 tick 里排下一档,要么已经 settle)
509
+ if (settled) return true
510
+ }
511
+ return settled
512
+ },
513
+ delays,
514
+ }
515
+ }
516
+
517
+ test('R1 window: the retry schedule is a fixed 5-step backoff bounded inside an explicit window', () => {
518
+ // 验收要求「在注释与测试里写一个明确值」:这里把三个数字逐项钉住
519
+ assert.deepEqual([...CHAT_POINT_RETRY_DELAYS_MS], [500, 1_500, 3_000, 6_000, 12_000], '退避表 = 0.5 / 1.5 / 3 / 6 / 12 s')
520
+ const cumulative = CHAT_POINT_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0)
521
+ assert.equal(cumulative, 23_000, '退避表累计 23 s')
522
+ assert.equal(CHAT_POINT_LOOKUP_WINDOW_MS, 30_000, '窗口是明确的 30 s(落在 10–30 s 区间)')
523
+ assert.ok(cumulative < CHAT_POINT_LOOKUP_WINDOW_MS, '退避表必须落在窗口内(最后一轮读得到,而不是注定超窗)')
524
+ for (let i = 1; i < CHAT_POINT_RETRY_DELAYS_MS.length; i += 1) {
525
+ assert.ok((CHAT_POINT_RETRY_DELAYS_MS[i] as number) > (CHAT_POINT_RETRY_DELAYS_MS[i - 1] as number), '退避必须严格递增(否则就是轮询)')
526
+ }
527
+ assert.deepEqual(CHAT_POINT_RETRY_DELAYS_MS, [...CHAT_POINT_RETRY_DELAYS_MS].sort((a, b) => a - b), '退避表本身必须有序')
528
+ })
529
+
530
+ test('R1 empty session: retries are bounded (6 reads total, 5 waits, zero further read after the window)', async () => {
531
+ const h = retryHarness()
532
+ const forces: boolean[] = []
533
+ const run = retryChatPointUntilHit({
534
+ attempt: async (force) => { forces.push(force); return R1_EMPTY },
535
+ retry: h.retry,
536
+ now: h.now,
537
+ })
538
+ assert.equal(await h.flush(run), true, '窗口结束时循环必须已 settle(不是悬挂)')
539
+ const { result, reads } = (await run) as { result: ChatPointLookup; reads: number }
540
+ assert.deepEqual(forces, [false, true, true, true, true, true], '首读走普通读,其后每一轮都强制刷新(跳过那 30 s 缓存)')
541
+ assert.equal(reads, 6, '窗口内恰好 6 次读取:1 次首读 + 5 次重试(不是无限轮询)')
542
+ assert.deepEqual(h.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '每一次重试都按退避表的下一档排程')
543
+ assert.equal(h.delays.reduce((sum, delay) => sum + delay, 0), 23_000)
544
+ assert.deepEqual(result, R1_EMPTY, '窗口结束仍为空 ⇒ 调用点从此可以说「没有回滚点」')
545
+ assert.equal(h.now() < CHAT_POINT_LOOKUP_WINDOW_MS, true, '整个重试过程都在窗口内结束')
546
+ })
547
+
548
+ test('R1 hit: the first hit stops the schedule immediately (no further read is ever scheduled)', async () => {
549
+ const h = retryHarness()
550
+ const forces: boolean[] = []
551
+ const run = retryChatPointUntilHit({
552
+ attempt: async (force) => { forces.push(force); return forces.length >= 3 ? R1_HIT : R1_EMPTY },
553
+ retry: h.retry,
554
+ now: h.now,
555
+ })
556
+ assert.equal(await h.flush(run), true, '命中时循环必须已 settle')
557
+ const { result, reads } = (await run) as { result: ChatPointLookup; reads: number }
558
+ assert.equal(reads, 3, '第三次读到点 ⇒ 停止(不再读第 4 次)')
559
+ assert.deepEqual(forces, [false, true, true])
560
+ assert.deepEqual(h.delays, [500, 1_500], '只排了命中前的两次重试:命中后一个定时器都不再排(不 churn)')
561
+ assert.equal(result.point?.id, 'p-turn-3-auto')
562
+ assert.equal(shouldRetryChatPoint(result), false, '命中即停的唯一判据')
563
+ assert.equal(shouldRetryChatPoint(R1_EMPTY), true, '读到空列表仍是暂态,必须继续(这正是旧实现永久禁用的根因)')
564
+ assert.equal(shouldRetryChatPoint(R1_UNRESOLVED), true, '读失败是判据未知,同样不能当成「没有」')
565
+ assert.equal(shouldRetryChatPoint(null), true)
566
+ })
567
+
568
+ test('R1 late write(真机时序:收尾后 7.8 s 才写完点):重试把这个回合的点抓回来', async () => {
569
+ // 真机:turn/end 之后引擎还要 7.8 s 才写完点 ⇒ 首读必空、后续轮才可能命中。
570
+ // 这里用**假时钟上的晚到写回**同构复现:宿主在第 LATE_AT 毫秒之前返回空列表,之后才返回点。
571
+ const LATE_AT = 1_600
572
+ const { port } = fakePort([])
573
+ const late: RollbackPointPort = {
574
+ ...port,
575
+ rollbackPoints: async () => ({ points: Date.now() - startedAt >= LATE_AT ? POINTS : [], storage: { available: true } }),
576
+ } as RollbackPointPort
577
+ const startedAt = Date.now() // 必须在 port 被调用之前取值(闭包里读它)
578
+ resetChatPointCache()
579
+ let reads = 0
580
+ const { result, reads: total } = await retryChatPointUntilHit({
581
+ attempt: (force) => { reads += 1; return lookupPointForTurn({ port: late, sessionId: 's1', turn: 3, force }) },
582
+ // 3/3/3 s 一档 = 退避表的压缩版(假窗口 10 s):第 1 档 3 s 时仍空、第 2 档 6 s 时撞上晚到的写回
583
+ delays: [3_000, 3_000, 3_000],
584
+ windowMs: 10_000,
585
+ })
586
+ assert.equal(result.resolved, true)
587
+ assert.equal(result.point?.id, 'p-turn-3-auto', '空 → 有点的时序里,重试必须把点抓回来(而不是把空列表当真值)')
588
+ assert.ok(reads >= 2 && total >= 2, '至少读了两轮才可能撞上晚到的写回,实际 reads=' + String(total))
589
+ resetChatPointCache()
590
+ })
591
+
592
+ test('R1 force: 强制刷新绕过 30 s 列表缓存(跳过缓存读、写回新结果),且不破在途去重', async () => {
593
+ resetChatPointCache()
594
+ const { port, calls } = fakePort([]) // 首读:列表里还没有这个回合的点(真机的空窗)
595
+ const first = await lookupPointForTurn({ port, sessionId: 's1', turn: 3 })
596
+ assert.equal(first.point, null)
597
+ assert.equal(calls(), 1)
598
+ // 非强制:30 s 缓存内的第二次读**不会**再发请求(空结果被缓存 —— 旧实现就死在这里)
599
+ await lookupPointForTurn({ port, sessionId: 's1', turn: 3 })
600
+ assert.equal(calls(), 1, '非强制读走缓存(30 s TTL 一个字没动)')
601
+ // 强制:跳过缓存读、真的再读一次宿主
602
+ await lookupPointForTurn({ port, sessionId: 's1', turn: 3, force: true })
603
+ assert.equal(calls(), 2, 'force 必须真的再读一次宿主(否则空结果会锁死 30 s)')
604
+ // 且**写回**新结果:宿主侧之后出现了这个回合的点,下一次非强制读立刻拿得到
605
+ const grown = {
606
+ ...fakePort().port,
607
+ rollbackPoints: async () => ({ points: POINTS, storage: { available: true } }),
608
+ } as RollbackPointPort
609
+ const fresh = await lookupPointForTurn({ port: grown, sessionId: 's1', turn: 3, force: true })
610
+ assert.equal(fresh.point?.id, 'p-turn-3-auto', 'force 的结果必须写回缓存')
611
+ const cachedHit = await lookupPointForTurn({ port: grown, sessionId: 's1', turn: 3 })
612
+ assert.equal(cachedHit.point?.id, 'p-turn-3-auto', '写回之后按缓存读也命中(不是「每次都必须 force」)')
613
+ resetChatPointCache()
614
+ })
615
+
616
+ test('R1 wiring:组件接了有界重试,且窗口状态交给一次性状态机(不让 effect 自己重开窗口)', () => {
617
+ const src = sourceOf('ChatEntryView.tsx')
618
+ assert.match(src, /import \{ ChatPointWindowController, type ChatPointWindowState \} from '\.\/chat-point-window\.ts'/, '窗口状态机必须来自纯模块(可被 node 断言)')
619
+ // T9/RR2-A:实例必须建在 effect **体内**;放进 useMemo 会被 StrictMode 的第二次 effect 复用 ⇒ 永久死窗
620
+ assert.match(src, /const controller = new ChatPointWindowController\(\{/, '实例必须在 effect 体内新建(一次 effect 调用 = 一个新实例)')
621
+ assert.doesNotMatch(src, /useMemo\(\s*\(\)\s*=>\s*new ChatPointWindowController/, 'RR2-A:不得再把实例放进 useMemo(那正是 StrictMode 双调用下窗口永久死掉的根因)')
622
+ assert.match(src, /attempt: \(force\) => lookupPointForTurn\(\{ port, sessionId, turn[^)]*force \}\)/, '重试轮必须传 force 以跳过那 30 s 缓存')
623
+ assert.match(src, /chatEntryButtonState\(\{ node, sessionId, lookup, pending: false, retrying \}\)/, '按钮状态机由 retrying(窗口开着)单一驱动,不再需要手写的 pending 标志')
624
+ assert.match(src, /controller\.start\(\)/, '挂载后必须启动这一轮窗口')
625
+ assert.match(src, /return \(\) => \{ controller\.dispose\(\) \}/, '卸载/依赖变化必须走取消通道(F2)')
626
+ assert.doesNotMatch(src, /retryTimerRef/, 'F2:重试定时器 ref / clearTimeout 的旧做法必须消失(改走取消通道)')
627
+ assert.match(src, /\}, \[canLookup, port, sessionId, turn\]\)/, 'F1:effect 依赖只有 canLookup / 会话 / 回合 —— 不含任何 React 状态')
628
+ assert.doesNotMatch(src, /\[canLookup, [^\]]*lookup/, 'F1:依赖里不得含 lookup(窗口关后那次 setState 会重跑 effect)')
629
+ assert.doesNotMatch(src, /windowClosed/, 'T9:windowOpen 取代了 windowClosed(去双重否定,少一处可能漂移的标志)')
630
+ assert.doesNotMatch(src, /if \(!canLookup \|\| turn === null \|\| lookup !== null\) return undefined/, '旧的「读到过就永不重查」条件必须已经消失')
631
+ })
632
+
633
+ /* ------------------------------------------------------------ ⑥c T6:窗口不重开(F1)+ 可取消(F2) */
634
+
635
+ test('F1:点始终不出现时,一个实例只跑一轮(窗口关后把时钟推过 3 个窗口仍恒为 6 次读)', async () => {
636
+ const h = retryHarness()
637
+ let reads = 0
638
+ const seen: ChatPointWindowState[] = []
639
+ const controller = new ChatPointWindowController({
640
+ attempt: async () => { reads += 1; return R1_EMPTY },
641
+ onState: (state) => { seen.push(state) },
642
+ retry: h.retry,
643
+ now: h.now,
644
+ })
645
+ controller.start()
646
+ await h.flush()
647
+ assert.equal(reads, 6, '一轮窗口 = 1 次首读 + 5 次重试')
648
+ assert.deepEqual(h.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '恰好按退避表排了 5 档')
649
+ const closedAt = h.now()
650
+ assert.equal(closedAt, 23_000, '窗口在退避表走完那一刻关掉(累计 23 s)')
651
+ assert.equal(controller.state().open, false, '窗口已关')
652
+ assert.deepEqual(controller.state().lookup, R1_EMPTY, '关窗时的结果就是末次读的结果(空 ⇒ 之后才允许说「没有回滚点」)')
653
+
654
+ // 推过 3 个窗口时长:同实例再 start() 若干次,读次数必须**恒为 6**(不是 18)
655
+ for (let round = 0; round < 3; round += 1) { controller.start(); await h.flush() }
656
+ assert.equal(h.now(), 23_000, '时钟没有再前进(没有新的排程 ⇒ 没有任何新的等待)')
657
+ assert.equal(reads, 6, 'F1:时钟推过 3 个窗口后读取次数仍恒为 6(旧实现会是 6×4=24)')
658
+ assert.deepEqual(h.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '再也没有新的排程(零 churn)')
659
+ assert.equal(seen.length, 2, '状态回调恰好两次:开窗一次 + 关窗一次(没有周期性重开)')
660
+ assert.deepEqual(seen[0], { lookup: null, open: true }, '开窗态:还不知道结果,窗口开着 ⇒ 界面显示「正在读取」')
661
+ })
662
+
663
+ test('F1 证实(旧形态是「每 settle 一次就重开一轮」):同样的假时钟下 4 轮 = 24 次读', async () => {
664
+ // 旧组件的形态:effect 依赖含 lookup ⇒ 每一轮窗口 settle(setLookup 写新对象)就重跑 effect、
665
+ // 守卫只挡「已命中」⇒ 再开一轮。这里把那个形态**逐字建模**成可执行断言 ——
666
+ // 它证明 F1 不是猜测:同样的退避表与窗口,4 轮会读到 24 次(修复后同一时钟下恒为 6 次)。
667
+ const h = retryHarness()
668
+ let reads = 0
669
+ for (let round = 0; round < 4; round += 1) {
670
+ const run = retryChatPointUntilHit({ attempt: async () => { reads += 1; return R1_EMPTY }, retry: h.retry, now: h.now })
671
+ assert.equal(await h.flush(run), true, '第 ' + String(round + 1) + ' 轮窗口照常收尾(这正是它会重开的原因)')
672
+ await run
673
+ }
674
+ assert.equal(reads, 24, '旧形态 4 轮 = 24 次读(F1 的周期性重开:每 ~23 s 一轮)')
675
+ assert.equal(h.delays.length, 20, '旧形态一共排了 20 档(4 × 5)')
676
+ })
677
+
678
+ test('F1:新实例 = 用户的「再次触发」仍能重新开一轮(不得永久锁死)', async () => {
679
+ const h1 = retryHarness()
680
+ let reads1 = 0
681
+ const first = new ChatPointWindowController({ attempt: async () => { reads1 += 1; return R1_EMPTY }, onState: () => {}, retry: h1.retry, now: h1.now })
682
+ first.start()
683
+ await h1.flush()
684
+ assert.equal(reads1, 6, '第一次挂载跑满一轮')
685
+ first.dispose()
686
+ await h1.flush()
687
+ assert.equal(reads1, 6, 'dispose 之后一次请求都不再发')
688
+
689
+ // 重新挂载 / 会话切换 = 新实例(独立窗口):窗口关过不是「永久锁死」
690
+ const h2 = retryHarness()
691
+ let reads2 = 0
692
+ const second = new ChatPointWindowController({ attempt: async () => { reads2 += 1; return R1_EMPTY }, onState: () => {}, retry: h2.retry, now: h2.now })
693
+ second.start()
694
+ await h2.flush()
695
+ assert.equal(reads2, 6, '新实例必须能重新跑一轮(用户再次触发不被永久锁死)')
696
+ assert.deepEqual(h2.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '新实例自己有完整的一轮窗口')
697
+ })
698
+
699
+ test('F1 边界:窗口关后 start() 不会重开;重新挂载(新实例)能重新开一轮 —— 可执行版本', async () => {
700
+ const h = retryHarness()
701
+ let reads = 0
702
+ let controller = new ChatPointWindowController({ attempt: async () => { reads += 1; return R1_EMPTY }, onState: () => {}, retry: h.retry, now: h.now })
703
+ controller.start()
704
+ await h.flush()
705
+ assert.equal(controller.state().open, false)
706
+ assert.equal(reads, 6)
707
+ controller.start() // 重入(等价于 effect 重跑)—— 不得重开
708
+ await h.flush()
709
+ assert.equal(reads, 6, '窗口已关 ⇒ start() 是 no-op(F1 的核心不变量)')
710
+ // 重新挂载 = 新实例
711
+ controller = new ChatPointWindowController({ attempt: async () => { reads += 1; return R1_EMPTY }, onState: () => {}, retry: h.retry, now: h.now })
712
+ controller.start()
713
+ await h.flush()
714
+ assert.equal(reads, 12, '新实例照常跑一轮(6 + 6)—— 「已跑过」只限实例,不是永久锁死')
715
+ })
716
+
717
+ test('F2:取消通道 —— dispose 后循环在有界 tick 内 settle,且宿主读取次数不再增加', async () => {
718
+ const h = retryHarness()
719
+ let reads = 0
720
+ let cancelled = false
721
+ const run = retryChatPointUntilHit({
722
+ attempt: async () => { reads += 1; return R1_EMPTY },
723
+ retry: h.retry,
724
+ now: h.now,
725
+ isCancelled: () => cancelled,
726
+ })
727
+ await h.pump(1) // 只跑到第一次重试(排程里还有下一档)
728
+ assert.equal(reads, 2, '取消之前已经读了 2 次(首读 + 第一次重试)')
729
+ const scheduledAtCancel = h.delays.length
730
+ cancelled = true
731
+ assert.equal(await h.flush(run), true, 'F2:被取消的循环必须在有限 tick 内 settle(旧实现在这里悬挂)')
732
+ assert.equal(reads, 2, '取消之后一次宿主读取都不再发生')
733
+ assert.equal(h.delays.length, scheduledAtCancel, '取消之后不再排任何新定时器')
734
+ })
735
+
736
+ test('F2:取消发生在等待定时器期间 ⇒ 立刻收尾、不再进下一轮读(把时钟推过整个窗口也不动)', async () => {
737
+ const h = retryHarness()
738
+ let reads = 0
739
+ let cancelled = false
740
+ const run = retryChatPointUntilHit({ attempt: async () => { reads += 1; return R1_EMPTY }, retry: h.retry, now: h.now, isCancelled: () => cancelled })
741
+ await h.pump(1) // 首读 + 第一次重试(正处在「等下一档」的 await 里)
742
+ assert.equal(reads, 2)
743
+ cancelled = true
744
+ assert.equal(await h.flush(run), true, '循环已 settle(没有悬挂的 await)')
745
+ assert.equal(reads, 2, '卸载后宿主读取次数不再增加')
746
+ // 取消之后继续推排程:队列里再也没有新东西 ⇒ 一次读都不会多(窗口不再重开)
747
+ await h.pump(5)
748
+ assert.equal(reads, 2, '卸载后继续推进排程也不会有任何新读取')
749
+ })
750
+
751
+ test('F2 组件侧:dispose 之后就再也不会有状态回调(卸载后 pending 不再被写)', async () => {
752
+ const h = retryHarness()
753
+ const states: ChatPointWindowState[] = []
754
+ let reads = 0
755
+ const controller = new ChatPointWindowController({
756
+ attempt: async () => { reads += 1; return R1_EMPTY },
757
+ onState: (state) => { states.push(state) },
758
+ retry: h.retry,
759
+ now: h.now,
760
+ })
761
+ controller.start()
762
+ assert.deepEqual(states, [{ lookup: null, open: true }], '启动那一刻先回调「窗口开着、还没有结果」')
763
+ controller.dispose() // 卸载:组件不再需要任何状态
764
+ await h.flush()
765
+ await new Promise<void>((resolve) => { setImmediate(resolve) })
766
+ assert.equal(states.length, 1, 'dispose 之后不再有 onState 回调(不会在卸载后 setState)')
767
+ assert.equal(reads, 1, '取消在第一次读之后立即生效:只有那一次已经在途的读')
768
+ })
769
+
770
+ /* ------------------------------------------------------------ ⑥d T9:StrictMode 双调用(RR2-A)+ dispose 语义(RR2-B) */
771
+
772
+ test('RR2-A:StrictMode 双调用(effect → cleanup → effect)后窗口必须关掉、按钮必须能到 enabled', async () => {
773
+ const h = retryHarness()
774
+ let globalReads = 0
775
+ const all: ChatPointWindowState[] = []
776
+ /**
777
+ * **与组件 effect 逐字同形**的一次调用:新建实例(修法①)+ start + 返回它自己的 onState 记录。
778
+ * 组件就是这么写的(controller 在 effect 体内 new),所以 StrictMode 的
779
+ * `effect → cleanup → effect` 序列在这里就是可执行断言。
780
+ */
781
+ const effectRun = (hitAfter: number): { controller: ChatPointWindowController; states: ChatPointWindowState[] } => {
782
+ const own: ChatPointWindowState[] = []
783
+ let ownReads = 0
784
+ const controller = new ChatPointWindowController({
785
+ attempt: async () => { globalReads += 1; ownReads += 1; return ownReads >= hitAfter ? R1_HIT : R1_EMPTY },
786
+ onState: (state) => { own.push(state); all.push(state) },
787
+ retry: h.retry,
788
+ now: h.now,
789
+ })
790
+ controller.start()
791
+ return { controller, states: own }
792
+ }
793
+
794
+ // ① 第一次 effect 调用:首读在途(此时按钮态 = pointsPending,这是对的)
795
+ const first = effectRun(99) // 永不命中
796
+ assert.equal(globalReads, 1, '第一次调用发出首读')
797
+ assert.deepEqual(first.states, [{ lookup: null, open: true }], '开窗一次(还不知道结果 ⇒ 显示「正在读取」)')
798
+ // ② StrictMode 立刻 cleanup(同一实例被 dispose)
799
+ first.controller.dispose()
800
+ assert.equal(first.controller.state().open, false, '①:被 dispose 的实例不得再声称「窗口开着」(旧实现恒 open: true)')
801
+ // ③ 第二次 effect 调用:**新实例**(修法①)—— 窗口必须照常工作
802
+ const second = effectRun(2) // 第二次读(一次重试)就命中
803
+ assert.equal(await h.flush(), true, '新实例这一轮必须跑到收尾')
804
+ assert.equal(second.controller.state().open, false, '①:窗口必须被正常关掉(旧实现这里永久 open: true)')
805
+ assert.equal(second.controller.state().lookup?.point?.id, 'p-turn-3-auto', '②:点出现时必须读到点')
806
+ // ② 派生按钮态:命中 ⇒ enabled —— 不得停在 pointsPending
807
+ const button = chatEntryButtonState({
808
+ node: nodeOf(),
809
+ sessionId: 's1',
810
+ lookup: second.controller.state().lookup,
811
+ pending: false,
812
+ retrying: second.controller.state().open,
813
+ })
814
+ assert.equal(button.kind, 'enabled', '②:修好后按钮必须能到 enabled(旧实现永久停在禁用的「正在读取」)')
815
+ // ① reviewer 的反向判据:**出现 closed onState**,且活下来的那个实例恰好开/关各一次
816
+ assert.equal(second.states.length, 2, '①:关窗的那个实例恰好两次 onState(开窗 + 关窗)')
817
+ assert.deepEqual(second.states.map((state) => state.open), [true, false], '①:第二次是 closed onState')
818
+ assert.equal(all.filter((state) => !state.open).length, 1, '①:整个序列里恰有一次关窗(不再有「永久开窗」的实例)')
819
+ // ③ 次数与退避仍在界内、不叠加
820
+ assert.equal(globalReads, 3, '③:被取消的 1 次 + 新实例的 2 次(每实例 ≤6)')
821
+ assert.deepEqual(h.delays, [500], '③:新实例只排了一档(第二次读就命中 ⇒ 命中即停)')
822
+ assert.ok(h.now() < CHAT_POINT_LOOKUP_WINDOW_MS, '③:虚拟 23 s 以内 < 30 s 窗口')
823
+ second.controller.start()
824
+ await h.flush()
825
+ assert.equal(globalReads, 3, '③:窗口关掉后 start() 仍是 no-op(重跑不叠加)')
826
+ })
827
+
828
+ test('RR2-A/② 空场景:StrictMode 序列后窗口关且仍为空 ⇒ 按钮必须是 noPoint(不得停在 pointsPending)', async () => {
829
+ const h = retryHarness()
830
+ let reads = 0
831
+ /** 与组件 effect 同形的一次调用:新实例 + start。 */
832
+ const effectRun = (): ChatPointWindowController => {
833
+ const controller = new ChatPointWindowController({
834
+ attempt: async () => { reads += 1; return R1_EMPTY },
835
+ onState: () => {},
836
+ retry: h.retry,
837
+ now: h.now,
838
+ })
839
+ controller.start()
840
+ return controller
841
+ }
842
+ const first = effectRun()
843
+ first.dispose()
844
+ const second = effectRun() // StrictMode 的第二次 effect
845
+ assert.equal(await h.flush(), true, '第二次调用这一轮必须跑到收尾')
846
+ assert.equal(second.state().open, false, '①:窗口必须关掉(不是永久「正在读取」)')
847
+ assert.deepEqual(second.state().lookup, R1_EMPTY, '关窗时的结论:这个回合(此刻)没有点')
848
+ assert.equal(reads, 7, '②:被取消的 1 次 + 新实例完整一轮 6 次(每实例 ≤6)')
849
+ assert.deepEqual(h.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '②:只有新实例排了那 5 档(被取消的实例零排程)')
850
+ const button = chatEntryButtonState({
851
+ node: nodeOf(),
852
+ sessionId: 's1',
853
+ lookup: second.state().lookup,
854
+ pending: false,
855
+ retrying: second.state().open,
856
+ })
857
+ assert.equal(button.kind, 'disabled', '②:窗口关且仍空 ⇒ 禁用')
858
+ assert.equal(button.kind === 'disabled' ? button.code : 'enabled', 'noPoint', '②:必须是 noPoint(而不是永久 pointsPending)')
859
+ })
860
+
861
+ test('RR2-A/④ 非 StrictMode 回归:一次 effect = 一轮恒 6 读,窗口关后 start() 仍 no-op', async () => {
862
+ const h = retryHarness()
863
+ let reads = 0
864
+ const states: ChatPointWindowState[] = []
865
+ const controller = new ChatPointWindowController({
866
+ attempt: async () => { reads += 1; return R1_EMPTY },
867
+ onState: (state) => { states.push(state) },
868
+ retry: h.retry,
869
+ now: h.now,
870
+ })
871
+ controller.start()
872
+ await h.flush()
873
+ assert.equal(reads, 6, '④:一轮 = 1 次首读 + 5 次重试')
874
+ assert.deepEqual(h.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '④:退避恰好 5 档')
875
+ assert.equal(h.now(), 23_000, '④:虚拟 23 s')
876
+ assert.ok(h.now() < CHAT_POINT_LOOKUP_WINDOW_MS, '④:23 s < 30 s 窗口')
877
+ assert.deepEqual(states.map((state) => state.open), [true, false], '④:开窗 + 关窗各一次')
878
+ assert.equal(controller.state().open, false)
879
+ controller.start()
880
+ await h.flush()
881
+ assert.equal(reads, 6, '④:窗口关后 start() 仍是 no-op(不叠加)')
882
+ assert.deepEqual(h.delays, [...CHAT_POINT_RETRY_DELAYS_MS], '④:零新排程')
883
+ })
884
+
885
+ test('RR2-A/③ 连续 3 个 StrictMode 回合:每个被取消的实例最多 1 次读、零残留排程', async () => {
886
+ const h = retryHarness()
887
+ let reads = 0
888
+ const perInstance: number[] = []
889
+ for (let round = 0; round < 3; round += 1) {
890
+ let own = 0
891
+ // 一次 effect 调用 = 一个新实例;StrictMode 紧接着 cleanup ⇒ 只可能有一次在途读
892
+ const controller = new ChatPointWindowController({
893
+ attempt: async () => { reads += 1; own += 1; return R1_EMPTY },
894
+ onState: () => {},
895
+ retry: h.retry,
896
+ now: h.now,
897
+ })
898
+ controller.start()
899
+ controller.dispose()
900
+ perInstance.push(own)
901
+ await h.flush()
902
+ }
903
+ assert.deepEqual(perInstance, [1, 1, 1], '③:每个被取消的实例只读了那一次在途读')
904
+ assert.equal(reads, 3, '③:3 个回合共 3 次读(没有叠加成 3×6)')
905
+ assert.deepEqual(h.delays, [], '③:读回来时才发现被取消 ⇒ 连一档排程都不排(取消的最早生效点)')
906
+ })
907
+
908
+ test('RR2-B:dispose 只置取消 + 把 state 记为已关窗,**不回调** onState(不制造卸载后 setState)', async () => {
909
+ const h = retryHarness()
910
+ let reads = 0
911
+ const states: ChatPointWindowState[] = []
912
+ const controller = new ChatPointWindowController({
913
+ attempt: async () => { reads += 1; return R1_EMPTY },
914
+ onState: (state) => { states.push(state) },
915
+ retry: h.retry,
916
+ now: h.now,
917
+ })
918
+ controller.start()
919
+ assert.equal(states.length, 1, 'start ⇒ 一次开窗回调')
920
+ controller.dispose()
921
+ assert.equal(controller.state().open, false, 'RR2-B:dispose 后 state 不再声称窗口开着(注释与实现一致)')
922
+ assert.equal(states.length, 1, 'RR2-B:dispose 不回调(此刻调用点正在卸载/换会话)')
923
+ assert.equal(await h.flush(), true, '被取消的循环立即 settle')
924
+ await new Promise<void>((resolve) => { setImmediate(resolve) })
925
+ assert.equal(states.length, 1, '循环 settle 也不再回调:窗口终值只能读 state()')
926
+ assert.equal(reads, 1, '在途那次读之后不再有新读')
927
+ assert.deepEqual(h.delays, [], 'dispose 之后连一档排程都不排')
928
+ controller.start()
929
+ await h.flush()
930
+ assert.equal(reads, 1, 'dispose 是永久停手:再 start() 也不复活(不得用「忽略 dispose」绕过)')
931
+ })
932
+
933
+ /* ------------------------------------------------------------ ② 气泡能力面(文本/图片/文件/rest) */
934
+
935
+ test('bubble text: text blocks keep newlines and whitespace verbatim (host contentParts shape)', () => {
936
+ const view = buildChatEntryBubble([
937
+ { type: 'text', text: 'line1\n line2\t' },
938
+ { type: 'text', text: 'line3' },
939
+ ])
940
+ assert.equal(view.text, 'line1\n line2\tline3')
941
+ assert.equal(view.showBubble, true)
942
+ assert.equal(view.raw.length, 0)
943
+ })
944
+
945
+ test('bubble images: payload is forwarded verbatim (only renderMessageImages may render it)', () => {
946
+ const attachment = { id: 'a1', width: 10, height: 20 }
947
+ const view = buildChatEntryBubble([{ type: 'text', text: 'x' }, { type: 'image', attachment }])
948
+ assert.equal(view.images.length, 1)
949
+ assert.deepEqual(view.images[0], { attachment })
950
+ // 逐张一次调用(宿主 attachments.map 的逐字同构):单图 owner 只带一张;单个附件 ⇒ compact=false
951
+ assert.deepEqual(planChatEntryAttachments(view), [{ kind: 'image', index: 0, owner: { images: [{ attachment }], align: 'end', compact: false } }])
952
+ const two = buildChatEntryBubble([{ type: 'image', attachment }, { type: 'image', attachment: { id: 'a2' } }])
953
+ assert.deepEqual(
954
+ planChatEntryAttachments(two),
955
+ [
956
+ { kind: 'image', index: 0, owner: { images: [{ attachment }], align: 'end', compact: true } },
957
+ { kind: 'image', index: 1, owner: { images: [{ attachment: { id: 'a2' } }], align: 'end', compact: true } },
958
+ ],
959
+ 'one call per image, compact mirrors the host attachments.length > 1',
960
+ )
961
+ // 没有 attachment 的 image 块必须进 rest,绝不静默丢
962
+ const noAttachment = buildChatEntryBubble([{ type: 'image' }])
963
+ assert.equal(noAttachment.images.length, 0)
964
+ assert.equal(noAttachment.raw.length, 1)
965
+ })
966
+
967
+ test('attachments: one ordered list in content order + compact counts ALL attachments (D1/D2)', () => {
968
+ // 反例(t4 复核给的例子):file 在前、image 在后 —— 旧实现固定「先图片后文件」,顺序是反的;
969
+ // compact 旧实现只看图片数(这里 images.length=1 ⇒ false),宿主看**附件总数**(2 ⇒ true)。
970
+ const fileFirst = buildChatEntryBubble([
971
+ { type: 'file', attachment: { name: 'first.txt', bytes: 3 } },
972
+ { type: 'image', attachment: { id: 'a' } },
973
+ ])
974
+ assert.deepEqual(fileFirst.attachments.map((item) => item.kind), ['file', 'image'], 'attachment order must follow content order')
975
+ assert.equal(chatEntryCompactOf(fileFirst), true, 'file + image = 2 attachments => compact (host rule)')
976
+ const plan = planChatEntryAttachments(fileFirst)
977
+ assert.deepEqual(plan.map((item) => item.kind), ['file', 'image'], 'the render plan keeps content order')
978
+ assert.equal(plan[0]?.kind === 'file' ? plan[0].size : null, '3B', 'file card size text rides along')
979
+ assert.equal(plan[1]?.kind === 'image' ? plan[1].owner.compact : null, true, 'the image owner carries the total-attachment compact flag')
980
+
981
+ // 单个文件 ⇒ compact=false(附件总数 1);图片 + 两个文件 ⇒ true
982
+ assert.equal(chatEntryCompactOf(buildChatEntryBubble([{ type: 'file', attachment: { name: 'a.txt', bytes: 1 } }])), false)
983
+ assert.equal(chatEntryCompactOf(buildChatEntryBubble([
984
+ { type: 'image', attachment: { id: 'i' } },
985
+ { type: 'file', attachment: { name: 'a.txt', bytes: 1 } },
986
+ { type: 'file', attachment: { name: 'b.txt', bytes: 2 } },
987
+ ])), true)
988
+
989
+ // 三者交错时逐项顺序 = content 顺序,且 images/files 是它的派生视图(同一份真相)
990
+ const mixed = buildChatEntryBubble([
991
+ { type: 'image', attachment: { id: 'i1' } },
992
+ { type: 'file', attachment: { name: 'f1.txt', bytes: 1 } },
993
+ { type: 'image', attachment: { id: 'i2' } },
994
+ { type: 'file', attachment: { name: 'f2.txt', bytes: 2 } },
995
+ ])
996
+ assert.deepEqual(mixed.attachments.map((item) => item.kind), ['image', 'file', 'image', 'file'])
997
+ assert.deepEqual(mixed.files.map((file) => file.name), ['f1.txt', 'f2.txt'])
998
+ assert.deepEqual(mixed.images.map((image) => image.attachment), [{ id: 'i1' }, { id: 'i2' }])
999
+ })
1000
+
1001
+ test('bubble file cards: file name + extension + size; a missing byte count stays unknown (no fake 0 B)', () => {
1002
+ const view = buildChatEntryBubble([
1003
+ { type: 'file', attachment: { name: 'C:/tmp/report.final.PDF', bytes: 1536 } },
1004
+ { type: 'file', attachment: { name: 'noext' } },
1005
+ { type: 'file', attachment: {} },
1006
+ ])
1007
+ assert.equal(view.files.length, 3)
1008
+ assert.deepEqual(view.files[0], { name: 'C:/tmp/report.final.PDF', ext: 'PDF', bytes: 1536 })
1009
+ assert.equal(fileSizeTextOf(view.files[0]!.bytes), '1.5KB')
1010
+ assert.deepEqual(view.files[1], { name: 'noext', ext: null, bytes: null })
1011
+ assert.equal(fileSizeTextOf(null), null)
1012
+ // 宿主 fileSizeText 逐字复刻:**无空格**;判的是**单位值** < 10 才带一位小数,≥10 取整
1013
+ assert.equal(fileSizeTextOf(0), '0B')
1014
+ assert.equal(fileSizeTextOf(512), '512B')
1015
+ assert.equal(fileSizeTextOf(1024), '1.0KB')
1016
+ assert.equal(fileSizeTextOf(4.3 * 1024), '4.3KB')
1017
+ assert.equal(fileSizeTextOf(20 * 1024), '20KB')
1018
+ assert.equal(fileSizeTextOf(1536 * 1024), '1.5MB')
1019
+ assert.equal(fileSizeTextOf(10 * 1024 * 1024), '10MB')
1020
+ assert.equal(fileSizeTextOf(2.5 * 1024 * 1024 * 1024), '2.5GB')
1021
+ assert.equal(fileSizeTextOf(-1), null, 'a negative byte count is unknown, never "-1B"')
1022
+ // 宿主 fileExtension:**前导点也算后缀**('.gitignore' → 'gitignore');末尾点 → 空串 → 无徽标。
1023
+ // 长度上限 8 也照宿主:宿主文件卡那行 meta 就是 fileExtension(name).toUpperCase().slice(0, 8)。
1024
+ assert.equal(fileExtensionOf('.gitignore'), 'GITIGNOR')
1025
+ assert.equal(fileExtensionOf('a.'), null)
1026
+ assert.equal(fileExtensionOf('D:/x/y/report.pdf'), 'PDF')
1027
+ assert.equal(fileExtensionOf('a.b.c'), 'C')
1028
+ assert.equal(fileExtensionOf('no-dot-at-all'), null, 'no dot = no extension')
1029
+ assert.equal(fileExtensionOf('file.verylongextension12345'), 'VERYLONG', 'the 8-character display cap is the host rule')
1030
+ })
1031
+
1032
+ test('bubble rest: unknown blocks always reach the renderer (nothing is silently dropped)', () => {
1033
+ const unknown = { type: 'mystery', payload: { deep: [1, 2] } }
1034
+ const view = buildChatEntryBubble([{ type: 'text', text: 'hi' }, unknown])
1035
+ assert.equal(view.raw.length, 1)
1036
+ assert.deepEqual(view.raw[0]?.payload, unknown)
1037
+ assert.equal(rawTextOf(view.raw[0]?.payload), JSON.stringify(unknown))
1038
+
1039
+ // rest-only content still asks for a bubble (the host does the same with showBubble)
1040
+ const restOnly = buildChatEntryBubble([unknown])
1041
+ assert.equal(restOnly.text, '')
1042
+ assert.equal(restOnly.showBubble, true)
1043
+
1044
+ // hostile / non-array content degrades into a visible block instead of vanishing
1045
+ assert.equal(buildChatEntryBubble('plain text').text, 'plain text')
1046
+ assert.equal(buildChatEntryBubble([null]).raw.length, 1)
1047
+ assert.equal(buildChatEntryBubble(undefined).raw.length, 0)
1048
+ assert.equal(buildChatEntryBubble(42).raw.length, 1)
1049
+ })
1050
+
1051
+ test('view model: node data drives the bubble; referenceLabels/skillNames are recorded but not faked', () => {
1052
+ const view = buildChatEntryView({
1053
+ kind: 'user',
1054
+ location: { kind: 'step', turn: { turn: 5, status: 'closed' } },
1055
+ data: { content: [{ type: 'text', text: 'q' }], referenceLabels: ['session-a', 7], skillNames: ['changelog-writing'], time: 1_700_000_000_000 },
1056
+ })
1057
+ assert.equal(view.turnNumber, 5)
1058
+ assert.equal(view.turnStatus, 'closed')
1059
+ assert.equal(view.bubble.text, 'q')
1060
+ assert.deepEqual(view.referenceLabels, ['session-a'])
1061
+ assert.deepEqual(view.skillNames, ['changelog-writing'])
1062
+ assert.ok(view.clock !== null && view.clock !== '', 'a numeric data.time renders as a clock')
1063
+ })
1064
+
1065
+ test('session wire form: the projection collapses @[label](dsh-session:…) to its label (D5)', () => {
1066
+ // 宿主 SESSION_WIRE_RE 的形状:@[ + label(不含 ] 与换行) + ](dsh-session: + 非空白 id + );
1067
+ // 捕获组 1 = 给人看的 label(宿主 projectUserText 用它当 chip 文本)。
1068
+ assert.equal(projectChatEntryText('see @[Some session](dsh-session:eyJhIjoxfQ) please'), 'see Some session please')
1069
+ assert.equal(projectChatEntryText('a @[x](dsh-session:1) b @[y](dsh-session:2)'), 'a x b y', 'every occurrence is replaced')
1070
+ assert.equal(projectChatEntryText('no wire here'), 'no wire here')
1071
+ assert.equal(projectChatEntryText('@[broken](dsh-session:'), '@[broken](dsh-session:', 'an unterminated wire form is left as-is')
1072
+ assert.equal(projectChatEntryText('mail me at a@[b](not-session:1)'), 'mail me at a@[b](not-session:1)', 'only the dsh-session form is a reference')
1073
+ // 投影必须发生在**气泡文本**上(用户看不到机器编码),且 referenceLabels 仍被读入视图模型
1074
+ const bubble = buildChatEntryBubble([
1075
+ { type: 'text', text: 'q @[Session A](dsh-session:aaa)' },
1076
+ { type: 'text', text: ' and @[Session B](dsh-session:bbb)' },
1077
+ ])
1078
+ assert.equal(bubble.text, 'q Session A and Session B')
1079
+ assert.ok(!bubble.text.includes('dsh-session:'), 'the wire form must never reach the screen')
1080
+ const view = buildChatEntryView({
1081
+ kind: 'user',
1082
+ data: { content: [{ type: 'text', text: '@[Session A](dsh-session:aaa)' }], referenceLabels: ['Session A', 'Session B'] },
1083
+ })
1084
+ assert.deepEqual(view.referenceLabels, ['Session A', 'Session B'], 'referenceLabels drive the summary line')
1085
+ // 崩溃降级路径同样不许打印线格式
1086
+ assert.equal(plainTextOf([{ type: 'text', text: '@[Session A](dsh-session:aaa)' }]), 'Session A')
1087
+ })
1088
+
1089
+ test('clock: fixed 24-hour zero-padded HH:mm (host format, D4); missing/hostile timestamps stay unknown', () => {
1090
+ // 用**本地时间构造**,断言与运行时时区无关:05:04 必须补零、15:04 必须还是 15 点(不是 3 点)
1091
+ assert.equal(formatChatEntryClock(new Date(2026, 9, 10, 5, 4).getTime()), '05:04')
1092
+ assert.equal(formatChatEntryClock(new Date(2026, 9, 10, 15, 4).getTime()), '15:04')
1093
+ assert.equal(formatChatEntryClock(new Date(2026, 0, 1, 0, 0).getTime()), '00:00')
1094
+ assert.equal(formatChatEntryClock(new Date(2026, 0, 1, 23, 59).getTime()), '23:59')
1095
+ const formatted = formatChatEntryClock(1_700_000_000_000)
1096
+ assert.ok(formatted !== null && /^\d{2}:\d{2}$/.test(formatted), 'always HH:mm: ' + String(formatted))
1097
+ assert.equal(formatChatEntryClock(null), null)
1098
+ assert.equal(formatChatEntryClock(Number.NaN), null)
1099
+ assert.equal(formatChatEntryClock(Number.POSITIVE_INFINITY), null)
1100
+ const noTime = buildChatEntryView({ kind: 'user', location: { kind: 'step', turn: { turn: 1, status: 'closed' } }, data: { content: [], time: '2026-10-10T00:00:00Z' } })
1101
+ assert.equal(noTime.clock, null, 'a string timestamp is not guessed into a clock')
1102
+ })
1103
+
1104
+ /* ------------------------------------------------------------ ④ 错误边界降级 */
1105
+
1106
+ test('error boundary: a throwing projection still yields the user text as plain text', () => {
1107
+ const node = nodeOf({ content: [{ type: 'text', text: 'keep me visible' }] })
1108
+ const row = buildChatEntrySafeRow({ node, project: () => { throw new Error('projection exploded') } })
1109
+ assert.equal(row.text, 'keep me visible', 'the user message text must survive a projection crash')
1110
+ assert.deepEqual(row.fileNames, [])
1111
+
1112
+ const normal = buildChatEntrySafeRow({ node, project: buildChatEntryView })
1113
+ assert.equal(normal.text, 'keep me visible')
1114
+ })
1115
+
1116
+ test('error boundary: rest-only content still degrades to visible text; files are listed by name', () => {
1117
+ const node = nodeOf({ content: [{ type: 'mystery', payload: { a: 1 } }, { type: 'file', attachment: { name: 'x.txt', bytes: 1 } }] })
1118
+ const row = buildChatEntrySafeRow({ node, project: () => { throw new Error('boom') } })
1119
+ const newline = String.fromCharCode(10)
1120
+ const unknownText = JSON.stringify({ type: 'mystery', payload: { a: 1 } })
1121
+ assert.equal(row.text, unknownText + newline + 'x.txt', 'unknown blocks keep their JSON text and file blocks keep their name')
1122
+ const intact = buildChatEntrySafeRow({ node, project: buildChatEntryView })
1123
+ assert.deepEqual(intact.fileNames, ['x.txt'])
1124
+ })
1125
+
1126
+ test('error boundary: the degradation is plain text, never a silent drop (plainTextOf is total)', () => {
1127
+ assert.equal(plainTextOf('abc'), 'abc')
1128
+ assert.equal(plainTextOf([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]), 'a\nb')
1129
+ assert.equal(plainTextOf([{ type: 'tool-call', id: 't' }]), JSON.stringify({ type: 'tool-call', id: 't' }))
1130
+ assert.equal(plainTextOf(null), '')
1131
+ assert.equal(plainTextOf(undefined), '')
1132
+ const circular: Record<string, unknown> = {}
1133
+ circular['self'] = circular
1134
+ assert.equal(typeof plainTextOf(circular), 'string', 'a circular payload must not throw')
1135
+ })
1136
+
1137
+ test('error boundary: the console.warn prefix is ASCII (diagnostics only, never UI copy)', () => {
1138
+ assert.equal(CHAT_ENTRY_WARN_PREFIX, '[config-manager] chat user node renderer degraded to plain text: ')
1139
+ // eslint-disable-next-line no-control-regex
1140
+ assert.equal(/[\u4e00-\u9fff]/.test(CHAT_ENTRY_WARN_PREFIX), false, 'console.warn arguments must stay ASCII')
1141
+ })
1142
+
1143
+ test('component-level boundary wiring is asserted through the pure helpers it calls', () => {
1144
+ // ChatEntryErrorBoundary 在 *.tsx 里(node 的 type-strip 不加载 tsx),但它调用的三件事都在这里:
1145
+ // ① getDerivedStateFromError 的返回形状;② componentDidCatch 的降级投影;③ 换节点后的复位判据。
1146
+ const failing = { failed: true, row: null }
1147
+ assert.equal(failing.failed, true)
1148
+ const node = nodeOf({ content: [{ type: 'text', text: 'still here' }] })
1149
+ assert.equal(buildChatEntrySafeRow({ node, project: () => { throw new Error('x') } }).text, 'still here')
1150
+ assert.equal(node !== nodeOf({ content: [{ type: 'text', text: 'still here' }] }), true, 'a new node object must reset the failed state')
1151
+ })
1152
+
1153
+ /* ------------------------------------------------------------ 字典覆盖(组件里出现的键) */
1154
+
1155
+ test('i18n: every chat entry key used by the new surface exists in zh and en (same key set)', () => {
1156
+ const keys = [
1157
+ 'chat.entry.label',
1158
+ 'chat.entry.copy',
1159
+ 'chat.entry.copied',
1160
+ 'chat.entry.extraBlock',
1161
+ 'chat.entry.truncated',
1162
+ 'chat.entry.noImageRenderer',
1163
+ ...Object.values(CHAT_ENTRY_DISABLED_KEYS),
1164
+ ]
1165
+ for (const key of keys) {
1166
+ assert.ok(typeof chatZh[key as keyof typeof chatZh] === 'string', 'zh missing ' + key)
1167
+ assert.ok(typeof chatEn[key as keyof typeof chatEn] === 'string', 'en missing ' + key)
1168
+ }
1169
+ assert.deepEqual(Object.keys(chatZh).sort(), Object.keys(chatEn).sort(), 'zh and en must stay mirror images')
1170
+ })
1171
+
1172
+ function rawTextOf(payload: unknown): string {
1173
+ return JSON.stringify(payload)
1174
+ }