- NDJSONDecoder emits .toolUseRequested whenever tool_calls is present on
any chunk; never gates on done:true (AGENT-04 transport gotcha).
- Each tool_use gets a fresh UUID id (Ollama emits no ids on /api/chat).
- done_reason mapping: stop→endTurn (or toolUse if calls seen), length→
maxTokens, tool_calls→toolUse, missing→toolUse-if-calls-else-endTurn.
- Mid-stream EOF emits .stopReason(.streamTruncated) — no
partialToolUseAtDisconnect (NDJSON is atomic per line).
- OllamaRequestBody.encodeNative drops the tools array entirely when
ToolChoice == .none (AGENT-07 cap-recovery invariant).
- ToolChoice.tool(name:) appends a best-effort system hint message
(Ollama has no first-class tool-choice.force).
- OpenAI-compat path: tool_choice serializes as "auto"/"none"/"required"
string or {type:function,function:{name}} object.
- Replaces Plan 04-01 placeholder files; tests: 19 passing.
185 lines
8.3 KiB
Swift
185 lines
8.3 KiB
Swift
import XCTest
|
||
import Foundation
|
||
@testable import OllamaProvider
|
||
@testable import AgentCore
|
||
|
||
/// Unit tests for `NDJSONDecoder`. The critical AGENT-04 regression guard
|
||
/// (Test N2) verifies that `tool_calls` on a non-terminator chunk is emitted
|
||
/// before `stopReason` — i.e., the decoder does NOT gate tool_calls on the
|
||
/// terminator flag.
|
||
final class NDJSONDecoderTests: XCTestCase {
|
||
|
||
// MARK: - Helpers
|
||
|
||
/// Run a sequence of NDJSON lines (as JSON-string literals) through the
|
||
/// decoder, collecting all yielded events.
|
||
private func run(_ lines: [String], eof: Bool = true) -> [LLMEvent] {
|
||
var decoder = NDJSONDecoder()
|
||
var collected: [LLMEvent] = []
|
||
for line in lines {
|
||
decoder.dispatch(line: Data(line.utf8)) { collected.append($0) }
|
||
if decoder.messageStopEmitted { break }
|
||
}
|
||
if eof, !decoder.messageStopEmitted {
|
||
decoder.flushOnEOF { collected.append($0) }
|
||
}
|
||
return collected
|
||
}
|
||
|
||
/// Find the first index whose event matches the predicate.
|
||
private func firstIndex(
|
||
_ events: [LLMEvent],
|
||
matching predicate: (LLMEvent) -> Bool
|
||
) -> Int? {
|
||
events.firstIndex(where: predicate)
|
||
}
|
||
|
||
// MARK: - N1: happy text
|
||
|
||
func testHappyText() {
|
||
let events = run([
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"x"},"done":false}"#,
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"y"},"done":false}"#,
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"z"},"done":false}"#,
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","prompt_eval_count":12,"eval_count":47}"#,
|
||
])
|
||
// messageStart, textDelta×3, stopReason, usage, messageStop.
|
||
XCTAssertEqual(events.count, 7)
|
||
guard case .messageStart = events[0] else { XCTFail("expected messageStart"); return }
|
||
guard case .textDelta(let t1) = events[1], t1 == "x" else { XCTFail("textDelta x"); return }
|
||
guard case .textDelta(let t2) = events[2], t2 == "y" else { XCTFail("textDelta y"); return }
|
||
guard case .textDelta(let t3) = events[3], t3 == "z" else { XCTFail("textDelta z"); return }
|
||
guard case .stopReason(let r) = events[4], r == .endTurn else { XCTFail("stopReason endTurn"); return }
|
||
guard case .usage(let u) = events[5] else { XCTFail("usage"); return }
|
||
XCTAssertEqual(u.inputTokens, 12)
|
||
XCTAssertEqual(u.outputTokens, 47)
|
||
XCTAssertEqual(u.cacheCreationInputTokens, 0)
|
||
XCTAssertEqual(u.cacheReadInputTokens, 0)
|
||
guard case .messageStop = events[6] else { XCTFail("messageStop"); return }
|
||
}
|
||
|
||
// MARK: - N2: AGENT-04 regression guard — tool_calls on non-terminator chunk
|
||
|
||
/// Tool calls arrive on chunk 2 with `done:false`; the terminator is
|
||
/// chunk 3. The decoder MUST emit `.toolUseRequested` from chunk 2 —
|
||
/// before the terminator — not gate it on `done:true`.
|
||
func testToolCallsOnNonTerminatorChunk_AGENT04() {
|
||
let events = run([
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"let me check"},"done":false}"#,
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_time","arguments":{"timezone":"UTC"}}}]},"done":false}"#,
|
||
#"{"model":"qwen2.5-coder:32b","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}"#,
|
||
])
|
||
|
||
let toolReqIdx = firstIndex(events) {
|
||
if case .toolUseRequested = $0 { return true } else { return false }
|
||
}
|
||
let stopReasonIdx = firstIndex(events) {
|
||
if case .stopReason = $0 { return true } else { return false }
|
||
}
|
||
XCTAssertNotNil(toolReqIdx, "decoder must emit toolUseRequested on tool_calls chunk")
|
||
XCTAssertNotNil(stopReasonIdx, "decoder must emit stopReason at terminator")
|
||
XCTAssertLessThan(toolReqIdx!, stopReasonIdx!,
|
||
"AGENT-04 regression: toolUseRequested must precede stopReason")
|
||
|
||
guard case .toolUseRequested(let req) = events[toolReqIdx!] else {
|
||
XCTFail("event type"); return
|
||
}
|
||
XCTAssertEqual(req.name, "get_time")
|
||
XCTAssertEqual(String(data: req.argsJSON, encoding: .utf8), #"{"timezone":"UTC"}"#)
|
||
XCTAssertNotNil(UUID(uuidString: req.id), "id should be a UUID")
|
||
}
|
||
|
||
// MARK: - N3: done_reason mapping
|
||
|
||
func testDoneReasonMapping_stop() {
|
||
let events = run([
|
||
#"{"message":{"content":"hi"},"done":true,"done_reason":"stop"}"#,
|
||
])
|
||
XCTAssertTrue(events.contains { if case .stopReason(.endTurn) = $0 { return true } else { return false } })
|
||
}
|
||
|
||
func testDoneReasonMapping_length() {
|
||
let events = run([
|
||
#"{"message":{"content":"hi"},"done":true,"done_reason":"length"}"#,
|
||
])
|
||
XCTAssertTrue(events.contains { if case .stopReason(.maxTokens) = $0 { return true } else { return false } })
|
||
}
|
||
|
||
func testDoneReasonMapping_toolCalls() {
|
||
let events = run([
|
||
#"{"message":{"content":"","tool_calls":[{"function":{"name":"a","arguments":{}}}]},"done":true,"done_reason":"tool_calls"}"#,
|
||
])
|
||
XCTAssertTrue(events.contains { if case .stopReason(.toolUse) = $0 { return true } else { return false } })
|
||
}
|
||
|
||
func testDoneReasonMapping_missingButToolCallsSeen() {
|
||
// done:true with NO done_reason field BUT we previously saw tool_calls
|
||
// → maps to .toolUse.
|
||
let events = run([
|
||
#"{"message":{"content":"","tool_calls":[{"function":{"name":"a","arguments":{}}}]},"done":false}"#,
|
||
#"{"message":{"content":""},"done":true}"#,
|
||
])
|
||
XCTAssertTrue(events.contains { if case .stopReason(.toolUse) = $0 { return true } else { return false } })
|
||
}
|
||
|
||
// MARK: - N4: mid-stream EOF
|
||
|
||
func testMidStreamEOF() {
|
||
let events = run([
|
||
#"{"message":{"content":"partial"},"done":false}"#,
|
||
#"{"message":{"content":" response"},"done":false}"#,
|
||
], eof: true)
|
||
|
||
// Must end with stopReason(.streamTruncated), usage, messageStop.
|
||
XCTAssertTrue(events.contains { if case .stopReason(.streamTruncated) = $0 { return true } else { return false } })
|
||
XCTAssertTrue(events.contains { if case .messageStop = $0 { return true } else { return false } })
|
||
// No partialToolUseAtDisconnect — NDJSON is atomic per line.
|
||
XCTAssertFalse(events.contains { if case .partialToolUseAtDisconnect = $0 { return true } else { return false } })
|
||
}
|
||
|
||
// MARK: - N5: parallel tool calls
|
||
|
||
func testParallelToolCalls() {
|
||
let events = run([
|
||
#"{"message":{"content":"","tool_calls":[{"function":{"name":"a","arguments":{}}},{"function":{"name":"b","arguments":{}}}]},"done":false}"#,
|
||
#"{"message":{"content":""},"done":true,"done_reason":"tool_calls"}"#,
|
||
])
|
||
let names: [String] = events.compactMap {
|
||
if case .toolUseRequested(let r) = $0 { return r.name } else { return nil }
|
||
}
|
||
XCTAssertEqual(names, ["a", "b"])
|
||
}
|
||
|
||
// MARK: - N6: each tool_use id is a distinct UUID
|
||
|
||
func testToolUseIDsAreFreshUUIDs() {
|
||
let events = run([
|
||
#"{"message":{"content":"","tool_calls":[{"function":{"name":"a","arguments":{}}},{"function":{"name":"b","arguments":{}}}]},"done":false}"#,
|
||
#"{"message":{"content":""},"done":true,"done_reason":"tool_calls"}"#,
|
||
])
|
||
let ids: [String] = events.compactMap {
|
||
if case .toolUseRequested(let r) = $0 { return r.id } else { return nil }
|
||
}
|
||
XCTAssertEqual(ids.count, 2)
|
||
for id in ids {
|
||
XCTAssertNotNil(UUID(uuidString: id), "id '\(id)' is not a valid UUID")
|
||
XCTAssertFalse(id.isEmpty)
|
||
}
|
||
XCTAssertNotEqual(ids[0], ids[1], "parallel tool_use ids must differ")
|
||
}
|
||
|
||
// MARK: - N7: malformed JSON
|
||
|
||
func testMalformedJSON() {
|
||
let events = run([
|
||
#"{"incomplete":"# ,
|
||
])
|
||
XCTAssertTrue(events.contains {
|
||
if case .providerError(let err) = $0,
|
||
case .decode = err { return true }
|
||
return false
|
||
})
|
||
XCTAssertTrue(events.contains { if case .messageStop = $0 { return true } else { return false } })
|
||
}
|
||
}
|