diff --git a/.changeset/evidence-backed-capability-states.md b/.changeset/evidence-backed-capability-states.md new file mode 100644 index 000000000..de6cdf927 --- /dev/null +++ b/.changeset/evidence-backed-capability-states.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Replace target adapter Boolean capability records with evidence-backed supported, degraded, unavailable, and prohibited states while retaining `TargetRegistry.supports()` as a derived compatibility view. diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts new file mode 100644 index 000000000..21ec5540f --- /dev/null +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -0,0 +1,170 @@ +import { sha256Hex, stableJson } from '../core/digest.ts'; +import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; +import type { TargetAdapterMetadata } from './types.ts'; + +/** Builds immutable evidence from a target's pinned capability-table metadata. */ +export const capabilityEvidence = ( + target: string, + metadata: TargetAdapterMetadata, +): CapabilityEvidence => Object.freeze({ + capabilityRevision: metadata.capabilityRevision, + capabilitySha256: metadata.capabilitySha256, + observedVersion: metadata.observedVersion, + target, +}); + +export const supportedCapability = (evidence: CapabilityEvidence): CapabilityState => Object.freeze({ + evidence, + state: 'supported', +}); + +export const unavailableCapability = (reason: string): CapabilityState => Object.freeze({ + reason, + state: 'unavailable', +}); + +export const capabilityStateFromSupport = ( + supported: boolean, + evidence: CapabilityEvidence, + unavailableReason: string, +): CapabilityState => supported ? supportedCapability(evidence) : unavailableCapability(unavailableReason); + +/** The temporary Boolean compatibility rule: only supported maps to true. */ +export const capabilityIsSupported = (capability: CapabilityState | undefined): boolean => { + if (capability === undefined) return false; + switch (capability.state) { + case 'supported': + return true; + case 'degraded': + case 'unavailable': + case 'prohibited': + return false; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } +}; + +/** Immutable Boolean view retained for callers that have not migrated yet. */ +export const capabilityBooleanView = ( + capabilities: Readonly>, +): Readonly> => Object.freeze(Object.fromEntries( + Object.entries(capabilities).map(([name, capability]) => [name, capabilityIsSupported(capability)]), +)); + +const evidenceFor = (capability: CapabilityState): CapabilityEvidence | undefined => { + switch (capability.state) { + case 'supported': + return capability.evidence; + case 'degraded': + return capability.evidence; + case 'unavailable': + case 'prohibited': + return undefined; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } +}; + +const precedenceFor = (capability: CapabilityState): 0 | 1 | 2 | 3 => { + switch (capability.state) { + case 'supported': + return 0; + case 'degraded': + return 1; + case 'unavailable': + return 2; + case 'prohibited': + return 3; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } +}; + +const reasonFor = (capability: CapabilityState, precedence: 1 | 2 | 3): string | undefined => { + switch (capability.state) { + case 'supported': + return undefined; + case 'degraded': + return precedence === 1 ? capability.reason : undefined; + case 'unavailable': + return precedence === 2 ? capability.reason : undefined; + case 'prohibited': + return precedence === 3 ? capability.reason : undefined; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } +}; + +const mergedReason = ( + left: CapabilityState, + right: CapabilityState, + precedence: 1 | 2 | 3, +): string => [...new Set([reasonFor(left, precedence), reasonFor(right, precedence)] + .filter((reason): reason is string => reason !== undefined))] + .sort((first, second) => first.localeCompare(second)) + .join('; '); + +/** Merges evidence without discarding either pinned table identity. */ +export const mergeCapabilityEvidence = ( + left: CapabilityEvidence, + right: CapabilityEvidence, +): CapabilityEvidence => { + const evidence = [left, right].sort((first, second) => { + const targetOrder = first.target.localeCompare(second.target); + return targetOrder === 0 ? stableJson(first).localeCompare(stableJson(second)) : targetOrder; + }); + return Object.freeze({ + capabilityRevision: evidence.map((entry) => `${entry.target}@${entry.capabilityRevision}`).join('+'), + capabilitySha256: sha256Hex(stableJson(evidence)), + observedVersion: evidence.map((entry) => `${entry.target}@${entry.observedVersion}`).join('+'), + target: evidence.map((entry) => entry.target).join('+'), + }); +}; + +/** + * Intersects two host judgments for a composite adapter. Prohibition dominates, + * then unavailability, then degradation; two supported states merge evidence. + */ +export const intersectCapabilityStates = ( + left: CapabilityState, + right: CapabilityState, +): CapabilityState => { + const leftPrecedence = precedenceFor(left); + const rightPrecedence = precedenceFor(right); + const precedence = leftPrecedence > rightPrecedence ? leftPrecedence : rightPrecedence; + switch (precedence) { + case 0: + if (left.state !== 'supported' || right.state !== 'supported') { + throw new Error('Supported capability intersection lost its evidence invariant.'); + } + return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence)); + case 1: { + const leftEvidence = evidenceFor(left); + const rightEvidence = evidenceFor(right); + const evidence = leftEvidence === undefined || rightEvidence === undefined + ? undefined + : mergeCapabilityEvidence(leftEvidence, rightEvidence); + return Object.freeze({ + ...(evidence === undefined ? {} : { evidence }), + reason: mergedReason(left, right, precedence), + state: 'degraded', + }); + } + case 2: + return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'unavailable' }); + case 3: + return Object.freeze({ reason: mergedReason(left, right, precedence), state: 'prohibited' }); + default: { + const exhaustive: never = precedence; + return exhaustive; + } + } +}; diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 4ddcc329e..a809d8b17 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -14,6 +14,7 @@ import { standardMcpPathTokens, } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; +import { capabilityEvidence, capabilityStateFromSupport, supportedCapability } from './capability-state.ts'; import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import { mergeHookDocuments, @@ -88,6 +89,7 @@ const metadata = Object.freeze({ observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); +const evidence = capabilityEvidence(claudeName, metadata); const artifactValidation = Object.freeze({ documents: Object.freeze([ @@ -277,10 +279,18 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout: standardArtifactLayout, capabilities: Object.freeze({ - marketplace: true, - hooks: true, - mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, - skills: capabilityTable.plugin.skills, + marketplace: supportedCapability(evidence), + hooks: supportedCapability(evidence), + mcp: capabilityStateFromSupport( + capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, + evidence, + 'The pinned Claude contract does not support both required modern MCP transports.', + ), + skills: capabilityStateFromSupport( + capabilityTable.plugin.skills, + evidence, + 'The pinned Claude plugin contract does not support skills.', + ), }), configExtension: Object.freeze({ key: claudeName }), hookContract, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index e6c554776..fd746c73e 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -14,6 +14,7 @@ import { } from '../core/types.ts'; import { createMcpPathTokenResolver, standardMcpPathTokens } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../services/mcp-runtime.ts'; +import { capabilityEvidence, capabilityStateFromSupport, supportedCapability } from './capability-state.ts'; import capabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { mergeHookDocuments, @@ -114,6 +115,7 @@ const metadata = Object.freeze({ observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); +const evidence = capabilityEvidence(codexName, metadata); const artifactValidation = Object.freeze({ documents: Object.freeze([ @@ -398,10 +400,18 @@ export const codexAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout: standardArtifactLayout, capabilities: Object.freeze({ - marketplace: true, - hooks: true, - mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, - skills: capabilityTable.plugin.skills, + marketplace: supportedCapability(evidence), + hooks: supportedCapability(evidence), + mcp: capabilityStateFromSupport( + capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, + evidence, + 'The pinned Codex contract does not support both required modern MCP transports.', + ), + skills: capabilityStateFromSupport( + capabilityTable.plugin.skills, + evidence, + 'The pinned Codex plugin contract does not support skills.', + ), }), configExtension: Object.freeze({ key: codexName }), hookContract, diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 85bd39056..e12032a89 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -13,6 +13,7 @@ import { standardMcpPathTokens, } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; +import { capabilityEvidence, capabilityStateFromSupport, supportedCapability, unavailableCapability } from './capability-state.ts'; import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; import { cursorHookWrapperSource, @@ -239,6 +240,7 @@ const metadata = Object.freeze({ observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); +const evidence = capabilityEvidence(cursorName, metadata); const hookContract = createCursorHookContract({ manifestPath: cursorArtifactPaths.hooks, @@ -343,9 +345,18 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout: standardArtifactLayout, capabilities: Object.freeze({ - hooks: true, - mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, - skills: capabilityTable.plugin.skills, + hooks: supportedCapability(evidence), + marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'), + mcp: capabilityStateFromSupport( + capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, + evidence, + 'The pinned Cursor Plugin contract does not support both required modern MCP transports.', + ), + skills: capabilityStateFromSupport( + capabilityTable.plugin.skills, + evidence, + 'The pinned Cursor Plugin contract does not support skills.', + ), }), hookContract, metadata, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 1d3662101..91dc6bbf3 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -8,6 +8,7 @@ import { standardMcpPathTokens, } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; +import { intersectCapabilityStates } from './capability-state.ts'; import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts'; @@ -412,10 +413,10 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout, capabilities: Object.freeze({ - marketplace: claudeAdapter.capabilities.marketplace === true && codexAdapter.capabilities.marketplace === true, - hooks: claudeAdapter.capabilities.hooks === true && codexAdapter.capabilities.hooks === true, - mcp: claudeAdapter.capabilities.mcp === true && codexAdapter.capabilities.mcp === true, - skills: claudeAdapter.capabilities.skills === true && codexAdapter.capabilities.skills === true, + marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!), + hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!), + mcp: intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), + skills: intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), }), hookContract: bundleHookContract, metadata, diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index da908cdd1..6b01f1350 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -14,6 +14,7 @@ import { standardMcpPathTokens, } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; +import { capabilityEvidence, capabilityStateFromSupport, unavailableCapability } from './capability-state.ts'; import capabilityTable from './capabilities/portable-1.0.0.json' with { type: 'json' }; import schemaProvenance from './schemas/portable/PROVENANCE.json' with { type: 'json' }; import mcpSchema from './schemas/portable/mcp.schema.json' with { type: 'json' }; @@ -55,6 +56,7 @@ const metadata = Object.freeze({ observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); +const evidence = capabilityEvidence(portableName, metadata); const artifactValidation = Object.freeze({ documents: Object.freeze([ @@ -320,8 +322,18 @@ export const portableAdapter: TargetAdapter = Object.freeze({ skills: 'skills', }), capabilities: Object.freeze({ - mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, - skills: capabilityTable.plugin.skills, + hooks: unavailableCapability('Agent Plugins 1.0.0 does not define a hooks component.'), + marketplace: unavailableCapability('Agent Plugins 1.0.0 does not define a marketplace document.'), + mcp: capabilityStateFromSupport( + capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, + evidence, + 'Agent Plugins 1.0.0 does not support both required modern MCP transports.', + ), + skills: capabilityStateFromSupport( + capabilityTable.plugin.skills, + evidence, + 'Agent Plugins 1.0.0 does not support skills.', + ), }), configExtension: Object.freeze({ key: portableName }), metadata, diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index 38bad16ad..62fa3e6b9 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -1,3 +1,4 @@ +import type { CapabilityState } from '../core/capabilities.ts'; import { dataArrayValues } from '../core/strict-json.ts'; import type { AgentBundleConfig, @@ -5,6 +6,7 @@ import type { NormalizationNativeHookSource, NormalizationTargetRegistry, } from '../core/types.ts'; +import { capabilityIsSupported } from './capability-state.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; import { cursorAdapter } from './cursor.ts'; @@ -206,7 +208,7 @@ const snapshotArtifactLayout = ( if ((mcpApps !== undefined || mcpEntries !== undefined) && mcpRuntime === undefined) { throw new Error(`Target adapter "${adapter.name}" declares MCP output layout without an MCP runtime contract.`); } - if (skills !== undefined && adapter.capabilities.skills !== true) { + if (skills !== undefined && !capabilityIsSupported(adapter.capabilities.skills)) { throw new Error(`Target adapter "${adapter.name}" declares Skill layout without skills capability.`); } return Object.freeze({ @@ -306,10 +308,10 @@ const snapshotNativeHookSource = (adapter: TargetAdapter): NativeHookSource | un const snapshotHookContract = (adapter: TargetAdapter): TargetHookContract | undefined => { const hookContract = adapter.hookContract; - if (adapter.capabilities.hooks === true && hookContract === undefined) { + if (capabilityIsSupported(adapter.capabilities.hooks) && hookContract === undefined) { throw new Error(`Target adapter "${adapter.name}" declares hooks capability without a hook contract.`); } - if (adapter.capabilities.hooks !== true && hookContract !== undefined) { + if (!capabilityIsSupported(adapter.capabilities.hooks) && hookContract !== undefined) { throw new Error(`Target adapter "${adapter.name}" declares a hook contract without hooks capability.`); } if (hookContract === undefined) return undefined; @@ -323,10 +325,10 @@ const snapshotHookContract = (adapter: TargetAdapter): TargetHookContract | unde const snapshotMcpRuntime = (adapter: TargetAdapter): TargetMcpRuntimeContract | undefined => { const mcpRuntime = adapter.mcpRuntime; - if (adapter.capabilities.mcp === true && mcpRuntime === undefined) { + if (capabilityIsSupported(adapter.capabilities.mcp) && mcpRuntime === undefined) { throw new Error(`Target adapter "${adapter.name}" declares mcp capability without an MCP runtime contract.`); } - if (adapter.capabilities.mcp !== true && mcpRuntime !== undefined) { + if (!capabilityIsSupported(adapter.capabilities.mcp) && mcpRuntime !== undefined) { throw new Error(`Target adapter "${adapter.name}" declares an MCP runtime contract without mcp capability.`); } if (mcpRuntime === undefined) return undefined; @@ -477,8 +479,12 @@ export class TargetRegistry implements NormalizationTargetRegistry { return Object.freeze(sources); } + capabilityState(name: string, capability: string): CapabilityState | undefined { + return this.#adapters.get(name)?.capabilities[capability]; + } + supports(name: string, capability: string): boolean { - return this.#adapters.get(name)?.capabilities[capability] === true; + return capabilityIsSupported(this.capabilityState(name, capability)); } names(): readonly string[] { diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index e52b781c4..d63112349 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -2,6 +2,7 @@ import { Ajv } from 'ajv/dist/ajv.js'; import { Ajv2020 } from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; +import type { CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import { snapshotStrictJsonValue } from '../core/strict-json.ts'; @@ -411,7 +412,7 @@ export interface TargetAdapter { readonly artifactValidation?: TargetArtifactValidationContract; /** Declares compiler-owned artifact layouts beyond target-native documents. */ readonly artifactLayout?: TargetArtifactLayout; - readonly capabilities: Readonly>; + readonly capabilities: Readonly>; readonly configExtension?: TargetConfigExtension; readonly hookContract?: TargetHookContract; readonly metadata: TargetAdapterMetadata; diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 47674dff1..a52934984 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1,10 +1,12 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { capabilityIsSupported } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; +import type { CapabilityState } from './core/capabilities.ts'; import { isInsideOrEqual } from './core/paths.ts'; import { emptyCompiledRouteGraph } from './routes/graph.ts'; import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts'; @@ -413,11 +415,11 @@ const inspectableComponents = (model: NormalizedPlugin): readonly InspectableCom const skippedComponentsFor = ( components: readonly InspectableComponent[], target: string, - capabilities: Readonly>, + capabilities: Readonly>, ): readonly InspectionSkippedComponent[] => Object.freeze(components .filter((component) => !component.targets.includes(target) || - (component.capability !== undefined && capabilities[component.capability] !== true)) + (component.capability !== undefined && !capabilityIsSupported(capabilities[component.capability]))) .map((component) => Object.freeze({ id: component.id, kind: component.kind, diff --git a/packages/agent-bundle/src/core/capabilities.ts b/packages/agent-bundle/src/core/capabilities.ts new file mode 100644 index 000000000..7ee879d05 --- /dev/null +++ b/packages/agent-bundle/src/core/capabilities.ts @@ -0,0 +1,14 @@ +/** Evidence backing a supported or degraded capability judgment. */ +export interface CapabilityEvidence { + readonly capabilityRevision: string; + readonly capabilitySha256: string; + readonly observedVersion: string; + readonly target: string; +} + +/** Shared capability-state contract for adapters, routes, and projectors. */ +export type CapabilityState = + | { readonly state: 'supported'; readonly evidence: CapabilityEvidence } + | { readonly state: 'degraded'; readonly reason: string; readonly evidence?: CapabilityEvidence } + | { readonly state: 'unavailable'; readonly reason: string } + | { readonly state: 'prohibited'; readonly reason: string }; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts index b9c3af733..0b2b2cffb 100644 --- a/packages/agent-bundle/src/routes/types.ts +++ b/packages/agent-bundle/src/routes/types.ts @@ -26,28 +26,7 @@ export interface RouteProvenance { readonly relativePath: string; } -/** - * Evidence backing a `supported`/`degraded` capability judgment: the adapter - * capability record that asserted it, in the same identity terms the target - * adapter metadata already publishes. - */ -export interface CapabilityEvidence { - readonly capabilityRevision: string; - readonly capabilitySha256: string; - readonly observedVersion: string; - readonly target: string; -} - -/** - * The one capability-state contract route validation, projectors, events, - * and host components share (#93). This release exports the types only; - * population arrives with the host-component capability work (#100). - */ -export type CapabilityState = - | { readonly state: 'supported'; readonly evidence: CapabilityEvidence } - | { readonly state: 'degraded'; readonly reason: string; readonly evidence?: CapabilityEvidence } - | { readonly state: 'unavailable'; readonly reason: string } - | { readonly state: 'prohibited'; readonly reason: string }; +export type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; /** * The config of a route module without an extractable `config` export: the diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts new file mode 100644 index 000000000..939e7c2e1 --- /dev/null +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from '@rstest/core'; + +import { + capabilityBooleanView, + capabilityEvidence, + intersectCapabilityStates, + supportedCapability, +} from '../src/adapters/capability-state.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import type { CapabilityEvidence, CapabilityState } from '../src/core/capabilities.ts'; + +const evidence = (target: string): CapabilityEvidence => Object.freeze({ + capabilityRevision: `${target}-capability-revision`, + capabilitySha256: target.repeat(64).slice(0, 64), + observedVersion: `${target}-version`, + target, +}); +const state = (value: CapabilityState): CapabilityState => Object.freeze(value); + +it('keeps the plugin Boolean capability view as the Claude and Codex intersection', () => { + const registry = createDefaultRegistry(); + + for (const capability of ['marketplace', 'hooks', 'mcp', 'skills']) { + expect(registry.supports('plugin', capability)).toBe( + registry.supports('claude', capability) && registry.supports('codex', capability), + ); + } +}); + +it('intersects supported composite capabilities and merges both evidence records', () => { + const intersection = intersectCapabilityStates( + supportedCapability(evidence('claude')), + supportedCapability(evidence('codex')), + ); + + expect(intersection.state).toBe('supported'); + if (intersection.state !== 'supported') throw new Error('Expected a supported capability intersection.'); + expect(intersection.evidence).toMatchObject({ + capabilityRevision: 'claude@claude-capability-revision+codex@codex-capability-revision', + observedVersion: 'claude@claude-version+codex@codex-version', + target: 'claude+codex', + }); + expect(intersection.evidence.capabilitySha256).toMatch(/^[0-9a-f]{64}$/); +}); + +it('applies prohibited, unavailable, degraded, and supported intersection precedence', () => { + const supported = supportedCapability(evidence('supported')); + const degraded = state({ state: 'degraded', reason: 'degraded host', evidence: evidence('degraded') }); + const unavailable = state({ state: 'unavailable', reason: 'unavailable host' }); + const prohibited = state({ state: 'prohibited', reason: 'prohibited host' }); + + for (const other of [supported, degraded, unavailable]) { + expect(intersectCapabilityStates(other, prohibited)).toEqual(prohibited); + expect(intersectCapabilityStates(prohibited, other)).toEqual(prohibited); + } + for (const other of [supported, degraded]) { + expect(intersectCapabilityStates(other, unavailable)).toEqual(unavailable); + expect(intersectCapabilityStates(unavailable, other)).toEqual(unavailable); + } + expect(intersectCapabilityStates(supported, degraded)).toMatchObject({ + state: 'degraded', + reason: 'degraded host', + }); + expect(intersectCapabilityStates(degraded, supported)).toMatchObject({ + state: 'degraded', + reason: 'degraded host', + }); +}); + +it('keeps the Boolean compatibility view thin and exhaustive', () => { + expect(capabilityBooleanView({ + degraded: { state: 'degraded', reason: 'partial' }, + prohibited: { state: 'prohibited', reason: 'policy' }, + supported: supportedCapability(evidence('supported')), + unavailable: { state: 'unavailable', reason: 'missing' }, + })).toEqual({ + degraded: false, + prohibited: false, + supported: true, + unavailable: false, + }); +}); + +it('surfaces built-in adapter metadata as immutable capability evidence', () => { + const registry = createDefaultRegistry(); + const cursor = registry.get('cursor'); + + expect(cursor.capabilities.mcp).toEqual({ + evidence: capabilityEvidence('cursor', cursor.metadata), + state: 'supported', + }); + if (cursor.capabilities.mcp?.state !== 'supported') throw new Error('Expected Cursor MCP support evidence.'); + expect(cursor.capabilities.mcp.evidence).toEqual({ + capabilityRevision: '2026-08-28', + capabilitySha256: '234920e63508664ae79db4e1a5422c1022d93ad572fae345a179bfd774f6f6d7', + observedVersion: '2026-08-28', + target: 'cursor', + }); + expect(Object.isFrozen(cursor.capabilities.mcp.evidence)).toBe(true); +}); diff --git a/packages/agent-bundle/tests/adapter-contract.test.ts b/packages/agent-bundle/tests/adapter-contract.test.ts index a36df3684..9ed04ec7b 100644 --- a/packages/agent-bundle/tests/adapter-contract.test.ts +++ b/packages/agent-bundle/tests/adapter-contract.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -40,7 +41,7 @@ it('delegates selected native hook sources through registered adapters', async ( const source = join(root, 'example-hooks.json'); let calledWithAdapter = false; const adapter: TargetAdapter = { - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), configExtension: { key: 'example' }, hookContract, metadata, @@ -118,7 +119,7 @@ it('rejects contradictory hook capability and contract registrations atomically' plan: () => ({ diagnostics: [], entries: [] }), }); const adapter = (name: string): TargetAdapter => ({ - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), metadata, name, plan: () => ({ diagnostics: [], entries: [] }), @@ -139,7 +140,7 @@ it('normalizes malformed native hook source values into diagnostics without skip const source = join(root, 'valid-hooks.json'); const registry = new TargetRegistry() .register({ - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract, metadata, name: 'invalid', @@ -147,7 +148,7 @@ it('normalizes malformed native hook source values into diagnostics without skip plan: () => ({ diagnostics: [], entries: [] }), }) .register({ - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract, metadata, name: 'valid', @@ -188,7 +189,7 @@ it('normalizes malformed native hook source values into diagnostics without skip it('normalizes thrown native hook sources into diagnostics', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-source-throws-')); const registry = new TargetRegistry().register({ - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract, metadata, name: 'throws', diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 6a85f91cc..4018efcc6 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { chmod, mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -121,7 +122,7 @@ const syntheticAdapter: TargetAdapter = Object.freeze({ hookWrappers: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'hooks' }), mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), }), - capabilities: Object.freeze({ hooks: true, mcp: true }), + capabilities: supportedCapabilities('hooks', 'mcp'), configExtension: Object.freeze({ key: 'synthetic' }), hookContract: syntheticHookContract, metadata: syntheticMetadata, diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index f891b8b60..687c89100 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -60,7 +61,7 @@ const runtimeRegistry = ( mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' }, scripts: { allowedSuffixes: ['.mjs'], directory: 'scripts' }, }, - capabilities: { hooks: true, mcp: true }, + capabilities: supportedCapabilities('hooks', 'mcp'), hookContract: { commandRoot: '${HOOK_ROOT}', encodePlaygroundInput: (input) => input, diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 330a8d3dd..0008f80e7 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { execFile } from 'node:child_process'; import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; @@ -131,7 +132,7 @@ const coherenceManifestTarget = Object.freeze({ ...coherenceMetadata, name: cohe const coherenceRegistry = (): TargetRegistry => new TargetRegistry().register({ artifactLayout: { mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' } }, - capabilities: { mcp: true }, + capabilities: supportedCapabilities('mcp'), mcpRuntime: createTargetMcpRuntime({ manifestPath: 'native/servers.json', remoteTypes: ['streamable-http'], @@ -167,7 +168,7 @@ const hookCoherenceContract = { const hookCoherenceRegistry = (): TargetRegistry => new TargetRegistry().register({ artifactLayout: { hookWrappers: { allowedSuffixes: ['.mjs'], directory: 'hooks' } }, - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract: hookCoherenceContract, metadata: hookCoherenceMetadata, name: hookCoherenceTarget, @@ -189,7 +190,7 @@ const customRegistry = (validate = validateCustomDocument): TargetRegistry => ne scripts: { allowedSuffixes: ['.json', '.mjs', '.sh'], directory: 'scripts' }, skills: 'skills', }, - capabilities: { skills: true }, + capabilities: supportedCapabilities('skills'), metadata: customMetadata, name: customTarget, plan: () => ({ diagnostics: [], entries: [] }), @@ -913,7 +914,7 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', }); const registry = coherenceRegistry().register({ artifactLayout: { mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' } }, - capabilities: { mcp: true }, + capabilities: supportedCapabilities('mcp'), mcpRuntime: createTargetMcpRuntime({ manifestPath: 'native/servers.json', remoteTypes: ['streamable-http'], diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 241430a51..874e1fd86 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { chmod, mkdtemp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; @@ -576,7 +577,7 @@ it('rejects hook entries stamped for a target other than their selected adapter' tools: [], }; const adapter: TargetAdapter = { - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract, metadata: testAdapterMetadata, name: 'portable', diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index 23c619324..cb250b748 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -263,7 +264,7 @@ it('uses the injected adapter hook contract for custom manifests, mappings, matc wrapperSource: () => 'export default undefined;\n', } satisfies TargetHookContract); const adapter: TargetAdapter = { - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract: contract, metadata: { adapterRevision: 'test', diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index cd27d019d..963eacfb8 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { spawn } from 'node:child_process'; import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -637,7 +638,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art ); const collisionRegistry = new TargetRegistry().register({ - capabilities: { mcp: true }, + capabilities: supportedCapabilities('mcp'), mcpRuntime: createDefaultRegistry().mcpRuntime('portable'), metadata: testAdapterMetadata, name: 'portable', diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index 6f96e56bd..40abcd64d 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { execFile as executeFile } from 'node:child_process'; import { access, mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; @@ -116,7 +117,7 @@ it('exposes advanced adapter registry and contract types only from the advanced resolveValue: (_field, _roots, value) => ({ diagnostics: [], value }), } satisfies TargetMcpRuntimeContract; const adapter = { - capabilities: { hooks: true, mcp: true }, + capabilities: supportedCapabilities('hooks', 'mcp'), hookContract, mcpRuntime, metadata: { diff --git a/packages/agent-bundle/tests/support/adapter-capabilities.ts b/packages/agent-bundle/tests/support/adapter-capabilities.ts new file mode 100644 index 000000000..faa0630cf --- /dev/null +++ b/packages/agent-bundle/tests/support/adapter-capabilities.ts @@ -0,0 +1,15 @@ +import { supportedCapability } from '../../src/adapters/capability-state.ts'; +import type { CapabilityEvidence, CapabilityState } from '../../src/core/capabilities.ts'; + +const evidence: CapabilityEvidence = Object.freeze({ + capabilityRevision: 'test', + capabilitySha256: '0'.repeat(64), + observedVersion: 'test', + target: 'test', +}); + +export const supportedCapabilities = ( + ...names: readonly string[] +): Readonly> => Object.freeze(Object.fromEntries( + names.map((name) => [name, supportedCapability(evidence)]), +)); diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 6bcbf7725..b96a30cc7 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { spawn } from 'node:child_process'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -180,7 +181,7 @@ it('builds adapter-owned native hook event, layout, and wrapper source', async ( const staleTargetContract = { ...contract, target: 'contract-target' }; const adapter: TargetAdapter = { artifactLayout: { hookWrappers: { allowedSuffixes: ['.mjs'], directory: 'runtime' } }, - capabilities: { hooks: true }, + capabilities: supportedCapabilities('hooks'), hookContract: contract, metadata, name: 'synthetic', diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 6c7a26ad4..3359049ca 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -1,3 +1,4 @@ +import { supportedCapabilities } from './support/adapter-capabilities.ts'; import { access, cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -294,7 +295,7 @@ it('rejects contradictory MCP capability and runtime registrations atomically', plan: () => ({ diagnostics: [], entries: [] }), }); const adapter = (name: string): TargetAdapter => ({ - capabilities: { mcp: true }, + capabilities: supportedCapabilities('mcp'), metadata, name, plan: () => ({ diagnostics: [], entries: [] }), @@ -319,7 +320,7 @@ it('delegates one-shot and persistent MCP operations to an injected target runti const calls: string[] = []; const adapter: TargetAdapter = { artifactLayout: { scripts: { allowedSuffixes: ['.mjs'], directory: 'scripts' } }, - capabilities: { mcp: true }, + capabilities: supportedCapabilities('mcp'), mcpRuntime: runtime, metadata, name: 'synthetic-mcp',