behavior-check.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. /**
  2. * Standalone behavior check for prompt-journal/index.ts.
  3. * Runs on Node >= 22.18 (type stripping); no build step.
  4. *
  5. * Fakes: Context (logger/get/on/effect), FileSystem (node:fs in a temp dir),
  6. * Agent/Session/UserMessage shapes used by the listener.
  7. */
  8. import assert from 'node:assert/strict'
  9. import { mkdtempSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs'
  10. import { tmpdir } from 'node:os'
  11. import { join } from 'node:path'
  12. const plugin = await import('./index.ts')
  13. // ---- fake fs provider backed by node:fs ----------------------------------
  14. const cwd = mkdtempSync(join(tmpdir(), 'prompt-journal-'))
  15. let resolves = 0
  16. let lastWritePolicy = undefined
  17. const fakePolicy = {
  18. resolve: (req) => ({ mode: 'workspace-write', workspaceRoot: req.session.header.cwd }),
  19. }
  20. const fakeFs = {
  21. resolve: async (path, opts) => {
  22. resolves += 1
  23. assert.ok(opts?.cwd === cwd, 'resolve must receive the session cwd')
  24. assert.ok(opts.signal instanceof AbortSignal)
  25. const abs = join(cwd, path)
  26. return { targetKey: abs, displayPath: abs }
  27. },
  28. stat: async (t) => {
  29. try {
  30. const s = statSync(t.targetKey)
  31. return s.isFile() ? { type: 'file' } : { type: 'directory' }
  32. } catch (error) {
  33. if (error.code === 'ENOENT') return undefined
  34. throw error
  35. }
  36. },
  37. readText: async (t) => readFileSync(t.targetKey, 'utf8'),
  38. writeText: async (t, content, _expected, _signal, policy) => {
  39. lastWritePolicy = policy
  40. writeFileSync(t.targetKey, content)
  41. return { version: 'v1' }
  42. },
  43. }
  44. // ---- fake ctx --------------------------------------------------------------
  45. const listeners = []
  46. const effects = []
  47. const warnings = []
  48. const fakeCtx = {
  49. logger: () => ({ warn: (m) => warnings.push(m) }),
  50. get: (key) => (key === 'fs' ? fakeFs : key === 'sandboxPolicy' ? fakePolicy : undefined),
  51. on: (name, fn, opts) => { listeners.push({ name, fn, opts }) },
  52. effect: (fn) => { effects.push(fn) },
  53. }
  54. plugin.apply(fakeCtx, { file: 'prompts.md', separator: '\n\n---\n\n' })
  55. assert.equal(listeners.length, 1, 'registers exactly one listener')
  56. assert.equal(listeners[0].name, 'agent/pre-step')
  57. assert.deepEqual(listeners[0].opts, { prepend: true }, 'listener is prepended')
  58. const preStep = listeners[0].fn
  59. const signal = new AbortController().signal
  60. const userMsg = (text, source = { kind: 'user' }) => ({
  61. id: 'm1', role: 'user', source,
  62. content: text === undefined ? [] : [{ type: 'text', text }],
  63. })
  64. const agent = (origin, cwdOverride) => ({
  65. session: { header: { origin, cwd: cwdOverride ?? cwd } },
  66. })
  67. let delegated = 0
  68. const next = async () => { delegated += 1; return { kind: 'enter', messages: [] } }
  69. // 1. user prompt on a root agent is journaled before delegation
  70. await preStep(
  71. { agent: agent(undefined), messages: [userMsg('第一条需求')], turn: 1, step: 1, signal },
  72. next,
  73. )
  74. assert.equal(delegated, 1)
  75. assert.equal(
  76. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  77. '第一条需求\n',
  78. 'first entry is plain text + newline',
  79. )
  80. // 2. second entry separated by \n\n---\n\n
  81. await preStep(
  82. { agent: agent(undefined), messages: [userMsg('第二条需求')], turn: 2, step: 1, signal },
  83. next,
  84. )
  85. assert.equal(
  86. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  87. '第一条需求\n\n---\n\n第二条需求\n',
  88. )
  89. // 3. plugin-source and subagent messages are not journaled
  90. await preStep(
  91. {
  92. agent: agent('subagent'),
  93. messages: [userMsg('子任务'), userMsg('也是子任务', { kind: 'plugin', plugin: 'x' })],
  94. turn: 3, step: 1, signal,
  95. },
  96. next,
  97. )
  98. await preStep(
  99. {
  100. agent: agent(undefined),
  101. messages: [userMsg('上下文注入', { kind: 'plugin', plugin: 'time-context' })],
  102. turn: 4, step: 1, signal,
  103. },
  104. next,
  105. )
  106. assert.equal(
  107. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  108. '第一条需求\n\n---\n\n第二条需求\n',
  109. 'subagent/plugin messages are skipped',
  110. )
  111. // 4. whitespace-only text, image-only messages, empty batch are skipped
  112. await preStep(
  113. {
  114. agent: agent(undefined),
  115. messages: [
  116. userMsg(' '),
  117. { id: 'm2', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: {} }] },
  118. ],
  119. turn: 5, step: 1, signal,
  120. },
  121. next,
  122. )
  123. await preStep({ agent: agent(undefined), messages: [], turn: 6, step: 2, signal }, next)
  124. assert.equal(
  125. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  126. '第一条需求\n\n---\n\n第二条需求\n',
  127. 'empty/image-only messages are skipped',
  128. )
  129. // 5. pre-existing file (with CRLF and trailing blanks) is appended, not rewritten
  130. writeFileSync(join(cwd, 'prompts.md'), '历史条目。\r\n\r\n')
  131. await preStep(
  132. { agent: agent(undefined), messages: [userMsg('新条目')], turn: 7, step: 1, signal },
  133. next,
  134. )
  135. assert.equal(
  136. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  137. '历史条目。\n\n---\n\n新条目\n',
  138. 'trailing blanks trimmed, separator inserted',
  139. )
  140. // 6. multi-block text joined with newline; multiple messages -> multiple entries
  141. await preStep(
  142. {
  143. agent: agent(undefined),
  144. messages: [userMsg('多行\r\n第二行'), userMsg('独立需求')],
  145. turn: 8, step: 1, signal,
  146. },
  147. next,
  148. )
  149. assert.equal(
  150. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  151. '历史条目。\n\n---\n\n新条目\n\n---\n\n多行\r\n第二行\n\n---\n\n独立需求\n',
  152. )
  153. // 7. write failure is contained: warn once, step still delegated
  154. const failingFs = { ...fakeFs, writeText: async () => { throw new Error('disk full') } }
  155. let getCalls = 0
  156. fakeCtx.get = (key) => { getCalls += 1; return key === 'fs' ? failingFs : key === 'sandboxPolicy' ? fakePolicy : undefined }
  157. await preStep(
  158. { agent: agent(undefined), messages: [userMsg('会失败')], turn: 9, step: 1, signal },
  159. next,
  160. )
  161. assert.equal(delegated, 9)
  162. assert.equal(warnings.length, 1)
  163. assert.match(warnings[0], /failed to journal a prompt: disk full/)
  164. // 8. providerless deployment: one warning, delegation continues
  165. fakeCtx.get = () => undefined
  166. await preStep(
  167. { agent: agent(undefined), messages: [userMsg('无 fs')], turn: 10, step: 1, signal },
  168. next,
  169. )
  170. await preStep(
  171. { agent: agent(undefined), messages: [userMsg('无 fs 二次')], turn: 11, step: 1, signal },
  172. next,
  173. )
  174. assert.equal(warnings.length, 2)
  175. assert.match(warnings[1], /no fs provider/)
  176. // 9. aborted signal skips journaling but still delegates
  177. fakeCtx.get = (key) => (key === 'fs' ? fakeFs : key === 'sandboxPolicy' ? fakePolicy : undefined)
  178. const aborted = AbortSignal.abort('stop')
  179. await preStep(
  180. { agent: agent(undefined), messages: [userMsg('已取消')], turn: 12, step: 1, signal: aborted },
  181. next,
  182. )
  183. assert.equal(
  184. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  185. '历史条目。\n\n---\n\n新条目\n\n---\n\n多行\r\n第二行\n\n---\n\n独立需求\n',
  186. )
  187. // 9b. file already ending on the separator rule line: only the tail is added
  188. writeFileSync(join(cwd, 'prompts.md'), '历史条目。\n\n---\n\n')
  189. await preStep(
  190. { agent: agent(undefined), messages: [userMsg('接在分隔线后')], turn: 13, step: 1, signal },
  191. next,
  192. )
  193. assert.equal(
  194. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  195. '历史条目。\n\n---\n\n接在分隔线后\n',
  196. 'trailing rule line is not duplicated',
  197. )
  198. // 9c. old-style rule ending (no blank lines) also recognized
  199. writeFileSync(join(cwd, 'prompts.md'), '旧式结尾\n---\n')
  200. await preStep(
  201. { agent: agent(undefined), messages: [userMsg('旧式追加')], turn: 14, step: 1, signal },
  202. next,
  203. )
  204. assert.equal(
  205. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  206. '旧式结尾\n---\n\n旧式追加\n',
  207. 'old-style trailing rule gets only the blank-line half',
  208. )
  209. // 9d. trailing blank lines after the rule are tolerated
  210. writeFileSync(join(cwd, 'prompts.md'), 'A\n\n---\n\n\n\n')
  211. await preStep(
  212. { agent: agent(undefined), messages: [userMsg('B')], turn: 15, step: 1, signal },
  213. next,
  214. )
  215. assert.equal(
  216. readFileSync(join(cwd, 'prompts.md'), 'utf8'),
  217. 'A\n\n---\n\nB\n',
  218. 'blank lines after a trailing rule are trimmed',
  219. )
  220. // 9e. sandbox policy resolves from the session and rides the write
  221. writeFileSync(join(cwd, 'prompts.md'), '')
  222. await preStep(
  223. { agent: agent(undefined), messages: [userMsg('沙箱验证')], turn: 16, step: 1, signal },
  224. next,
  225. )
  226. assert.deepEqual(lastWritePolicy, { mode: 'workspace-write', workspaceRoot: cwd })
  227. // 10. effect disposer clears serialization state
  228. assert.equal(effects.length, 1)
  229. effects[0]()
  230. assert.equal(resolves > 0, true)
  231. // 11. config validation fails loudly
  232. assert.throws(() => plugin.apply(fakeCtx, { file: ' ', separator: '\n\n---\n\n' }), /non-empty/)
  233. assert.throws(() => plugin.apply(fakeCtx, { file: 'a.md', separator: ' ' }), /non-empty/)
  234. rmSync(cwd, { recursive: true, force: true })
  235. console.log('prompt-journal behavior checks: all passed')