takt/src/infra/mock/scenario.ts
nrslib 222560a96a プロバイダーエラーを blocked から error ステータスに分離し、Codex にリトライ機構を追加
blocked はユーザー入力で解決可能な状態、error はプロバイダー障害として意味を明確化。
PieceEngine で error ステータスを検知して即座に abort する。
Codex クライアントにトランジェントエラー(stream disconnected, transport error 等)の
指数バックオフリトライ(最大3回)を追加。
2026-02-09 22:04:52 +09:00

152 lines
4.2 KiB
TypeScript

/**
* Mock scenario support for integration testing.
*
* Provides a queue-based mechanism to control mock provider responses
* per agent or by call order. Scenarios can be loaded from JSON files
* (via TAKT_MOCK_SCENARIO env var) or set programmatically in tests.
*/
import { readFileSync, existsSync } from 'node:fs';
import type { ScenarioEntry } from './types.js';
export type { ScenarioEntry };
/**
* Queue that dispenses scenario entries.
*
* Matching rules:
* 1. If an entry has `agent` set, it only matches calls for that agent name.
* 2. Entries without `agent` match any call (consumed in order).
* 3. First matching entry is removed from the queue and returned.
* 4. Returns undefined when no matching entry remains.
*/
export class ScenarioQueue {
private entries: ScenarioEntry[];
constructor(entries: ScenarioEntry[]) {
// Defensive copy
this.entries = [...entries];
}
/**
* Consume the next matching entry for the given agent.
*/
consume(personaName: string): ScenarioEntry | undefined {
// Try persona-specific match first
const personaIndex = this.entries.findIndex(
(e) => e.persona !== undefined && e.persona === personaName,
);
if (personaIndex >= 0) {
return this.entries.splice(personaIndex, 1)[0];
}
// Fall back to first unspecified entry
const anyIndex = this.entries.findIndex((e) => e.persona === undefined);
if (anyIndex >= 0) {
return this.entries.splice(anyIndex, 1)[0];
}
return undefined;
}
/** Number of remaining entries */
get remaining(): number {
return this.entries.length;
}
}
// --- Global singleton (module-level state) ---
let globalQueue: ScenarioQueue | null = null;
/**
* Set mock scenario programmatically (for tests).
* Pass null to clear.
*/
export function setMockScenario(entries: ScenarioEntry[] | null): void {
globalQueue = entries ? new ScenarioQueue(entries) : null;
}
/**
* Get the current global scenario queue.
* Lazily loads from TAKT_MOCK_SCENARIO env var on first access.
*/
export function getScenarioQueue(): ScenarioQueue | null {
if (globalQueue) return globalQueue;
const envPath = process.env.TAKT_MOCK_SCENARIO;
if (envPath) {
const entries = loadScenarioFile(envPath);
globalQueue = new ScenarioQueue(entries);
return globalQueue;
}
return null;
}
/**
* Reset global scenario state (for test cleanup).
*/
export function resetScenario(): void {
globalQueue = null;
}
/**
* Load and validate a scenario JSON file.
*
* @param filePath Absolute or relative path to scenario JSON
* @throws Error if file not found or JSON invalid
*/
export function loadScenarioFile(filePath: string): ScenarioEntry[] {
if (!existsSync(filePath)) {
throw new Error(`Scenario file not found: ${filePath}`);
}
const raw = readFileSync(filePath, 'utf-8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Scenario file is not valid JSON: ${filePath}`);
}
if (!Array.isArray(parsed)) {
throw new Error(`Scenario file must contain a JSON array: ${filePath}`);
}
return parsed.map((entry, i) => validateEntry(entry, i));
}
function validateEntry(entry: unknown, index: number): ScenarioEntry {
if (typeof entry !== 'object' || entry === null) {
throw new Error(`Scenario entry [${index}] must be an object`);
}
const obj = entry as Record<string, unknown>;
// content is required
if (typeof obj.content !== 'string') {
throw new Error(`Scenario entry [${index}] must have a "content" string`);
}
// status defaults to 'done'
const validStatuses = ['done', 'blocked', 'error', 'approved', 'rejected', 'improve'] as const;
const status = obj.status ?? 'done';
if (typeof status !== 'string' || !validStatuses.includes(status as typeof validStatuses[number])) {
throw new Error(
`Scenario entry [${index}] has invalid status "${String(status)}". Valid: ${validStatuses.join(', ')}`,
);
}
// persona is optional
if (obj.persona !== undefined && typeof obj.persona !== 'string') {
throw new Error(`Scenario entry [${index}] "persona" must be a string if provided`);
}
return {
persona: obj.persona as string | undefined,
status: status as ScenarioEntry['status'],
content: obj.content as string,
};
}