Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 8e473bcb56 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 <noreply@anthropic.com>
2026-10-15 19:37:19 -05:00
james.maes 60435c27e3 Merge pull request #56 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:49 -05: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());
}
}