arbitration.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. IRREVERSIBLE_KINDS,
  4. APPROVE_LABEL,
  5. applyArbitrationField,
  6. askAuthor,
  7. buildAskRequest,
  8. interpretAnswer,
  9. parseArbitration,
  10. serializeArbitration,
  11. type AskRequest,
  12. } from '../src/arbitration'
  13. import { parseDocument, serializeDocument } from '../src/repo/frontmatter'
  14. import type {
  15. AskUserQuestionAnswer as DshAskUserQuestionAnswer,
  16. AskUserQuestionItem as DshAskUserQuestionItem,
  17. } from '@deepseek-ai/dsh-user-questions/types'
  18. describe('不可逆清单(插件规格 §6)', () => {
  19. it('五种不可逆动作都在清单内且能生成提问', () => {
  20. expect([...IRREVERSIBLE_KINDS]).toEqual([
  21. '定稿入档', '世界书条目转正', '记忆入记', '吃书补偿', '规则来源声明',
  22. ])
  23. for (const kind of IRREVERSIBLE_KINDS) {
  24. const req = buildAskRequest(kind, { 范围: '卷01/初见', 摘要: '测试' })
  25. expect(req.questions).toHaveLength(1)
  26. expect(req.questions[0]?.id).toBe(kind)
  27. expect(req.questions[0]?.options?.map((o) => o.label)).toEqual(['批准', '退回'])
  28. }
  29. })
  30. })
  31. describe('答案解释', () => {
  32. it('批准/退回', () => {
  33. expect(interpretAnswer('定稿入档', { id: '定稿入档', selected: ['批准'] }))
  34. .toEqual({ ok: true, 决定: '已批准' })
  35. expect(interpretAnswer('定稿入档', { id: '定稿入档', selected: ['退回'] }))
  36. .toEqual({ ok: true, 决定: '已退回' })
  37. expect(interpretAnswer('规则来源声明', { id: '规则来源声明', selected: ['批准'] }))
  38. .toEqual({ ok: true, 决定: '已确认' })
  39. })
  40. it('缺答案或非选项→拒绝', () => {
  41. expect(interpretAnswer('定稿入档', undefined).ok).toBe(false)
  42. expect(interpretAnswer('定稿入档', { id: '定稿入档', selected: [] }).ok).toBe(false)
  43. expect(interpretAnswer('定稿入档', { id: '定稿入档', selected: ['随便'] }).ok).toBe(false)
  44. })
  45. it('Other 自由文本不算批准(dsh custom 字段,fail-closed)', () => {
  46. // dsh AskUserQuestionAnswerItem 带 custom?:string(自由文本 Other)。不可逆动作
  47. // 只认具名批准选项:走自由文本时 selected 为空或非选项,一律拒绝,不得放行。
  48. expect(interpretAnswer('定稿入档', { id: '定稿入档', selected: [], custom: '批准' }).ok).toBe(false)
  49. expect(interpretAnswer('定稿入档', { id: '定稿入档', selected: ['批准', '退回'] }).ok).toBe(false)
  50. expect(interpretAnswer('定稿入档', { id: '世界书条目转正', selected: ['批准'] }).ok).toBe(false)
  51. })
  52. it('askAuthor 注入假 askFn', async () => {
  53. const approved = await askAuthor(
  54. async () => ({ answers: [{ id: '记忆入记', selected: ['批准'] }] }),
  55. '记忆入记',
  56. { 范围: '本书记忆/文风' },
  57. )
  58. expect(approved).toMatchObject({ ok: true, 决定: '已批准' })
  59. const missing = await askAuthor(
  60. async () => ({ answers: [] }),
  61. '吃书补偿',
  62. { 范围: '卷01' },
  63. )
  64. expect(missing.ok).toBe(false)
  65. })
  66. it('预取消不发起作者问答', async () => {
  67. const controller = new AbortController()
  68. controller.abort()
  69. let calls = 0
  70. const result = await askAuthor(async () => {
  71. calls++
  72. return { answers: [{ id: '吃书补偿', selected: ['批准'] }] }
  73. }, '吃书补偿', { 范围: '卷01' }, { signal: controller.signal })
  74. expect(calls).toBe(0)
  75. expect(result).toMatchObject({ ok: false, reason: expect.stringContaining('ASK_ABORTED') })
  76. })
  77. it('等待中取消立即结束,迟到批准不改变结果', async () => {
  78. const controller = new AbortController()
  79. let answer!: (value: { answers: Array<{ id: string; selected: string[] }> }) => void
  80. const pending = askAuthor(() => new Promise(resolve => { answer = resolve }), '吃书补偿', { 范围: '卷01' }, { signal: controller.signal })
  81. controller.abort()
  82. const result = await pending
  83. expect(result).toMatchObject({ ok: false, reason: expect.stringContaining('ASK_ABORTED') })
  84. answer({ answers: [{ id: '吃书补偿', selected: ['批准'] }] })
  85. await Promise.resolve()
  86. expect(result.ok).toBe(false)
  87. })
  88. })
  89. describe('作者裁决 frontmatter 往返', () => {
  90. it('serializeDocument/parseDocument 还原记录', () => {
  91. const rec = {
  92. 谁: '作者',
  93. 何时: '2026-08-23T00:00:00.000Z',
  94. 版本: 3,
  95. 范围: '卷01/初见',
  96. 种类: '定稿入档' as const,
  97. 决定: '已批准' as const,
  98. }
  99. const text = serializeDocument(applyArbitrationField({ 状态: '已批准待入档' }, rec), '正文')
  100. const parsed = parseDocument(text)
  101. expect(parsed.ok).toBe(true)
  102. if (!parsed.ok) return
  103. expect(parseArbitration(parsed.data.fields['作者裁决'])).toEqual(rec)
  104. expect(parseArbitration(serializeArbitration(rec))).toEqual(rec)
  105. })
  106. })
  107. describe('裁决 14:请求形状对齐 dsh userQuestions 真源类型', () => {
  108. it('buildAskRequest 产出可直接赋给 dsh AskUserQuestionItem[]', () => {
  109. // 编译期即校验:类型不兼容则 tsc 报错(测试仅确认运行期字段齐备)
  110. const req = buildAskRequest('定稿入档', { 范围: '卷01/初见', 摘要: 'x' })
  111. const items: DshAskUserQuestionItem[] = req.questions.map((q) => ({ ...q, options: q.options?.map((o) => ({ ...o })) }))
  112. expect(items[0]?.id).toBe('定稿入档')
  113. expect(items[0]?.multiSelect).toBe(false)
  114. })
  115. it('dsh 答案形状可直接喂给 interpretAnswer', () => {
  116. const answer: DshAskUserQuestionAnswer = { answers: [{ id: '定稿入档', selected: ['批准'] }] }
  117. expect(interpretAnswer('定稿入档', answer.answers[0])).toEqual({ ok: true, 决定: '已批准' })
  118. })
  119. it('intent 具名批准选项:不靠选项顺序推断裁决(裁决 11 审批≠裁决点)', () => {
  120. const req = buildAskRequest('定稿入档', { 范围: '卷01/初见', 摘要: 'x' })
  121. const q = req.questions[0]!
  122. expect(q.intent).toEqual({ kind: 'plan-review', approve: APPROVE_LABEL })
  123. // dsh 侧硬约束:approve 必须命名本题某个选项,否则 ask() 拒绝
  124. expect(q.options?.map((o) => o.label)).toContain(q.intent!.approve)
  125. })
  126. it('每种不可逆动作的 intent.approve 都在自身选项内', () => {
  127. for (const kind of IRREVERSIBLE_KINDS) {
  128. const q = buildAskRequest(kind, { 范围: 'x' }).questions[0]!
  129. expect(q.options?.map((o) => o.label), `${kind}: approve 应命名本题选项`).toContain(q.intent!.approve)
  130. }
  131. })
  132. })
  133. describe('裁决 14:askAuthor 对齐 dsh ctx.userQuestions.ask() 真签名', () => {
  134. it('askFn 收到的请求可直接赋给 dsh AskUserQuestionRequest', async () => {
  135. let seen: AskRequest | undefined
  136. const r = await askAuthor(
  137. async (req) => { seen = req; return { answers: [{ id: '定稿入档', selected: ['批准'] }] } },
  138. '定稿入档',
  139. { 范围: '卷01/初见' },
  140. )
  141. expect(r).toEqual({ ok: true, 决定: '已批准', kind: '定稿入档' })
  142. expect(seen!.questions[0]?.id).toBe('定稿入档')
  143. })
  144. it('透传 agent 与 signal:dsh 靠 agent 判活体,缺则跳过校验', async () => {
  145. let seen: AskRequest | undefined
  146. const agent = { id: 'agent-1' } as never
  147. const ac = new AbortController()
  148. await askAuthor(
  149. async (req) => { seen = req; return { answers: [{ id: '记忆入记', selected: ['批准'] }] } },
  150. '记忆入记',
  151. { 范围: 'x' },
  152. { agent, signal: ac.signal },
  153. )
  154. expect(seen!.agent).toBe(agent)
  155. expect(seen!.signal).toBe(ac.signal)
  156. })
  157. it('askFn 抛错(如 CALLER_NOT_LIVE)不吞:上抛给调用方', async () => {
  158. await expect(askAuthor(
  159. async () => { throw new Error('CALLER_NOT_LIVE') },
  160. '定稿入档',
  161. { 范围: 'x' },
  162. )).rejects.toThrow('CALLER_NOT_LIVE')
  163. })
  164. // dsh ask() 一手约束(user-questions/src/index.ts:130):带 intent 的问题必须有
  165. // detail,否则抛 BAD_INTENT。作者要批准不可逆动作,本就必须看见批准的是什么。
  166. it('buildAskRequest: 范围/摘要皆空时 detail 仍非空(否则真机 ask() 抛 BAD_INTENT)', () => {
  167. const q = buildAskRequest('定稿入档', {}).questions[0]!
  168. expect(q.intent).toBeDefined()
  169. expect(q.detail).toBeTypeOf('string')
  170. expect((q.detail as string).trim().length).toBeGreaterThan(0)
  171. })
  172. it('buildAskRequest: 五种不可逆动作的 detail 一律非空', () => {
  173. for (const kind of ['定稿入档', '世界书条目转正', '记忆入记', '吃书补偿', '规则来源声明'] as const) {
  174. const q = buildAskRequest(kind, {}).questions[0]!
  175. expect((q.detail ?? '').trim().length, `${kind}: detail 应非空`).toBeGreaterThan(0)
  176. }
  177. })
  178. })