runtime-align.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /**
  2. * 真实组合测试(dsh 运行时对齐批,手册 §11/G1 首块)。
  3. *
  4. * 不用手搓假 ctx:真实 cordis 运行时+真实 dsh-tools ToolRuntime+真实
  5. * dsh-system-prompt+真实 dsh-scope(createScope 造 agent scope,agent 对象
  6. * 即 ScopeKey)+真实 bundle 插件加载。宿主侧 userQuestions/agents/
  7. * workspaceRegistry 用极窄 Service stub(真实服务键、真实事件路由)。
  8. *
  9. * 覆盖:主 Agent scoped 可见性(E1)、子 Agent restrict 拒绝执行(E2/
  10. * UNKNOWN_TOOL)、approval 轴 R8 策略、ToolRunContext 透传(agent/signal
  11. * 到本次裁决)、卸载→重载等价性(B4)。
  12. */
  13. import { afterAll, describe, expect, it } from 'vitest'
  14. import { Context, Service } from '@deepseek-ai/cordis'
  15. import { ToolRuntime, type ToolRuntime as ToolRuntimeType } from '@deepseek-ai/dsh-tools'
  16. import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
  17. import { createScope } from '@deepseek-ai/dsh-scope'
  18. import * as fs from 'node:fs'
  19. import * as nodePath from 'node:path'
  20. import * as os from 'node:os'
  21. import { apply, resetWorkspaceRoot } from '../src'
  22. import { NOVEL_TOOL_NAMES } from '../src/novel-tools'
  23. const roots: string[] = []
  24. afterAll(() => {
  25. for (const r of roots) {
  26. try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟 */ }
  27. }
  28. resetWorkspaceRoot()
  29. })
  30. /** 极窄 userQuestions stub:真实服务键,捕获请求、按预置答案回。 */
  31. class UserQuestionsStub extends Service {
  32. static captured: Array<{ questions: ReadonlyArray<{ id: string }>; signal?: AbortSignal; agent?: unknown }> = []
  33. answer: { answers: Array<{ id: string; selected: string[] }> } = { answers: [] }
  34. constructor(ctx: Context) { super(ctx, 'userQuestions') }
  35. ask = async (request: { questions: ReadonlyArray<{ id: string }>; signal?: AbortSignal; agent?: unknown }): Promise<unknown> => {
  36. UserQuestionsStub.captured.push(request)
  37. if (request.signal?.aborted) {
  38. throw Object.assign(new Error('ask aborted'), { code: 'ASK_ABORTED' })
  39. }
  40. return this.answer
  41. }
  42. }
  43. /** 极窄 agents stub:真实服务键,list() 驱动 bundle 的初装路径。 */
  44. class AgentsStub extends Service {
  45. items: unknown[] = []
  46. constructor(ctx: Context) { super(ctx, 'agents') }
  47. list = (): unknown[] => this.items
  48. }
  49. /** 极窄 workspaceRegistry stub:真实服务键,认领测试工作范围。 */
  50. class WorkspaceRegistryStub extends Service {
  51. constructor(ctx: Context, private readonly workspace: string) { super(ctx, 'workspaceRegistry') }
  52. list = (): unknown[] => [{ path: this.workspace, title: '测试工作范围' }]
  53. }
  54. interface TestAgent {
  55. readonly id: string
  56. ctx: Context
  57. readonly session?: {
  58. readonly header?: { readonly origin?: string; readonly cwd?: string }
  59. append(type: string, data: unknown): unknown
  60. }
  61. }
  62. interface SessionEvents {
  63. events: Array<{ type: string; data: unknown }>
  64. }
  65. function mkWorkspace(): string {
  66. const ws = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'webnovel-align-'))
  67. roots.push(ws)
  68. const book = nodePath.join(ws, '测试书')
  69. fs.mkdirSync(nodePath.join(book, '作品契约'), { recursive: true })
  70. fs.writeFileSync(
  71. nodePath.join(book, '作品契约', '契约.md'),
  72. '---\n书id: test-book\n---\n\n# 作品契约\n\n测试书。\n',
  73. 'utf-8',
  74. )
  75. return ws
  76. }
  77. function makeAgent(
  78. root: Context,
  79. id: string,
  80. opts: { parent?: TestAgent; origin?: string; cwd?: string },
  81. ): { agent: TestAgent; events: SessionEvents; scope: Awaited<ReturnType<typeof createScope>> } {
  82. const events: SessionEvents = { events: [] }
  83. const agent: TestAgent = { id, ctx: undefined as unknown as Context }
  84. const scope = createScope(root, agent, opts.parent === undefined ? {} : { parent: opts.parent })
  85. agent.ctx = scope.ctx
  86. agent.session = {
  87. header: { ...(opts.origin === undefined ? {} : { origin: opts.origin }), ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }) },
  88. append: (type: string, data: unknown) => { events.events.push({ type, data }); return undefined },
  89. }
  90. return { agent, events, scope }
  91. }
  92. async function loadBundle(root: Context, ws: string, agents: unknown[]): Promise<{ unload: () => Promise<void>; uq: UserQuestionsStub }> {
  93. await root.plugin(UserQuestionsStub)
  94. await root.plugin(WorkspaceRegistryStubFactory(ws))
  95. await root.plugin(AgentsStub)
  96. const svc = (name: string): unknown => (root as unknown as { get(n: string): unknown }).get(name)
  97. const agentsStub = svc('agents') as AgentsStub
  98. agentsStub.items.push(...agents)
  99. const bundle = await import('../src')
  100. const fiber = await root.plugin({ name: bundle.name, apply: bundle.apply })
  101. return {
  102. uq: svc('userQuestions') as UserQuestionsStub,
  103. unload: async () => { await fiber.dispose() },
  104. }
  105. }
  106. /** WorkspaceRegistryStub 需要每测试不同 workspace,用工厂插件承载。 */
  107. function WorkspaceRegistryStubFactory(workspace: string): new (ctx: Context) => Service {
  108. return class extends WorkspaceRegistryStub {
  109. constructor(ctx: Context) { super(ctx, workspace) }
  110. }
  111. }
  112. async function disposeRoot(root: Context): Promise<void> {
  113. await (root as unknown as { dispose?: () => unknown }).dispose?.()
  114. }
  115. function schemasOf(tools: ToolRuntimeType, agent: TestAgent): string[] {
  116. const schemas = tools.schemas(agent) as ReadonlyArray<{ name?: string }>
  117. return schemas.map((s) => String(s.name ?? ''))
  118. }
  119. async function execTool(tools: ToolRuntimeType, agent: TestAgent, name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
  120. return tools.execute({
  121. callId: `call-${Math.random().toString(36).slice(2)}`,
  122. name,
  123. arguments: args,
  124. agent,
  125. signal: signal ?? new AbortController().signal,
  126. } as never)
  127. }
  128. const approveAnswer = (): { answers: Array<{ id: string; selected: string[] }> } => ({ answers: [{ id: '定稿入档', selected: ['批准'] }] })
  129. describe('dsh 运行时对齐:主 Agent scoped 工具面', () => {
  130. it('主 Agent 可见小说工具全集;未装机且无链路的 scope 不可见,NOVEL_TOOL_NAMES 与注册集一致', async () => {
  131. resetWorkspaceRoot()
  132. const ws = mkWorkspace()
  133. const root = new Context()
  134. await root.plugin(SystemPrompt)
  135. await root.plugin(ToolRuntime)
  136. const main = makeAgent(root, 'main-1', { cwd: ws })
  137. // 不装机的外部 scope:与 main 无父子链、也不在 agents.list 里——
  138. // E1 的泄漏分辨:scoped 层注册不得落到全局层(否则任何 scope 都可见)
  139. const outsider = makeAgent(root, 'outsider-1', {})
  140. await loadBundle(root, ws, [main.agent])
  141. const tools = (root as unknown as { get(n: string): unknown }).get('tools') as ToolRuntimeType
  142. const mainNames = schemasOf(tools, main.agent)
  143. for (const n of NOVEL_TOOL_NAMES) expect(mainNames).toContain(n)
  144. expect(mainNames.filter((n) => n.startsWith('novel_'))).toHaveLength(NOVEL_TOOL_NAMES.length)
  145. expect(schemasOf(tools, outsider.agent).filter((n) => n.startsWith('novel_'))).toEqual([])
  146. await disposeRoot(root)
  147. })
  148. it('子 Agent:继承面被 restrict 收掉;执行小说工具得 UNKNOWN_TOOL isError;主 Agent 不受限', async () => {
  149. resetWorkspaceRoot()
  150. const ws = mkWorkspace()
  151. const root = new Context()
  152. await root.plugin(SystemPrompt)
  153. await root.plugin(ToolRuntime)
  154. const main = makeAgent(root, 'main-2', { cwd: ws })
  155. const sub = makeAgent(root, 'sub-2', { parent: main.agent, origin: 'subagent', cwd: ws })
  156. await loadBundle(root, ws, [main.agent, sub.agent])
  157. const tools = (root as unknown as { get(n: string): unknown }).get('tools') as ToolRuntimeType
  158. const subNames = schemasOf(tools, sub.agent)
  159. expect(subNames.filter((n) => NOVEL_TOOL_NAMES.includes(n))).toEqual([])
  160. // 子 Agent 执行 → 物化 isError,UNKNOWN_TOOL(不 throw)
  161. const rejected = await execTool(tools, sub.agent, 'novel_select_book', { bookId: 'test-book' })
  162. const text = JSON.stringify(rejected)
  163. expect(text).toContain('UNKNOWN_TOOL')
  164. expect((rejected as { isError?: boolean }).isError).toBe(true)
  165. // 主 Agent 同名调用走真实执行(非 UNKNOWN_TOOL)
  166. const mainResult = await execTool(tools, main.agent, 'novel_select_book', { bookId: 'test-book' })
  167. const mainText = JSON.stringify(mainResult)
  168. expect(mainText).not.toContain('UNKNOWN_TOOL')
  169. expect(mainText).toContain('test-book')
  170. // restrict 后主 Agent 面不缩
  171. expect(schemasOf(tools, main.agent).filter((n) => NOVEL_TOOL_NAMES.includes(n))).toHaveLength(NOVEL_TOOL_NAMES.length)
  172. await disposeRoot(root)
  173. })
  174. })
  175. describe('dsh 运行时对齐:ToolRunContext 透传与作者裁决', () => {
  176. it('agent/signal 传入本次裁决,不附加会话记录', async () => {
  177. resetWorkspaceRoot()
  178. UserQuestionsStub.captured = []
  179. const ws = mkWorkspace()
  180. const root = new Context()
  181. await root.plugin(SystemPrompt)
  182. await root.plugin(ToolRuntime)
  183. const main = makeAgent(root, 'main-3', { cwd: ws })
  184. const { unload, uq } = await loadBundle(root, ws, [main.agent])
  185. uq.answer = approveAnswer()
  186. const tools = (root as unknown as { get(n: string): unknown }).get('tools') as ToolRuntimeType
  187. const controller = new AbortController()
  188. // 定稿入档:裁决发生在一切落盘检查之前,最小书即可走到 askAuthor
  189. const result = await execTool(tools, main.agent, 'novel_settle_chapter', {
  190. bookId: 'test-book', 卷: 1, 章: 1, 章名: '第一章', summary: '测试',
  191. }, controller.signal)
  192. // 裁决请求捕获:agent 与 signal 全量透传
  193. expect(UserQuestionsStub.captured.length).toBeGreaterThanOrEqual(1)
  194. const captured = UserQuestionsStub.captured[0]!
  195. expect(captured.signal).toBe(controller.signal)
  196. expect((captured.agent as { id?: string } | undefined)?.id).toBe('main-3')
  197. expect(main.events!.events).toEqual([])
  198. void result
  199. await unload()
  200. await disposeRoot(root)
  201. })
  202. it('作者退回仍拒绝入档,不附加会话记录', async () => {
  203. resetWorkspaceRoot()
  204. UserQuestionsStub.captured = []
  205. const ws = mkWorkspace()
  206. const root = new Context()
  207. await root.plugin(SystemPrompt)
  208. await root.plugin(ToolRuntime)
  209. const main = makeAgent(root, 'main-4', { cwd: ws })
  210. const { unload, uq } = await loadBundle(root, ws, [main.agent])
  211. // 作者退回:有效答案但决定=已退回
  212. uq.answer = { answers: [{ id: '定稿入档', selected: ['退回'] }] }
  213. const tools = (root as unknown as { get(n: string): unknown }).get('tools') as ToolRuntimeType
  214. const rejected = await execTool(tools, main.agent, 'novel_settle_chapter', {
  215. bookId: 'test-book', 卷: 1, 章: 1, 章名: '第一章', summary: '测试',
  216. })
  217. expect(main.events!.events).toEqual([])
  218. expect(JSON.stringify(rejected)).toContain('未获作者批准')
  219. await unload()
  220. await disposeRoot(root)
  221. })
  222. })
  223. describe('dsh 运行时对齐:子 Agent 审批策略与卸载重载等价性', () => {
  224. it('卸载→工具面撤销;重载→新装等价;子 Agent 重载后仍被封', async () => {
  225. resetWorkspaceRoot()
  226. const ws = mkWorkspace()
  227. const root = new Context()
  228. await root.plugin(SystemPrompt)
  229. await root.plugin(ToolRuntime)
  230. const main = makeAgent(root, 'main-5', { cwd: ws })
  231. const sub = makeAgent(root, 'sub-5', { parent: main.agent, origin: 'subagent', cwd: ws })
  232. const { unload } = await loadBundle(root, ws, [main.agent, sub.agent])
  233. const tools = (root as unknown as { get(n: string): unknown }).get('tools') as ToolRuntimeType
  234. const before = schemasOf(tools, main.agent).filter((n) => NOVEL_TOOL_NAMES.includes(n))
  235. expect(before).toHaveLength(NOVEL_TOOL_NAMES.length)
  236. // 卸载:插件 effect 逆操作撤销 scoped 注册
  237. await unload()
  238. expect(schemasOf(tools, main.agent).filter((n) => NOVEL_TOOL_NAMES.includes(n))).toEqual([])
  239. // 重载:stub 服务仍在 root 上,只重装 bundle 本体;agents.list 初装路径对既有主 Agent 重装
  240. const bundle = await import('../src')
  241. await root.plugin({ name: bundle.name, apply: bundle.apply })
  242. const after = schemasOf(tools, main.agent).filter((n) => NOVEL_TOOL_NAMES.includes(n))
  243. expect(after).toHaveLength(NOVEL_TOOL_NAMES.length)
  244. expect([...after].sort()).toEqual([...before].sort())
  245. // 重载后子 Agent 仍被封(初装路径对子 Agent 重挂 restrict)
  246. expect(schemasOf(tools, sub.agent).filter((n) => NOVEL_TOOL_NAMES.includes(n))).toEqual([])
  247. const subRejected = await execTool(tools, sub.agent, 'novel_select_book', { bookId: 'test-book' })
  248. expect(JSON.stringify(subRejected)).toContain('UNKNOWN_TOOL')
  249. await disposeRoot(root)
  250. })
  251. })