Skip to content

fix(testing): isolate withMockTools per async context (AsyncLocalStorage)#1865

Open
toubatbrian wants to merge 5 commits into
1.5.0from
brian/mocktools-async-local-storage
Open

fix(testing): isolate withMockTools per async context (AsyncLocalStorage)#1865
toubatbrian wants to merge 5 commits into
1.5.0from
brian/mocktools-async-local-storage

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Problem

The withMockTools test utility stored its active mock registry in a module-level mutable global (export let activeMockTools in agents/src/voice/testing/run_result.ts). getMockTool read that global (consumed in generation.ts), and withMockTools set/restored it on enter/[Symbol.dispose].

Because the registry was a single shared global, overlapping or concurrent async tests could clobber each other's mock maps — there was no per-async-context isolation. Python avoids this with a per-async-context ContextVar (_MockToolsContextVar in livekit-agents/.../voice/run_result.py).

Fix

Move the registry into a module-level AsyncLocalStorage<MockToolsMap> — the JS analog of Python's ContextVar, and already the pattern used elsewhere in this repo (agentActivityStorage, functionCallStorage, speechHandleStorage).

  • getMockTool now reads mockToolsStorage.getStore() (same lookup logic).
  • withMockTools(agent, mocks) computes previous = getStore(), builds the merged map, and calls enterWith(updated); [Symbol.dispose]() restores via enterWith(previous). Using enterWith preserves the synchronous enter/exit ergonomics of the using declaration while giving per-async-context isolation.
  • The public using withMockTools(...) Disposable API is unchanged.
  • The previously-exported activeMockTools binding is removed; tests now use a new /** @internal */ getActiveMockTools() accessor.

Tests

  • All existing run_result.test.ts cases pass with the getActiveMockTools() accessor (they read the active map synchronously right after withMockTools in the same context — enterWith makes that work).
  • Added a test that a child async task started after withMockTools inherits the registry.
  • Added a test that two overlapping async scopes installing conflicting mocks for the same agent/tool each keep their own view (no clobbering), and the outer context stays clean.

Validation

  • pnpm build:agents — success.
  • pnpm --filter @livekit/agents exec vitest run src/voice/testing/run_result.test.ts — 8/8 pass.
  • pnpm --filter @livekit/agents exec vitest run src/voice/generation_tools.test.ts — 10/10 pass.
  • prettier --check on changed files — clean.
  • Changeset added (@livekit/agents patch).

Known pre-existing noise unrelated to this change: amd.test.ts, api:check export * as, and a fresh-worktree eslint duplicate-plugin env error.

…age)

Replace the module-level `activeMockTools` global with an
`AsyncLocalStorage`-backed registry so overlapping/concurrent async tests
no longer clobber each other's mock maps. Mirrors Python's per-async-context
`ContextVar`. The public `using withMockTools(...)` Disposable API is
unchanged; tests now read the active registry via `getActiveMockTools()`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 225520a

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

This PR includes changesets to release 35 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai 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[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>
devin-ai-integration[bot]

This comment was marked as resolved.

toubatbrian and others added 2 commits June 23, 2026 20:22
…calls

Co-authored-by: Cursor <cursoragent@cursor.com>
…ler-leak

Co-authored-by: Cursor <cursoragent@cursor.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

Open in Devin Review

return {
[Symbol.dispose]() {
activeMockTools = previous;
mockToolsStorage.enterWith(previous as MockToolsMap);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Unsafe cast of undefined to MockToolsMap in dispose can pass undefined to enterWith

When withMockTools is called without a pre-existing store, previous is undefined. In the Symbol.dispose handler at line 1037, mockToolsStorage.enterWith(previous as MockToolsMap) passes undefined (cast to MockToolsMap) to enterWith. While AsyncLocalStorage<T>.enterWith() expects a value of type T (here MockToolsMap), it receives undefined. At runtime this happens to work because getStore() returns undefined in both cases, but it violates the enterWith API contract and could break if Node.js ever adds runtime validation. The fix should use mockToolsStorage.disable() when previous is undefined, or change the storage type to AsyncLocalStorage<MockToolsMap | undefined>.

Suggested change
mockToolsStorage.enterWith(previous as MockToolsMap);
if (previous) {
mockToolsStorage.enterWith(previous);
} else {
mockToolsStorage.disable();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +280 to +299
it('routes the activity-loop tool execution to a mock installed in the test body', async () => {
realToolRan = false;
mockRan = false;

using _mock = withMockTools(ProbeAgent, {
theTool: () => {
mockRan = true;
return 'MOCKED';
},
});

const result = session.run({ userInput: 'order' });
await result.wait();

result.expect.containsFunctionCall({ name: 'theTool' });
expect(mockRan).toBe(true);
expect(realToolRan).toBe(false);
// The tool output is JSON-serialized, so the raw string 'MOCKED' surfaces as '"MOCKED"'.
result.expect.containsFunctionCallOutput({ output: '"MOCKED"' });
}, 30_000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Mock visibility in activity loop depends on async context inheritance from session.run()

The test at run_result.test.ts:280-299 expects mocks installed via enterWith in the test body to be visible during tool execution in the agent activity loop. This works because session.run() (agent_session.ts:1155) spawns an async IIFE synchronously from the test body's execution context, inheriting the mock store. The IIFE then calls generateReplycreateSpeechTaskTask.from which starts the task in the same inherited context. In generation.ts:1171, getMockTool() reads the store. However, this relies on the entire chain NOT introducing an async boundary that breaks the inheritance before getMockTool is called. If the internal scheduling changes (e.g., dispatching through an event emitter or setTimeout), the mock visibility could break silently.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

2 participants