Skip to content

Commit e6f9621

Browse files
authored
Cleaner support of conversation history (#18)
* Cleaner support of conversation history * Typing on conversation changes
1 parent e85dcde commit e6f9621

File tree

12 files changed

+1162
-736
lines changed

12 files changed

+1162
-736
lines changed

examples/basic/agents_sdk.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import * as readline from 'readline';
1313
import { GuardrailAgent } from '../../src';
1414
import { InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered } from '@openai/agents';
15+
import type { AgentInputItem } from '@openai/agents';
1516

1617
// Define your pipeline configuration
1718
const PIPELINE_CONFIG = {
@@ -82,6 +83,9 @@ async function main(): Promise<void> {
8283
// Dynamic import to avoid bundling issues
8384
const { run } = await import('@openai/agents');
8485

86+
// Maintain conversation history locally (TypeScript Agents SDK doesn't support Sessions yet)
87+
let thread: AgentInputItem[] = [];
88+
8589
const rl = createReadlineInterface();
8690

8791
// Handle graceful shutdown
@@ -108,14 +112,21 @@ async function main(): Promise<void> {
108112

109113
console.log('🤔 Processing...\n');
110114

111-
const result = await run(agent, userInput);
115+
// Pass conversation history with the new user message
116+
const result = await run(agent, thread.concat({ role: 'user', content: userInput }));
117+
118+
// Update thread with the complete history including newly generated items
119+
thread = result.history;
120+
112121
console.log(`Assistant: ${result.finalOutput}\n`);
113122
} catch (error: any) {
114123
// Handle guardrail tripwire exceptions
115-
if (error instanceof InputGuardrailTripwireTriggered) {
124+
const errorType = error?.constructor?.name;
125+
126+
if (errorType === 'InputGuardrailTripwireTriggered' || error instanceof InputGuardrailTripwireTriggered) {
116127
console.log('🛑 Input guardrail triggered! Please try a different message.\n');
117128
continue;
118-
} else if (error instanceof OutputGuardrailTripwireTriggered) {
129+
} else if (errorType === 'OutputGuardrailTripwireTriggered' || error instanceof OutputGuardrailTripwireTriggered) {
119130
console.log('🛑 Output guardrail triggered! The response was blocked.\n');
120131
continue;
121132
} else {

src/__tests__/unit/chat-resources.test.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ vi.mock('../../streaming.js', () => ({
2222
}));
2323

2424
const baseClientMock = () => {
25+
const normalizedMessages = [{ role: 'user', content: 'normalized' }];
26+
const normalizedString = [{ role: 'user', content: 'Tell me something' }];
27+
2528
return {
2629
extractLatestUserTextMessage: vi.fn().mockReturnValue(['latest user', 1]),
2730
runStageGuardrails: vi.fn(),
@@ -38,6 +41,12 @@ const baseClientMock = () => {
3841
create: vi.fn().mockResolvedValue({ id: 'responses-api' }),
3942
},
4043
},
44+
normalizeConversationHistory: vi
45+
.fn()
46+
.mockImplementation((payload) =>
47+
typeof payload === 'string' ? normalizedString : normalizedMessages
48+
),
49+
loadConversationHistoryFromPreviousResponse: vi.fn().mockResolvedValue([]),
4150
};
4251
};
4352

@@ -70,15 +79,15 @@ describe('Chat resource', () => {
7079
1,
7180
'pre_flight',
7281
'latest user',
73-
messages,
82+
client.normalizeConversationHistory.mock.results[0].value,
7483
false,
7584
false
7685
);
7786
expect(client.runStageGuardrails).toHaveBeenNthCalledWith(
7887
2,
7988
'input',
8089
'latest user',
81-
messages,
90+
client.normalizeConversationHistory.mock.results[0].value,
8291
false,
8392
false
8493
);
@@ -92,7 +101,7 @@ describe('Chat resource', () => {
92101
{ id: 'chat-response' },
93102
[{ stage: 'preflight' }],
94103
[{ stage: 'input' }],
95-
messages,
104+
client.normalizeConversationHistory.mock.results[0].value,
96105
false
97106
);
98107
expect(result).toEqual({ result: 'handled' });
@@ -124,20 +133,21 @@ describe('Responses resource', () => {
124133
model: 'gpt-4o',
125134
});
126135

136+
expect(client.loadConversationHistoryFromPreviousResponse).toHaveBeenCalledWith(undefined);
127137
expect(client.extractLatestUserTextMessage).not.toHaveBeenCalled(); // string input path
128138
expect(client.runStageGuardrails).toHaveBeenNthCalledWith(
129139
1,
130140
'pre_flight',
131141
'Tell me something',
132-
undefined,
142+
client.normalizeConversationHistory.mock.results[0].value,
133143
false,
134144
false
135145
);
136146
expect(client.runStageGuardrails).toHaveBeenNthCalledWith(
137147
2,
138148
'input',
139149
'Tell me something',
140-
undefined,
150+
client.normalizeConversationHistory.mock.results[0].value,
141151
false,
142152
false
143153
);
@@ -152,7 +162,7 @@ describe('Responses resource', () => {
152162
{ id: 'responses-api' },
153163
[{ stage: 'preflight' }],
154164
[{ stage: 'input' }],
155-
'Tell me something',
165+
client.normalizeConversationHistory.mock.results[0].value,
156166
false
157167
);
158168
expect(payload).toEqual({ result: 'handled' });
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
appendAssistantResponse,
4+
mergeConversationWithItems,
5+
normalizeConversation,
6+
parseConversationInput,
7+
} from '../../../utils/conversation';
8+
9+
describe('conversation utilities', () => {
10+
describe('normalizeConversation', () => {
11+
it('normalizes plain user strings', () => {
12+
const result = normalizeConversation('hello');
13+
expect(result).toEqual([{ role: 'user', content: 'hello' }]);
14+
});
15+
16+
it('normalizes mixed message objects and tool calls', () => {
17+
const result = normalizeConversation([
18+
{ role: 'user', content: 'search for docs' },
19+
{
20+
role: 'assistant',
21+
tool_calls: [
22+
{
23+
id: 'call_1',
24+
function: { name: 'search_docs', arguments: '{"query": "docs"}' },
25+
},
26+
],
27+
},
28+
]);
29+
30+
expect(result).toEqual([
31+
{ role: 'user', content: 'search for docs' },
32+
{ role: 'assistant' },
33+
{
34+
type: 'function_call',
35+
tool_name: 'search_docs',
36+
arguments: '{"query": "docs"}',
37+
call_id: 'call_1',
38+
},
39+
]);
40+
});
41+
42+
it('handles responses API content arrays', () => {
43+
const result = normalizeConversation([
44+
{
45+
role: 'user',
46+
content: [
47+
{ type: 'text', text: 'hello' },
48+
{ type: 'text', text: 'world' },
49+
],
50+
},
51+
]);
52+
53+
expect(result).toEqual([{ role: 'user', content: 'hello world' }]);
54+
});
55+
});
56+
57+
describe('appendAssistantResponse', () => {
58+
it('appends assistant output from chat responses', () => {
59+
const history = [{ role: 'user', content: 'hi' }];
60+
const response = {
61+
choices: [
62+
{
63+
message: {
64+
role: 'assistant',
65+
content: 'hello back',
66+
},
67+
},
68+
],
69+
};
70+
71+
const result = appendAssistantResponse(history, response);
72+
expect(result).toEqual([
73+
{ role: 'user', content: 'hi' },
74+
{ role: 'assistant', content: 'hello back' },
75+
]);
76+
});
77+
});
78+
79+
describe('mergeConversationWithItems', () => {
80+
it('extends conversation history with additional tool output items', () => {
81+
const history = [{ role: 'user', content: 'plan trip' }];
82+
const items = [
83+
{
84+
type: 'function_call_output',
85+
tool_name: 'calendar',
86+
arguments: '{"date":"2025-01-01"}',
87+
output: '{"available": true}',
88+
},
89+
];
90+
91+
const result = mergeConversationWithItems(history, items);
92+
expect(result).toEqual([
93+
{ role: 'user', content: 'plan trip' },
94+
{
95+
type: 'function_call_output',
96+
tool_name: 'calendar',
97+
arguments: '{"date":"2025-01-01"}',
98+
output: '{"available": true}',
99+
},
100+
]);
101+
});
102+
});
103+
104+
describe('parseConversationInput', () => {
105+
it('parses JSON strings extracting messages', () => {
106+
const payload = JSON.stringify({ messages: [{ role: 'user', content: 'hello' }] });
107+
const result = parseConversationInput(payload);
108+
expect(result).toEqual([{ role: 'user', content: 'hello' }]);
109+
});
110+
111+
it('falls back to empty array for unsupported payloads', () => {
112+
expect(parseConversationInput(123)).toEqual([]);
113+
});
114+
});
115+
});

0 commit comments

Comments
 (0)