novel-tools.spec.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { spawnSync } from 'node:child_process'
  6. import { createNovelTools } from '../src/novel-tools'
  7. import { createBook } from '@webnovel/core'
  8. import { nativeWriteStub } from './fixtures/native-write-stub'
  9. const roots: string[] = []
  10. function mkRoot(): string {
  11. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-tools-'))
  12. roots.push(dir)
  13. return dir
  14. }
  15. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  16. describe('主 Agent 专用 Tools (方案 A)', () => {
  17. it('novel_create_book & novel_select_book & novel_seed_min_design & novel_update_contract & novel_get_story_status 闭环', async () => {
  18. const ws = mkRoot()
  19. const tools = createNovelTools({
  20. nativeWrite: nativeWriteStub,
  21. workspaceRoot: () => ws,
  22. bookRootOfBookId: (id) => path.join(ws, '仙途长生'),
  23. })
  24. const createTool = tools.find((t) => t.name === 'novel_create_book')!
  25. const selectTool = tools.find((t) => t.name === 'novel_select_book')!
  26. const seedTool = tools.find((t) => t.name === 'novel_seed_min_design')!
  27. const contractTool = tools.find((t) => t.name === 'novel_update_contract')!
  28. const statusTool = tools.find((t) => t.name === 'novel_get_story_status')!
  29. // 1. 建书
  30. const createRes = (await createTool.execute({
  31. bookName: '仙途长生',
  32. concept: {
  33. 状态: '已确认',
  34. 核心创意: '凡人流修仙与上古残魂',
  35. 题材与目标读者: '古典仙侠',
  36. 主角核心欲望: '求长生与道心坚定',
  37. 主要冲突: '宗门倾轧与魔修劫难',
  38. 核心看点: '稳健发育与步步为营',
  39. 差异化方向: '无系统纯智商博弈',
  40. 明确不要什么: '无脑倒贴与战力崩塌',
  41. },
  42. }, { agent: { id: 'agent-1', session: { append: () => {} } } })) as { ok: boolean; bookId: string }
  43. expect(createRes.ok).toBe(true)
  44. expect(createRes.bookId).toMatch(/^b-/)
  45. // 2. 更新契约(题材与读者定位)
  46. const contractRes = (await contractTool.execute({
  47. bookId: createRes.bookId,
  48. partName: '题材与读者定位',
  49. state: '已确认',
  50. content: '练气九层、筑基三阶、金丹九品。',
  51. })) as { ok: boolean }
  52. expect(contractRes.ok).toBe(true)
  53. // 验证契约内容与书id未丢失
  54. const contractContent = fs.readFileSync(path.join(ws, '仙途长生', '作品契约/契约.md'), 'utf-8')
  55. expect(contractContent).toContain(`书id: ${createRes.bookId}`)
  56. expect(contractContent).toContain('练气九层、筑基三阶、金丹九品。')
  57. // 3. 最小设计
  58. const seedRes = (await seedTool.execute({ bookId: createRes.bookId })) as { ok: boolean }
  59. expect(seedRes.ok).toBe(true)
  60. // 4. 读取真源状态
  61. const statusRes = (await statusTool.execute({ bookId: createRes.bookId })) as { ok: boolean; chapters: any[] }
  62. expect(statusRes.ok).toBe(true)
  63. expect(statusRes.chapters.length).toBeGreaterThan(0)
  64. })
  65. it('novel_prepare_pack & novel_settle_chapter 守卫:无待审稿报错、无裁决通道拒绝沉淀', async () => {
  66. const ws = mkRoot()
  67. const tools = createNovelTools({
  68. nativeWrite: nativeWriteStub,
  69. workspaceRoot: () => ws,
  70. bookRootOfBookId: () => path.join(ws, '守卫书'),
  71. })
  72. const createTool = tools.find((t) => t.name === 'novel_create_book')!
  73. const prepareTool = tools.find((t) => t.name === 'novel_prepare_pack')!
  74. const settleTool = tools.find((t) => t.name === 'novel_settle_chapter')!
  75. const createRes = (await createTool.execute({
  76. bookName: '守卫书',
  77. concept: {
  78. 状态: '已确认',
  79. 核心创意: 'x', 题材与目标读者: 'x', 主角核心欲望: 'x', 主要冲突: 'x',
  80. 核心看点: 'x', 差异化方向: 'x', 明确不要什么: 'x',
  81. },
  82. }, { agent: { id: 'agent-1', session: { append: () => {} } } })) as { ok: boolean; bookId: string }
  83. // 无待审稿:组装定稿包失败
  84. const prepareRes = (await prepareTool.execute({ bookId: createRes.bookId, 卷: 1, 章: 1, 章名: '第一章' })) as { ok: boolean; reason?: string }
  85. expect(prepareRes.ok).toBe(false)
  86. expect(prepareRes.reason).toContain('待审稿')
  87. // 未注入裁决通道:拒绝沉淀(裁决 14:审批不可由模型自证,详见 settle-approval.spec.ts)
  88. const noChannel = (await settleTool.execute({ bookId: createRes.bookId, 卷: 1, 章: 1, 章名: '第一章', summary: 'x' })) as { ok: boolean; reason?: string }
  89. expect(noChannel.ok).toBe(false)
  90. expect(noChannel.reason).toContain('裁决通道')
  91. })
  92. it('提交点跟随作者确认(拍板 1/2/7):契约已确认→design: 提交;暂定→不提交;seed→整批一次提交且重跑幂等', async () => {
  93. const ws = mkRoot()
  94. const tools = createNovelTools({
  95. nativeWrite: nativeWriteStub,
  96. workspaceRoot: () => ws,
  97. bookRootOfBookId: (id) => path.join(ws, '提交纪律书'),
  98. })
  99. const createTool = tools.find((t) => t.name === 'novel_create_book')!
  100. const seedTool = tools.find((t) => t.name === 'novel_seed_min_design')!
  101. const contractTool = tools.find((t) => t.name === 'novel_update_contract')!
  102. const createRes = (await createTool.execute({
  103. bookName: '提交纪律书',
  104. concept: {
  105. 状态: '已确认',
  106. 核心创意: 'x', 题材与目标读者: 'x', 主角核心欲望: 'x', 主要冲突: 'x',
  107. 核心看点: 'x', 差异化方向: 'x', 明确不要什么: 'x',
  108. },
  109. }, { agent: { id: 'agent-1', session: { append: () => {} } } })) as { ok: boolean; bookId: string }
  110. const git = (args: string[]) => spawnSync('git', ['-c', 'core.quotepath=false', ...args], { cwd: path.join(ws, '提交纪律书'), encoding: 'utf-8' })
  111. const count = () => git(['rev-list', '--count', 'HEAD']).stdout?.trim()
  112. // 暂定态:只写文件,不产生提交
  113. const tentative = (await contractTool.execute({
  114. bookId: createRes.bookId,
  115. partName: '题材与读者定位', state: '暂定', content: '暂定内容',
  116. })) as { ok: boolean }
  117. expect(tentative.ok).toBe(true)
  118. const afterTentative = count()
  119. expect(git(['log', '-1', '--pretty=%s', '--', '作品契约/契约.md']).stdout?.trim()).not.toMatch(/^design:/)
  120. // 已确认:产生 design: 提交
  121. const confirmed = (await contractTool.execute({
  122. bookId: createRes.bookId,
  123. partName: '题材与读者定位', state: '已确认', content: '练气九层、筑基三阶。',
  124. })) as { ok: boolean }
  125. expect(confirmed.ok).toBe(true)
  126. const confirmedLog = git(['log', '-1', '--pretty=%s', '--', '作品契约/契约.md']).stdout?.trim()
  127. expect(confirmedLog).toMatch(/^design: /)
  128. expect(Number(count())).toBe(Number(afterTentative) + 1)
  129. // seed:整批一次 design: 提交
  130. const beforeSeed = count()
  131. const seedRes = (await seedTool.execute({ bookId: createRes.bookId })) as { ok: boolean }
  132. expect(seedRes.ok).toBe(true)
  133. expect(Number(count())).toBe(Number(beforeSeed) + 1)
  134. const seedShow = git(['show', '--name-only', '--pretty=format:', 'HEAD']).stdout?.split('\n').map((l) => l.trim()).filter((l) => l !== '')
  135. expect(seedShow?.length).toBeGreaterThan(1)
  136. expect(seedShow).toContain('大纲/故事骨架.md')
  137. // 重跑 seed:幂等,无新提交
  138. const seedReplay = (await seedTool.execute({ bookId: createRes.bookId })) as { ok: boolean }
  139. expect(seedReplay.ok).toBe(true)
  140. expect(Number(count())).toBe(Number(beforeSeed) + 1)
  141. })
  142. })