Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/484-mount-test-state.md
Original file line number Diff line number Diff line change
@@ -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)
92 changes: 40 additions & 52 deletions examples/audiobook-curator/tests/route-unit/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down Expand Up @@ -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 });
}
});
Expand Down
60 changes: 21 additions & 39 deletions examples/host-test/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CapturesState, CaptureEvents>;
// 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<CapturesState, CaptureEvents>;
let sequence = 0;

const eventInput = (
Expand Down Expand Up @@ -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<Record<string, unknown>[]> =>
(await readFile(join(logDir, 'captures.ndjson'), 'utf8'))
Expand All @@ -78,20 +63,17 @@ const readLogLines = async (): Promise<Record<string, unknown>[]> =>
.map((line) => JSON.parse(line) as Record<string, unknown>);

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<CapturesState, CaptureEvents>();
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', () => {
Expand Down
Loading
Loading