diff --git a/.changeset/484-mount-test-state.md b/.changeset/484-mount-test-state.md new file mode 100644 index 000000000..fe8d23b84 --- /dev/null +++ b/.changeset/484-mount-test-state.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Export `mountTestState()` and `withTestState()` from `agent-bundle/test`: mount the project's state definition and notice ledger once — a disposable sqlite root for `workspace-durable`, the memory driver otherwise, or `options.driver` — and spread `context()` into any number of `renderRoute` / `renderRouteEvents` calls for a multi-render journey, with `read()` and `notices()` snapshots and one `close()`. `options.definition` mounts an explicit definition instead; a manifest without state or an `external` definition without a driver fails closed (`manifest-unavailable`, `invalid-input`). The worktree-proximity, host-test, and audiobook-curator examples drop their hand-rolled `@agent-bundle/runtime/mount` and `/state` mounts for it. Fixes #484. (#525) diff --git a/examples/audiobook-curator/tests/route-unit/state.test.ts b/examples/audiobook-curator/tests/route-unit/state.test.ts index 1ecf9307b..73006a232 100644 --- a/examples/audiobook-curator/tests/route-unit/state.test.ts +++ b/examples/audiobook-curator/tests/route-unit/state.test.ts @@ -2,16 +2,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { expect, it } from '@rstest/core'; -import { - createAgentStateHandle, - createMemoryStateDriver, - defineState, -} from '@agent-bundle/runtime/state'; -import { expectDocument, renderRoute } from 'agent-bundle/test'; +import { it } from '@rstest/core'; +import { expectDocument, renderRoute, withTestState } from 'agent-bundle/test'; import * as ReviewCurationShelfRoute from '../../src/mcp/curator/tools/review_curation_shelf.js'; -import shelfStateDefinition from '../../src/state.js'; it('persists an Audible selection across tool renders with the same state handle', async () => { const directory = await mkdtemp(join(tmpdir(), 'curator-route-unit-state-')); @@ -44,55 +38,49 @@ it('persists an Audible selection across tool renders with the same state handle reviewNote: 'Choose the matching edition.', })); - const definition = defineState({ - ...shelfStateDefinition, - id: 'audiobook-curator/test-shelf', - lifetime: 'process', - }); - const driver = createMemoryStateDriver({ lifetime: 'process' }); - const store = await driver.open(definition); - const state = createAgentStateHandle(store); - try { - const selected = await renderRoute('tool:curator/select_audible_edition', { - context: { - invocation: { id: 'state-test:select' }, - state, - }, - input: { candidate: 1, candidates }, - }); - - expectDocument(selected) - .toHaveStatus('success') - .toContainText('Recorded human-reviewed Audible candidate 1.') - .toContainMarkdown('The Persisted Edition') - .toContainMarkdown('B0CURATOR01'); - const receipt = selected.document.value as { readonly generatedAt: string }; + // One mounted shelf state (the project's own `src/state.ts`, in a + // disposable store) serves both renders, so the review reads the selection. + await withTestState(async (shelf) => { + const selected = await renderRoute('tool:curator/select_audible_edition', { + context: { + ...shelf.context(), + invocation: { id: 'state-test:select' }, + }, + input: { candidate: 1, candidates }, + }); - const reviewed = await renderRoute('tool:curator/review_curation_shelf', { - context: { - invocation: { id: 'state-test:review' }, - state, - }, - input: {}, - }); + expectDocument(selected) + .toHaveStatus('success') + .toContainText('Recorded human-reviewed Audible candidate 1.') + .toContainMarkdown('The Persisted Edition') + .toContainMarkdown('B0CURATOR01'); + const receipt = selected.document.value as { readonly generatedAt: string }; - expectDocument(reviewed) - .toHaveStatus('success') - .toContainMarkdown('The Persisted Edition') - .toContainMarkdown('B0CURATOR01') - .toHaveValue({ - mutations: [], - selections: [{ - asin: 'B0CURATOR01', - candidateNumber: 1, - region: 'us', - selectedAt: receipt.generatedAt, - title: 'The Persisted Edition', - }], + const reviewed = await renderRoute('tool:curator/review_curation_shelf', { + context: { + ...shelf.context(), + invocation: { id: 'state-test:review' }, + }, + input: {}, }); + + expectDocument(reviewed) + .toHaveStatus('success') + .toContainMarkdown('The Persisted Edition') + .toContainMarkdown('B0CURATOR01') + .toHaveValue({ + mutations: [], + selections: [{ + asin: 'B0CURATOR01', + candidateNumber: 1, + region: 'us', + selectedAt: receipt.generatedAt, + title: 'The Persisted Edition', + }], + }); + }); } finally { - await driver.close(); await rm(directory, { force: true, recursive: true }); } }); diff --git a/examples/host-test/tests/route-unit/routes.test.ts b/examples/host-test/tests/route-unit/routes.test.ts index 34b7f4e0c..3c2457b5c 100644 --- a/examples/host-test/tests/route-unit/routes.test.ts +++ b/examples/host-test/tests/route-unit/routes.test.ts @@ -4,26 +4,19 @@ import { join } from 'node:path'; import { afterEach, beforeEach, expect, it } from '@rstest/core'; import { available, type AgentLineage } from '@agent-bundle/runtime'; -import { - createGeneratedRuntimeState, - type GeneratedRuntimeState, -} from '@agent-bundle/runtime/mount'; -import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; -import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; +import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; import { LOG_DIR_ENV } from '../../src/log.js'; import { DEFAULT_DUMP_LIMIT } from '../../src/mcp/host-test/tools/dump.js'; -import { - capturesStateDefinition, - type CaptureEvents, - type CapturesState, -} from '../../src/state.js'; +import type { CaptureEvents, CapturesState } from '../../src/state.js'; const manifest = testManifest(); -let stateRoot: string; +let logRoot: string; let logDir: string; -let runtimeState: GeneratedRuntimeState; +// One mounted captures state per test, shared by every event and tool render +// in it, so the durable summary a `dump` reads is the one the events wrote. +let mounted: MountedTestState; let sequence = 0; const eventInput = ( @@ -52,24 +45,16 @@ const render = async ( sessionId = 'root-session', host = 'claude', lineage?: AgentLineage, -) => { - const bindings = await runtimeState.requestBindings(); - try { - return await renderRoute(route, { - context: { - host: available({ name: host }, 'native'), - ...(lineage === undefined ? {} : { lineage: available(lineage, 'native') }), - noticeLedger: bindings.noticeLedger, - session: available({ sessionId }, 'native'), - state: bindings.state, - workspace: available({ root: '/repo' }, 'native'), - }, - input, - }); - } finally { - await bindings.close(); - } -}; +) => renderRoute(route, { + context: { + ...mounted.context(), + host: available({ name: host }, 'native'), + ...(lineage === undefined ? {} : { lineage: available(lineage, 'native') }), + session: available({ sessionId }, 'native'), + workspace: available({ root: '/repo' }, 'native'), + }, + input, +}); const readLogLines = async (): Promise[]> => (await readFile(join(logDir, 'captures.ndjson'), 'utf8')) @@ -78,20 +63,17 @@ const readLogLines = async (): Promise[]> => .map((line) => JSON.parse(line) as Record); beforeEach(async () => { - stateRoot = await mkdtemp(join(tmpdir(), 'host-test-route-unit-')); - logDir = join(stateRoot, 'log'); + logRoot = await mkdtemp(join(tmpdir(), 'host-test-route-unit-')); + logDir = join(logRoot, 'log'); process.env[LOG_DIR_ENV] = logDir; - runtimeState = createGeneratedRuntimeState({ - definition: capturesStateDefinition, - driver: createSqliteStateDriver({ root: stateRoot }), - }); + mounted = await mountTestState(); sequence = 0; }); afterEach(async () => { delete process.env[LOG_DIR_ENV]; - await runtimeState.close(); - await rm(stateRoot, { force: true, recursive: true }); + await mounted.close(); + await rm(logRoot, { force: true, recursive: true }); }); it('compiles every canonical event family plus the MCP and CLI surfaces', () => { diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index 48ba768f2..70c5ca956 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -1,23 +1,10 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; import { available, type AgentLineage, type Observed } from '@agent-bundle/runtime'; -import { - createGeneratedRuntimeState, - type GeneratedRuntimeState, -} from '@agent-bundle/runtime/mount'; -import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; -import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; +import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; import BeforeTool from '../../src/events/tool/before.js'; import agentTopologyProvider from '../../src/providers/agent-topology.js'; -import { - topologyStateDefinition, - type TopologyEvents, - type TopologyState, -} from '../../src/state.js'; +import type { TopologyEvents, TopologyState } from '../../src/state.js'; const manifest = testManifest(); @@ -27,8 +14,10 @@ const worktrees = { b: '/repo/.worktrees/b', } as const; -let stateRoot: string; -let runtimeState: GeneratedRuntimeState; +// One mounted topology state (and notice ledger) per test: every event in a +// journey records into it and the assertions read it back, exactly as one +// generated runtime would serve the whole session. +let mounted: MountedTestState; let sequence = 0; const provider = (root: string) => ({ @@ -77,30 +66,22 @@ const renderEventInput = async ( worktreeRoot: string, actorId?: string, lineage?: Observed, -) => { - const bindings = await runtimeState.requestBindings(); - try { - return await renderRoute(route, { - context: { - actor: actorId === undefined ? undefined : available({ id: actorId }, 'native'), - host: available({ name: 'claude' }, 'native'), - invocation: { - id: `invocation:${id}`, - startedAt: `2026-09-01T20:01:${String(sequence).padStart(2, '0')}.000Z`, - }, - ...(lineage === undefined ? {} : { lineage }), - noticeLedger: bindings.noticeLedger, - providers: providers(worktreeRoot), - session: available({ sessionId: 'root-session' }, 'native'), - state: bindings.state, - workspace: available({ root: worktreeRoot }, 'native'), - }, - input, - }); - } finally { - await bindings.close(); - } -}; +) => renderRoute(route, { + context: { + ...mounted.context(), + actor: actorId === undefined ? undefined : available({ id: actorId }, 'native'), + host: available({ name: 'claude' }, 'native'), + invocation: { + id: `invocation:${id}`, + startedAt: `2026-09-01T20:01:${String(sequence).padStart(2, '0')}.000Z`, + }, + ...(lineage === undefined ? {} : { lineage }), + providers: providers(worktreeRoot), + session: available({ sessionId: 'root-session' }, 'native'), + workspace: available({ root: worktreeRoot }, 'native'), + }, + input, +}); const renderEvent = ( route: string, @@ -190,17 +171,12 @@ const recordIntent = ( ); beforeEach(async () => { - stateRoot = await mkdtemp(join(tmpdir(), 'worktree-proximity-route-unit-')); - runtimeState = createGeneratedRuntimeState({ - definition: topologyStateDefinition, - driver: createSqliteStateDriver({ root: stateRoot }), - }); + mounted = await mountTestState(); sequence = 0; }); afterEach(async () => { - await runtimeState.close(); - await rm(stateRoot, { force: true, recursive: true }); + await mounted.close(); }); it('compiles the complete shared-runtime route surface', () => { @@ -242,18 +218,13 @@ describe('worktree proximity journeys', () => { // decision and therefore no reason, so the host's permission flow is untouched. expect(rendered.document.value).toEqual({ outcome: 'continue' }); - const bindings = await runtimeState.requestBindings(); - try { - const snapshot = await bindings.noticeLedger.read(); - expect(snapshot.notices).toEqual([ - expect.objectContaining({ - recipient: { workspace: { root: worktrees.a } }, - state: 'pending', - }), - ]); - } finally { - await bindings.close(); - } + const notices = await mounted.notices(); + expect(notices.notices).toEqual([ + expect.objectContaining({ + recipient: { workspace: { root: worktrees.a } }, + state: 'pending', + }), + ]); }); it('attempts and surfaces a notice on the recipient next event (journey 6)', async () => { @@ -281,16 +252,11 @@ describe('worktree proximity journeys', () => { .toContainContext('Directed proximity notice') .toContainContext('src/shared.ts'); - const bindings = await runtimeState.requestBindings(); - try { - const snapshot = await bindings.noticeLedger.read(); - expect(snapshot.notices[0]).toMatchObject({ - attempts: [expect.objectContaining({ invocationId: 'invocation:intent:a:after' })], - state: 'attempted', - }); - } finally { - await bindings.close(); - } + const notices = await mounted.notices(); + expect(notices.notices[0]).toMatchObject({ + attempts: [expect.objectContaining({ invocationId: 'invocation:intent:a:after' })], + state: 'attempted', + }); }); it('deduplicates a repeated native intent envelope (journey 7)', async () => { @@ -321,13 +287,8 @@ describe('worktree proximity journeys', () => { 'agent-a', ); - const bindings = await runtimeState.requestBindings(); - try { - const snapshot = await bindings.state.read(); - expect(snapshot.state.activities.filter((activity) => activity.actorId === 'agent-a')).toHaveLength(1); - } finally { - await bindings.close(); - } + const snapshot = await mounted.read(); + expect(snapshot.state.activities.filter((activity) => activity.actorId === 'agent-a')).toHaveLength(1); }); it('records a refusal and never fabricates an edge without native agent identity (journey 8)', async () => { @@ -349,18 +310,13 @@ describe('worktree proximity journeys', () => { .toContainContext('Parent identity unavailable') .toContainContext('refused to fabricate'); - const bindings = await runtimeState.requestBindings(); - try { - const snapshot = await bindings.state.read(); - expect(snapshot.state.actors).toEqual([]); - expect(snapshot.state.refusals).toEqual([ - expect.objectContaining({ - reason: 'agent/start omitted native agent_id; refused to fabricate a topology edge', - }), - ]); - } finally { - await bindings.close(); - } + const snapshot = await mounted.read(); + expect(snapshot.state.actors).toEqual([]); + expect(snapshot.state.refusals).toEqual([ + expect.objectContaining({ + reason: 'agent/start omitted native agent_id; refused to fabricate a topology edge', + }), + ]); }); it('records the child and its parent from request.lineage when the envelope carries no agent_id', async () => { @@ -382,22 +338,17 @@ describe('worktree proximity journeys', () => { expectDocument(rendered).toHaveStatus('success').toHaveNodeKinds(['result']); - const bindings = await runtimeState.requestBindings(); - try { - const snapshot = await bindings.state.read(); - expect(snapshot.state.refusals).toEqual([]); - expect(snapshot.state.actors).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'agent-c', - kind: 'child', - parentSessionId: 'root-session', - provenance: expect.objectContaining({ id: 'registry', parentSessionId: 'registry' }), - worktreeRoot: worktrees.b, - }), - ])); - } finally { - await bindings.close(); - } + const snapshot = await mounted.read(); + expect(snapshot.state.refusals).toEqual([]); + expect(snapshot.state.actors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'agent-c', + kind: 'child', + parentSessionId: 'root-session', + provenance: expect.objectContaining({ id: 'registry', parentSessionId: 'registry' }), + worktreeRoot: worktrees.b, + }), + ])); }); it('attributes a tool envelope to the lineage child ahead of the worktree binding', async () => { @@ -421,20 +372,15 @@ describe('worktree proximity journeys', () => { expectDocument(rendered).toHaveStatus('success'); expect(rendered.document.value).toEqual({ outcome: 'continue' }); - const bindings = await runtimeState.requestBindings(); - try { - const snapshot = await bindings.state.read(); - expect(snapshot.state.actors).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'agent-c', - provenance: expect.objectContaining({ id: 'registry', parentSessionId: 'registry' }), - worktreeRoot: worktrees.a, - }), - ])); - expect(snapshot.state.activities.map((activity) => activity.actorId)).toEqual(['agent-c']); - } finally { - await bindings.close(); - } + const snapshot = await mounted.read(); + expect(snapshot.state.actors).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 'agent-c', + provenance: expect.objectContaining({ id: 'registry', parentSessionId: 'registry' }), + worktreeRoot: worktrees.a, + }), + ])); + expect(snapshot.state.activities.map((activity) => activity.actorId)).toEqual(['agent-c']); }); it('renders the coordinator status from mounted topology state', async () => { @@ -442,20 +388,13 @@ describe('worktree proximity journeys', () => { await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); - const bindings = await runtimeState.requestBindings(); - let rendered: Awaited>; - try { - rendered = await renderRoute('tool:coordinator/status', { - context: { - noticeLedger: bindings.noticeLedger, - providers: providers(worktrees.root), - state: bindings.state, - }, - input: {}, - }); - } finally { - await bindings.close(); - } + const rendered = await renderRoute('tool:coordinator/status', { + context: { + ...mounted.context(), + providers: providers(worktrees.root), + }, + input: {}, + }); expectDocument(rendered) .toHaveStatus('success') diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 4637fb8ce..dc3adf0eb 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -8,7 +8,7 @@ * * | level | helper | what it proves | * | --- | --- | --- | - * | `route-unit` | `renderRoute`, `renderRouteEvents`, `loadRouteModule`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer (and the evaluated route module itself, by compiled id); explicit target-capability projection through the real MCP projector, without transport or host proof | + * | `route-unit` | `renderRoute`, `renderRouteEvents`, `loadRouteModule`, `mountTestState`, `withTestState`, `createTargetCapabilityFixture`, `projectTargetCapabilities` | the route component and its document through the real Agent renderer (and the evaluated route module itself, by compiled id; one mounted state shared across a multi-render journey); explicit target-capability projection through the real MCP projector, without transport or host proof | * | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | the real generated MCP server's protocol contract, over the SDK's in-memory transport; MCP App routes are not registered and report `not-applicable` | * | `dev-epoch` | `runDevEpochContractMatrix` | an epoch-pinned generated stdio process opened through the Workbench session service; MCP App routes are covered (surface + `ui://` sweep) and auto-covered without a fixture | * | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a compiled plain or rendered CLI command dispatched through the routed CLI's own shell, including rendered output modes, in this process | @@ -61,12 +61,15 @@ export { AGENT_TEST_REGISTRY_VERSION, registerTestRoutes, testManifest } from '. export type { AgentProviderModuleLoader, AgentTestRouteRegistry } from './registry.ts'; export { AgentTestError } from './errors.ts'; export type { AgentTestErrorCode } from './errors.ts'; -export { loadRouteModule, renderRoute, renderRouteEvents } from './render.ts'; +export { loadRouteModule, mountTestState, renderRoute, renderRouteEvents, withTestState } from './render.ts'; export type { HarnessOptionsArguments, LoadRouteModuleConstraint, LoadRouteModuleOptions, LoadedRouteModule, + MountTestStateOptions, + MountedTestState, + MountedTestStateContext, RouteModuleSchema, RenderRouteContext, RenderRouteContextInit, diff --git a/packages/agent-bundle/src/test/render.ts b/packages/agent-bundle/src/test/render.ts index 1913456c2..820e93ed0 100644 --- a/packages/agent-bundle/src/test/render.ts +++ b/packages/agent-bundle/src/test/render.ts @@ -5,10 +5,12 @@ import { join } from 'node:path'; import type * as AgentFlightServer from '@agent-bundle/runtime/flight/server'; import type * as AgentRuntime from '@agent-bundle/runtime'; import type * as AgentMount from '@agent-bundle/runtime/mount'; +import type * as AgentNotices from '@agent-bundle/runtime/notices'; import type * as AgentState from '@agent-bundle/runtime/state'; import type { AgentDocument, AgentInvocationInput, + AgentNoticeLedger, AgentProgressReporter, AgentProgressUpdate, AgentProviderValues, @@ -644,6 +646,25 @@ const noMountedState: AutoMountedState = Object.freeze({ type StateMount = (renderer: Renderer, signal: AbortSignal) => Promise; +/** + * Marks the `state` handle a {@link mountTestState} mount hands out. A render + * that receives one rebinds the shared owner to its own request signal — the + * same `requestBindings({ signal })` a generated request scope performs — so + * aborting that render stops its in-flight state operations without + * disturbing the owner the other renders share. + */ +const MOUNTED_TEST_STATE: unique symbol = Symbol.for('agent-bundle/test-mounted-state'); + +type RebindMountedState = (signal: AbortSignal) => Promise; + +type RebindableStateHandle< + TState = unknown, + TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas, +> = AgentState.AgentStateHandle & { readonly [MOUNTED_TEST_STATE]?: RebindMountedState }; + +const mountedStateRebind = (context: RenderRouteContext): RebindMountedState | undefined => + (context.state as RebindableStateHandle | undefined)?.[MOUNTED_TEST_STATE]; + /** * Resolves how a manifest render mounts its state, without mounting it: the * loader lookup is harness wiring and fails here, while loading the state @@ -655,6 +676,8 @@ const manifestStateMount = ( provenance: RenderedRouteProvenance, context: RenderRouteContext, ): StateMount => { + const rebind = mountedStateRebind(context); + if (rebind !== undefined) return (_renderer, signal) => rebind(signal); const descriptor = manifest?.state; if ( manifest === undefined @@ -687,30 +710,72 @@ const mountManifestState = async ( signal: AbortSignal, ): Promise => manifestStateMount(manifest, provenance, context)(renderer, signal); -const mountState = async ( - descriptor: NonNullable, - loader: AgentStateModuleLoader, - context: RenderRouteContext, +interface OpenedStateOwner { + readonly owner: AgentMount.GeneratedRuntimeState; + /** Closes the owner (and its driver), then removes the disposable sqlite root when one was created. */ + dispose(): Promise; +} + +/** + * Opens one generated state owner — the project state plus its notice ledger + * — over the driver the route-unit level uses: a disposable sqlite root for a + * `workspace-durable` definition, so repeated renders are deterministic, and + * the memory driver for every other lifetime. A caller-supplied driver + * replaces that choice and is closed with the owner. + */ +const openStateOwner = async ( + definition: AgentState.AgentStateDefinition, + lifetime: AgentState.AgentStateLifetime, renderer: Renderer, - signal: AbortSignal, -): Promise => { - const definition = (await loader()).default; + explicitDriver?: AgentState.AgentStateDriver, +): Promise> => { let root: string | undefined; let driver: AgentState.AgentStateDriver; try { - if (descriptor.lifetime === 'workspace-durable') { + if (explicitDriver !== undefined) { + driver = explicitDriver; + } else if (lifetime === 'external') { + // The compiler admits no `external` state definition, so only an + // explicit `options.definition` reaches here; its storage is the caller's. + throw new AgentTestError( + 'invalid-input', + `State ${definition.id} has the external lifetime, which names no storage the harness could open.`, + { recovery: 'Pass the driver that owns its storage as options.driver.' }, + ); + } else if (lifetime === 'workspace-durable') { root = await mkdtemp(join(tmpdir(), 'agent-bundle-route-state-')); driver = (await import('@agent-bundle/runtime/state/sqlite')).createSqliteStateDriver({ root }); } else { - driver = renderer.createMemoryStateDriver({ lifetime: descriptor.lifetime }); + driver = renderer.createMemoryStateDriver({ lifetime }); } } catch (error) { if (root !== undefined) await rm(root, { force: true, recursive: true }); throw error; } const owner = renderer.createGeneratedRuntimeState({ definition, driver }); + return { + owner, + dispose: async () => { + try { + await owner.close(); + } finally { + if (root !== undefined) await rm(root, { force: true, recursive: true }); + } + }, + }; +}; + +const mountState = async ( + descriptor: NonNullable, + loader: AgentStateModuleLoader, + context: RenderRouteContext, + renderer: Renderer, + signal: AbortSignal, +): Promise => { + const definition = (await loader()).default; + const opened = await openStateOwner(definition, descriptor.lifetime, renderer); try { - const bindings = await owner.requestBindings({ signal }); + const bindings = await opened.owner.requestBindings({ signal }); let closed = false; return Object.freeze({ context: { @@ -723,22 +788,180 @@ const mountState = async ( try { await bindings.close(); } finally { - try { - await owner.close(); - } finally { - if (root !== undefined) await rm(root, { force: true, recursive: true }); - } + await opened.dispose(); } }, }); } catch (error) { - try { - await owner.close(); - } finally { - if (root !== undefined) await rm(root, { force: true, recursive: true }); - } + await opened.dispose(); + throw error; + } +}; + +export interface MountTestStateOptions< + TState = unknown, + TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas, +> { + /** + * The state definition to mount instead of the manifest's registered state + * module. It also types `state` and `read()`; without it, pass the + * definition's types as type arguments (`mountTestState()`). + */ + readonly definition?: AgentState.AgentStateDefinition; + /** A driver of your own, closed with the mount; replaces the disposable-sqlite / memory choice. */ + readonly driver?: AgentState.AgentStateDriver; + /** Mounts the state declared by an explicit manifest instead of the one the generated configuration registered. */ + readonly manifest?: AgentBundleTestManifest; + readonly signal?: AbortSignal; +} + +/** The `state` and `noticeLedger` context members one mounted test state hands every render. */ +export interface MountedTestStateContext< + TState = unknown, + TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas, +> { + readonly noticeLedger: AgentNoticeLedger; + readonly state: AgentState.AgentStateHandle; +} + +/** + * One state owner mounted for a whole test rather than one render: the same + * `state` handle and `noticeLedger` for every `renderRoute` / + * `renderRouteEvents` call that spreads {@link MountedTestState.context} into + * its `context`, a typed snapshot read, the ledger's snapshot, and one + * `close()`. + */ +export interface MountedTestState< + TState = unknown, + TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas, +> extends MountedTestStateContext { + /** Releases the bindings, closes the owner and its driver, and removes the disposable sqlite root. Idempotent. */ + close(): Promise; + /** `{ noticeLedger, state }`, to spread into the `context` of any number of renders. */ + context(): MountedTestStateContext; + /** The notice ledger's current snapshot: every notice with its delivery state. */ + notices(): Promise; + /** The project state's current snapshot, typed by the mounted definition. */ + read(): Promise>; +} + +const manifestStateDefinition = async ( + manifest: AgentBundleTestManifest, +): Promise> => { + if (manifest.state === undefined) { + throw new AgentTestError( + 'manifest-unavailable', + 'The project declares no state, so there is no state definition to mount.', + { recovery: 'Declare src/state.ts in the project, or pass the definition to mount as options.definition.' }, + ); + } + const loader = registeredStateLoader(manifest); + if (loader === undefined) { + throw new AgentTestError( + 'manifest-unavailable', + `State ${manifest.state.id} is declared but no test-time state module loader is registered for it.`, + { recovery: 'Build the Rstest configuration with agentBundleRstest() so the generated setup registers the state loader, or pass the definition as options.definition.' }, + ); + } + return (await loader()).default; +}; + +/** + * Mounts the project's state definition and notice ledger once, for a journey + * that spans several renders — record on one event, read on the next — where + * the fresh per-render owner `renderRoute` mounts by default would forget + * everything between calls. The same driver rules apply: a + * `workspace-durable` definition opens a disposable sqlite root that `close()` + * removes; every other lifetime uses the memory driver. `renderRoute` and + * `renderRouteEvents` honour a caller-supplied `state` and `noticeLedger`, so + * spreading `context()` into each render's `context` is the whole wiring; a + * render that omits them still mounts its own isolated owner. Each render + * that receives the handles binds the shared owner to its own request + * signal, exactly as a generated request scope does, so a render's `signal` + * still cancels its state operations; `read()`, `notices()`, and the handles + * themselves are bound to `options.signal`. The owner is held as one request's + * bindings, so a `request`-lifetime definition behaves as one long request. + * Always `close()` it (or use {@link withTestState}). + */ +export const mountTestState = async < + TState = unknown, + TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas, +>( + ...[options = {}]: HarnessOptionsArguments> +): Promise> => { + const renderer = await loadRenderer(); + // Without an explicit definition the registered module is the project's own + // `src/state.ts`; its types are whatever the caller named as type arguments. + const definition = options.definition + ?? (await manifestStateDefinition(options.manifest ?? testManifest()) as unknown as AgentState.AgentStateDefinition); + const opened = await openStateOwner(definition, definition.lifetime, renderer, options.driver); + let bindings: AgentMount.GeneratedRuntimeRequestBindings; + try { + bindings = await opened.owner.requestBindings(options.signal === undefined ? {} : { signal: options.signal }); + } catch (error) { + await opened.dispose(); throw error; } + let closed = false; + // A render that receives these handles rebinds the shared owner to its own + // request signal (see MOUNTED_TEST_STATE). A `request`-lifetime owner opens + // fresh stores per binding, so it is not rebound: the mount's one binding + // is the request every render shares. + const rebind: RebindMountedState = async (signal) => { + if (closed || definition.lifetime === 'request') return noMountedState; + const request = await opened.owner.requestBindings({ signal }); + return Object.freeze({ + context: { noticeLedger: request.noticeLedger, state: request.state }, + close: request.close, + }); + }; + const state: RebindableStateHandle = Object.freeze({ + lifetime: bindings.state.lifetime, + changes: bindings.state.changes, + dispatch: bindings.state.dispatch, + read: bindings.state.read, + [MOUNTED_TEST_STATE]: rebind, + }); + const context: MountedTestStateContext = Object.freeze({ + noticeLedger: bindings.noticeLedger, + state, + }); + return Object.freeze({ + ...context, + async close() { + if (closed) return; + closed = true; + try { + await bindings.close(); + } finally { + await opened.dispose(); + } + }, + context: () => context, + notices: () => context.noticeLedger.read(), + read: () => context.state.read(), + }); +}; + +/** + * {@link mountTestState} scoped to one callback: the mounted state is closed + * — and its disposable root removed — when `run` settles, whether it resolved + * or threw. + */ +export const withTestState = async < + TState = unknown, + TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas, + T = void, +>( + run: (state: MountedTestState) => Promise, + ...[options = {}]: HarnessOptionsArguments> +): Promise => { + const mounted = await mountTestState(options); + try { + return await run(mounted); + } finally { + await mounted.close(); + } }; const progressFor = ( diff --git a/packages/agent-bundle/tests/route-unit/mount-test-state.test.ts b/packages/agent-bundle/tests/route-unit/mount-test-state.test.ts new file mode 100644 index 000000000..65b23ffef --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/mount-test-state.test.ts @@ -0,0 +1,160 @@ +import { createMemoryStateDriver, defineState } from '@agent-bundle/runtime/state'; +import { describe, expect, it } from '@rstest/core'; +import { z } from 'zod'; + +import journalStateDefinition from '../../fixtures/route-harness/src/state.ts'; +import { AgentTestError } from '../../src/test/errors.ts'; +import { expectDocument } from '../../src/test/matchers.ts'; +import { mountTestState, renderRoute, withTestState } from '../../src/test/render.ts'; +import { testManifest } from '../../src/test/registry.ts'; + +type JournalState = typeof journalStateDefinition extends { readonly initial: infer S } ? S : never; + +const rejection = async (attempt: Promise): Promise => { + try { + await attempt; + } catch (thrown: unknown) { + return thrown as AgentTestError; + } + throw new Error('The call resolved, so no harness diagnostic was produced.'); +}; + +/** + * `mountTestState` (#484) keeps one state owner — project state plus notice + * ledger — alive across several renders, where `renderRoute` on its own + * mounts and closes a fresh owner per render. + */ +describe('mountTestState', () => { + it('carries the manifest state and its notice ledger across renders, then reads both back', async () => { + const mounted = await mountTestState(); + try { + await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'first' } }); + const second = await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'second' } }); + expectDocument(second).toHaveValue({ entries: [{ note: 'first' }, { note: 'second' }], revision: 2 }); + + const published = await renderRoute('tool:harness/publish-notice', { + context: { ...mounted.context(), session: { source: 'native', state: 'available', value: { sessionId: 'sess-a' } } }, + input: { message: 'shared ledger', recipientSession: 'sess-b' }, + }); + expectDocument(published).toHaveStatus('success'); + + // The typed snapshot is the owner's, not a per-render copy. + const snapshot = await mounted.read(); + expect(snapshot.revision).toBe(2); + expect(snapshot.state.entries).toEqual([{ note: 'first' }, { note: 'second' }]); + const notices = await mounted.notices(); + expect(notices.notices).toEqual([expect.objectContaining({ + id: (published.result as { noticeId: string }).noticeId, + state: 'pending', + })]); + + // A render that omits the mounted handles still gets its own isolated owner. + const isolated = await renderRoute('tool:harness/journal'); + expectDocument(isolated).toHaveValue({ entries: [], revision: 0 }); + } finally { + await mounted.close(); + } + }); + + it('binds each render to its own signal without disturbing the shared owner', async () => { + const mounted = await mountTestState(); + try { + await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'kept' } }); + + // A cancelled render neither commits nor closes the owner the other renders share. + const cancelled = new AbortController(); + cancelled.abort(); + await expect(renderRoute('tool:harness/journal', { + context: mounted.context(), + input: { note: 'never' }, + signal: cancelled.signal, + })).rejects.toThrow(); + expect((await mounted.read()).state.entries).toEqual([{ note: 'kept' }]); + + // The handle a render receives is the owner rebound to that render's + // signal — the request scope's own binding — not the mount-wide one. + const rebind = (mounted.state as unknown as Record Promise<{ + readonly context: { readonly state: { read(): Promise } }; + close(): Promise; + }>>)[Symbol.for('agent-bundle/test-mounted-state')]!; + const aborted = new AbortController(); + aborted.abort(new Error('render cancelled')); + const bound = await rebind(aborted.signal); + try { + await expect(bound.context.state.read()).rejects.toThrow(); + await expect(mounted.read()).resolves.toMatchObject({ state: { entries: [{ note: 'kept' }] } }); + } finally { + await bound.close(); + } + + const after = await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'after' } }); + expectDocument(after).toHaveValue({ entries: [{ note: 'kept' }, { note: 'after' }], revision: 2 }); + } finally { + await mounted.close(); + } + }); + + it('closes idempotently, after which the handles are closed too', async () => { + const mounted = await mountTestState(); + const rendered = await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'x' } }); + expectDocument(rendered).toHaveStatus('success'); + await mounted.close(); + await mounted.close(); + await expect(mounted.read()).rejects.toThrow(); + await expect(mounted.notices()).rejects.toThrow(); + }); + + it('mounts an explicit definition over the driver its lifetime selects, typing read() from it', async () => { + const definition = defineState({ + events: { bumped: z.object({ by: z.number() }).strict() }, + id: 'route-harness/counter', + initial: { count: 0 }, + lifetime: 'process', + reduce: (state, event) => ({ count: state.count + event.payload.by }), + schema: z.object({ count: z.number() }).strict(), + }); + await withTestState(async (counter) => { + await counter.state.dispatch('bumped', { by: 2 }, { idempotencyKey: 'bump:1' }); + await counter.state.dispatch('bumped', { by: 3 }, { idempotencyKey: 'bump:2' }); + const snapshot = await counter.read(); + const count: number = snapshot.state.count; + expect(count).toBe(5); + expect(counter.state.lifetime).toBe('process'); + }, { definition }); + }); + + it('uses and closes a caller-supplied driver', async () => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const definition = defineState({ ...journalStateDefinition, lifetime: 'process' }); + const mounted = await mountTestState({ definition, driver }); + await mounted.state.dispatch('recorded', { note: 'own driver' }, { idempotencyKey: 'own:1' }); + expect((await mounted.read()).state.entries).toEqual([{ note: 'own driver' }]); + await mounted.close(); + await expect(driver.open(definition)).rejects.toThrow(); + }); + + it('closes the mounted state when the scoped callback throws', async () => { + let seen: Awaited> | undefined; + await expect(withTestState(async (state) => { + seen = state; + throw new Error('journey failed'); + })).rejects.toThrow('journey failed'); + await expect(seen!.read()).rejects.toThrow(); + }); + + it('refuses a manifest that declares no state, naming the recovery', async () => { + const manifest = { ...testManifest(), state: undefined }; + const error = await rejection(mountTestState({ manifest })); + expect(error).toBeInstanceOf(AgentTestError); + expect(error.code).toBe('manifest-unavailable'); + expect(error.message).toContain('declares no state'); + expect(error.message).toContain('options.definition'); + }); + + it('refuses an external-lifetime definition without a driver', async () => { + const definition = defineState({ ...journalStateDefinition, lifetime: 'external' }); + const error = await rejection(mountTestState({ definition })); + expect(error.code).toBe('invalid-input'); + expect(error.message).toContain('options.driver'); + }); +}); diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index f98913ce9..c15f5839c 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -140,6 +140,46 @@ the call fails closed with `manifest-unavailable`; a manifest describing another with the same mismatch report `renderRoute` gives, because route loaders are bound to the compilation that produced them. +### One state across several renders + +Every `renderRoute` mounts the project's declared state — and its notice ledger — fresh for that +one render and closes it afterwards, so two renders never see each other's writes. That is the +right default for an isolated route test, but a journey that records on one event and reads on +the next needs the same mounted state under every step. `mountTestState()` mounts the manifest's +state definition once, with the same driver rules (a `workspace-durable` definition opens a +disposable sqlite root that `close()` removes; other lifetimes use the memory driver), and hands +the same `state` and `noticeLedger` to as many `renderRoute` or `renderRouteEvents` calls as spread +its `context()`: + +```ts +import { mountTestState, renderRoute } from 'agent-bundle/test'; + +import type { TopologyEvents, TopologyState } from '../../src/state.js'; + +const topology = await mountTestState(); +try { + await renderRoute('event:session/start', { context: { ...topology.context(), host, workspace }, input: start }); + await renderRoute('event:tool/before', { context: { ...topology.context(), host, workspace }, input: intent }); + + const snapshot = await topology.read(); // AgentStateSnapshot + expect(snapshot.state.activities).toHaveLength(1); + const { notices } = await topology.notices(); // the ledger those renders published into +} finally { + await topology.close(); +} +``` + +`read()` and `notices()` are the owner's own snapshots, not a per-render copy; `state` and +`noticeLedger` are exposed too, for a direct `dispatch`. Each render binds the shared owner to its +own request signal, exactly as a generated request scope does, so a render's `signal` still +cancels that render's state operations. `withTestState(async (state) => { ... })` +is the same mount scoped to one callback, closed when it settles. The snapshot type comes from the +type arguments — the state registration carries no state type — or, checked, from +`options.definition`, which mounts that definition instead of the manifest's (an `external` +definition needs `options.driver`, since it names no storage the harness could open). A render +that omits the mounted handles still gets its own isolated owner, so the two styles mix in one +suite. + Matchers over the Agent Document contracts: `toHaveStatus`, `toContainMarkdown`, `toContainText`, `toHaveValue`, `toHaveError`, and `toHaveNodeKinds`. @@ -154,7 +194,7 @@ and prints it in every failure, because a pass at one level is never a receipt f | Level | Helpers | What it proves | | --- | --- | --- | -| `route-unit` | `renderRoute`, `renderRouteEvents` | A route module renders to the document — and render-event stream — it claims. | +| `route-unit` | `renderRoute`, `renderRouteEvents`, `mountTestState` | A route module renders to the document — and render-event stream — it claims; one mounted state can span a multi-render journey. | | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface`, `runContractMatrix` | The real generated MCP server's protocol contract, over the SDK's in-memory transport. | | `dev-epoch` | `runDevEpochContractMatrix` | An epoch-pinned generated stdio process opened through the Workbench session service; the caller owns the epoch lease and process lifetime, and MCP App routes are covered (surface plus `ui://` sweep). | | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | A plain or rendered argv vector resolved and run through the routed CLI's own shell — including rendered Markdown, explicit TTY, JSON, and NDJSON modes — in-process. | diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index bc657b433..44c6182e1 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -119,6 +119,41 @@ for (const route of mcpRoutes) { 测试池之外,调用会以 `manifest-unavailable` 关闭式失败;描述另一个项目的 manifest 会给出与 `renderRoute` 相同的不匹配报告并拒绝,因为路由加载器绑定到产生它们的那次编译。 +### 跨多次渲染共用一份 state + +每次 `renderRoute` 都会为这一次渲染重新挂载项目声明的 state——以及它的 notice ledger——并在渲染结束后关闭, +因此两次渲染永远看不到彼此的写入。对于隔离的路由测试这是正确的默认值,但一个"在某个事件上记录、在下一个 +事件上读取"的旅程需要每一步都落在同一份已挂载的 state 上。`mountTestState()` 把 manifest 的 state 定义 +只挂载一次,遵循同样的驱动规则(`workspace-durable` 定义打开一个 `close()` 时会移除的一次性 sqlite 根目录; +其他生命周期使用内存驱动),并把同一个 `state` 与 `noticeLedger` 交给任意多次展开了它的 `context()` 的 +`renderRoute` 或 `renderRouteEvents` 调用: + +```ts +import { mountTestState, renderRoute } from 'agent-bundle/test'; + +import type { TopologyEvents, TopologyState } from '../../src/state.js'; + +const topology = await mountTestState(); +try { + await renderRoute('event:session/start', { context: { ...topology.context(), host, workspace }, input: start }); + await renderRoute('event:tool/before', { context: { ...topology.context(), host, workspace }, input: intent }); + + const snapshot = await topology.read(); // AgentStateSnapshot + expect(snapshot.state.activities).toHaveLength(1); + const { notices } = await topology.notices(); // 那些渲染发布进去的 ledger +} finally { + await topology.close(); +} +``` + +`read()` 与 `notices()` 是所有者自身的快照,而不是每次渲染的副本;`state` 与 `noticeLedger` 也会暴露出来, +以便直接 `dispatch`。每次渲染都会把共享的所有者绑定到它自己的请求信号上——与生成的请求作用域完全一致—— +因此某次渲染的 `signal` 仍然会取消该次渲染的 state 操作。`withTestState(async (state) => { ... })` 是同一次挂载限定在一个回调内的形式,回调 +结束时关闭。快照类型来自类型参数——state 注册不携带 state 类型——或者(经过检查地)来自 +`options.definition`,它会挂载该定义而不是 manifest 的定义(`external` 定义需要 `options.driver`, +因为它没有指名任何测试工具能打开的存储)。省略了已挂载句柄的渲染仍会获得自己隔离的所有者,因此两种写法 +可以在同一个套件中混用。 + 针对 Agent Document 契约的匹配器:`toHaveStatus`、`toContainMarkdown`、`toContainText`、 `toHaveValue`、`toHaveError` 与 `toHaveNodeKinds`。 @@ -132,7 +167,7 @@ for (const route of mcpRoutes) { | 级别 | 辅助函数 | 它证明什么 | | --- | --- | --- | -| `route-unit` | `renderRoute`、`renderRouteEvents` | 一个路由模块渲染出了它所声称的文档——以及渲染事件流。 | +| `route-unit` | `renderRoute`、`renderRouteEvents`、`mountTestState` | 一个路由模块渲染出了它所声称的文档——以及渲染事件流;一份已挂载的 state 可以贯穿多次渲染的旅程。 | | `mcp-in-memory` | `openInMemoryMcpServer`、`invokeMcpTool`、`readMcpResource`、`getMcpPrompt`、`listMcpSurface`、`runContractMatrix` | 真实生成式 MCP 服务器的协议契约,经由 SDK 的内存内传输。 | | `dev-epoch` | `runDevEpochContractMatrix` | 通过 Workbench 会话服务打开的、锁定到某个 epoch 的生成式 stdio 进程;调用方拥有 epoch 租约与进程生命周期,MCP App 路由被覆盖(表面加 `ui://` 扫描)。 | | `cli-dispatch` | `invokeCli`、`cliJson`、`cliNdjson` | 一个普通或渲染式 argv 向量在路由式 CLI 自己的 shell 中被解析并执行——包括渲染式 Markdown、显式 TTY、JSON 与 NDJSON 模式——全部在进程内完成。 |