Files
Codewalkers/src/test/cassette/normalizer.ts
Lukas May 0ed657b644 feat: Add VCR-style cassette testing system for agent subprocess pipeline
Implements cassette recording/replay to test the full agent execution
pipeline (ProcessManager → FileTailer → OutputHandler → SignalManager)
without real AI API calls.

Key components:
- `CassetteProcessManager`: extends ProcessManager, intercepts spawnDetached
  to replay cassettes or record real runs on completion
- `replay-worker.mjs`: standalone node script that replays JSONL + signal.json
  as a subprocess, exercising the complete file-based output pipeline
- `CassetteStore`: reads/writes cassette JSON files keyed by SHA256 hash
- `normalizer.ts`: strips dynamic content (UUIDs, temp paths, timestamps,
  session numbers) from prompts for stable cassette keys
- `key.ts`: hashes normalized prompt + provider args + worktree file content
  (worktree hash detects content drift for execute-mode agents)
- `createCassetteHarness()`: wraps RealProviderHarness with cassette support,
  same interface so existing real-provider tests work unchanged

Mode control via env vars:
  (default)                  → replay: cassette must exist (safe for CI)
  CW_CASSETTE_RECORD=1       → auto: replay if exists, record if missing
  CW_CASSETTE_FORCE_RECORD=1 → record: always run real agent, overwrite cassette

MultiProviderAgentManager gains an optional `processManagerOverride` constructor
parameter for clean dependency injection without changing existing callers.

Cassette files live in src/test/cassettes/ and are intended to be committed
to git so CI runs without API access.
2026-03-02 12:17:52 +09:00

68 lines
2.3 KiB
TypeScript

/**
* Cassette Normalizer
*
* Strips dynamic content from prompts and CLI args before hashing into a cassette key.
* Dynamic content (UUIDs, temp paths, timestamps, session numbers) varies between
* test runs but doesn't affect how the agent responds — so we replace them with
* stable placeholders to get a stable cache key.
*/
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
const ISO_TIMESTAMP_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?/g;
const UNIX_EPOCH_MS_RE = /\b1[0-9]{12}\b/g;
const SESSION_NUM_RE = /\bsession[_\s-]?\d+\b/gi;
/**
* Normalize a prompt for stable cassette key generation.
*
* Replacements applied in order (most-specific first to avoid partial matches):
* 1. Absolute workspace root path → __WORKSPACE__
* 2. UUIDs → __UUID__
* 3. ISO 8601 timestamps → __TIMESTAMP__
* 4. Unix epoch milliseconds → __EPOCH__
* 5. Session numbers → session__N__
*/
export function normalizePrompt(prompt: string, workspaceRoot: string): string {
let normalized = prompt;
if (workspaceRoot) {
normalized = normalized.replaceAll(workspaceRoot, '__WORKSPACE__');
}
normalized = normalized.replace(UUID_RE, '__UUID__');
normalized = normalized.replace(ISO_TIMESTAMP_RE, '__TIMESTAMP__');
normalized = normalized.replace(UNIX_EPOCH_MS_RE, '__EPOCH__');
normalized = normalized.replace(SESSION_NUM_RE, 'session__N__');
return normalized;
}
/**
* Strip the prompt value from CLI args to produce stable modelArgs for the cassette key.
*
* Handles all provider prompt flag styles:
* - Native: `-p <prompt>` (Claude)
* - Flag: `--prompt <prompt>`, `-p <prompt>` (Gemini, Cursor, Auggie, Amp, Opencode)
* - Also removes the bare prompt value if it appears as a positional arg.
*/
export function stripPromptFromArgs(args: string[], prompt: string): string[] {
if (!prompt) return [...args];
const result: string[] = [];
let i = 0;
while (i < args.length) {
const arg = args[i];
const PROMPT_FLAGS = ['-p', '--prompt', '--message'];
if (PROMPT_FLAGS.includes(arg) && args[i + 1] === prompt) {
i += 2; // skip flag + value
} else if (arg === prompt) {
i += 1; // skip bare positional prompt
} else {
result.push(arg);
i++;
}
}
return result;
}