diff --git a/Sources/Tachikoma/Core/Generation.swift b/Sources/Tachikoma/Core/Generation.swift index 837bdd3..60fc583 100644 --- a/Sources/Tachikoma/Core/Generation.swift +++ b/Sources/Tachikoma/Core/Generation.swift @@ -433,7 +433,7 @@ public func streamText( } } - if buffersUntilDone, !didReceiveTerminal, !bufferedDeltas.isEmpty { + if buffersUntilDone, !didReceiveTerminal { throw TachikomaError.apiError("Stream ended before provider completion status was received") } @@ -1049,7 +1049,10 @@ extension LanguageModel { fallbackContent.append(.text(text)) } fallbackContent.append(contentsOf: missingToolCalls.map { .toolCall($0) }) - history.append(ModelMessage(role: .assistant, content: fallbackContent)) + let fallbackMetadata = needsFallbackBoundary + ? MessageMetadata(customData: ["tachikoma.internal.boundary": "reasoning_only"]) + : nil + history.append(ModelMessage(role: .assistant, content: fallbackContent, metadata: fallbackMetadata)) return history } diff --git a/Tests/TachikomaTests/Core/GenerationTests.swift b/Tests/TachikomaTests/Core/GenerationTests.swift index ec8763c..77f465a 100644 --- a/Tests/TachikomaTests/Core/GenerationTests.swift +++ b/Tests/TachikomaTests/Core/GenerationTests.swift @@ -1258,6 +1258,29 @@ struct GenerationTests { .canonical("https://example.test")) } + @Test + func `GenerateText tags fallback reasoning-only boundary for Anthropic-compatible Fable`() async throws { + let providerResponse = ProviderResponse( + text: "", + finishReason: .stop, + reasoning: [ProviderReasoningBlock(text: "private", signature: "sig")], + ) + let config = TachikomaConfiguration(loadFromEnvironment: false) + config.setProviderFactoryOverride { _, _ in StaticProvider(response: providerResponse) } + + let result = try await generateText( + model: .anthropicCompatible(modelId: "claude-fable-5", baseURL: "https://example.test"), + messages: [.user("hi")], + configuration: config, + ) + + #expect(result.messages.count == 3) + #expect(result.messages[1].channel == .thinking) + #expect(result.messages[2].role == .assistant) + #expect(result.messages[2].content == [.text("")]) + #expect(result.messages[2].metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only") + } + @Test func `GenerateText tags fallback reasoning for direct custom Fable`() async throws { let provider = StaticProvider( @@ -1483,6 +1506,36 @@ struct GenerationTests { #expect(operation.usage.outputTokens > 0) } + @Test + func `StreamText buffered mode fails when provider ends without terminal status`() async throws { + let config = TachikomaConfiguration(loadFromEnvironment: false) + config.setProviderFactoryOverride { _, _ in + StaticProvider( + response: ProviderResponse(text: "", finishReason: .stop), + capabilities: ModelCapabilities(supportsStreaming: true), + streamDeltas: [], + ) + } + + let result = try await streamText( + model: .openaiCompatible(modelId: "compatible-model", baseURL: "https://example.test"), + messages: [.user("hi")], + settings: GenerationSettings(streamBuffering: .untilTerminal), + configuration: config, + ) + + do { + for try await _ in result.stream {} + Issue.record("Expected missing terminal status error") + } catch let error as TachikomaError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got \(error)") + return + } + #expect(message.contains("provider completion status")) + } + } + @Test func `StreamText stop conditions ignore reasoning deltas`() async throws { let config = TachikomaConfiguration(loadFromEnvironment: false)