host-loader.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import assert from 'node:assert/strict'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import { createRequire } from 'node:module'
  5. import { pathToFileURL } from 'node:url'
  6. import { execFileSync } from 'node:child_process'
  7. import { baseline, loadSourceHost } from '../../scripts/dsh-source.mjs'
  8. import { checkNativeWrites } from './native-write-checks.mjs'
  9. import { checkReliability } from './reliability-checks.mjs'
  10. import { checkMemoryCatalog } from './memory-catalog-checks.mjs'
  11. import { checkMaterialSupplements } from './material-supplement-checks.mjs'
  12. import { checkMinimalExport } from './export-checks.mjs'
  13. import { checkSearch } from './retrieval-checks.mjs'
  14. const root = path.resolve(process.argv[2])
  15. const bundleUrl = pathToFileURL(path.resolve(process.argv[3])).href
  16. const workspace = path.join(root, 'workspace')
  17. const require = createRequire(import.meta.url)
  18. const host = process.env.WEBNOVEL_DSH_SOURCE === undefined ? undefined : loadSourceHost(process.env.WEBNOVEL_DSH_SOURCE)
  19. const { boot } = await import(host === undefined ? '@deepseek-ai/dsh-app-boot' : pathToFileURL(host.entry('@deepseek-ai/dsh-app-boot')).href)
  20. const expectedVersion = host?.version ?? baseline.registry.version
  21. const report = { node: process.version, checks: {}, versions: {}, baseline: { version: expectedVersion, sourceCommit: host?.commit ?? null, kind: host === undefined ? 'npm' : 'source' } }
  22. const check = async (name, fn) => {
  23. report.running = name
  24. fs.writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2))
  25. try {
  26. await fn()
  27. report.checks[name] = { ok: true }
  28. } catch (error) {
  29. report.checks[name] = { ok: false, error: error.stack ?? String(error) }
  30. }
  31. fs.writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2))
  32. }
  33. const profile = [
  34. { id: 'llm', name: '@deepseek-ai/dsh-llm' },
  35. { id: 'session', name: '@deepseek-ai/dsh-session' },
  36. { id: 'agent', name: '@deepseek-ai/dsh-agent' },
  37. { id: 'system-prompt', name: '@deepseek-ai/dsh-system-prompt' },
  38. { id: 'tools', name: '@deepseek-ai/dsh-tools' },
  39. { id: 'skills', name: '@deepseek-ai/dsh-skill' },
  40. { id: 'session-projection', name: '@deepseek-ai/dsh-session-projection' },
  41. { id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
  42. { id: 'approval', name: '@deepseek-ai/dsh-user-approval', config: { policy: 'ask' } },
  43. { id: 'user-questions', name: '@deepseek-ai/dsh-user-questions' },
  44. { id: 'fs', name: '@deepseek-ai/dsh-fs-local', config: { cwd: workspace } },
  45. { id: 'fs-observation-policy', name: '@deepseek-ai/dsh-fs-observation-policy' },
  46. { id: 'tool-fs', name: '@deepseek-ai/dsh-tool-fs' },
  47. { id: 'storage', name: '@deepseek-ai/dsh-storage' },
  48. { id: 'storage-json', name: '@deepseek-ai/dsh-storage-json', config: { root: path.join(root, 'storage') } },
  49. { id: 'storage-domain', name: '@deepseek-ai/dsh-storage-domain', config: { backend: 'json' } },
  50. { id: 'persistence', name: '@deepseek-ai/dsh-session-persistence-jsonl', config: { root: path.join(root, 'sessions'), compression: 'none' } },
  51. { id: 'workspace', name: '@deepseek-ai/dsh-workspace' },
  52. { id: 'settings', name: '@deepseek-ai/dsh-settings-file', config: { path: path.join(root, 'settings.yaml'), watch: false } },
  53. { id: 'credentials', name: '@deepseek-ai/dsh-credentials-local', config: { path: path.join(root, 'credentials.yaml'), watch: false } },
  54. { id: 'embedding', name: pathToFileURL(path.join(root, 'embedding-provider.mjs')).href },
  55. { id: 'webnovel', name: bundleUrl },
  56. ]
  57. const configPath = path.join(root, 'cordis.yml')
  58. const configuredProfile = host === undefined ? profile : profile.map(row => row.name.startsWith('@deepseek-ai/')
  59. ? { ...row, name: pathToFileURL(host.entry(row.name)).href }
  60. : row)
  61. fs.writeFileSync(configPath, JSON.stringify(configuredProfile, null, 2))
  62. const book = path.join(workspace, '测试书')
  63. fs.mkdirSync(path.join(book, '作品契约'), { recursive: true })
  64. fs.writeFileSync(path.join(book, '作品契约', '契约.md'), '---\n书id: loader-book\n---\n\n# 测试书\n')
  65. const chapter = path.join(book, '定稿', '卷01', '0001-开篇.md')
  66. fs.mkdirSync(path.dirname(chapter), { recursive: true })
  67. fs.writeFileSync(chapter, '---\n身份:\n 卷: 1\n 章: 1\n 章名: 开篇\n角色: 已定稿\n版本: 1\n---\n\n原定稿正文。\n')
  68. fs.writeFileSync(path.join(book, '.gitignore'), '草稿区/\n.webnovel/\n')
  69. const git = (...args) => execFileSync('git', args, { cwd: book, encoding: 'utf8', windowsHide: true })
  70. git('init', '--quiet')
  71. git('config', 'user.name', 'Loader Test')
  72. git('config', 'user.email', 'loader-test@example.invalid')
  73. git('config', 'commit.gpgsign', 'false')
  74. git('config', 'core.autocrlf', 'false')
  75. git('add', '.')
  76. git('commit', '--quiet', '-m', 'ch: fixture')
  77. const ctx = await boot('webnovel-loader-test', configPath)
  78. const service = name => ctx.get(name)
  79. const schemas = agent => service('tools').schemas(agent).map(tool => tool.name).filter(name => name.startsWith('novel_')).sort()
  80. let sequence = 0
  81. const execute = (agent, name, args, signal = new AbortController().signal) => service('tools').execute({
  82. agent, name, arguments: args, signal, callId: `loader-call-${++sequence}`,
  83. })
  84. const events = agent => Array.from({ length: agent.session.seq }, (_, index) => agent.session.eventAt(index))
  85. const entry = id => [...service('loader').entries()].find(item => item.options.id === id)
  86. const presentation = async agent => {
  87. const assembled = await service('systemPrompt').assemble({ agent, scope: agent })
  88. return {
  89. persona: assembled.sections.filter(section => section.name === 'deployment:persona-prefix'),
  90. status: assembled.contexts.filter(context => context.name === 'webnovel.status'),
  91. }
  92. }
  93. const toggle = async (id, disabled) => {
  94. await entry(id).update({ disabled }, false, true)
  95. await service('loader').await()
  96. }
  97. let turn = 0
  98. const withTurn = async (agent, action) => {
  99. const number = ++turn
  100. agent.session.append('turn/start', { turn: number })
  101. try { return await action() }
  102. finally { agent.session.append('turn/end', { turn: number, reason: { kind: 'completed' } }) }
  103. }
  104. const retconArgs = { bookId: 'loader-book', 卷: 1, 章: 1, 章名: '开篇', 更正后正文: '取消后不得出现的正文。', 摘要: 'Loader cancellation probe' }
  105. try {
  106. await check('真实 Loader 与基线版本', async () => {
  107. for (const row of profile.filter(row => row.name.startsWith('@deepseek-ai/dsh-'))) {
  108. const metadata = host === undefined ? require(`${row.name}/package.json`) : host.get(row.name).metadata
  109. report.versions[metadata.name] = metadata.version
  110. assert.equal(metadata.version, expectedVersion)
  111. }
  112. assert.equal(entry('webnovel').options.name, bundleUrl)
  113. for (const name of ['agents', 'agentLoop', 'sessions', 'sessionProjections', 'userQuestions', 'approval', 'workspaceRegistry']) assert.ok(service(name), name)
  114. await service('workspaceRegistry').create(workspace)
  115. })
  116. const main = (await service('agents').create({ sessionId: 'loader-main', meta: { cwd: workspace } })).agent
  117. const child = (await main.ctx.get('agents').create({
  118. parentAgent: main, sessionId: 'loader-child', meta: { cwd: workspace, origin: 'subagent', parentSession: main.id, delegationDepth: 1 },
  119. })).agent
  120. report.identities = { main: main.id, child: child.id, sessionFormat: main.session.header.version, runtimeRoots: service('agents').roots().map(agent => agent.id) }
  121. await check('主 Agent 工具与全局隔离', async () => {
  122. assert.equal(main.session.header.version, host === undefined ? baseline.registry.sessionFormat : baseline.source.sessionFormat)
  123. assert.equal(schemas(main).length, 27)
  124. assert.deepEqual(schemas(undefined), [])
  125. const result = await execute(main, 'novel_select_book', { bookId: 'loader-book' })
  126. assert.equal(result.value.ok, true, JSON.stringify(result))
  127. assert.match(result.value.message, /【当前书】/)
  128. assert.equal(typeof result.value.近况, 'string')
  129. assert.ok(result.value.近况.length > 0)
  130. })
  131. await check('子 Agent 工具拒绝与真实 never 策略', async () => {
  132. assert.deepEqual(schemas(child), [])
  133. const result = await execute(child, 'novel_select_book', { bookId: 'loader-book' })
  134. assert.equal(result.isError, true)
  135. assert.match(JSON.stringify(result), /UNKNOWN_TOOL/)
  136. assert.equal(service('approval').overrideOf(child.session), 'never')
  137. })
  138. await check('真实问答拒绝受委派调用', async () => {
  139. await assert.rejects(service('userQuestions').ask({ agent: child, questions: [{ id: 'test', question: '继续?' }] }), { code: 'DELEGATED_CALLER' })
  140. })
  141. await check('作者裁决与本次调用绑定', async () => {
  142. let seen
  143. const off = ctx.on('user-questions/request', async request => {
  144. seen = request
  145. return { answers: request.questions.map(question => ({ id: question.id, selected: ['退回'] })) }
  146. })
  147. try {
  148. const result = await execute(main, 'novel_settle_chapter', { bookId: 'loader-book', 卷: 1, 章: 1, 章名: '开篇', summary: '测试' })
  149. assert.match(JSON.stringify(result), /未获作者批准/)
  150. assert.equal(seen.agent, main)
  151. assert.ok(seen.signal instanceof AbortSignal)
  152. } finally { off() }
  153. })
  154. await check('随包技能发现、资源、覆盖优先级与卸载重载', async () => {
  155. const skills = () => service('skills').list({ cwd: workspace, scope: main })
  156. const names = (await skills()).map(skill => skill.name).sort()
  157. assert.equal(names.length, 10)
  158. for (const name of names) {
  159. const skill = await service('skills').get(name, { cwd: workspace, scope: main })
  160. assert.equal(skill.provider, 'webnovel-bundled')
  161. assert.ok(skill.content.length > 100)
  162. assert.ok(skill.resourceBase.path.startsWith(path.join(root, 'skills')))
  163. }
  164. const local = path.join(workspace, '.agents', 'skills', names[0])
  165. fs.mkdirSync(local, { recursive: true })
  166. fs.writeFileSync(path.join(local, 'SKILL.md'), `---\nname: ${names[0]}\ndescription: 本地覆盖验收\n---\n本地技能覆盖。\n`)
  167. const filesystem = await import(host === undefined ? '@deepseek-ai/dsh-skill-filesystem' : pathToFileURL(host.entry('@deepseek-ai/dsh-skill-filesystem')).href)
  168. const localProvider = ctx.plugin(filesystem, { providerName: 'acceptance-local' })
  169. await localProvider
  170. assert.equal((await service('skills').get(names[0], { cwd: workspace, scope: main })).provider, 'acceptance-local')
  171. await localProvider.dispose()
  172. await toggle('webnovel', true)
  173. assert.equal((await skills()).length, 0)
  174. await toggle('webnovel', false)
  175. assert.deepEqual((await skills()).map(skill => skill.name).sort(), names)
  176. await toggle('skills', true)
  177. await toggle('skills', false)
  178. assert.deepEqual((await skills()).map(skill => skill.name).sort(), names)
  179. })
  180. await check('Loader 卸载重载等价', async () => {
  181. const before = schemas(main)
  182. const prompt = await presentation(main)
  183. const hostPrompt = await presentation(undefined)
  184. assert.equal(prompt.persona.length, 1)
  185. assert.equal(prompt.status.length, 1)
  186. const row = entry('webnovel')
  187. await row.update({ disabled: true }, false, true)
  188. await service('loader').await()
  189. let absentTools
  190. let absentPrompt
  191. try {
  192. absentTools = schemas(main)
  193. absentPrompt = await presentation(main)
  194. } finally {
  195. await row.update({ disabled: false }, false, true)
  196. await service('loader').await()
  197. }
  198. assert.deepEqual(absentTools, [])
  199. assert.deepEqual(absentPrompt, hostPrompt)
  200. assert.deepEqual(schemas(main), before)
  201. assert.deepEqual(schemas(child), [])
  202. assert.deepEqual(await presentation(main), prompt)
  203. })
  204. await check('运行时归属优先于历史 origin', async () => {
  205. const unmarked = await main.ctx.get('agents').create({ parentAgent: main, sessionId: 'loader-unmarked-child', meta: { cwd: workspace } })
  206. const restoredRoot = await service('agents').create({ sessionId: 'loader-lineage-root', meta: { cwd: workspace, origin: 'subagent', parentSession: main.id } })
  207. try {
  208. assert.ok(!service('agents').roots().includes(unmarked.agent))
  209. assert.ok(service('agents').roots().includes(restoredRoot.agent))
  210. assert.deepEqual(schemas(unmarked.agent), [])
  211. assert.equal(schemas(restoredRoot.agent).length, 27)
  212. assert.equal(service('approval').overrideOf(unmarked.agent.session), 'never')
  213. assert.notEqual(service('approval').overrideOf(restoredRoot.agent.session), 'never')
  214. } finally {
  215. await unmarked.dispose()
  216. await restoredRoot.dispose()
  217. }
  218. })
  219. await check('同一会话恢复后重新装机', async () => {
  220. const previous = await service('agents').create({ sessionId: 'loader-resume', meta: { cwd: workspace } })
  221. assert.equal(schemas(previous.agent).length, 27)
  222. await service('sessionPersistence').flush()
  223. await previous.dispose()
  224. const resumed = await service('agents').resume({ resumeSessionId: 'loader-resume' })
  225. try {
  226. assert.equal(schemas(resumed.agent).length, 27)
  227. assert.match((await presentation(resumed.agent)).status[0].text, /【工作区总览】/)
  228. }
  229. finally { await resumed.dispose() }
  230. })
  231. await check('问答依赖卸载与重载', async () => {
  232. await toggle('user-questions', true)
  233. const whileMissing = schemas(main)
  234. await toggle('user-questions', false)
  235. assert.deepEqual(whileMissing, [])
  236. assert.equal(schemas(main).length, 27)
  237. assert.deepEqual(schemas(child), [])
  238. })
  239. await check('审批依赖晚到仍拒绝子 Agent', async () => {
  240. await toggle('approval', true)
  241. const late = await main.ctx.get('agents').create({ parentAgent: main, sessionId: 'loader-late-child', meta: { cwd: workspace, origin: 'subagent' } })
  242. await toggle('approval', false)
  243. let asked = 0
  244. const off = ctx.on('approval/request', async () => { asked++; return 'allowed-once' })
  245. try {
  246. const outcome = await withTurn(late.agent, () => service('approval').request({ agent: late.agent, toolName: 'write' }))
  247. assert.equal(outcome, 'rejected')
  248. assert.equal(asked, 0)
  249. assert.equal(service('approval').overrideOf(late.agent.session), 'never')
  250. } finally { off(); await late.dispose() }
  251. })
  252. await check('真实文件工具动作授权与审计', async () => {
  253. const destination = path.join(root, 'outside-approved.md')
  254. let asked = 0
  255. const off = ctx.on('approval/request', async request => {
  256. asked++
  257. assert.equal(request.agent, main)
  258. return 'allowed-once'
  259. })
  260. try {
  261. const result = await withTurn(main, () => execute(main, 'write', { file_path: destination, content: 'approved once' }))
  262. assert.equal(result.isError, false, JSON.stringify(result))
  263. assert.equal(fs.readFileSync(destination, 'utf8'), 'approved once')
  264. assert.equal(asked, 1)
  265. const audit = events(main).filter(event => event.type === 'approval/asked' || event.type === 'approval/decided').slice(-2)
  266. assert.equal(audit[0].data.toolName, 'write')
  267. assert.equal(audit[1].data.outcome, 'allowed-once')
  268. assert.equal(audit[0].data.id, audit[1].data.id)
  269. const before = fs.readFileSync(chapter, 'utf8')
  270. const denied = await execute(main, 'write', { file_path: chapter, content: 'forbidden' })
  271. assert.equal(denied.isError, true)
  272. assert.equal(fs.readFileSync(chapter, 'utf8'), before)
  273. } finally { off() }
  274. })
  275. await check('文件动作预取消与等待中取消', async () => {
  276. const destination = path.join(root, 'outside-cancelled.md')
  277. let called = 0
  278. let entered
  279. let answer
  280. const waiting = new Promise(resolve => { entered = resolve })
  281. const off = ctx.on('approval/request', async () => {
  282. called++
  283. entered()
  284. return await new Promise(resolve => { answer = resolve })
  285. })
  286. try {
  287. const cancelled = new AbortController()
  288. cancelled.abort()
  289. await withTurn(main, () => execute(main, 'write', { file_path: destination, content: 'cancelled' }, cancelled.signal))
  290. assert.equal(called, 0)
  291. assert.equal(fs.existsSync(destination), false)
  292. const controller = new AbortController()
  293. const pending = withTurn(main, () => execute(main, 'write', { file_path: destination, content: 'cancelled' }, controller.signal))
  294. await waiting
  295. controller.abort()
  296. answer('allowed-once')
  297. const result = await pending
  298. assert.equal(result.isError, true)
  299. assert.equal(fs.existsSync(destination), false)
  300. assert.equal(events(main).filter(event => event.type === 'approval/decided').at(-1).data.outcome, 'cancelled')
  301. } finally { off() }
  302. })
  303. await check('作者裁决预取消与迟到批准', async () => {
  304. const before = fs.readFileSync(chapter, 'utf8')
  305. const head = git('rev-parse', 'HEAD')
  306. let called = 0
  307. let entered
  308. let answer
  309. const waiting = new Promise(resolve => { entered = resolve })
  310. const off = ctx.on('user-questions/request', async request => {
  311. called++
  312. entered()
  313. await new Promise(resolve => { answer = resolve })
  314. return { answers: request.questions.map(question => ({ id: question.id, selected: ['批准'] })) }
  315. })
  316. try {
  317. const cancelled = new AbortController()
  318. cancelled.abort()
  319. await execute(main, 'novel_apply_retcon', retconArgs, cancelled.signal)
  320. assert.equal(called, 0)
  321. assert.equal(fs.readFileSync(chapter, 'utf8'), before)
  322. const controller = new AbortController()
  323. const pending = execute(main, 'novel_apply_retcon', retconArgs, controller.signal)
  324. await waiting
  325. controller.abort()
  326. answer()
  327. await pending
  328. assert.equal(fs.readFileSync(chapter, 'utf8'), before)
  329. assert.equal(git('rev-parse', 'HEAD'), head)
  330. } finally { off() }
  331. })
  332. await check('工作区注册、别名与非法 cwd', async () => {
  333. const registry = service('workspaceRegistry')
  334. const registered = await registry.resolveByPath(workspace)
  335. assert.ok(registered)
  336. const alias = path.join(root, 'workspace-alias')
  337. fs.symlinkSync(workspace, alias, 'junction')
  338. try {
  339. assert.equal((await registry.resolveByPath(alias)).id, registered.id)
  340. const agent = await service('agents').create({ sessionId: 'loader-alias', meta: { cwd: alias } })
  341. try {
  342. const result = await execute(agent.agent, 'novel_select_book', { bookId: 'loader-book' })
  343. assert.equal(result.value.ok, true, JSON.stringify(result))
  344. } finally { await agent.dispose() }
  345. } finally { fs.unlinkSync(alias) }
  346. await assert.rejects(service('agents').create({ sessionId: 'loader-relative', meta: { cwd: 'relative/path' } }))
  347. const unowned = path.join(root, 'unowned')
  348. fs.mkdirSync(unowned)
  349. assert.equal(await registry.resolveByPath(unowned), undefined)
  350. await assert.rejects(registry.resolveByPath(path.join(root, 'missing')))
  351. })
  352. await check('裁决服务失败不写真源', async () => {
  353. for (const [name, args] of [
  354. ['novel_apply_retcon', retconArgs],
  355. ['novel_settle_chapter', { bookId: 'loader-book', 卷: 1, 章: 1, 章名: '开篇', summary: 'no provider' }],
  356. ]) {
  357. const before = fs.readFileSync(chapter, 'utf8')
  358. const result = await execute(main, name, args)
  359. assert.equal(result.isError, true)
  360. assert.match(JSON.stringify(result), /NO_PROVIDER/)
  361. assert.equal(fs.readFileSync(chapter, 'utf8'), before)
  362. }
  363. })
  364. await check('真实提案与补偿提交', async () => {
  365. const proposal = await execute(main, 'novel_record_proposal', {
  366. bookId: 'loader-book', 域: '吃书补偿', 类型: '事实更正', 内容: '更正开篇事实', 来源: 'Loader 验收', 影响分析: '已定稿命中:开篇;未定稿下游:无。',
  367. })
  368. assert.equal(proposal.value.ok, true)
  369. const decided = await execute(main, 'novel_resolve_proposal', { bookId: 'loader-book', 提案编号: proposal.value.编号, 决定: '通过', 裁决记录: '验收答复器批准本次测试更正' })
  370. assert.equal(decided.value.ok, true)
  371. let authorQuestions = 0
  372. let actionQuestions = 0
  373. const offAction = ctx.on('approval/request', async () => { actionQuestions++; return 'allowed-once' })
  374. const offAuthor = ctx.on('user-questions/request', async request => {
  375. authorQuestions++
  376. return { answers: request.questions.map(question => ({ id: question.id, selected: ['批准'] })) }
  377. })
  378. try {
  379. const result = await withTurn(main, () => execute(main, 'novel_apply_retcon', { ...retconArgs, 提案编号: proposal.value.编号, 更正后正文: '作者批准的更正正文。', 摘要: 'Loader 真实补偿验收' }))
  380. assert.equal(result.value.ok, true, JSON.stringify(result))
  381. assert.equal(authorQuestions, 1)
  382. assert.match(fs.readFileSync(chapter, 'utf8'), /作者批准的更正正文/)
  383. assert.match(git('log', '-1', '--format=%s'), /^retcon:/)
  384. assert.ok(fs.existsSync(path.join(book, result.value.补偿事件)))
  385. report.axes = { retconAuthorQuestions: authorQuestions, retconActionQuestions: actionQuestions }
  386. } finally { offAction(); offAuthor() }
  387. })
  388. await checkNativeWrites({ root, book, main, ctx, service, execute, git, check, report })
  389. await checkReliability({ root, book, main, ctx, service, execute, git, check, report, withTurn })
  390. await checkMemoryCatalog({ host, ctx, workspace, book, service, execute, check, report, withTurn })
  391. await checkMaterialSupplements({ root, workspace, main, execute, check, report, withTurn })
  392. await checkMinimalExport({ root, workspace, check, report })
  393. await checkSearch({ root, workspace, main, child, ctx, service, execute, check, toggle, host })
  394. await check('工作区依赖重启交回旧根', async () => {
  395. const bundle = await import(bundleUrl)
  396. for (const name of ['workspace-next', 'workspace-final']) {
  397. const next = path.join(root, name)
  398. fs.mkdirSync(next)
  399. await service('workspaceRegistry').create(next)
  400. await toggle('workspace', true)
  401. await toggle('workspace', false)
  402. assert.equal(bundle.currentWorkspaceRoot(), fs.realpathSync.native(next))
  403. }
  404. })
  405. await check('原生文件工具对子 Agent 的边界', async () => {
  406. const denied = await execute(child, 'write', { file_path: chapter, content: '不应覆盖定稿' })
  407. assert.equal(denied.isError, true)
  408. const outside = path.join(root, 'child-outside.md')
  409. const rejected = await withTurn(child, () => execute(child, 'write', { file_path: outside, content: '不应获批' }))
  410. assert.equal(rejected.isError, true)
  411. assert.equal(fs.existsSync(outside), false)
  412. const read = await execute(child, 'read', { file_path: chapter })
  413. assert.equal(read.isError, false, JSON.stringify(read))
  414. report.nativeFileBoundary = { sourceWriteDenied: true, childApprovalRejected: true, sourceReadStillVisible: true, sandboxBackend: 'fs-local (no OS sandbox)' }
  415. })
  416. await check('工具服务重启后重新装机', async () => {
  417. await toggle('tools', true)
  418. assert.equal(service('agents').list().length, 0)
  419. await toggle('tools', false)
  420. const fresh = await service('agents').create({ sessionId: 'loader-after-tools', meta: { cwd: workspace } })
  421. const delegated = await fresh.agent.ctx.get('agents').create({ parentAgent: fresh.agent, sessionId: 'loader-after-tools-child', meta: { cwd: workspace, origin: 'subagent' } })
  422. try {
  423. assert.equal(schemas(fresh.agent).length, 27)
  424. assert.deepEqual(schemas(delegated.agent), [])
  425. assert.equal((await presentation(fresh.agent)).status.length, 1)
  426. assert.equal((await execute(fresh.agent, 'novel_select_book', { bookId: 'loader-book' })).value.ok, true)
  427. } finally { await delegated.dispose(); await fresh.dispose() }
  428. })
  429. await check('原生对话恢复后继续', async () => {
  430. const { LlmAdapter, createUserMessage } = await import(host === undefined
  431. ? '@deepseek-ai/dsh-llm' : pathToFileURL(host.entry('@deepseek-ai/dsh-llm')).href)
  432. const textResponse = text => [
  433. { type: 'block-start', index: 0, blockType: 'text' },
  434. { type: 'text-delta', index: 0, text },
  435. { type: 'block-end', index: 0, block: { type: 'text', text } },
  436. { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
  437. { type: 'finish', reason: { kind: 'stop' } },
  438. ]
  439. const callResponse = (id, name, args) => {
  440. const argumentsJson = JSON.stringify(args)
  441. return [
  442. { type: 'block-start', index: 0, blockType: 'tool-call' },
  443. { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: argumentsJson },
  444. { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: argumentsJson } },
  445. { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
  446. { type: 'finish', reason: { kind: 'tool-calls' } },
  447. ]
  448. }
  449. const script = [
  450. callResponse('native-select', 'novel_select_book', { bookId: 'loader-book' }),
  451. callResponse('native-settle', 'novel_settle_chapter', { bookId: 'loader-book', 卷: 1, 章: 1, 章名: '开篇', summary: '原生上下文恢复测试' }),
  452. textResponse('已读取近况;作者退回定稿,等待继续。'),
  453. textResponse('收到继续,沿用前文处理。'),
  454. ]
  455. class ScriptedAdapter extends LlmAdapter {
  456. requests = []
  457. async * stream(options) {
  458. this.requests.push(options)
  459. const chunks = script.shift()
  460. assert.ok(chunks, 'unexpected model request')
  461. for (const chunk of chunks) yield chunk
  462. }
  463. }
  464. const adapter = new ScriptedAdapter()
  465. const options = { provider: 'loader-native', model: 'fixture' }
  466. const completeTurn = async (context, agent, text) => {
  467. await new Promise((resolve, reject) => {
  468. const timer = setTimeout(() => { off(); reject(new Error('native turn did not finish')) }, 10_000)
  469. const off = context.on('agent/status', ({ agent: subject, status }) => {
  470. if (subject !== agent || status !== 'idle') return
  471. clearTimeout(timer)
  472. off()
  473. resolve()
  474. })
  475. agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
  476. })
  477. assert.equal(events(agent).filter(event => event.type === 'turn/end').at(-1).data.reason.kind, 'completed')
  478. }
  479. service('llm').registerAdapter(['loader-native'], adapter)
  480. // Another provider changes its runtime context between model steps. The memory catalog
  481. // must not be repeated just because the host emits a new combined runtime snapshot.
  482. service('systemPrompt').context({ name: 'acceptance.changed-context', order: 999, text: () => `验收状态${adapter.requests.length}` })
  483. let authorQuestions = 0
  484. ctx.on('user-questions/request', async request => {
  485. authorQuestions++
  486. return { answers: request.questions.map(question => ({ id: question.id, selected: ['退回'] })) }
  487. })
  488. const previous = await service('agents').create({ sessionId: 'loader-native-resume', meta: { cwd: workspace }, agentOptions: options })
  489. await completeTurn(ctx, previous.agent, '继续写《测试书》,保留原生上下文标记。')
  490. assert.equal(authorQuestions, 1)
  491. assert.equal(adapter.requests.length, 3)
  492. // 15A:作者记忆目录只在第一次模型输入的快照里出现一次;同会话后续请求沿用历史,不重复注入
  493. const snapshotMessages = request => request.messages.filter(message => message.role === 'user'
  494. && (message.content ?? []).some(part => part.type === 'text' && part.text.includes('【作者记忆目录】会话开始时')))
  495. const catalogHits = adapter.requests.map(request => snapshotMessages(request).length)
  496. assert.deepEqual(catalogHits, [1, 1, 1], JSON.stringify(catalogHits))
  497. assert.equal(snapshotMessages(adapter.requests[0])[0].source?.form, 'snapshot')
  498. assert.equal(snapshotMessages(adapter.requests[0])[0].source?.plugin, 'webnovel-memory-catalog')
  499. assert.match(JSON.stringify(adapter.requests[0].messages), /- 冷开场 — 作者偏好 \{\{冷开场\}\} 直接入戏/)
  500. assert.match(JSON.stringify(adapter.requests[1].messages), /【本书记忆目录】选书时/)
  501. assert.ok(events(previous.agent).some(event => event.type === 'tool/result'))
  502. assert.ok(events(previous.agent).every(event => !event.type.startsWith('novel/')))
  503. await ctx.fiber.dispose()
  504. const restoredHost = await boot('webnovel-native-resume', configPath)
  505. try {
  506. restoredHost.get('llm').registerAdapter(['loader-native'], adapter)
  507. const restored = await restoredHost.get('agents').resume({ resumeSessionId: 'loader-native-resume', agentOptions: options })
  508. await completeTurn(restoredHost, restored.agent, '继续')
  509. assert.equal(adapter.requests.length, 4)
  510. const history = JSON.stringify(adapter.requests.at(-1).messages)
  511. assert.match(history, /保留原生上下文标记/)
  512. assert.match(history, /loader-book/)
  513. assert.match(history, /近况/)
  514. assert.match(history, /未获作者批准/)
  515. assert.match(history, /继续/)
  516. // The plugin recognizes its already delivered ordinary message; changed runtime
  517. // context in the restored Host does not append another author catalog.
  518. assert.equal(snapshotMessages(adapter.requests.at(-1)).length, 1)
  519. assert.equal(adapter.requests.at(-1).messages.filter(message => JSON.stringify(message).includes('【本书记忆目录】选书时')).length, 1)
  520. assert.ok(events(restored.agent).every(event => !event.type.startsWith('novel/')))
  521. report.nativeContinuation = { restored: true, previousUserText: true, bookAndProgress: true, priorToolOutcome: true, customEvents: 0 }
  522. } finally { await restoredHost.fiber.dispose() }
  523. })
  524. } finally {
  525. await ctx.fiber.dispose()
  526. delete report.running
  527. fs.writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2))
  528. console.log(JSON.stringify(report))
  529. }