memory-catalog-context.spec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import { afterAll, describe, expect, it } 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 { writeAuthorMemory, writeBookMemory } from '@webnovel/core'
  6. import { createUserMessage, type UserMessage } from '@deepseek-ai/dsh-llm'
  7. import type { PreStepDecision } from '@deepseek-ai/dsh-agent'
  8. import { attachInitialMemoryCatalog, MEMORY_CATALOG_SOURCE, bookMemoryCatalogText } from '../src/status-context'
  9. import { createNovelTools } from '../src/novel-tools'
  10. import { nativeWriteStub } from './fixtures/native-write-stub'
  11. import { removeSync } from '../../core/src/repo/remove'
  12. const roots: string[] = []
  13. afterAll(() => roots.forEach(root => removeSync(root)))
  14. function makeWorkspace(): string {
  15. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-memctx-'))
  16. roots.push(dir)
  17. const book = path.join(dir, '星辰')
  18. fs.mkdirSync(path.join(book, '作品契约'), { recursive: true })
  19. fs.writeFileSync(path.join(book, '作品契约/契约.md'), '---\n书id: xingchen-001\n---\n正文\n')
  20. return dir
  21. }
  22. function harness(workspaceRoot: () => string | undefined, history: UserMessage[] = []) {
  23. let listener: (payload: unknown, next: () => Promise<PreStepDecision>) => Promise<PreStepDecision>
  24. let disposed = false
  25. const ctx = { on: (_name: string, callback: typeof listener) => { listener = callback; return () => { disposed = true } } }
  26. const agent = { ctx, session: { get seq() { return history.length }, eventAt: (seq: number) => ({ type: 'user/message', data: history[seq] }) } }
  27. const dispose = attachInitialMemoryCatalog(ctx as never, { workspaceRoot })
  28. return {
  29. dispose, history, isDisposed: () => disposed,
  30. run: async (options: { rejected?: boolean; aborted?: boolean; commit?: boolean; delegated?: boolean; messages?: UserMessage[] } = {}) => {
  31. const decision: PreStepDecision = options.rejected ? { kind: 'reject', reason: 'fixture' } as PreStepDecision : { kind: 'enter', messages: options.messages ?? [] }
  32. const signal = options.aborted ? AbortSignal.abort() : new AbortController().signal
  33. const result = await listener({ agent: options.delegated ? { ...agent, ctx: {} } : agent, signal }, async () => decision)
  34. if (result.kind === 'enter' && !options.aborted && options.commit !== false) history.push(...result.messages)
  35. return result
  36. },
  37. }
  38. }
  39. const contents = (messages: readonly UserMessage[]) => messages.flatMap(m => m.content.flatMap(p => p.type === 'text' ? [p.text] : [])).join('\n')
  40. const write = (ws: string, name: string) => writeAuthorMemory(ws, { 名称: name, 描述: `作者喜欢 {{${name}}}`, 类: '文风', 标签: ['星辰'], 来源: '对谈', 正文: '正文不进目录' })
  41. describe('作者记忆目录:独立的一次性原生消息(15A)', () => {
  42. it('首次发送,后续记忆或其他运行时context变化不重发,花括号保持原文', async () => {
  43. const ws = makeWorkspace()
  44. write(ws, '冷开场')
  45. const host = harness(() => ws)
  46. await host.run({ delegated: true })
  47. expect(host.history).toHaveLength(0)
  48. await host.run()
  49. expect(host.history).toHaveLength(1)
  50. expect(host.history[0]!.source).toMatchObject({ kind: 'plugin', plugin: MEMORY_CATALOG_SOURCE })
  51. expect(contents(host.history)).toContain('作者喜欢 {{冷开场}}')
  52. expect(contents(host.history)).not.toContain('正文不进目录')
  53. write(ws, '新记忆')
  54. for (let i = 0; i < 3; i++) await host.run({ messages: [createUserMessage({ content: [{ type: 'text', text: `变动状态${i}` }], source: { kind: 'plugin', plugin: 'other-context' } })] })
  55. expect(host.history.filter(m => m.source.kind === 'plugin' && m.source.plugin === MEMORY_CATALOG_SOURCE)).toHaveLength(1)
  56. expect(contents(host.history)).not.toContain('新记忆')
  57. host.dispose()
  58. expect(host.isDisposed()).toBe(true)
  59. })
  60. it('工作范围迟到:首次就绪后才发送,没有记忆时也给索引入口', async () => {
  61. const ws = makeWorkspace()
  62. let ready = false
  63. const host = harness(() => ready ? ws : undefined)
  64. await host.run()
  65. expect(host.history).toHaveLength(0)
  66. ready = true
  67. await host.run()
  68. expect(contents(host.history)).toContain('目前没有作者记忆')
  69. expect(contents(host.history)).toContain(path.join(ws, '书房/作者记忆/索引.md'))
  70. })
  71. it('拒绝、取消和未提交的步骤不吞掉首次发送', async () => {
  72. const ws = makeWorkspace()
  73. const host = harness(() => ws)
  74. await host.run({ rejected: true })
  75. await host.run({ aborted: true })
  76. await host.run({ commit: false })
  77. expect(host.history).toHaveLength(0)
  78. await host.run()
  79. await host.run()
  80. expect(host.history).toHaveLength(1)
  81. })
  82. it('恢复沿用已发送消息,目录后来变化也不追加;新会话读取新目录', async () => {
  83. const ws = makeWorkspace()
  84. const first = harness(() => ws)
  85. await first.run()
  86. write(ws, '恢复前新增')
  87. const restored = harness(() => ws, [...first.history])
  88. await restored.run()
  89. expect(restored.history).toHaveLength(1)
  90. expect(contents(restored.history)).not.toContain('恢复前新增')
  91. const fresh = harness(() => ws)
  92. await fresh.run()
  93. expect(contents(fresh.history)).toContain('恢复前新增')
  94. })
  95. it('兼容历史combined快照的已发送目录,不再另加一份', async () => {
  96. const ws = makeWorkspace()
  97. const legacy = createUserMessage({ content: [{ type: 'text', text: '旧目录' }], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot', sections: [{ name: 'webnovel.memory', text: '旧目录' }] } })
  98. const host = harness(() => ws, [legacy])
  99. await host.run()
  100. expect(host.history).toEqual([legacy])
  101. })
  102. })
  103. describe('本书记忆目录:随选书结果返回(15A)', () => {
  104. it('novel_select_book 成功返回 记忆目录(选书时快照);普通查询不附目录;切书换书目录', async () => {
  105. const ws = makeWorkspace()
  106. const bookRoot = path.join(ws, '星辰')
  107. writeBookMemory(bookRoot, { 类: '决策', 名称: '不写系统', 正文: '全书无系统面板', 来源: '定稿/卷01/0001-开篇.md', 裁决记录: '作者批准' })
  108. const tools = createNovelTools({ nativeWrite: nativeWriteStub, workspaceRoot: () => ws, bookRootOfBookId: () => bookRoot })
  109. const select = tools.find((t) => t.name === 'novel_select_book')!
  110. const res = (await select.execute({ bookId: 'xingchen-001' })) as { ok: boolean; bookId: string; 记忆目录: string; message: string }
  111. expect(res.ok).toBe(true)
  112. expect(res.bookId).toBe('xingchen-001')
  113. expect(res.记忆目录).toContain('【本书记忆目录】选书时')
  114. expect(res.记忆目录).toContain('- 不写系统 — (缺描述)|类:决策')
  115. expect(res.记忆目录).toContain(path.join(bookRoot, '本书记忆', '索引.md'))
  116. expect(res.记忆目录).not.toContain('全书无系统面板')
  117. expect(res.message).not.toContain('记忆目录')
  118. const status = tools.find((t) => t.name === 'novel_get_story_status')!
  119. const statusRes = (await status.execute({ bookId: 'xingchen-001' })) as Record<string, unknown>
  120. expect(statusRes['记忆目录']).toBeUndefined()
  121. expect(JSON.stringify(statusRes)).not.toContain('记忆目录')
  122. // 切书:另一本书返回自己的目录
  123. const other = path.join(ws, '夜航')
  124. fs.mkdirSync(path.join(other, '作品契约'), { recursive: true })
  125. fs.writeFileSync(path.join(other, '作品契约', '契约.md'), '---\n书id: yehang-002\n---\n', 'utf8')
  126. const res2 = (await select.execute({ bookId: 'yehang-002' })) as { ok: boolean; 记忆目录: string }
  127. expect(res2.ok).toBe(true)
  128. expect(res2.记忆目录).toContain('目前没有本书记忆')
  129. expect(res2.记忆目录).toContain(path.join(other, '本书记忆', '索引.md'))
  130. expect(res2.记忆目录).not.toContain('不写系统')
  131. // 选书失败不带目录
  132. const bad = (await select.execute({ bookId: '不存在' })) as Record<string, unknown>
  133. expect(bad['ok']).toBe(false)
  134. expect(bad['记忆目录']).toBeUndefined()
  135. expect(bookMemoryCatalogText(path.join(ws, '不存在的书'))).toContain('目前没有本书记忆')
  136. })
  137. })