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). *******************************************************************************/