diff --git a/.changeset/contract-matrix-stage2.md b/.changeset/contract-matrix-stage2.md new file mode 100644 index 000000000..5b3a72693 --- /dev/null +++ b/.changeset/contract-matrix-stage2.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add stage-2 stateful lifecycle replay to the generated-plugin contract matrix (#218). Projects can supply deterministic `unknown → queued → running → first-progress → repeated-progress → terminal` drivers while the shared matrix owns transport, per-phase schema/render/compat checks, live-progress evidence, journal accumulation, notice observation, idempotency replay, typed commit-budget rejection, and same-store restart durability at both in-memory and packed boundaries. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index c8da14932..152bfcf54 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -332,7 +332,9 @@ The contract matrix is the framework-owned generated-plugin wire-contract suite. Two entry points share one implementation; boundary differences are explicit capability flags, not forked check logic. The project supplies only fixtures — valid inputs, a declared `resultCompat` policy for every in-memory tool route, -optional `previousResults` payloads, and optional `cancellation` cases. +optional `previousResults` payloads, optional `cancellation` cases, and an +optional deterministic lifecycle transition driver with declarative +expectations. **`runContractMatrix` (`mcp-in-memory`)** opens one real MCP client against the real generated server over the SDK's in-memory transport and runs the full @@ -354,12 +356,24 @@ open/close). It proves process stdio evidence for surface completeness successful-path sweeps, advertised input-schema rejection, and client-side cancellation hygiene. It cannot load project route modules — source may be deleted and verified absent — so serialized-round-trip, compat-probe, and -version-skew are reported `not-applicable` with an honest reason. The packed +version-skew (including their per-lifecycle-phase variants) are reported +`not-applicable` with an honest reason. The packed server validates every tool result through its bundled `resultSchema` before returning; a successful sweep invocation is that evidence. -**Neither boundary proves:** host install, browser App HTML, or lifecycle replay -across artifact rebuilds (stage 2+). +Lifecycle fixtures replay +`unknown → queued → running → first-progress → repeated-progress → terminal` +over the matrix's one open client. The framework validates every phase's +structured content and rendered output, additive/closed compatibility, live +progress before settlement, journal accumulation, declared notices, +idempotent commit replay, and typed budget rejection. A caller-supplied +same-store `restart` callback adds durability evidence at that boundary; +without one the check is honestly `not-applicable`. Packed callers should wire +that callback into the existing packed journey's restart rather than creating +a second pack/build/install path. + +**Neither boundary proves:** host install, browser App HTML, artifact-rebuild +replay, or state-lifetime catalog identity. When the advertised input schema declares `additionalProperties: false`, plain `z.object` tool routes may still strip unknown keys without a protocol failure. diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx new file mode 100644 index 000000000..4110eeb14 --- /dev/null +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/tools/lifecycle.tsx @@ -0,0 +1,140 @@ +import { setTimeout as delay } from 'node:timers/promises'; + +import { Agent, agent } from '@agent-bundle/runtime'; +import { AgentStateError } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +const lifecyclePhaseSchema = z.enum([ + 'queued', + 'running', + 'first-progress', + 'repeated-progress', + 'terminal', +]); + +export const config = { + description: 'Replays a deterministic durable lifecycle through mounted state.', + title: 'Lifecycle', +}; + +export const inputSchema = z.object({ + action: z.enum(['exceed-budget', 'observe', 'transition']), + emitProgress: z.boolean().optional(), + idempotencyKey: z.string().optional(), + payload: z.string().optional(), + phase: lifecyclePhaseSchema.optional(), +}).strict(); + +export const resultSchema = z.object({ + budgetError: z.literal('budget-exceeded').optional(), + history: z.array(lifecyclePhaseSchema), + noticeState: z.literal('pending').optional(), + phase: z.union([z.literal('unknown'), lifecyclePhaseSchema]), + replayed: z.boolean(), + revision: z.number().int().nonnegative(), +}); + +type LifecyclePhase = z.infer; + +interface LifecycleState { + readonly lifecycle: { + readonly history: readonly LifecyclePhase[]; + readonly phase: 'unknown' | LifecyclePhase; + }; +} + +export default async function Lifecycle({ input }: { readonly input: z.infer }) { + const context = await agent(); + if (context.state === undefined) throw new TypeError('Lifecycle state is unavailable.'); + + if (input.action === 'observe') { + const snapshot = await context.state.read(); + const lifecycle = (snapshot.state as LifecycleState).lifecycle; + return ( + + {`lifecycle: ${lifecycle.phase}`} + + ); + } + + if (input.action === 'exceed-budget') { + try { + await context.state.dispatch('transitioned', { + payload: input.payload ?? '', + phase: 'terminal', + }, { + idempotencyKey: 'lifecycle:budget', + }); + throw new TypeError('Lifecycle budget fixture unexpectedly committed.'); + } catch (error) { + if (!(error instanceof AgentStateError) || error.code !== 'budget-exceeded') throw error; + const snapshot = await context.state.read(); + const lifecycle = (snapshot.state as LifecycleState).lifecycle; + return ( + + {`lifecycle: ${lifecycle.phase}`} + + ); + } + } + + if (input.phase === undefined || input.idempotencyKey === undefined) { + throw new TypeError('Lifecycle transitions require phase and idempotencyKey.'); + } + if (input.emitProgress === true) { + const reports = input.phase === 'repeated-progress' ? 2 : 1; + for (let completed = 1; completed <= reports; completed += 1) { + await context.progress.report({ + completed, + message: `${input.phase}:${String(completed)}`, + total: reports, + }); + } + await delay(10); + } + const committed = await context.state.dispatch('transitioned', { + phase: input.phase, + }, { + idempotencyKey: input.idempotencyKey, + }); + let noticeState: 'pending' | undefined; + if (input.phase === 'terminal') { + if (context.notices === undefined) throw new TypeError('Lifecycle notices are unavailable.'); + if (context.workspace.state !== 'available') throw new TypeError('Lifecycle workspace identity is unavailable.'); + const published = await context.notices.publish({ + content: { + root: { kind: 'text', text: 'lifecycle terminal' }, + status: 'success', + version: 1, + }, + priority: 'normal', + recipient: { workspace: context.workspace.value }, + }, { + idempotencyKey: 'lifecycle:terminal-notice', + }); + noticeState = published.notice.state === 'pending' ? 'pending' : undefined; + } + const lifecycle = (committed.state as LifecycleState).lifecycle; + return ( + + {`lifecycle: ${lifecycle.phase}`} + + ); +} diff --git a/packages/agent-bundle/fixtures/route-harness/src/state.ts b/packages/agent-bundle/fixtures/route-harness/src/state.ts index de020e61b..53aadbb9b 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/state.ts +++ b/packages/agent-bundle/fixtures/route-harness/src/state.ts @@ -5,19 +5,60 @@ const journalEntrySchema = z.object({ note: z.string(), }).strict(); +const lifecyclePhaseSchema = z.enum([ + 'queued', + 'running', + 'first-progress', + 'repeated-progress', + 'terminal', +]); + export default defineState({ + budgets: { + maxEventBytes: 256, + }, events: { recorded: journalEntrySchema, + transitioned: z.object({ + payload: z.string().optional(), + phase: lifecyclePhaseSchema, + }).strict(), }, id: 'route-harness/journal', initial: { entries: [], + lifecycle: { + history: [], + phase: 'unknown' as const, + }, }, lifetime: 'workspace-durable', - reduce: (state, event) => ({ - entries: [...state.entries, event.payload], - }), + reduce: (state, event) => { + switch (event.name) { + case 'recorded': + return { + ...state, + entries: [...state.entries, event.payload], + }; + case 'transitioned': + return { + ...state, + lifecycle: { + history: [...state.lifecycle.history, event.payload.phase], + phase: event.payload.phase, + }, + }; + default: { + const exhaustive: never = event; + return exhaustive; + } + } + }, schema: z.object({ entries: z.array(journalEntrySchema), + lifecycle: z.object({ + history: z.array(lifecyclePhaseSchema), + phase: z.union([z.literal('unknown'), lifecyclePhaseSchema]), + }).strict(), }).strict(), }); diff --git a/packages/agent-bundle/src/test/contract.ts b/packages/agent-bundle/src/test/contract.ts index 0a860217a..f6e52ca26 100644 --- a/packages/agent-bundle/src/test/contract.ts +++ b/packages/agent-bundle/src/test/contract.ts @@ -4,8 +4,9 @@ * * Both entry points share one implementation. Boundary differences are explicit * capability flags, not forked check logic. The project supplies only fixtures - * — valid inputs, declared result-compat policy, version-skew payloads, and - * optional cancellation cases — not the check logic itself. + * — valid inputs, declared result-compat policy, version-skew payloads, + * optional cancellation cases, and deterministic lifecycle transitions — + * not the transport, schema, render, or assertion logic itself. * * **`runContractMatrix` (`mcp-in-memory`)** opens one real MCP client against * the real generated server over the SDK's in-memory transport and runs the @@ -26,8 +27,10 @@ * every tool result through its bundled `resultSchema` before returning; a * successful sweep invocation is that evidence. * - * **Neither boundary proves:** host install, browser App HTML, or lifecycle - * replay across artifact rebuilds (stage 2+). + * Stateful lifecycle fixtures replay over one open client at both boundaries. + * Same-store restart callbacks add boundary-local durability evidence; a run + * without one reports restart durability as not-applicable. Neither boundary + * proves host install, browser App HTML, or state-lifetime catalog identity. */ import type { Client } from '@modelcontextprotocol/client'; @@ -50,6 +53,53 @@ import type { AgentRouteModule, TestableRouteDescriptor } from './types.ts'; /** Declared serialized-result compatibility policy for tool routes. */ export type ResultCompatPolicy = 'additive' | 'closed'; +export type ContractLifecyclePhase = + | 'unknown' + | 'queued' + | 'running' + | 'first-progress' + | 'repeated-progress' + | 'terminal'; + +export interface ContractLifecycleTransition { + readonly expectedStructuredContent: unknown; + readonly input: unknown; + readonly phase: ContractLifecyclePhase; + readonly progressNotifications: number; + readonly renderedTextIncludes?: string; +} + +export interface ContractLifecycleFixture { + readonly state?: { + readonly budget?: { + readonly codePath: readonly string[]; + readonly expectedCode: string; + readonly input: unknown; + readonly revisionPath: readonly string[]; + }; + readonly durability?: { + readonly expectedStructuredContent: unknown; + readonly input: unknown; + }; + readonly idempotency?: { + readonly phase: ContractLifecyclePhase; + readonly replayedPath: readonly string[]; + readonly revisionPath: readonly string[]; + }; + readonly journal?: { + readonly expected: unknown; + readonly path: readonly string[]; + }; + readonly notice?: { + readonly expected: unknown; + readonly path: readonly string[]; + readonly phase: ContractLifecyclePhase; + }; + }; + /** Pure deterministic phase driver; transport and assertions remain framework-owned. */ + readonly transitionDriver: () => readonly ContractLifecycleTransition[]; +} + export interface ContractRouteFixture { /** Valid input for the sweep invocation (tools/prompts; resources need none). */ readonly input?: unknown; @@ -67,6 +117,12 @@ export interface ContractRouteFixture { * leave the session usable. */ readonly cancellation?: { readonly abortAfterMs?: number; readonly input?: unknown }; + /** Optional stateful replay over this matrix run's single open client. */ + readonly lifecycle?: ContractLifecycleFixture; +} + +export interface ContractMatrixRestartSession { + readonly client: Client; } export interface ContractMatrixOptions extends InMemoryMcpSessionOptions { @@ -74,6 +130,8 @@ export interface ContractMatrixOptions extends InMemoryMcpSessionOptions { readonly server?: string; /** Route id -> fixture. Every compiled non-app route on the server must be covered. */ readonly fixtures: Readonly>; + /** Reopens the same durable store after the matrix closes its initial in-memory session. */ + readonly restart?: () => Promise; } export type ContractCheckStatus = 'failed' | 'not-applicable' | 'passed'; @@ -100,6 +158,8 @@ export interface PackedContractMatrixOptions { readonly server?: string; /** An already-open packed session; this entry point never opens or closes it. */ readonly session: PackedMcpSession; + /** Caller-owned packed restart; it must close the initial session and reopen the same artifact/store. */ + readonly restart?: () => Promise; } interface MatrixBoundaryCapabilities { @@ -107,6 +167,7 @@ interface MatrixBoundaryCapabilities { readonly moduleSchemaNotApplicableReason: string; readonly proofLevel: AgentTestProofLevel; readonly registersAppResources: boolean; + readonly restart?: () => Promise; } const PACKED_MODULE_SCHEMA_NOT_APPLICABLE_REASON = @@ -119,12 +180,16 @@ const IN_MEMORY_BOUNDARY: MatrixBoundaryCapabilities = Object.freeze({ registersAppResources: false, }); -const packedBoundaryFromSession = (session: PackedMcpSession): MatrixBoundaryCapabilities => +const packedBoundaryFromSession = ( + session: PackedMcpSession, + restart: (() => Promise) | undefined, +): MatrixBoundaryCapabilities => Object.freeze({ canLoadRouteModules: false, moduleSchemaNotApplicableReason: PACKED_MODULE_SCHEMA_NOT_APPLICABLE_REASON, proofLevel: session.provenance.proofLevel, registersAppResources: true, + ...(restart === undefined ? {} : { restart }), }); const COMPAT_PROBE_KEY = '__agentBundleContractProbe'; @@ -137,6 +202,15 @@ const CHECK_COMPAT_PROBE = 'compat-probe'; const CHECK_VERSION_SKEW = 'version-skew'; const CHECK_NEGATIVE_INPUTS = 'negative-inputs'; const CHECK_CANCELLATION = 'cancellation'; +const CHECK_LIFECYCLE_REPLAY = 'lifecycle-replay'; +const CHECK_LIFECYCLE_SERIALIZED_ROUND_TRIP = 'lifecycle-serialized-round-trip'; +const CHECK_LIFECYCLE_COMPAT_PROBE = 'lifecycle-compat-probe'; +const CHECK_LIVE_PROGRESS = 'live-progress-before-terminal'; +const CHECK_STATE_JOURNAL = 'state-journal'; +const CHECK_STATE_NOTICE = 'state-notice'; +const CHECK_STATE_IDEMPOTENCY = 'state-idempotency'; +const CHECK_STATE_BUDGET = 'state-budget'; +const CHECK_RESTART_DURABILITY = 'restart-durability'; interface MatrixFailure { readonly check: string; @@ -305,7 +379,7 @@ const listLiveSurface = async (client: Client): Promise => { }; type ToolInvocationResult = - | { readonly isError: boolean; readonly structuredContent?: unknown; readonly threw: false } + | { readonly content?: unknown; readonly isError: boolean; readonly structuredContent?: unknown; readonly threw: false } | { readonly threw: true; readonly error: unknown }; const invocationCacheKey = (name: string, input: unknown): string => @@ -317,6 +391,7 @@ const callToolResult = async ( input: unknown, options?: { readonly cache?: Map; + readonly progressToken?: string | number; readonly signal?: AbortSignal; readonly timeout?: number; }, @@ -327,13 +402,20 @@ const callToolResult = async ( } try { const result = await client.callTool( - { arguments: (input ?? {}) as Record, name }, + { + arguments: (input ?? {}) as Record, + name, + ...(options?.progressToken === undefined + ? {} + : { _meta: { progressToken: options.progressToken } }), + }, { ...(options?.signal === undefined ? {} : { signal: options.signal }), ...(options?.timeout === undefined ? {} : { timeout: options.timeout }), }, - ) as { isError?: boolean; structuredContent?: unknown }; + ) as { content?: unknown; isError?: boolean; structuredContent?: unknown }; const settled: ToolInvocationResult = { + ...(result.content === undefined ? {} : { content: result.content }), isError: result.isError === true, ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }), threw: false, @@ -741,6 +823,251 @@ const runCancellation = async ( return passed(); }; +const LIFECYCLE_PHASES: readonly ContractLifecyclePhase[] = Object.freeze([ + 'unknown', + 'queued', + 'running', + 'first-progress', + 'repeated-progress', + 'terminal', +]); + +const valueAtPath = (value: unknown, path: readonly string[]): unknown => { + let current = value; + for (const key of path) { + if (typeof current !== 'object' || current === null || Array.isArray(current)) return undefined; + current = (current as Record)[key]; + } + return current; +}; + +const containsExpected = (actual: unknown, expected: unknown): boolean => { + if (Array.isArray(expected)) { + return Array.isArray(actual) + && actual.length === expected.length + && expected.every((entry, index) => containsExpected(actual[index], entry)); + } + if (typeof expected === 'object' && expected !== null) { + if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) return false; + return Object.entries(expected).every(([key, value]) => + containsExpected((actual as Record)[key], value)); + } + return Object.is(actual, expected); +}; + +const renderedText = (content: unknown): string => Array.isArray(content) + ? content.flatMap((block) => { + if (typeof block !== 'object' || block === null || Array.isArray(block)) return []; + const text = (block as { readonly text?: unknown }).text; + return typeof text === 'string' ? [text] : []; + }).join('\n') + : ''; + +interface LifecyclePhaseEvidence { + readonly liveProgress: number; + readonly result: ToolInvocationResult; + readonly transition: ContractLifecycleTransition; +} + +interface LifecycleEvidence { + readonly byPhase: ReadonlyMap; + readonly orderFailure?: string; +} + +const executeLifecycleTransitions = async ( + client: Client, + descriptor: TestableRouteDescriptor, + lifecycle: ContractLifecycleFixture, +): Promise => { + let transitions: readonly ContractLifecycleTransition[]; + try { + transitions = lifecycle.transitionDriver(); + } catch (error) { + return { + byPhase: new Map(), + orderFailure: `transitionDriver threw: ${error instanceof Error ? error.message : captured(error)}`, + }; + } + const actualPhases = transitions.map((transition) => transition.phase); + if ( + actualPhases.length !== LIFECYCLE_PHASES.length + || actualPhases.some((phase, index) => phase !== LIFECYCLE_PHASES[index]) + ) { + return { + byPhase: new Map(), + orderFailure: `transitionDriver returned ${actualPhases.join(' → ') || '(no phases)'}; expected ${LIFECYCLE_PHASES.join(' → ')}`, + }; + } + + const byPhase = new Map(); + for (const [index, transition] of transitions.entries()) { + const progressToken = `agent-bundle-contract-lifecycle:${descriptor.id}:${String(index)}`; + let settled = false; + let liveProgress = 0; + client.setNotificationHandler('notifications/progress', (notification) => { + if (notification.params.progressToken === progressToken && !settled) liveProgress += 1; + }); + const result = await callToolResult( + client, + routeProtocolName(descriptor), + transition.input, + { progressToken, timeout: 10_000 }, + ); + settled = true; + byPhase.set(transition.phase, { liveProgress, result, transition }); + } + return { byPhase }; +}; + +const checkLifecycleReplay = (evidence: LifecycleEvidence): ContractCheckOutcome => { + if (evidence.orderFailure !== undefined) return failed(evidence.orderFailure); + for (const phase of LIFECYCLE_PHASES) { + const phaseEvidence = evidence.byPhase.get(phase); + if (phaseEvidence === undefined) return failed(`transitionDriver produced no ${phase} evidence`); + const { result, transition } = phaseEvidence; + if (result.threw) { + return failed(`${phase} callTool threw: ${result.error instanceof Error ? result.error.message : captured(result.error)}`); + } + if (result.isError || result.structuredContent === undefined) { + return failed(`${phase} did not return successful structuredContent`); + } + if (!containsExpected(result.structuredContent, transition.expectedStructuredContent)) { + return failed(`${phase} structuredContent did not include ${captured(transition.expectedStructuredContent)}; received ${captured(result.structuredContent)}`); + } + if (renderedText(result.content) === '') { + return failed(`${phase} returned no rendered Agent Document text output`); + } + if (transition.renderedTextIncludes !== undefined && !renderedText(result.content).includes(transition.renderedTextIncludes)) { + return failed(`${phase} rendered output did not include ${JSON.stringify(transition.renderedTextIncludes)}`); + } + } + return passed(); +}; + +const checkLifecycleSchema = ( + evidence: LifecycleEvidence, + module: AgentRouteModule & { readonly resultSchema: { parse: (value: unknown) => unknown } }, +): ContractCheckOutcome => { + if (evidence.orderFailure !== undefined) return notApplicable('lifecycle phase order failed before schema validation.'); + for (const phase of LIFECYCLE_PHASES) { + const result = evidence.byPhase.get(phase)?.result; + if (result === undefined || result.threw || result.isError || result.structuredContent === undefined) { + return notApplicable(`${phase} produced no successful structuredContent to validate.`); + } + try { + module.resultSchema.parse(serializedRoundTrip(result.structuredContent)); + } catch (error) { + return failed(`${phase} JSON round-tripped structuredContent failed resultSchema.parse: ${error instanceof Error ? error.message : captured(error)}`); + } + } + return passed(); +}; + +const checkLifecycleCompat = ( + evidence: LifecycleEvidence, + fixture: ContractRouteFixture, + module: AgentRouteModule & { readonly resultSchema: { parse: (value: unknown) => unknown } }, +): ContractCheckOutcome => { + if (fixture.resultCompat === undefined) return failed('lifecycle tool fixture must declare resultCompat.'); + for (const phase of LIFECYCLE_PHASES) { + const result = evidence.byPhase.get(phase)?.result; + if (result === undefined || result.threw || result.isError || result.structuredContent === undefined) { + return notApplicable(`${phase} produced no successful structuredContent for compat probing.`); + } + const probe = compatProbe( + serializedRoundTrip(result.structuredContent), + fixture.resultCompat, + module.resultSchema.parse.bind(module.resultSchema), + ); + if (fixture.resultCompat === 'additive' && !probe.accepted) { + return failed(`${phase} declared additive policy but resultSchema rejected unknown key ${JSON.stringify(COMPAT_PROBE_KEY)}`); + } + if (fixture.resultCompat === 'closed' && probe.accepted) { + return failed(`${phase} declared closed policy but resultSchema accepted unknown key ${JSON.stringify(COMPAT_PROBE_KEY)}`); + } + } + return passed(); +}; + +const checkLiveProgress = (evidence: LifecycleEvidence): ContractCheckOutcome => { + if (evidence.orderFailure !== undefined) return notApplicable('lifecycle phase order failed before progress assertions.'); + for (const phase of LIFECYCLE_PHASES) { + const phaseEvidence = evidence.byPhase.get(phase); + if (phaseEvidence === undefined) continue; + if (phaseEvidence.liveProgress < phaseEvidence.transition.progressNotifications) { + return failed( + `${phase} exposed ${String(phaseEvidence.liveProgress)} live progress notification(s) before settlement; expected at least ${String(phaseEvidence.transition.progressNotifications)}`, + ); + } + } + return passed(); +}; + +const checkLifecyclePath = ( + evidence: LifecycleEvidence, + phase: ContractLifecyclePhase, + path: readonly string[], + expected: unknown, +): ContractCheckOutcome => { + const result = evidence.byPhase.get(phase)?.result; + if (result === undefined || result.threw || result.structuredContent === undefined) { + return notApplicable(`${phase} produced no structuredContent for state assertion.`); + } + const actual = valueAtPath(result.structuredContent, path); + return containsExpected(actual, expected) + ? passed() + : failed(`${phase} structuredContent path ${path.join('.')} expected ${captured(expected)}; received ${captured(actual)}`); +}; + +const runStateIdempotency = async ( + client: Client, + descriptor: TestableRouteDescriptor, + evidence: LifecycleEvidence, + assertion: NonNullable['idempotency']>, +): Promise => { + const original = evidence.byPhase.get(assertion.phase); + if (original === undefined || original.result.threw || original.result.structuredContent === undefined) { + return notApplicable(`${assertion.phase} produced no structuredContent for idempotency replay.`); + } + const replay = await callToolResult(client, routeProtocolName(descriptor), original.transition.input); + if (replay.threw || replay.isError || replay.structuredContent === undefined) { + return failed(`replaying ${assertion.phase} did not return successful structuredContent`); + } + const originalRevision = valueAtPath(original.result.structuredContent, assertion.revisionPath); + const replayRevision = valueAtPath(replay.structuredContent, assertion.revisionPath); + if (!Object.is(originalRevision, replayRevision)) { + return failed(`idempotent replay changed revision from ${captured(originalRevision)} to ${captured(replayRevision)}`); + } + return valueAtPath(replay.structuredContent, assertion.replayedPath) === true + ? passed() + : failed(`idempotent replay did not report true at ${assertion.replayedPath.join('.')}`); +}; + +const runStateBudget = async ( + client: Client, + descriptor: TestableRouteDescriptor, + evidence: LifecycleEvidence, + assertion: NonNullable['budget']>, +): Promise => { + const terminal = evidence.byPhase.get('terminal')?.result; + if (terminal === undefined || terminal.threw || terminal.structuredContent === undefined) { + return notApplicable('terminal produced no structuredContent for budget boundary comparison.'); + } + const result = await callToolResult(client, routeProtocolName(descriptor), assertion.input); + if (result.threw || result.isError || result.structuredContent === undefined) { + return failed('budget probe did not return typed successful fixture evidence'); + } + const code = valueAtPath(result.structuredContent, assertion.codePath); + if (code !== assertion.expectedCode) { + return failed(`budget probe expected typed code ${JSON.stringify(assertion.expectedCode)}; received ${captured(code)}`); + } + const before = valueAtPath(terminal.structuredContent, assertion.revisionPath); + const after = valueAtPath(result.structuredContent, assertion.revisionPath); + return Object.is(before, after) + ? passed() + : failed(`budget-exceeded commit changed revision from ${captured(before)} to ${captured(after)}`); +}; + const matrixRouteDescriptors = ( manifest: AgentBundleTestManifest, serverName: string, @@ -811,6 +1138,11 @@ const executeContractMatrix = async (options: { const surface = await listLiveSurface(client); const invocationCache = new Map(); + const durabilityChecks: Array<{ + readonly assertion: NonNullable['durability']>; + readonly checks: Record; + readonly descriptor: TestableRouteDescriptor; + }> = []; for (const descriptor of matrixRouteDescriptors(manifest, serverName)) { if (descriptor.kind === 'app' && !boundary.registersAppResources) { @@ -906,17 +1238,157 @@ const executeContractMatrix = async (options: { CHECK_CANCELLATION, await runCancellation(client, descriptor, fixture, invocationCache), ); + + if (fixture.lifecycle === undefined) { + const reason = 'no lifecycle fixture declared.'; + checks[CHECK_LIFECYCLE_REPLAY] = notApplicable(reason); + checks[CHECK_LIFECYCLE_SERIALIZED_ROUND_TRIP] = notApplicable(reason); + checks[CHECK_LIFECYCLE_COMPAT_PROBE] = notApplicable(reason); + checks[CHECK_LIVE_PROGRESS] = notApplicable(reason); + checks[CHECK_STATE_JOURNAL] = notApplicable(reason); + checks[CHECK_STATE_NOTICE] = notApplicable(reason); + checks[CHECK_STATE_IDEMPOTENCY] = notApplicable(reason); + checks[CHECK_STATE_BUDGET] = notApplicable(reason); + checks[CHECK_RESTART_DURABILITY] = notApplicable(reason); + } else { + const lifecycle = fixture.lifecycle; + const evidence = await executeLifecycleTransitions(client, descriptor, lifecycle); + checks[CHECK_LIFECYCLE_REPLAY] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_LIFECYCLE_REPLAY, + checkLifecycleReplay(evidence), + ); + checks[CHECK_LIVE_PROGRESS] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_LIVE_PROGRESS, + checkLiveProgress(evidence), + ); + if (boundary.canLoadRouteModules) { + const module = await loadRouteModule(manifest, descriptor); + checks[CHECK_LIFECYCLE_SERIALIZED_ROUND_TRIP] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_LIFECYCLE_SERIALIZED_ROUND_TRIP, + checkLifecycleSchema(evidence, module), + ); + checks[CHECK_LIFECYCLE_COMPAT_PROBE] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_LIFECYCLE_COMPAT_PROBE, + checkLifecycleCompat(evidence, fixture, module), + ); + } else { + checks[CHECK_LIFECYCLE_SERIALIZED_ROUND_TRIP] = moduleSchemaNotApplicable(); + checks[CHECK_LIFECYCLE_COMPAT_PROBE] = moduleSchemaNotApplicable(); + } + const journal = lifecycle.state?.journal; + checks[CHECK_STATE_JOURNAL] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_STATE_JOURNAL, + journal === undefined + ? notApplicable('no lifecycle state journal assertion declared.') + : checkLifecyclePath(evidence, 'terminal', journal.path, journal.expected), + ); + const notice = lifecycle.state?.notice; + checks[CHECK_STATE_NOTICE] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_STATE_NOTICE, + notice === undefined + ? notApplicable('no lifecycle notice assertion declared.') + : checkLifecyclePath(evidence, notice.phase, notice.path, notice.expected), + ); + const idempotency = lifecycle.state?.idempotency; + checks[CHECK_STATE_IDEMPOTENCY] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_STATE_IDEMPOTENCY, + idempotency === undefined + ? notApplicable('no lifecycle idempotency assertion declared.') + : await runStateIdempotency(client, descriptor, evidence, idempotency), + ); + const budget = lifecycle.state?.budget; + checks[CHECK_STATE_BUDGET] = outcomeFromCheck( + failures, + descriptor.id, + CHECK_STATE_BUDGET, + budget === undefined + ? notApplicable('no lifecycle budget assertion declared.') + : await runStateBudget(client, descriptor, evidence, budget), + ); + const durability = lifecycle.state?.durability; + if (durability === undefined) { + checks[CHECK_RESTART_DURABILITY] = notApplicable( + 'no lifecycle restart-durability assertion declared.', + ); + } else { + durabilityChecks.push({ assertion: durability, checks, descriptor }); + } + } } else { checks[CHECK_SERIALIZED_ROUND_TRIP] = notApplicable('applies to tool routes only.'); checks[CHECK_COMPAT_PROBE] = notApplicable('applies to tool routes only.'); checks[CHECK_VERSION_SKEW] = notApplicable('applies to tool routes only.'); checks[CHECK_NEGATIVE_INPUTS] = notApplicable('applies to tool routes only.'); checks[CHECK_CANCELLATION] = notApplicable('applies to tool routes only.'); + checks[CHECK_LIFECYCLE_REPLAY] = notApplicable('applies to tool routes only.'); + checks[CHECK_LIFECYCLE_SERIALIZED_ROUND_TRIP] = notApplicable('applies to tool routes only.'); + checks[CHECK_LIFECYCLE_COMPAT_PROBE] = notApplicable('applies to tool routes only.'); + checks[CHECK_LIVE_PROGRESS] = notApplicable('applies to tool routes only.'); + checks[CHECK_STATE_JOURNAL] = notApplicable('applies to tool routes only.'); + checks[CHECK_STATE_NOTICE] = notApplicable('applies to tool routes only.'); + checks[CHECK_STATE_IDEMPOTENCY] = notApplicable('applies to tool routes only.'); + checks[CHECK_STATE_BUDGET] = notApplicable('applies to tool routes only.'); + checks[CHECK_RESTART_DURABILITY] = notApplicable('applies to tool routes only.'); } routeReports[descriptor.id] = { checks }; } + if (durabilityChecks.length > 0) { + if (boundary.restart === undefined) { + for (const pending of durabilityChecks) { + pending.checks[CHECK_RESTART_DURABILITY] = notApplicable( + 'this matrix run was not supplied a same-store restart callback; durability cannot be inferred from the initial connection.', + ); + } + } else { + let restarted: ContractMatrixRestartSession | undefined; + let restartError: unknown; + try { + restarted = await boundary.restart(); + } catch (error) { + restartError = error; + } + for (const pending of durabilityChecks) { + let outcome: ContractCheckOutcome; + if (restarted === undefined) { + outcome = failed(`same-store restart failed: ${restartError instanceof Error ? restartError.message : captured(restartError)}`); + } else { + const result = await callToolResult( + restarted.client, + routeProtocolName(pending.descriptor), + pending.assertion.input, + ); + outcome = result.threw || result.isError || result.structuredContent === undefined + ? failed('restarted session did not return successful structuredContent') + : containsExpected(result.structuredContent, pending.assertion.expectedStructuredContent) + ? passed() + : failed(`restarted structuredContent did not include ${captured(pending.assertion.expectedStructuredContent)}; received ${captured(result.structuredContent)}`); + } + pending.checks[CHECK_RESTART_DURABILITY] = outcomeFromCheck( + failures, + pending.descriptor.id, + CHECK_RESTART_DURABILITY, + outcome, + ); + } + } + } + return finalizeContractMatrixReport(failures, boundary, provenance, routeReports); }; @@ -931,15 +1403,28 @@ export const runContractMatrix = async ( ): Promise => { const manifest = options.manifest ?? testManifest(); const serverName = resolveServerName(manifest, options.server); - await using session = await openInMemoryMcpServer(options); - return await executeContractMatrix({ - boundary: IN_MEMORY_BOUNDARY, - client: session.client, - fixtures: options.fixtures, - manifest, - provenance: session.provenance, - serverName, - }); + const session = await openInMemoryMcpServer(options); + try { + const boundary: MatrixBoundaryCapabilities = options.restart === undefined + ? IN_MEMORY_BOUNDARY + : Object.freeze({ + ...IN_MEMORY_BOUNDARY, + restart: async () => { + await session.close(); + return options.restart!(); + }, + }); + return await executeContractMatrix({ + boundary, + client: session.client, + fixtures: options.fixtures, + manifest, + provenance: session.provenance, + serverName, + }); + } finally { + await session.close(); + } }; /** @@ -951,7 +1436,7 @@ export const runPackedContractMatrix = async ( ): Promise => { const serverName = resolveServerName(options.manifest, options.server); return executeContractMatrix({ - boundary: packedBoundaryFromSession(options.session), + boundary: packedBoundaryFromSession(options.session, options.restart), client: options.session.client, fixtures: options.fixtures, manifest: options.manifest, diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 0d83031c0..e378abf03 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -89,9 +89,13 @@ export { export type { ContractCheckOutcome, ContractCheckStatus, + ContractLifecycleFixture, + ContractLifecyclePhase, + ContractLifecycleTransition, ContractMatrixOptions, ContractMatrixProvenance, ContractMatrixReport, + ContractMatrixRestartSession, ContractRouteFixture, ContractRouteReport, PackedContractMatrixOptions, diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index a8f6f6b01..0d95b5fec 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -9,7 +9,11 @@ import { expect, it } from '@rstest/core'; import { requestEventRuntime } from '../src/events/ipc.ts'; import { compileTestManifest } from '../src/test/manifest.ts'; import { runPackedContractMatrix } from '../src/test/contract.ts'; -import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; +import { + openPackedMcpServer, + removeProjectSource, + type PackedMcpSession, +} from '../src/test/packed.ts'; import { routeHarnessPackedContractFixtures } from './support/contract-matrix-fixtures.ts'; import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; @@ -99,6 +103,7 @@ it('serves compiled routes and durable state across packed process restarts', as entry, env, }); + let secondSession: PackedMcpSession | undefined; let noticeId: string; try { expect(firstSession.provenance.proofLevel).toBe('packed-deleted-source'); @@ -111,6 +116,7 @@ it('serves compiled routes and durable state across packed process restarts', as 'context', 'echo', 'journal', + 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', @@ -124,35 +130,6 @@ it('serves compiled routes and durable state across packed process restarts', as expect.objectContaining({ mimeType: 'text/html;profile=mcp-app', uri: 'ui://harness/panel' }), ])); - const matrixReport = await runPackedContractMatrix({ - fixtures: routeHarnessPackedContractFixtures(), - manifest: harnessManifest, - server: 'harness', - session: firstSession, - }); - expect(matrixReport.provenance.proofLevel).toBe('packed-deleted-source'); - expect(matrixReport.routes['app:harness/panel']?.checks['surface-completeness']).toEqual({ - status: 'passed', - }); - for (const routeId of [ - 'tool:harness/echo', - 'tool:harness/ticket', - 'tool:harness/strict-report', - ] as const) { - expect(matrixReport.routes[routeId]?.checks['serialized-round-trip']).toEqual({ - reason: expect.stringContaining('packed sessions cannot load project route modules'), - status: 'not-applicable', - }); - expect(matrixReport.routes[routeId]?.checks['compat-probe']).toEqual({ - reason: expect.stringContaining('packed sessions cannot load project route modules'), - status: 'not-applicable', - }); - expect(matrixReport.routes[routeId]?.checks['version-skew']).toEqual({ - reason: expect.stringContaining('packed sessions cannot load project route modules'), - status: 'not-applicable', - }); - } - await expect(firstSession.client.callTool({ arguments: { message: 'packed' }, name: 'echo' })) .resolves.toMatchObject({ content: [{ text: '# Echo\n\npacked', type: 'text' }, { text: expect.stringContaining('workspace:'), type: 'text' }], @@ -220,6 +197,33 @@ it('serves compiled routes and durable state across packed process restarts', as })).resolves.toEqual({ messages: [{ content: { text: 'Summarize chapter one', type: 'text' }, role: 'user' }], }); + const matrixReport = await runPackedContractMatrix({ + fixtures: routeHarnessPackedContractFixtures(), + manifest: harnessManifest, + restart: async () => { + await firstSession.close(); + secondSession = await openPackedMcpServer({ + cwd: project, + deletedSource, + entry, + env, + }); + return secondSession; + }, + server: 'harness', + session: firstSession, + }); + expect(matrixReport.provenance.proofLevel).toBe('packed-deleted-source'); + expect(matrixReport.routes['app:harness/panel']?.checks['surface-completeness']).toEqual({ + status: 'passed', + }); + expect(matrixReport.routes['tool:harness/lifecycle']?.checks['restart-durability']).toEqual({ + status: 'passed', + }); + expect(matrixReport.routes['tool:harness/lifecycle']?.checks['lifecycle-serialized-round-trip']).toEqual({ + reason: expect.stringContaining('packed sessions cannot load project route modules'), + status: 'not-applicable', + }); expect(firstSession.stderr()).not.toContain('"jsonrpc"'); } finally { await firstSession.close(); @@ -230,16 +234,11 @@ it('serves compiled routes and durable state across packed process restarts', as expect.stringMatching(/\.sqlite$/u), ])); - const secondSession = await openPackedMcpServer({ - cwd: project, - deletedSource, - entry, - env, - }); + if (secondSession === undefined) throw new TypeError('Contract matrix did not restart the packed session.'); try { await expect(secondSession.client.callTool({ arguments: {}, name: 'journal' })) .resolves.toMatchObject({ - structuredContent: { entries: [{ note: 'packed durable proof' }], revision: 1 }, + structuredContent: { entries: [{ note: 'packed durable proof' }], revision: 6 }, }); const artifactManifest = JSON.parse( await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8'), diff --git a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts index 3207deba9..2fd16d67d 100644 --- a/packages/agent-bundle/tests/projection/cli-dispatch.test.ts +++ b/packages/agent-bundle/tests/projection/cli-dispatch.test.ts @@ -31,6 +31,7 @@ describe('the CLI dispatch level', () => { 'harness context', 'harness echo', 'harness journal', + 'harness lifecycle', 'harness mutation-probe', 'harness publish-notice', 'harness strict-report', diff --git a/packages/agent-bundle/tests/projection/contract-matrix.test.ts b/packages/agent-bundle/tests/projection/contract-matrix.test.ts index 72161d81c..ae10f5e5c 100644 --- a/packages/agent-bundle/tests/projection/contract-matrix.test.ts +++ b/packages/agent-bundle/tests/projection/contract-matrix.test.ts @@ -9,7 +9,11 @@ import stateDefinition from '../../fixtures/route-harness/src/state.ts'; import { AgentTestError } from '../../src/test/errors.ts'; import { MCP_IN_MEMORY_PROOF_LEVEL, proofLevelLabel } from '../../src/test/manifest.ts'; import { runContractMatrix, type ContractMatrixOptions } from '../../src/test/contract.ts'; -import { routeHarnessContractFixtures } from '../support/contract-matrix-fixtures.ts'; +import { openInMemoryMcpServer, type InMemoryMcpSession } from '../../src/test/mcp.ts'; +import { + routeHarnessContractFixtures, + routeHarnessLifecycleWithoutLiveProgress, +} from '../support/contract-matrix-fixtures.ts'; const proofLabel = proofLevelLabel(MCP_IN_MEMORY_PROOF_LEVEL); @@ -18,15 +22,27 @@ const withStatefulMatrix = async ( body: (options: ContractMatrixOptions) => Promise, ): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-contract-matrix-')); + let restarted: InMemoryMcpSession | undefined; try { - return await body({ + const options = { fixtures, + restart: async () => { + restarted = await openInMemoryMcpServer({ + state: { + definition: stateDefinition, + driver: createSqliteStateDriver({ root }), + }, + }); + return restarted; + }, state: { definition: stateDefinition, driver: createSqliteStateDriver({ root }), }, - } as unknown as ContractMatrixOptions); + } as unknown as ContractMatrixOptions; + return await body(options); } finally { + await restarted?.close(); await rm(root, { force: true, recursive: true }); } }; @@ -39,6 +55,13 @@ describe('the generated-plugin contract matrix', () => { expect(report.provenance.proofLevel).toBe('mcp-in-memory'); expect(report.routes['tool:harness/wait']?.checks.cancellation).toEqual({ status: 'passed' }); expect(report.routes['tool:harness/ticket']?.checks['version-skew']).toEqual({ status: 'passed' }); + expect(report.routes['tool:harness/lifecycle']?.checks['lifecycle-replay']).toEqual({ status: 'passed' }); + expect(report.routes['tool:harness/lifecycle']?.checks['live-progress-before-terminal']).toEqual({ + status: 'passed', + }); + expect(report.routes['tool:harness/lifecycle']?.checks['state-idempotency']).toEqual({ status: 'passed' }); + expect(report.routes['tool:harness/lifecycle']?.checks['state-budget']).toEqual({ status: 'passed' }); + expect(report.routes['tool:harness/lifecycle']?.checks['restart-durability']).toEqual({ status: 'passed' }); expect(report.routes['app:harness/panel']?.checks['surface-completeness']).toEqual({ reason: 'MCP Apps are not registered by the in-memory projection level.', status: 'not-applicable', @@ -49,6 +72,17 @@ describe('the generated-plugin contract matrix', () => { }); }, 30_000); + it('reports lifecycle progress that was not live before settlement', async () => { + const error = await withStatefulMatrix(routeHarnessLifecycleWithoutLiveProgress(), (options) => + runContractMatrix(options).catch((thrown: unknown) => thrown)); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).code).toBe('contract-violation'); + expect((error as AgentTestError).message).toContain('tool:harness/lifecycle'); + expect((error as AgentTestError).message).toContain('live-progress-before-terminal'); + expect((error as AgentTestError).message).toContain(proofLabel); + }, 30_000); + it('aggregates missing route coverage with the proof-level label', async () => { const fixtures = { ...routeHarnessContractFixtures() }; delete fixtures['tool:harness/echo']; diff --git a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts index b1716e590..848b27b69 100644 --- a/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts +++ b/packages/agent-bundle/tests/projection/mcp-in-memory.test.ts @@ -30,7 +30,7 @@ describe('the in-memory MCP projection level', () => { it('registers every compiled route kind on the real generated server', async () => { const surface = await listMcpSurface(); - expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'unavailable', 'wait']); + expect(surface.tools).toEqual(['catalog', 'context', 'echo', 'journal', 'lifecycle', 'mutation-probe', 'publish-notice', 'strict-report', 'ticket', 'unavailable', 'wait']); expect(surface.prompts).toEqual(['summarize']); expect(surface.resources).toEqual(['harness://notes']); expect(surface.provenance).toMatchObject({ @@ -42,6 +42,7 @@ describe('the in-memory MCP projection level', () => { 'tool:harness/context', 'tool:harness/echo', 'tool:harness/journal', + 'tool:harness/lifecycle', 'tool:harness/mutation-probe', 'tool:harness/publish-notice', 'tool:harness/strict-report', diff --git a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts index 2b17579a3..ecf3f6411 100644 --- a/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts +++ b/packages/agent-bundle/tests/support/contract-matrix-fixtures.ts @@ -1,4 +1,80 @@ -import type { ContractRouteFixture } from '../../src/test/contract.ts'; +import type { + ContractLifecyclePhase, + ContractRouteFixture, +} from '../../src/test/contract.ts'; + +const lifecyclePhases: readonly ContractLifecyclePhase[] = [ + 'unknown', + 'queued', + 'running', + 'first-progress', + 'repeated-progress', + 'terminal', +]; + +const lifecycleHistory = (phase: ContractLifecyclePhase): readonly ContractLifecyclePhase[] => { + const index = lifecyclePhases.indexOf(phase); + return phase === 'unknown' ? [] : lifecyclePhases.slice(1, index + 1); +}; + +const lifecycleFixture = (revisionOffset = 0): ContractRouteFixture => ({ + input: { action: 'observe' }, + lifecycle: { + state: { + budget: { + codePath: ['budgetError'], + expectedCode: 'budget-exceeded', + input: { action: 'exceed-budget', payload: 'x'.repeat(512) }, + revisionPath: ['revision'], + }, + durability: { + expectedStructuredContent: { + history: lifecyclePhases.slice(1), + phase: 'terminal', + revision: revisionOffset + 5, + }, + input: { action: 'observe' }, + }, + idempotency: { + phase: 'repeated-progress', + replayedPath: ['replayed'], + revisionPath: ['revision'], + }, + journal: { + expected: lifecyclePhases.slice(1), + path: ['history'], + }, + notice: { + expected: 'pending', + path: ['noticeState'], + phase: 'terminal', + }, + }, + transitionDriver: () => lifecyclePhases.map((phase, index) => ({ + expectedStructuredContent: { + history: lifecycleHistory(phase), + phase, + replayed: false, + revision: revisionOffset + index, + ...(phase === 'terminal' ? { noticeState: 'pending' } : {}), + }, + input: phase === 'unknown' + ? { action: 'observe' } + : { + action: 'transition', + emitProgress: phase === 'first-progress' || phase === 'repeated-progress', + idempotencyKey: `lifecycle:${phase}`, + phase, + }, + phase, + progressNotifications: phase === 'first-progress' + ? 1 + : phase === 'repeated-progress' ? 2 : 0, + renderedTextIncludes: `lifecycle: ${phase}`, + })), + }, + resultCompat: 'additive', +}); /** Shared route-harness fixtures for projection-level contract matrix tests. */ export const routeHarnessContractFixtures = (): Record => ({ @@ -7,7 +83,8 @@ export const routeHarnessContractFixtures = (): Record => { + const fixtures = routeHarnessContractFixtures(); + const lifecycle = fixtures['tool:harness/lifecycle']?.lifecycle; + if (lifecycle === undefined) throw new TypeError('Lifecycle fixture is unavailable.'); + return { + ...fixtures, + 'tool:harness/lifecycle': { + ...fixtures['tool:harness/lifecycle'], + lifecycle: { + ...lifecycle, + transitionDriver: () => lifecycle.transitionDriver().map((transition) => ({ + ...transition, + input: transition.phase === 'first-progress' + ? { ...transition.input as Record, emitProgress: false } + : transition.input, + })), + }, + }, + }; +}; diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index cee800db9..2c9174b84 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -74,6 +74,7 @@ describe('the compiled test manifest', () => { 'tool:harness/context', 'tool:harness/echo', 'tool:harness/journal', + 'tool:harness/lifecycle', 'tool:harness/mutation-probe', 'tool:harness/publish-notice', 'tool:harness/strict-report', @@ -192,6 +193,7 @@ describe('the compiled test manifest', () => { projected('context', 'Returns the request identity axes observed by this route.', true), projected('echo', 'Echoes one message back with the observed workspace root.', false), projected('journal', 'Records and reads durable route-harness journal entries.', true), + projected('lifecycle', 'Replays a deterministic durable lifecycle through mounted state.', true), projected('mutation-probe', 'Records how many times the mutation probe executed.', true), projected('publish-notice', 'Publishes a durable notice for a later session event.', true), projected('strict-report', 'Returns a closed-object report that rejects unknown serialized keys.', true),