From c838eb4f677165c9cd51a3f5ba7e28175f7a4c81 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 15 Oct 2026 11:54:05 -0500 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 5 + pom.xml | 2 +- .../assistant/api/specs/AssistantSpecs.java | 57 +- .../resources/openapi/qqq-v1/assistant.yaml | 510 ++++++++++++++---- .../api/AssistantRouteProviderTest.java | 15 +- .../api/specs/AssistantSpecsContractTest.java | 11 +- 6 files changed, 479 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 337632e..f17fc06 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.14, 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..de847b4 100644 --- a/pom.xml +++ b/pom.xml @@ -217,7 +217,7 @@ - 4.1.0-RC.8 + 4.1.0-RC.14 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(); } -- 2.54.0 From 0a23db241b19cb3e6b2c261943c937c9e6b94bfa Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 15 Oct 2026 12:27:03 -0500 Subject: [PATCH 2/2] chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f17fc06..a0f0946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased - UI API 1.6.0 -- UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.14, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes. +- 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 diff --git a/pom.xml b/pom.xml index de847b4..12f8322 100644 --- a/pom.xml +++ b/pom.xml @@ -217,7 +217,7 @@ - 4.1.0-RC.14 + 4.1.0-RC.15 -- 2.54.0