Files
james.maes 2323a6b929 feat(04-02): NDJSONDecoder with tool_calls-on-sight invariant + OllamaRequestBody drop-tools-array on .none (AGENT-04, AGENT-07)
- 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.
2026-04-24 17:38:35 -05:00

185 lines
8.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } })
}
}