singularity-forge/src/tests/artifact-manager.test.ts
TÂCHES 789a6645da feat: TTSR + blob/artifact storage (ported from oh-my-pi)
* docs(M002): context, requirements, and roadmap

* feat: port TTSR and blob/artifact storage from oh-my-pi

Phase 1 — TTSR (Time Traveling Stream Rules):
- TtsrManager: regex-based stream monitoring with scope filtering,
  repeat gating, and buffer isolation (picomatch replaces Bun.Glob)
- Rule loader: scans ~/.gsd/agent/rules/*.md and .gsd/rules/*.md
  with YAML frontmatter parsing; project rules override global
- TTSR extension: wires into pi event lifecycle (session_start,
  turn_start, message_update, turn_end, agent_end) to abort on
  match and inject violation as system reminder via sendMessage
- Interrupt template for rule violation injection

Phase 2 — Blob/Artifact Storage:
- BlobStore: content-addressed storage at ~/.gsd/agent/blobs/ using
  Node crypto (sha256), sync I/O, automatic deduplication
- ArtifactManager: session-scoped sequential artifact files stored
  alongside session JSONL (lazy dir creation, resume-safe ID scan)
- Session manager integration: prepareForPersistence externalizes
  images ≥1KB to blob store before JSONL write; resolveBlobRefs
  rehydrates on session load; truncates strings >500KB
- Bash tool artifact spill: uses ArtifactManager instead of temp
  files when available, includes artifact:// references in output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden blob store, TTSR manager, and dep classification

- Validate SHA-256 hex format in BlobStore.get/has/parseBlobRef to
  prevent path traversal via crafted blob references
- Cap TTSR per-stream buffers at 512KB to prevent unbounded memory growth
- Move picomatch from devDependencies to dependencies (runtime import)
- Warn on invalid regex in TTSR rule conditions instead of silent skip
- Remove .gsd/ planning files that were force-added past .gitignore
- Add trailing newline to ttsr-interrupt.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add tests for blob store, artifact manager, TTSR manager, and rule loader

55 tests covering:
- BlobStore put/get/has, idempotency, path traversal rejection
- parseBlobRef/isBlobRef validation, externalize/resolve round-trips
- ArtifactManager sequential IDs, lazy dir creation, session resume
- TtsrManager rule matching, scope filtering, buffer isolation,
  repeat gating, buffer size cap, injection persistence
- Rule loader frontmatter parsing, directory scanning, merge logic

Also fixes BlobStore constructor to avoid TS parameter property syntax
(incompatible with Node's strip-only TypeScript mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:43:56 -06:00

166 lines
6.4 KiB
TypeScript

/**
* Tests for ArtifactManager: sequential ID allocation, save/retrieve,
* and session resume (ID continuity).
*/
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { ArtifactManager } from '../../packages/pi-coding-agent/src/core/artifact-manager.ts'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeTmpSession(): { sessionFile: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), 'artifact-test-'))
const sessionFile = join(dir, 'session.jsonl')
return { sessionFile, cleanup: () => rmSync(dir, { recursive: true, force: true }) }
}
// ═══════════════════════════════════════════════════════════════════════════
// save / getPath
// ═══════════════════════════════════════════════════════════════════════════
test('save creates artifact file with sequential ID', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
const id0 = mgr.save('output 0', 'bash')
const id1 = mgr.save('output 1', 'bash')
assert.equal(id0, '0')
assert.equal(id1, '1')
const path0 = mgr.getPath('0')
assert.ok(path0)
assert.equal(readFileSync(path0, 'utf-8'), 'output 0')
const path1 = mgr.getPath('1')
assert.ok(path1)
assert.equal(readFileSync(path1, 'utf-8'), 'output 1')
} finally {
cleanup()
}
})
test('artifact directory is named after session file without .jsonl', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
const expectedDir = sessionFile.slice(0, -6) // strip .jsonl
assert.equal(mgr.dir, expectedDir)
} finally {
cleanup()
}
})
test('artifact directory is created lazily on first write', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
const artifactDir = mgr.dir
assert.equal(existsSync(artifactDir), false)
mgr.save('trigger creation', 'bash')
assert.ok(existsSync(artifactDir))
} finally {
cleanup()
}
})
// ═══════════════════════════════════════════════════════════════════════════
// exists
// ═══════════════════════════════════════════════════════════════════════════
test('exists returns true for saved artifact', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
const id = mgr.save('content', 'bash')
assert.ok(mgr.exists(id))
} finally {
cleanup()
}
})
test('exists returns false for missing artifact', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
assert.equal(mgr.exists('999'), false)
} finally {
cleanup()
}
})
// ═══════════════════════════════════════════════════════════════════════════
// allocatePath
// ═══════════════════════════════════════════════════════════════════════════
test('allocatePath returns path without writing', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
const { id, path } = mgr.allocatePath('fetch')
assert.equal(id, '0')
assert.ok(path.endsWith('0.fetch.log'))
// File should not exist yet — allocatePath doesn't write
assert.equal(existsSync(path), false)
} finally {
cleanup()
}
})
// ═══════════════════════════════════════════════════════════════════════════
// Session resume — ID continuity
// ═══════════════════════════════════════════════════════════════════════════
test('new manager picks up where previous left off', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr1 = new ArtifactManager(sessionFile)
mgr1.save('first', 'bash')
mgr1.save('second', 'bash')
// Simulate session resume — new manager for same session file
const mgr2 = new ArtifactManager(sessionFile)
const id = mgr2.save('third', 'bash')
assert.equal(id, '2') // continues from 0, 1 → next is 2
} finally {
cleanup()
}
})
// ═══════════════════════════════════════════════════════════════════════════
// listFiles
// ═══════════════════════════════════════════════════════════════════════════
test('listFiles returns all artifact filenames', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
mgr.save('a', 'bash')
mgr.save('b', 'fetch')
const files = mgr.listFiles()
assert.equal(files.length, 2)
assert.ok(files.some(f => f === '0.bash.log'))
assert.ok(files.some(f => f === '1.fetch.log'))
} finally {
cleanup()
}
})
test('listFiles returns empty for nonexistent dir', () => {
const { sessionFile, cleanup } = makeTmpSession()
try {
const mgr = new ArtifactManager(sessionFile)
assert.deepEqual(mgr.listFiles(), [])
} finally {
cleanup()
}
})