From e0f50ecb3ad562b073c0b573f2be3c22e83640ba Mon Sep 17 00:00:00 2001 From: xlx1212 Date: Fri, 7 Aug 2026 09:29:50 +0800 Subject: [PATCH] fix: mark truncated assistant replies as incomplete instead of complete (#1980) When the model output stream ends prematurely (provider-side interruption, timeout, connection drop, or max_tokens cut), the execution engine now: - Sets has_final_response=false for partial_truncated (was true) - Includes partial_truncated in the success matches (turn produced partial output) - Sets finalization_reason=Some(partial_truncated) in the cancellation path (was None, which defaulted to complete) The DialogTurnCompleted event now carries finish_reason=partial_truncated and has_final_response=false for truncated turns, allowing the UI to distinguish incomplete turns from complete ones. --- pr-body-1980.md | 42 +++++++++++++++++++ .../src/agentic/execution/execution_engine.rs | 10 ++++- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 pr-body-1980.md diff --git a/pr-body-1980.md b/pr-body-1980.md new file mode 100644 index 000000000..1d21edb8f --- /dev/null +++ b/pr-body-1980.md @@ -0,0 +1,42 @@ +## Fix: Mark truncated assistant replies as incomplete instead of complete + +Closes #1980 + +### Problem + +When the model output stream ends prematurely (provider-side interruption, timeout, connection drop, or `max_tokens` cut), the execution engine marks the dialog turn as `complete` / `hasFinalResponse=true` instead of `interrupted`/`incomplete`. This causes the frontend to persist and display an incomplete assistant reply as if it were a complete, final response. + +### Root Cause + +In [`execution_engine.rs`](src/crates/assembly/core/src/agentic/execution/execution_engine.rs), three code paths contribute to the bug: + +1. **`partial_truncated` marks as final (line ~4464):** When continuation attempts are exhausted after a partial recovery, `finalization_reason` is set to `"partial_truncated"`, but the code then sets `has_final_response = true` — treating the truncated text as a complete final response. + +2. **Success calculation excludes `partial_truncated` (line ~4480):** The `success` variable is `has_final_response || matches!(effective_finish_reason, "max_rounds" | "repeated_tool_failures")`. Since `"partial_truncated"` is not in the matches, and after fix #1 `has_final_response` is `false`, the turn would be marked as failed (`success=false`), which is too harsh — the turn did produce partial output. + +3. **Cancellation path leaves `finalization_reason = None` (line ~4194):** When `should_continue_after_partial_response(reason)` returns `false` (reason contains "cancelled"), the code breaks immediately without setting `finalization_reason`, so it defaults to `None` → `effective_finish_reason = "complete"` → `has_final_response = true`. A cancelled stream with partial text is reported as complete. + +### Fix + +Three surgical changes: + +1. **Line ~4465:** `has_final_response = true` → `has_final_response = false` for the `partial_truncated` branch. A truncated response is not a complete final response. + +2. **Line ~4483:** Add `"partial_truncated"` to the `matches!` list for `success`. The turn produced partial output, so `success=true` with `has_final_response=false` tells the UI: "the turn produced output, but it is incomplete." + +3. **Line ~4199:** Set `finalization_reason = Some("partial_truncated")` before `break` in the cancellation path. This ensures a cancelled stream with partial text is reported as `partial_truncated` (incomplete) rather than `complete`. + +### Event Flow After Fix + +The `DialogTurnCompleted` event now carries for truncated turns: +- `finish_reason: "partial_truncated"` (was `"complete"`) +- `has_final_response: false` (was `true`) +- `success: true` (was `true` for path 1, `false` would have been too harsh) +- `partial_recovery_reason: ` (unchanged — e.g., "idle_timeout", "cancelled") + +The frontend projection (`frontend_projection.rs`) forwards these fields as `finishReason`, `hasFinalResponse`, `success`, and `partialRecoveryReason`, allowing the UI to distinguish incomplete turns from complete ones. + +### Validation + +- `cargo build -p bitfun-core --lib` — compiles clean (1 pre-existing warning, unrelated) +- `cargo test -p bitfun-core --lib execution::execution_engine` — all 38 existing tests pass diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 0ce48d0e5..5e8b5abcb 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -4196,6 +4196,10 @@ impl ExecutionEngine { "Model round {} ended with partial answer after cancellation, reason: {:?}", round_index, round_result.finish_reason ); + // Mark as truncated so the turn is not reported as + // complete — a cancelled stream with partial text is + // still incomplete. + finalization_reason = Some("partial_truncated"); break; } } else { @@ -4462,7 +4466,9 @@ impl ExecutionEngine { } } } else if reason == "partial_truncated" { - has_final_response = true; + // A truncated response is not a complete final response — the + // model output stream ended prematurely. + has_final_response = false; } } @@ -4480,7 +4486,7 @@ impl ExecutionEngine { let success = has_final_response || matches!( effective_finish_reason, - "max_rounds" | "repeated_tool_failures" + "max_rounds" | "repeated_tool_failures" | "partial_truncated" ); // Post-processing hook: when a DeepResearch dialog turn finishes