Skip to content

Commit 0ab910c

Browse files
committed
feat: Replace OpenAIProvider with Runner protocol implementation (AIC-2388) (#1337)
1 parent a8e7bbb commit 0ab910c

15 files changed

Lines changed: 1163 additions & 640 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { LDAIAgentConfig } from '@launchdarkly/server-sdk-ai';
2+
3+
import { OpenAIAgentRunner } from '../src/OpenAIAgentRunner';
4+
5+
const mockRun = jest.fn();
6+
7+
function makeRunResult(overrides: Record<string, any> = {}) {
8+
return {
9+
finalOutput: overrides.finalOutput ?? '',
10+
newItems: overrides.newItems ?? [],
11+
runContext: {
12+
usage: overrides.usage ?? { totalTokens: 0, inputTokens: 0, outputTokens: 0 },
13+
},
14+
...overrides,
15+
};
16+
}
17+
18+
describe('OpenAIAgentRunner', () => {
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
it('returns content with no toolCalls when the model does not invoke tools', async () => {
24+
mockRun.mockResolvedValue(
25+
makeRunResult({
26+
finalOutput: 'Done',
27+
usage: { totalTokens: 12, inputTokens: 8, outputTokens: 4 },
28+
}),
29+
);
30+
31+
const runner = new OpenAIAgentRunner({}, mockRun, {});
32+
const result = await runner.run('Say done');
33+
34+
expect(result.content).toBe('Done');
35+
expect(result.metrics.success).toBe(true);
36+
expect(result.metrics.toolCalls).toBeUndefined();
37+
expect(result.metrics.usage).toEqual({ total: 12, input: 8, output: 4 });
38+
});
39+
40+
it('reports tool calls from newItems with LD config name mapping', async () => {
41+
mockRun.mockResolvedValue(
42+
makeRunResult({
43+
finalOutput: 'The answer is 42.',
44+
newItems: [
45+
{
46+
type: 'tool_call_item',
47+
rawItem: { type: 'function_call', name: 'lookup' },
48+
agent: { name: 'ldai-agent' },
49+
},
50+
],
51+
usage: { totalTokens: 28, inputTokens: 16, outputTokens: 12 },
52+
}),
53+
);
54+
55+
const runner = new OpenAIAgentRunner({}, mockRun, { lookup: 'lookup' });
56+
const result = await runner.run('Look up 42');
57+
58+
expect(result.content).toBe('The answer is 42.');
59+
expect(result.metrics.toolCalls).toEqual(['lookup']);
60+
expect(result.metrics.usage).toEqual({ total: 28, input: 16, output: 12 });
61+
});
62+
63+
it('returns an unsuccessful RunnerResult when the agent run throws', async () => {
64+
mockRun.mockRejectedValue(new Error('boom'));
65+
66+
const runner = new OpenAIAgentRunner({}, mockRun, {});
67+
const result = await runner.run('Hi');
68+
69+
expect(result.content).toBe('');
70+
expect(result.metrics.success).toBe(false);
71+
});
72+
73+
it('calls run with maxTurns of 25', async () => {
74+
mockRun.mockResolvedValue(makeRunResult({ finalOutput: 'ok' }));
75+
76+
const agent = { name: 'test-agent' };
77+
const runner = new OpenAIAgentRunner(agent, mockRun, {});
78+
await runner.run('test');
79+
80+
expect(mockRun).toHaveBeenCalledWith(
81+
agent,
82+
'test',
83+
expect.objectContaining({ maxTurns: 25 }),
84+
);
85+
});
86+
87+
it('reuses the same Agent across multiple run() calls', async () => {
88+
mockRun.mockResolvedValue(makeRunResult({ finalOutput: 'ok' }));
89+
90+
const agent = { name: 'test-agent' };
91+
const runner = new OpenAIAgentRunner(agent, mockRun, {});
92+
await runner.run('first');
93+
await runner.run('second');
94+
await runner.run('third');
95+
96+
expect(mockRun).toHaveBeenCalledTimes(3);
97+
expect(mockRun.mock.calls[0][0]).toBe(agent);
98+
expect(mockRun.mock.calls[1][0]).toBe(agent);
99+
expect(mockRun.mock.calls[2][0]).toBe(agent);
100+
});
101+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import {
2+
convertMessagesToOpenAI,
3+
getAIMetricsFromResponse,
4+
getAIUsageFromAgentResult,
5+
getAIUsageFromResponse,
6+
getToolCallsFromRunItems,
7+
isAgentToolInstance,
8+
registryValueToAgentTool,
9+
} from '../src/OpenAIHelper';
10+
11+
it('converts LDMessages to OpenAI message dicts preserving role and content', () => {
12+
const messages = convertMessagesToOpenAI([
13+
{ role: 'system', content: 'You are X' },
14+
{ role: 'user', content: 'Hi' },
15+
{ role: 'assistant', content: 'Hello' },
16+
]);
17+
18+
expect(messages).toEqual([
19+
{ role: 'system', content: 'You are X' },
20+
{ role: 'user', content: 'Hi' },
21+
{ role: 'assistant', content: 'Hello' },
22+
]);
23+
});
24+
25+
it('returns undefined when usage is missing from response', () => {
26+
expect(getAIUsageFromResponse({})).toBeUndefined();
27+
});
28+
29+
it('maps OpenAI prompt/completion/total token fields to LDTokenUsage', () => {
30+
const usage = getAIUsageFromResponse({
31+
usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 },
32+
});
33+
34+
expect(usage).toEqual({ total: 15, input: 5, output: 10 });
35+
});
36+
37+
it('returns success=true with usage extracted from the response', () => {
38+
const metrics = getAIMetricsFromResponse({
39+
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
40+
});
41+
42+
expect(metrics).toEqual({
43+
success: true,
44+
usage: { total: 3, input: 1, output: 2 },
45+
});
46+
});
47+
48+
it('returns undefined when runContext.usage is missing', () => {
49+
expect(getAIUsageFromAgentResult({ runContext: {} })).toBeUndefined();
50+
});
51+
52+
it('returns undefined when all token counts are zero', () => {
53+
const result = {
54+
runContext: { usage: { totalTokens: 0, inputTokens: 0, outputTokens: 0 } },
55+
};
56+
expect(getAIUsageFromAgentResult(result)).toBeUndefined();
57+
});
58+
59+
it('extracts token usage from runContext.usage', () => {
60+
const result = {
61+
runContext: { usage: { totalTokens: 30, inputTokens: 20, outputTokens: 10 } },
62+
};
63+
expect(getAIUsageFromAgentResult(result)).toEqual({ total: 30, input: 20, output: 10 });
64+
});
65+
66+
it('returns undefined on malformed agent result input without throwing', () => {
67+
expect(getAIUsageFromAgentResult(null)).toBeUndefined();
68+
expect(getAIUsageFromAgentResult({})).toBeUndefined();
69+
});
70+
71+
it('extracts function_call names from tool_call_items', () => {
72+
const items = [
73+
{ type: 'tool_call_item', rawItem: { type: 'function_call', name: 'lookup' } },
74+
{ type: 'tool_call_item', rawItem: { type: 'function_call', name: 'save' } },
75+
];
76+
expect(getToolCallsFromRunItems(items)).toEqual(['lookup', 'save']);
77+
});
78+
79+
it('extracts hosted_tool_call names from run items', () => {
80+
const items = [
81+
{ type: 'tool_call_item', rawItem: { type: 'hosted_tool_call', name: 'web_search' } },
82+
];
83+
expect(getToolCallsFromRunItems(items)).toEqual(['web_search']);
84+
});
85+
86+
it('normalizes _call suffix to known hosted tool names', () => {
87+
const items = [
88+
{ type: 'tool_call_item', rawItem: { type: 'web_search_call' } },
89+
{ type: 'tool_call_item', rawItem: { type: 'file_search_call' } },
90+
];
91+
expect(getToolCallsFromRunItems(items)).toEqual(['web_search', 'file_search']);
92+
});
93+
94+
it('preserves unknown _call suffix types as-is', () => {
95+
const items = [
96+
{ type: 'tool_call_item', rawItem: { type: 'custom_thing_call' } },
97+
];
98+
expect(getToolCallsFromRunItems(items)).toEqual(['custom_thing_call']);
99+
});
100+
101+
it('skips non tool_call_item entries', () => {
102+
const items = [
103+
{ type: 'message_item', rawItem: { type: 'message', content: 'hi' } },
104+
{ type: 'tool_call_item', rawItem: { type: 'function_call', name: 'fn' } },
105+
];
106+
expect(getToolCallsFromRunItems(items)).toEqual(['fn']);
107+
});
108+
109+
it('returns false for functions passed to isAgentToolInstance', () => {
110+
expect(isAgentToolInstance(() => {})).toBe(false);
111+
});
112+
113+
it('returns true for non-callable objects passed to isAgentToolInstance', () => {
114+
expect(isAgentToolInstance({ name: 'web_search' })).toBe(true);
115+
expect(isAgentToolInstance('string')).toBe(true);
116+
});
117+
118+
describe('given a shared fakeTool mock', () => {
119+
const fakeTool = jest.fn((opts: any) => ({ ...opts, _wrapped: true }));
120+
121+
it('passes through non-callable values without wrapping', () => {
122+
const hostedTool = { name: 'web_search', type: 'hosted' };
123+
expect(registryValueToAgentTool(hostedTool, fakeTool)).toBe(hostedTool);
124+
expect(fakeTool).not.toHaveBeenCalled();
125+
});
126+
127+
it('wraps callable values using the tool helper with schema from definition', async () => {
128+
const fn = jest.fn().mockResolvedValue('result');
129+
const definition = {
130+
name: 'myTool',
131+
description: 'Does stuff',
132+
parameters: { type: 'object', properties: { x: { type: 'number' } } },
133+
};
134+
135+
const wrapped = registryValueToAgentTool(fn, fakeTool, definition);
136+
137+
expect(fakeTool).toHaveBeenCalledWith(
138+
expect.objectContaining({
139+
name: 'myTool',
140+
description: 'Does stuff',
141+
strict: false,
142+
}),
143+
);
144+
expect(wrapped._wrapped).toBe(true);
145+
});
146+
147+
it('serializes non-string tool results to JSON', async () => {
148+
const fn = jest.fn().mockResolvedValue({ key: 'value' });
149+
const definition = { name: 'test' };
150+
151+
let capturedExecute: any;
152+
fakeTool.mockImplementation((opts: any) => {
153+
capturedExecute = opts.execute;
154+
return opts;
155+
});
156+
157+
registryValueToAgentTool(fn, fakeTool, definition);
158+
const result = await capturedExecute({ arg: 1 });
159+
160+
expect(fn).toHaveBeenCalledWith({ arg: 1 });
161+
expect(result).toBe('{"key":"value"}');
162+
});
163+
});

0 commit comments

Comments
 (0)