Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
105 changes: 104 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,106 @@ 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('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');
});
});
61 changes: 55 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). A registered mock set fully replaces the tools for its
// Agent type — there is no per-tool fallthrough between the two scopes.
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) {
return mocks[toolName];
}
Comment thread
toubatbrian marked this conversation as resolved.
}
}
return undefined;
Expand Down Expand Up @@ -1020,6 +1033,42 @@ 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.
*
* 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