| 1 | package responses |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | func chunksOf(t *testing.T, events ...string) []provider.Chunk { |
| 12 | t.Helper() |
| 13 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| 14 | writeEvents(w, events...) |
| 15 | })) |
| 16 | defer server.Close() |
| 17 | return collect(t, New(Config{Name: "compatible", APIKey: "k", BaseURL: server.URL, Model: "m", Mode: "stateless"}), |
| 18 | provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "list files"}}}) |
| 19 | } |
| 20 | |
| 21 | func toolCallsOf(t *testing.T, events ...string) []provider.ToolCall { |
| 22 | t.Helper() |
| 23 | var calls []provider.ToolCall |
| 24 | for _, chunk := range chunksOf(t, events...) { |
| 25 | if chunk.Type == provider.ChunkToolCall && chunk.ToolCall != nil { |
| 26 | calls = append(calls, *chunk.ToolCall) |
| 27 | } |
| 28 | } |
| 29 | return calls |
| 30 | } |
| 31 | |
| 32 | func TestCompletedOutputClosesACallWhoseDoneEventsNeverCame(t *testing.T) { |
| 33 | calls := toolCallsOf(t, |
| 34 | `{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":""}}`, |
| 35 | `{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"path\":"}`, |
| 36 | `{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\".\"}"}`, |
| 37 | `{"type":"response.completed","response":{"id":"resp_1","output":[{"id":"fc_1","type":"function_call","status":"completed","call_id":"call_1","name":"ls","arguments":"{\"path\":\".\"}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 38 | ) |
| 39 | if len(calls) != 1 || calls[0].ID != "call_1" || calls[0].Name != "ls" || calls[0].Arguments != `{"path":"."}` { |
| 40 | t.Fatalf("calls = %#v, want the one call the completed response lists", calls) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestCompletedOutputClosesACallWhoseDeltasCarriedNoItemID(t *testing.T) { |
| 45 | calls := toolCallsOf(t, |
| 46 | `{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":""}}`, |
| 47 | `{"type":"response.function_call_arguments.delta","delta":"{}"}`, |
| 48 | `{"type":"response.completed","response":{"id":"resp_1","output":[{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 49 | ) |
| 50 | if len(calls) != 1 || calls[0].ID != "call_1" || calls[0].Name != "ls" { |
| 51 | t.Fatalf("calls = %#v, want exactly the listed call", calls) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestCompletedOutputDoesNotRepeatACallTheStreamClosed(t *testing.T) { |
| 56 | calls := toolCallsOf(t, |
| 57 | `{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":""}}`, |
| 58 | `{"type":"response.output_item.done","item":{"id":"fc_2","type":"function_call","status":"completed","call_id":"call_1","name":"ls","arguments":"{}"}}`, |
| 59 | `{"type":"response.completed","response":{"id":"resp_1","output":[{"id":"fc_1","type":"function_call","status":"completed","call_id":"call_1","name":"ls","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 60 | ) |
| 61 | if len(calls) != 1 || calls[0].ID != "call_1" { |
| 62 | t.Fatalf("calls = %#v, want call_1 dispatched once", calls) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestIncompleteOutputCallIsNotDispatched(t *testing.T) { |
| 67 | calls := toolCallsOf(t, |
| 68 | `{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"write_file","arguments":""}}`, |
| 69 | `{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"path\":\"a"}`, |
| 70 | `{"type":"response.incomplete","response":{"id":"resp_1","incomplete_details":{"reason":"max_output_tokens"},"output":[{"id":"fc_1","type":"function_call","status":"incomplete","call_id":"call_1","name":"write_file","arguments":"{\"path\":\"a"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 71 | ) |
| 72 | if len(calls) != 0 { |
| 73 | t.Fatalf("calls = %#v, want a truncated call left undispatched", calls) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestCompletedOutputSurvivesAnItemItCannotRead(t *testing.T) { |
| 78 | chunks := chunksOf(t, |
| 79 | `{"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":""}}`, |
| 80 | `{"type":"response.completed","response":{"id":"resp_1","output":[{"id":"rs_1","type":"reasoning","status":{"phase":"done"}},{"id":"fc_2","type":"function_call","call_id":"call_2","name":"ls","arguments":{"path":"."}},{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 81 | ) |
| 82 | var calls []provider.ToolCall |
| 83 | done := false |
| 84 | for _, chunk := range chunks { |
| 85 | switch chunk.Type { |
| 86 | case provider.ChunkError: |
| 87 | t.Fatalf("error chunk: %v", chunk.Err) |
| 88 | case provider.ChunkDone: |
| 89 | done = true |
| 90 | case provider.ChunkToolCall: |
| 91 | calls = append(calls, *chunk.ToolCall) |
| 92 | } |
| 93 | } |
| 94 | if !done { |
| 95 | t.Fatalf("chunks = %#v, want the turn to finish", chunks) |
| 96 | } |
| 97 | if len(calls) != 1 || calls[0].ID != "call_1" { |
| 98 | t.Fatalf("calls = %#v, want only the readable call_1", calls) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestOutputCallWithoutCallIDIsNotDispatched(t *testing.T) { |
| 103 | calls := toolCallsOf(t, |
| 104 | `{"type":"response.completed","response":{"id":"resp_1","output":[{"id":"fc_1","type":"function_call","status":"completed","name":"ls","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 105 | ) |
| 106 | if len(calls) != 0 { |
| 107 | t.Fatalf("calls = %#v, want a call with no call id left undispatched", calls) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestOutputListingACallTwiceDispatchesItOnce(t *testing.T) { |
| 112 | calls := toolCallsOf(t, |
| 113 | `{"type":"response.completed","response":{"id":"resp_1","output":[{"id":"fc_1","type":"function_call","call_id":"call_1","name":"ls","arguments":"{}"},{"id":"fc_2","type":"function_call","call_id":"call_1","name":"ls","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 114 | ) |
| 115 | if len(calls) != 1 || calls[0].ID != "call_1" { |
| 116 | t.Fatalf("calls = %#v, want call_1 dispatched once", calls) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestArgumentsDoneWithoutItemIDIsNotDispatched(t *testing.T) { |
| 121 | calls := toolCallsOf(t, |
| 122 | `{"type":"response.function_call_arguments.done","arguments":"{}"}`, |
| 123 | `{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, |
| 124 | ) |
| 125 | if len(calls) != 0 { |
| 126 | t.Fatalf("calls = %#v, want no call without a name", calls) |
| 127 | } |
| 128 | } |
| 129 |