Skip to content

Commit 7021d22

Browse files
fix(test): bind each render on a mounted test state to its own request 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.
1 parent c40e6d2 commit 7021d22

4 files changed

Lines changed: 92 additions & 7 deletions

File tree

‎packages/agent-bundle/src/test/render.ts‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,25 @@ const noMountedState: AutoMountedState = Object.freeze({
646646

647647
type StateMount = (renderer: Renderer, signal: AbortSignal) => Promise<AutoMountedState>;
648648

649+
/**
650+
* Marks the `state` handle a {@link mountTestState} mount hands out. A render
651+
* that receives one rebinds the shared owner to its own request signal — the
652+
* same `requestBindings({ signal })` a generated request scope performs — so
653+
* aborting that render stops its in-flight state operations without
654+
* disturbing the owner the other renders share.
655+
*/
656+
const MOUNTED_TEST_STATE: unique symbol = Symbol.for('agent-bundle/test-mounted-state');
657+
658+
type RebindMountedState = (signal: AbortSignal) => Promise<AutoMountedState>;
659+
660+
type RebindableStateHandle<
661+
TState = unknown,
662+
TEvents extends AgentState.AgentStateEventSchemas = AgentState.AgentStateEventSchemas,
663+
> = AgentState.AgentStateHandle<TState, TEvents> & { readonly [MOUNTED_TEST_STATE]?: RebindMountedState };
664+
665+
const mountedStateRebind = (context: RenderRouteContext): RebindMountedState | undefined =>
666+
(context.state as RebindableStateHandle | undefined)?.[MOUNTED_TEST_STATE];
667+
649668
/**
650669
* Resolves how a manifest render mounts its state, without mounting it: the
651670
* loader lookup is harness wiring and fails here, while loading the state
@@ -657,6 +676,8 @@ const manifestStateMount = (
657676
provenance: RenderedRouteProvenance,
658677
context: RenderRouteContext,
659678
): StateMount => {
679+
const rebind = mountedStateRebind(context);
680+
if (rebind !== undefined) return (_renderer, signal) => rebind(signal);
660681
const descriptor = manifest?.state;
661682
if (
662683
manifest === undefined
@@ -854,9 +875,13 @@ const manifestStateDefinition = async (
854875
* removes; every other lifetime uses the memory driver. `renderRoute` and
855876
* `renderRouteEvents` honour a caller-supplied `state` and `noticeLedger`, so
856877
* spreading `context()` into each render's `context` is the whole wiring; a
857-
* render that omits them still mounts its own isolated owner. The owner is
858-
* held as one request's bindings, so a `request`-lifetime definition behaves
859-
* as one long request. Always `close()` it (or use {@link withTestState}).
878+
* render that omits them still mounts its own isolated owner. Each render
879+
* that receives the handles binds the shared owner to its own request
880+
* signal, exactly as a generated request scope does, so a render's `signal`
881+
* still cancels its state operations; `read()`, `notices()`, and the handles
882+
* themselves are bound to `options.signal`. The owner is held as one request's
883+
* bindings, so a `request`-lifetime definition behaves as one long request.
884+
* Always `close()` it (or use {@link withTestState}).
860885
*/
861886
export const mountTestState = async <
862887
TState = unknown,
@@ -877,11 +902,30 @@ export const mountTestState = async <
877902
await opened.dispose();
878903
throw error;
879904
}
905+
let closed = false;
906+
// A render that receives these handles rebinds the shared owner to its own
907+
// request signal (see MOUNTED_TEST_STATE). A `request`-lifetime owner opens
908+
// fresh stores per binding, so it is not rebound: the mount's one binding
909+
// is the request every render shares.
910+
const rebind: RebindMountedState = async (signal) => {
911+
if (closed || definition.lifetime === 'request') return noMountedState;
912+
const request = await opened.owner.requestBindings({ signal });
913+
return Object.freeze({
914+
context: { noticeLedger: request.noticeLedger, state: request.state },
915+
close: request.close,
916+
});
917+
};
918+
const state: RebindableStateHandle<TState, TEvents> = Object.freeze({
919+
lifetime: bindings.state.lifetime,
920+
changes: bindings.state.changes,
921+
dispatch: bindings.state.dispatch,
922+
read: bindings.state.read,
923+
[MOUNTED_TEST_STATE]: rebind,
924+
});
880925
const context: MountedTestStateContext<TState, TEvents> = Object.freeze({
881926
noticeLedger: bindings.noticeLedger,
882-
state: bindings.state,
927+
state,
883928
});
884-
let closed = false;
885929
return Object.freeze({
886930
...context,
887931
async close() {

‎packages/agent-bundle/tests/route-unit/mount-test-state.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,44 @@ describe('mountTestState', () => {
5656
}
5757
});
5858

59+
it('binds each render to its own signal without disturbing the shared owner', async () => {
60+
const mounted = await mountTestState<JournalState>();
61+
try {
62+
await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'kept' } });
63+
64+
// A cancelled render neither commits nor closes the owner the other renders share.
65+
const cancelled = new AbortController();
66+
cancelled.abort();
67+
await expect(renderRoute('tool:harness/journal', {
68+
context: mounted.context(),
69+
input: { note: 'never' },
70+
signal: cancelled.signal,
71+
})).rejects.toThrow();
72+
expect((await mounted.read()).state.entries).toEqual([{ note: 'kept' }]);
73+
74+
// The handle a render receives is the owner rebound to that render's
75+
// signal — the request scope's own binding — not the mount-wide one.
76+
const rebind = (mounted.state as unknown as Record<symbol, (signal: AbortSignal) => Promise<{
77+
readonly context: { readonly state: { read(): Promise<unknown> } };
78+
close(): Promise<void>;
79+
}>>)[Symbol.for('agent-bundle/test-mounted-state')]!;
80+
const aborted = new AbortController();
81+
aborted.abort(new Error('render cancelled'));
82+
const bound = await rebind(aborted.signal);
83+
try {
84+
await expect(bound.context.state.read()).rejects.toThrow();
85+
await expect(mounted.read()).resolves.toMatchObject({ state: { entries: [{ note: 'kept' }] } });
86+
} finally {
87+
await bound.close();
88+
}
89+
90+
const after = await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'after' } });
91+
expectDocument(after).toHaveValue({ entries: [{ note: 'kept' }, { note: 'after' }], revision: 2 });
92+
} finally {
93+
await mounted.close();
94+
}
95+
});
96+
5997
it('closes idempotently, after which the handles are closed too', async () => {
6098
const mounted = await mountTestState();
6199
const rendered = await renderRoute('tool:harness/journal', { context: mounted.context(), input: { note: 'x' } });

‎website/docs/en/guide/development/testing.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ try {
170170
```
171171

172172
`read()` and `notices()` are the owner's own snapshots, not a per-render copy; `state` and
173-
`noticeLedger` are exposed too, for a direct `dispatch`. `withTestState(async (state) => { ... })`
173+
`noticeLedger` are exposed too, for a direct `dispatch`. Each render binds the shared owner to its
174+
own request signal, exactly as a generated request scope does, so a render's `signal` still
175+
cancels that render's state operations. `withTestState(async (state) => { ... })`
174176
is the same mount scoped to one callback, closed when it settles. The snapshot type comes from the
175177
type arguments — the state registration carries no state type — or, checked, from
176178
`options.definition`, which mounts that definition instead of the manifest's (an `external`

‎website/docs/zh/guide/development/testing.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ try {
147147
```
148148

149149
`read()` 与 `notices()` 是所有者自身的快照,而不是每次渲染的副本;`state` 与 `noticeLedger` 也会暴露出来,
150-
以便直接 `dispatch`。`withTestState(async (state) => { ... })` 是同一次挂载限定在一个回调内的形式,回调
150+
以便直接 `dispatch`。每次渲染都会把共享的所有者绑定到它自己的请求信号上——与生成的请求作用域完全一致——
151+
因此某次渲染的 `signal` 仍然会取消该次渲染的 state 操作。`withTestState(async (state) => { ... })` 是同一次挂载限定在一个回调内的形式,回调
151152
结束时关闭。快照类型来自类型参数——state 注册不携带 state 类型——或者(经过检查地)来自
152153
`options.definition`,它会挂载该定义而不是 manifest 的定义(`external` 定义需要 `options.driver`,
153154
因为它没有指名任何测试工具能打开的存储)。省略了已挂载句柄的渲染仍会获得自己隔离的所有者,因此两种写法

0 commit comments

Comments
 (0)