native-write.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { afterEach, 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 type { ToolExecutionInput, ToolExecutionResult, ToolExecutionToken, ToolRuntime } from '@deepseek-ai/dsh-tools'
  6. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  7. import { createNativeWriteBridge } from '../src/native-write'
  8. import { createNovelTools, type ToolExecContext } from '../src/novel-tools'
  9. import { removeSync } from '../../core/src/repo/remove'
  10. let root: string
  11. beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-native-')) })
  12. afterEach(() => { removeSync(root) })
  13. const op = { relPath: '大纲/故事骨架.md', content: '# 完整正文\n' }
  14. const success: ToolExecutionResult = { isError: false, value: { written: true }, content: [] }
  15. const context = (signal = new AbortController().signal): ToolExecContext => ({
  16. agent: { id: 'main' }, token: Symbol('business-call') as ToolExecutionToken,
  17. callId: 'business-call', rootCallId: 'model-call', signal,
  18. })
  19. describe('受信原生写入桥', () => {
  20. it('只放行当前嵌套 callId、parent、Agent 实例、write、目标和正文的完整匹配', async () => {
  21. let dispatch!: ToolExecutionInput
  22. let resolve!: (result: ToolExecutionResult) => void
  23. const execute: ToolRuntime['execute'] = async input => {
  24. dispatch = input
  25. return new Promise(done => { resolve = done })
  26. }
  27. const bridge = createNativeWriteBridge({ execute })
  28. const exec = context()
  29. const task = bridge.write(root, op, exec)
  30. expect(dispatch).toMatchObject({ name: 'write', agent: exec.agent, parent: exec.token, rootCallId: exec.rootCallId, signal: exec.signal })
  31. expect(dispatch.arguments).toEqual({ file_path: path.resolve(root, op.relPath), content: op.content })
  32. expect(bridge.allows(dispatch)).toBe(true)
  33. for (const changed of [
  34. { callId: 'unrelated-call' }, { parent: Symbol('unrelated') }, { parent: undefined },
  35. { agent: { id: 'main' } }, { name: 'edit' }, { name: 'read' },
  36. { arguments: { file_path: path.resolve(root, '作品契约/契约.md'), content: op.content } },
  37. { arguments: { file_path: path.resolve(root, op.relPath), content: '伪造正文' } },
  38. ]) expect(bridge.allows({ ...dispatch, ...changed })).toBe(false)
  39. expect(createNativeWriteBridge({ execute }).allows(dispatch)).toBe(false)
  40. resolve(success)
  41. await task
  42. expect(bridge.allows(dispatch)).toBe(false)
  43. })
  44. it('失败与取消都清理授权;保留原生错误和附加上下文', async () => {
  45. const extra = createUserMessage({ content: [{ type: 'text', text: '请重读文件后重试' }], source: { kind: 'plugin', plugin: 'test' } })
  46. let dispatch!: ToolExecutionInput
  47. const execute = vi.fn<ToolRuntime['execute']>(async input => {
  48. dispatch = input
  49. return { isError: true, content: [], error: { message: 're-read the file, then retry' }, additionalContexts: [extra] }
  50. })
  51. const bridge = createNativeWriteBridge({ execute })
  52. const deferContext = vi.fn()
  53. await expect(bridge.write(root, op, { ...context(), deferContext })).rejects.toThrow('re-read the file, then retry')
  54. expect(deferContext).toHaveBeenCalledWith(extra)
  55. expect(bridge.allows(dispatch)).toBe(false)
  56. const controller = new AbortController()
  57. controller.abort(new Error('已取消'))
  58. await expect(bridge.write(root, op, context(controller.signal))).rejects.toThrow('已取消')
  59. expect(execute).toHaveBeenCalledTimes(1)
  60. execute.mockImplementationOnce(async input => {
  61. dispatch = input
  62. return new Promise((_resolve, reject) => input.signal.addEventListener('abort', () => reject(input.signal.reason), { once: true }))
  63. })
  64. const waiting = new AbortController()
  65. const task = bridge.write(root, op, context(waiting.signal))
  66. waiting.abort(new Error('等待中取消'))
  67. await expect(task).rejects.toThrow('等待中取消')
  68. expect(bridge.allows(dispatch)).toBe(false)
  69. })
  70. it('成功时透传 concludeTurn,卸载后当前与后续调用均无放行凭证', async () => {
  71. let dispatch!: ToolExecutionInput
  72. let resolve!: (result: ToolExecutionResult) => void
  73. const execute = vi.fn<ToolRuntime['execute']>(async input => {
  74. dispatch = input
  75. return new Promise(done => { resolve = done })
  76. })
  77. const bridge = createNativeWriteBridge({ execute })
  78. const concludeTurn = vi.fn()
  79. const task = bridge.write(root, op, { ...context(), concludeTurn })
  80. bridge.dispose()
  81. expect(bridge.allows(dispatch)).toBe(false)
  82. resolve({ ...success, concludesTurn: true })
  83. await task
  84. expect(concludeTurn).toHaveBeenCalledTimes(1)
  85. await expect(bridge.write(root, op, context())).rejects.toThrow(/上下文不可用/)
  86. expect(execute).toHaveBeenCalledTimes(1)
  87. })
  88. it('缺执行上下文或路径越界时不调用宿主', async () => {
  89. const execute = vi.fn<ToolRuntime['execute']>()
  90. const bridge = createNativeWriteBridge({ execute })
  91. for (const exec of [undefined, {}, { ...context(), token: undefined }, { ...context(), agent: undefined }, { ...context(), signal: undefined }]) {
  92. await expect(bridge.write(root, op, exec)).rejects.toThrow(/上下文不可用/)
  93. }
  94. await expect(bridge.write(root, { ...op, relPath: '../越界.md' }, context())).rejects.toThrow(/不在书仓内/)
  95. expect(execute).not.toHaveBeenCalled()
  96. })
  97. })
  98. describe('设计工具写入前置条件', () => {
  99. it('宿主能力缺席时拒绝直接覆盖;业务校验失败时不发起原生写入', async () => {
  100. fs.mkdirSync(path.join(root, '大纲/卷规划/卷01'), { recursive: true })
  101. fs.writeFileSync(path.join(root, '大纲/故事骨架.md'), '原文\n')
  102. const deps = { workspaceRoot: () => root, bookRootOfBookId: () => root }
  103. const missing = createNovelTools(deps).find(tool => tool.name === 'novel_update_skeleton')!
  104. expect(await missing.execute({ bookId: 'book', 正文: '新文' })).toMatchObject({ ok: false, reason: expect.stringMatching(/无版本保护/) })
  105. expect(fs.readFileSync(path.join(root, '大纲/故事骨架.md'), 'utf8')).toBe('原文\n')
  106. const nativeWrite = vi.fn()
  107. const tools = createNovelTools({ ...deps, nativeWrite })
  108. const invalid = tools.find(tool => tool.name === 'novel_confirm_volume_outline')!
  109. expect(await invalid.execute({ bookId: 'book', 卷: 1, 目标: '卷纲', 正文: '缺少四段卷纲' })).toMatchObject({ ok: false, reason: expect.stringMatching(/段落不完整/) })
  110. fs.writeFileSync(path.join(root, '大纲/故事骨架.md'), '---\n版本: [坏格式\n---\n旧文')
  111. const skeleton = tools.find(tool => tool.name === 'novel_update_skeleton')!
  112. expect(await skeleton.execute({ bookId: 'book', 正文: '新文' })).toMatchObject({ ok: false, reason: expect.stringMatching(/解析失败/) })
  113. expect(nativeWrite).not.toHaveBeenCalled()
  114. })
  115. })