| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- /**
- * Standalone behavior check for prompt-journal/index.ts.
- * Runs on Node >= 22.18 (type stripping); no build step.
- *
- * Fakes: Context (logger/get/on/effect), FileSystem (node:fs in a temp dir),
- * Agent/Session/UserMessage shapes used by the listener.
- */
- import assert from 'node:assert/strict'
- import { mkdtempSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- const plugin = await import('./index.ts')
- // ---- fake fs provider backed by node:fs ----------------------------------
- const cwd = mkdtempSync(join(tmpdir(), 'prompt-journal-'))
- let resolves = 0
- let lastWritePolicy = undefined
- const fakePolicy = {
- resolve: (req) => ({ mode: 'workspace-write', workspaceRoot: req.session.header.cwd }),
- }
- const fakeFs = {
- resolve: async (path, opts) => {
- resolves += 1
- assert.ok(opts?.cwd === cwd, 'resolve must receive the session cwd')
- assert.ok(opts.signal instanceof AbortSignal)
- const abs = join(cwd, path)
- return { targetKey: abs, displayPath: abs }
- },
- stat: async (t) => {
- try {
- const s = statSync(t.targetKey)
- return s.isFile() ? { type: 'file' } : { type: 'directory' }
- } catch (error) {
- if (error.code === 'ENOENT') return undefined
- throw error
- }
- },
- readText: async (t) => readFileSync(t.targetKey, 'utf8'),
- writeText: async (t, content, _expected, _signal, policy) => {
- lastWritePolicy = policy
- writeFileSync(t.targetKey, content)
- return { version: 'v1' }
- },
- }
- // ---- fake ctx --------------------------------------------------------------
- const listeners = []
- const effects = []
- const warnings = []
- const fakeCtx = {
- logger: () => ({ warn: (m) => warnings.push(m) }),
- get: (key) => (key === 'fs' ? fakeFs : key === 'sandboxPolicy' ? fakePolicy : undefined),
- on: (name, fn, opts) => { listeners.push({ name, fn, opts }) },
- effect: (fn) => { effects.push(fn) },
- }
- plugin.apply(fakeCtx, { file: 'prompts.md', separator: '\n\n---\n\n' })
- assert.equal(listeners.length, 1, 'registers exactly one listener')
- assert.equal(listeners[0].name, 'agent/pre-step')
- assert.deepEqual(listeners[0].opts, { prepend: true }, 'listener is prepended')
- const preStep = listeners[0].fn
- const signal = new AbortController().signal
- const userMsg = (text, source = { kind: 'user' }) => ({
- id: 'm1', role: 'user', source,
- content: text === undefined ? [] : [{ type: 'text', text }],
- })
- const agent = (origin, cwdOverride) => ({
- session: { header: { origin, cwd: cwdOverride ?? cwd } },
- })
- let delegated = 0
- const next = async () => { delegated += 1; return { kind: 'enter', messages: [] } }
- // 1. user prompt on a root agent is journaled before delegation
- await preStep(
- { agent: agent(undefined), messages: [userMsg('第一条需求')], turn: 1, step: 1, signal },
- next,
- )
- assert.equal(delegated, 1)
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '第一条需求\n',
- 'first entry is plain text + newline',
- )
- // 2. second entry separated by \n\n---\n\n
- await preStep(
- { agent: agent(undefined), messages: [userMsg('第二条需求')], turn: 2, step: 1, signal },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '第一条需求\n\n---\n\n第二条需求\n',
- )
- // 3. plugin-source and subagent messages are not journaled
- await preStep(
- {
- agent: agent('subagent'),
- messages: [userMsg('子任务'), userMsg('也是子任务', { kind: 'plugin', plugin: 'x' })],
- turn: 3, step: 1, signal,
- },
- next,
- )
- await preStep(
- {
- agent: agent(undefined),
- messages: [userMsg('上下文注入', { kind: 'plugin', plugin: 'time-context' })],
- turn: 4, step: 1, signal,
- },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '第一条需求\n\n---\n\n第二条需求\n',
- 'subagent/plugin messages are skipped',
- )
- // 4. whitespace-only text, image-only messages, empty batch are skipped
- await preStep(
- {
- agent: agent(undefined),
- messages: [
- userMsg(' '),
- { id: 'm2', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: {} }] },
- ],
- turn: 5, step: 1, signal,
- },
- next,
- )
- await preStep({ agent: agent(undefined), messages: [], turn: 6, step: 2, signal }, next)
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '第一条需求\n\n---\n\n第二条需求\n',
- 'empty/image-only messages are skipped',
- )
- // 5. pre-existing file (with CRLF and trailing blanks) is appended, not rewritten
- writeFileSync(join(cwd, 'prompts.md'), '历史条目。\r\n\r\n')
- await preStep(
- { agent: agent(undefined), messages: [userMsg('新条目')], turn: 7, step: 1, signal },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '历史条目。\n\n---\n\n新条目\n',
- 'trailing blanks trimmed, separator inserted',
- )
- // 6. multi-block text joined with newline; multiple messages -> multiple entries
- await preStep(
- {
- agent: agent(undefined),
- messages: [userMsg('多行\r\n第二行'), userMsg('独立需求')],
- turn: 8, step: 1, signal,
- },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '历史条目。\n\n---\n\n新条目\n\n---\n\n多行\r\n第二行\n\n---\n\n独立需求\n',
- )
- // 7. write failure is contained: warn once, step still delegated
- const failingFs = { ...fakeFs, writeText: async () => { throw new Error('disk full') } }
- let getCalls = 0
- fakeCtx.get = (key) => { getCalls += 1; return key === 'fs' ? failingFs : key === 'sandboxPolicy' ? fakePolicy : undefined }
- await preStep(
- { agent: agent(undefined), messages: [userMsg('会失败')], turn: 9, step: 1, signal },
- next,
- )
- assert.equal(delegated, 9)
- assert.equal(warnings.length, 1)
- assert.match(warnings[0], /failed to journal a prompt: disk full/)
- // 8. providerless deployment: one warning, delegation continues
- fakeCtx.get = () => undefined
- await preStep(
- { agent: agent(undefined), messages: [userMsg('无 fs')], turn: 10, step: 1, signal },
- next,
- )
- await preStep(
- { agent: agent(undefined), messages: [userMsg('无 fs 二次')], turn: 11, step: 1, signal },
- next,
- )
- assert.equal(warnings.length, 2)
- assert.match(warnings[1], /no fs provider/)
- // 9. aborted signal skips journaling but still delegates
- fakeCtx.get = (key) => (key === 'fs' ? fakeFs : key === 'sandboxPolicy' ? fakePolicy : undefined)
- const aborted = AbortSignal.abort('stop')
- await preStep(
- { agent: agent(undefined), messages: [userMsg('已取消')], turn: 12, step: 1, signal: aborted },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '历史条目。\n\n---\n\n新条目\n\n---\n\n多行\r\n第二行\n\n---\n\n独立需求\n',
- )
- // 9b. file already ending on the separator rule line: only the tail is added
- writeFileSync(join(cwd, 'prompts.md'), '历史条目。\n\n---\n\n')
- await preStep(
- { agent: agent(undefined), messages: [userMsg('接在分隔线后')], turn: 13, step: 1, signal },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '历史条目。\n\n---\n\n接在分隔线后\n',
- 'trailing rule line is not duplicated',
- )
- // 9c. old-style rule ending (no blank lines) also recognized
- writeFileSync(join(cwd, 'prompts.md'), '旧式结尾\n---\n')
- await preStep(
- { agent: agent(undefined), messages: [userMsg('旧式追加')], turn: 14, step: 1, signal },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- '旧式结尾\n---\n\n旧式追加\n',
- 'old-style trailing rule gets only the blank-line half',
- )
- // 9d. trailing blank lines after the rule are tolerated
- writeFileSync(join(cwd, 'prompts.md'), 'A\n\n---\n\n\n\n')
- await preStep(
- { agent: agent(undefined), messages: [userMsg('B')], turn: 15, step: 1, signal },
- next,
- )
- assert.equal(
- readFileSync(join(cwd, 'prompts.md'), 'utf8'),
- 'A\n\n---\n\nB\n',
- 'blank lines after a trailing rule are trimmed',
- )
- // 9e. sandbox policy resolves from the session and rides the write
- writeFileSync(join(cwd, 'prompts.md'), '')
- await preStep(
- { agent: agent(undefined), messages: [userMsg('沙箱验证')], turn: 16, step: 1, signal },
- next,
- )
- assert.deepEqual(lastWritePolicy, { mode: 'workspace-write', workspaceRoot: cwd })
- // 10. effect disposer clears serialization state
- assert.equal(effects.length, 1)
- effects[0]()
- assert.equal(resolves > 0, true)
- // 11. config validation fails loudly
- assert.throws(() => plugin.apply(fakeCtx, { file: ' ', separator: '\n\n---\n\n' }), /non-empty/)
- assert.throws(() => plugin.apply(fakeCtx, { file: 'a.md', separator: ' ' }), /non-empty/)
- rmSync(cwd, { recursive: true, force: true })
- console.log('prompt-journal behavior checks: all passed')
|