Skip to content

Commit e94cade

Browse files
cmun2claude
andcommitted
fix(realtime): keep conversation order when updateHistory corrects or inserts an item
`conversation.item.create` appends to the end of the conversation when `previous_item_id` is omitted. `resetHistory()` omitted it for every item it created, so correcting an item in the middle of the history -- which the voice agent guide recommends for exactly that purpose -- moved it behind everything that followed it. Inserting an item did the same. The items are now walked in new-history order so each created item can name the item it follows. Untouched items keep their place and can anchor the next insert; a function call that could not be created does not, and neither does a removed item. An item with nothing before it is still created without `previous_item_id`. The protocol accepts `previous_item_id: null` but does not document where that places the item, so this leaves that case exactly as it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eb690a4 commit e94cade

3 files changed

Lines changed: 134 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openai/agents-realtime': patch
3+
---
4+
5+
fix(realtime): keep conversation order when updateHistory corrects or inserts an item

packages/agents-realtime/src/openaiRealtimeBase.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -993,27 +993,48 @@ export abstract class OpenAIRealtimeBase
993993
}
994994
}
995995

996-
const additionsAndUpdates = [...additions, ...updates];
996+
// Walk the new history in order so each created item can name the item it
997+
// follows. `conversation.item.create` appends to the end of the
998+
// conversation when `previous_item_id` is omitted, which would move a
999+
// corrected or inserted item behind everything after it.
1000+
const pendingIds = new Set(
1001+
[...additions, ...updates].map((item) => item.itemId),
1002+
);
1003+
let previousItemId: string | null = null;
1004+
1005+
for (const item of newHistory) {
1006+
if (!pendingIds.has(item.itemId)) {
1007+
// Untouched items keep their place and can anchor the next insert.
1008+
previousItemId = item.itemId;
1009+
continue;
1010+
}
9971011

998-
for (const addition of additionsAndUpdates) {
999-
if (addition.type === 'message') {
1012+
if (item.type === 'message') {
10001013
const itemEntry: Record<string, any> = {
10011014
type: 'message',
1002-
role: addition.role,
1003-
content: addition.content,
1004-
id: addition.itemId,
1015+
role: item.role,
1016+
content: item.content,
1017+
id: item.itemId,
10051018
};
1006-
if (addition.role !== 'system' && addition.status) {
1007-
itemEntry.status = addition.status;
1019+
if (item.role !== 'system' && item.status) {
1020+
itemEntry.status = item.status;
10081021
}
10091022
this.sendEvent({
10101023
type: 'conversation.item.create',
1024+
// Only when the item has a predecessor. `previous_item_id: null` is
1025+
// accepted by the protocol but its placement is not documented, so
1026+
// an item with nothing before it keeps the existing behaviour.
1027+
...(previousItemId === null
1028+
? {}
1029+
: { previous_item_id: previousItemId }),
10111030
item: itemEntry,
10121031
});
1013-
} else if (addition.type === 'function_call') {
1032+
previousItemId = item.itemId;
1033+
} else if (item.type === 'function_call') {
10141034
logger.warn(
10151035
'Function calls cannot be manually added or updated at the moment. Ignoring.',
10161036
);
1037+
// Not created, so it cannot anchor the next insert.
10171038
}
10181039
}
10191040
}

packages/agents-realtime/test/openaiRealtimeBase.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,105 @@ describe('OpenAIRealtimeBase helpers', () => {
586586
expect(base.events).toHaveLength(0);
587587
});
588588

589+
describe('resetHistory item placement', () => {
590+
// `conversation.item.create` appends when `previous_item_id` is omitted, so
591+
// a corrected or inserted item has to name the item it follows or it lands
592+
// behind everything after it.
593+
const message = (itemId: string, text: string) =>
594+
({
595+
itemId,
596+
type: 'message',
597+
role: 'user',
598+
status: 'completed',
599+
content: [{ type: 'input_text', text }],
600+
}) as any;
601+
602+
const functionCall = (itemId: string) =>
603+
({
604+
itemId,
605+
type: 'function_call',
606+
name: 'f',
607+
callId: itemId,
608+
arguments: '{}',
609+
status: 'completed',
610+
}) as any;
611+
612+
function creates(oldHistory: any[], newHistory: any[]) {
613+
const base = new TestBase();
614+
base.resetHistory(oldHistory, newHistory);
615+
return base.events
616+
.filter((event: any) => event.type === 'conversation.item.create')
617+
.map((event: any) =>
618+
'previous_item_id' in event
619+
? `${event.item.id} after ${event.previous_item_id}`
620+
: `${event.item.id} unanchored`,
621+
);
622+
}
623+
624+
it('anchors a corrected item to the one before it', () => {
625+
expect(
626+
creates(
627+
[message('a', '1'), message('b', '2'), message('c', '3')],
628+
[message('a', '1'), message('b', 'edited'), message('c', '3')],
629+
),
630+
).toEqual(['b after a']);
631+
});
632+
633+
it('anchors an inserted item to the one before it', () => {
634+
expect(
635+
creates(
636+
[message('a', '1'), message('c', '3')],
637+
[message('a', '1'), message('b', '2'), message('c', '3')],
638+
),
639+
).toEqual(['b after a']);
640+
});
641+
642+
it('chains consecutive corrections in history order', () => {
643+
expect(
644+
creates(
645+
[message('a', '1'), message('b', '2'), message('c', '3')],
646+
[message('a', '1'), message('b', 'B'), message('c', 'C')],
647+
),
648+
).toEqual(['b after a', 'c after b']);
649+
});
650+
651+
it('leaves an item with nothing before it unanchored', () => {
652+
expect(
653+
creates(
654+
[message('a', '1'), message('b', '2')],
655+
[message('a', 'edited'), message('b', '2')],
656+
),
657+
).toEqual(['a unanchored']);
658+
});
659+
660+
it('does not anchor to a function call it could not create', () => {
661+
expect(
662+
creates(
663+
[message('a', '1')],
664+
[message('a', '1'), functionCall('f'), message('c', '3')],
665+
),
666+
).toEqual(['c after a']);
667+
});
668+
669+
it('anchors to a function call that is already in the conversation', () => {
670+
expect(
671+
creates(
672+
[message('a', '1'), functionCall('f')],
673+
[message('a', '1'), functionCall('f'), message('c', '3')],
674+
),
675+
).toEqual(['c after f']);
676+
});
677+
678+
it('does not anchor to a removed item', () => {
679+
expect(
680+
creates(
681+
[message('a', '1'), message('b', '2')],
682+
[message('a', '1'), message('c', '3')],
683+
),
684+
).toEqual(['c after a']);
685+
});
686+
});
687+
589688
it('sendMcpResponse emits approval response items', () => {
590689
const base = new TestBase();
591690
base.sendMcpResponse(

0 commit comments

Comments
 (0)