|
4 | 4 | // |
5 | 5 |
|
6 | 6 | import Foundation |
7 | | -import TableProPluginKit |
8 | 7 | @testable import TablePro |
| 8 | +import TableProPluginKit |
9 | 9 | import Testing |
10 | 10 |
|
11 | 11 | @Suite("OpenAICompatibleProvider stream parser") |
@@ -175,6 +175,112 @@ struct OpenAICompatibleProviderParserTests { |
175 | 175 | #expect(text == "hi") |
176 | 176 | } |
177 | 177 |
|
| 178 | + @Test("delta.reasoning_content on first chunk emits reasoningStart then reasoningDelta") |
| 179 | + func reasoningContentFirstChunk() { |
| 180 | + var state = OpenAIStreamState() |
| 181 | + let result = OpenAICompatibleProvider.parseChunk([ |
| 182 | + "choices": [[ |
| 183 | + "delta": ["reasoning_content": "Let me think..."] |
| 184 | + ]] |
| 185 | + ], state: &state) |
| 186 | + #expect(result.events.count == 2) |
| 187 | + guard case .reasoningStart(let id) = result.events[0] else { |
| 188 | + Issue.record("expected reasoningStart; got \(result.events[0])") |
| 189 | + return |
| 190 | + } |
| 191 | + guard case .reasoningDelta(let deltaID, let text) = result.events[1] else { |
| 192 | + Issue.record("expected reasoningDelta; got \(result.events[1])") |
| 193 | + return |
| 194 | + } |
| 195 | + #expect(deltaID == id) |
| 196 | + #expect(text == "Let me think...") |
| 197 | + #expect(state.reasoningBlockID == id) |
| 198 | + } |
| 199 | + |
| 200 | + @Test("Subsequent delta.reasoning_content chunks emit only reasoningDelta (no duplicate start)") |
| 201 | + func reasoningContentSubsequentChunk() { |
| 202 | + var state = OpenAIStreamState() |
| 203 | + state.reasoningBlockID = "reasoning_abc" |
| 204 | + let result = OpenAICompatibleProvider.parseChunk([ |
| 205 | + "choices": [[ |
| 206 | + "delta": ["reasoning_content": " more thinking"] |
| 207 | + ]] |
| 208 | + ], state: &state) |
| 209 | + #expect(result.events.count == 1) |
| 210 | + guard case .reasoningDelta(let id, let text) = result.events[0] else { |
| 211 | + Issue.record("expected reasoningDelta; got \(result.events)") |
| 212 | + return |
| 213 | + } |
| 214 | + #expect(id == "reasoning_abc") |
| 215 | + #expect(text == " more thinking") |
| 216 | + } |
| 217 | + |
| 218 | + @Test("finish_reason: stop flushes open reasoning block as reasoningEnd with nil opaque") |
| 219 | + func finishReasonStopClosesReasoningBlock() { |
| 220 | + var state = OpenAIStreamState() |
| 221 | + state.reasoningBlockID = "reasoning_xyz" |
| 222 | + let result = OpenAICompatibleProvider.parseChunk([ |
| 223 | + "choices": [["finish_reason": "stop"]] |
| 224 | + ], state: &state) |
| 225 | + #expect(result.events.count == 1) |
| 226 | + guard case .reasoningEnd(let id, let opaque) = result.events[0] else { |
| 227 | + Issue.record("expected reasoningEnd; got \(result.events)") |
| 228 | + return |
| 229 | + } |
| 230 | + #expect(id == "reasoning_xyz") |
| 231 | + #expect(opaque == nil) |
| 232 | + #expect(state.reasoningBlockID == nil) |
| 233 | + } |
| 234 | + |
| 235 | + @Test("reasoning_content followed by finish_reason in same chunk emits start, delta, end") |
| 236 | + func reasoningContentWithFinishReason() { |
| 237 | + var state = OpenAIStreamState() |
| 238 | + let result = OpenAICompatibleProvider.parseChunk([ |
| 239 | + "choices": [[ |
| 240 | + "delta": ["reasoning_content": "final thought"], |
| 241 | + "finish_reason": "stop" |
| 242 | + ]] |
| 243 | + ], state: &state) |
| 244 | + let kinds = result.events.map { event -> String in |
| 245 | + switch event { |
| 246 | + case .reasoningStart: return "start" |
| 247 | + case .reasoningDelta: return "delta" |
| 248 | + case .reasoningEnd: return "end" |
| 249 | + default: return "other" |
| 250 | + } |
| 251 | + } |
| 252 | + #expect(kinds == ["start", "delta", "end"]) |
| 253 | + #expect(state.reasoningBlockID == nil) |
| 254 | + } |
| 255 | + |
| 256 | + @Test("delta.reasoning_content: null is ignored and does not emit reasoningStart") |
| 257 | + func reasoningContentNullIgnored() { |
| 258 | + var state = OpenAIStreamState() |
| 259 | + let result = OpenAICompatibleProvider.parseChunk([ |
| 260 | + "choices": [[ |
| 261 | + "delta": ["content": "hello", "reasoning_content": NSNull()] |
| 262 | + ]] |
| 263 | + ], state: &state) |
| 264 | + #expect(state.reasoningBlockID == nil) |
| 265 | + #expect(result.events.count == 1) |
| 266 | + guard case .textDelta = result.events[0] else { |
| 267 | + Issue.record("expected only textDelta; got \(result.events)") |
| 268 | + return |
| 269 | + } |
| 270 | + } |
| 271 | + |
| 272 | + @Test("finish_reason: stop does not flush pending tool calls") |
| 273 | + func finishReasonStopLeavesToolCallsIntact() { |
| 274 | + var state = OpenAIStreamState() |
| 275 | + state.toolCallIndexToId = [0: "call_a"] |
| 276 | + state.toolCallOrder = [0] |
| 277 | + let result = OpenAICompatibleProvider.parseChunk([ |
| 278 | + "choices": [["finish_reason": "stop"]] |
| 279 | + ], state: &state) |
| 280 | + #expect(result.events.isEmpty) |
| 281 | + #expect(state.toolCallIndexToId[0] == "call_a") |
| 282 | + } |
| 283 | + |
178 | 284 | @Test("Empty chunk yields no events and doesn't break") |
179 | 285 | func emptyChunk() { |
180 | 286 | var state = OpenAIStreamState() |
|
0 commit comments