Skip to content
Open
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/session-scoped-mock-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Add session-scoped `mockTools(agent, mocks, session)` to `voice.testing`: assigns a mock set for an Agent type on a specific session, effective for the session's lifetime. Context-scoped `withMockTools` mocks take precedence when both are active.
2 changes: 1 addition & 1 deletion agents/src/voice/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,7 @@ export function performToolExecutions({
{ functionCall: toolCall, speechHandle },
async () => {
const runCtx = new RunContext(session, speechHandle, toolCall);
const mock = getMockTool(session.currentAgent, toolCall.name);
const mock = getMockTool(session.currentAgent, toolCall.name, session);
const toolToExecute = mock
? {
...tool,
Expand Down
1 change: 1 addition & 0 deletions agents/src/voice/testing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
MessageAssert,
RunAssert,
RunResult,
mockTools,
withMockTools,
type MockToolFn,
type MockToolsMap,
Expand Down
121 changes: 120 additions & 1 deletion agents/src/voice/testing/run_result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ToolContext, tool } from '../../llm/tool_context.js';
import { Agent } from '../agent.js';
import { performToolExecutions } from '../generation.js';
import { SpeechHandle } from '../speech_handle.js';
import { activeMockTools, withMockTools } from './run_result.js';
import { activeMockTools, getMockTool, mockTools, withMockTools } from './run_result.js';

class AgentA extends Agent {
constructor() {
Expand Down Expand Up @@ -180,3 +180,122 @@ describe('withMockTools', () => {
expect(output.output[0]?.toolCallOutput?.isError).toBe(true);
});
});

describe('mockTools (session-scoped)', () => {
// mockTools only uses the session as a WeakMap key; a stub is sufficient,
// matching the session stubs used by the performToolExecutions tests above.
const makeSession = () => ({ currentAgent: undefined }) as never;

it('registers mocks for a session and resolves them via getMockTool', () => {
const session = makeSession();
const agent = new AgentA();
const mock = () => 'session-mocked';

mockTools(AgentA, { tool1: mock }, session);

expect(getMockTool(agent, 'tool1', session)).toBe(mock);
// without the session, session-scoped mocks are invisible
expect(getMockTool(agent, 'tool1')).toBeUndefined();
});

it('isolates mock sets per session', () => {
const sessionA = makeSession();
const sessionB = makeSession();
const agent = new AgentA();

mockTools(AgentA, { tool1: () => 'a' }, sessionA);

expect(getMockTool(agent, 'tool1', sessionA)).toBeDefined();
expect(getMockTool(agent, 'tool1', sessionB)).toBeUndefined();
});

it('replaces the mock set on re-registration and removes it with an empty record', () => {
const session = makeSession();
const agent = new AgentA();
const second = () => 'second';

mockTools(AgentA, { tool1: () => 'first' }, session);
mockTools(AgentA, { tool2: second }, session);

// full replacement: tool1 is gone, tool2 is present
expect(getMockTool(agent, 'tool1', session)).toBeUndefined();
expect(getMockTool(agent, 'tool2', session)).toBe(second);

mockTools(AgentA, {}, session);
expect(getMockTool(agent, 'tool2', session)).toBeUndefined();
});

it('context-scoped mocks take precedence over session-scoped ones', () => {
const session = makeSession();
const agent = new AgentA();
const sessionMock = () => 'session';
const contextMock = () => 'context';

mockTools(AgentA, { tool1: sessionMock }, session);

{
using _mock = withMockTools(AgentA, { tool1: contextMock });
expect(getMockTool(agent, 'tool1', session)).toBe(contextMock);
}

// context scope ended: session mocks apply again
expect(getMockTool(agent, 'tool1', session)).toBe(sessionMock);
});

it('falls through to session mocks for tools the context set does not cover', () => {
const session = makeSession();
const agent = new AgentA();
const sessionMock = () => 'session';

mockTools(AgentA, { getWeather: sessionMock }, session);

{
// context mocks a *different* tool of the same agent type — the
// session-scoped mock for getWeather must remain active
using _mock = withMockTools(AgentA, { orderItem: () => 'ordered' });
expect(getMockTool(agent, 'getWeather', session)).toBe(sessionMock);
expect(getMockTool(agent, 'orderItem', session)).toBeDefined();
}
});

it('routes performToolExecutions to a session-scoped mock', async () => {
let realCalled = false;
const realTool = tool({
name: 'greet',
description: 'real',
parameters: z.object({ name: z.string() }),
execute: async () => {
realCalled = true;
return 'real';
},
});
const toolCtx = new ToolContext([realTool]);
const speechHandle = SpeechHandle.create({ allowInterruptions: false });
const session = { currentAgent: new AgentA() } as never;

mockTools(AgentA, { greet: () => 'session-mocked' }, session);

const controller = new AbortController();
const call = FunctionCall.create({
callId: 'call_session_mock',
name: 'greet',
args: JSON.stringify({ name: 'world' }),
});
const stream = new ReadableStream<FunctionCall>({
start(c) {
c.enqueue(call);
c.close();
},
});
const [task, output] = performToolExecutions({
session,
speechHandle,
toolCtx,
toolCallStream: stream,
controller,
});
await task.result;
expect(realCalled).toBe(false);
expect(output.output[0]?.rawOutput).toBe('session-mocked');
});
});
62 changes: 56 additions & 6 deletions agents/src/voice/testing/run_result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { tool } from '../../llm/tool_context.js';
import type { Task } from '../../utils.js';
import { Future } from '../../utils.js';
import type { Agent } from '../agent.js';
import type { AgentSession } from '../agent_session.js';
import { type SpeechHandle, isSpeechHandle } from '../speech_handle.js';
import {
type AgentHandoffAssertOptions,
Expand Down Expand Up @@ -966,13 +967,25 @@ export type MockToolsMap = Map<AgentConstructor, Record<string, MockToolFn>>;
/** @internal */
export let activeMockTools: MockToolsMap | undefined;

/** @internal */
export function getMockTool(agent: Agent, toolName: string): MockToolFn | undefined {
if (!activeMockTools) return undefined;
/** @internal – session-scoped mock sets registered via {@link mockTools}. */
const sessionMockTools = new WeakMap<AgentSession, MockToolsMap>();

for (const [agentConstructor, mocks] of activeMockTools) {
if (agent.constructor === agentConstructor) {
return mocks[toolName];
/** @internal */
export function getMockTool(
agent: Agent,
toolName: string,
session?: AgentSession,
): MockToolFn | undefined {
// Context-scoped mocks (withMockTools) take precedence over session-scoped
// ones (mockTools), per tool: a scope whose mock set lacks the tool falls
// through to the next scope instead of unmocking it.
const scopes = [activeMockTools, session ? sessionMockTools.get(session) : undefined];
for (const scope of scopes) {
if (!scope) continue;
for (const [agentConstructor, mocks] of scope) {
if (agent.constructor === agentConstructor && toolName in mocks) {
return mocks[toolName];
}
Comment thread
toubatbrian marked this conversation as resolved.
}
}
return undefined;
Expand Down Expand Up @@ -1020,6 +1033,43 @@ export function withMockTools(
};
}

/**
* Assign a set of mock tool callables to a specific Agent type on a session,
* effective immediately and for the session's lifetime.
*
* Mocks intercept tool *execution* only; the LLM keeps seeing the real tool
* schemas. Call again to replace the mock set for that Agent type, or pass an
* empty record to remove all mocks for it. When both this and
* {@link withMockTools} are active, the context-scoped mocks take precedence
* per tool; session mocks for tools the context set does not cover still apply.
*
* Mirrors the session form of Python's `mock_tools` (livekit/agents#6080).
*
* @param agent - The Agent constructor whose tools should be mocked.
* @param mocks - A record mapping tool name to a mock implementation.
* @param session - The session the mock set is bound to.
*
* @example
* ```typescript
* mockTools(DriveThruAgent, { getWeather: () => 'sunny' }, session);
*
* const result = await session.run({ userInput: "What's the weather?" });
* result.expect.containsFunctionCallOutput({ output: 'sunny' });
* ```
*/
export function mockTools(
agent: AgentConstructor,
mocks: Record<string, MockToolFn>,
session: AgentSession,
): void {
let map = sessionMockTools.get(session);
if (!map) {
map = new Map();
sessionMockTools.set(session, map);
}
map.set(agent, { ...mocks });
}

/**
* Format events for debug output, optionally marking a selected index.
*/
Expand Down
Loading