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
42 changes: 42 additions & 0 deletions .changeset/a2a-context-continuity-validator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@adcp/client': minor
---

Storyboard runner: add `a2a_context_continuity` validation check for
multi-step A2A storyboards. Closes the sibling regression class to
adcp-client#952 — sellers that bypass the `@a2a-js/sdk`
`DefaultRequestHandler` and stamp their own `contextId` on the response
Task (instead of echoing the buyer-supplied value) will now fail the
storyboard suite.

A2A 0.3.0 §7.1 lets buyers pass `params.message.contextId` to bind a
follow-up send to an existing server-side context; the server must echo
the same value on the response Task. The SDK handles this automatically
via `createA2AAdapter`'s `requestContext.contextId` forwarding, so SDK-
based sellers pass silently. Sellers that bypass the SDK are only
detectable on multi-step storyboards — this validator is that gate.

Runner plumbing:
- `ExecutionState.lastA2aContextId` accumulates the contextId from each
A2A step's response Task. Before each A2A dispatch, the current value
is forwarded as `TaskOptions.contextId` (which rides on
`params.message.contextId` on the wire via `TaskExecutor`). After
dispatch, the response Task's contextId updates the state for the next
step.
- `outboundA2aContextId` is captured before dispatch and passed to
`ValidationContext` so the validator can compare "what was sent" to
"what came back" — avoiding the tautology of asserting against a value
the runner never actually forwarded.
- `executeStoryboardTask` accepts a `contextId` in its opts argument and
threads it through `TaskOptions`, which `SingleAgentClient` and
`TaskExecutor` already forward to `callA2ATool`.

Validator behavior:
- Self-skips with `not_applicable` when: no `outboundA2aContextId`
(first A2A step, non-A2A run, or prior step had no contextId),
no `a2aEnvelope` (capture miss), or a JSON-RPC error envelope (no
Task returned, so continuity is not verifiable).
- Fails when response `Task.contextId` ≠ `outboundA2aContextId`, or
when response `Task.contextId` is absent/empty on a follow-up send.

9 unit tests added. Refs adcp-client#962.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions src/lib/testing/storyboard/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,15 @@ interface ExecutionState {
* the shallow-merge semantics of the context itself.
*/
contextProvenance?: Map<string, ContextProvenanceEntry>;
/**
* A2A contextId from the most recent step that returned a non-empty
* Task.contextId. Forwarded as `TaskOptions.contextId` on the next
* A2A dispatch so multi-step storyboards bind follow-up sends to the
* same server-side context. `a2a_context_continuity` reads the value
* that was forwarded (i.e. what existed BEFORE a step ran) to confirm
* the seller echoed it back. Issue #962.
*/
lastA2aContextId?: string;
}

async function executeStep(
Expand Down Expand Up @@ -1497,6 +1506,7 @@ async function executeStep(
let httpResult: HttpProbeResult | undefined;
let responseRecord: RunnerResponseRecord | undefined;
let a2aEnvelope: A2ATaskEnvelope | undefined;
let outboundA2aContextId: string | undefined;

if (useRawProbe) {
const started = Date.now();
Expand Down Expand Up @@ -1545,10 +1555,17 @@ async function executeStep(
// `agentTools` later). If a future "auto-detect protocol" flow
// lands, key the capture off the negotiated transport instead.
const captureA2a = options.protocol === 'a2a';
// Snapshot the contextId we are about to forward on this step's outbound
// A2A request. Captured BEFORE dispatch so `a2a_context_continuity` can
// compare it against the response — the runner updates `lastA2aContextId`
// AFTER dispatch (with the response's contextId), so reading it here
// gives us "what was sent", not "what came back".
outboundA2aContextId = captureA2a ? runState.lastA2aContextId : undefined;
let a2aCaptures: RawHttpCapture[] | undefined;
const dispatch = () =>
executeStoryboardTask(client, effectiveStep.task, request, {
skipIdempotencyAutoInject: testsMissingIdempotencyKey,
...(outboundA2aContextId && { contextId: outboundA2aContextId }),
});
const run = await runStep(step.title, effectiveStep.task, async () => {
if (!captureA2a) return dispatch();
Expand All @@ -1572,6 +1589,19 @@ async function executeStep(
if (captureA2a && a2aCaptures) {
a2aEnvelope = parseLastA2aMessageSendCapture(a2aCaptures);
}
// Update the session contextId from the response so the NEXT step
// forwards it. Only update when the response Task carried a non-empty
// string — preserves the existing contextId on error paths or when
// the seller omitted the field.
if (captureA2a && a2aEnvelope) {
const responseTask = a2aEnvelope.result;
if (responseTask != null && typeof responseTask === 'object' && !Array.isArray(responseTask)) {
const responseCtxId = (responseTask as Record<string, unknown>).contextId;
if (typeof responseCtxId === 'string' && responseCtxId.length > 0) {
runState.lastA2aContextId = responseCtxId;
}
}
}
if (taskResult) {
responseRecord = {
transport: options.protocol === 'a2a' ? 'a2a' : 'mcp',
Expand Down Expand Up @@ -1658,6 +1688,7 @@ async function executeStep(
...(responseRecord && { response: responseRecord }),
storyboardContext: context,
...(a2aEnvelope && { a2aEnvelope }),
...(outboundA2aContextId && { outboundA2aContextId }),
};
validations = runValidations(resolvedValidations, vctx);
}
Expand Down
10 changes: 8 additions & 2 deletions src/lib/testing/storyboard/task-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,19 @@ export async function executeStoryboardTask(
client: any,
taskName: string,
params: Record<string, unknown>,
opts: { skipIdempotencyAutoInject?: boolean } = {}
opts: { skipIdempotencyAutoInject?: boolean; contextId?: string } = {}
): Promise<TaskResult> {
const methodName = Object.hasOwn(TASK_TO_METHOD, taskName) ? TASK_TO_METHOD[taskName] : undefined;

// Only pass TaskOptions when a flag is actually set — avoids changing
// behavior for the common path that relies on method defaults.
const taskOptions = opts.skipIdempotencyAutoInject ? { skipIdempotencyAutoInject: true } : undefined;
const taskOptions =
opts.skipIdempotencyAutoInject || opts.contextId
? {
...(opts.skipIdempotencyAutoInject && { skipIdempotencyAutoInject: true }),
...(opts.contextId && { contextId: opts.contextId }),
}
: undefined;

let result;
const invoke = async () => {
Expand Down
1 change: 1 addition & 0 deletions src/lib/testing/storyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export type StoryboardValidationCheck =
| 'any_of'
// A2A wire-shape checks (transport-specific; skipped on non-A2A runs)
| 'a2a_submitted_artifact'
| 'a2a_context_continuity'
// Cross-step checks
| 'refs_resolve';

Expand Down
133 changes: 133 additions & 0 deletions src/lib/testing/storyboard/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export interface ValidationContext {
* those checks self-skip with `not_applicable`.
*/
a2aEnvelope?: A2ATaskEnvelope;
/**
* The `contextId` that was forwarded on the outbound A2A `message/send`
* for this step — i.e., the value the runner passed as
* `TaskOptions.contextId`, which rode on `params.message.contextId` on
* the wire. Populated only when a prior A2A step returned a contextId
* that the runner carried forward. `a2a_context_continuity` uses this
* to confirm the seller echoed the buyer-supplied id rather than
* stamping a new one.
*
* Absent on the first A2A step (no prior contextId to forward), on MCP
* runs, and on runs where the prior step's envelope carried no contextId.
*/
outboundA2aContextId?: string;
}

/**
Expand Down Expand Up @@ -114,6 +127,8 @@ function runValidation(validation: StoryboardValidation, ctx: ValidationContext)
return validateAnyOf(validation, ctx.contributions);
case 'a2a_submitted_artifact':
return validateA2ASubmittedArtifact(validation, ctx);
case 'a2a_context_continuity':
return validateA2AContextContinuity(validation, ctx);
case 'refs_resolve':
return validateRefsResolve(validation, ctx);
default:
Expand Down Expand Up @@ -1528,6 +1543,124 @@ function validateA2ASubmittedArtifact(validation: StoryboardValidation, ctx: Val
};
}

// ────────────────────────────────────────────────────────────
// a2a_context_continuity (multi-step A2A session guard)
// ────────────────────────────────────────────────────────────

/**
* Assert that the seller echoes the buyer-supplied `contextId` on every
* follow-up `message/send` — confirming A2A session continuity per
* A2A 0.3.0 §7.1.
*
* A2A 0.3.0 §7.1 lets buyers bind follow-up sends to an existing server-
* side context by setting `params.message.contextId`; the server MUST
* echo the same value on the response Task. The `@a2a-js/sdk`
* `DefaultRequestHandler` does this automatically (via
* `createA2AAdapter`'s `requestContext.contextId` forwarding), so SDK-
* based sellers pass transparently. Sellers that bypass the SDK and stamp
* their own contextId only fail on multi-step storyboards — this check
* is that multi-step gate.
*
* The check runs at step N+1: the runner captured contextId from step N's
* response, forwarded it as `TaskOptions.contextId` on the step N+1
* dispatch (which rides on `params.message.contextId` on the wire), and
* now verifies the response Task echoes the same value.
*
* Self-skips with `not_applicable` when:
* - No `outboundA2aContextId` — first A2A step, non-A2A run, or prior
* step had no contextId to forward.
* - No `a2aEnvelope` — capture didn't fire (raw probe path, SDK error).
*
* Hard-fails when:
* - JSON-RPC error envelope (can't verify contextId; distinct error_code
* so dashboards separate transport errors from continuity breaks).
* - Response Task.contextId ≠ outbound contextId (seller stamped new id).
* - Response Task.contextId absent/empty on follow-up (continuity break).
*/
function validateA2AContextContinuity(validation: StoryboardValidation, ctx: ValidationContext): ValidationResult {
// Skip when there is no prior contextId to forward (first step or non-A2A).
if (!ctx.outboundA2aContextId) {
return {
check: 'a2a_context_continuity',
passed: true,
description: validation.description,
observations: [
'no_prior_a2a_context_id: no contextId was forwarded on this send (first A2A step, non-A2A run, or prior step carried no contextId)',
],
};
}

const envelope = ctx.a2aEnvelope;
if (!envelope) {
return {
check: 'a2a_context_continuity',
passed: true,
description: validation.description,
observations: [
'a2a_envelope_not_captured: no JSON-RPC envelope recorded (non-A2A transport, or A2A dispatch threw before envelope was parsed)',
],
};
}

// JSON-RPC error envelopes carry no Task, so there is no contextId to
// check. Skip with not_applicable rather than hard-failing — the seller
// may have rejected the request for reasons entirely unrelated to context
// binding (auth, rate-limit, schema error, business logic), and flagging
// that as a "continuity break" would produce false positives. Contrast
// with a2a_submitted_artifact, which hard-fails error envelopes because
// the submitted-arm shape check is specifically about the Task payload;
// here the invariant is only meaningful when a Task was returned.
if (envelope.envelope.error !== undefined) {
return {
check: 'a2a_context_continuity',
passed: true,
description: validation.description,
observations: [
`a2a_jsonrpc_error_envelope: server returned a JSON-RPC error; contextId continuity check skipped (no Task to verify)`,
],
};
}

const result = envelope.result;
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
return {
check: 'a2a_context_continuity',
passed: false,
description: validation.description,
error: 'JSON-RPC `result` is not an object — A2A `message/send` must return a Task.',
json_pointer: '/result',
expected: 'object (A2A Task)',
actual: result,
};
}
const task = result as Record<string, unknown>;
const responseContextId =
typeof task.contextId === 'string' && task.contextId.length > 0 ? task.contextId : undefined;
const outbound = ctx.outboundA2aContextId;

if (responseContextId === outbound) {
return {
check: 'a2a_context_continuity',
passed: true,
description: validation.description,
};
}

const detail = responseContextId
? `A2A Task.contextId '${responseContextId}' does not match the forwarded contextId '${outbound}' — seller must echo the buyer-supplied contextId per A2A 0.3.0 §7.1.`
: `A2A Task.contextId was absent or empty on a follow-up send — seller must echo the buyer-supplied contextId '${outbound}' per A2A 0.3.0 §7.1.`;

return {
check: 'a2a_context_continuity',
passed: false,
description: validation.description,
error: detail,
json_pointer: '/result/contextId',
expected: outbound,
actual: task.contextId ?? null,
};
}

// ────────────────────────────────────────────────────────────
// refs_resolve (cross-step integrity check)
// ────────────────────────────────────────────────────────────
Expand Down
Loading
Loading