From 8e473bcb56594702b80034fa56f9123a0f6ab888 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 15 Oct 2026 19:37:19 -0500 Subject: [PATCH] fix(assistant): records renders keep empty lists; streamed progress keeps the turn context A query that matched nothing produced a records render without rows or columns: QQQ's JSON omits empty lists, and Next crashed reading rows.length. Parts and stream events are now written keeping empty lists (RenderShapes), stored ones are completed when read, and extension 1.4.0 documents AssistantRecordsRenderV1. Streamed deltas arrive on the provider's virtual thread without a QContext, so the writing-phase progress (JobLogWriter) failed with an NPE; the delta handler now runs in the turn's captured context. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + .../assistant/api/specs/AssistantSpecs.java | 7 +- .../qbits/assistant/engine/MessageParts.java | 6 +- .../qbits/assistant/engine/RenderShapes.java | 106 ++++++++++++++++++ .../qbits/assistant/engine/TurnEngine.java | 20 +++- .../qbits/assistant/engine/TurnEvent.java | 6 +- .../resources/openapi/qqq-v1/assistant.yaml | 55 ++++++++- .../assistant/engine/DurableTurnsTest.java | 36 ++++++ .../assistant/engine/RenderShapesTest.java | 95 ++++++++++++++++ 9 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java create mode 100644 qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f0946..370bb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased - Empty renders and stream context (UI API extension 1.4.0) + +- Tool result renders always carry their lists: a `records` render for a query that matched nothing now has `"rows": []` and `"columns": []` (QQQ's JSON omitted empty lists, and Next's Assistant crashed the page reading `rows.length`). Parts and events stored before the fix are completed when read. Extension 1.4.0 documents the shape as `AssistantRecordsRenderV1`. +- Streamed text arrives on the provider's own (virtual) thread; the turn's progress updates now run in the turn's QContext, so job progress no longer fails with "Could not flush job progress" (NPE on the missing instance). + ## Unreleased - UI API 1.6.0 - UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java index 5859a4d..1928164 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus; public final class AssistantSpecs { public static final String EXTENSION_NAME = "assistant"; - public static final String EXTENSION_VERSION = "1.3.0"; + public static final String EXTENSION_VERSION = "1.4.0"; public static final String PREFIX = "/assistant"; @@ -210,6 +210,11 @@ public final class AssistantSpecs rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).", "id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true), "createDate", string(null).withNullable(true), "parts", array(freeObject("A message part."), null))); + rs.put("AssistantRecordsRenderV1", object("A tool part's render of type records (query results). columns and rows are always present - empty when the query matched nothing (since 1.4.0; older stored parts are completed when read).", + "type", string("records"), "tableName", string(null), "tableLabel", string(null), + "columns", array(object("A column.", "name", string(null), "label", string(null)), "Always present; empty when nothing matched."), + "rows", array(object("A row.", "id", string("The primary key."), "href", string("The record's /app link."), "values", freeObject("Display values by column name.")), "Always present; empty when nothing matched."), + "totalCount", integer("All matching rows.").withNullable(true), "truncated", bool("More rows match than are shown."), "openHref", string("The Query screen link."))); rs.put("AssistantConversationDetailResponseV1", object("A conversation and a page of its messages, oldest first.", "conversation", ref("AssistantConversationV1"), "messages", array(ref("AssistantMessageV1"), null))); rs.put("AssistantStatusResponseV1", object("An acknowledgement.", "status", string(null))); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java index 8be9c18..ed9b4d5 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/MessageParts.java @@ -53,6 +53,10 @@ public class MessageParts List> list = JsonUtils.toObject(json, new TypeReference>>() {}); if(list != null) { + //////////////////////////////////////////////////////////////// + // parts stored before RenderShapes may lack empty render lists // + //////////////////////////////////////////////////////////////// + list.forEach(RenderShapes::complete); result.parts.addAll(list); } } @@ -240,7 +244,7 @@ public class MessageParts *******************************************************************************/ public synchronized String toJson() { - return (JsonUtils.toJson(parts)); + return (RenderShapes.toJson(parts)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java new file mode 100644 index 0000000..26fc467 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/RenderShapes.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.engine; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; + + +/******************************************************************************* + ** The shape clients rely on for tool result renders: their lists are always + ** present, empty when there is nothing (a query that matched no rows has + ** "rows": [] and "columns": []). QQQ's default JSON omits empty lists, so + ** parts and stream events are written with {@link #toJson(Object)}, and parts + ** stored before this fix are completed by {@link #complete(Map)} when read. + *******************************************************************************/ +public final class RenderShapes +{ + /** each render type's list fields */ + private static final Map> LISTS = Map.of( + "records", List.of("columns", "rows"), + "record", List.of("fields"), + "history", List.of("entries")); + + + + /******************************************************************************* + ** not instantiable + *******************************************************************************/ + private RenderShapes() + { + } + + + + /******************************************************************************* + ** JSON that keeps empty lists (nulls are still left out). + *******************************************************************************/ + public static String toJson(Object value) + { + return (JsonUtils.toJsonCustomized(value, builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL))); + } + + + + /******************************************************************************* + ** Fill in a part's render lists that are missing (in place); returns the part. + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static Map complete(Map part) + { + if(part != null && part.get("render") instanceof Map render) + { + completeRender((Map) render); + } + return (part); + } + + + + /******************************************************************************* + ** Fill in a render's missing lists (in place). + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static void completeRender(Map render) + { + if(render == null || !(render.get("type") instanceof String type)) + { + return; + } + for(String list : LISTS.getOrDefault(type, List.of())) + { + if(!(render.get(list) instanceof List)) + { + render.put(list, new ArrayList<>()); + } + } + if("history".equals(type)) + { + for(Object entry : (List) render.get("entries")) + { + if(entry instanceof Map map && !(map.get("details") instanceof List)) + { + ((Map) map).put("details", new ArrayList<>()); + } + } + } + } +} 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 88abbb2..b38b0d0 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 @@ -67,6 +67,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall; import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor; +import com.kingsrook.qqq.backend.core.context.CapturedContext; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -770,13 +771,28 @@ public class TurnEngine implements Runnable { throw (new BudgetService.Blocked("The assistant has reached this provider's monthly cost cap. Ask an administrator.")); } + /////////////////////////////////////////////////////////////////////// + // providers stream on their own (virtual) threads, which have no // + // QContext: run the delta handler in the turn's context, or progress // + // writes (JobLogWriter) fail with no instance // + /////////////////////////////////////////////////////////////////////// + CapturedContext turnContext = QContext.capture(); ModelEventListener listener = new ModelEventListener() { @Override public void onTextDelta(String text) { - phase("writing", null); - bufferText(text); + if(QContext.getQInstance() != null) + { + phase("writing", null); + bufferText(text); + return; + } + QContext.withTemporaryContext(turnContext, () -> + { + phase("writing", null); + bufferText(text); + }); } }; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java index c655ec6..d2b9022 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEvent.java @@ -82,6 +82,10 @@ public class TurnEvent if(json != null) { data = JsonUtils.toObject(json, new TypeReference>() {}); + ////////////////////////////////////////////////////////////////// + // events stored before RenderShapes may lack empty render lists // + ////////////////////////////////////////////////////////////////// + RenderShapes.complete(data); } } catch(Exception e) @@ -110,7 +114,7 @@ public class TurnEvent *******************************************************************************/ public String toJson() { - return (JsonUtils.toJson(data)); + return (RenderShapes.toJson(data)); } diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml index 886bedc..ca9de0e 100644 --- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -161,7 +161,7 @@ components: type: "string" type: "object" AssistantErrorV1: - description: "Deprecated since 1.3.0: errors use BasicErrorResponseV1." + description: "Deprecated since 1.4.0: errors use BasicErrorResponseV1." properties: error: description: "A message for the user." @@ -216,6 +216,57 @@ components: type: "string" type: "array" type: "object" + AssistantRecordsRenderV1: + description: "A tool part's render of type records (query results). columns\ + \ and rows are always present - empty when the query matched nothing (since\ + \ 1.4.0; older stored parts are completed when read)." + properties: + type: + description: "records" + type: "string" + tableName: + type: "string" + tableLabel: + type: "string" + columns: + description: "Always present; empty when nothing matched." + items: + description: "A column." + properties: + name: + type: "string" + label: + type: "string" + type: "object" + type: "array" + rows: + description: "Always present; empty when nothing matched." + items: + description: "A row." + properties: + id: + description: "The primary key." + type: "string" + href: + description: "The record's /app link." + type: "string" + values: + additionalProperties: true + description: "Display values by column name." + type: "object" + type: "object" + type: "array" + totalCount: + description: "All matching rows." + nullable: true + type: "integer" + truncated: + description: "More rows match than are shown." + type: "boolean" + openHref: + description: "The Query screen link." + type: "string" + type: "object" AssistantStatusResponseV1: description: "An acknowledgement." properties: @@ -323,7 +374,7 @@ info: description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ \ and data access explanations" title: "QQQ Middleware API extension: assistant" - version: "1.3.0" + version: "1.4.0" openapi: "3.0.3" paths: /qqq/v1/assistant/config: diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java index c6246f5..6b49a61 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/DurableTurnsTest.java @@ -208,6 +208,42 @@ class DurableTurnsTest extends BaseAssistantTest + /******************************************************************************* + ** Streamed text arrives on the provider's own thread; the "writing" progress + ** it triggers still runs in the turn's QContext (the job tracker writes + ** through QQQ, which needs the instance). + *******************************************************************************/ + @Test + void streamedProgressRunsInTheTurnContext() throws Exception + { + List contexts = new CopyOnWriteArrayList<>(); + config.setRunTracker(new AssistantRunTracker() + { + @Override + public String start(String userId, Integer turnId, Integer conversationId, String label) + { + return ("job-" + turnId); + } + + @Override + public void progress(String trackingId, String phase, String message) + { + contexts.add(phase + ":" + (QContext.getQInstance() != null)); + } + + @Override + public void finish(String trackingId, String status, String message) + { + } + }); + provider.thenText("A streamed answer on the provider's thread"); + AssistantTurn row = completed(api.startTurn(conversation(), Map.of("text", "hello"))); + assertThat(row.getStatus()).isEqualTo("COMPLETE"); + assertThat(contexts).contains("writing:true").doesNotContain("writing:false"); + } + + + /******************************************************************************* ** A throwing post-processing collaborator cannot strand a streamed answer. *******************************************************************************/ diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java new file mode 100644 index 0000000..61b902f --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RenderShapesTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.engine; + + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Records renders keep their (empty) lists in stored parts and stream events, + ** and parts stored without them are completed when read. + *******************************************************************************/ +class RenderShapesTest +{ + + /******************************************************************************* + ** A query that matched nothing. + *******************************************************************************/ + private static Map emptyRecordsPart() + { + Map render = new LinkedHashMap<>(); + render.put("type", "records"); + render.put("tableName", "db_perf_finding"); + render.put("tableLabel", "Findings"); + render.put("columns", new ArrayList<>()); + render.put("rows", new ArrayList<>()); + render.put("totalCount", 0); + render.put("truncated", false); + render.put("openHref", "/app/db_perf_finding"); + Map part = new LinkedHashMap<>(); + part.put("type", "tool"); + part.put("name", "query_records"); + part.put("render", render); + return (part); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void emptyListsSurviveSerialization() + { + String json = RenderShapes.toJson(List.of(emptyRecordsPart())); + assertThat(json).contains("\"columns\":[]").contains("\"rows\":[]"); + assertThat(new TurnEvent(1, "tool.result", emptyRecordsPart()).toJson()).contains("\"rows\":[]").contains("\"columns\":[]"); + } + + + + /******************************************************************************* + ** The exact shape stored on kof22 before the fix (lists omitted). + *******************************************************************************/ + @Test + void partsStoredWithoutListsAreCompletedWhenRead() + { + String stored = "[{\"type\":\"tool\",\"name\":\"query_records\",\"outcome\":\"OK\",\"render\":{\"type\":\"records\",\"tableName\":\"db_perf_finding\",\"tableLabel\":\"Findings\",\"openHref\":\"/app/db_perf_finding\",\"totalCount\":0,\"truncated\":false}}," + + "{\"type\":\"tool\",\"name\":\"history\",\"render\":{\"type\":\"history\",\"entries\":[{\"message\":\"Edited\"}]}}]"; + List> parts = MessageParts.fromJson(stored).list(); + @SuppressWarnings("unchecked") + Map records = (Map) parts.get(0).get("render"); + assertThat(records.get("rows")).isEqualTo(List.of()); + assertThat(records.get("columns")).isEqualTo(List.of()); + @SuppressWarnings("unchecked") + Map history = (Map) parts.get(1).get("render"); + @SuppressWarnings("unchecked") + Map entry = ((List>) history.get("entries")).get(0); + assertThat(entry.get("details")).isEqualTo(List.of()); + + Map event = TurnEvent.fromJson(3, "tool.result", "{\"render\":{\"type\":\"records\",\"tableLabel\":\"Findings\"}}").getData(); + @SuppressWarnings("unchecked") + Map eventRender = (Map) event.get("render"); + assertThat(eventRender.get("rows")).isEqualTo(List.of()); + } +} -- 2.54.0