fix(assistant): records renders keep empty lists; streamed progress keeps the turn context #59

Merged
KofTwentyTwo merged 1 commits from fix/records-render-and-stream-context into develop 2026-10-16 00:55:50 +00:00
9 changed files with 329 additions and 7 deletions

No files matched your search

+5
View File
@@ -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.
@@ -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)));
@@ -53,6 +53,10 @@ public class MessageParts
List<Map<String, Object>> list = JsonUtils.toObject(json, new TypeReference<List<Map<String, Object>>>() {});
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));
}
@@ -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<String, List<String>> 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<String, Object> complete(Map<String, Object> part)
{
if(part != null && part.get("render") instanceof Map<?, ?> render)
{
completeRender((Map<String, Object>) render);
}
return (part);
}
/*******************************************************************************
** Fill in a render's missing lists (in place).
*******************************************************************************/
@SuppressWarnings("unchecked")
public static void completeRender(Map<String, Object> 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<Object>) render.get("entries"))
{
if(entry instanceof Map<?, ?> map && !(map.get("details") instanceof List))
{
((Map<String, Object>) map).put("details", new ArrayList<>());
}
}
}
}
}
@@ -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);
});
}
};
@@ -82,6 +82,10 @@ public class TurnEvent
if(json != null)
{
data = JsonUtils.toObject(json, new TypeReference<Map<String, Object>>() {});
//////////////////////////////////////////////////////////////////
// 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));
}
@@ -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:
@@ -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<String> 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.
*******************************************************************************/
@@ -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<String, Object> emptyRecordsPart()
{
Map<String, Object> 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<String, Object> 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<Map<String, Object>> parts = MessageParts.fromJson(stored).list();
@SuppressWarnings("unchecked")
Map<String, Object> records = (Map<String, Object>) parts.get(0).get("render");
assertThat(records.get("rows")).isEqualTo(List.of());
assertThat(records.get("columns")).isEqualTo(List.of());
@SuppressWarnings("unchecked")
Map<String, Object> history = (Map<String, Object>) parts.get(1).get("render");
@SuppressWarnings("unchecked")
Map<String, Object> entry = ((List<Map<String, Object>>) history.get("entries")).get(0);
assertThat(entry.get("details")).isEqualTo(List.of());
Map<String, Object> event = TurnEvent.fromJson(3, "tool.result", "{\"render\":{\"type\":\"records\",\"tableLabel\":\"Findings\"}}").getData();
@SuppressWarnings("unchecked")
Map<String, Object> eventRender = (Map<String, Object>) event.get("render");
assertThat(eventRender.get("rows")).isEqualTo(List.of());
}
}