Skip to content

Commit ba21dcf

Browse files
test: add ChatRequestParsingTests covering tool_calls index mapping (PR #92)
1 parent 75b4f66 commit ba21dcf

1 file changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import XCTest
2+
import Foundation
3+
@testable import SwiftLM
4+
5+
final class ChatRequestParsingTests: XCTestCase {
6+
7+
// MARK: - Helper: decode a ChatCompletionRequest from a JSON string
8+
9+
private func decode(_ json: String) throws -> ChatCompletionRequest {
10+
let data = try XCTUnwrap(json.data(using: .utf8))
11+
return try JSONDecoder().decode(ChatCompletionRequest.self, from: data)
12+
}
13+
14+
// MARK: - Helper: replicate the exact mapping logic from handleChatCompletion (lines 1368-1382)
15+
// This mirrors the production code so the test locks down current behavior.
16+
17+
private func mapAssistantToolCalls(_ msg: ChatCompletionRequest.Message) -> [[String: any Sendable]]? {
18+
guard let tc = msg.tool_calls, !tc.isEmpty else { return nil }
19+
return tc.enumerated().map { (index, call) in
20+
[
21+
"index": index,
22+
"id": call.id,
23+
"type": call.type,
24+
"function": [
25+
"name": call.function.name,
26+
"arguments": call.function.arguments
27+
] as [String: any Sendable]
28+
] as [String: any Sendable]
29+
}
30+
}
31+
32+
// ═══════════════════════════════════════════════════════════════════
33+
// MARK: - 1. Tool calls with index field (PR #92)
34+
// ═══════════════════════════════════════════════════════════════════
35+
36+
func testToolCallsMappingIncludesIndex() throws {
37+
let json = """
38+
{
39+
"model": "test-model",
40+
"messages": [
41+
{
42+
"role": "assistant",
43+
"content": "I'll search for that.",
44+
"tool_calls": [
45+
{
46+
"id": "call_abc123",
47+
"type": "function",
48+
"function": {
49+
"name": "get_weather",
50+
"arguments": "{\\"city\\": \\"Tokyo\\"}"
51+
}
52+
}
53+
]
54+
}
55+
]
56+
}
57+
"""
58+
59+
let req = try decode(json)
60+
let msg = req.messages[0]
61+
let mapped = try XCTUnwrap(mapAssistantToolCalls(msg))
62+
63+
XCTAssertEqual(mapped.count, 1)
64+
XCTAssertEqual(mapped[0]["index"] as? Int, 0, "First tool call must have index 0")
65+
XCTAssertEqual(mapped[0]["id"] as? String, "call_abc123")
66+
XCTAssertEqual(mapped[0]["type"] as? String, "function")
67+
68+
let fn = try XCTUnwrap(mapped[0]["function"] as? [String: any Sendable])
69+
XCTAssertEqual(fn["name"] as? String, "get_weather")
70+
XCTAssertEqual(fn["arguments"] as? String, "{\"city\": \"Tokyo\"}")
71+
}
72+
73+
func testMultipleToolCallsHaveCorrectIndices() throws {
74+
let json = """
75+
{
76+
"model": "test-model",
77+
"messages": [
78+
{
79+
"role": "assistant",
80+
"content": "",
81+
"tool_calls": [
82+
{
83+
"id": "call_1",
84+
"type": "function",
85+
"function": { "name": "search", "arguments": "{\\"q\\": \\"a\\"}" }
86+
},
87+
{
88+
"id": "call_2",
89+
"type": "function",
90+
"function": { "name": "lookup", "arguments": "{\\"id\\": 42}" }
91+
},
92+
{
93+
"id": "call_3",
94+
"type": "function",
95+
"function": { "name": "save", "arguments": "{}" }
96+
}
97+
]
98+
}
99+
]
100+
}
101+
"""
102+
103+
let req = try decode(json)
104+
let mapped = try XCTUnwrap(mapAssistantToolCalls(req.messages[0]))
105+
106+
XCTAssertEqual(mapped.count, 3)
107+
for i in 0..<3 {
108+
XCTAssertEqual(mapped[i]["index"] as? Int, i, "Tool call at position \(i) must have index \(i)")
109+
}
110+
XCTAssertEqual(mapped[0]["id"] as? String, "call_1")
111+
XCTAssertEqual(mapped[1]["id"] as? String, "call_2")
112+
XCTAssertEqual(mapped[2]["id"] as? String, "call_3")
113+
}
114+
115+
// ═══════════════════════════════════════════════════════════════════
116+
// MARK: - 2. Assistant message without tool_calls
117+
// ═══════════════════════════════════════════════════════════════════
118+
119+
func testAssistantWithoutToolCalls() throws {
120+
let json = """
121+
{
122+
"model": "test-model",
123+
"messages": [
124+
{
125+
"role": "assistant",
126+
"content": "Hello, how can I help you?"
127+
}
128+
]
129+
}
130+
"""
131+
132+
let req = try decode(json)
133+
let mapped = mapAssistantToolCalls(req.messages[0])
134+
XCTAssertNil(mapped, "Assistant message without tool_calls should map to nil")
135+
}
136+
137+
func testAssistantWithEmptyToolCalls() throws {
138+
let json = """
139+
{
140+
"model": "test-model",
141+
"messages": [
142+
{
143+
"role": "assistant",
144+
"content": "Done.",
145+
"tool_calls": []
146+
}
147+
]
148+
}
149+
"""
150+
151+
let req = try decode(json)
152+
let mapped = mapAssistantToolCalls(req.messages[0])
153+
XCTAssertNil(mapped, "Assistant message with empty tool_calls array should map to nil")
154+
}
155+
156+
// ═══════════════════════════════════════════════════════════════════
157+
// MARK: - 3. Tool role message (tool_call_id)
158+
// ═══════════════════════════════════════════════════════════════════
159+
160+
func testToolRoleMessage() throws {
161+
let json = """
162+
{
163+
"model": "test-model",
164+
"messages": [
165+
{
166+
"role": "tool",
167+
"content": "{\\"temp\\": 22}",
168+
"tool_call_id": "call_abc123"
169+
}
170+
]
171+
}
172+
"""
173+
174+
let req = try decode(json)
175+
let msg = req.messages[0]
176+
XCTAssertEqual(msg.role, "tool")
177+
XCTAssertEqual(msg.tool_call_id, "call_abc123")
178+
XCTAssertEqual(msg.textContent, "{\"temp\": 22}")
179+
}
180+
181+
// ═══════════════════════════════════════════════════════════════════
182+
// MARK: - 4. Multi-turn conversation with tool round-trip
183+
// ═══════════════════════════════════════════════════════════════════
184+
185+
func testFullToolRoundTrip() throws {
186+
let json = """
187+
{
188+
"model": "test-model",
189+
"messages": [
190+
{ "role": "system", "content": "You are a helpful assistant." },
191+
{ "role": "user", "content": "What's the weather in Tokyo?" },
192+
{
193+
"role": "assistant",
194+
"content": "",
195+
"tool_calls": [
196+
{
197+
"id": "call_w1",
198+
"type": "function",
199+
"function": { "name": "get_weather", "arguments": "{\\"city\\":\\"Tokyo\\"}" }
200+
}
201+
]
202+
},
203+
{
204+
"role": "tool",
205+
"content": "{\\"temp\\":22,\\"condition\\":\\"sunny\\"}",
206+
"tool_call_id": "call_w1"
207+
},
208+
{ "role": "assistant", "content": "It's 22°C and sunny in Tokyo." }
209+
]
210+
}
211+
"""
212+
213+
let req = try decode(json)
214+
XCTAssertEqual(req.messages.count, 5)
215+
216+
// Message 0: system
217+
XCTAssertEqual(req.messages[0].role, "system")
218+
219+
// Message 1: user
220+
XCTAssertEqual(req.messages[1].role, "user")
221+
222+
// Message 2: assistant with tool_calls
223+
let assistantToolMsg = req.messages[2]
224+
XCTAssertEqual(assistantToolMsg.role, "assistant")
225+
let mapped = try XCTUnwrap(mapAssistantToolCalls(assistantToolMsg))
226+
XCTAssertEqual(mapped.count, 1)
227+
XCTAssertEqual(mapped[0]["index"] as? Int, 0)
228+
XCTAssertEqual(mapped[0]["id"] as? String, "call_w1")
229+
230+
// Message 3: tool response
231+
XCTAssertEqual(req.messages[3].role, "tool")
232+
XCTAssertEqual(req.messages[3].tool_call_id, "call_w1")
233+
234+
// Message 4: final assistant
235+
XCTAssertEqual(req.messages[4].role, "assistant")
236+
XCTAssertNil(req.messages[4].tool_calls)
237+
}
238+
239+
// ═══════════════════════════════════════════════════════════════════
240+
// MARK: - 5. MessageContent decoding (string vs multipart)
241+
// ═══════════════════════════════════════════════════════════════════
242+
243+
func testTextContentFromPlainString() throws {
244+
let json = """
245+
{
246+
"model": "m",
247+
"messages": [{ "role": "user", "content": "Hello world" }]
248+
}
249+
"""
250+
let req = try decode(json)
251+
XCTAssertEqual(req.messages[0].textContent, "Hello world")
252+
}
253+
254+
func testTextContentFromMultipartParts() throws {
255+
let json = """
256+
{
257+
"model": "m",
258+
"messages": [{
259+
"role": "user",
260+
"content": [
261+
{ "type": "text", "text": "Describe this image:" },
262+
{ "type": "image_url", "image_url": { "url": "https://example.com/cat.jpg" } }
263+
]
264+
}]
265+
}
266+
"""
267+
let req = try decode(json)
268+
XCTAssertEqual(req.messages[0].textContent, "Describe this image:")
269+
}
270+
271+
func testNullContentDecodesToEmptyString() throws {
272+
let json = """
273+
{
274+
"model": "m",
275+
"messages": [{ "role": "assistant", "content": null }]
276+
}
277+
"""
278+
let req = try decode(json)
279+
XCTAssertEqual(req.messages[0].textContent, "")
280+
}
281+
282+
// ═══════════════════════════════════════════════════════════════════
283+
// MARK: - 6. Tools definition parsing
284+
// ═══════════════════════════════════════════════════════════════════
285+
286+
func testToolsDefinitionParsing() throws {
287+
let json = """
288+
{
289+
"model": "m",
290+
"messages": [{ "role": "user", "content": "hi" }],
291+
"tools": [
292+
{
293+
"type": "function",
294+
"function": {
295+
"name": "get_weather",
296+
"description": "Get current weather for a city",
297+
"parameters": {
298+
"type": { "type": "object" },
299+
"properties": {
300+
"type": "object",
301+
"city": { "type": "string" }
302+
}
303+
}
304+
}
305+
}
306+
]
307+
}
308+
"""
309+
let req = try decode(json)
310+
XCTAssertNotNil(req.tools)
311+
XCTAssertEqual(req.tools?.count, 1)
312+
XCTAssertEqual(req.tools?[0].function.name, "get_weather")
313+
XCTAssertEqual(req.tools?[0].function.description, "Get current weather for a city")
314+
}
315+
}

0 commit comments

Comments
 (0)