Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e473bcb56
|
||
|
|
60435c27e3 | ||
|
|
4b7eb81d6d
|
||
|
|
b8aff19826 | ||
|
|
0a23db241b
|
||
|
|
c838eb4f67
|
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+45
-17
@@ -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
+13
-2
@@ -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);
|
||||
|
||||
+10
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
+95
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user