diff --git a/CHANGELOG.md b/CHANGELOG.md
index 337632e..a0f0946 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 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
diff --git a/pom.xml b/pom.xml
index 86eb88f..12f8322 100644
--- a/pom.xml
+++ b/pom.xml
@@ -217,7 +217,7 @@
- 4.1.0-RC.8
+ 4.1.0-RC.15
diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java
index 44d863f..5859a4d 100644
--- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java
+++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java
@@ -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.3.0";
public static final String PREFIX = "/assistant";
@@ -227,7 +229,7 @@ public final class AssistantSpecs
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: , id: , 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=."), "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 +262,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 +338,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 defineResponses()
+ {
+ Map 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 +425,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 +435,16 @@ public final class AssistantSpecs
AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class);
if(apiException != null)
{
- Map 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 +459,12 @@ public final class AssistantSpecs
public List 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())));
}
diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml
index 0e63520..886bedc 100644
--- a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml
+++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml
@@ -161,7 +161,7 @@ components:
type: "string"
type: "object"
AssistantErrorV1:
- description: "An error."
+ description: "Deprecated since 1.3.0: errors use BasicErrorResponseV1."
properties:
error:
description: "A message for the user."
@@ -240,48 +240,121 @@ components:
\ toolName and label."
type: "object"
type: "object"
+ BasicErrorResponseV1:
+ description: "The error body of every UI API operation (core and extensions)."
+ properties:
+ error:
+ description: "The error message (the same as message; kept for clients of\
+ \ UI API 1.0 to 1.5)."
+ type: "string"
+ code:
+ description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
+ \ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
+ \ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
+ \ Extensions may add codes; treat an unknown code by the HTTP status.\
+ \ Since 1.6.0."
+ type: "string"
+ message:
+ description: "A message to show the user. Since 1.6.0."
+ type: "string"
+ fieldErrors:
+ description: "Errors about particular request fields, when known. Since\
+ \ 1.6.0."
+ items:
+ properties:
+ field:
+ description: "The field (or parameter) name."
+ type: "string"
+ message:
+ description: "The error about that field."
+ type: "string"
+ code:
+ description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
+ \ PATTERN."
+ type: "string"
+ type: "object"
+ type: "array"
+ traceId:
+ description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
+ \ response header); quote it when reporting a problem. Since 1.6.0."
+ type: "string"
+ type: "object"
securitySchemes:
sessionUuidCookie:
+ description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
in: "cookie"
name: "sessionUUID"
type: "apiKey"
+ bearerAuth:
+ bearerFormat: "JWT or session UUID"
+ description: "`Authorization: Bearer `, where the token is either\n-\
+ \ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
+ \ to the app's client,\n or to its native client (`values.nativeClientId`\
+ \ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
+ \ grant or auth-code + PKCE. It is verified against the\n provider's keys\
+ \ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
+ \ as signing in on the web. When it expires (401), refresh it at the provider\
+ \ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
+ \ which the server keeps refreshed."
+ scheme: "bearer"
+ type: "http"
+ oauth2:
+ description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
+ \ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
+ \ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
+ \ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
+ \ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
+ \ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
+ \ token\nas `Authorization: Bearer`."
+ flows:
+ authorizationCode:
+ authorizationUrl: "/authorize"
+ scopes:
+ email: "Email address"
+ offline_access: "A refresh token, to renew the access token without signing\
+ \ in again"
+ openid: "OpenID Connect sign-in"
+ profile: "Name, username and groups (permissions come from the groups)"
+ tokenUrl: "/token"
+ type: "oauth2"
info:
contact:
email: "contact@kingsrook.com"
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
- version: "1.2.0"
+ version: "1.3.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
get:
description: "Whether the assistant is available to this user, its persona and\
\ limits"
+ operationId: "getAssistantConfig"
responses:
400:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -293,21 +366,37 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Whether the assistant is available to this user, its persona and limits"
+ tags:
+ - "assistant"
/qqq/v1/assistant/conversations:
get:
description: "The user's own conversations"
+ operationId: "getAssistantConversations"
parameters:
- description: "ARCHIVED for archived conversations; active ones by default."
in: "query"
@@ -320,25 +409,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -350,20 +439,36 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "The user's own conversations"
+ tags:
+ - "assistant"
post:
description: "Start a conversation"
+ operationId: "postAssistantConversations"
requestBody:
content:
application/json:
@@ -383,31 +488,31 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
409:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
201:
content:
@@ -419,15 +524,31 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Start a conversation"
+ tags:
+ - "assistant"
/qqq/v1/assistant/conversations/{id}:
delete:
description: "Delete a conversation"
+ operationId: "deleteAssistantConversationsById"
parameters:
- description: "The conversation id."
in: "path"
@@ -440,50 +561,62 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
409:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
204:
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/null"
description: "Done (no body)"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Delete a conversation"
+ tags:
+ - "assistant"
get:
description: "A conversation and a page of its messages"
+ operationId: "getAssistantConversationsById"
parameters:
- description: "The conversation id."
in: "path"
@@ -508,25 +641,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -538,20 +671,36 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "A conversation and a page of its messages"
+ tags:
+ - "assistant"
patch:
description: "Rename, pin or archive a conversation"
+ operationId: "patchAssistantConversationsById"
parameters:
- description: "The conversation id."
in: "path"
@@ -579,25 +728,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -609,21 +758,37 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Rename, pin or archive a conversation"
+ tags:
+ - "assistant"
/qqq/v1/assistant/conversations/{id}/turns:
post:
description: "Ask: start a turn and stream the reply as server-sent events"
+ operationId: "postAssistantConversationsByIdTurns"
parameters:
- description: "The conversation id."
in: "path"
@@ -655,25 +820,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -685,22 +850,38 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Ask: start a turn and stream the reply as server-sent events"
+ tags:
+ - "assistant"
/qqq/v1/assistant/turns/{turnId}/events:
get:
description: "Resume a turn's events after a sequence number (replay, then the\
\ live tail)"
+ operationId: "getAssistantTurnsByTurnIdEvents"
parameters:
- description: "The turn id."
in: "path"
@@ -719,25 +900,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -749,22 +930,38 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Resume a turn's events after a sequence number (replay, then the live\
\ tail)"
+ tags:
+ - "assistant"
/qqq/v1/assistant/turns/{turnId}/cancel:
post:
description: "Stop a running turn"
+ operationId: "postAssistantTurnsByTurnIdCancel"
parameters:
- description: "The turn id."
in: "path"
@@ -777,25 +974,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -807,7 +1004,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
202:
content:
@@ -819,39 +1016,55 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Stop a running turn"
+ tags:
+ - "assistant"
/qqq/v1/assistant/turns/active:
get:
description: "Find the user's active assistant runs"
+ operationId: "getAssistantTurnsActive"
responses:
400:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -863,21 +1076,37 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Find the user's active assistant runs"
+ tags:
+ - "assistant"
/qqq/v1/assistant/messages/{id}/feedback:
post:
description: "Thumbs up or down on an assistant message"
+ operationId: "postAssistantMessagesByIdFeedback"
parameters:
- description: "The assistant message id."
in: "path"
@@ -905,51 +1134,63 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
409:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
204:
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/null"
description: "Done (no body)"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Thumbs up or down on an assistant message"
+ tags:
+ - "assistant"
/qqq/v1/assistant/actions/{id}:
get:
description: "A write the assistant proposed, and its state"
+ operationId: "getAssistantActionsById"
parameters:
- description: "The pending action id."
in: "path"
@@ -962,25 +1203,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -992,22 +1233,38 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "A write the assistant proposed, and its state"
+ tags:
+ - "assistant"
/qqq/v1/assistant/actions/{id}/confirm:
post:
description: "Confirm a proposed write: it runs once, through the UI API, with\
\ this request's session"
+ operationId: "postAssistantActionsByIdConfirm"
parameters:
- description: "The pending action id."
in: "path"
@@ -1020,25 +1277,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -1050,22 +1307,38 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Confirm a proposed write: it runs once, through the UI API, with this\
\ request's session"
+ tags:
+ - "assistant"
/qqq/v1/assistant/actions/{id}/reject:
post:
description: "Cancel a proposed write; nothing changes"
+ operationId: "postAssistantActionsByIdReject"
parameters:
- description: "The pending action id."
in: "path"
@@ -1078,25 +1351,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -1108,22 +1381,38 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Cancel a proposed write; nothing changes"
+ tags:
+ - "assistant"
/qqq/v1/assistant/access:
get:
description: "Explain access: which UI API operations the assistant could call\
\ for this user, and why not"
+ operationId: "getAssistantAccess"
parameters:
- description: "Only this table."
in: "query"
@@ -1148,25 +1437,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
403:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "No access to the assistant"
404:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found (or not the user's own)"
503:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Disabled, not configured, or the kill switch is on"
200:
content:
@@ -1178,16 +1467,37 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Already finished or decided"
429:
content:
application/json:
schema:
- $ref: "#/components/schemas/AssistantErrorV1"
+ $ref: "#/components/schemas/BasicErrorResponseV1"
description: "Budget or rate limit"
+ 401:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/BasicErrorResponseV1"
+ description: "Not authenticated, or the session or token is invalid or expired\
+ \ (code UNAUTHENTICATED). Sign in again, or refresh the token."
security:
- sessionUuidCookie:
- "N/A"
+ - bearerAuth:
+ - "N/A"
+ - oauth2:
+ - "openid"
+ - "profile"
+ - "email"
summary: "Explain access: which UI API operations the assistant could call for\
\ this user, and why not"
+ tags:
+ - "assistant"
+servers:
+- description: "The QQQ server that serves this document (paths include /qqq/v1)."
+ url: "/"
+tags:
+- description: "Operations of the assistant extension."
+ name: "assistant"
diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java
index e721617..8f0a589 100644
--- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java
+++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java
@@ -179,7 +179,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
assertThat((Map) 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 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 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 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);
diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java
index ba5f1ec..5ab7344 100644
--- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java
+++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java
@@ -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 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 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();
}