History

Merge pull request #2231 from 7RPH/fix/responses-stream-multi-tool-calls

Luis Pater committed 7 months ago

Commit 779e6c22 parents 73c8317 cc32f5f2 files · +440 −132Browse files

Unified · Split

2 files
  1. internal/translator/openai/openai/responses/openai_openai-responses_response.go +135 −132
  2. internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +305 −0
internal/translator/openai/openai/responses/openai_openai-responses_response.go+135−132
@@ -4,6 +4,7 @@ import ( · 3 unmodified lines
44 "bytes"
55 "context"
66 "fmt"
7+ "sort"
78 "strings"
89 "sync/atomic"
910 "time"
@@ -16,6 +17,7 @@ import ( · 6 unmodified lines
1617 type oaiToResponsesStateReasoning struct {
1718 ReasoningID string
1819 ReasoningData string
20+ OutputIndex int
1921 }
2022 type oaiToResponsesState struct {
2123 Seq int
@@ -29,16 +31,19 @@ type oaiToResponsesState struct { · 7 unmodified lines
2931 MsgTextBuf map[int]*strings.Builder
3032 ReasoningBuf strings.Builder
3133 Reasonings []oaiToResponsesStateReasoning
32− FuncArgsBuf map[int]*strings.Builder // index -> args
33− FuncNames map[int]string // index -> name
34− FuncCallIDs map[int]string // index -> call_id
34+ FuncArgsBuf map[string]*strings.Builder
35+ FuncNames map[string]string
36+ FuncCallIDs map[string]string
37+ FuncOutputIx map[string]int
38+ MsgOutputIx map[int]int
39+ NextOutputIx int
3540 // message item state per output index
3641 MsgItemAdded map[int]bool // whether response.output_item.added emitted for message
3742 MsgContentAdded map[int]bool // whether response.content_part.added emitted for message
3843 MsgItemDone map[int]bool // whether message done events were emitted
3944 // function item done state
40− FuncArgsDone map[int]bool
41− FuncItemDone map[int]bool
45+ FuncArgsDone map[string]bool
46+ FuncItemDone map[string]bool
4247 // usage aggregation
4348 PromptTokens int64
4449 CachedTokens int64
@@ -60,15 +65,17 @@ func emitRespEvent(event string, payload []byte) []byte { · 15 unmodified lines
6065 func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
6166 if *param == nil {
6267 *param = &oaiToResponsesState{
63− FuncArgsBuf: make(map[int]*strings.Builder),
64− FuncNames: make(map[int]string),
65− FuncCallIDs: make(map[int]string),
68+ FuncArgsBuf: make(map[string]*strings.Builder),
69+ FuncNames: make(map[string]string),
70+ FuncCallIDs: make(map[string]string),
71+ FuncOutputIx: make(map[string]int),
72+ MsgOutputIx: make(map[int]int),
6673 MsgTextBuf: make(map[int]*strings.Builder),
6774 MsgItemAdded: make(map[int]bool),
6875 MsgContentAdded: make(map[int]bool),
6976 MsgItemDone: make(map[int]bool),
70− FuncArgsDone: make(map[int]bool),
71− FuncItemDone: make(map[int]bool),
77+ FuncArgsDone: make(map[string]bool),
78+ FuncItemDone: make(map[string]bool),
7279 Reasonings: make([]oaiToResponsesStateReasoning, 0),
7380 }
7481 }
@@ -125,6 +132,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 50 unmodified lines
125132 }
126133
127134 nextSeq := func() int { st.Seq++; return st.Seq }
135+ allocOutputIndex := func() int {
136+ ix := st.NextOutputIx
137+ st.NextOutputIx++
138+ return ix
139+ }
140+ toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) }
128141 var out [][]byte
129142
130143 if !st.Started {
@@ -135,14 +148,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 4 unmodified lines
135148 st.ReasoningBuf.Reset()
136149 st.ReasoningID = ""
137150 st.ReasoningIndex = 0
138− st.FuncArgsBuf = make(map[int]*strings.Builder)
139− st.FuncNames = make(map[int]string)
140− st.FuncCallIDs = make(map[int]string)
151+ st.FuncArgsBuf = make(map[string]*strings.Builder)
152+ st.FuncNames = make(map[string]string)
153+ st.FuncCallIDs = make(map[string]string)
154+ st.FuncOutputIx = make(map[string]int)
155+ st.MsgOutputIx = make(map[int]int)
156+ st.NextOutputIx = 0
141157 st.MsgItemAdded = make(map[int]bool)
142158 st.MsgContentAdded = make(map[int]bool)
143159 st.MsgItemDone = make(map[int]bool)
144− st.FuncArgsDone = make(map[int]bool)
145− st.FuncItemDone = make(map[int]bool)
160+ st.FuncArgsDone = make(map[string]bool)
161+ st.FuncItemDone = make(map[string]bool)
146162 st.PromptTokens = 0
147163 st.CachedTokens = 0
148164 st.CompletionTokens = 0
@@ -185,7 +201,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 36 unmodified lines
185201 outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.text", text)
186202 out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
187203
188− st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text})
204+ st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex})
189205 st.ReasoningID = ""
190206 }
191207
@@ -201,10 +217,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 9 unmodified lines
201217 stopReasoning(st.ReasoningBuf.String())
202218 st.ReasoningBuf.Reset()
203219 }
220+ if _, exists := st.MsgOutputIx[idx]; !exists {
221+ st.MsgOutputIx[idx] = allocOutputIndex()
222+ }
223+ msgOutputIndex := st.MsgOutputIx[idx]
204224 if !st.MsgItemAdded[idx] {
205225 item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
206226 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
207− item, _ = sjson.SetBytes(item, "output_index", idx)
227+ item, _ = sjson.SetBytes(item, "output_index", msgOutputIndex)
208228 item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
209229 out = append(out, emitRespEvent("response.output_item.added", item))
210230 st.MsgItemAdded[idx] = true
@@ -213,7 +233,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 2 unmodified lines
213233 part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
214234 part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
215235 part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
216− part, _ = sjson.SetBytes(part, "output_index", idx)
236+ part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex)
217237 part, _ = sjson.SetBytes(part, "content_index", 0)
218238 out = append(out, emitRespEvent("response.content_part.added", part))
219239 st.MsgContentAdded[idx] = true
@@ -222,7 +242,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 2 unmodified lines
222242 msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
223243 msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
224244 msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
225− msg, _ = sjson.SetBytes(msg, "output_index", idx)
245+ msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex)
226246 msg, _ = sjson.SetBytes(msg, "content_index", 0)
227247 msg, _ = sjson.SetBytes(msg, "delta", c.String())
228248 out = append(out, emitRespEvent("response.output_text.delta", msg))
@@ -238,10 +258,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 9 unmodified lines
238258 // On first appearance, add reasoning item and part
239259 if st.ReasoningID == "" {
240260 st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
241− st.ReasoningIndex = idx
261+ st.ReasoningIndex = allocOutputIndex()
242262 item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
243263 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
244− item, _ = sjson.SetBytes(item, "output_index", idx)
264+ item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
245265 item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)
246266 out = append(out, emitRespEvent("response.output_item.added", item))
247267 part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
@@ -269,6 +289,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 21 unmodified lines
269289 // Before emitting any function events, if a message is open for this index,
270290 // close its text/content to match Codex expected ordering.
271291 if st.MsgItemAdded[idx] && !st.MsgItemDone[idx] {
292+ msgOutputIndex := st.MsgOutputIx[idx]
272293 fullText := ""
273294 if b := st.MsgTextBuf[idx]; b != nil {
274295 fullText = b.String()
@@ -276,7 +297,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
276297 done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
277298 done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
278299 done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
279− done, _ = sjson.SetBytes(done, "output_index", idx)
300+ done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
280301 done, _ = sjson.SetBytes(done, "content_index", 0)
281302 done, _ = sjson.SetBytes(done, "text", fullText)
282303 out = append(out, emitRespEvent("response.output_text.done", done))
@@ -284,69 +305,72 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
284305 partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
285306 partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
286307 partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
287− partDone, _ = sjson.SetBytes(partDone, "output_index", idx)
308+ partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
288309 partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
289310 partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
290311 out = append(out, emitRespEvent("response.content_part.done", partDone))
291312
292313 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
293314 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
294− itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
315+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
295316 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
296317 itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
297318 out = append(out, emitRespEvent("response.output_item.done", itemDone))
298319 st.MsgItemDone[idx] = true
299320 }
300321
301− // Only emit item.added once per tool call and preserve call_id across chunks.
302− newCallID := tcs.Get("0.id").String()
303− nameChunk := tcs.Get("0.function.name").String()
304− if nameChunk != "" {
305− st.FuncNames[idx] = nameChunk
306− }
307− existingCallID := st.FuncCallIDs[idx]
308− effectiveCallID := existingCallID
309− shouldEmitItem := false
310− if existingCallID == "" && newCallID != "" {
311− // First time seeing a valid call_id for this index
312− effectiveCallID = newCallID
313− st.FuncCallIDs[idx] = newCallID
314− shouldEmitItem = true
315− }
316−
317− if shouldEmitItem && effectiveCallID != "" {
318− o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
319− o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
320− o, _ = sjson.SetBytes(o, "output_index", idx)
321− o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
322− o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
323− name := st.FuncNames[idx]
324− o, _ = sjson.SetBytes(o, "item.name", name)
325− out = append(out, emitRespEvent("response.output_item.added", o))
326− }
327−
328− // Ensure args buffer exists for this index
329− if st.FuncArgsBuf[idx] == nil {
330− st.FuncArgsBuf[idx] = &strings.Builder{}
331− }
332−
333− // Append arguments delta if available and we have a valid call_id to reference
334− if args := tcs.Get("0.function.arguments"); args.Exists() && args.String() != "" {
335− // Prefer an already known call_id; fall back to newCallID if first time
336− refCallID := st.FuncCallIDs[idx]
337− if refCallID == "" {
338− refCallID = newCallID
322+ tcs.ForEach(func(_, tc gjson.Result) bool {
323+ toolIndex := int(tc.Get("index").Int())
324+ key := toolStateKey(idx, toolIndex)
325+ newCallID := tc.Get("id").String()
326+ nameChunk := tc.Get("function.name").String()
327+ if nameChunk != "" {
328+ st.FuncNames[key] = nameChunk
339329 }
340− if refCallID != "" {
341− ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
342− ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
343− ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID))
344− ad, _ = sjson.SetBytes(ad, "output_index", idx)
345− ad, _ = sjson.SetBytes(ad, "delta", args.String())
346− out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
330+
331+ existingCallID := st.FuncCallIDs[key]
332+ effectiveCallID := existingCallID
333+ shouldEmitItem := false
334+ if existingCallID == "" && newCallID != "" {
335+ effectiveCallID = newCallID
336+ st.FuncCallIDs[key] = newCallID
337+ st.FuncOutputIx[key] = allocOutputIndex()
338+ shouldEmitItem = true
347339 }
348− st.FuncArgsBuf[idx].WriteString(args.String())
349− }
340+
341+ if shouldEmitItem && effectiveCallID != "" {
342+ outputIndex := st.FuncOutputIx[key]
343+ o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
344+ o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
345+ o, _ = sjson.SetBytes(o, "output_index", outputIndex)
346+ o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
347+ o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
348+ o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key])
349+ out = append(out, emitRespEvent("response.output_item.added", o))
350+ }
351+
352+ if st.FuncArgsBuf[key] == nil {
353+ st.FuncArgsBuf[key] = &strings.Builder{}
354+ }
355+
356+ if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" {
357+ refCallID := st.FuncCallIDs[key]
358+ if refCallID == "" {
359+ refCallID = newCallID
360+ }
361+ if refCallID != "" {
362+ outputIndex := st.FuncOutputIx[key]
363+ ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
364+ ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
365+ ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", refCallID))
366+ ad, _ = sjson.SetBytes(ad, "output_index", outputIndex)
367+ ad, _ = sjson.SetBytes(ad, "delta", args.String())
368+ out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
369+ }
370+ st.FuncArgsBuf[key].WriteString(args.String())
371+ }
372+ return true
373+ })
350374 }
351375 }
352376
@@ -360,15 +384,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 7 unmodified lines
360384 for i := range st.MsgItemAdded {
361385 idxs = append(idxs, i)
362386 }
363− for i := 0; i < len(idxs); i++ {
364− for j := i + 1; j < len(idxs); j++ {
365− if idxs[j] < idxs[i] {
366− idxs[i], idxs[j] = idxs[j], idxs[i]
367− }
368− }
369− }
387+ sort.Slice(idxs, func(i, j int) bool { return st.MsgOutputIx[idxs[i]] < st.MsgOutputIx[idxs[j]] })
370388 for _, i := range idxs {
371389 if st.MsgItemAdded[i] && !st.MsgItemDone[i] {
390+ msgOutputIndex := st.MsgOutputIx[i]
372391 fullText := ""
373392 if b := st.MsgTextBuf[i]; b != nil {
374393 fullText = b.String()
@@ -376,7 +395,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
376395 done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
377396 done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
378397 done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
379− done, _ = sjson.SetBytes(done, "output_index", i)
398+ done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
380399 done, _ = sjson.SetBytes(done, "content_index", 0)
381400 done, _ = sjson.SetBytes(done, "text", fullText)
382401 out = append(out, emitRespEvent("response.output_text.done", done))
@@ -384,14 +403,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
384403 partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
385404 partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
386405 partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
387− partDone, _ = sjson.SetBytes(partDone, "output_index", i)
406+ partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
388407 partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
389408 partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
390409 out = append(out, emitRespEvent("response.content_part.done", partDone))
391410
392411 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
393412 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
394− itemDone, _ = sjson.SetBytes(itemDone, "output_index", i)
413+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
395414 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
396415 itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
397416 out = append(out, emitRespEvent("response.output_item.done", itemDone))
@@ -407,43 +426,42 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 9 unmodified lines
407426
408427 // Emit function call done events for any active function calls
409428 if len(st.FuncCallIDs) > 0 {
410− idxs := make([]int, 0, len(st.FuncCallIDs))
411− for i := range st.FuncCallIDs {
412− idxs = append(idxs, i)
413− }
414− for i := 0; i < len(idxs); i++ {
415− for j := i + 1; j < len(idxs); j++ {
416− if idxs[j] < idxs[i] {
417− idxs[i], idxs[j] = idxs[j], idxs[i]
418− }
419− }
420− }
421− for _, i := range idxs {
422− callID := st.FuncCallIDs[i]
423− if callID == "" || st.FuncItemDone[i] {
429+ keys := make([]string, 0, len(st.FuncCallIDs))
430+ for key := range st.FuncCallIDs {
431+ keys = append(keys, key)
432+ }
433+ sort.Slice(keys, func(i, j int) bool {
434+ left := st.FuncOutputIx[keys[i]]
435+ right := st.FuncOutputIx[keys[j]]
436+ return left < right || (left == right && keys[i] < keys[j])
437+ })
438+ for _, key := range keys {
439+ callID := st.FuncCallIDs[key]
440+ if callID == "" || st.FuncItemDone[key] {
424441 continue
425442 }
443+ outputIndex := st.FuncOutputIx[key]
426444 args := "{}"
427− if b := st.FuncArgsBuf[i]; b != nil && b.Len() > 0 {
445+ if b := st.FuncArgsBuf[key]; b != nil && b.Len() > 0 {
428446 args = b.String()
429447 }
430448 fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
431449 fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
432450 fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))
433− fcDone, _ = sjson.SetBytes(fcDone, "output_index", i)
451+ fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
434452 fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)
435453 out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone))
436454
437455 itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
438456 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
439− itemDone, _ = sjson.SetBytes(itemDone, "output_index", i)
457+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
440458 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
441459 itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
442460 itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
443− itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[i])
461+ itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[key])
444462 out = append(out, emitRespEvent("response.output_item.done", itemDone))
445− st.FuncItemDone[i] = true
446− st.FuncArgsDone[i] = true
463+ st.FuncItemDone[key] = true
464+ st.FuncArgsDone[key] = true
447465 }
448466 }
449467 completed := []byte(`{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`)
@@ -516,28 +534,21 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 66 unmodified lines
516534 }
517535 // Build response.output using aggregated buffers
518536 outputsWrapper := []byte(`{"arr":[]}`)
537+ type completedOutputItem struct {
538+ index int
539+ raw []byte
540+ }
541+ outputItems := make([]completedOutputItem, 0, len(st.Reasonings)+len(st.MsgItemAdded)+len(st.FuncArgsBuf))
519542 if len(st.Reasonings) > 0 {
520543 for _, r := range st.Reasonings {
521544 item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
522545 item, _ = sjson.SetBytes(item, "id", r.ReasoningID)
523546 item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData)
524− outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
547+ outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: item})
525548 }
526549 }
527− // Append message items in ascending index order
528550 if len(st.MsgItemAdded) > 0 {
529− midxs := make([]int, 0, len(st.MsgItemAdded))
530551 for i := range st.MsgItemAdded {
531− midxs = append(midxs, i)
532− }
533− for i := 0; i < len(midxs); i++ {
534− for j := i + 1; j < len(midxs); j++ {
535− if midxs[j] < midxs[i] {
536− midxs[i], midxs[j] = midxs[j], midxs[i]
537− }
538− }
539− }
540− for _, i := range midxs {
541552 txt := ""
542553 if b := st.MsgTextBuf[i]; b != nil {
543554 txt = b.String()
@@ -545,37 +556,29 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, · 1 unmodified line
545556 item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
546557 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
547558 item, _ = sjson.SetBytes(item, "content.0.text", txt)
548− outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
559+ outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item})
549560 }
550561 }
551562 if len(st.FuncArgsBuf) > 0 {
552− idxs := make([]int, 0, len(st.FuncArgsBuf))
553− for i := range st.FuncArgsBuf {
554− idxs = append(idxs, i)
555− }
556− // small-N sort without extra imports
557− for i := 0; i < len(idxs); i++ {
558− for j := i + 1; j < len(idxs); j++ {
559− if idxs[j] < idxs[i] {
560− idxs[i], idxs[j] = idxs[j], idxs[i]
561− }
562− }
563− }
564− for _, i := range idxs {
563+ for key := range st.FuncArgsBuf {
565564 args := ""
566− if b := st.FuncArgsBuf[i]; b != nil {
565+ if b := st.FuncArgsBuf[key]; b != nil {
567566 args = b.String()
568567 }
569− callID := st.FuncCallIDs[i]
570− name := st.FuncNames[i]
568+ callID := st.FuncCallIDs[key]
569+ name := st.FuncNames[key]
571570 item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
572571 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
573572 item, _ = sjson.SetBytes(item, "arguments", args)
574573 item, _ = sjson.SetBytes(item, "call_id", callID)
575574 item, _ = sjson.SetBytes(item, "name", name)
576− outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
575+ outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
577576 }
578577 }
578+ sort.Slice(outputItems, func(i, j int) bool { return outputItems[i].index < outputItems[j].index })
579+ for _, item := range outputItems {
580+ outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item.raw)
581+ }
579582 if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {
580583 completed, _ = sjson.SetRawBytes(completed, "response.output", []byte(gjson.GetBytes(outputsWrapper, "arr").Raw))
581584 }
internal/translator/openai/openai/responses/openai_openai-responses_response_test.go+305−0
@@ -0,0 +1,305 @@
1+package responses
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+
8+ "github.com/tidwall/gjson"
9+)
10+
11+func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
12+ t.Helper()
13+
14+ lines := strings.Split(string(chunk), "\n")
15+ if len(lines) < 2 {
16+ t.Fatalf("unexpected SSE chunk: %q", chunk)
17+ }
18+
19+ event := strings.TrimSpace(strings.TrimPrefix(lines[0], "event:"))
20+ dataLine := strings.TrimSpace(strings.TrimPrefix(lines[1], "data:"))
21+ if !gjson.Valid(dataLine) {
22+ t.Fatalf("invalid SSE data JSON: %q", dataLine)
23+ }
24+ return event, gjson.Parse(dataLine)
25+}
26+
27+func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultipleToolCallsRemainSeparate(t *testing.T) {
28+ in := []string{
29+ `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_read","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
30+ `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\",\"limit\":400,\"offset\":1}"}}]},"finish_reason":null}]}`,
31+ `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":1,"id":"call_glob","type":"function","function":{"name":"glob","arguments":""}}]},"finish_reason":null}]}`,
32+ `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":\"C:\\\\repo\",\"pattern\":\"*.{yml,yaml}\"}"}}]},"finish_reason":null}]}`,
33+ `data: {"id":"resp_test","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
34+ }
35+
36+ request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
37+
38+ var param any
39+ var out [][]byte
40+ for _, line := range in {
41+ out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
42+ }
43+
44+ addedNames := map[string]string{}
45+ doneArgs := map[string]string{}
46+ doneNames := map[string]string{}
47+ outputItems := map[string]gjson.Result{}
48+
49+ for _, chunk := range out {
50+ ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
51+ switch ev {
52+ case "response.output_item.added":
53+ if data.Get("item.type").String() != "function_call" {
54+ continue
55+ }
56+ addedNames[data.Get("item.call_id").String()] = data.Get("item.name").String()
57+ case "response.output_item.done":
58+ if data.Get("item.type").String() != "function_call" {
59+ continue
60+ }
61+ callID := data.Get("item.call_id").String()
62+ doneArgs[callID] = data.Get("item.arguments").String()
63+ doneNames[callID] = data.Get("item.name").String()
64+ case "response.completed":
65+ output := data.Get("response.output")
66+ for _, item := range output.Array() {
67+ if item.Get("type").String() == "function_call" {
68+ outputItems[item.Get("call_id").String()] = item
69+ }
70+ }
71+ }
72+ }
73+
74+ if len(addedNames) != 2 {
75+ t.Fatalf("expected 2 function_call added events, got %d", len(addedNames))
76+ }
77+ if len(doneArgs) != 2 {
78+ t.Fatalf("expected 2 function_call done events, got %d", len(doneArgs))
79+ }
80+
81+ if addedNames["call_read"] != "read" {
82+ t.Fatalf("unexpected added name for call_read: %q", addedNames["call_read"])
83+ }
84+ if addedNames["call_glob"] != "glob" {
85+ t.Fatalf("unexpected added name for call_glob: %q", addedNames["call_glob"])
86+ }
87+
88+ if !gjson.Valid(doneArgs["call_read"]) {
89+ t.Fatalf("invalid JSON args for call_read: %q", doneArgs["call_read"])
90+ }
91+ if !gjson.Valid(doneArgs["call_glob"]) {
92+ t.Fatalf("invalid JSON args for call_glob: %q", doneArgs["call_glob"])
93+ }
94+ if strings.Contains(doneArgs["call_read"], "}{") {
95+ t.Fatalf("call_read args were concatenated: %q", doneArgs["call_read"])
96+ }
97+ if strings.Contains(doneArgs["call_glob"], "}{") {
98+ t.Fatalf("call_glob args were concatenated: %q", doneArgs["call_glob"])
99+ }
100+
101+ if doneNames["call_read"] != "read" {
102+ t.Fatalf("unexpected done name for call_read: %q", doneNames["call_read"])
103+ }
104+ if doneNames["call_glob"] != "glob" {
105+ t.Fatalf("unexpected done name for call_glob: %q", doneNames["call_glob"])
106+ }
107+
108+ if got := gjson.Get(doneArgs["call_read"], "filePath").String(); got != `C:\repo` {
109+ t.Fatalf("unexpected filePath for call_read: %q", got)
110+ }
111+ if got := gjson.Get(doneArgs["call_glob"], "path").String(); got != `C:\repo` {
112+ t.Fatalf("unexpected path for call_glob: %q", got)
113+ }
114+ if got := gjson.Get(doneArgs["call_glob"], "pattern").String(); got != "*.{yml,yaml}" {
115+ t.Fatalf("unexpected pattern for call_glob: %q", got)
116+ }
117+
118+ if len(outputItems) != 2 {
119+ t.Fatalf("expected 2 function_call items in response.output, got %d", len(outputItems))
120+ }
121+ if outputItems["call_read"].Get("name").String() != "read" {
122+ t.Fatalf("unexpected response.output name for call_read: %q", outputItems["call_read"].Get("name").String())
123+ }
124+ if outputItems["call_glob"].Get("name").String() != "glob" {
125+ t.Fatalf("unexpected response.output name for call_glob: %q", outputItems["call_glob"].Get("name").String())
126+ }
127+}
128+
129+func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultiChoiceToolCallsUseDistinctOutputIndexes(t *testing.T) {
130+ in := []string{
131+ `data: {"id":"resp_multi_choice","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_choice0","type":"function","function":{"name":"glob","arguments":""}}]},"finish_reason":null},{"index":1,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_choice1","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
132+ `data: {"id":"resp_multi_choice","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"C:\\\\repo\",\"pattern\":\"*.go\"}"}}]},"finish_reason":null},{"index":1,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\\\\README.md\",\"limit\":20,\"offset\":1}"}}]},"finish_reason":null}]}`,
133+ `data: {"id":"resp_multi_choice","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"},{"index":1,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
134+ }
135+
136+ request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
137+
138+ var param any
139+ var out [][]byte
140+ for _, line := range in {
141+ out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
142+ }
143+
144+ type fcEvent struct {
145+ outputIndex int64
146+ name string
147+ arguments string
148+ }
149+
150+ added := map[string]fcEvent{}
151+ done := map[string]fcEvent{}
152+
153+ for _, chunk := range out {
154+ ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
155+ switch ev {
156+ case "response.output_item.added":
157+ if data.Get("item.type").String() != "function_call" {
158+ continue
159+ }
160+ callID := data.Get("item.call_id").String()
161+ added[callID] = fcEvent{
162+ outputIndex: data.Get("output_index").Int(),
163+ name: data.Get("item.name").String(),
164+ }
165+ case "response.output_item.done":
166+ if data.Get("item.type").String() != "function_call" {
167+ continue
168+ }
169+ callID := data.Get("item.call_id").String()
170+ done[callID] = fcEvent{
171+ outputIndex: data.Get("output_index").Int(),
172+ name: data.Get("item.name").String(),
173+ arguments: data.Get("item.arguments").String(),
174+ }
175+ }
176+ }
177+
178+ if len(added) != 2 {
179+ t.Fatalf("expected 2 function_call added events, got %d", len(added))
180+ }
181+ if len(done) != 2 {
182+ t.Fatalf("expected 2 function_call done events, got %d", len(done))
183+ }
184+
185+ if added["call_choice0"].name != "glob" {
186+ t.Fatalf("unexpected added name for call_choice0: %q", added["call_choice0"].name)
187+ }
188+ if added["call_choice1"].name != "read" {
189+ t.Fatalf("unexpected added name for call_choice1: %q", added["call_choice1"].name)
190+ }
191+ if added["call_choice0"].outputIndex == added["call_choice1"].outputIndex {
192+ t.Fatalf("expected distinct output indexes for different choices, both got %d", added["call_choice0"].outputIndex)
193+ }
194+
195+ if !gjson.Valid(done["call_choice0"].arguments) {
196+ t.Fatalf("invalid JSON args for call_choice0: %q", done["call_choice0"].arguments)
197+ }
198+ if !gjson.Valid(done["call_choice1"].arguments) {
199+ t.Fatalf("invalid JSON args for call_choice1: %q", done["call_choice1"].arguments)
200+ }
201+ if done["call_choice0"].outputIndex == done["call_choice1"].outputIndex {
202+ t.Fatalf("expected distinct done output indexes for different choices, both got %d", done["call_choice0"].outputIndex)
203+ }
204+ if done["call_choice0"].name != "glob" {
205+ t.Fatalf("unexpected done name for call_choice0: %q", done["call_choice0"].name)
206+ }
207+ if done["call_choice1"].name != "read" {
208+ t.Fatalf("unexpected done name for call_choice1: %q", done["call_choice1"].name)
209+ }
210+}
211+
212+func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MixedMessageAndToolUseDistinctOutputIndexes(t *testing.T) {
213+ in := []string{
214+ `data: {"id":"resp_mixed","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":"hello","reasoning_content":null,"tool_calls":null},"finish_reason":null},{"index":1,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_choice1","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
215+ `data: {"id":"resp_mixed","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"stop"},{"index":1,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\\\\README.md\",\"limit\":20,\"offset\":1}"}}]},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
216+ }
217+
218+ request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
219+
220+ var param any
221+ var out [][]byte
222+ for _, line := range in {
223+ out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
224+ }
225+
226+ var messageOutputIndex int64 = -1
227+ var toolOutputIndex int64 = -1
228+
229+ for _, chunk := range out {
230+ ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
231+ if ev != "response.output_item.added" {
232+ continue
233+ }
234+ switch data.Get("item.type").String() {
235+ case "message":
236+ if data.Get("item.id").String() == "msg_resp_mixed_0" {
237+ messageOutputIndex = data.Get("output_index").Int()
238+ }
239+ case "function_call":
240+ if data.Get("item.call_id").String() == "call_choice1" {
241+ toolOutputIndex = data.Get("output_index").Int()
242+ }
243+ }
244+ }
245+
246+ if messageOutputIndex < 0 {
247+ t.Fatal("did not find message output index")
248+ }
249+ if toolOutputIndex < 0 {
250+ t.Fatal("did not find tool output index")
251+ }
252+ if messageOutputIndex == toolOutputIndex {
253+ t.Fatalf("expected distinct output indexes for message and tool call, both got %d", messageOutputIndex)
254+ }
255+}
256+
257+func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_FunctionCallDoneAndCompletedOutputStayAscending(t *testing.T) {
258+ in := []string{
259+ `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":0,"id":"call_glob","type":"function","function":{"name":"glob","arguments":""}}]},"finish_reason":null}]}`,
260+ `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":\"C:\\\\repo\",\"pattern\":\"*.go\"}"}}]},"finish_reason":null}]}`,
261+ `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":[{"index":1,"id":"call_read","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}`,
262+ `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"index":1,"function":{"arguments":"{\"filePath\":\"C:\\\\repo\\\\README.md\",\"limit\":20,\"offset\":1}"}}]},"finish_reason":null}]}`,
263+ `data: {"id":"resp_order","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":"tool_calls"}],"usage":{"completion_tokens":10,"total_tokens":20,"prompt_tokens":10}}`,
264+ }
265+
266+ request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
267+
268+ var param any
269+ var out [][]byte
270+ for _, line := range in {
271+ out = append(out, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), &param)...)
272+ }
273+
274+ var doneIndexes []int64
275+ var completedOrder []string
276+
277+ for _, chunk := range out {
278+ ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
279+ switch ev {
280+ case "response.output_item.done":
281+ if data.Get("item.type").String() == "function_call" {
282+ doneIndexes = append(doneIndexes, data.Get("output_index").Int())
283+ }
284+ case "response.completed":
285+ for _, item := range data.Get("response.output").Array() {
286+ if item.Get("type").String() == "function_call" {
287+ completedOrder = append(completedOrder, item.Get("call_id").String())
288+ }
289+ }
290+ }
291+ }
292+
293+ if len(doneIndexes) != 2 {
294+ t.Fatalf("expected 2 function_call done indexes, got %d", len(doneIndexes))
295+ }
296+ if doneIndexes[0] >= doneIndexes[1] {
297+ t.Fatalf("expected ascending done output indexes, got %v", doneIndexes)
298+ }
299+ if len(completedOrder) != 2 {
300+ t.Fatalf("expected 2 function_call items in completed output, got %d", len(completedOrder))
301+ }
302+ if completedOrder[0] != "call_glob" || completedOrder[1] != "call_read" {
303+ t.Fatalf("unexpected completed function_call order: %v", completedOrder)
304+ }
305+}