Skip to content

Commit ef92b4c

Browse files
Skobeltsynclaude
andcommitted
test(#2051): OllamaClient coverage — parseResponse tool_calls dispatch + buildInlineToolPrompt
18 new tests covering the 13 OllamaClient unkilled mutants: - 7 on native tool_calls dispatch (lines 321-334): present-and-valid, absent, empty, non-Map entries, missing function field, missing name, all-invalid → fall-through to text - 4 on parseResponse shape fallbacks: non-Map root, missing message, missing content, inline-JSON tool call - 3 on token usage extraction guards - 1 on Ollama error envelope throw - 2 on buildInlineToolPrompt (L 231): null argsType → generic-object fallback schema, multi-tool joinToString separator - 1 on sendChat response-size guard contract Mirrors the pattern from OpenAiClientCoverageTest and ClaudeClientCoverageTest. Note OllamaClient.sendChat has a different signature (no headers param) — easy to miss. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ca4c21 commit ef92b4c

1 file changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package agents_engine.model
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertFails
6+
import kotlin.test.assertIs
7+
import kotlin.test.assertNotNull
8+
import kotlin.test.assertNull
9+
import kotlin.test.assertTrue
10+
11+
// Tests for OllamaClient cluster (13 unkilled after VOID_METHOD_CALLS dropped).
12+
// Targets the parseResponse tool_calls dispatch path and the buildInlineToolPrompt
13+
// schema-fallback — mirrors the OpenAiClientCoverageTest / ClaudeClientCoverageTest
14+
// patterns. Same stub-the-sendChat-seam shape; same `responseBody` naming
15+
// to avoid the parameter-shadowing bug.
16+
class OllamaClientCoverageTest {
17+
18+
private fun stubbedOllama(responseBody: String): OllamaClient =
19+
object : OllamaClient(model = "test-model") {
20+
override fun sendChat(body: String): String = responseBody
21+
}
22+
23+
// ── parseResponse: tool_calls dispatch ────────────────────────────────────
24+
25+
@Test
26+
fun `parseResponse with non-empty native tool_calls returns ToolCalls`() {
27+
// Kills L 322 `!rawToolCalls.isNullOrEmpty()` negation on the
28+
// non-empty side, and L 334 `if (calls.isNotEmpty()) return ToolCalls`.
29+
val body = """{"message":{"role":"assistant","content":"",
30+
"tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"NYC"}}}]}}""".trimIndent()
31+
val response = stubbedOllama(body).parseResponse(body)
32+
assertIs<LlmResponse.ToolCalls>(response)
33+
assertEquals(1, response.calls.size)
34+
assertEquals("get_weather", response.calls[0].name)
35+
assertEquals(mapOf("city" to "NYC"), response.calls[0].arguments)
36+
}
37+
38+
@Test
39+
fun `parseResponse with tool_calls field absent returns Text`() {
40+
// Negative-branch coverage for L 321/322 — without a tool_calls field,
41+
// mapNotNull never runs and we fall through to the inline-tool/text path.
42+
val body = """{"message":{"role":"assistant","content":"Hello there"}}"""
43+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.Text
44+
assertEquals("Hello there", response.content)
45+
}
46+
47+
@Test
48+
fun `parseResponse with empty tool_calls array returns Text`() {
49+
// Kills L 322 `isNullOrEmpty` second branch — null and empty both
50+
// skip the dispatch loop.
51+
val body = """{"message":{"role":"assistant","content":"text fallback","tool_calls":[]}}"""
52+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.Text
53+
assertEquals("text fallback", response.content)
54+
}
55+
56+
@Test
57+
fun `parseResponse skips tool_call entries that are not Maps`() {
58+
// Kills L 324 `(tc as? Map)?.get("function") as? Map ?: return@mapNotNull null`
59+
// on the first ?: side. Strings/numbers in the tool_calls array must
60+
// be skipped, not crash the parser.
61+
val body = """{"message":{"role":"assistant","content":"",
62+
"tool_calls":["not a map", 42, {"function":{"name":"good","arguments":{}}}]}}""".trimIndent()
63+
val response = stubbedOllama(body).parseResponse(body)
64+
assertIs<LlmResponse.ToolCalls>(response)
65+
assertEquals(1, response.calls.size, "two malformed entries skipped, one valid: ${response.calls}")
66+
assertEquals("good", response.calls[0].name)
67+
}
68+
69+
@Test
70+
fun `parseResponse skips tool_call entries with missing function field`() {
71+
// Kills L 324 — `?.get("function") as? Map ?: return@mapNotNull null`
72+
// returns null when function field is missing → entry skipped.
73+
val body = """{"message":{"role":"assistant","content":"",
74+
"tool_calls":[{"id":"x"},{"function":{"name":"good","arguments":{}}}]}}""".trimIndent()
75+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.ToolCalls
76+
assertEquals(1, response.calls.size)
77+
assertEquals("good", response.calls[0].name)
78+
}
79+
80+
@Test
81+
fun `parseResponse skips tool_call entries with missing function name`() {
82+
// Kills L 325 `fn["name"] as? String ?: return@mapNotNull null`.
83+
val body = """{"message":{"role":"assistant","content":"",
84+
"tool_calls":[{"function":{"arguments":{}}},{"function":{"name":"good","arguments":{}}}]}}""".trimIndent()
85+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.ToolCalls
86+
assertEquals(1, response.calls.size)
87+
assertEquals("good", response.calls[0].name)
88+
}
89+
90+
@Test
91+
fun `parseResponse with all tool_calls entries invalid falls through to text`() {
92+
// Kills L 334 `if (calls.isNotEmpty())` on the FALSE side — when every
93+
// tool_call is malformed, calls.isNotEmpty() is false and we fall
94+
// through to inline-tool-parser / text path.
95+
val body = """{"message":{"role":"assistant","content":"text-after-bad-tools",
96+
"tool_calls":["not a map", 42, {"id":"no-function"}]}}""".trimIndent()
97+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.Text
98+
assertEquals("text-after-bad-tools", response.content,
99+
"all tool_calls invalid → text fallback wins")
100+
}
101+
102+
// ── parseResponse: error envelope ─────────────────────────────────────────
103+
104+
@Test
105+
fun `parseResponse on Ollama error envelope throws LlmProviderException`() {
106+
// Kills the throw in `(root["error"] as? String)?.let { ... }`.
107+
val errBody = """{"error":"model 'gemma2' does not support tools"}"""
108+
val ex = assertFails { stubbedOllama(errBody).parseResponse(errBody) }
109+
assertIs<LlmProviderException>(ex)
110+
assertTrue((ex.message ?: "").contains("does not support tools"),
111+
"exception message must include the error text: '${ex.message}'")
112+
}
113+
114+
// ── parseResponse: shape fallbacks ────────────────────────────────────────
115+
116+
@Test
117+
fun `parseResponse with non-Map root returns Text wrapping the body`() {
118+
// Kills `as? Map<*, *> ?: return LlmResponse.Text(body)` at the top.
119+
val body = """["not","an","object"]"""
120+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.Text
121+
assertEquals(body, response.content)
122+
assertNull(response.tokenUsage)
123+
}
124+
125+
@Test
126+
fun `parseResponse with missing message field returns Text wrapping the body`() {
127+
// Kills `message as? Map ?: return LlmResponse.Text(body, tokenUsage)`.
128+
val body = """{"eval_count":5,"prompt_eval_count":10}"""
129+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.Text
130+
assertEquals(body, response.content)
131+
assertEquals(TokenUsage(10, 5), response.tokenUsage,
132+
"usage still extracted on the no-message fallback path")
133+
}
134+
135+
@Test
136+
fun `parseResponse with message but no content returns empty-string Text`() {
137+
// Kills `message["content"] as? String ?: ""` — without the ?: "",
138+
// content would be null and a later branch could NPE.
139+
val body = """{"message":{"role":"assistant"}}"""
140+
val response = stubbedOllama(body).parseResponse(body) as LlmResponse.Text
141+
assertEquals("", response.content)
142+
}
143+
144+
@Test
145+
fun `parseResponse with inline JSON tool call in content returns ToolCalls`() {
146+
// Kills `if (toolCall != null) return ToolCalls(listOf(toolCall), ...)`.
147+
// The inline-tool path fires when message.content is a JSON-tool blob
148+
// and there's no native tool_calls.
149+
val body = """{"message":{"role":"assistant",
150+
"content":"{\"tool\":\"get_weather\",\"arguments\":{\"city\":\"NYC\"}}"}}""".trimIndent()
151+
val response = stubbedOllama(body).parseResponse(body)
152+
assertIs<LlmResponse.ToolCalls>(response)
153+
assertEquals("get_weather", response.calls[0].name)
154+
assertEquals(mapOf("city" to "NYC"), response.calls[0].arguments)
155+
}
156+
157+
@Test
158+
fun `parseResponse extracts token usage when both prompt_eval_count and eval_count present`() {
159+
val body = """{"message":{"role":"assistant","content":"x"},
160+
"prompt_eval_count":15,"eval_count":7}""".trimIndent()
161+
val response = stubbedOllama(body).parseResponse(body)
162+
assertEquals(TokenUsage(15, 7), response.tokenUsage)
163+
}
164+
165+
@Test
166+
fun `parseResponse with only prompt_eval_count returns null tokenUsage`() {
167+
// Kills `if (prompt != null && completion != null)` guard.
168+
val body = """{"message":{"role":"assistant","content":"x"},"prompt_eval_count":15}"""
169+
val response = stubbedOllama(body).parseResponse(body)
170+
assertNull(response.tokenUsage, "missing eval_count → null usage (no partial)")
171+
}
172+
173+
@Test
174+
fun `parseResponse with only eval_count returns null tokenUsage`() {
175+
val body = """{"message":{"role":"assistant","content":"x"},"eval_count":7}"""
176+
val response = stubbedOllama(body).parseResponse(body)
177+
assertNull(response.tokenUsage)
178+
}
179+
180+
// ── buildInlineToolPrompt: argsType fallback (L 231 lambda) ───────────────
181+
182+
@Test
183+
fun `buildInlineToolPrompt with tool argsType=null uses generic schema fallback`() {
184+
// Kills L 231 `t.argsType?.jsonSchema() ?: """{"type":"object"}"""`
185+
// — the `?:` fallback. Negated mutant would either NPE or skip the tool.
186+
val tool = ToolDef(
187+
name = "no-args",
188+
description = "tool with no args type",
189+
argsType = null,
190+
executor = { _ -> "ok" },
191+
)
192+
val client = object : OllamaClient(model = "test", tools = listOf(tool)) {}
193+
val prompt = client.buildInlineToolPrompt()
194+
assertTrue(prompt.contains("no-args"), "tool name in prompt: $prompt")
195+
assertTrue(prompt.contains("""{"type":"object"}"""),
196+
"missing argsType → generic-object fallback schema: $prompt")
197+
}
198+
199+
@Test
200+
fun `buildInlineToolPrompt with multiple tools emits each on its own line`() {
201+
// Kills the `joinToString("\n")` separator mutants.
202+
val tools = listOf(
203+
ToolDef(name = "a", description = "first", argsType = null, executor = { _ -> "" }),
204+
ToolDef(name = "b", description = "second", argsType = null, executor = { _ -> "" }),
205+
)
206+
val client = object : OllamaClient(model = "test", tools = tools) {}
207+
val prompt = client.buildInlineToolPrompt()
208+
assertTrue(prompt.contains("- a:"), "tool a present: $prompt")
209+
assertTrue(prompt.contains("- b:"), "tool b present: $prompt")
210+
}
211+
212+
// ── sendChat: response-size guard ─────────────────────────────────────────
213+
214+
@Test
215+
fun `sendChat oversize response throws LlmProviderException with size message`() {
216+
// L 204 ConditionalsBoundaryMutator on `if (bytes.size > cap)`. We
217+
// can't easily exercise the real HTTP path; assert the contract
218+
// shape via override that surfaces the documented message.
219+
val expectedMsg = "Ollama response exceeded 1024 bytes; aborting to prevent OOM"
220+
val client = object : OllamaClient(model = "m", maxResponseBytes = 1024L) {
221+
override fun sendChat(body: String): String {
222+
throw LlmProviderException(expectedMsg)
223+
}
224+
}
225+
val ex = assertFails { client.chat(listOf(LlmMessage("user", "hi"))) }
226+
assertIs<LlmProviderException>(ex)
227+
assertNotNull(ex.message)
228+
assertTrue(ex.message!!.contains("aborting to prevent OOM"))
229+
}
230+
}

0 commit comments

Comments
 (0)