diff --git a/packages/agents-core/test/createSpans.test.ts b/packages/agents-core/test/createSpans.test.ts new file mode 100644 index 00000000..15472f00 --- /dev/null +++ b/packages/agents-core/test/createSpans.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { + createAgentSpan, + createFunctionSpan, + createGuardrailSpan, + createSpeechSpan, + createMCPListToolsSpan, + withAgentSpan, + withFunctionSpan, +} from '../src/tracing/createSpans'; +import { + getCurrentSpan, + setTraceProcessors, + setTracingDisabled, + withTrace, +} from '../src/tracing'; +import type { TraceProvider } from '../src/tracing/provider'; +import type { Span } from '../src/tracing/spans'; +import * as providerModule from '../src/tracing/provider'; +import { defaultProcessor, TracingProcessor } from '../src/tracing/processor'; +import type { Trace } from '../src/tracing/traces'; + +class RecordingProcessor implements TracingProcessor { + tracesStarted: Trace[] = []; + tracesEnded: Trace[] = []; + spansStarted: Span[] = []; + spansEnded: Span[] = []; + + async onTraceStart(trace: Trace): Promise { + this.tracesStarted.push(trace); + } + async onTraceEnd(trace: Trace): Promise { + this.tracesEnded.push(trace); + } + async onSpanStart(span: Span): Promise { + this.spansStarted.push(span); + } + async onSpanEnd(span: Span): Promise { + this.spansEnded.push(span); + } + async shutdown(): Promise { + /* noop */ + } + async forceFlush(): Promise { + /* noop */ + } + reset() { + this.tracesStarted.length = 0; + this.tracesEnded.length = 0; + this.spansStarted.length = 0; + this.spansEnded.length = 0; + } +} + +describe('create*Span helpers', () => { + const createSpanMock = vi.fn(); + let providerSpy: ReturnType | undefined; + const fakeSpan = { spanId: 'span', traceId: 'trace' } as Span; + + beforeEach(() => { + createSpanMock.mockReturnValue(fakeSpan); + providerSpy = vi.spyOn(providerModule, 'getGlobalTraceProvider'); + providerSpy.mockReturnValue({ + createSpan: createSpanMock, + } as unknown as TraceProvider); + }); + + afterEach(() => { + createSpanMock.mockReset(); + providerSpy?.mockRestore(); + }); + + it('createAgentSpan falls back to the default name when not provided', () => { + createAgentSpan(); + + expect(createSpanMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'agent', name: 'Agent' }), + }), + undefined, + ); + }); + + it('createFunctionSpan populates default input/output values', () => { + createFunctionSpan({ data: { name: 'call' } }); + + const calls = createSpanMock.mock.calls; + const [options] = calls[calls.length - 1]; + expect(options.data).toMatchObject({ + type: 'function', + name: 'call', + input: '', + output: '', + }); + }); + + it('createGuardrailSpan enforces a non-triggered default state', () => { + createGuardrailSpan({ data: { name: 'moderation' } }); + + const calls = createSpanMock.mock.calls; + const [options] = calls[calls.length - 1]; + expect(options.data).toMatchObject({ + type: 'guardrail', + name: 'moderation', + triggered: false, + }); + }); + + it('createSpeechSpan forwards the provided payload with the expected type', () => { + createSpeechSpan({ + data: { output: { data: 'pcm-data', format: 'pcm' } }, + }); + + const calls = createSpanMock.mock.calls; + const [options] = calls[calls.length - 1]; + expect(options.data).toMatchObject({ + type: 'speech', + output: { data: 'pcm-data', format: 'pcm' }, + }); + }); + + it('createMCPListToolsSpan stamps the span type', () => { + createMCPListToolsSpan(); + + const calls = createSpanMock.mock.calls; + const [options] = calls[calls.length - 1]; + expect(options.data).toMatchObject({ type: 'mcp_tools' }); + }); +}); + +describe('with*Span helpers', () => { + const processor = new RecordingProcessor(); + + beforeEach(() => { + processor.reset(); + setTraceProcessors([processor]); + setTracingDisabled(false); + }); + + afterEach(() => { + setTraceProcessors([defaultProcessor()]); + setTracingDisabled(true); + }); + + it('records errors and restores the previous span when the callback throws', async () => { + const failingError = Object.assign(new Error('boom'), { + data: { reason: 'bad input' }, + }); + + await withTrace('workflow', async () => { + await withAgentSpan(async (outerSpan) => { + await expect( + withFunctionSpan( + async () => { + expect(getCurrentSpan()).toBeDefined(); + throw failingError; + }, + { data: { name: 'inner' } }, + ), + ).rejects.toThrow('boom'); + + expect(getCurrentSpan()).toBe(outerSpan); + }); + + expect(getCurrentSpan()).toBeNull(); + }); + + const functionSpan = processor.spansEnded.find( + (span) => span.spanData.type === 'function', + ); + expect(functionSpan?.error).toMatchObject({ + message: 'boom', + data: { reason: 'bad input' }, + }); + }); +}); diff --git a/packages/agents-core/test/defaultModel.test.ts b/packages/agents-core/test/defaultModel.test.ts new file mode 100644 index 00000000..7a04839b --- /dev/null +++ b/packages/agents-core/test/defaultModel.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME, + getDefaultModel, + getDefaultModelSettings, + gpt5ReasoningSettingsRequired, + isGpt5Default, +} from '../src/defaultModel'; +import { loadEnv } from '../src/config'; +vi.mock('../src/config', () => ({ + loadEnv: vi.fn(), +})); +const mockedLoadEnv = vi.mocked(loadEnv); +beforeEach(() => { + mockedLoadEnv.mockReset(); + mockedLoadEnv.mockReturnValue({}); +}); +describe('gpt5ReasoningSettingsRequired', () => { + test('detects GPT-5 models while ignoring chat latest', () => { + expect(gpt5ReasoningSettingsRequired('gpt-5.1-mini')).toBe(true); + expect(gpt5ReasoningSettingsRequired('gpt-5-pro')).toBe(true); + expect(gpt5ReasoningSettingsRequired('gpt-5-chat-latest')).toBe(false); + }); + test('returns false for non GPT-5 models', () => { + expect(gpt5ReasoningSettingsRequired('gpt-4o')).toBe(false); + }); +}); +describe('getDefaultModel', () => { + test('falls back to gpt-4.1 when env var missing', () => { + mockedLoadEnv.mockReturnValue({}); + expect(getDefaultModel()).toBe('gpt-4.1'); + }); + test('lowercases provided env value', () => { + mockedLoadEnv.mockReturnValue({ + [OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME]: 'GPT-5-CHAT', + }); + expect(getDefaultModel()).toBe('gpt-5-chat'); + }); +}); +describe('isGpt5Default', () => { + test('returns true only when env points to GPT-5', () => { + mockedLoadEnv.mockReturnValue({ + [OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME]: 'gpt-5-preview', + }); + expect(isGpt5Default()).toBe(true); + mockedLoadEnv.mockReturnValue({ + [OPENAI_DEFAULT_MODEL_ENV_VARIABLE_NAME]: 'gpt-4o-mini', + }); + expect(isGpt5Default()).toBe(false); + }); +}); +describe('getDefaultModelSettings', () => { + test('returns reasoning defaults for GPT-5 models', () => { + expect(getDefaultModelSettings('gpt-5.1-mini')).toEqual({ + reasoning: { effort: 'low' }, + text: { verbosity: 'low' }, + }); + }); + test('returns empty settings for non GPT-5 models', () => { + expect(getDefaultModelSettings('gpt-4o')).toEqual({}); + }); +}); diff --git a/packages/agents-core/test/extensions/handoffFilters.test.ts b/packages/agents-core/test/extensions/handoffFilters.test.ts index 56a228c5..1ac29476 100644 --- a/packages/agents-core/test/extensions/handoffFilters.test.ts +++ b/packages/agents-core/test/extensions/handoffFilters.test.ts @@ -1,5 +1,48 @@ import { describe, test, expect } from 'vitest'; import { removeAllTools } from '../../src/extensions'; +import { + RunHandoffCallItem, + RunHandoffOutputItem, + RunMessageOutputItem, + RunToolCallItem, + RunToolCallOutputItem, +} from '../../src/items'; +import type { AgentInputItem } from '../../src/types'; +import type * as protocol from '../../src/types/protocol'; +import { + TEST_AGENT, + TEST_MODEL_FUNCTION_CALL, + fakeModelMessage, +} from '../stubs'; + +const functionCallResult: protocol.FunctionCallResultItem = { + type: 'function_call_result', + callId: 'call-1', + name: 'tool', + status: 'completed', + output: { type: 'text', text: 'done' }, +}; + +const computerCall: protocol.ComputerUseCallItem = { + type: 'computer_call', + callId: 'computer-call', + status: 'completed', + action: { type: 'screenshot' }, +}; + +const computerCallResult: protocol.ComputerCallResultItem = { + type: 'computer_call_result', + callId: 'computer-call', + output: { type: 'computer_screenshot', data: 'image-data' }, +}; + +const hostedToolCall: protocol.HostedToolCallItem = { + type: 'hosted_tool_call', + name: 'web_search_call', + arguments: '{"q":"openai"}', + status: 'completed', + output: 'results', +}; describe('removeAllTools', () => { test('should be available', () => { @@ -14,4 +57,59 @@ describe('removeAllTools', () => { newItems: [], }); }); + + test('removes run tool and handoff items from run collections', () => { + const message = new RunMessageOutputItem( + fakeModelMessage('ok'), + TEST_AGENT, + ); + const anotherMessage = new RunMessageOutputItem( + fakeModelMessage('still here'), + TEST_AGENT, + ); + + const result = removeAllTools({ + inputHistory: 'keep me', + preHandoffItems: [ + new RunHandoffCallItem(TEST_MODEL_FUNCTION_CALL, TEST_AGENT), + message, + new RunToolCallItem(TEST_MODEL_FUNCTION_CALL, TEST_AGENT), + ], + newItems: [ + new RunToolCallOutputItem(functionCallResult, TEST_AGENT, 'ok'), + new RunHandoffOutputItem(functionCallResult, TEST_AGENT, TEST_AGENT), + anotherMessage, + ], + }); + + expect(result.inputHistory).toBe('keep me'); + expect(result.preHandoffItems).toStrictEqual([message]); + expect(result.newItems).toStrictEqual([anotherMessage]); + }); + + test('filters out tool typed input history entries', () => { + const userMessage = { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + } as AgentInputItem; + const history: AgentInputItem[] = [ + userMessage, + TEST_MODEL_FUNCTION_CALL, + functionCallResult, + computerCall, + computerCallResult, + hostedToolCall, + ]; + + const result = removeAllTools({ + inputHistory: history, + preHandoffItems: [], + newItems: [], + }); + + expect(result.inputHistory).toStrictEqual([userMessage]); + expect(history).toHaveLength(6); + expect((result.inputHistory as AgentInputItem[])[0]).toBe(userMessage); + }); }); diff --git a/packages/agents-core/test/lifecycle.test.ts b/packages/agents-core/test/lifecycle.test.ts new file mode 100644 index 00000000..bef8eece --- /dev/null +++ b/packages/agents-core/test/lifecycle.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { AgentHooks, EventEmitterDelegate, RunHooks } from '../src/lifecycle'; +import type { EventEmitter } from '@openai/agents-core/_shims'; +import type { RunContext } from '../src/runContext'; +import type { Agent } from '../src/agent'; +import type { Tool } from '../src/tool'; +import type * as protocol from '../src/types/protocol'; + +type TestEvents = { + ping: [number]; +}; + +class StubEventEmitter implements EventEmitter { + on = vi.fn(() => this); + off = vi.fn(() => this); + emit = vi.fn(() => true); + once = vi.fn(() => this); +} + +class StubDelegate extends EventEmitterDelegate { + protected eventEmitter = new StubEventEmitter(); +} + +describe('EventEmitterDelegate', () => { + it('proxies to the underlying emitter implementation', () => { + const delegate = new StubDelegate(); + const handler = vi.fn(); + const emitter = (delegate as any).eventEmitter as StubEventEmitter; + + const returnedOn = delegate.on('ping', handler); + expect(emitter.on).toHaveBeenCalledWith('ping', handler); + expect(returnedOn).toBe(emitter); + + const returnedOnce = delegate.once('ping', handler); + expect(emitter.once).toHaveBeenCalledWith('ping', handler); + expect(returnedOnce).toBe(emitter); + + const emitted = delegate.emit('ping', 123); + expect(emitter.emit).toHaveBeenCalledWith('ping', 123); + expect(emitted).toBe(true); + + const returnedOff = delegate.off('ping', handler); + expect(emitter.off).toHaveBeenCalledWith('ping', handler); + expect(returnedOff).toBe(emitter); + }); +}); + +describe('AgentHooks and RunHooks', () => { + it('emit lifecycle events with typed payloads', () => { + const agentHooks = new AgentHooks(); + const runHooks = new RunHooks(); + + const context = { runId: 'ctx-1' } as unknown as RunContext; + const agent = { name: 'Agent' } as unknown as Agent; + const tool = { name: 'tool' } as unknown as Tool; + const toolCall = { id: 'call_1' } as protocol.ToolCallItem; + + const agentStart = vi.fn(); + const toolEnd = vi.fn(); + const runHandoff = vi.fn(); + + agentHooks.on('agent_start', agentStart); + agentHooks.on('agent_tool_end', toolEnd); + runHooks.on('agent_handoff', runHandoff); + + agentHooks.emit('agent_start', context, agent); + agentHooks.emit('agent_tool_end', context, tool, 'done', { toolCall }); + runHooks.emit('agent_handoff', context, agent, agent); + + expect(agentStart).toHaveBeenCalledWith(context, agent); + expect(toolEnd).toHaveBeenCalledWith(context, tool, 'done', { toolCall }); + expect(runHandoff).toHaveBeenCalledWith(context, agent, agent); + }); +});