Skip to content

feat(test): mountTestState/withTestState — one mounted state across several renderRoute calls - #525

Merged
ScriptedAlchemy merged 3 commits into
mainfrom
feat/test-ergonomics
Sep 4, 2026
Merged

feat(test): mountTestState/withTestState — one mounted state across several renderRoute calls#525
ScriptedAlchemy merged 3 commits into
mainfrom
feat/test-ergonomics

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Fixes #484.

What

agent-bundle/test now exports mountTestState() and withTestState(): one state owner — the project's state definition plus its notice ledger — mounted once for a whole test, so a journey that records on one event and reads on the next runs against the same state / noticeLedger under every render.

  • mountTestState<TState, TEvents>(options?)MountedTestState with context() ({ state, noticeLedger } to spread into any renderRoute / renderRouteEvents context), read() (typed AgentStateSnapshot<TState>), notices() (the ledger snapshot), close() (idempotent), plus the bare state / noticeLedger handles for a direct dispatch.
  • Same driver rules as the per-render mount (render.ts mountState), now shared through one openStateOwner: workspace-durable → disposable sqlite root removed on close(); other lifetimes → memory driver; options.driver overrides and is closed with the mount.
  • options.definition mounts an explicit definition (checked typing) instead of the manifest's registered src/state.ts; options.manifest picks an explicit manifest. A manifest without state fails manifest-unavailable; an external definition without a driver fails invalid-input — both name the recovery.
  • withTestState(run, options?) is the scoped form (try/finally owned by the helper).
  • No render-path change: renderRoute already honours caller-supplied state/noticeLedger and mounts nothing when both are present.

Shape decision: both helpers, since the scoped one is a five-line wrapper; the examples show each style (beforeEach/afterEach with mountTestState, single-test withTestState). read() typing comes from type arguments or a checked options.definition — the generated Register carries route contracts only, no state type; registering the state type via typegen would be a separate change.

Consumer code deleted

  • examples/worktree-proximity/tests/route-unit/routes.test.ts: the mkdtemp/createSqliteStateDriver/createGeneratedRuntimeState mount, the per-render requestBindings()/close() pair, and six more requestBindings() pairs used only to read snapshots — replaced with mounted.context(), mounted.read(), mounted.notices(). No more @agent-bundle/runtime/mount or /state/sqlite imports.
  • examples/host-test/tests/route-unit/routes.test.ts: same mount and per-render bindings removed (the mkdtemp that remains is for the example's own log dir).
  • examples/audiobook-curator/tests/route-unit/state.test.ts: the re-wrapped defineState({ ...shelfStateDefinition, id, lifetime: 'process' }) + createMemoryStateDriver + createAgentStateHandle mount replaced with withTestState; it now renders against the project's real definition and gets a notice ledger too.

Docs

website/docs/{en,zh}/guide/development/testing.mdx: new "One state across several renders" subsection and the route-unit row of the proof-level table. pnpm docs:site:build passes (parity, dead links).

Tests

  • New packages/agent-bundle/tests/route-unit/mount-test-state.test.ts (7 tests): state and ledger carried across renders and read back; isolated per-render mounts still work alongside; idempotent close; explicit definition typed read(); caller driver used and closed; scoped close on throw; no-state manifest and external-without-driver refusals.
  • pnpm test:route-unit 65/65; pnpm test:unit green except framework-plugin-registration.test.ts (pre-existing on main, fixed by test(build): pass the project root to the composed configs in the plugin-registry test #523) and four load-induced timeouts that pass in isolation; pnpm typecheck clean apart from the same test(build): pass the project root to the composed configs in the plugin-registry test #523 file; pnpm lint clean.
  • Touched examples: worktree-proximity (typecheck + test:routes 10/10), host-test (typecheck + test 7/7), audiobook-curator (typecheck + test:routes 31/31).

Changeset

.changeset/484-mount-test-state.md (agent-bundle: patch).

Review status

  • 2e4093a1f — Codex reviewed; one P1 thread on render.ts (mountTestState handles were bound only to the mount-wide signal, so a render's own signal did not cancel its in-flight state operations). Addressed in f5e5cd223: a render that receives the mounted handles rebinds the shared owner through requestBindings({ signal }) — the generated request scope's own binding — so its signal cancels its state operations while the owner the other renders share is untouched; request-lifetime owners keep the mount's single binding. Covered by the new "binds each render to its own signal without disturbing the shared owner" test. Docs (en + zh) state the per-render binding.
  • Rebased onto main at test(build): pass the project root to the composed configs in the plugin-registry test #523 (picks up the framework-plugin-registration typecheck fix). Awaiting the reviewer on the current head; per this task's rules no PR comments are posted, so the re-review is not requested via @codex review.

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bbf2019

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T05:27:19.366921Z 2e4093a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@525
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@525
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@525

commit: bbf2019

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e4093a1f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const opened = await openStateOwner(definition, definition.lifetime, renderer, options.driver);
let bindings: AgentMount.GeneratedRuntimeRequestBindings<TState, TEvents>;
try {
bindings = await opened.owner.requestBindings(options.signal === undefined ? {} : { signal: options.signal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind shared state operations to each render's abort signal

When a journey gives an individual renderRoute(..., { signal }) its own cancellation signal, this binding still uses only the mount-wide options.signal and is then reused by every render. createAgentStateHandle folds the binding signal into read/dispatch/changes (packages/rsc-runtime/src/state/handle.ts:31-34), so aborting one render will not stop its in-flight state operation; a slow dispatch can commit after cancellation and contaminate later steps in the shared-state test. Preserve the shared owner while binding operations to each render's request signal, or otherwise combine that signal with the mount-wide signal.

Useful? React with 👍 / 👎.

ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 4, 2026 06:40
…t for multi-render state journeys

One mounted state owner (project state + notice ledger) shared across any
number of renderRoute / renderRouteEvents calls, with typed read() and
notices() snapshots and one close(). Same driver rules as the per-render
mount. The worktree-proximity, host-test, and audiobook-curator examples
drop their hand-rolled @agent-bundle/runtime/mount and /state mounts.

Fixes #484
…t signal

A render that receives mountTestState()'s handles rebinds the shared owner
through requestBindings({ signal }) — the generated request scope's own
binding — so aborting one render stops its in-flight state operations
without disturbing the owner the other renders share. Request-lifetime
owners stay as the mount's one binding.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agent-bundle/test: mount one state (and notice ledger) across several renderRoute calls — every state-bearing example re-implements mountState

1 participant