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
6 changes: 6 additions & 0 deletions .changeset/required-provider-fixtures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-bundle/runtime": minor
"agent-bundle": minor
---

Require provider fixtures wherever conventional providers do not run (breaking for provider-enabled projects that omitted them). Once a project's generated `.agent-bundle/routes.d.ts` augmentation declares provider keys, `runAgentRequest`'s `providers` (`AgentRequestProvidersInit`), the `renderRoute` / `invokeCli` / in-memory MCP harness `options` argument (`HarnessOptionsArguments`), and its `context.providers` (`RenderRouteContextInit`) become required, so a handler typed against `(await agent()).providers.<key>` never observes an unchecked `undefined` from a custom scope or a route-unit test; provider-free projects are unchanged. Make `agent-bundle inspect` project only the four-state contract fields of adapter-owned capability rows into component accounting, so extension fields on JavaScript or third-party adapters cannot shadow the canonical capability name or break `inspect --json`. (#409)
6 changes: 5 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,11 @@ counter, so provider filenames must not derive that key.
Route-unit and CLI-dispatch tests inject provider values through the same
`context` seam as identity axes (`renderRoute(id, { context: { providers:
{ library: fixture } } })`); the harness never executes conventional provider
modules, so a test chooses exactly the values a component observes.
modules, so a test chooses exactly the values a component observes. Once the
generated `.agent-bundle/routes.d.ts` augmentation declares provider keys, the
harness `options` and its `context.providers` become required (as does
`providers` on a direct `runAgentRequest`), so omitting a fixture the route's
types promise is a compile error rather than a runtime `undefined`.

### Handler request context

Expand Down
6 changes: 5 additions & 1 deletion docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ the file is part of the project's TypeScript program (add
`".agent-bundle/routes.d.ts"` to `tsconfig.json` `include`). Undeclared keys
stay `unknown`. Route-unit and CLI-dispatch tests inject fixture values through
`renderRoute(id, { context: { providers: { library } } })`; the harness never
executes provider modules on a test's behalf.
executes provider modules on a test's behalf. Because the augmentation makes
declared keys required, the same program also requires `context.providers`
(and the harness `options` argument) on every `renderRoute`, `invokeCli`, and
in-memory MCP call, and `providers` on a direct `runAgentRequest`: a handler
typed against `providers.library` can never observe an unchecked `undefined`.

### What reaches the MCP wire

Expand Down
42 changes: 34 additions & 8 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
packOutputFromJson,
type PackOutput,
} from './build/pack-inventory.ts';
import type { CapabilityState } from './core/capabilities.ts';
import type { CapabilityEvidence, CapabilityState } from './core/capabilities.ts';
import { isInsideOrEqual } from './core/paths.ts';
import {
stateDefinitionProjection,
Expand Down Expand Up @@ -616,15 +616,41 @@ const componentCapabilityFor = (
capabilities: Readonly<Record<string, CapabilityState>>,
): InspectionComponentCapability | undefined => {
if (component.capability === undefined) return undefined;
const state = capabilities[component.capability];
return Object.freeze({
name: component.capability,
...(state ?? unavailableCapability(
`The ${target} adapter publishes no ${component.capability} capability row.`,
)),
});
const state = capabilities[component.capability] ?? unavailableCapability(
`The ${target} adapter publishes no ${component.capability} capability row.`,
);
return Object.freeze({ ...capabilityContract(state), name: component.capability });
};

/**
* Projects only the four-state contract fields of an adapter-owned capability
* row. `isCapabilityState` admits extension fields on JavaScript and third-party
* adapters, and copying them would let one named `name` shadow the canonical
* capability name or a cyclic one break `inspect --json`.
*/
const capabilityContract = (state: CapabilityState): CapabilityState => {
switch (state.state) {
case 'supported':
return { evidence: capabilityEvidenceContract(state.evidence), state: state.state };
case 'degraded':
return {
...(state.evidence === undefined ? {} : { evidence: capabilityEvidenceContract(state.evidence) }),
reason: state.reason,
state: state.state,
};
case 'unavailable':
case 'prohibited':
return { reason: state.reason, state: state.state };
default: {
const exhaustive: never = state;
throw new Error(`Unhandled capability state ${JSON.stringify(exhaustive)}`);
}
}
};

const capabilityEvidenceContract = (evidence: CapabilityEvidence): CapabilityEvidence =>
Object.freeze({ observedVersion: evidence.observedVersion, target: evidence.target });

interface AccountedComponents {
readonly selected: readonly InspectionSelectedComponent[];
readonly skipped: readonly InspectionSkippedComponent[];
Expand Down
15 changes: 10 additions & 5 deletions packages/agent-bundle/src/test/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,12 @@ import type { CompiledCliCommand } from '../routes/types.ts';
import { AgentTestError, captured } from './errors.ts';
import { CLI_DISPATCH_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts';
import { registeredRouteLoader, testManifest } from './registry.ts';
import { prepareCliRenderHost, type RenderRouteContext } from './render.ts';
import { prepareCliRenderHost, type HarnessOptionsArguments, type RenderRouteContextInit } from './render.ts';
import type { AgentRouteModule, RenderedRouteProvenance } from './types.ts';

export type { CliRenderedEvent };

export interface InvokeCliOptions {
/** Request-scope overrides for the dispatched command, over the runtime's request contract. */
readonly context?: RenderRouteContext;
export interface InvokeCliOptionsBase {
readonly manifest?: AgentBundleTestManifest;
readonly signal?: AbortSignal;
/**
Expand All @@ -42,6 +40,13 @@ export interface InvokeCliOptions {
readonly tty?: boolean;
}

/**
* Dispatch options; `context` carries the request-scope overrides for the
* dispatched command over the runtime's request contract and is required once
* the project declares providers (see {@link RenderRouteContextInit}).
*/
export type InvokeCliOptions = InvokeCliOptionsBase & RenderRouteContextInit;

export interface CliInvocation {
/** The argv vector as dispatched, including the command path segments. */
readonly argv: readonly string[];
Expand Down Expand Up @@ -150,7 +155,7 @@ const moduleFor = async (
*/
export const invokeCli = async (
argv: readonly string[],
options: InvokeCliOptions = {},
...[options = {}]: HarnessOptionsArguments<InvokeCliOptions>
): Promise<CliInvocation> => {
const manifest = options.manifest ?? testManifest();
if (manifest.cliCommands.length === 0) throw noCommands(manifest);
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bundle/src/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ export { AgentTestError } from './errors.ts';
export type { AgentTestErrorCode } from './errors.ts';
export { renderRoute, renderRouteEvents } from './render.ts';
export type {
HarnessOptionsArguments,
RenderRouteContext,
RenderRouteContextInit,
RenderRouteOptions,
RenderRouteOptionsBase,
RenderRouteTarget,
RenderedRoute,
RenderedRouteEvents,
Expand Down Expand Up @@ -119,6 +122,7 @@ export type {
export type {
InMemoryMcpSession,
InMemoryMcpSessionOptions,
InMemoryMcpSessionOptionsBase,
McpContentBlock,
McpInvocationOptions,
McpProjectionProvenance,
Expand All @@ -128,7 +132,7 @@ export type {
McpToolInvocation,
} from './mcp.ts';
export { cliJson, cliNdjson, invokeCli } from './cli.ts';
export type { CliDispatchProvenance, CliInvocation, CliRenderedEvent, InvokeCliOptions } from './cli.ts';
export type { CliDispatchProvenance, CliInvocation, CliRenderedEvent, InvokeCliOptions, InvokeCliOptionsBase } from './cli.ts';
export { openPackedMcpServer, removeProjectSource } from './packed.ts';
export type {
DeletedSourceReceipt,
Expand Down
26 changes: 17 additions & 9 deletions packages/agent-bundle/src/test/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type { createGeneratedRuntimeState } from '@agent-bundle/runtime/mount';
import { AgentTestError, captured } from './errors.ts';
import { MCP_IN_MEMORY_PROOF_LEVEL, type AgentBundleTestManifest } from './manifest.ts';
import { registeredRouteLoader, testManifest } from './registry.ts';
import type { RenderRouteContext } from './render.ts';
import type { HarnessOptionsArguments, RenderRouteContextInit } from './render.ts';
import type { RenderedRouteProvenance, TestableRouteDescriptor } from './types.ts';

/** Where an in-memory projection result came from and what it proves. */
Expand Down Expand Up @@ -57,12 +57,10 @@ export interface McpToolInvocation {
readonly structuredContent?: unknown;
}

export interface InMemoryMcpSessionOptions<
export interface InMemoryMcpSessionOptionsBase<
TState = unknown,
TEvents extends AgentStateEventSchemas = AgentStateEventSchemas,
> {
/** Request-scoped overrides applied to every route render in this session. */
readonly context?: RenderRouteContext;
readonly manifest?: AgentBundleTestManifest;
/** MCP server name. Optional when the project compiled exactly one server. */
readonly server?: string;
Expand All @@ -73,6 +71,16 @@ export interface InMemoryMcpSessionOptions<
};
}

/**
* Session options; `context` holds the request-scoped overrides applied to
* every route render in this session and is required once the project
* declares providers (see {@link RenderRouteContextInit}).
*/
export type InMemoryMcpSessionOptions<
TState = unknown,
TEvents extends AgentStateEventSchemas = AgentStateEventSchemas,
> = InMemoryMcpSessionOptionsBase<TState, TEvents> & RenderRouteContextInit;

export interface InMemoryMcpSession extends AsyncDisposable {
/** The real MCP SDK client, for protocol calls this module does not wrap. */
readonly client: Client;
Expand Down Expand Up @@ -242,7 +250,7 @@ export const openInMemoryMcpServer = async <
TState = unknown,
TEvents extends AgentStateEventSchemas = AgentStateEventSchemas,
>(
options: InMemoryMcpSessionOptions<TState, TEvents> = {},
...[options = {}]: HarnessOptionsArguments<InMemoryMcpSessionOptions<TState, TEvents>>
): Promise<InMemoryMcpSession> => {
const manifest = options.manifest ?? testManifest();
const serverName = resolveServerName(manifest, options.server);
Expand Down Expand Up @@ -414,7 +422,7 @@ const asContentBlocks = (value: unknown): readonly McpContentBlock[] =>
*/
export const invokeMcpTool = async (
tool: string,
options: McpInvocationOptions = {},
...[options = {}]: HarnessOptionsArguments<McpInvocationOptions>
): Promise<McpToolInvocation> => withSession(options, async (session) => {
const result = await session.client.callTool({
arguments: (options.input ?? {}) as Record<string, unknown>,
Expand All @@ -437,7 +445,7 @@ export interface McpResourceRead {
/** Reads one compiled resource route by URI through the real protocol. */
export const readMcpResource = async (
uri: string,
options: InMemoryMcpSessionOptions = {},
...[options = {}]: HarnessOptionsArguments<InMemoryMcpSessionOptions>
): Promise<McpResourceRead> => withSession(options, async (session) => {
const result = await session.client.readResource({ uri }) as { contents?: unknown };
return Object.freeze({
Expand All @@ -454,7 +462,7 @@ export interface McpPromptResult {
/** Gets one compiled prompt route through the real protocol. */
export const getMcpPrompt = async (
prompt: string,
options: McpInvocationOptions = {},
...[options = {}]: HarnessOptionsArguments<McpInvocationOptions>
): Promise<McpPromptResult> => withSession(options, async (session) => {
const result = await session.client.getPrompt({
arguments: (options.input ?? {}) as Record<string, string>,
Expand All @@ -478,7 +486,7 @@ export interface McpSurfaceListing {
* that a compiled route reached the protocol at all.
*/
export const listMcpSurface = async (
options: InMemoryMcpSessionOptions = {},
...[options = {}]: HarnessOptionsArguments<InMemoryMcpSessionOptions>
): Promise<McpSurfaceListing> => withSession(options, async (session) => {
const [tools, resources, prompts] = await Promise.all([
session.client.listTools(),
Expand Down
31 changes: 27 additions & 4 deletions packages/agent-bundle/src/test/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AgentInvocationInput,
AgentProgressReporter,
AgentProgressUpdate,
AgentProviderValues,
AgentRenderDispatch,
AgentRenderEvent,
AgentRenderInvocation,
Expand Down Expand Up @@ -53,10 +54,21 @@ export type RenderRouteContext = Omit<AgentRequestInit, 'invocation' | 'progress
readonly progress?: AgentProgressReporter;
};

export interface RenderRouteOptions {
/**
* The `context` member of every harness call. The harness installs fixture
* values instead of executing `src/providers/*`, so once the project's
* generated `.agent-bundle/routes.d.ts` augmentation declares required provider
* keys, `context` (and its `providers`) becomes mandatory: a test cannot omit
* the fixtures while the route's types promise them. Provider-free projects
* keep `context` optional.
*/
export type RenderRouteContextInit = Record<never, never> extends AgentProviderValues
? { readonly context?: RenderRouteContext }
: { readonly context: RenderRouteContext };

export interface RenderRouteOptionsBase {
/** CLI route arguments; `cli` routes only. */
readonly args?: readonly string[];
readonly context?: RenderRouteContext;
/** The route's input: tool input, event payload, or script input. */
readonly input?: unknown;
/** Overrides the route kind when a module is rendered directly; ignored for manifest routes. */
Expand All @@ -69,6 +81,17 @@ export interface RenderRouteOptions {
readonly signal?: AbortSignal;
}

export type RenderRouteOptions = RenderRouteOptionsBase & RenderRouteContextInit;

/**
* The trailing options parameter of every harness entry point. Provider-free
* projects may omit it; once the generated augmentation declares provider
* keys it is mandatory, so no harness call can silently skip the fixtures.
*/
export type HarnessOptionsArguments<Options> = Record<never, never> extends AgentProviderValues
? readonly [options?: Options]
: readonly [options: Options];

export interface RenderedRoute {
/** The final Agent Document the real renderer produced. */
readonly document: AgentDocument;
Expand Down Expand Up @@ -769,7 +792,7 @@ const renderFailure = (
*/
export const renderRoute = async (
target: RenderRouteTarget,
options: RenderRouteOptions = {},
...[options = {}]: HarnessOptionsArguments<RenderRouteOptions>
): Promise<RenderedRoute> => {
const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options);
try {
Expand Down Expand Up @@ -804,7 +827,7 @@ export interface RenderedRouteEvents extends RenderedRoute {
*/
export const renderRouteEvents = async (
target: RenderRouteTarget,
options: RenderRouteOptions = {},
...[options = {}]: HarnessOptionsArguments<RenderRouteOptions>
): Promise<RenderedRouteEvents> => {
const { close, collected, dispatcher, invocation, resolved, signal } = await prepareRender(target, options);
const events: AgentRenderEvent[] = [];
Expand Down
44 changes: 44 additions & 0 deletions packages/agent-bundle/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../src/adapters/hook-contract.ts';
import type { TargetAdapter } from '../src/adapters/types.ts';
import { inspectArtifactFilesystem } from '../src/build/emit.ts';
import type { CapabilityState } from '../src/core/capabilities.ts';
import type { Diagnostic } from '../src/core/diagnostics.ts';
import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts';
import { ProjectService } from '../src/dev/project-service.ts';
Expand Down Expand Up @@ -177,6 +178,49 @@ it('prepares and inspects a target owned only by the supplied advanced registry'
}
});

it('projects only the capability contract fields of adapter-owned rows into inspection', async () => {
const root = await createProject();
// A JavaScript or third-party adapter may decorate an otherwise valid row
// with extension fields. `isCapabilityState` admits them, so the inspection
// must not copy them: a `name` extension would shadow the canonical
// capability name and a cyclic value would break `inspect --json`.
const cyclic: Record<string, unknown> = {};
cyclic['self'] = cyclic;
const decorated = (row: CapabilityState): CapabilityState =>
({ ...row, cyclic, name: 'shadow' }) as unknown as CapabilityState;
const capabilities: Record<string, CapabilityState> = {
hooks: decorated({ evidence: { observedVersion: '1.0.0', target: syntheticTarget }, state: 'supported' }),
mcp: decorated({ evidence: { observedVersion: '1.0.0', target: syntheticTarget }, state: 'supported' }),
skills: decorated({ reason: 'partial skill support', state: 'degraded' }),
};
const registry = new TargetRegistry().register({ ...syntheticAdapter, capabilities }, { default: true });
try {
await writeFile(join(root, 'agent-bundle.config.ts'), [
'export default {',
" plugin: { name: 'synthetic-api-fixture', version: '1.0.0' },",
" hooks: { sessionStart: { handler: './src/hook.ts' } },",
" synthetic: { enabled: true },",
" targets: ['synthetic'],",
'};',
'',
].join('\n'));

const result = await readyInspection({ registry, root });
const plan = result.plans[0];
if (plan === undefined) throw new Error('Expected one synthetic plan.');

expect(plan.selected.map((component) => component.capability)).toEqual([
{ evidence: { observedVersion: '1.0.0', target: syntheticTarget }, name: 'hooks', state: 'supported' },
]);
expect(plan.skipped.map((component) => component.capability)).toEqual([
{ name: 'skills', reason: 'partial skill support', state: 'degraded' },
]);
expect(() => JSON.stringify(result.plans)).not.toThrow();
} finally {
await rm(join(root, '..'), { force: true, recursive: true });
}
});

it('accepts claude.userConfig through the public inspection and build APIs', async () => {
const root = await createProject();
const artifact = join(root, 'artifact');
Expand Down
Loading
Loading