Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/plugin-inspect-emission-accounting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Fix inspect so commands and rules emitted by the composite plugin target are no longer reported as skipped for unsupported capabilities by accounting for component kinds as a union of host-side emission while preserving honest intersected capability claims.
46 changes: 46 additions & 0 deletions packages/agent-bundle/src/adapters/capability-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,49 @@ export const intersectCapabilityStates = (
}
}
};

/**
* Unions two host judgments for composite emission dispatch: a composite
* emits a surface if any host side does. Support dominates degradation,
* unavailability, and prohibition; equally ranked supporting sides merge
* evidence, while equally ranked non-supported sides merge reasons.
*/
export const unionCapabilityStates = (
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') {
return supportedCapability(mergeCapabilityEvidence(left.evidence, right.evidence));
}
if (left.state === 'supported') return supportedCapability(left.evidence);
if (right.state === 'supported') return supportedCapability(right.evidence);
throw new Error('Supported capability union lost its evidence invariant.');
case 1: {
const leftEvidence = left.state === 'degraded' ? left.evidence : undefined;
const rightEvidence = right.state === 'degraded' ? right.evidence : undefined;
const evidence = leftEvidence === undefined
? rightEvidence
: rightEvidence === undefined
? leftEvidence
: 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;
throw new CapabilityStateError(`Capability precedence ${String(exhaustive)} has no union rule.`);
}
}
};
15 changes: 15 additions & 0 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
intersectCapabilityStates,
supportedEventRouteNamesFrom,
unavailableCapability,
unionCapabilityStates,
} 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' };
Expand Down Expand Up @@ -528,6 +529,19 @@ const compositeEventCapabilities = Object.freeze(Object.fromEntries(
}),
));

const componentCapabilities = Object.freeze(Object.fromEntries(
['commands', 'hooks', 'mcp', 'rules', 'skills'].map((capability) => [
capability,
unionCapabilityStates(
unionCapabilityStates(
claudeAdapter.capabilities[capability]!,
codexAdapter.capabilities[capability]!,
),
cursorAdapter.capabilities[capability]!,
),
]),
));

export const pluginAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout,
Expand Down Expand Up @@ -568,6 +582,7 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
cursorAdapter.capabilities.skills!,
),
}),
componentCapabilities,
hookContract: bundleHookContract,
metadata,
mcpRuntime,
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-bundle/src/adapters/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,20 @@ const assertCapabilityContract = (adapter: TargetAdapter): void => {
`${capabilityStateNames.join('/')} with that state's required fields.`,
);
}
if (adapter.componentCapabilities === undefined) return;
const componentCapabilities = record(adapter.componentCapabilities);
if (componentCapabilities === undefined) {
throw new CapabilityStateError(
`Target adapter "${adapter.name}" must declare component capabilities as a record.`,
);
}
for (const [capability, state] of Object.entries(componentCapabilities)) {
if (state === undefined || isCapabilityState(state)) continue;
throw new CapabilityStateError(
`Target adapter "${adapter.name}" component capability "${capability}" must declare one of ` +
`${capabilityStateNames.join('/')} with that state's required fields.`,
);
}
};

export class TargetRegistry implements NormalizationTargetRegistry {
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ export interface TargetAdapter {
/** Declares compiler-owned artifact layouts beyond target-native documents. */
readonly artifactLayout?: TargetArtifactLayout;
readonly capabilities: Readonly<Record<string, CapabilityState>>;
/** Per-component-kind emission dispatch used by inspect skip accounting; defaults to `capabilities`. */
readonly componentCapabilities?: Readonly<Record<string, CapabilityState>>;
readonly configExtension?: TargetConfigExtension;
readonly hookContract?: TargetHookContract;
readonly metadata: TargetAdapterMetadata;
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,7 +519,11 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
diagnostics: freezeDiagnostics(plan.diagnostics),
entries: Object.freeze([...plan.entries]),
hookEntries: Object.freeze([...(plan.hookEntries ?? [])]),
skipped: skippedComponentsFor(components, target.name, adapter.capabilities),
skipped: skippedComponentsFor(
components,
target.name,
adapter.componentCapabilities ?? adapter.capabilities,
),
target: target.name,
});
}));
Expand Down
28 changes: 28 additions & 0 deletions packages/agent-bundle/tests/adapter-capability-states.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
intersectCapabilityStates,
supportedCapability,
unavailableCapability,
unionCapabilityStates,
} from '../src/adapters/capability-state.ts';
import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts';
import { CapabilityStateError, isCapabilityState } from '../src/core/capabilities.ts';
Expand Down Expand Up @@ -150,6 +151,24 @@ it('applies prohibited, unavailable, degraded, and supported intersection preced
});
});

it('unions host capability states according to composite emission dispatch', () => {
const supported = supportedCapability(evidence('supported'));
const unavailable = unavailableCapability('unavailable host');
const prohibited = state({ state: 'prohibited', reason: 'prohibited host' });

expect(unionCapabilityStates(supported, unavailable)).toEqual(supported);
expect(unionCapabilityStates(unavailable, supported)).toEqual(supported);
expect(unionCapabilityStates(
unavailableCapability('second unavailable host'),
unavailable,
)).toEqual({
reason: 'second unavailable host; unavailable host',
state: 'unavailable',
});
expect(unionCapabilityStates(prohibited, supported)).toEqual(supported);
expect(unionCapabilityStates(supported, prohibited)).toEqual(supported);
});

it('keeps the Boolean compatibility view thin and exhaustive', () => {
expect(capabilityBooleanView({
degraded: { state: 'degraded', reason: 'partial' },
Expand Down Expand Up @@ -227,6 +246,15 @@ it('rejects a malformed capability declaration when the adapter registers', () =
expect(() => new TargetRegistry().register(source)).not.toThrow();
});

it('rejects a malformed inspection component capability when the adapter registers', () => {
const source = createDefaultRegistry().get('cursor');

expect(() => new TargetRegistry().register({
...source,
componentCapabilities: { commands: malformed({ state: 'suported' }) },
})).toThrow(/component capability "commands" must declare one of degraded\/prohibited\/supported\/unavailable/u);
});

it('surfaces built-in adapter metadata as immutable capability evidence', () => {
const registry = createDefaultRegistry();
const cursor = registry.get('cursor');
Expand Down
13 changes: 11 additions & 2 deletions packages/agent-bundle/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ it('returns an invalid inspection for selected targets outside the normalized pr
}
});

it('reports skipped target/component pairs with intersection-rule reasons', async () => {
it('reports skipped target/component pairs against each target emission surface', async () => {
const root = await createProject();
try {
await Promise.all([
Expand All @@ -566,7 +566,7 @@ it('reports skipped target/component pairs with intersection-rule reasons', asyn
" hooks: { sessionStart: { handler: './src/hook.ts' } },",
" plugin: { name: 'api-fixture', version: '1.0.0' },",
" scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },",
" targets: ['portable', 'codex', 'claude', 'cursor'],",
" targets: ['portable', 'codex', 'claude', 'cursor', 'plugin'],",
'};',
'',
].join('\n')),
Expand Down Expand Up @@ -602,6 +602,15 @@ it('reports skipped target/component pairs with intersection-rule reasons', asyn
component.kind === 'command' && component.name === 'shared')).toBe(false);
expect(planFor('cursor')?.skipped.some((component) => component.kind === 'command')).toBe(false);
expect(planFor('cursor')?.skipped.some((component) => component.kind === 'rule')).toBe(false);
expect(planFor('plugin')?.skipped).toEqual([
expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }),
expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }),
expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }),
]);
expect(planFor('plugin')?.entries).toEqual(expect.arrayContaining([
expect.objectContaining({ relativePath: 'commands/shared.md' }),
expect.objectContaining({ relativePath: 'rules/shared.mdc' }),
]));
expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true);
} finally {
await rm(join(root, '..'), { force: true, recursive: true });
Expand Down
Loading