From 3539f5fd936efe14b3c745bac68ff7dbfda7856f 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 | 5 +++ .../qbits/assistant/engine/TurnEngine.java | 11 ++++++ .../assistant/engine/TurnEngineTest.java | 21 +++++++++++ .../qbits/assistant/testing/TurnRunner.java | 37 +++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f55e67..c83d7f3 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 - Clear reason without a model provider (UI API extension 1.7.0) - `GET /config` checks the server-wide causes before the user's permission: `DISABLED`, then `NOT_CONFIGURED` (no usable model provider), then `NO_PERMISSION`, then `KILL_SWITCH`. A server with no provider no longer tells users to ask for access (#82). @@ -34,6 +38,7 @@ - Provider SPI: `ModelPart.Type.IMAGE` (`mediaType`, base64 `data`), mapped by the Anthropic, Bedrock and OpenAI-compatible providers. Claude models report vision. `ModelPart.toString()` never includes text or image data. - `GET /config`: `features.attachments`, `limits.maxAttachmentBytes`, `limits.maxAttachments`, `limits.attachmentTypes`. - System prompt: when `create_record` / `update_record` are offered, the model proposes the change instead of saying it cannot create records. +||||||| parent of c5125f9 (fix(assistant): store the transcript before message.end so the panel shows the answer) ## Unreleased - Optional provider health 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 0c3e269..4e2f034 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 @@ -1343,6 +1343,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