Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/format-mismatch-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@adcp/client": minor
---

feat(conformance): add FormatMismatchHint to StoryboardStepHint taxonomy

Third member of the `StoryboardStepHint` discriminated union. Emits a structured hint when a response_schema validation passes the lenient Zod check but fails strict AJV validation on a `format` keyword (`date-time`, `uuid`, `uri`, `email`, etc.). Non-fatal — does not flip step pass/fail. Fires only on strict_only_failure steps so it surfaces the strict/lenient delta without adding noise to already-failing validations. Closes #947.
1 change: 1 addition & 0 deletions src/lib/testing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export {
type StoryboardStepResult,
type StoryboardStepHint,
type ContextValueRejectedHint,
type FormatMismatchHint,
type ContextProvenanceEntry,
type StoryboardPhaseResult,
type StoryboardResult,
Expand Down
119 changes: 119 additions & 0 deletions src/lib/testing/storyboard/format-mismatch-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Format-mismatch hint detection.
*
* When a response_schema validation passes the lenient Zod check but fails
* strict AJV validation on a `format` keyword (`date-time`, `uuid`, `uri`,
* `email`, etc.), emit a non-fatal `format_mismatch` hint. This is the
* strict/lenient delta the runner already computes inside
* `StrictValidationVerdict` — today the delta shows up as a `warning` string
* on `ValidationResult`; this module surfaces it as machine-readable fields
* a renderer can quote back at the developer with an exact fix suggestion.
*
* Fires only on `strict_only_failure` steps: lenient Zod accepted the
* response (`ValidationResult.passed === true`) but strict AJV rejected it
* (`strict.valid === false`). Firing on already-failing lenient steps would
* add noise on top of more fundamental failures. See issue #947.
*/

import type { FormatMismatchHint, ValidationResult } from './types';
import { resolvePath } from './path';

const FORMAT_KEYWORD_RE = /must match format "([^"]+)"/i;
const OBSERVED_VALUE_MAX_LEN = 200;

/**
* Extract `format_mismatch` hints from a step's validation results.
*
* Pure — safe to call regardless of step pass/fail. Returns an empty array
* when no strict_only_failure response_schema validations have format issues.
*
* @param validations - The step's computed validation results.
* @param tool - AdCP task name (snake_case) the step dispatched.
* @param data - Raw parsed response payload (`taskResult.data`). Used to
* extract `observed_value` at the RFC 6901 pointer AJV reported.
*/
export function detectFormatMismatchHints(
validations: ValidationResult[],
tool: string,
data?: unknown
): FormatMismatchHint[] {
const hints: FormatMismatchHint[] = [];
for (const v of validations) {
if (v.check !== 'response_schema') continue;
if (!v.passed) continue; // strict_only_failure only — lenient must have passed
if (!v.strict || v.strict.valid) continue; // no AJV failure to report
for (const issue of v.strict.issues ?? []) {
if (issue.keyword !== 'format') continue;
const instance_path = issue.instance_path;
// Fallback to '(unknown)' rather than issue.keyword ('format') — the keyword
// filter already guarantees keyword === 'format', so using it as a fallback
// produces the confusing message `must match format "format"`.
const expected_format = extractFormatName(issue.message) ?? '(unknown)';
const observed_value = extractObservedValue(data, instance_path);
hints.push({
kind: 'format_mismatch',
message: buildMessage(tool, instance_path, expected_format, observed_value),
tool,
instance_path,
expected_format,
...(observed_value !== undefined && { observed_value }),
});
}
}
return hints;
}

/**
* Parse the format name from an AJV format-keyword message.
* AJV's stable template: `must match format "<name>"`.
* Falls back to undefined when the message doesn't match (AJV version skew
* or a custom format validator with non-standard message).
*/
function extractFormatName(message: string): string | undefined {
return FORMAT_KEYWORD_RE.exec(message)?.[1];
}

/**
* Convert an RFC 6901 JSON Pointer to the dot-bracket notation `resolvePath`
* speaks. `/packages/0/start_date` → `packages[0].start_date`.
* Applies RFC 6901 escape decoding (`~1` → `/`, `~0` → `~`).
*/
function pointerToDotPath(pointer: string): string {
if (!pointer.startsWith('/')) return pointer;
return pointer
.slice(1)
.split('/')
.map(seg => seg.replace(/~1/g, '/').replace(/~0/g, '~')) // RFC 6901 §3: decode ~1 before ~0
.map(seg => (/^\d+$/.test(seg) ? `[${seg}]` : seg))
.join('.')
.replace(/\.\[/g, '[');
}

/**
* Resolve the value at `pointer` inside `data` and return it if it is a
* string within the length cap. Non-string values (objects, arrays, numbers)
* are not echoed — binary blobs and structured payloads would inflate the hint
* and risk leaking sensitive nested data. Truncates long strings at 200 chars
* using code-point slicing so surrogate pairs aren't cleaved.
*/
function extractObservedValue(data: unknown, pointer: string): string | undefined {
if (data === undefined || data === null) return undefined;
const dotPath = pointerToDotPath(pointer);
const value = resolvePath(data, dotPath);
if (typeof value !== 'string') return undefined;
if (value.length <= OBSERVED_VALUE_MAX_LEN) return value;
return (
Array.from(value)
.slice(0, OBSERVED_VALUE_MAX_LEN - 1)
.join('') + '…'
);
}

function buildMessage(tool: string, instance_path: string, expected_format: string, observed_value?: string): string {
const fieldRepr = instance_path || '/';
const valueClause = observed_value !== undefined ? ` (observed: "${observed_value}")` : '';
return (
`\`${tool}\` response: \`${fieldRepr}\` must match format "${expected_format}"${valueClause}. ` +
`Strict JSON-schema (AJV) rejected this value; lenient Zod validation accepted it.`
);
}
4 changes: 4 additions & 0 deletions src/lib/testing/storyboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
ContextInput,
ContextProvenanceEntry,
ContextValueRejectedHint,
FormatMismatchHint,
StoryboardContext,
StoryboardRunOptions,
ValidationResult,
Expand Down Expand Up @@ -137,6 +138,9 @@ export type { ContextWriteResult } from './context';
// Rejection-hint detection (issue #870)
export { detectContextRejectionHints } from './rejection-hints';

// Format-mismatch hint detection (issue #947)
export { detectFormatMismatchHints } from './format-mismatch-hints';

// Request builder
export { buildRequest, hasRequestBuilder } from './request-builder';

Expand Down
16 changes: 15 additions & 1 deletion src/lib/testing/storyboard/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type RunnerVariables,
} from './context';
import { detectContextRejectionHints } from './rejection-hints';
import { detectFormatMismatchHints } from './format-mismatch-hints';
import { runValidations, type ValidationContext } from './validations';
import { enrichRequest, hasRequestEnricher } from './request-builder';
import { resolveAccount, resolveBrand } from '../client';
Expand Down Expand Up @@ -1685,10 +1686,23 @@ async function executeStep(
// since the rejected value can't have come from this step's own
// extraction.
const stepFailed = !(passed && allValidationsPassed);
const hints =
// context_value_rejected hints: fire only on step failure (the seller's
// error response carries the accepted-values list we trace back to a
// prior-step context write). Gate unchanged from pre-#947 behavior.
const contextRejectionHints =
stepFailed && runState.contextProvenance
? detectContextRejectionHints(taskResult, request, context, runState.contextProvenance, effectiveStep.task)
: [];
// format_mismatch hints: fire on strict_only_failure validations (lenient
// Zod passed, strict AJV rejected on a format keyword). These are most
// valuable when the step is green — AJV found a silent strictness gap the
// step would otherwise surface only as a warning string. Gate is separate
// from stepFailed so hints reach lenient-passing steps. Issue #947.
// No explicit guard needed: skipped / probe / webhook-assertion steps have
// empty `validations` arrays, so detectFormatMismatchHints returns [] for them
// without inspecting taskResult.
const formatMismatchHints = detectFormatMismatchHints(validations, effectiveStep.task, taskResult?.data);
const hints = [...contextRejectionHints, ...formatMismatchHints];

// Build next step preview
const next = getNextStepPreview(step.id, allSteps, updatedContext, runState.runnerVars);
Expand Down
44 changes: 39 additions & 5 deletions src/lib/testing/storyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,12 +1049,46 @@ export interface ContextProvenanceEntry {
}

/**
* Non-fatal hint attached to a step result. Today the runner only emits
* `context_value_rejected` — more kinds may be added over time. The
* discriminator lives on `kind` so consumers that only know how to render
* a subset can ignore the rest without losing them.
* Non-fatal hint attached to a step result. More kinds may be added over
* time. The discriminator lives on `kind` so consumers that only know how
* to render a subset can ignore the rest without losing them.
*/
export type StoryboardStepHint = ContextValueRejectedHint;
export type StoryboardStepHint = ContextValueRejectedHint | FormatMismatchHint;

/**
* A response field passed the lenient Zod check but failed strict JSON-schema
* (AJV) validation on a `format` keyword (`date-time`, `uuid`, `uri`, `email`,
* etc.). Fires only on strict_only_failure validations — lenient passed, strict
* failed — so it adds signal on top of a green step without obscuring hard
* failures that already surface via `ValidationResult.error`.
*
* Non-fatal: does not flip the step's pass/fail. `message` carries a
* human-readable summary renderers can display without needing to understand
* the structured fields.
*/
export interface FormatMismatchHint {
kind: 'format_mismatch';
/** Pre-formatted human-readable summary suitable for a console line. */
message: string;
/** AdCP tool name (snake_case) whose response contained the violation. */
tool: string;
/**
* RFC 6901 JSON Pointer to the offending field in the agent's response.
* Matches `SchemaValidationError.instance_path` — `FormatMismatchHint` is
* a typed projection of a `SchemaValidationError` filtered by
* `keyword === 'format'`.
*/
instance_path: string;
/** JSON Schema `format` keyword value that rejected the field (e.g. `'date-time'`, `'uuid'`, `'uri'`). */
expected_format: string;
/**
* The value the runner observed at `instance_path`. Present only when the
* value is a string — non-string values (objects, arrays, numbers) are not
* echoed into hints. Truncated at 200 characters so binary blobs and
* unexpectedly long strings don't inflate the hint payload.
*/
observed_value?: string;
}

/**
* A seller rejected a request value that the runner traced back to a
Expand Down
6 changes: 3 additions & 3 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* AdCP client library version
*/
export const LIBRARY_VERSION = '5.16.0';
export const LIBRARY_VERSION = '5.17.0';

/**
* AdCP specification version this library is built for
Expand All @@ -27,10 +27,10 @@ export const COMPATIBLE_ADCP_VERSIONS = ['v2.5', 'v2.6', 'v3', '3.0.0-beta.1', '
* Full version information
*/
export const VERSION_INFO = {
library: '5.16.0',
library: '5.17.0',
adcp: '3.0.0',
compatibleVersions: COMPATIBLE_ADCP_VERSIONS,
generatedAt: '2026-04-24T21:53:15.899Z',
generatedAt: '2026-04-25T10:06:32.349Z',
} as const;

/**
Expand Down
Loading
Loading