Skip to content

Commit 34d632a

Browse files
authored
Merge pull request #635 from Chris0Jeky/feature/616-chat-json-truncation
Fix chat response truncation and raw JSON display
2 parents a027e7e + 5d51b68 commit 34d632a

6 files changed

Lines changed: 283 additions & 6 deletions

File tree

backend/src/Taskdeck.Application/Services/GeminiLlmProvider.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,42 @@ public async Task<LlmCompletionResult> CompleteAsync(ChatCompletionRequest reque
8181
return BuildFallbackResult(lastUserMessage, "Live provider request failed.", GetConfiguredModelOrDefault());
8282
}
8383

84-
if (!TryParseResponse(body, out var content, out var tokensUsed))
84+
if (!TryParseResponse(body, out var content, out var tokensUsed, out var finishReason))
8585
{
8686
_logger.LogWarning("Gemini completion response could not be parsed.");
8787
return BuildFallbackResult(lastUserMessage, "Live provider response parsing failed.", GetConfiguredModelOrDefault());
8888
}
8989

90+
// Detect truncation: Gemini returns finishReason "MAX_TOKENS" when the
91+
// response was cut off by the maxOutputTokens limit.
92+
if (string.Equals(finishReason, "MAX_TOKENS", StringComparison.OrdinalIgnoreCase))
93+
{
94+
_logger.LogWarning("Gemini response was truncated (finishReason=MAX_TOKENS).");
95+
return new LlmCompletionResult(
96+
content,
97+
tokensUsed,
98+
IsActionable: false,
99+
Provider: "Gemini",
100+
Model: GetConfiguredModelOrDefault(),
101+
IsDegraded: true,
102+
DegradedReason: "Response was truncated");
103+
}
104+
105+
// When JSON mode was requested and the response starts with '{' but
106+
// does not parse as valid JSON, the output was likely truncated.
107+
if (useInstructionExtraction && LooksLikeTruncatedJson(content))
108+
{
109+
_logger.LogWarning("Gemini JSON-mode response is not valid JSON; treating as truncated.");
110+
return new LlmCompletionResult(
111+
content,
112+
tokensUsed,
113+
IsActionable: false,
114+
Provider: "Gemini",
115+
Model: GetConfiguredModelOrDefault(),
116+
IsDegraded: true,
117+
DegradedReason: "Response was truncated");
118+
}
119+
90120
// Try to parse structured instruction extraction from the LLM response
91121
if (LlmInstructionExtractionPrompt.TryParseStructuredResponse(
92122
content,
@@ -225,10 +255,11 @@ private static object MapMessage(ChatCompletionMessage message)
225255
};
226256
}
227257

228-
private static bool TryParseResponse(string responseBody, out string content, out int tokensUsed)
258+
private static bool TryParseResponse(string responseBody, out string content, out int tokensUsed, out string? finishReason)
229259
{
230260
content = string.Empty;
231261
tokensUsed = 0;
262+
finishReason = null;
232263

233264
if (string.IsNullOrWhiteSpace(responseBody))
234265
{
@@ -248,6 +279,13 @@ private static bool TryParseResponse(string responseBody, out string content, ou
248279
}
249280

250281
var firstCandidate = candidates[0];
282+
283+
if (firstCandidate.TryGetProperty("finishReason", out var finishReasonElement) &&
284+
finishReasonElement.ValueKind == JsonValueKind.String)
285+
{
286+
finishReason = finishReasonElement.GetString();
287+
}
288+
251289
if (!firstCandidate.TryGetProperty("content", out var candidateContent) ||
252290
!candidateContent.TryGetProperty("parts", out var parts) ||
253291
parts.ValueKind != JsonValueKind.Array ||
@@ -324,6 +362,30 @@ private static LlmCompletionResult BuildFallbackResult(string userMessage, strin
324362
Instructions: instructions);
325363
}
326364

365+
/// <summary>
366+
/// Returns true when <paramref name="text"/> starts with '{' but does not
367+
/// parse as valid JSON — a strong signal the response was cut off mid-output.
368+
/// </summary>
369+
internal static bool LooksLikeTruncatedJson(string text)
370+
{
371+
if (string.IsNullOrWhiteSpace(text))
372+
return false;
373+
374+
var trimmed = text.TrimStart();
375+
if (!trimmed.StartsWith('{'))
376+
return false;
377+
378+
try
379+
{
380+
using var doc = JsonDocument.Parse(trimmed);
381+
return false;
382+
}
383+
catch (JsonException)
384+
{
385+
return true;
386+
}
387+
}
388+
327389
private static int EstimateTokens(string text)
328390
{
329391
if (string.IsNullOrWhiteSpace(text))

backend/src/Taskdeck.Application/Services/ILlmProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public interface ILlmProvider
1010

1111
public record ChatCompletionRequest(
1212
List<ChatCompletionMessage> Messages,
13-
int MaxTokens = 1024,
13+
int MaxTokens = 2048,
1414
double Temperature = 0.7,
1515
LlmRequestAttribution? Attribution = null,
1616
string? SystemPrompt = null,

backend/src/Taskdeck.Application/Services/OpenAiLlmProvider.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,43 @@ public async Task<LlmCompletionResult> CompleteAsync(ChatCompletionRequest reque
5252
return BuildFallbackResult(lastUserMessage, "Live provider request failed.", GetConfiguredModelOrDefault());
5353
}
5454

55-
if (!TryParseResponse(body, out var content, out var tokensUsed))
55+
if (!TryParseResponse(body, out var content, out var tokensUsed, out var finishReason))
5656
{
5757
_logger.LogWarning("OpenAI completion response could not be parsed.");
5858
return BuildFallbackResult(lastUserMessage, "Live provider response parsing failed.", GetConfiguredModelOrDefault());
5959
}
6060

61+
// Detect truncation: OpenAI returns finish_reason "length" when the
62+
// response was cut off by the max_tokens limit.
63+
if (string.Equals(finishReason, "length", StringComparison.OrdinalIgnoreCase))
64+
{
65+
_logger.LogWarning("OpenAI response was truncated (finish_reason=length).");
66+
return new LlmCompletionResult(
67+
content,
68+
tokensUsed,
69+
IsActionable: false,
70+
Provider: "OpenAI",
71+
Model: GetConfiguredModelOrDefault(),
72+
IsDegraded: true,
73+
DegradedReason: "Response was truncated");
74+
}
75+
76+
// When JSON mode was requested and the response starts with '{' but
77+
// does not parse as valid JSON, the output was likely truncated.
78+
var useInstructionExtraction = request.SystemPrompt is null;
79+
if (useInstructionExtraction && LooksLikeTruncatedJson(content))
80+
{
81+
_logger.LogWarning("OpenAI JSON-mode response is not valid JSON; treating as truncated.");
82+
return new LlmCompletionResult(
83+
content,
84+
tokensUsed,
85+
IsActionable: false,
86+
Provider: "OpenAI",
87+
Model: GetConfiguredModelOrDefault(),
88+
IsDegraded: true,
89+
DegradedReason: "Response was truncated");
90+
}
91+
6192
// Try to parse structured instruction extraction from the LLM response
6293
if (LlmInstructionExtractionPrompt.TryParseStructuredResponse(
6394
content,
@@ -227,10 +258,11 @@ private object BuildRequestPayload(ChatCompletionRequest request)
227258
return payload;
228259
}
229260

230-
private static bool TryParseResponse(string responseBody, out string content, out int tokensUsed)
261+
private static bool TryParseResponse(string responseBody, out string content, out int tokensUsed, out string? finishReason)
231262
{
232263
content = string.Empty;
233264
tokensUsed = 0;
265+
finishReason = null;
234266

235267
if (string.IsNullOrWhiteSpace(responseBody))
236268
{
@@ -259,6 +291,12 @@ private static bool TryParseResponse(string responseBody, out string content, ou
259291
return false;
260292
}
261293

294+
if (first.TryGetProperty("finish_reason", out var finishReasonElement) &&
295+
finishReasonElement.ValueKind == JsonValueKind.String)
296+
{
297+
finishReason = finishReasonElement.GetString();
298+
}
299+
262300
if (root.TryGetProperty("usage", out var usage) &&
263301
usage.TryGetProperty("total_tokens", out var totalTokens) &&
264302
totalTokens.TryGetInt32(out var parsedTokens))
@@ -308,6 +346,30 @@ private static LlmCompletionResult BuildFallbackResult(string userMessage, strin
308346
Instructions: instructions);
309347
}
310348

349+
/// <summary>
350+
/// Returns true when <paramref name="text"/> starts with '{' but does not
351+
/// parse as valid JSON — a strong signal the response was cut off mid-output.
352+
/// </summary>
353+
internal static bool LooksLikeTruncatedJson(string text)
354+
{
355+
if (string.IsNullOrWhiteSpace(text))
356+
return false;
357+
358+
var trimmed = text.TrimStart();
359+
if (!trimmed.StartsWith('{'))
360+
return false;
361+
362+
try
363+
{
364+
using var doc = JsonDocument.Parse(trimmed);
365+
return false;
366+
}
367+
catch (JsonException)
368+
{
369+
return true;
370+
}
371+
}
372+
311373
private static int EstimateTokens(string text)
312374
{
313375
if (string.IsNullOrWhiteSpace(text))

backend/tests/Taskdeck.Application.Tests/Services/GeminiLlmProviderTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,55 @@ await provider.CompleteAsync(new ChatCompletionRequest(
432432
hasSystemInstruction.Should().BeFalse("empty system prompt should not produce system_instruction field");
433433
}
434434

435+
[Fact]
436+
public async Task CompleteAsync_ShouldReturnDegraded_WhenFinishReasonIsMaxTokens()
437+
{
438+
var settings = BuildSettings();
439+
var handler = new StubHttpMessageHandler(_ =>
440+
{
441+
return new HttpResponseMessage(HttpStatusCode.OK)
442+
{
443+
Content = new StringContent(
444+
"""
445+
{
446+
"candidates": [
447+
{
448+
"content": {
449+
"parts": [{ "text": "partial response" }]
450+
},
451+
"finishReason": "MAX_TOKENS"
452+
}
453+
],
454+
"usageMetadata": { "totalTokenCount": 50 }
455+
}
456+
""",
457+
Encoding.UTF8,
458+
"application/json")
459+
};
460+
});
461+
462+
var provider = new GeminiLlmProvider(new HttpClient(handler), settings, NullLogger<GeminiLlmProvider>.Instance);
463+
var result = await provider.CompleteAsync(new ChatCompletionRequest(
464+
[new ChatCompletionMessage("User", "tell me something")],
465+
SystemPrompt: string.Empty));
466+
467+
result.IsDegraded.Should().BeTrue();
468+
result.DegradedReason.Should().Be("Response was truncated");
469+
result.Content.Should().Be("partial response");
470+
result.IsActionable.Should().BeFalse();
471+
}
472+
473+
[Theory]
474+
[InlineData("{\"reply\":\"incomplete", true)]
475+
[InlineData("{}", false)]
476+
[InlineData("plain text response", false)]
477+
[InlineData("", false)]
478+
[InlineData(" { broken json", true)]
479+
public void LooksLikeTruncatedJson_ShouldDetectPartialJson(string input, bool expected)
480+
{
481+
GeminiLlmProvider.LooksLikeTruncatedJson(input).Should().Be(expected);
482+
}
483+
435484
private static LlmProviderSettings BuildSettings()
436485
{
437486
return new LlmProviderSettings

backend/tests/Taskdeck.Application.Tests/Services/OpenAiLlmProviderTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,85 @@ public async Task CompleteAsync_ShouldRedactSensitiveDetails_WhenUnexpectedExcep
269269
message.Should().Contain($"Authorization: Bearer {SensitiveDataRedactor.RedactedValue}");
270270
}
271271

272+
[Fact]
273+
public async Task CompleteAsync_ShouldReturnDegraded_WhenFinishReasonIsLength()
274+
{
275+
var settings = BuildSettings();
276+
var handler = new StubHttpMessageHandler(_ =>
277+
{
278+
return new HttpResponseMessage(HttpStatusCode.OK)
279+
{
280+
Content = new StringContent(
281+
"""
282+
{
283+
"choices": [
284+
{
285+
"message": { "content": "partial response" },
286+
"finish_reason": "length"
287+
}
288+
],
289+
"usage": { "total_tokens": 50 }
290+
}
291+
""",
292+
Encoding.UTF8,
293+
"application/json")
294+
};
295+
});
296+
297+
var provider = new OpenAiLlmProvider(new HttpClient(handler), settings, NullLogger<OpenAiLlmProvider>.Instance);
298+
var result = await provider.CompleteAsync(new ChatCompletionRequest(
299+
[new ChatCompletionMessage("User", "tell me something")],
300+
SystemPrompt: string.Empty));
301+
302+
result.IsDegraded.Should().BeTrue();
303+
result.DegradedReason.Should().Be("Response was truncated");
304+
result.Content.Should().Be("partial response");
305+
result.IsActionable.Should().BeFalse();
306+
}
307+
308+
[Fact]
309+
public async Task CompleteAsync_ShouldReturnDegraded_WhenJsonModeResponseIsInvalidJson()
310+
{
311+
var settings = BuildSettings();
312+
// Build a valid OpenAI response whose content value is truncated JSON.
313+
// The inner content must be JSON-escaped so the outer envelope parses.
314+
var truncatedContent = "{\\\"reply\\\":\\\"this is cut off";
315+
var responseBody = $@"{{
316+
""choices"": [{{
317+
""message"": {{ ""content"": ""{truncatedContent}"" }},
318+
""finish_reason"": ""stop""
319+
}}],
320+
""usage"": {{ ""total_tokens"": 50 }}
321+
}}";
322+
var handler = new StubHttpMessageHandler(_ =>
323+
{
324+
return new HttpResponseMessage(HttpStatusCode.OK)
325+
{
326+
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
327+
};
328+
});
329+
330+
var provider = new OpenAiLlmProvider(new HttpClient(handler), settings, NullLogger<OpenAiLlmProvider>.Instance);
331+
332+
// SystemPrompt defaults to null -> JSON mode is requested
333+
var result = await provider.CompleteAsync(new ChatCompletionRequest(
334+
[new ChatCompletionMessage("User", "tell me something")]));
335+
336+
result.IsDegraded.Should().BeTrue();
337+
result.DegradedReason.Should().Be("Response was truncated");
338+
}
339+
340+
[Theory]
341+
[InlineData("{\"reply\":\"incomplete", true)]
342+
[InlineData("{}", false)]
343+
[InlineData("plain text response", false)]
344+
[InlineData("", false)]
345+
[InlineData(" { broken json", true)]
346+
public void LooksLikeTruncatedJson_ShouldDetectPartialJson(string input, bool expected)
347+
{
348+
OpenAiLlmProvider.LooksLikeTruncatedJson(input).Should().Be(expected);
349+
}
350+
272351
private static LlmProviderSettings BuildSettings()
273352
{
274353
return new LlmProviderSettings

0 commit comments

Comments
 (0)