lifecycle.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import { beforeEach, describe, expect, it, vi } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as os from 'node:os'
  4. import * as path from 'node:path'
  5. import { apply, currentWorkspaceRoot, registerWorkspaceRoot, resetWorkspaceRoot } from '../src/index'
  6. /** 临时工作范围:一本带契约的书。 */
  7. function makeWorkspace(): string {
  8. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-lifecycle-'))
  9. const book = path.join(dir, '星辰')
  10. fs.mkdirSync(path.join(book, '作品契约'), { recursive: true })
  11. fs.writeFileSync(path.join(book, '作品契约', '契约.md'), '---\n书id: xingchen-001\n---\n正文\n', 'utf8')
  12. return dir
  13. }
  14. interface FakeAgentCtx {
  15. systemPrompt: { context: (c: unknown) => () => void; section: (s: unknown) => () => void }
  16. inject: (deps: unknown, cb: (scope: unknown) => void) => { dispose: () => Promise<void> }
  17. on: (e: string) => void
  18. }
  19. /**
  20. * agent.ctx 桩(审计 B4)。
  21. *
  22. * 两处必须照真类型来,否则 B2 那类泄漏测不出来:
  23. * 1. `systemPrompt.context/section` 真类型都回注销函数
  24. * (dsh system-prompt index.d.ts:185/192 `(...): () => void`)——原桩回 undefined,
  25. * 生产代码丢不丢这个返回值,测试完全看不出。
  26. * 2. `inject` 真类型回 `Fiber & PromiseLike<Fiber>`(cordis registry.d.ts:111),
  27. * 其 `dispose: () => Promise<void>`(fiber.d.ts:112)——原桩回 undefined,
  28. * 于是「fork 的 scope 跨卸载存活」这条路径在测试里根本不存在。
  29. *
  30. * 交回记录写进 released,由用例断言两样都交回了。
  31. */
  32. function makeAgentCtx(sink: unknown[], released: string[] = [], injected: string[] = []): FakeAgentCtx {
  33. const self: FakeAgentCtx = {
  34. systemPrompt: {
  35. context: (c) => { sink.push(c); return () => { released.push('context') } },
  36. section: () => () => { released.push('section') },
  37. },
  38. inject: (_deps, cb) => {
  39. injected.push('inject')
  40. cb(self)
  41. return { dispose: async () => { released.push('fiber') } }
  42. },
  43. on: () => {},
  44. }
  45. return self
  46. }
  47. /**
  48. * 宿主 ctx 桩:服务按 name 逐个「就绪」,inject 记录依赖声明。
  49. * ready 里没有的服务视为尚未就绪(pending),对应真机上 workspaceRegistry 等待
  50. * storageDomain 的那一刻。
  51. */
  52. function makeHost(opts: {
  53. readonly ready: Record<string, unknown>
  54. readonly withInject?: boolean
  55. readonly withEffect?: boolean
  56. }) {
  57. const injected: Array<{ deps: readonly string[]; run: (scope: unknown) => void }> = []
  58. const effects: Array<{ label?: string; disposers: Array<() => void> }> = []
  59. const listeners = new Map<string, Array<(...a: never[]) => unknown>>()
  60. const host: Record<string, unknown> = {
  61. logger: { info: () => {}, warn: () => {} },
  62. get: (n: string) => opts.ready[n],
  63. on: (e: string, l: (...a: never[]) => unknown) => {
  64. const arr = listeners.get(e) ?? []
  65. arr.push(l)
  66. listeners.set(e, arr)
  67. return () => {}
  68. },
  69. }
  70. if (opts.withInject !== false) {
  71. host.inject = (deps: readonly string[], run: (scope: unknown) => void) => {
  72. injected.push({ deps, run })
  73. // 依赖全就绪才跑,模拟 cordis「依赖出现即激活」
  74. if (deps.every((d) => opts.ready[d] !== undefined)) run(host)
  75. return undefined
  76. }
  77. }
  78. if (opts.withEffect !== false) {
  79. host.effect = (execute: () => unknown, label?: string) => {
  80. const rec: { label?: string; disposers: Array<() => void> } = { label, disposers: [] }
  81. effects.push(rec)
  82. const r = execute()
  83. if (typeof r === 'function') rec.disposers.push(r as () => void)
  84. else if (r !== null && typeof r === 'object' && Symbol.iterator in (r as object)) {
  85. for (const d of r as Iterable<() => void>) rec.disposers.push(d)
  86. }
  87. return () => { for (const d of [...rec.disposers].reverse()) d() }
  88. }
  89. }
  90. return { host, injected, effects, listeners }
  91. }
  92. describe('B6 依赖反应性(inject 持续契约,非 apply 时刻快照)', () => {
  93. beforeEach(() => { resetWorkspaceRoot() })
  94. it('缺失技能挂载能力明确告警,等待 skills 服务时不误报', () => {
  95. const missing = makeHost({ ready: {} })
  96. const warn = vi.fn()
  97. missing.host.logger = { info: () => {}, warn }
  98. apply(missing.host as never)
  99. expect(warn).toHaveBeenCalledWith(expect.stringContaining('随包技能未挂载'))
  100. const waiting = makeHost({ ready: {} })
  101. waiting.host.plugin = vi.fn()
  102. waiting.host.logger = { info: () => {}, warn: vi.fn() }
  103. apply(waiting.host as never)
  104. expect(waiting.injected.some(item => item.deps.includes('skills'))).toBe(true)
  105. expect(waiting.host.plugin).not.toHaveBeenCalled()
  106. expect((waiting.host.logger as { warn: unknown }).warn).not.toHaveBeenCalled()
  107. })
  108. it('workspaceRegistry 在 apply 时刻尚未就绪:仍经 inject 声明,后到即认到工作范围', () => {
  109. const ws = makeWorkspace()
  110. const sink: unknown[] = []
  111. const agentCtx = makeAgentCtx(sink)
  112. // apply 时刻 workspaceRegistry 缺席(pending),只有 agents/tools 在
  113. const { host, injected } = makeHost({
  114. ready: { agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] } },
  115. })
  116. apply(host as never)
  117. // 关键:必须经 inject 声明 workspaceRegistry,而不是 apply 时刻 get 一次就放弃
  118. const decl = injected.find((i) => i.deps.includes('workspaceRegistry'))
  119. expect(decl, 'workspaceRegistry 应经 ctx.inject 声明').toBeDefined()
  120. // 依赖后到:cordis 会重跑 setup,registry 的值应盖过 headless 兜底的进程 cwd
  121. decl!.run({ workspaceRegistry: { list: () => [{ path: ws }] } })
  122. expect(currentWorkspaceRoot()).toBe(ws)
  123. })
  124. it('tools 后到也能注册工具(注册随 agent 装机,反应式声明照旧,不因 apply 时刻缺席而永久放弃)', () => {
  125. const registered: string[] = []
  126. // dsh 运行时对齐批:注册落点=agent 的 scope 层。桩 agent 的 ctx 暴露
  127. // tools 记录器,反应式回调(tools/userQuestions 就绪)触发时经 agentCtx.get 可达。
  128. const recorder = { register: (t: { name: string }) => { registered.push(t.name); return () => {} } }
  129. const agentCtx = { get: (n: string) => (n === 'tools' ? recorder : undefined) }
  130. const { host, injected } = makeHost({ ready: { agents: { list: () => [{ id: 'sess-9', ctx: agentCtx }] } } })
  131. apply(host as never)
  132. const decl = injected.find((i) => i.deps.includes('tools') && i.deps.includes('userQuestions'))
  133. expect(decl, 'tools+userQuestions 应经 ctx.inject 声明').toBeDefined()
  134. expect(registered.length).toBe(0)
  135. decl!.run({ logger: { info: () => {}, warn: () => {} } })
  136. expect(registered.length).toBeGreaterThan(0)
  137. expect(registered).toContain('novel_create_book')
  138. })
  139. it('无 inject 表面的宿主:退回 ctx.get 快照路径,仍加载不抛', () => {
  140. const ws = makeWorkspace()
  141. const sink: unknown[] = []
  142. const agentCtx = makeAgentCtx(sink)
  143. const { host } = makeHost({
  144. ready: {
  145. workspaceRegistry: { list: () => [{ path: ws }] },
  146. agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
  147. },
  148. withInject: false,
  149. withEffect: false,
  150. })
  151. expect(() => apply(host as never)).not.toThrow()
  152. expect(currentWorkspaceRoot()).toBe(ws)
  153. })
  154. })
  155. describe('B3 多步装机走 effect(中途失败逆序回滚)', () => {
  156. beforeEach(() => { resetWorkspaceRoot() })
  157. it('装机各步经 ctx.effect 注册,带可读 label', () => {
  158. const ws = makeWorkspace()
  159. const sink: unknown[] = []
  160. const agentCtx = makeAgentCtx(sink)
  161. const { host, effects } = makeHost({
  162. ready: {
  163. workspaceRegistry: { list: () => [{ path: ws }] },
  164. agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
  165. tools: { register: () => {} },
  166. },
  167. })
  168. apply(host as never)
  169. expect(effects.length, '装机步骤应经 ctx.effect 注册').toBeGreaterThan(0)
  170. for (const e of effects) {
  171. expect(e.label, 'effect 应带可读 label 供 fiber 诊断').toBeTruthy()
  172. }
  173. })
  174. it('每步 yield 的逆操作在卸载时逆序执行', () => {
  175. const ws = makeWorkspace()
  176. const order: string[] = []
  177. const sink: unknown[] = []
  178. const agentCtx = makeAgentCtx(sink)
  179. const { host, effects } = makeHost({
  180. ready: {
  181. workspaceRegistry: { list: () => [{ path: ws }] },
  182. agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
  183. tools: { register: () => {} },
  184. },
  185. })
  186. apply(host as never)
  187. // 逐个 effect 的 disposer 都应可调用且不抛
  188. for (const [i, e] of effects.entries()) {
  189. for (const d of [...e.disposers].reverse()) {
  190. expect(() => d()).not.toThrow()
  191. order.push(`e${i}`)
  192. }
  193. }
  194. expect(order.length).toBe(effects.reduce((n, e) => n + e.disposers.length, 0))
  195. })
  196. })
  197. describe('B4 汇合测试(手册 §13:加载A→卸载A→加载B→卸载B→加载A 等价于直接加载A)', () => {
  198. beforeEach(() => { resetWorkspaceRoot() })
  199. /** 一轮完整加载,返回可观察状态与卸载函数。 */
  200. function load(ws: string) {
  201. const contexts: unknown[] = []
  202. const toolNames: string[] = []
  203. const released: string[] = []
  204. const injected: string[] = []
  205. const agentCtx = makeAgentCtx(contexts, released, injected)
  206. const { host, effects } = makeHost({
  207. ready: {
  208. workspaceRegistry: { list: () => [{ path: ws }] },
  209. agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
  210. tools: { register: (t: { name: string }) => { toolNames.push(t.name) } },
  211. },
  212. })
  213. apply(host as never)
  214. // 卸载只跑 effect 的逆操作 —— 不额外调 resetWorkspaceRoot 兜底:
  215. // 那等于替生产代码补交回,逆操作真漏了也照样绿。
  216. const unload = () => {
  217. for (const e of [...effects].reverse()) {
  218. for (const d of [...e.disposers].reverse()) d()
  219. }
  220. }
  221. return { contexts, toolNames, released, injected, unload, root: currentWorkspaceRoot() }
  222. }
  223. it('A→卸A→B→卸B→A 的可观察状态与直接加载 A 等价', () => {
  224. const wsA = makeWorkspace()
  225. const wsB = makeWorkspace()
  226. const baseline = load(wsA)
  227. const baselineShape = {
  228. root: baseline.root,
  229. tools: [...baseline.toolNames].sort(),
  230. contexts: baseline.contexts.length,
  231. }
  232. baseline.unload()
  233. const b = load(wsB)
  234. expect(b.root).toBe(wsB)
  235. b.unload()
  236. const again = load(wsA)
  237. expect({
  238. root: again.root,
  239. tools: [...again.toolNames].sort(),
  240. contexts: again.contexts.length,
  241. }).toEqual(baselineShape)
  242. again.unload()
  243. })
  244. it('卸载后 per-agent 三样全部交回:status context / persona section / inject 的 fork', () => {
  245. const ws = makeWorkspace()
  246. const r = load(ws)
  247. expect(r.released, '装载期间不应有交回').toEqual([])
  248. r.unload()
  249. // 三样各自的交回都要发生。fiber 是 fork 出来的子 scope,
  250. // 不 dispose 则连同依赖订阅跨卸载存活,重装即叠加(审计 B2)。
  251. expect(r.released, 'status context 应交回注销函数').toContain('context')
  252. expect(r.released, 'persona section 应交回注销函数').toContain('section')
  253. // 按次数比对:persona 与 status 各 inject 一次,只数「有没有 fiber」的话
  254. // 其中一条漏 dispose 也照样绿(persona 早先就是这么漏掉的)。
  255. const disposed = r.released.filter((x) => x === 'fiber').length
  256. expect(disposed, '每次 inject 的 fork 都应 dispose,不能只交回其中一条').toBe(r.injected.length)
  257. expect(r.injected.length, '至少有 persona 与 status 两条 inject').toBeGreaterThanOrEqual(2)
  258. expect(currentWorkspaceRoot(), '工作范围应由逆操作交回,不靠测试兜底').toBeUndefined()
  259. })
  260. it('重装不叠加:两轮的注册数与交回数相等', () => {
  261. const ws = makeWorkspace()
  262. const first = load(ws)
  263. const firstContexts = first.contexts.length
  264. first.unload()
  265. const firstReleased = [...first.released]
  266. const second = load(ws)
  267. expect(second.contexts.length, '重装的注册数应与首装相同(未叠加)').toBe(firstContexts)
  268. second.unload()
  269. expect([...second.released].sort(), '两轮交回形状应一致').toEqual(firstReleased.sort())
  270. })
  271. })
  272. describe('B2 per-agent 注册随卸载交回(persona / status / 门禁)', () => {
  273. beforeEach(() => { resetWorkspaceRoot() })
  274. it('attachPersonaToAgent 交回 sys.section 的注销函数', async () => {
  275. const { attachPersonaToAgent } = await import('../src/persona')
  276. let disposed = 0
  277. const self: Record<string, unknown> = {}
  278. self.systemPrompt = { section: () => () => { disposed += 1 } }
  279. self.inject = (_d: unknown, cb: (s: unknown) => void) => { cb(self); return undefined }
  280. const undo = attachPersonaToAgent(self as never)
  281. expect(typeof undo, 'persona 应交回注销函数而非 boolean').toBe('function')
  282. ;(undo as () => void)()
  283. expect(disposed, '卸载应调用 section 的注销函数').toBe(1)
  284. })
  285. it('attachFileGateToAgent 交回 tools/pre-execute 的注销函数', async () => {
  286. const { attachFileGateToAgent } = await import('../src/gates')
  287. let disposed = 0
  288. const agentCtx = {
  289. on: (_e: string, _l: unknown) => () => { disposed += 1 },
  290. }
  291. const undo = attachFileGateToAgent(agentCtx as never, {
  292. bookRootOfId: () => undefined,
  293. bookRootForAbs: () => undefined,
  294. workspaceRoot: () => undefined,
  295. })
  296. expect(typeof undo, '门禁应交回注销函数').toBe('function')
  297. ;(undo as () => void)()
  298. expect(disposed, '卸载应注销 tools/pre-execute 监听').toBe(1)
  299. })
  300. it('无 systemPrompt / 无 on 表面时交回 undefined(fail-open,不抛)', async () => {
  301. const { attachPersonaToAgent } = await import('../src/persona')
  302. const { attachFileGateToAgent } = await import('../src/gates')
  303. expect(attachPersonaToAgent(undefined)).toBeUndefined()
  304. expect(attachFileGateToAgent({} as never, {
  305. bookRootOfId: () => undefined,
  306. bookRootForAbs: () => undefined,
  307. workspaceRoot: () => undefined,
  308. })).toBeUndefined()
  309. })
  310. it('apply 装的 per-agent 注册在 effect 卸载时全部交回', () => {
  311. const ws = makeWorkspace()
  312. const sectionDisposed: string[] = []
  313. const contexts: unknown[] = []
  314. const self: Record<string, unknown> = {}
  315. self.systemPrompt = {
  316. context: (c: unknown) => { contexts.push(c); return () => { sectionDisposed.push('context') } },
  317. section: () => () => { sectionDisposed.push('section') },
  318. }
  319. self.inject = (_d: unknown, cb: (s: unknown) => void) => { cb(self); return { dispose: () => Promise.resolve() } }
  320. self.on = () => () => { sectionDisposed.push('on') }
  321. const { host, effects } = makeHost({
  322. ready: {
  323. workspaceRegistry: { list: () => [{ path: ws }] },
  324. agents: { list: () => [{ id: 'sess-1', ctx: self }] },
  325. },
  326. })
  327. apply(host as never)
  328. for (const e of [...effects].reverse()) {
  329. for (const d of [...e.disposers].reverse()) d()
  330. }
  331. expect(sectionDisposed, 'persona section 与门禁监听均应交回').toContain('section')
  332. expect(sectionDisposed).toContain('on')
  333. })
  334. })
  335. describe('B5 工作范围状态随 fiber 生命周期(不是模块级泄漏)', () => {
  336. beforeEach(() => { resetWorkspaceRoot() })
  337. it('卸载后工作范围交回,重装不继承上一轮的值', () => {
  338. const wsA = makeWorkspace()
  339. const sink: unknown[] = []
  340. const { host, effects } = makeHost({
  341. ready: {
  342. workspaceRegistry: { list: () => [{ path: wsA }] },
  343. agents: { list: () => [{ id: 'sess-1', ctx: makeAgentCtx(sink) }] },
  344. },
  345. })
  346. apply(host as never)
  347. expect(currentWorkspaceRoot()).toBe(wsA)
  348. for (const e of [...effects].reverse()) {
  349. for (const d of [...e.disposers].reverse()) d()
  350. }
  351. expect(currentWorkspaceRoot(), '卸载后工作范围应交回').toBeUndefined()
  352. })
  353. it('两个宿主实例各自的工作范围互不串(后装不覆盖先装的书架视图)', () => {
  354. const wsA = makeWorkspace()
  355. const wsB = makeWorkspace()
  356. const sinkA: unknown[] = []
  357. const sinkB: unknown[] = []
  358. const a = makeHost({
  359. ready: {
  360. workspaceRegistry: { list: () => [{ path: wsA }] },
  361. agents: { list: () => [{ id: 'a-1', ctx: makeAgentCtx(sinkA) }] },
  362. },
  363. })
  364. apply(a.host as never)
  365. const rootAfterA = currentWorkspaceRoot()
  366. expect(rootAfterA).toBe(wsA)
  367. // 第二个实例装上来:先装那一份的观察值不应被悄悄改写
  368. const b = makeHost({
  369. ready: {
  370. workspaceRegistry: { list: () => [{ path: wsB }] },
  371. agents: { list: () => [{ id: 'b-1', ctx: makeAgentCtx(sinkB) }] },
  372. },
  373. })
  374. apply(b.host as never)
  375. // claimWorkspaceRoot 幂等:已认到就不改(先到先得),B 不覆盖 A
  376. expect(currentWorkspaceRoot(), '已认到工作范围时后装不应覆盖').toBe(wsA)
  377. for (const e of [...b.effects, ...a.effects].reverse()) {
  378. for (const d of [...e.disposers].reverse()) d()
  379. }
  380. expect(currentWorkspaceRoot()).toBeUndefined()
  381. })
  382. })