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
james.maes 4b7eb81d6d Merge remote-tracking branch 'origin/develop' into feature/ui-api-1.6 2026-10-15 16:01:07 -05:00
james.maes b8aff19826 Merge pull request #57 from QRun-IO/fix/shutdown-hook-weak-runtime
fix(runtime): shutdown hook holds the runtime weakly
2026-10-15 15:57:49 -05:00
james.maesandClaude Opus 5.5 0a23db241b chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:03 -05:00
james.maesandClaude Opus 5.5 c838eb4f67 feat(ui-api): assistant extension 1.3.0 for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Every operation has an operationId and the standard error responses
(BasicErrorResponseV1, keeping the assistant-specific codes); AssistantErrorV1
is kept but deprecated; bodyless 204 responses no longer reference null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:54:05 -05:00
12 changed files with 805 additions and 125 deletions

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# 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.
- Retain AssistantErrorV1 as a deprecated component for compatibility. Remove dangling `null` schema references from the two bodyless 204 responses (delete conversation and feedback), with an empty-content workaround for core schema traversal. Success payloads are unchanged; the contract test retains the release baseline with explicit exceptions for the error schema migration and these documentation corrections.
## Unreleased - Central health
### Added
+1 -1
View File
@@ -217,7 +217,7 @@
<properties>
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles; -->
<!-- RC.8 provides core job tracking (JobTracking, the Jobs tray). -->
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -37,9 +37,11 @@ import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Response;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
import io.javalin.http.ContentType;
@@ -54,14 +56,14 @@ import io.javalin.http.HttpStatus;
** routes, with the same URLs and bodies, so they appear in the served
** /qqq/v1/openapi.yaml and a generated client covers them.
**
** Errors keep the assistant's shape: HTTP status plus {"error", "code"}.
** Errors use BasicErrorResponseV1 and retain assistant-specific codes.
** Turns and events are text/event-stream; each event type is documented as
** AssistantTurnEventV1.
*******************************************************************************/
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.2.0";
public static final String EXTENSION_VERSION = "1.4.0";
public static final String PREFIX = "/assistant";
@@ -208,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)));
@@ -227,7 +234,7 @@ public final class AssistantSpecs
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, turn.phase, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
"seq", integer("Sequence number; resume with ?after=<seq>."), "type", string("The event type (also the SSE event name)."),
"data", freeObject("The event's fields, by type. message.start includes startedAt (ISO-8601). turn.phase includes phase (thinking, tool, writing), optional toolName and label.")));
rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
rs.put("AssistantErrorV1", object("Deprecated since " + EXTENSION_VERSION + ": errors use BasicErrorResponseV1.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
return (rs);
}
@@ -260,7 +267,7 @@ public final class AssistantSpecs
/*******************************************************************************
** Base of the assistant's JSON specs: the user's own AssistantApi (with the
** request's credentials, forwarded to the UI API by the tools), errors in
** the assistant's {"error","code"} shape with their status.
** the standard error shape with their status and assistant-specific code.
*******************************************************************************/
public abstract static class AssistantJsonSpec extends AbstractJsonEndpointSpec
{
@@ -336,6 +343,26 @@ public final class AssistantSpecs
/*******************************************************************************
** A bodyless response has empty content. Core 1.6's schema traversal
** requires a non-null map even when the response has no schema.
*******************************************************************************/
@Override
public Map<Integer, Response> defineResponses()
{
Map<Integer, Response> responses = super.defineResponses();
for(Response response : responses.values())
{
if(response.getContent() == null)
{
response.withContent(Map.of());
}
}
return (responses);
}
/*******************************************************************************
** Keep the request (for its credentials and the server's local port).
*******************************************************************************/
@@ -403,7 +430,7 @@ public final class AssistantSpecs
/*******************************************************************************
** Assistant errors keep their status and {"error","code"}; others go
** Assistant errors keep their status and code in the standard shape; others go
** through QQQ's mapping (400 / 401 / 403 / 404 / 500).
*******************************************************************************/
@Override
@@ -413,15 +440,16 @@ public final class AssistantSpecs
AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class);
if(apiException != null)
{
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", apiException.getMessage());
context.contentType(ContentType.APPLICATION_JSON);
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.getCode(apiException.getStatus()), apiException.getMessage());
if(apiException.getCode() != null)
{
body.put("code", apiException.getCode());
context.result(JsonUtils.toJson(new BasicErrorResponseV1()
.withError(apiException.getMessage())
.withCode(apiException.getCode())
.withTraceId(QJavalinUtils.getTraceId(context))
.toMap()));
}
context.status(apiException.getStatus());
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJson(body));
return;
}
QJavalinUtils.handleException(null, context, e);
@@ -436,12 +464,12 @@ public final class AssistantSpecs
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AssistantErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", "AssistantErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", "AssistantErrorV1"),
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", "AssistantErrorV1"),
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", "AssistantErrorV1"),
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", "AssistantErrorV1")));
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", BasicErrorResponseV1.class.getSimpleName())));
}
@@ -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));
}
File diff suppressed because it is too large. Load diff
@@ -179,7 +179,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
assertThat(send("GET", "/conversations", NO_ACCESS, null).statusCode()).isEqualTo(403);
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
assertThat(denied.statusCode()).isEqualTo(403);
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")
.containsEntry("message", json(denied).get("error"))
.containsEntry("traceId", denied.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
setKillSwitch(true);
assertThat(json(send("GET", "/config", EDITOR, null))).containsEntry("enabled", false).containsEntry("reason", "KILL_SWITCH");
@@ -217,6 +221,9 @@ class AssistantRouteProviderTest extends BaseAssistantTest
HttpResponse<String> foreign = send("GET", "/conversations/" + id, OTHER_EDITOR, null);
assertThat(foreign.statusCode()).isEqualTo(404);
assertThat(foreign.body()).doesNotContain("Posts");
assertThat(json(foreign)).containsEntry("code", "NOT_FOUND")
.containsEntry("message", json(foreign).get("error"))
.containsEntry("traceId", foreign.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
assertThat(send("PATCH", "/conversations/" + id, OTHER_EDITOR, "{\"title\":\"x\"}").statusCode()).isEqualTo(404);
assertThat(send("DELETE", "/conversations/" + id, OTHER_EDITOR, null).statusCode()).isEqualTo(404);
assertThat(send("POST", "/conversations/" + id + "/turns", OTHER_EDITOR, "{\"text\":\"hi\"}").statusCode()).isEqualTo(404);
@@ -238,7 +245,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
HttpRequest form = HttpRequest.newBuilder(URI.create(base + "/conversations")).header("Cookie", "sessionId=" + EDITOR)
.header("Content-Type", "application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofString("title=x")).build();
assertThat(client.send(form, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(400);
assertThat(send("GET", "/conversations/abc", EDITOR, null).statusCode()).isEqualTo(400);
HttpResponse<String> invalid = send("GET", "/conversations/abc", EDITOR, null);
assertThat(invalid.statusCode()).isEqualTo(400);
assertThat(json(invalid)).containsEntry("code", "BAD_REQUEST")
.containsEntry("message", json(invalid).get("error"))
.containsEntry("traceId", invalid.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
assertThat(send("POST", "/conversations/" + id + "/turns", EDITOR, "{}").statusCode()).isEqualTo(400);
assertThat(send("DELETE", "/conversations/" + id, EDITOR, null).statusCode()).isEqualTo(204);
@@ -65,8 +65,17 @@ class AssistantSpecsContractTest extends BaseAssistantTest
assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm")
.contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access");
///////////////////////////////////////////////////////////////////////////////////////
// The standard error schema retains error/code and adds message/traceId/fieldErrors. //
// The two 204 responses never had bodies; their old refs pointed to schema "null". //
// Keep the release baseline and accept only these documented migration corrections. //
///////////////////////////////////////////////////////////////////////////////////////
List<String> acceptedChanges = List.of(
"$ref changed from #/components/schemas/AssistantErrorV1 to #/components/schemas/BasicErrorResponseV1",
"DELETE /qqq/v1/assistant/conversations/{id} response 204: content type removed: application/json",
"POST /qqq/v1/assistant/messages/{id}/feedback response 204: content type removed: application/json");
List<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true", acceptedChanges);
assertThat(problems).isEmpty();
}
@@ -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());
}
}