@nifrajs/testing 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ /** Test adapters for typed tool contracts. Assertions stay on the shared core pipeline. */
2
+
3
+ import {
4
+ createToolHttpHandler,
5
+ executeTool,
6
+ type ToolAdapter,
7
+ type ToolAdapterResult,
8
+ type ToolCallOptions,
9
+ type ToolContract,
10
+ type ToolError,
11
+ type ToolEvidence,
12
+ } from "@nifrajs/core/tool-contract"
13
+ import { toMcpTool } from "@nifrajs/mcp/tool-contract"
14
+
15
+ export function inProcessToolAdapter<Input, Output>(
16
+ tool: ToolContract<Input, Output>,
17
+ baseOptions: ToolCallOptions = {},
18
+ ): ToolAdapter {
19
+ return {
20
+ name: "in-process",
21
+ call: (input, options) => executeTool(tool, input, { ...baseOptions, ...options }),
22
+ }
23
+ }
24
+
25
+ export function testToolAdapter<Input, Output>(
26
+ tool: ToolContract<Input, Output>,
27
+ baseOptions: ToolCallOptions = {},
28
+ ): ToolAdapter {
29
+ return {
30
+ name: "test",
31
+ call: (input, options) => executeTool(tool, input, { ...baseOptions, ...options }),
32
+ }
33
+ }
34
+
35
+ export function mcpToolAdapter<Input, Output>(
36
+ tool: ToolContract<Input, Output>,
37
+ baseOptions: Omit<ToolCallOptions, "signal" | "ledger"> = {},
38
+ ): ToolAdapter {
39
+ return {
40
+ name: "mcp",
41
+ async call(input, options) {
42
+ const args = recordOf(input)
43
+ if (args === undefined) throw new Error("tool adapter: MCP input must be an object")
44
+ const mcp = toMcpTool(tool, { ...baseOptions, ...options })
45
+ const result = await mcp.handler(args, {
46
+ signal: options?.signal ?? new AbortController().signal,
47
+ requestId: "test",
48
+ reportProgress: () => {},
49
+ })
50
+ if (typeof result === "string") throw new Error("tool adapter: MCP returned plain text")
51
+ const text = result.content?.find((item) => item.type === "text")
52
+ if (text === undefined || text.type !== "text")
53
+ throw new Error("tool adapter: MCP result has no text")
54
+ const parsed = JSON.parse(text.text)
55
+ const record = recordOf(parsed)
56
+ if (result.isError === true) {
57
+ const error = toolErrorOf(record?.error)
58
+ return {
59
+ ok: false,
60
+ dryRun: record?.dryRun === true,
61
+ error,
62
+ evidence: evidenceOf(record?.evidence),
63
+ }
64
+ }
65
+ return {
66
+ ok: true,
67
+ dryRun: record?.dryRun === true,
68
+ ...(record?.output === undefined ? {} : { output: record.output }),
69
+ evidence: evidenceOf(record?.evidence),
70
+ }
71
+ },
72
+ }
73
+ }
74
+
75
+ /** Exercise the Web adapter while returning the same normalized result shape as direct calls. */
76
+ export function httpToolAdapter<Input, Output>(
77
+ tool: ToolContract<Input, Output>,
78
+ baseOptions: Omit<ToolCallOptions, "signal" | "ledger"> = {},
79
+ ): ToolAdapter {
80
+ return {
81
+ name: "http",
82
+ async call(input, options) {
83
+ const handler = createToolHttpHandler(tool, { ...baseOptions, ...options })
84
+ const request = new Request("http://nifra.local/tool", {
85
+ method: "POST",
86
+ body: JSON.stringify(input),
87
+ })
88
+ const response = await handler(request)
89
+ const body = (await response.json()) as unknown
90
+ if (response.status >= 200 && response.status < 300) {
91
+ const record = recordOf(body)
92
+ return {
93
+ ok: true,
94
+ dryRun: record?.dryRun === true,
95
+ ...(record?.output === undefined ? {} : { output: record.output }),
96
+ evidence: Array.isArray(record?.evidence) ? record.evidence : [],
97
+ } satisfies ToolAdapterResult
98
+ }
99
+ const record = recordOf(body)
100
+ const error = toolErrorOf(record?.error)
101
+ return {
102
+ ok: false,
103
+ dryRun: record?.dryRun === true,
104
+ error,
105
+ evidence: Array.isArray(record?.evidence) ? record.evidence : [],
106
+ } satisfies ToolAdapterResult
107
+ },
108
+ }
109
+ }
110
+
111
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
112
+ return value !== null && typeof value === "object" && !Array.isArray(value)
113
+ ? (value as Record<string, unknown>)
114
+ : undefined
115
+ }
116
+
117
+ function toolErrorOf(value: unknown): ToolError {
118
+ const record = recordOf(value)
119
+ const code = record?.code
120
+ const stage = record?.stage
121
+ if (!isToolErrorCode(code) || !isToolEvidenceStage(stage)) {
122
+ throw new Error("tool adapter: malformed error result")
123
+ }
124
+ return { code, stage }
125
+ }
126
+
127
+ function evidenceOf(value: unknown): readonly ToolEvidence[] {
128
+ if (!Array.isArray(value)) return []
129
+ const evidence: ToolEvidence[] = []
130
+ for (const item of value) {
131
+ const record = recordOf(item)
132
+ if (record === undefined || typeof record.seq !== "number" || !Number.isSafeInteger(record.seq))
133
+ continue
134
+ if (!isToolEvidenceStage(record.stage) || !isToolEvidenceOutcome(record.outcome)) continue
135
+ evidence.push({
136
+ seq: record.seq,
137
+ stage: record.stage,
138
+ outcome: record.outcome,
139
+ ...(typeof record.code === "string" ? { code: record.code } : {}),
140
+ })
141
+ }
142
+ return evidence
143
+ }
144
+
145
+ function isToolErrorCode(value: unknown): value is ToolError["code"] {
146
+ return (
147
+ typeof value === "string" &&
148
+ [
149
+ "input_invalid",
150
+ "capability_denied",
151
+ "execution_policy_unsatisfied",
152
+ "approval_required",
153
+ "approval_denied",
154
+ "idempotency_store_missing",
155
+ "idempotency_durability",
156
+ "idempotency_duplicate",
157
+ "idempotency_in_flight",
158
+ "idempotency_capacity",
159
+ "budget_exceeded",
160
+ "cancelled",
161
+ "execution_failed",
162
+ "output_invalid",
163
+ "ledger_failed",
164
+ ].includes(value)
165
+ )
166
+ }
167
+
168
+ function isToolEvidenceStage(value: unknown): value is ToolEvidence["stage"] {
169
+ return (
170
+ typeof value === "string" &&
171
+ [
172
+ "input",
173
+ "capability",
174
+ "policy",
175
+ "approval",
176
+ "idempotency",
177
+ "budget",
178
+ "execution",
179
+ "output",
180
+ ].includes(value)
181
+ )
182
+ }
183
+
184
+ function isToolEvidenceOutcome(value: unknown): value is ToolEvidence["outcome"] {
185
+ return (
186
+ typeof value === "string" &&
187
+ ["passed", "denied", "failed", "skipped", "committed", "dry-run"].includes(value)
188
+ )
189
+ }
@@ -0,0 +1,328 @@
1
+ import {
2
+ type AgentDefinition,
3
+ type AgentModelPort,
4
+ type AgentPorts,
5
+ type AgentRunResult,
6
+ type AgentTranscript,
7
+ type AgentTurnInput,
8
+ type AgentTurnState,
9
+ runAgent,
10
+ } from "@nifrajs/agent"
11
+ import type { StandardSchemaV1 } from "@nifrajs/core/schema"
12
+ import { createToolBudget, type ToolBudget, type ToolContract } from "@nifrajs/core/tool-contract"
13
+ import { createFailureLab, type FailureEvidence } from "./failure-lab.ts"
14
+ import type { FaultProfile, FaultProfileFault, FaultProfileFaultKind } from "./fault-profile.ts"
15
+
16
+ export interface TrajectoryTranscript {
17
+ readonly version: 1
18
+ readonly digest: string
19
+ readonly turn: AgentTranscript
20
+ readonly faultProfile?: string
21
+ readonly seed?: number
22
+ }
23
+
24
+ export interface CreateTrajectoryTranscriptOptions {
25
+ readonly faultProfile?: string
26
+ readonly seed?: number
27
+ }
28
+
29
+ export interface TrajectoryRun<Output> {
30
+ readonly result: AgentRunResult<Output>
31
+ readonly transcript: TrajectoryTranscript
32
+ }
33
+
34
+ export interface ReplayTrajectoryOptions {
35
+ readonly state: AgentTurnState
36
+ readonly maxTurns?: number
37
+ readonly goal?: (output: unknown) => boolean
38
+ readonly faultProfile?: FaultProfile
39
+ readonly seed?: number
40
+ }
41
+
42
+ export type TrajectoryInvariantId =
43
+ | "ledger-evidence"
44
+ | "capability-admission"
45
+ | "budget-monotonic"
46
+ | "bounded-stop"
47
+ | "resumable-suspension"
48
+
49
+ export interface TrajectoryInvariantResult {
50
+ readonly id: TrajectoryInvariantId
51
+ readonly ok: boolean
52
+ readonly code?: string
53
+ }
54
+
55
+ export interface TrajectoryReplayResult<Output> {
56
+ readonly result: AgentRunResult<Output>
57
+ readonly transcript: TrajectoryTranscript
58
+ readonly faults: readonly FailureEvidence[]
59
+ readonly invariants: readonly TrajectoryInvariantResult[]
60
+ readonly regressionIds: readonly string[]
61
+ }
62
+
63
+ export interface TrajectoryInvariantOptions {
64
+ readonly maxSteps?: number
65
+ /** Remaining budget snapshots, used when the caller owns a budget adapter. */
66
+ readonly budgetSnapshots?: readonly Readonly<Record<string, number>>[]
67
+ }
68
+
69
+ const DEFAULT_SEED = 0x4e_49_46_52
70
+ const FAULT_KINDS: readonly FaultProfileFaultKind[] = [
71
+ "tool-error",
72
+ "malformed-model-output",
73
+ "budget-pressure",
74
+ "approval-denial",
75
+ "cancellation",
76
+ ]
77
+
78
+ /** Record the transcript emitted by a turn without adding a second execution recording path. */
79
+ export async function createTrajectoryTranscript(
80
+ transcript: AgentTranscript,
81
+ options: CreateTrajectoryTranscriptOptions = {},
82
+ ): Promise<TrajectoryTranscript> {
83
+ validateAgentTranscript(transcript)
84
+ const payload = {
85
+ version: 1,
86
+ turn: transcript,
87
+ ...(options.faultProfile === undefined ? {} : { faultProfile: options.faultProfile }),
88
+ ...(options.seed === undefined ? {} : { seed: options.seed }),
89
+ }
90
+ return Object.freeze({ ...payload, version: 1 as const, digest: await digest(payload) })
91
+ }
92
+
93
+ /** Alias that reads naturally at a call site which has just completed a run. */
94
+ export const recordTrajectory = createTrajectoryTranscript
95
+
96
+ export async function runTrajectory<
97
+ InputSchema extends StandardSchemaV1,
98
+ OutputSchema extends StandardSchemaV1,
99
+ >(
100
+ definition: AgentDefinition<InputSchema, OutputSchema>,
101
+ input: AgentTurnInput,
102
+ ports: AgentPorts,
103
+ options: {
104
+ readonly state: AgentTurnState
105
+ readonly maxTurns?: number
106
+ readonly goal?: (output: unknown) => boolean
107
+ },
108
+ ): Promise<TrajectoryRun<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
109
+ const result = await runAgent(definition, input, ports, options)
110
+ const transcript = await createTrajectoryTranscript(result.transcript)
111
+ return Object.freeze({ result, transcript })
112
+ }
113
+
114
+ /**
115
+ * Replay a recorded run with local model decisions and dry-run tools. The supplied model port is
116
+ * never called. Fault schedules use the existing deterministic failure-lab controller.
117
+ */
118
+ export async function replayTrajectory<
119
+ InputSchema extends StandardSchemaV1,
120
+ OutputSchema extends StandardSchemaV1,
121
+ >(
122
+ definition: AgentDefinition<InputSchema, OutputSchema>,
123
+ input: AgentTurnInput,
124
+ ports: Omit<AgentPorts, "model">,
125
+ transcript: TrajectoryTranscript,
126
+ options: ReplayTrajectoryOptions,
127
+ ): Promise<TrajectoryReplayResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
128
+ validateAgentTranscript(transcript.turn)
129
+ const profile = options.faultProfile
130
+ const seed = options.seed ?? DEFAULT_SEED
131
+ const lab = createFailureLab({ seed, schedule: profile?.schedule ?? [] })
132
+ const faults = profile?.faults ?? []
133
+ const counts = new Map<string, number>()
134
+ const controller = new AbortController()
135
+ if (ports.signal?.aborted === true) controller.abort()
136
+ ports.signal?.addEventListener("abort", () => controller.abort(), { once: true })
137
+ let responseIndex = 0
138
+ let idSequence = 0
139
+ const takeFault = (
140
+ point: FaultProfileFault["point"],
141
+ kind?: FaultProfileFaultKind,
142
+ ): FaultProfileFault | undefined => {
143
+ for (const candidateKind of kind === undefined ? FAULT_KINDS : [kind]) {
144
+ const key = `${point}:${candidateKind}`
145
+ const occurrence = (counts.get(key) ?? 0) + 1
146
+ counts.set(key, occurrence)
147
+ const fault = faults.find(
148
+ (candidate) =>
149
+ candidate.point === point &&
150
+ candidate.kind === candidateKind &&
151
+ (candidate.occurrence ?? 1) === occurrence,
152
+ )
153
+ if (fault !== undefined) return fault
154
+ }
155
+ return undefined
156
+ }
157
+ const model: AgentModelPort = {
158
+ complete: async (): Promise<unknown> => {
159
+ lab.checkpoint("trajectory.model")
160
+ const fault = takeFault("model")
161
+ if (fault?.kind === "malformed-model-output") return { kind: "invalid" }
162
+ if (fault?.kind === "cancellation") {
163
+ controller.abort()
164
+ throw new Error("trajectory cancellation")
165
+ }
166
+ if (fault?.kind === "budget-pressure") throw new Error("trajectory budget pressure")
167
+ const response = transcript.turn.responses[responseIndex++]
168
+ if (response === undefined) throw new Error("trajectory transcript ended")
169
+ return lab.provider("trajectory.model", () => response)
170
+ },
171
+ }
172
+ const tools = definition.tools.map((tool) => wrapTool(tool, lab, () => takeFault("tool")))
173
+ const replayDefinition: AgentDefinition<InputSchema, OutputSchema> = { ...definition, tools }
174
+ let effectiveApproval = ports.approval
175
+ if (effectiveApproval !== undefined) {
176
+ const approval = effectiveApproval
177
+ effectiveApproval = {
178
+ request: async (request) => {
179
+ const fault = takeFault("effect", "approval-denial")
180
+ if (fault !== undefined) return { status: "denied", reason: "trajectory fault" }
181
+ return approval.request(request)
182
+ },
183
+ }
184
+ }
185
+ const pressuredBudget: ToolBudget = createToolBudget({ limits: { calls: 0 } })
186
+ const effectivePorts: AgentPorts = {
187
+ ...ports,
188
+ model,
189
+ dryRun: true,
190
+ signal: controller.signal,
191
+ idFactory: () => `trajectory-${seed}-${++idSequence}`,
192
+ ...(effectiveApproval === undefined ? {} : { approval: effectiveApproval }),
193
+ ...(faults.some((fault) => fault.kind === "budget-pressure")
194
+ ? { budget: pressuredBudget }
195
+ : {}),
196
+ }
197
+ const result = await runAgent(replayDefinition, input, effectivePorts, {
198
+ state: options.state,
199
+ ...(options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }),
200
+ ...(options.goal === undefined ? {} : { goal: options.goal }),
201
+ })
202
+ const invariantResults = checkTrajectoryInvariants(result)
203
+ const regressionIds = await Promise.all(
204
+ invariantResults.map((invariant) =>
205
+ trajectoryRegressionId(transcript, profile?.name ?? "none", invariant.id),
206
+ ),
207
+ )
208
+ return Object.freeze({
209
+ result,
210
+ transcript,
211
+ faults: lab.evidence(),
212
+ invariants: Object.freeze(invariantResults),
213
+ regressionIds: Object.freeze(regressionIds),
214
+ })
215
+ }
216
+
217
+ export function checkTrajectoryInvariants(
218
+ result: AgentRunResult<unknown>,
219
+ options: TrajectoryInvariantOptions = {},
220
+ ): readonly TrajectoryInvariantResult[] {
221
+ const evidence = result.evidence
222
+ const committedTools = evidence.filter(
223
+ (item) => item.kind === "tool" && item.outcome === "committed",
224
+ )
225
+ const ledger = committedTools.every(
226
+ (item) => typeof item.ledgerHead === "string" && item.ledgerHead.length > 0,
227
+ )
228
+ const deniedEffects = new Set(
229
+ evidence
230
+ .filter((item) => item.code === "capability_denied" && item.effectId !== undefined)
231
+ .map((item) => item.effectId),
232
+ )
233
+ const capability = committedTools.every(
234
+ (item) => item.effectId === undefined || !deniedEffects.has(item.effectId),
235
+ )
236
+ const bounded =
237
+ evidence.length <= (options.maxSteps ?? 256) &&
238
+ (result.status === "completed" || result.status === "suspended")
239
+ const resumable =
240
+ result.status !== "suspended" ||
241
+ (result.state.status === "suspended" && result.pending.effectId.length > 0)
242
+ const snapshots = options.budgetSnapshots ?? []
243
+ const budget = snapshots.every((snapshot, index) => {
244
+ const values = Object.values(snapshot)
245
+ if (values.some((value) => !Number.isFinite(value) || value < 0)) return false
246
+ if (index === 0) return true
247
+ const previous = snapshots[index - 1] ?? {}
248
+ return Object.entries(snapshot).every(([axis, value]) => value <= (previous[axis] ?? value))
249
+ })
250
+ return Object.freeze([
251
+ { id: "ledger-evidence", ok: ledger, ...(ledger ? {} : { code: "effect_without_ledger" }) },
252
+ {
253
+ id: "capability-admission",
254
+ ok: capability,
255
+ ...(capability ? {} : { code: "capability_admission_failed" }),
256
+ },
257
+ { id: "budget-monotonic", ok: budget, ...(budget ? {} : { code: "budget_negative" }) },
258
+ { id: "bounded-stop", ok: bounded, ...(bounded ? {} : { code: "stop_bound_exceeded" }) },
259
+ {
260
+ id: "resumable-suspension",
261
+ ok: resumable,
262
+ ...(resumable ? {} : { code: "suspension_not_resumable" }),
263
+ },
264
+ ])
265
+ }
266
+
267
+ export function assertTrajectoryInvariants(
268
+ result: AgentRunResult<unknown>,
269
+ options: TrajectoryInvariantOptions = {},
270
+ ): void {
271
+ const failures = checkTrajectoryInvariants(result, options).filter((invariant) => !invariant.ok)
272
+ if (failures.length > 0)
273
+ throw new Error(
274
+ `trajectory invariants failed: ${failures.map((invariant) => invariant.code ?? invariant.id).join(",")}`,
275
+ )
276
+ }
277
+
278
+ export async function trajectoryRegressionId(
279
+ transcript: TrajectoryTranscript,
280
+ faultProfile: string,
281
+ invariant: TrajectoryInvariantId,
282
+ ): Promise<string> {
283
+ if (faultProfile.trim() === "") throw new TypeError("trajectory: fault profile name is required")
284
+ return digest({ transcript: transcript.digest, faultProfile, invariant })
285
+ }
286
+
287
+ function wrapTool<Input, Output>(
288
+ tool: ToolContract<Input, Output>,
289
+ lab: ReturnType<typeof createFailureLab>,
290
+ nextFault: () => FaultProfileFault | undefined,
291
+ ): ToolContract<Input, Output> {
292
+ return Object.freeze({
293
+ ...tool,
294
+ execute: async (
295
+ input: Input,
296
+ context: Parameters<ToolContract<Input, Output>["execute"]>[1],
297
+ ) => {
298
+ lab.checkpoint(`trajectory.tool.${tool.name}`)
299
+ const fault = nextFault()
300
+ if (fault?.kind === "tool-error" || fault?.kind === "budget-pressure")
301
+ throw new Error("trajectory tool fault")
302
+ if (fault?.kind === "cancellation") throw new Error("trajectory cancellation")
303
+ lab.checkpoint(`trajectory.effect.${tool.name}`)
304
+ return tool.execute(input, context)
305
+ },
306
+ })
307
+ }
308
+
309
+ function validateAgentTranscript(value: AgentTranscript): void {
310
+ if (
311
+ value === null ||
312
+ typeof value !== "object" ||
313
+ value.version !== 1 ||
314
+ typeof value.turnId !== "string" ||
315
+ !Array.isArray(value.responses) ||
316
+ !Array.isArray(value.evidence)
317
+ ) {
318
+ throw new TypeError("trajectory: invalid agent transcript")
319
+ }
320
+ }
321
+
322
+ async function digest(value: unknown): Promise<string> {
323
+ const serialized = JSON.stringify(value)
324
+ if (serialized === undefined) throw new TypeError("trajectory: value is not serializable")
325
+ const bytes = new TextEncoder().encode(serialized)
326
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
327
+ return [...hash].map((byte) => byte.toString(16).padStart(2, "0")).join("")
328
+ }