|
@@ -0,0 +1,256 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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')
|