Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-session-config-tool-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openai/agents-realtime': patch
---

Fix #339 session config breaking tool registration
15 changes: 10 additions & 5 deletions packages/agents-realtime/src/openaiRealtimeBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ export abstract class OpenAIRealtimeBase
}

protected _getMergedSessionConfig(config: Partial<RealtimeSessionConfig>) {
const sessionData = {
const sessionData: any = {
instructions: config.instructions,
model:
config.model ??
Expand All @@ -414,15 +414,20 @@ export abstract class OpenAIRealtimeBase
DEFAULT_OPENAI_REALTIME_SESSION_CONFIG.turnDetection,
tool_choice:
config.toolChoice ?? DEFAULT_OPENAI_REALTIME_SESSION_CONFIG.toolChoice,
tools: config.tools?.map((tool) => ({
...tool,
strict: undefined,
})),
// We don't set tracing here to make sure that we don't try to override it on every
// session.update as it might lead to errors
...(config.providerData ?? {}),
};

// Only include tools if they are explicitly provided and not empty
// This prevents sending an empty tools array which would disable tool calls
if (config.tools && config.tools.length > 0) {
sessionData.tools = config.tools.map((tool) => ({
...tool,
strict: undefined,
}));
}

return sessionData;
}

Expand Down
9 changes: 7 additions & 2 deletions packages/agents-realtime/src/realtimeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,17 @@ export class RealtimeSession<
const fullConfig: Partial<RealtimeSessionConfig> = {
...base,
instructions,
voice: this.#currentAgent.voice,
// Use session config voice if provided, otherwise fall back to agent voice
voice: base.voice ?? this.#currentAgent.voice,
model: this.options.model,
tools: this.#currentTools,
tracing: tracingConfig,
};

// Only include tools field if there are actually tools
if (this.#currentTools.length > 0) {
fullConfig.tools = this.#currentTools;
}

// Update our cache so subsequent updates inherit the full set including any
// dynamic fields we just overwrote.
this.#lastSessionConfig = fullConfig;
Expand Down
311 changes: 235 additions & 76 deletions packages/agents-realtime/test/realtimeSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,84 +318,243 @@ describe('RealtimeSession', () => {
expect(last.outputAudioFormat).toBe('g711_ulaw');
});

it('defaults item status to completed for done output items without status', async () => {
class TestTransport extends OpenAIRealtimeBase {
status: 'connected' | 'disconnected' | 'connecting' | 'disconnecting' =
'connected';
connect = vi.fn(async () => {});
sendEvent = vi.fn();
mute = vi.fn();
close = vi.fn();
interrupt = vi.fn();
get muted() {
return false;
it('defaults item status to completed for done output items without status', async () => {
class TestTransport extends OpenAIRealtimeBase {
status: 'connected' | 'disconnected' | 'connecting' | 'disconnecting' =
'connected';
connect = vi.fn(async () => { });
sendEvent = vi.fn();
mute = vi.fn();
close = vi.fn();
interrupt = vi.fn();
get muted() {
return false;
}
}
}
const transport = new TestTransport();
const agent = new RealtimeAgent({ name: 'A', handoffs: [] });
const session = new RealtimeSession(agent, { transport });
await session.connect({ apiKey: 'test' });
const historyEvents: RealtimeItem[][] = [];
session.on('history_updated', (h) => historyEvents.push([...h]));
(transport as any)._onMessage({
data: JSON.stringify({
type: 'response.output_item.done',
event_id: 'e',
item: {
id: 'm1',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
},
output_index: 0,
response_id: 'r1',
}),
const transport = new TestTransport();
const agent = new RealtimeAgent({ name: 'A', handoffs: [] });
const session = new RealtimeSession(agent, { transport });
await session.connect({ apiKey: 'test' });
const historyEvents: RealtimeItem[][] = [];
session.on('history_updated', (h) => historyEvents.push([...h]));
(transport as any)._onMessage({
data: JSON.stringify({
type: 'response.output_item.done',
event_id: 'e',
item: {
id: 'm1',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
},
output_index: 0,
response_id: 'r1',
}),
});
const latest = historyEvents.at(-1)!;
const msg = latest.find(
(i): i is Extract<RealtimeItem, { type: 'message'; role: 'assistant' }> =>
i.type === 'message' && i.role === 'assistant' && (i as any).itemId === 'm1'
);
expect(msg).toBeDefined();
expect(msg!.status).toBe('completed');
});
const latest = historyEvents.at(-1)!;
const msg = latest.find(
(i): i is Extract<RealtimeItem, { type: 'message'; role: 'assistant' }> =>
i.type === 'message' && i.role === 'assistant' && (i as any).itemId === 'm1'
);
expect(msg).toBeDefined();
expect(msg!.status).toBe('completed');
});

it('preserves explicit completed status on done', async () => {
class TestTransport extends OpenAIRealtimeBase {
status: 'connected' | 'disconnected' | 'connecting' | 'disconnecting' = 'connected';
connect = vi.fn(async () => {});
sendEvent = vi.fn(); mute = vi.fn(); close = vi.fn(); interrupt = vi.fn();
get muted() { return false; }
}
const transport = new TestTransport();
const session = new RealtimeSession(new RealtimeAgent({ name: 'A', handoffs: [] }), { transport });
await session.connect({ apiKey: 'test' });

const historyEvents: RealtimeItem[][] = [];
session.on('history_updated', (h) => historyEvents.push([...h]));

(transport as any)._onMessage({
data: JSON.stringify({
type: 'response.output_item.done',
event_id: 'e',
item: {
id: 'm2',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'text', text: 'hi again' }],
},
output_index: 0,
response_id: 'r2',
}),

it('preserves explicit completed status on done', async () => {
class TestTransport extends OpenAIRealtimeBase {
status: 'connected' | 'disconnected' | 'connecting' | 'disconnecting' = 'connected';
connect = vi.fn(async () => { });
sendEvent = vi.fn(); mute = vi.fn(); close = vi.fn(); interrupt = vi.fn();
get muted() { return false; }
}
const transport = new TestTransport();
const session = new RealtimeSession(new RealtimeAgent({ name: 'A', handoffs: [] }), { transport });
await session.connect({ apiKey: 'test' });

const historyEvents: RealtimeItem[][] = [];
session.on('history_updated', (h) => historyEvents.push([...h]));

(transport as any)._onMessage({
data: JSON.stringify({
type: 'response.output_item.done',
event_id: 'e',
item: {
id: 'm2',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'text', text: 'hi again' }],
},
output_index: 0,
response_id: 'r2',
}),
});

const latest = historyEvents.at(-1)!;
const msg = latest.find(
(i): i is Extract<RealtimeItem, { type: 'message'; role: 'assistant' }> =>
i.type === 'message' && i.role === 'assistant' && (i as any).itemId === 'm2'
);
expect(msg).toBeDefined();
expect(msg!.status).toBe('completed'); // ensure we didn't overwrite server status
});

it('includes tools in session config when session config is provided', async () => {
const transport = new FakeTransport();
const agent = new RealtimeAgent({
name: 'TestAgent',
handoffs: [],
tools: [TEST_TOOL]
});

// Test with session config - tools should still be included
const session = new RealtimeSession(agent, {
transport,
config: {
voice: 'alloy',
turnDetection: { type: 'server_vad' }
}
});

await session.connect({ apiKey: 'test' });

// Check that the initial session config includes tools
const connectCall = transport.connectCalls[0];
expect(connectCall?.initialSessionConfig?.tools).toBeDefined();
expect(connectCall?.initialSessionConfig?.tools).toHaveLength(1);
expect(connectCall?.initialSessionConfig?.tools?.[0]?.name).toBe('test');

// Check that voice config is also preserved
expect(connectCall?.initialSessionConfig?.voice).toBe('alloy');
expect(connectCall?.initialSessionConfig?.turnDetection).toEqual({ type: 'server_vad' });
});

it('includes tools in session config when no session config is provided', async () => {
const transport = new FakeTransport();
const agent = new RealtimeAgent({
name: 'TestAgent',
handoffs: [],
tools: [TEST_TOOL]
});

// Test without session config - tools should be included
const session = new RealtimeSession(agent, { transport });

await session.connect({ apiKey: 'test' });

// Check that the initial session config includes tools
const connectCall = transport.connectCalls[0];
expect(connectCall?.initialSessionConfig?.tools).toBeDefined();
expect(connectCall?.initialSessionConfig?.tools).toHaveLength(1);
expect(connectCall?.initialSessionConfig?.tools?.[0]?.name).toBe('test');
});

it('preserves tools when updateSessionConfig is called', async () => {
const transport = new FakeTransport();
const agent = new RealtimeAgent({
name: 'TestAgent',
handoffs: [],
tools: [TEST_TOOL]
});

const session = new RealtimeSession(agent, {
transport,
config: {
voice: 'alloy'
}
});

await session.connect({ apiKey: 'test' });

// Trigger an agent update to cause updateSessionConfig to be called
const newAgent = new RealtimeAgent({
name: 'UpdatedAgent',
handoffs: [],
tools: [TEST_TOOL]
});
await session.updateAgent(newAgent);

// Check that updateSessionConfig calls include tools
expect(transport.updateSessionConfigCalls.length).toBeGreaterThan(0);
const lastUpdateCall = transport.updateSessionConfigCalls[transport.updateSessionConfigCalls.length - 1];
expect(lastUpdateCall.tools).toBeDefined();
expect(lastUpdateCall.tools).toHaveLength(1);
expect(lastUpdateCall.tools?.[0]?.name).toBe('test');
});

it('does not include tools field when no tools are provided', async () => {
const transport = new FakeTransport();
const agent = new RealtimeAgent({
name: 'TestAgent',
handoffs: [],
tools: [] // No tools
});

const session = new RealtimeSession(agent, {
transport,
config: {
voice: 'alloy'
}
});

await session.connect({ apiKey: 'test' });

// Trigger an agent update to cause updateSessionConfig to be called
const newAgent = new RealtimeAgent({
name: 'UpdatedAgent',
handoffs: [],
tools: [] // No tools
});
await session.updateAgent(newAgent);

// Check that updateSessionConfig calls do not include tools field
expect(transport.updateSessionConfigCalls.length).toBeGreaterThan(0);
const lastUpdateCall = transport.updateSessionConfigCalls[transport.updateSessionConfigCalls.length - 1];
expect(Object.prototype.hasOwnProperty.call(lastUpdateCall, 'tools')).toBe(false);
});

const latest = historyEvents.at(-1)!;
const msg = latest.find(
(i): i is Extract<RealtimeItem, { type: 'message'; role: 'assistant' }> =>
i.type === 'message' && i.role === 'assistant' && (i as any).itemId === 'm2'
);
expect(msg).toBeDefined();
expect(msg!.status).toBe('completed'); // ensure we didn't overwrite server status
});
it('reproduces the original issue - tools work with config provided', async () => {
const transport = new FakeTransport();
const agent = new RealtimeAgent({
name: 'TestAgent',
handoffs: [],
tools: [TEST_TOOL]
});

// This is the scenario from the issue - session with config that includes voice setting
const session = new RealtimeSession(agent, {
transport,
config: {
voice: 'alloy', // Even just voice setting should not break tools
turnDetection: { type: 'server_vad' }
}
});

await session.connect({ apiKey: 'test' });

// Verify that tools are included in both initial and update configs
const connectCall = transport.connectCalls[0];
expect(connectCall?.initialSessionConfig?.tools).toBeDefined();
expect(connectCall?.initialSessionConfig?.tools).toHaveLength(1);
expect(connectCall?.initialSessionConfig?.tools?.[0]?.name).toBe('test');

// Trigger an agent update to cause updateSessionConfig to be called
const newAgent = new RealtimeAgent({
name: 'UpdatedAgent',
handoffs: [],
tools: [TEST_TOOL]
});
await session.updateAgent(newAgent);

// Verify that subsequent updates also include tools
expect(transport.updateSessionConfigCalls.length).toBeGreaterThan(0);
const lastUpdateCall = transport.updateSessionConfigCalls[transport.updateSessionConfigCalls.length - 1];
expect(lastUpdateCall.tools).toBeDefined();
expect(lastUpdateCall.tools).toHaveLength(1);
expect(lastUpdateCall.tools?.[0]?.name).toBe('test');

// Verify that voice config is preserved
expect(connectCall?.initialSessionConfig?.voice).toBe('alloy');
expect(lastUpdateCall.voice).toBe('alloy');
});
});