@adcp/sdk 7.11.6 → 7.11.7

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.
@@ -866,328 +866,334 @@ async function complyImpl(agentUrl, options) {
866
866
  }
867
867
  // Fail fast on malformed test kits before we spin up any agent connection.
868
868
  (0, test_kit_1.validateTestKit)(testOptions.test_kit);
869
- // Build a combined AbortSignal from timeout_ms and/or external signal
870
- const needsAbort = timeout_ms !== undefined || externalSignal !== undefined;
871
- const abortController = needsAbort ? new AbortController() : undefined;
872
- let timeoutId;
873
- const onExternalAbort = externalSignal ? () => abortController.abort(externalSignal.reason) : undefined;
874
- if (timeout_ms !== undefined && abortController) {
875
- timeoutId = setTimeout(() => abortController.abort(new Error(`comply() timed out after ${timeout_ms}ms`)), timeout_ms);
869
+ // `signal` is hard external cancellation. `timeout_ms` is a soft comply()
870
+ // scheduling budget: once exceeded, stop starting new storyboards, but do
871
+ // not abort discovery or the storyboard currently in flight.
872
+ const signal = externalSignal;
873
+ const complianceIndex = (0, compliance_1.loadComplianceIndex)();
874
+ let effectiveOptions = (0, runner_1.applyAdcpVersionRunOptions)(complianceIndex.adcp_version, {
875
+ ...testOptions,
876
+ sandbox: testOptions.sandbox !== false,
877
+ test_session_id: testOptions.test_session_id || `comply-${Date.now()}`,
878
+ });
879
+ // Check for abort before starting
880
+ signal?.throwIfAborted();
881
+ // Collect observations across all tracks
882
+ const allObservations = [];
883
+ // Discover agent capabilities once and share across all storyboards.
884
+ // External cancellation still aborts discovery; timeout_ms is enforced
885
+ // later as a soft storyboard-start budget.
886
+ const discoveryOptions = testOptions.versionEnvelope === undefined
887
+ ? { ...effectiveOptions, versionEnvelope: 'major-only' }
888
+ : effectiveOptions;
889
+ const discoveryClient = (0, client_1.createTestClient)(agentUrl, effectiveOptions.protocol ?? 'mcp', discoveryOptions);
890
+ const { profile, step: profileStep } = await (0, client_1.discoverAgentProfile)(discoveryClient, signal);
891
+ effectiveOptions = applyNegotiatedComplianceVersionOptions(profile, effectiveOptions, {
892
+ complianceVersion: complianceIndex.adcp_version,
893
+ ...(testOptions.adcpVersion !== undefined && { callerAdcpVersion: testOptions.adcpVersion }),
894
+ ...(testOptions.versionEnvelope !== undefined && { callerVersionEnvelope: testOptions.versionEnvelope }),
895
+ });
896
+ const client = discoveryOptions === effectiveOptions
897
+ ? discoveryClient
898
+ : (0, client_1.createTestClient)(agentUrl, effectiveOptions.protocol ?? 'mcp', effectiveOptions);
899
+ effectiveOptions._client = client;
900
+ effectiveOptions._profile = profile;
901
+ // Log discovered tools
902
+ if (profileStep.passed) {
903
+ allObservations.push({
904
+ category: 'tool_discovery',
905
+ severity: 'info',
906
+ message: `Discovered ${profile.tools.length} tools: [${profile.tools.join(', ')}]`,
907
+ evidence: { tools: profile.tools },
908
+ source: { kind: 'profile', code: 'tools-discovered' },
909
+ });
876
910
  }
877
- if (externalSignal && abortController) {
878
- if (externalSignal.aborted) {
879
- abortController.abort(externalSignal.reason);
911
+ // Warn loudly when the runner can't make a capability-driven decision — either
912
+ // the agent doesn't advertise get_adcp_capabilities at all, or the call failed.
913
+ // Without this, an agent that just passes universal storyboards looks "compliant"
914
+ // when in fact none of its declared domains or specialisms were tested.
915
+ if (profileStep.passed && !explicitStoryboards?.length) {
916
+ if (profile.capabilities_probe_error) {
917
+ // The probe error text is agent-controlled. Fence it so downstream
918
+ // LLM summarizers of a shared ComplianceResult don't follow any
919
+ // instructions a hostile agent may have embedded. Raw text is kept
920
+ // in `evidence` for operator diagnosis — `evidence` is operator-only
921
+ // and MUST NOT be fed into an LLM summarizer.
922
+ allObservations.push({
923
+ category: 'tool_discovery',
924
+ severity: 'error',
925
+ message: `get_adcp_capabilities is advertised but the call failed. ` +
926
+ `Only universal storyboards ran — domain and specialism bundles were skipped. ` +
927
+ `Agent-reported error: ${fenceAgentText(profile.capabilities_probe_error)}`,
928
+ evidence: { agent_reported_error: profile.capabilities_probe_error },
929
+ source: { kind: 'profile', code: 'capabilities-probe-failed' },
930
+ });
880
931
  }
881
- else {
882
- externalSignal.addEventListener('abort', onExternalAbort, { once: true });
932
+ else if (!profile.tools.includes('get_adcp_capabilities')) {
933
+ allObservations.push({
934
+ category: 'tool_discovery',
935
+ severity: 'warning',
936
+ message: 'Agent does not implement get_adcp_capabilities — ran universal storyboards only. ' +
937
+ 'Domain baselines and specialisms cannot be tested without a capabilities response.',
938
+ source: { kind: 'profile', code: 'capabilities-missing' },
939
+ });
883
940
  }
884
- }
885
- const signal = abortController?.signal;
886
- try {
887
- const complianceIndex = (0, compliance_1.loadComplianceIndex)();
888
- let effectiveOptions = (0, runner_1.applyAdcpVersionRunOptions)(complianceIndex.adcp_version, {
889
- ...testOptions,
890
- sandbox: testOptions.sandbox !== false,
891
- test_session_id: testOptions.test_session_id || `comply-${Date.now()}`,
892
- });
893
- // Check for abort before starting
894
- signal?.throwIfAborted();
895
- // Collect observations across all tracks
896
- const allObservations = [];
897
- // Discover agent capabilities once and share across all storyboards.
898
- // Pass the combined signal so a slow/unresponsive agent can't hold the
899
- // comply pipeline past its own timeout (adcp-client#1612).
900
- const discoveryOptions = testOptions.versionEnvelope === undefined
901
- ? { ...effectiveOptions, versionEnvelope: 'major-only' }
902
- : effectiveOptions;
903
- const discoveryClient = (0, client_1.createTestClient)(agentUrl, effectiveOptions.protocol ?? 'mcp', discoveryOptions);
904
- const { profile, step: profileStep } = await (0, client_1.discoverAgentProfile)(discoveryClient, signal);
905
- effectiveOptions = applyNegotiatedComplianceVersionOptions(profile, effectiveOptions, {
906
- complianceVersion: complianceIndex.adcp_version,
907
- ...(testOptions.adcpVersion !== undefined && { callerAdcpVersion: testOptions.adcpVersion }),
908
- ...(testOptions.versionEnvelope !== undefined && { callerVersionEnvelope: testOptions.versionEnvelope }),
909
- });
910
- const client = discoveryOptions === effectiveOptions
911
- ? discoveryClient
912
- : (0, client_1.createTestClient)(agentUrl, effectiveOptions.protocol ?? 'mcp', effectiveOptions);
913
- effectiveOptions._client = client;
914
- effectiveOptions._profile = profile;
915
- // Log discovered tools
916
- if (profileStep.passed) {
941
+ else if (!profile.supported_protocols?.length) {
917
942
  allObservations.push({
918
943
  category: 'tool_discovery',
919
- severity: 'info',
920
- message: `Discovered ${profile.tools.length} tools: [${profile.tools.join(', ')}]`,
921
- evidence: { tools: profile.tools },
922
- source: { kind: 'profile', code: 'tools-discovered' },
944
+ severity: 'warning',
945
+ message: 'get_adcp_capabilities returned no supported_protocols — ran universal storyboards only. ' +
946
+ 'Agent must declare at least one domain protocol to be fully tested.',
947
+ source: { kind: 'profile', code: 'no-supported-protocols' },
923
948
  });
924
949
  }
925
- // Warn loudly when the runner can't make a capability-driven decision — either
926
- // the agent doesn't advertise get_adcp_capabilities at all, or the call failed.
927
- // Without this, an agent that just passes universal storyboards looks "compliant"
928
- // when in fact none of its declared domains or specialisms were tested.
929
- if (profileStep.passed && !explicitStoryboards?.length) {
930
- if (profile.capabilities_probe_error) {
931
- // The probe error text is agent-controlled. Fence it so downstream
932
- // LLM summarizers of a shared ComplianceResult don't follow any
933
- // instructions a hostile agent may have embedded. Raw text is kept
934
- // in `evidence` for operator diagnosis — `evidence` is operator-only
935
- // and MUST NOT be fed into an LLM summarizer.
936
- allObservations.push({
937
- category: 'tool_discovery',
938
- severity: 'error',
939
- message: `get_adcp_capabilities is advertised but the call failed. ` +
940
- `Only universal storyboards ran domain and specialism bundles were skipped. ` +
941
- `Agent-reported error: ${fenceAgentText(profile.capabilities_probe_error)}`,
942
- evidence: { agent_reported_error: profile.capabilities_probe_error },
943
- source: { kind: 'profile', code: 'capabilities-probe-failed' },
944
- });
945
- }
946
- else if (!profile.tools.includes('get_adcp_capabilities')) {
947
- allObservations.push({
948
- category: 'tool_discovery',
949
- severity: 'warning',
950
- message: 'Agent does not implement get_adcp_capabilities ran universal storyboards only. ' +
951
- 'Domain baselines and specialisms cannot be tested without a capabilities response.',
952
- source: { kind: 'profile', code: 'capabilities-missing' },
953
- });
950
+ }
951
+ // Detect test controller for deterministic mode
952
+ let controllerDetection = { detected: false };
953
+ if (profileStep.passed && (0, test_controller_1.hasTestController)(profile)) {
954
+ controllerDetection = await (0, test_controller_1.detectController)(client, profile, effectiveOptions);
955
+ if (controllerDetection.detected) {
956
+ effectiveOptions._controllerCapabilities = controllerDetection;
957
+ }
958
+ }
959
+ if (!profileStep.passed) {
960
+ // Capability discovery failed. If it's an auth rejection, we can still
961
+ // run storyboards that don't need tool discovery — crucially
962
+ // universal/security_baseline, which is designed precisely to diagnose
963
+ // agents that mishandle auth. Fall back to the unreachable result only
964
+ // when no such storyboards are available.
965
+ const authCheck = await detectAuthRejection(agentUrl, profileStep.error, signal);
966
+ if (authCheck.isAuth) {
967
+ const degraded = { name: profile.name || 'Unknown (auth required)', tools: [] };
968
+ const candidate = explicitStoryboards?.length
969
+ ? resolveExplicitStoryboards(explicitStoryboards)
970
+ : resolveFromCapabilities(degraded).storyboards;
971
+ const runnable = candidate.filter(sb => (sb.required_tools?.length ?? 0) === 0 || sb.track === 'security');
972
+ if (runnable.length > 0) {
973
+ allObservations.push(...authCheck.observations);
974
+ effectiveOptions._profile = degraded;
975
+ // Skip the rest of the "reachable" setup no test controller, no
976
+ // capability-warning observations and jump straight to storyboard
977
+ // execution below with the filtered, runnable subset.
978
+ return await runWithDegradedProfile(agentUrl, degraded, runnable, options, effectiveOptions, allObservations, start, signal);
954
979
  }
955
- else if (!profile.supported_protocols?.length) {
956
- allObservations.push({
957
- category: 'tool_discovery',
958
- severity: 'warning',
959
- message: 'get_adcp_capabilities returned no supported_protocols — ran universal storyboards only. ' +
960
- 'Agent must declare at least one domain protocol to be fully tested.',
961
- source: { kind: 'profile', code: 'no-supported-protocols' },
980
+ }
981
+ return buildUnreachableResult(agentUrl, profile, profileStep.error, start, effectiveOptions, signal);
982
+ }
983
+ // Resolve storyboards: explicit IDs override capability-driven selection.
984
+ let initialStoryboards;
985
+ let notApplicable = [];
986
+ let missingToolStoryboards = [];
987
+ if (explicitStoryboards?.length) {
988
+ initialStoryboards = resolveExplicitStoryboards(explicitStoryboards);
989
+ }
990
+ else {
991
+ const resolved = resolveFromCapabilities(profile);
992
+ initialStoryboards = resolved.storyboards;
993
+ notApplicable = resolved.not_applicable;
994
+ }
995
+ const applicableStoryboards = expandScenarios(initialStoryboards);
996
+ // For capability-resolved runs, exclude storyboards and injected scenarios whose
997
+ // required_tools are absent from the agent's discovered toolset. These are
998
+ // not-applicable — the agent doesn't claim the specialism being tested. Running
999
+ // them produces cascading skips that pull the track to `partial`, which is a false
1000
+ // signal for AAO badge grading (adcp-client#1680).
1001
+ // Explicit storyboard IDs (options.storyboards) bypass this filter — they are an
1002
+ // operator override and should run regardless of required_tools.
1003
+ let runnableStoryboards;
1004
+ if (explicitStoryboards?.length) {
1005
+ runnableStoryboards = applicableStoryboards;
1006
+ }
1007
+ else {
1008
+ const discoveredToolNames = new Set(profile.tools);
1009
+ const filtered = [];
1010
+ for (const sb of applicableStoryboards) {
1011
+ const missing = (sb.required_tools ?? []).filter(t => !discoveredToolNames.has(t));
1012
+ if (missing.length > 0) {
1013
+ missingToolStoryboards.push({
1014
+ storyboard_id: sb.id,
1015
+ storyboard_title: sb.title,
1016
+ track: sb.track,
1017
+ reason: `missing required_tools: ${missing.join(', ')}`,
962
1018
  });
963
1019
  }
964
- }
965
- // Detect test controller for deterministic mode
966
- let controllerDetection = { detected: false };
967
- if (profileStep.passed && (0, test_controller_1.hasTestController)(profile)) {
968
- controllerDetection = await (0, test_controller_1.detectController)(client, profile, effectiveOptions);
969
- if (controllerDetection.detected) {
970
- effectiveOptions._controllerCapabilities = controllerDetection;
1020
+ else {
1021
+ filtered.push(sb);
971
1022
  }
972
1023
  }
973
- if (!profileStep.passed) {
974
- // Capability discovery failed. If it's an auth rejection, we can still
975
- // run storyboards that don't need tool discovery — crucially
976
- // universal/security_baseline, which is designed precisely to diagnose
977
- // agents that mishandle auth. Fall back to the unreachable result only
978
- // when no such storyboards are available.
979
- const authCheck = await detectAuthRejection(agentUrl, profileStep.error, signal);
980
- if (authCheck.isAuth) {
981
- const degraded = { name: profile.name || 'Unknown (auth required)', tools: [] };
982
- const candidate = explicitStoryboards?.length
983
- ? resolveExplicitStoryboards(explicitStoryboards)
984
- : resolveFromCapabilities(degraded).storyboards;
985
- const runnable = candidate.filter(sb => (sb.required_tools?.length ?? 0) === 0 || sb.track === 'security');
986
- if (runnable.length > 0) {
987
- allObservations.push(...authCheck.observations);
988
- effectiveOptions._profile = degraded;
989
- // Skip the rest of the "reachable" setup — no test controller, no
990
- // capability-warning observations — and jump straight to storyboard
991
- // execution below with the filtered, runnable subset.
992
- return await runWithDegradedProfile(agentUrl, degraded, runnable, options, effectiveOptions, allObservations, start, signal);
993
- }
994
- }
995
- return buildUnreachableResult(agentUrl, profile, profileStep.error, start, effectiveOptions, signal);
1024
+ runnableStoryboards = filtered;
1025
+ }
1026
+ // Run storyboards
1027
+ const storyboardResults = [];
1028
+ const executedStoryboards = [];
1029
+ const runOptions = {
1030
+ ...effectiveOptions,
1031
+ agentTools: profile.tools,
1032
+ ...(webhook_receiver !== undefined && { webhook_receiver }),
1033
+ ...(contracts !== undefined && { contracts }),
1034
+ ...(signal !== undefined && { signal }),
1035
+ };
1036
+ let stoppedForTimeoutBudget = false;
1037
+ for (const sb of runnableStoryboards) {
1038
+ signal?.throwIfAborted();
1039
+ if (hasComplyTimeoutBudgetExpired(start, timeout_ms)) {
1040
+ stoppedForTimeoutBudget = true;
1041
+ break;
996
1042
  }
997
- // Resolve storyboards: explicit IDs override capability-driven selection.
998
- let initialStoryboards;
999
- let notApplicable = [];
1000
- let missingToolStoryboards = [];
1001
- if (explicitStoryboards?.length) {
1002
- initialStoryboards = resolveExplicitStoryboards(explicitStoryboards);
1043
+ const result = await (0, runner_1.runStoryboard)(agentUrl, sb, runOptions);
1044
+ storyboardResults.push(result);
1045
+ executedStoryboards.push(sb);
1046
+ }
1047
+ if (stoppedForTimeoutBudget) {
1048
+ allObservations.push(buildComplyTimeoutBudgetObservation(timeout_ms, storyboardResults.length, runnableStoryboards.length));
1049
+ }
1050
+ // Surface storyboards the agent's declared major version predates as a
1051
+ // distinct skip row. Not running them is correct (they didn't exist at
1052
+ // the spec the agent certified against), but hiding them risks silent
1053
+ // green builds against agents that haven't bumped their declared
1054
+ // major_versions.
1055
+ for (const na of [...notApplicable, ...missingToolStoryboards]) {
1056
+ storyboardResults.push(buildNotApplicableStoryboardResult(agentUrl, na));
1057
+ }
1058
+ // Cross-storyboard spec-conformance gates. Push synthetic StoryboardResults
1059
+ // for protocol-level invariants the AdCP spec mandates regardless of which
1060
+ // specialism is being tested. Currently wires the universal account-discovery
1061
+ // gate (adcp-client#1624 / adcp#4302; AdCP 3.0.9 §accounts/overview).
1062
+ // Will migrate to per-storyboard `required_any_of_tools` tags once
1063
+ // adcp#4325 lands; tracked in #1642.
1064
+ const accountDiscoveryFailure = (0, spec_conformance_1.checkAccountDiscoveryGate)(profile, agentUrl);
1065
+ if (accountDiscoveryFailure) {
1066
+ storyboardResults.push(accountDiscoveryFailure);
1067
+ }
1068
+ // Group results by track and build TrackResults
1069
+ const grouped = groupByTrack(storyboardResults, runnableStoryboards, [...notApplicable, ...missingToolStoryboards]);
1070
+ const trackResults = [];
1071
+ // Tracks represented by the selected storyboards (used for deciding which rows to emit).
1072
+ // Includes not-applicable entries so a version-gated track still gets a row.
1073
+ const poolTrackSet = new Set();
1074
+ for (const sb of runnableStoryboards) {
1075
+ if (sb.track)
1076
+ poolTrackSet.add(sb.track);
1077
+ }
1078
+ // Synthetic spec-conformance gates always land in `core`; ensure `core`
1079
+ // is in the pool so its track row renders even when the run targeted a
1080
+ // non-core specialism bundle that excluded universal storyboards.
1081
+ if (accountDiscoveryFailure)
1082
+ poolTrackSet.add('core');
1083
+ for (const na of [...notApplicable, ...missingToolStoryboards]) {
1084
+ if (na.track)
1085
+ poolTrackSet.add(na.track);
1086
+ }
1087
+ const trackFilterSet = trackFilter?.length ? new Set(trackFilter) : null;
1088
+ for (const track of TRACK_ORDER) {
1089
+ if (!poolTrackSet.has(track))
1090
+ continue;
1091
+ if (trackFilterSet && !trackFilterSet.has(track))
1092
+ continue;
1093
+ const results = grouped.get(track) ?? [];
1094
+ if (results.length > 0) {
1095
+ const trackResult = (0, storyboard_tracks_1.mapStoryboardResultsToTrackResult)(track, results, profile);
1096
+ const observations = collectObservations(track, trackResult.scenarios, profile);
1097
+ trackResult.observations = observations;
1098
+ allObservations.push(...observations);
1099
+ trackResults.push(trackResult);
1003
1100
  }
1004
1101
  else {
1005
- const resolved = resolveFromCapabilities(profile);
1006
- initialStoryboards = resolved.storyboards;
1007
- notApplicable = resolved.not_applicable;
1008
- }
1009
- const applicableStoryboards = expandScenarios(initialStoryboards);
1010
- // For capability-resolved runs, exclude storyboards and injected scenarios whose
1011
- // required_tools are absent from the agent's discovered toolset. These are
1012
- // not-applicable — the agent doesn't claim the specialism being tested. Running
1013
- // them produces cascading skips that pull the track to `partial`, which is a false
1014
- // signal for AAO badge grading (adcp-client#1680).
1015
- // Explicit storyboard IDs (options.storyboards) bypass this filter — they are an
1016
- // operator override and should run regardless of required_tools.
1017
- let runnableStoryboards;
1018
- if (explicitStoryboards?.length) {
1019
- runnableStoryboards = applicableStoryboards;
1102
+ trackResults.push({
1103
+ track,
1104
+ status: 'skip',
1105
+ label: storyboard_tracks_1.TRACK_LABELS[track] || track,
1106
+ scenarios: [],
1107
+ skipped_scenarios: [],
1108
+ observations: [],
1109
+ duration_ms: 0,
1110
+ });
1020
1111
  }
1021
- else {
1022
- const discoveredToolNames = new Set(profile.tools);
1023
- const filtered = [];
1024
- for (const sb of applicableStoryboards) {
1025
- const missing = (sb.required_tools ?? []).filter(t => !discoveredToolNames.has(t));
1026
- if (missing.length > 0) {
1027
- missingToolStoryboards.push({
1028
- storyboard_id: sb.id,
1029
- storyboard_title: sb.title,
1030
- track: sb.track,
1031
- reason: `missing required_tools: ${missing.join(', ')}`,
1032
- });
1033
- }
1034
- else {
1035
- filtered.push(sb);
1112
+ }
1113
+ const summary = buildSummary(trackResults, storyboardResults);
1114
+ // Tag `_view` so grep-style triage can distinguish the canonical
1115
+ // `tracks` entry from its appearance under the `tested_tracks` filter
1116
+ // (adcp-client#1674). Shallow-copy on the `tested_tracks` side keeps
1117
+ // the shared nested `scenarios` references intact while preventing
1118
+ // the marker from colliding on the same object.
1119
+ for (const t of trackResults)
1120
+ t._view = 'canonical';
1121
+ const testedTracks = trackResults
1122
+ .filter(t => t.status === 'pass' || t.status === 'fail' || t.status === 'partial' || t.status === 'silent')
1123
+ .map(t => ({ ...t, _view: 'reference' }));
1124
+ const skippedTracks = trackResults
1125
+ .filter(t => t.status === 'skip')
1126
+ .map(t => ({
1127
+ track: t.track,
1128
+ label: t.label,
1129
+ reason: 'No storyboards produced results for this track',
1130
+ }));
1131
+ const overallStatus = stoppedForTimeoutBudget ? 'partial' : computeOverallStatus(summary);
1132
+ const agentRef = options.agent_alias || agentUrl;
1133
+ const failures = extractFailures(storyboardResults, runnableStoryboards, agentRef);
1134
+ // Aggregate notices from all storyboard runs. Dedup is by `code` (each
1135
+ // notice type appears once in the rollup), but the per-occurrence
1136
+ // `storyboard_ids` arrays are merged so auditors can see how widespread
1137
+ // a deprecation or future-required signal is without re-walking the
1138
+ // per-storyboard arrays. Order is stable: first occurrence wins for
1139
+ // the notice body; storyboard_ids preserves insertion order across the
1140
+ // run's storyboard execution order.
1141
+ const aggregatedNotices = new Map();
1142
+ for (const sbResult of storyboardResults) {
1143
+ for (const notice of sbResult.notices) {
1144
+ const existing = aggregatedNotices.get(notice.code);
1145
+ if (existing) {
1146
+ for (const sid of notice.storyboard_ids) {
1147
+ if (!existing.storyboard_ids.includes(sid))
1148
+ existing.storyboard_ids.push(sid);
1036
1149
  }
1037
1150
  }
1038
- runnableStoryboards = filtered;
1039
- }
1040
- // Run storyboards
1041
- const storyboardResults = [];
1042
- const runOptions = {
1043
- ...effectiveOptions,
1044
- agentTools: profile.tools,
1045
- ...(webhook_receiver !== undefined && { webhook_receiver }),
1046
- ...(contracts !== undefined && { contracts }),
1047
- ...(signal !== undefined && { signal }),
1048
- };
1049
- for (const sb of runnableStoryboards) {
1050
- signal?.throwIfAborted();
1051
- const result = await (0, runner_1.runStoryboard)(agentUrl, sb, runOptions);
1052
- storyboardResults.push(result);
1053
- }
1054
- // Surface storyboards the agent's declared major version predates as a
1055
- // distinct skip row. Not running them is correct (they didn't exist at
1056
- // the spec the agent certified against), but hiding them risks silent
1057
- // green builds against agents that haven't bumped their declared
1058
- // major_versions.
1059
- for (const na of [...notApplicable, ...missingToolStoryboards]) {
1060
- storyboardResults.push(buildNotApplicableStoryboardResult(agentUrl, na));
1061
- }
1062
- // Cross-storyboard spec-conformance gates. Push synthetic StoryboardResults
1063
- // for protocol-level invariants the AdCP spec mandates regardless of which
1064
- // specialism is being tested. Currently wires the universal account-discovery
1065
- // gate (adcp-client#1624 / adcp#4302; AdCP 3.0.9 §accounts/overview).
1066
- // Will migrate to per-storyboard `required_any_of_tools` tags once
1067
- // adcp#4325 lands; tracked in #1642.
1068
- const accountDiscoveryFailure = (0, spec_conformance_1.checkAccountDiscoveryGate)(profile, agentUrl);
1069
- if (accountDiscoveryFailure) {
1070
- storyboardResults.push(accountDiscoveryFailure);
1071
- }
1072
- // Group results by track and build TrackResults
1073
- const grouped = groupByTrack(storyboardResults, runnableStoryboards, [...notApplicable, ...missingToolStoryboards]);
1074
- const trackResults = [];
1075
- // Tracks represented by the selected storyboards (used for deciding which rows to emit).
1076
- // Includes not-applicable entries so a version-gated track still gets a row.
1077
- const poolTrackSet = new Set();
1078
- for (const sb of runnableStoryboards) {
1079
- if (sb.track)
1080
- poolTrackSet.add(sb.track);
1081
- }
1082
- // Synthetic spec-conformance gates always land in `core`; ensure `core`
1083
- // is in the pool so its track row renders even when the run targeted a
1084
- // non-core specialism bundle that excluded universal storyboards.
1085
- if (accountDiscoveryFailure)
1086
- poolTrackSet.add('core');
1087
- for (const na of [...notApplicable, ...missingToolStoryboards]) {
1088
- if (na.track)
1089
- poolTrackSet.add(na.track);
1090
- }
1091
- const trackFilterSet = trackFilter?.length ? new Set(trackFilter) : null;
1092
- for (const track of TRACK_ORDER) {
1093
- if (!poolTrackSet.has(track))
1094
- continue;
1095
- if (trackFilterSet && !trackFilterSet.has(track))
1096
- continue;
1097
- const results = grouped.get(track) ?? [];
1098
- if (results.length > 0) {
1099
- const trackResult = (0, storyboard_tracks_1.mapStoryboardResultsToTrackResult)(track, results, profile);
1100
- const observations = collectObservations(track, trackResult.scenarios, profile);
1101
- trackResult.observations = observations;
1102
- allObservations.push(...observations);
1103
- trackResults.push(trackResult);
1104
- }
1105
1151
  else {
1106
- trackResults.push({
1107
- track,
1108
- status: 'skip',
1109
- label: storyboard_tracks_1.TRACK_LABELS[track] || track,
1110
- scenarios: [],
1111
- skipped_scenarios: [],
1112
- observations: [],
1113
- duration_ms: 0,
1114
- });
1152
+ // Clone so subsequent merges don't mutate the per-storyboard array.
1153
+ aggregatedNotices.set(notice.code, { ...notice, storyboard_ids: [...notice.storyboard_ids] });
1115
1154
  }
1116
1155
  }
1117
- const summary = buildSummary(trackResults, storyboardResults);
1118
- // Tag `_view` so grep-style triage can distinguish the canonical
1119
- // `tracks` entry from its appearance under the `tested_tracks` filter
1120
- // (adcp-client#1674). Shallow-copy on the `tested_tracks` side keeps
1121
- // the shared nested `scenarios` references intact while preventing
1122
- // the marker from colliding on the same object.
1123
- for (const t of trackResults)
1124
- t._view = 'canonical';
1125
- const testedTracks = trackResults
1126
- .filter(t => t.status === 'pass' || t.status === 'fail' || t.status === 'partial' || t.status === 'silent')
1127
- .map(t => ({ ...t, _view: 'reference' }));
1128
- const skippedTracks = trackResults
1129
- .filter(t => t.status === 'skip')
1130
- .map(t => ({
1131
- track: t.track,
1132
- label: t.label,
1133
- reason: 'No storyboards produced results for this track',
1134
- }));
1135
- const overallStatus = computeOverallStatus(summary);
1136
- const agentRef = options.agent_alias || agentUrl;
1137
- const failures = extractFailures(storyboardResults, runnableStoryboards, agentRef);
1138
- // Aggregate notices from all storyboard runs. Dedup is by `code` (each
1139
- // notice type appears once in the rollup), but the per-occurrence
1140
- // `storyboard_ids` arrays are merged so auditors can see how widespread
1141
- // a deprecation or future-required signal is without re-walking the
1142
- // per-storyboard arrays. Order is stable: first occurrence wins for
1143
- // the notice body; storyboard_ids preserves insertion order across the
1144
- // run's storyboard execution order.
1145
- const aggregatedNotices = new Map();
1146
- for (const sbResult of storyboardResults) {
1147
- for (const notice of sbResult.notices) {
1148
- const existing = aggregatedNotices.get(notice.code);
1149
- if (existing) {
1150
- for (const sid of notice.storyboard_ids) {
1151
- if (!existing.storyboard_ids.includes(sid))
1152
- existing.storyboard_ids.push(sid);
1153
- }
1154
- }
1155
- else {
1156
- // Clone so subsequent merges don't mutate the per-storyboard array.
1157
- aggregatedNotices.set(notice.code, { ...notice, storyboard_ids: [...notice.storyboard_ids] });
1158
- }
1159
- }
1160
- }
1161
- const noticesDedup = [...aggregatedNotices.values()];
1162
- return {
1163
- agent_url: agentUrl,
1164
- agent_profile: profile,
1165
- overall_status: overallStatus,
1166
- tracks: trackResults,
1167
- tested_tracks: testedTracks,
1168
- skipped_tracks: skippedTracks,
1169
- summary,
1170
- observations: allObservations,
1171
- failures: failures.length > 0 ? failures : undefined,
1172
- storyboards_executed: runnableStoryboards.map(sb => sb.id),
1173
- ...(notApplicable.length > 0 && { storyboards_not_applicable: notApplicable.map(na => na.storyboard_id) }),
1174
- ...(missingToolStoryboards.length > 0 && {
1175
- storyboards_missing_tools: missingToolStoryboards.map(na => na.storyboard_id),
1176
- }),
1177
- controller_detected: controllerDetection.detected,
1178
- controller_scenarios: controllerDetection.detected ? controllerDetection.scenarios : undefined,
1179
- tested_at: new Date().toISOString(),
1180
- total_duration_ms: Date.now() - start,
1181
- notices: noticesDedup,
1182
- };
1183
- }
1184
- finally {
1185
- if (timeoutId !== undefined)
1186
- clearTimeout(timeoutId);
1187
- if (onExternalAbort && externalSignal) {
1188
- externalSignal.removeEventListener('abort', onExternalAbort);
1189
- }
1190
1156
  }
1157
+ const noticesDedup = [...aggregatedNotices.values()];
1158
+ return {
1159
+ agent_url: agentUrl,
1160
+ agent_profile: profile,
1161
+ overall_status: overallStatus,
1162
+ tracks: trackResults,
1163
+ tested_tracks: testedTracks,
1164
+ skipped_tracks: skippedTracks,
1165
+ summary,
1166
+ observations: allObservations,
1167
+ failures: failures.length > 0 ? failures : undefined,
1168
+ storyboards_executed: executedStoryboards.map(sb => sb.id),
1169
+ ...(notApplicable.length > 0 && { storyboards_not_applicable: notApplicable.map(na => na.storyboard_id) }),
1170
+ ...(missingToolStoryboards.length > 0 && {
1171
+ storyboards_missing_tools: missingToolStoryboards.map(na => na.storyboard_id),
1172
+ }),
1173
+ controller_detected: controllerDetection.detected,
1174
+ controller_scenarios: controllerDetection.detected ? controllerDetection.scenarios : undefined,
1175
+ tested_at: new Date().toISOString(),
1176
+ total_duration_ms: Date.now() - start,
1177
+ notices: noticesDedup,
1178
+ };
1179
+ }
1180
+ function hasComplyTimeoutBudgetExpired(start, timeout_ms, now = Date.now()) {
1181
+ return timeout_ms !== undefined && now - start >= timeout_ms;
1182
+ }
1183
+ function buildComplyTimeoutBudgetObservation(timeout_ms, storyboardsExecuted, storyboardsSelected) {
1184
+ return {
1185
+ category: 'performance',
1186
+ severity: 'warning',
1187
+ message: `Compliance timeout budget of ${timeout_ms}ms was reached. ` +
1188
+ `Stopped starting new storyboards after ${storyboardsExecuted}/${storyboardsSelected} selected storyboard(s).`,
1189
+ evidence: {
1190
+ timeout_ms,
1191
+ storyboards_executed: storyboardsExecuted,
1192
+ storyboards_selected: storyboardsSelected,
1193
+ storyboards_remaining: Math.max(0, storyboardsSelected - storyboardsExecuted),
1194
+ },
1195
+ source: { kind: 'profile', code: 'timeout-budget-exceeded' },
1196
+ };
1191
1197
  }
1192
1198
  /**
1193
1199
  * Detect whether a capability-discovery failure is an auth rejection.
@@ -1302,10 +1308,20 @@ async function runWithDegradedProfile(agentUrl, profile, storyboards, options, e
1302
1308
  ...(options.contracts !== undefined && { contracts: options.contracts }),
1303
1309
  ...(signal !== undefined && { signal }),
1304
1310
  };
1311
+ let stoppedForTimeoutBudget = false;
1312
+ const executedStoryboards = [];
1305
1313
  for (const sb of storyboards) {
1306
1314
  signal?.throwIfAborted();
1315
+ if (hasComplyTimeoutBudgetExpired(start, options.timeout_ms)) {
1316
+ stoppedForTimeoutBudget = true;
1317
+ break;
1318
+ }
1307
1319
  const result = await (0, runner_1.runStoryboard)(agentUrl, sb, runOptions);
1308
1320
  storyboardResults.push(result);
1321
+ executedStoryboards.push(sb);
1322
+ }
1323
+ if (stoppedForTimeoutBudget && options.timeout_ms !== undefined) {
1324
+ allObservations.push(buildComplyTimeoutBudgetObservation(options.timeout_ms, storyboardResults.length, storyboards.length));
1309
1325
  }
1310
1326
  const grouped = groupByTrack(storyboardResults, storyboards);
1311
1327
  const trackResults = [];
@@ -1324,9 +1340,20 @@ async function runWithDegradedProfile(agentUrl, profile, storyboards, options, e
1324
1340
  allObservations.push(...obs);
1325
1341
  trackResults.push(trackResult);
1326
1342
  }
1343
+ else {
1344
+ trackResults.push({
1345
+ track,
1346
+ status: 'skip',
1347
+ label: storyboard_tracks_1.TRACK_LABELS[track] || track,
1348
+ scenarios: [],
1349
+ skipped_scenarios: [],
1350
+ observations: [],
1351
+ duration_ms: 0,
1352
+ });
1353
+ }
1327
1354
  }
1328
1355
  const summary = buildSummary(trackResults, storyboardResults);
1329
- const overallStatus = computeOverallStatus(summary);
1356
+ const overallStatus = stoppedForTimeoutBudget ? 'partial' : computeOverallStatus(summary);
1330
1357
  const agentRef = options.agent_alias || agentUrl;
1331
1358
  const failures = extractFailures(storyboardResults, storyboards, agentRef);
1332
1359
  // Tag canonical vs reference views to disambiguate the same
@@ -1334,6 +1361,13 @@ async function runWithDegradedProfile(agentUrl, profile, storyboards, options, e
1334
1361
  // (adcp-client#1674).
1335
1362
  for (const t of trackResults)
1336
1363
  t._view = 'canonical';
1364
+ const skippedTracks = trackResults
1365
+ .filter(t => t.status === 'skip')
1366
+ .map(t => ({
1367
+ track: t.track,
1368
+ label: t.label,
1369
+ reason: 'No storyboards produced results for this track',
1370
+ }));
1337
1371
  return {
1338
1372
  agent_url: agentUrl,
1339
1373
  agent_profile: profile,
@@ -1342,11 +1376,11 @@ async function runWithDegradedProfile(agentUrl, profile, storyboards, options, e
1342
1376
  tested_tracks: trackResults
1343
1377
  .filter(t => t.status === 'pass' || t.status === 'fail' || t.status === 'partial' || t.status === 'silent')
1344
1378
  .map(t => ({ ...t, _view: 'reference' })),
1345
- skipped_tracks: [],
1379
+ skipped_tracks: skippedTracks,
1346
1380
  summary,
1347
1381
  observations: allObservations,
1348
1382
  failures: failures.length > 0 ? failures : undefined,
1349
- storyboards_executed: storyboards.map(sb => sb.id),
1383
+ storyboards_executed: executedStoryboards.map(sb => sb.id),
1350
1384
  controller_detected: false,
1351
1385
  tested_at: new Date().toISOString(),
1352
1386
  total_duration_ms: Date.now() - start,