Skip to content

refactor: extract testable per-chunk parsers from each provider's streamChat loop#1071

Merged
datlechin merged 2 commits into
mainfrom
refactor/provider-streaming-parser-tests
May 7, 2026
Merged

refactor: extract testable per-chunk parsers from each provider's streamChat loop#1071
datlechin merged 2 commits into
mainfrom
refactor/provider-streaming-parser-tests

Conversation

@datlechin

Copy link
Copy Markdown
Member

Summary

Closes the gap left from #1070's review. Each AI provider's stream-parsing logic was buried inside the for-await loop of streamChat, with mutable state (toolUseIdsByIndex, toolCallIndexToId, token counters) co-mingled with continuation.yield. Hard to test without mocking URLSession.

This PR extracts a per-provider parseChunk(_:state:) static function plus a state struct. The for-await loop becomes a thin shell that decodes lines and forwards events. 27 new tests cover the parsers directly.

Refactor shape (same pattern per provider)

struct AnthropicStreamState { var inputTokens, outputTokens: Int; var toolUseIdsByIndex: [Int: String] }

extension AnthropicProvider {
    static func decodeStreamLine(_ line: String) -> [String: Any]?
    static func parseChunk(_ json: [String: Any], state: inout AnthropicStreamState) throws -> [ChatStreamEvent]
}

The for-await loop in streamChat:

var state = AnthropicStreamState()
for try await line in bytes.lines {
    guard let json = Self.decodeStreamLine(line) else { continue }
    let events = try Self.parseChunk(json, state: &state)
    for event in events { continuation.yield(event) }
}
if let usage = state.finalUsageEvent() { continuation.yield(usage) }

Same shape for OpenAI/Compatible (with a (events, shouldBreak) return for Ollama's done: true) and Gemini (with an injected idGenerator: () -> String so tests can pin the synthetic UUID Gemini doesn't provide).

What's covered now

AnthropicProviderParserTests (12 tests):

  • text_delta yields .textDelta
  • content_block_start with tool_use updates state and yields .toolUseStart
  • input_json_delta resolves index → id from state
  • input_json_delta with unknown index yields nothing
  • content_block_stop yields .toolUseEnd and clears the mapping
  • fragmented input_json_delta concatenates correctly across chunks (the actual streaming protocol invariant)
  • message_start tracks input tokens
  • message_delta tracks output tokens
  • finalUsageEvent emits when tokens observed, nil when none
  • error event throws streamingFailed
  • decodeStreamLine handles framing (data/[DONE]/non-data)

OpenAICompatibleProviderParserTests (8 tests):

  • delta.content yields .textDelta
  • First tool_calls chunk emits .toolUseStart, subsequent chunks emit .toolUseDelta only
  • finish_reason "tool_calls" flushes .toolUseEnd for all tracked calls
  • Ollama arguments-as-object encodes to JSON string (the edge case discovered manually during phase 4)
  • Ollama arguments-as-string passes through verbatim
  • Ollama done: true sets shouldBreak and flushes pending tool ends
  • usage object populates state token counters
  • decodeStreamLine respects providerType (SSE vs NDJSON)

GeminiProviderParserTests (7 tests):

  • Text part yields .textDelta
  • functionCall part yields the start/delta/end trio in one chunk (Gemini's whole-args-non-streaming behavior)
  • Mixed text + functionCall parts yield events in part order
  • Empty parts yields no events
  • usageMetadata populates state
  • encodeArgsToJSONString fallback to {} on invalid input, round-trips valid objects

What was demoted

Three private helpers became internal-static (or were extracted from instance methods to static):

  • AnthropicProvider.decodeStreamLine, parseChunk
  • OpenAICompatibleProvider.decodeStreamLine, parseChunk, handleToolCallDeltas, handleOllamaToolCalls
  • GeminiProvider.decodeStreamLine, parseChunk, encodeArgsToJSONString

The instance method GeminiProvider.encodeArgsToJSONString was removed — the static replaces it.

Risk

Low. The for-await loop is a thin wrapper around the same logic that was inline before. Behavior should be identical. Tests lock the wire-shape contracts that were previously fragile.

CHANGELOG

No entry. This is a pure refactor with no user-visible behavior change.

Lint

swiftlint lint --strict clean across all 819 files.

Test plan

  • xcodebuild test -only-testing:TableProTests/AnthropicProviderParserTests
  • xcodebuild test -only-testing:TableProTests/OpenAICompatibleProviderParserTests
  • xcodebuild test -only-testing:TableProTests/GeminiProviderParserTests
  • Smoke test the chat panel with each provider (Anthropic, OpenAI, Gemini, Ollama). Streaming text + tool calls should still work end-to-end.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

… doc idGenerator, default-case + message.content tests)
@datlechin datlechin merged commit f815008 into main May 7, 2026
2 checks passed
@datlechin datlechin deleted the refactor/provider-streaming-parser-tests branch May 7, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant