| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433 |
- import { beforeEach, describe, expect, it, vi } from 'vitest'
- import * as fs from 'node:fs'
- import * as os from 'node:os'
- import * as path from 'node:path'
- import { apply, currentWorkspaceRoot, registerWorkspaceRoot, resetWorkspaceRoot } from '../src/index'
- /** 临时工作范围:一本带契约的书。 */
- function makeWorkspace(): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-lifecycle-'))
- const book = path.join(dir, '星辰')
- fs.mkdirSync(path.join(book, '作品契约'), { recursive: true })
- fs.writeFileSync(path.join(book, '作品契约', '契约.md'), '---\n书id: xingchen-001\n---\n正文\n', 'utf8')
- return dir
- }
- interface FakeAgentCtx {
- systemPrompt: { context: (c: unknown) => () => void; section: (s: unknown) => () => void }
- inject: (deps: unknown, cb: (scope: unknown) => void) => { dispose: () => Promise<void> }
- on: (e: string) => void
- }
- /**
- * agent.ctx 桩(审计 B4)。
- *
- * 两处必须照真类型来,否则 B2 那类泄漏测不出来:
- * 1. `systemPrompt.context/section` 真类型都回注销函数
- * (dsh system-prompt index.d.ts:185/192 `(...): () => void`)——原桩回 undefined,
- * 生产代码丢不丢这个返回值,测试完全看不出。
- * 2. `inject` 真类型回 `Fiber & PromiseLike<Fiber>`(cordis registry.d.ts:111),
- * 其 `dispose: () => Promise<void>`(fiber.d.ts:112)——原桩回 undefined,
- * 于是「fork 的 scope 跨卸载存活」这条路径在测试里根本不存在。
- *
- * 交回记录写进 released,由用例断言两样都交回了。
- */
- function makeAgentCtx(sink: unknown[], released: string[] = [], injected: string[] = []): FakeAgentCtx {
- const self: FakeAgentCtx = {
- systemPrompt: {
- context: (c) => { sink.push(c); return () => { released.push('context') } },
- section: () => () => { released.push('section') },
- },
- inject: (_deps, cb) => {
- injected.push('inject')
- cb(self)
- return { dispose: async () => { released.push('fiber') } }
- },
- on: () => {},
- }
- return self
- }
- /**
- * 宿主 ctx 桩:服务按 name 逐个「就绪」,inject 记录依赖声明。
- * ready 里没有的服务视为尚未就绪(pending),对应真机上 workspaceRegistry 等待
- * storageDomain 的那一刻。
- */
- function makeHost(opts: {
- readonly ready: Record<string, unknown>
- readonly withInject?: boolean
- readonly withEffect?: boolean
- }) {
- const injected: Array<{ deps: readonly string[]; run: (scope: unknown) => void }> = []
- const effects: Array<{ label?: string; disposers: Array<() => void> }> = []
- const listeners = new Map<string, Array<(...a: never[]) => unknown>>()
- const host: Record<string, unknown> = {
- logger: { info: () => {}, warn: () => {} },
- get: (n: string) => opts.ready[n],
- on: (e: string, l: (...a: never[]) => unknown) => {
- const arr = listeners.get(e) ?? []
- arr.push(l)
- listeners.set(e, arr)
- return () => {}
- },
- }
- if (opts.withInject !== false) {
- host.inject = (deps: readonly string[], run: (scope: unknown) => void) => {
- injected.push({ deps, run })
- // 依赖全就绪才跑,模拟 cordis「依赖出现即激活」
- if (deps.every((d) => opts.ready[d] !== undefined)) run(host)
- return undefined
- }
- }
- if (opts.withEffect !== false) {
- host.effect = (execute: () => unknown, label?: string) => {
- const rec: { label?: string; disposers: Array<() => void> } = { label, disposers: [] }
- effects.push(rec)
- const r = execute()
- if (typeof r === 'function') rec.disposers.push(r as () => void)
- else if (r !== null && typeof r === 'object' && Symbol.iterator in (r as object)) {
- for (const d of r as Iterable<() => void>) rec.disposers.push(d)
- }
- return () => { for (const d of [...rec.disposers].reverse()) d() }
- }
- }
- return { host, injected, effects, listeners }
- }
- describe('B6 依赖反应性(inject 持续契约,非 apply 时刻快照)', () => {
- beforeEach(() => { resetWorkspaceRoot() })
- it('缺失技能挂载能力明确告警,等待 skills 服务时不误报', () => {
- const missing = makeHost({ ready: {} })
- const warn = vi.fn()
- missing.host.logger = { info: () => {}, warn }
- apply(missing.host as never)
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('随包技能未挂载'))
- const waiting = makeHost({ ready: {} })
- waiting.host.plugin = vi.fn()
- waiting.host.logger = { info: () => {}, warn: vi.fn() }
- apply(waiting.host as never)
- expect(waiting.injected.some(item => item.deps.includes('skills'))).toBe(true)
- expect(waiting.host.plugin).not.toHaveBeenCalled()
- expect((waiting.host.logger as { warn: unknown }).warn).not.toHaveBeenCalled()
- })
- it('workspaceRegistry 在 apply 时刻尚未就绪:仍经 inject 声明,后到即认到工作范围', () => {
- const ws = makeWorkspace()
- const sink: unknown[] = []
- const agentCtx = makeAgentCtx(sink)
- // apply 时刻 workspaceRegistry 缺席(pending),只有 agents/tools 在
- const { host, injected } = makeHost({
- ready: { agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] } },
- })
- apply(host as never)
- // 关键:必须经 inject 声明 workspaceRegistry,而不是 apply 时刻 get 一次就放弃
- const decl = injected.find((i) => i.deps.includes('workspaceRegistry'))
- expect(decl, 'workspaceRegistry 应经 ctx.inject 声明').toBeDefined()
- // 依赖后到:cordis 会重跑 setup,registry 的值应盖过 headless 兜底的进程 cwd
- decl!.run({ workspaceRegistry: { list: () => [{ path: ws }] } })
- expect(currentWorkspaceRoot()).toBe(ws)
- })
- it('tools 后到也能注册工具(注册随 agent 装机,反应式声明照旧,不因 apply 时刻缺席而永久放弃)', () => {
- const registered: string[] = []
- // dsh 运行时对齐批:注册落点=agent 的 scope 层。桩 agent 的 ctx 暴露
- // tools 记录器,反应式回调(tools/userQuestions 就绪)触发时经 agentCtx.get 可达。
- const recorder = { register: (t: { name: string }) => { registered.push(t.name); return () => {} } }
- const agentCtx = { get: (n: string) => (n === 'tools' ? recorder : undefined) }
- const { host, injected } = makeHost({ ready: { agents: { list: () => [{ id: 'sess-9', ctx: agentCtx }] } } })
- apply(host as never)
- const decl = injected.find((i) => i.deps.includes('tools') && i.deps.includes('userQuestions'))
- expect(decl, 'tools+userQuestions 应经 ctx.inject 声明').toBeDefined()
- expect(registered.length).toBe(0)
- decl!.run({ logger: { info: () => {}, warn: () => {} } })
- expect(registered.length).toBeGreaterThan(0)
- expect(registered).toContain('novel_create_book')
- })
- it('无 inject 表面的宿主:退回 ctx.get 快照路径,仍加载不抛', () => {
- const ws = makeWorkspace()
- const sink: unknown[] = []
- const agentCtx = makeAgentCtx(sink)
- const { host } = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: ws }] },
- agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
- },
- withInject: false,
- withEffect: false,
- })
- expect(() => apply(host as never)).not.toThrow()
- expect(currentWorkspaceRoot()).toBe(ws)
- })
- })
- describe('B3 多步装机走 effect(中途失败逆序回滚)', () => {
- beforeEach(() => { resetWorkspaceRoot() })
- it('装机各步经 ctx.effect 注册,带可读 label', () => {
- const ws = makeWorkspace()
- const sink: unknown[] = []
- const agentCtx = makeAgentCtx(sink)
- const { host, effects } = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: ws }] },
- agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
- tools: { register: () => {} },
- },
- })
- apply(host as never)
- expect(effects.length, '装机步骤应经 ctx.effect 注册').toBeGreaterThan(0)
- for (const e of effects) {
- expect(e.label, 'effect 应带可读 label 供 fiber 诊断').toBeTruthy()
- }
- })
- it('每步 yield 的逆操作在卸载时逆序执行', () => {
- const ws = makeWorkspace()
- const order: string[] = []
- const sink: unknown[] = []
- const agentCtx = makeAgentCtx(sink)
- const { host, effects } = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: ws }] },
- agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
- tools: { register: () => {} },
- },
- })
- apply(host as never)
- // 逐个 effect 的 disposer 都应可调用且不抛
- for (const [i, e] of effects.entries()) {
- for (const d of [...e.disposers].reverse()) {
- expect(() => d()).not.toThrow()
- order.push(`e${i}`)
- }
- }
- expect(order.length).toBe(effects.reduce((n, e) => n + e.disposers.length, 0))
- })
- })
- describe('B4 汇合测试(手册 §13:加载A→卸载A→加载B→卸载B→加载A 等价于直接加载A)', () => {
- beforeEach(() => { resetWorkspaceRoot() })
- /** 一轮完整加载,返回可观察状态与卸载函数。 */
- function load(ws: string) {
- const contexts: unknown[] = []
- const toolNames: string[] = []
- const released: string[] = []
- const injected: string[] = []
- const agentCtx = makeAgentCtx(contexts, released, injected)
- const { host, effects } = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: ws }] },
- agents: { list: () => [{ id: 'sess-1', ctx: agentCtx }] },
- tools: { register: (t: { name: string }) => { toolNames.push(t.name) } },
- },
- })
- apply(host as never)
- // 卸载只跑 effect 的逆操作 —— 不额外调 resetWorkspaceRoot 兜底:
- // 那等于替生产代码补交回,逆操作真漏了也照样绿。
- const unload = () => {
- for (const e of [...effects].reverse()) {
- for (const d of [...e.disposers].reverse()) d()
- }
- }
- return { contexts, toolNames, released, injected, unload, root: currentWorkspaceRoot() }
- }
- it('A→卸A→B→卸B→A 的可观察状态与直接加载 A 等价', () => {
- const wsA = makeWorkspace()
- const wsB = makeWorkspace()
- const baseline = load(wsA)
- const baselineShape = {
- root: baseline.root,
- tools: [...baseline.toolNames].sort(),
- contexts: baseline.contexts.length,
- }
- baseline.unload()
- const b = load(wsB)
- expect(b.root).toBe(wsB)
- b.unload()
- const again = load(wsA)
- expect({
- root: again.root,
- tools: [...again.toolNames].sort(),
- contexts: again.contexts.length,
- }).toEqual(baselineShape)
- again.unload()
- })
- it('卸载后 per-agent 三样全部交回:status context / persona section / inject 的 fork', () => {
- const ws = makeWorkspace()
- const r = load(ws)
- expect(r.released, '装载期间不应有交回').toEqual([])
- r.unload()
- // 三样各自的交回都要发生。fiber 是 fork 出来的子 scope,
- // 不 dispose 则连同依赖订阅跨卸载存活,重装即叠加(审计 B2)。
- expect(r.released, 'status context 应交回注销函数').toContain('context')
- expect(r.released, 'persona section 应交回注销函数').toContain('section')
- // 按次数比对:persona 与 status 各 inject 一次,只数「有没有 fiber」的话
- // 其中一条漏 dispose 也照样绿(persona 早先就是这么漏掉的)。
- const disposed = r.released.filter((x) => x === 'fiber').length
- expect(disposed, '每次 inject 的 fork 都应 dispose,不能只交回其中一条').toBe(r.injected.length)
- expect(r.injected.length, '至少有 persona 与 status 两条 inject').toBeGreaterThanOrEqual(2)
- expect(currentWorkspaceRoot(), '工作范围应由逆操作交回,不靠测试兜底').toBeUndefined()
- })
- it('重装不叠加:两轮的注册数与交回数相等', () => {
- const ws = makeWorkspace()
- const first = load(ws)
- const firstContexts = first.contexts.length
- first.unload()
- const firstReleased = [...first.released]
- const second = load(ws)
- expect(second.contexts.length, '重装的注册数应与首装相同(未叠加)').toBe(firstContexts)
- second.unload()
- expect([...second.released].sort(), '两轮交回形状应一致').toEqual(firstReleased.sort())
- })
- })
- describe('B2 per-agent 注册随卸载交回(persona / status / 门禁)', () => {
- beforeEach(() => { resetWorkspaceRoot() })
- it('attachPersonaToAgent 交回 sys.section 的注销函数', async () => {
- const { attachPersonaToAgent } = await import('../src/persona')
- let disposed = 0
- const self: Record<string, unknown> = {}
- self.systemPrompt = { section: () => () => { disposed += 1 } }
- self.inject = (_d: unknown, cb: (s: unknown) => void) => { cb(self); return undefined }
- const undo = attachPersonaToAgent(self as never)
- expect(typeof undo, 'persona 应交回注销函数而非 boolean').toBe('function')
- ;(undo as () => void)()
- expect(disposed, '卸载应调用 section 的注销函数').toBe(1)
- })
- it('attachFileGateToAgent 交回 tools/pre-execute 的注销函数', async () => {
- const { attachFileGateToAgent } = await import('../src/gates')
- let disposed = 0
- const agentCtx = {
- on: (_e: string, _l: unknown) => () => { disposed += 1 },
- }
- const undo = attachFileGateToAgent(agentCtx as never, {
- bookRootOfId: () => undefined,
- bookRootForAbs: () => undefined,
- workspaceRoot: () => undefined,
- })
- expect(typeof undo, '门禁应交回注销函数').toBe('function')
- ;(undo as () => void)()
- expect(disposed, '卸载应注销 tools/pre-execute 监听').toBe(1)
- })
- it('无 systemPrompt / 无 on 表面时交回 undefined(fail-open,不抛)', async () => {
- const { attachPersonaToAgent } = await import('../src/persona')
- const { attachFileGateToAgent } = await import('../src/gates')
- expect(attachPersonaToAgent(undefined)).toBeUndefined()
- expect(attachFileGateToAgent({} as never, {
- bookRootOfId: () => undefined,
- bookRootForAbs: () => undefined,
- workspaceRoot: () => undefined,
- })).toBeUndefined()
- })
- it('apply 装的 per-agent 注册在 effect 卸载时全部交回', () => {
- const ws = makeWorkspace()
- const sectionDisposed: string[] = []
- const contexts: unknown[] = []
- const self: Record<string, unknown> = {}
- self.systemPrompt = {
- context: (c: unknown) => { contexts.push(c); return () => { sectionDisposed.push('context') } },
- section: () => () => { sectionDisposed.push('section') },
- }
- self.inject = (_d: unknown, cb: (s: unknown) => void) => { cb(self); return { dispose: () => Promise.resolve() } }
- self.on = () => () => { sectionDisposed.push('on') }
- const { host, effects } = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: ws }] },
- agents: { list: () => [{ id: 'sess-1', ctx: self }] },
- },
- })
- apply(host as never)
- for (const e of [...effects].reverse()) {
- for (const d of [...e.disposers].reverse()) d()
- }
- expect(sectionDisposed, 'persona section 与门禁监听均应交回').toContain('section')
- expect(sectionDisposed).toContain('on')
- })
- })
- describe('B5 工作范围状态随 fiber 生命周期(不是模块级泄漏)', () => {
- beforeEach(() => { resetWorkspaceRoot() })
- it('卸载后工作范围交回,重装不继承上一轮的值', () => {
- const wsA = makeWorkspace()
- const sink: unknown[] = []
- const { host, effects } = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: wsA }] },
- agents: { list: () => [{ id: 'sess-1', ctx: makeAgentCtx(sink) }] },
- },
- })
- apply(host as never)
- expect(currentWorkspaceRoot()).toBe(wsA)
- for (const e of [...effects].reverse()) {
- for (const d of [...e.disposers].reverse()) d()
- }
- expect(currentWorkspaceRoot(), '卸载后工作范围应交回').toBeUndefined()
- })
- it('两个宿主实例各自的工作范围互不串(后装不覆盖先装的书架视图)', () => {
- const wsA = makeWorkspace()
- const wsB = makeWorkspace()
- const sinkA: unknown[] = []
- const sinkB: unknown[] = []
- const a = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: wsA }] },
- agents: { list: () => [{ id: 'a-1', ctx: makeAgentCtx(sinkA) }] },
- },
- })
- apply(a.host as never)
- const rootAfterA = currentWorkspaceRoot()
- expect(rootAfterA).toBe(wsA)
- // 第二个实例装上来:先装那一份的观察值不应被悄悄改写
- const b = makeHost({
- ready: {
- workspaceRegistry: { list: () => [{ path: wsB }] },
- agents: { list: () => [{ id: 'b-1', ctx: makeAgentCtx(sinkB) }] },
- },
- })
- apply(b.host as never)
- // claimWorkspaceRoot 幂等:已认到就不改(先到先得),B 不覆盖 A
- expect(currentWorkspaceRoot(), '已认到工作范围时后装不应覆盖').toBe(wsA)
- for (const e of [...b.effects, ...a.effects].reverse()) {
- for (const d of [...e.disposers].reverse()) d()
- }
- expect(currentWorkspaceRoot()).toBeUndefined()
- })
- })
|