From c5125f9788656b68a2d8b54ae081ba2894d409ea Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 4 Nov 2026 19:15:44 -0600 Subject: [PATCH] fix(assistant): store the transcript before message.end so the panel shows the answer The panel reloads the conversation the moment the stream ends. The redacted transcript (message parts, pending actions, stored events) was flushed only after message.end was sent, so that reload read the "[]" placeholders and showed the newest question and answer as empty bubbles until the panel was closed and reopened. Persist and flush first, then emit message.end. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++ .../qbits/assistant/engine/TurnEngine.java | 11 ++++++ .../assistant/engine/TurnEngineTest.java | 21 +++++++++++ .../qbits/assistant/testing/TurnRunner.java | 37 +++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688af47..45aa3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased - Answer visible at the end of the stream + +- The turn's redacted transcript (the question and answer parts, pending actions, stored events) is now written before `message.end` is sent. The panel reloads the conversation the moment the stream ends, and used to read the empty placeholders (`[]`) the transcript redactor stores until its flush, so the newest question and answer showed as empty bubbles until the panel was closed and reopened. + ## Unreleased - Optional provider health - An enabled provider that is not the default and not the only route for any configured routing (an enabled profile's simple/complex model, a budget downgrade model or a knowledge source's embedding model, with no fallback on another usable provider) is optional: when it is unreachable, its `assistant.provider.` and `assistant.model.` checks report DEGRADED with a message naming the provider requests use instead, so one unfinished local server no longer turns the whole `assistant` component DOWN. The default provider and sole routes still report DOWN. A failed provider probe still carries `reachable=false`, so the circuit breaker keeps refusing the provider. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index 38a264e..df50808 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -1321,6 +1321,17 @@ public class TurnEngine implements Runnable String finalErrorText = errorText; safely(() -> emit(TurnEvent.ERROR, map("code", finalErrorCode, "message", finalErrorText))); } + + /////////////////////////////////////////////////////////////////////// + // the stream ends at message.end and the panel reloads the // + // conversation at once, so the redacted transcript (message parts, // + // pending actions, events) must be stored before that event is sent // + /////////////////////////////////////////////////////////////////////// + if(!alreadyTerminal) + { + safely(this::persistPendingEvents); + safely(() -> transcript.flush(store)); + } safely(() -> emit(TurnEvent.MESSAGE_END, map("messageId", assistantMessage.getId(), "status", finalStatus, "stopReason", finalStopReason))); if(!alreadyTerminal) { diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/TurnEngineTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/TurnEngineTest.java index 8bab8ea..7660df2 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/TurnEngineTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/TurnEngineTest.java @@ -141,6 +141,27 @@ class TurnEngineTest extends BaseAssistantTest + /******************************************************************************* + ** The panel reloads the conversation as soon as message.end arrives, so the + ** question and the answer must already be stored then, not after it. + *******************************************************************************/ + @Test + void testMessagesAreStoredBeforeMessageEnd() throws Exception + { + provider.thenText("Four posts are published."); + + TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?"); + assertThat(outcome.end()).containsEntry("status", "COMPLETE"); + + List stored = runner.messagesAtEnd(); + assertThat(stored).hasSize(2); + assertThat(stored).extracting(AssistantMessage::getPartsJson).allSatisfy(parts -> assertThat(parts).isNotEqualTo("[]")); + assertThat(stored.get(0).getPartsJson()).contains("how many published posts?"); + assertThat(stored.get(1).getPartsJson()).contains("Four posts are published."); + } + + + /******************************************************************************* ** Parallel tool calls: all results in ONE user message, in order. *******************************************************************************/ diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java index 82a3df2..00f17ac 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/TurnRunner.java @@ -27,6 +27,7 @@ import com.kingsrook.qbits.assistant.AssistantRuntime; import com.kingsrook.qbits.assistant.api.AssistantApi; import com.kingsrook.qbits.assistant.engine.RunningTurn; import com.kingsrook.qbits.assistant.engine.TurnEvent; +import com.kingsrook.qbits.assistant.model.AssistantMessage; import com.kingsrook.qbits.assistant.model.AssistantToolCall; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qqq.backend.core.context.QContext; @@ -47,6 +48,8 @@ public class TurnRunner private final QInstance qInstance; private final AssistantQBitConfig config; + private List messagesAtEnd = List.of(); + /******************************************************************************* @@ -169,6 +172,10 @@ public class TurnRunner { List batch = running.getLog().after(last, 1000); events.addAll(batch); + if(batch.stream().anyMatch(e -> TurnEvent.MESSAGE_END.equals(e.getType()))) + { + messagesAtEnd = messagesOf(userId, convId); + } if(!batch.isEmpty()) { last = batch.get(batch.size() - 1).getSeq(); @@ -194,6 +201,36 @@ public class TurnRunner + /******************************************************************************* + ** The conversation's stored messages, read the moment the run's message.end + ** was seen: what the panel finds when it reloads the conversation at the end + ** of the stream. + *******************************************************************************/ + public List messagesAtEnd() + { + return (messagesAtEnd); + } + + + + /******************************************************************************* + ** The stored messages of a conversation (read as the user). + *******************************************************************************/ + private List messagesOf(String userId, Integer conversationId) throws Exception + { + QContext.init(qInstance, AssistantTestFixture.ownerSession(userId, config)); + try + { + return (new AssistantStore().listMessages(userId, conversationId, null, 50)); + } + finally + { + QContext.clear(); + } + } + + + /******************************************************************************* ** The audited tool calls of a turn (read as the user). *******************************************************************************/ -- 2.54.0