/** * 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')