Compare commits

...
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 a588aafe2e test(assistant): pin the live thread to real title, question, answer and tool steps at message.end
build / maven (push) Successful in 4m29s
maven / build (push) Successful in 4m29s
The panel reloads the conversation at message.end. A tool-using turn must
already have stored the real title, the question and answer parts and the
tool-call rows then, not the transcript redactor's placeholders
(changed:v1, [], {}). The redactor is always attached to a turn, so this is
independent of Secured Data. Fails without the flush-before-message.end
ordering (RC.24), passes on develop and RC.25.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 19:46:55 -06:00
CI a712e6fbc8 Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-07 17:28:26 +00:00
james.maes 7391150aa0 Merge pull request 'build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)' (#91) from deps/align-gitea into develop
build / maven (push) Successful in 10m4s
maven / build (push) Successful in 10m4s
2026-11-07 17:18:25 +00:00
james.maesandClaude Opus 5.5 eb55ef552f build(deps): align with Gitea releases (parent 2.2.1, QQQ 4.1.0-RC.25)
build / maven (push) Successful in 6m49s
maven / build (push) Successful in 6m49s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.15 -> 4.1.0-RC.25
pom.xml: removed Central snapshot repository

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:46 +00:00
james.maes 7b27f2ea9e Merge pull request 'ci: pin shared workflow to v1' (#90) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 14s
build / maven (push) Successful in 10m55s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 18s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 10m55s
2026-11-06 16:03:24 +00:00
james.maesandClaude Opus 5.5 4eb81d2f5b ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
build / maven (push) Successful in 8m23s
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 6s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 8m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:04:14 +00:00
james.maes 8a2f9474c6 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#89) from ci/ci-workflows into develop
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
build / maven (push) Successful in 9m3s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 7s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 9m3s
2026-11-05 23:14:27 +00:00
james.maesandClaude Opus 5.5 55563ffac3 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
build / maven (push) Successful in 7m42s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 2s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Failing after 20m42s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Failing after 5m17s
maven / build (push) Successful in 7m42s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:01:54 +00:00
james.maes 67bdd320a8 Merge pull request 'fix(assistant): store the transcript before message.end (develop)' (#86) from fix/transcript-stored-before-message-end-develop into develop
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 32s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 32s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 7m35s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 30m22s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
qrun-release / postpublish (push) Successful in 5m43s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
qrun-release / release (push) Successful in 30m23s
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 6m29s
Shared Java Maven CI with Docker / gate (push) Successful in 12s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 12s
Shared QRun source version / version (push) Successful in 5m43s
2026-11-05 10:17:30 +00:00
james.maesandClaude Sonnet 5.5 3539f5fd93 fix(assistant): store the transcript before message.end so the panel shows the answer
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 32s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 7m47s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 8s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 27s
qrun-release / release (push) Successful in 12m20s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 20s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 14m14s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 6m48s
The panel reloads the conversation the moment the stream ends. The
redacted transcript (message parts, pending actions, stored events) was
flushed only after message.end was sent, so that reload read the "[]"
placeholders and showed the newest question and answer as empty bubbles
until the panel was closed and reopened. Persist and flush first, then
emit message.end.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:16:11 -06:00
forge-recovery 2d6df82f16 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 31s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 8m28s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 10m38s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
qrun-release / postpublish (push) Failing after 12m39s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
Shared Java Maven CI with Docker / gate (push) Successful in 17s
qrun-release / release (push) Successful in 10m53s
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 7m31s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 16s
Shared QRun source version / version (push) Failing after 12m39s
2026-11-02 22:09:34 +00:00
forge-recovery db8f5398b8 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 26s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 15m47s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 8s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 14m49s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 17s
2026-11-02 21:30:15 +00:00
forge-recoveryandCodex f51482f69d ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 59m43s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 6m42s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:13:19 +00:00
4083e5041f ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 16s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI with Docker / verify (push) Successful in 6m11s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
Shared Java Maven CI with Docker / gate (push) Successful in 13s
qrun-release / release (push) Successful in 2h16m43s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 2h23m0s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 6m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:45:12 +00:00
45b34b7ccd ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Failing after 19s
Shared Java Maven CI with Docker / verify (push) Successful in 7m52s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 1h26m36s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h26m37s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 10m30s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 22s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 7m29s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:15:41 +00:00
forge-recoveryandCodex 2652843c29 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Failing after 12m22s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Failing after 10s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Failing after 21s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 7m9s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 03:25:36 +00:00
james.maesandClaude Fable 5.1 ca9212731d fix(api): say NOT_CONFIGURED, with what to configure, when no model provider is usable (#83)
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Failing after 21s
QRun-IO/ci-policy: ci / java-docker-1 (push) Failing after 37m15s
QRun-IO/ci-policy: ci / ci-result (push) Failing after 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Skipped
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QBit CodeQL / Analyze branch source (push) Canceled after 0s
CodeQL / analyze (push) Canceled after 0s
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 7m45s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Failing after 8s
The probe reports DISABLED, NOT_CONFIGURED, NO_PERMISSION, KILL_SWITCH in that order; GET /config gains an additive message naming what to configure; a user without access on an unconfigured server gets 503 NOT_CONFIGURED instead of 403. Extension 1.7.0.

Fixes #82

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 11:30:04 -06:00
james.maesandCodex bcf173f4f2 ci: adopt managed orb 0.8 patch track (#78)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:31:42 -05:00
james.maesandCodex 5a1985fd96 ci: adopt shared orb 0.7.18 (#77)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:48:54 -05:00
james.maesandCodex 055df2e764 ci: adopt shared orb 0.7.17 (#76)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:20:22 -05:00
james.maesandCodex 0f79e7352a ci: use the shared branch-aware CodeQL worker (#74)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:51:16 -05:00
james.maesandCodex 6bb8c7d826 ci: adopt central orb 0.7.12 and stable gates (#72)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:58:48 -05:00
james.maesandCodex 3c04d1d1d7 ci: centralize rc release completion in qqq orb (#71)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:29:05 -05:00
james.maesandCodex a348a4b050 ci: adopt shared orb 0.7.11 (#70)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:00:37 -05:00
james.maes 4049dffd0a Merge pull request #69 from QRun-IO/feature/central-ci
ci: adopt verified shared orb 0.7.0
2026-10-20 07:30:02 -05:00
james.maesandCodex ebfdae8561 ci: adopt verified shared orb 0.7.0
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 07:23:15 -05:00
james.maesandOpenAI Codex 429397a4f1 fix(ci): normalize CircleCI runtime booleans
Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-20 06:12:58 -05:00
james.maesandOpenAI Codex a0f9187f71 ci: expose safe continuation failure diagnostics
Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-20 06:09:40 -05:00
james.maesandOpenAI Codex 141de96626 ci: adopt centralized QQQ pipeline canary
Replace copied workflow logic with the strict orb-owned QBit profile.
Preserve publication opt-outs and full Maven verification.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-20 06:01:46 -05:00
james.maes dcf883fccb Merge pull request #65 from QRun-IO/feature/conversation-management
Conversation management: table access, archive/restore/delete actions, UI API 1.6.0
2026-10-19 09:58:58 -05:00
james.maesandCodex 3637ca3ad0 fix: integrate conversation management with current develop
Preserve current attachment, notification and starter prompt behavior. Encrypt user titles that resemble ciphertext unless they match the same stored record; verify all eight reactor modules.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 09:52:41 -05:00
james.maes 63afe0b587 Merge pull request #64 from QRun-IO/feature/starter-prompts-from-access
Starter prompts follow the user's access
2026-10-19 09:33:30 -05:00
james.maesandCodex a353381d02 Merge develop into feature/starter-prompts-from-access
Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 09:20:00 -05:00
james.maesandCodex b250585dde fix(assistant): recheck starter prompt access on every request
Cap combined prompts at three and remove cached authorization-sensitive suggestions. Preserve bounded deterministic generation and cover field, row, table, policy and history changes. Thirty focused tests passed.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:50:26 -05:00
james.maesandCodex 407bdcdb47 fix(tools): read the flat v1 table metadata response
Preserve field visibility filtering while interpreting the actual API response shape. Verified allowed fields and hidden/password exclusions through the real endpoint.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:50:25 -05:00
james.maes 2167b77543 Merge pull request #63 from QRun-IO/feature/turns-not-jobs
Chat turns are not jobs; link bulk loads to their job; notification audit
2026-10-19 08:44:43 -05:00
james.maesandCodex 3db34201fc merge: integrate verified assistant attachment support
Preserve both attachment and notification behavior. Full eight-module verify and 45 focused turn, notification, bulk-load and attachment tests passed.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 08:36:30 -05:00
james.maes 300bb3b96c Merge pull request #66 from QRun-IO/feature/assistant-attachments
feat(assistant): read files attached in the panel (untrusted, capped, vision-aware)
2026-10-19 08:15:33 -05:00
james.maesandClaude Opus 5.5 589d8f5c1c docs: changelog and upgrade notes for conversation management
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:46:06 -05:00
james.maesandClaude Opus 5.5 b5cd747899 feat(assistant): users manage their own conversations in the table UI
assistant_conversation is open to every assistant user (assistant.hasAccess
or assistant.admin) through AssistantAccessPermissionChecker; rows stay
locked to their owner, and the Assistant app and other tables stay
admin-only. A conversation customizer limits user edits to title, pinned
and status, refuses deleting a conversation with an answer in progress and
deletes the child rows after a table delete, through the same store code
as DELETE /conversations/{id}.

Adds Archive, Restore and Delete conversations as standard streamed-ETL
record actions on the table, acting only on rows the user can see.

UI API extension assistant 1.6.0: conversation list items have running,
PATCH /conversations/{id} accepts {"read": true}, and GET /config has
conversationsTable when the session may query that table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:46:06 -05:00
james.maesandClaude Opus 5.5 e4f369a4e7 feat(config): build starter prompts per user from what they can access
GET /assistant/config returned the profile's starter prompts as static
text, so a user could be offered examples about tables they cannot see.
profile.starterPrompts (same field and shape) is now built per user:
up to three prompts generated from the tables the user can read through
the assistant that have rows (list_tables, count_records and
describe_table via the UI API with the user's own session), cached five
minutes, stable for the day, bounded and fail-soft. Configured prompts
stay only when tagged with tables the user can read.

Part of KofTwentyTwo/Website-Backend#303

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:34:35 -05:00
james.maesandClaude Opus 5.5 fc5026594d docs: changelog and upgrade notes for turns that are not jobs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:29:54 -05:00
james.maesandClaude Opus 5.5 e1f64cfa05 fix(assistant): send scheduled task notifications through core QAlerts
The notification a task owner asked for (output mode Notification or Both)
now goes through core QAlerts.notify: category Job, the owner as the only
audience, severity by run status and a link to the conversation. It replaces
a reflective call into an older alerts SPI that no longer matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:29:53 -05:00
james.maesandClaude Opus 5.5 f7235690b6 feat(assistant): link a launched bulk load to its job
A confirmed bulk load still launches a real durable job through
POST /qqq/v1/processes/{p}/jobs. Its tool result now carries jobHref (the
job's /app link) next to jobId and a record ref to the job, and the confirm
response's href points at the job, so the conversation links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:29:51 -05:00
james.maesandClaude Opus 5.5 ac2107bd97 feat(assistant)!: chat turns are not jobs; remove the run tracker SPI
Ordinary chat turns no longer create core tracking jobs, so they stay out
of the Jobs tray and tables and no longer trigger a per-answer "finished"
notification. Crash safety comes from the assistant's own turn table
(leased, heartbeated assistant_turn rows, seq-numbered event replay and the
abandoned-turn sweep), which is unchanged.

Removes AssistantRunTracker, JobTrackingRunTracker, the config accessors and
the tracking id on RunningTurn, with their tests. The panel's Stop
(POST /turns/{id}/cancel) is unchanged and still covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:29:48 -05:00
44 changed files with 2698 additions and 1147 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -14
View File
@@ -1,26 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
secrets: inherit
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+31
View File
@@ -1,5 +1,35 @@
# Changelog
## Unreleased - Answer visible at the end of the stream
- The turn's redacted transcript (the question and answer parts, pending actions, stored events) is now written before `message.end` is sent. The panel reloads the conversation the moment the stream ends, and used to read the empty placeholders (`[]`) the transcript redactor stores until its flush, so the newest question and answer showed as empty bubbles until the panel was closed and reopened.
## Unreleased - Clear reason without a model provider (UI API extension 1.7.0)
- `GET /config` checks the server-wide causes before the user's permission: `DISABLED`, then `NOT_CONFIGURED` (no usable model provider), then `NO_PERMISSION`, then `KILL_SWITCH`. A server with no provider no longer tells users to ask for access (#82).
- UI API extension assistant 1.7.0: `GET /config` has `message` whenever `reason` is set: a sentence for the user. For `NOT_CONFIGURED` it says what an administrator configures (an enabled row on **Assistant > Providers**, for example Anthropic with `ANTHROPIC_API_KEY` set in the server environment, or a local OpenAI-compatible server such as Ollama).
- Without a usable provider, calls from a user who also lacks `assistant.hasAccess` answer `503 NOT_CONFIGURED` with that message instead of `403 NO_PERMISSION`; users with access keep their conversations, and a new question answers `503 NOT_CONFIGURED`.
- Verified the OpenAI-compatible adapter (kind `OLLAMA`) live against a local Ollama `llama3.3:70b-instruct-q4_K_M`, including a tool call (`LiveOpenAiCompatibleTest`).
## Unreleased - Conversation management (UI API extension 1.6.0)
- `assistant_conversation` is open to every assistant user (`assistant.hasAccess` or `assistant.admin`, through the new `AssistantAccessPermissionChecker`) in the standard table UI. Rows stay locked to their owner by the `userId` record lock, so a user only ever sees, updates and deletes their own conversations; admins with the all-access key still see everyone's. The Assistant app (its nav) and every other assistant table stay admin-only.
- Users change only a conversation's title, pinned flag and status (Active / Archived) through the table; the other fields are the assistant's own. A new title is encrypted in HIPAA mode, including literal text beginning with `enc:v1:`. Only an exact match to the same record's stored ciphertext is preserved unchanged; internal SYSTEM writes retain their existing storage path.
- A table delete of a conversation also deletes its messages, turns, turn events, tool calls, feedback, data audits and pending actions (the same rows `DELETE /conversations/{id}` removes), so nothing is orphaned on a database without the cascading foreign keys. A conversation with an answer in progress is refused.
- New record actions on `assistant_conversation`: **Archive conversations**, **Restore conversations** and **Delete conversations** (standard streamed ETL: selected records, Review, Result; same permission as the table; only rows the user can see; delete skips conversations with an answer in progress).
- UI API extension assistant 1.6.0: `GET /conversations` items have `running` (an answer in progress: a RUNNING or CANCELLING turn that is not orphaned); `PATCH /conversations/{id}` accepts `{"read": true}` (sets `unreadCount` to 0); `GET /config` has `conversationsTable: "assistant_conversation"` when the session may query that table.
## Unreleased - Starter prompts follow access
- `profile.starterPrompts` in `GET /assistant/config` is built per user: up to three prompts generated from tables the user can read that have rows (UI API metadata and count as the user, bounded, current access rechecked on every request, stable order for the day), plus configured prompts only when tagged with tables the user can read (`[table] prompt`, `[none] prompt`; untagged lines are dropped). See UPGRADE.md.
## Unreleased - Chat turns are not jobs
- Ordinary chat turns no longer create core tracking jobs: they stay out of the Jobs tray and Jobs tables, and core no longer sends an "Assistant answer finished" notification for every answer. Crash safety is unchanged; it comes from the assistant's own turn table (leased, heartbeated `assistant_turn` rows, seq-numbered `assistant_turn_event` replay and the abandoned-turn sweep). The panel's Stop (`POST /turns/{id}/cancel`) is unchanged.
- Removed the `AssistantRunTracker` SPI, `JobTrackingRunTracker` and `AssistantQBitConfig.withRunTracker` / `setRunTracker` / `getRunTracker` (see UPGRADE.md).
- Real multi-step work stays a real job: a confirmed bulk load still launches through `POST /qqq/v1/processes/{p}/jobs`, and its tool result now carries `jobHref` (the job's `/app` link) next to `jobId`, plus a record ref to the job; the confirm response's `href` links to the job.
- Scheduled task notifications (output mode Notification or Both) now go through core `QAlerts.notify` (category Job, the owner as the only audience, a link to the conversation, success/error/warning severity by run status), replacing the reflective call into an older alerts SPI that no longer matched.
## Unreleased - Attachments
- Files attached in the panel (`pageContext.attachments`, core upload refs) are read for the question: as the signed-in user's own upload, in memory, with per-file and per-question byte and character caps (`AssistantQBitConfig.attachments`). The type is sniffed from content; executables, archives and unknown binary data are refused. Text, Markdown and JSON are decoded with charset detection, PDF page text is extracted (PDFBox via QQQ core, no scripts, host font mapper preserved), CSV/TSV/XLSX become a header + rows + count preview, and PNG/JPEG/GIF/WebP images go to vision models as image parts.
@@ -8,6 +38,7 @@
- Provider SPI: `ModelPart.Type.IMAGE` (`mediaType`, base64 `data`), mapped by the Anthropic, Bedrock and OpenAI-compatible providers. Claude models report vision. `ModelPart.toString()` never includes text or image data.
- `GET /config`: `features.attachments`, `limits.maxAttachmentBytes`, `limits.maxAttachments`, `limits.attachmentTypes`.
- System prompt: when `create_record` / `update_record` are offered, the model proposes the change instead of saying it cannot create records.
||||||| parent of c5125f9 (fix(assistant): store the transcript before message.end so the panel shows the answer)
## Unreleased - Optional provider health
+11
View File
@@ -479,6 +479,17 @@ OLLAMA, LM_STUDIO, VLLM, LLAMA_CPP or OPENAI_COMPATIBLE as their kind.
Registry prices override type/model prices, then configured prices. Unpriced
local models cost zero. Capabilities can be adjusted per registered model.
For a local Ollama with no key (the live test ran against `llama3.3:70b-instruct-q4_K_M`):
```java
config.withProviderType(new OpenAiCompatibleProviderType());
// provider row: type openai-compatible, kind OLLAMA, model llama3.3:70b-instruct-q4_K_M,
// base URL empty (http://localhost:11434/v1) or an allow-listed override such as http://127.0.0.1:11434/v1
```
Without any usable provider `GET /config` answers `enabled: false, reason: NOT_CONFIGURED` with a
`message` that says what to configure, before any permission check.
For Ollama behind a TLS proxy with a private CA and bearer authentication:
```java
+55
View File
@@ -1,5 +1,60 @@
# Upgrading qbit-assistant
## Conversation management (Unreleased, UI API extension assistant 1.6.0)
No schema change. `assistant_conversation` is now usable by every user holding
`assistant.hasAccess` (not only `assistant.admin`), together with three new
record actions (Archive, Restore and Delete conversations). Rows stay locked to
their owner. A user's table queries only return their own rows if their session
carries their owner key: call `AssistantSecurity.applySessionKeys` from the
authentication customizer for **every** user, not only for admins. Without it,
non-admin users see an empty table.
The panel can link to the table using `conversationsTable` from `GET /config`.
That field is present only when the user may query the table.
## Starter prompts follow the user's access (Unreleased)
`GET /assistant/config` no longer returns the profile's starter prompts as written.
`profile.starterPrompts` (same field, still `string[]`) is now built per user:
- Up to three prompts are generated from the tables the signed-in user can read
through the assistant and that have rows (a count as the user), using the table
labels, a date field when present, and "What changed in ... this week?" when the
change-history tool is offered. The pick is stable for the user for the day and
rechecked on every request, including table, row and field access. Generation
uses bounded UI API calls and no model calls. Any failure yields no generated prompts.
- A configured starter prompt (Assistant Profile > Starter prompts, one per line) is
shown only when it declares the tables it needs and the user can read all of
them. Declare them with a leading tag: `[blog_post] Which posts are drafts?`, or
`[blog_post, page] ...` for several. `[none] What can you help with?` names no
table and is always shown. A line without a tag is no longer shown, because
nothing says which tables it mentions: add a tag to keep it.
- Shown prompts are the permitted configured ones first, then generated ones, three
in all (a profile with three or more permitted prompts shows only those).
No schema change and no configuration. The panel needs no change.
## Chat turns are not jobs (Unreleased)
Ordinary chat turns no longer create core tracking jobs, so they no longer appear
in the Jobs tray or the Jobs tables and no longer trigger a per-answer "finished"
notification. No schema change and no configuration is needed.
The `AssistantRunTracker` SPI and `JobTrackingRunTracker` are removed, along with
`AssistantQBitConfig.withRunTracker`, `setRunTracker` and `getRunTracker`. A host
that called any of them must delete the call: `withRunTracker(AssistantRunTracker.NOOP)`
is now the only behavior, and a custom tracker has no replacement hook. Turn
progress, Stop and recovery are available through the assistant's own routes
(`GET /turns/active`, the turn events stream and `POST /turns/{id}/cancel`).
Bulk loads the assistant launches are still durable jobs. Their confirmed tool
result now includes `jobHref` (the job's in-app link) and the confirm response's
`href` points at the job.
Scheduled task notifications now use core `QAlerts.notify`; they reach whatever
alerts sink the host installed (or core's logging sink when none is).
## Provider setup state (Unreleased)
Apply the **Provider setup state** block at the end of `docs/schema-postgres.sql`
+2 -2
View File
@@ -44,7 +44,7 @@ Guards:
`ProviderService.resolve(policyModel)`:
1. The enabled row marked default (marking one default unmarks the others), else the first enabled row.
2. With no enabled row, the provider registered in code (`withModelProvider`) and the policy's main model: hosts that never add a row keep working.
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently.
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently. `GET /config` reports it before `NO_PERMISSION`, with a `message` naming what to configure (#82).
The model is the row's, else the policy's main model. The rows and prices are cached for `policyCacheSeconds` and dropped on any change.
@@ -124,7 +124,7 @@ Owner: *"the agent should have to go through the same API that the UIUX does. th
4. **Persona and skills, never more access** (owner, 2026-10-13): *"in the config for the AI settings, we should be able to set a name and personality and extra skills etc for the agent - and again - the agent should be limited to only using our UIUX API and being able to only do the things that the currently logged in user could do with the same UIUX API via the UIUX"*.
- **Profiles** (`assistant_profile`, admin-edited): display name, icon, personality (tone instructions, at most 2,000 characters), greeting and up to 6 starter prompts.
- One profile is the default. Others are scoped to an app, to users holding a permission (a role), or to both. The most specific profile wins.
- `/assistant/config` returns the profile for the panel header, the greeting and the starter prompts.
- `/assistant/config` returns the profile for the panel header, the greeting and the starter prompts (built per user from the tables the user can read; configured ones need a `[table]` tag, see UPGRADE.md).
- **Skills** (`assistant_skill`, linked to profiles through `assistant_profile_skill`): named Markdown instruction packs of at most 6,000 characters, with a description, an enabled flag, trigger keywords and apps.
- A skill is read only when relevant: a keyword appears in the question, the user is in one of its apps, or it has neither, in which case it is always read.
- All skills read for one question total at most 12,000 characters.
+3 -15
View File
@@ -20,7 +20,7 @@
<!-- Multi-module build. The qqq-orb sets the revision here; the modules -->
<!-- inherit it, and the flatten plugin resolves it in the published POMs. -->
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<revision>0.2.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -37,7 +37,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.1</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -217,7 +217,7 @@
<properties>
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles; -->
<!-- RC.8 provides core job tracking (JobTracking, the Jobs tray). -->
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.25</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -230,18 +230,6 @@
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
</profiles>
</project>
@@ -32,8 +32,6 @@ import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
import com.kingsrook.qbits.assistant.jobs.AssistantRunTracker;
import com.kingsrook.qbits.assistant.jobs.JobTrackingRunTracker;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
@@ -107,7 +105,6 @@ public class AssistantQBitConfig implements QBitConfig
private Integer shutdownGraceSeconds = 8;
private String defaultConcurrencyMode = "SUPERSEDE";
private Boolean registerShutdownHook = true;
private AssistantRunTracker runTracker = new JobTrackingRunTracker();
private Integer maxTurnSeconds = 300;
private Integer toolCallTimeoutSeconds = 60;
@@ -2361,36 +2358,4 @@ public class AssistantQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Getter for runTracker
*******************************************************************************/
@JsonIgnore
public AssistantRunTracker getRunTracker()
{
return (this.runTracker == null ? AssistantRunTracker.NOOP : this.runTracker);
}
/*******************************************************************************
** Setter for runTracker
*******************************************************************************/
public void setRunTracker(AssistantRunTracker runTracker)
{
this.runTracker = runTracker;
}
/*******************************************************************************
** Fluent setter for runTracker
*******************************************************************************/
public AssistantQBitConfig withRunTracker(AssistantRunTracker runTracker)
{
this.runTracker = runTracker;
return (this);
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.processes.ConversationActionsProcess;
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
@@ -184,6 +185,11 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QProcessMetaData testConnection = TestConnectionProcess.process();
testConnection.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(testConnection);
for(QProcessMetaData process : ConversationActionsProcess.processes())
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
}
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
{
process.setSourceQBitName(qBitMetaData.getName());
@@ -36,6 +36,7 @@ import com.kingsrook.qbits.assistant.engine.ClientErrors;
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProfileService;
import com.kingsrook.qbits.assistant.engine.StarterPrompts;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RateLimiter;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
@@ -95,6 +96,7 @@ public class AssistantRuntime
private final ContextBuilder contextBuilder;
private final DataAccessService dataAccessService;
private final ProfileService profileService;
private final StarterPrompts starterPrompts;
private final AccessExplainer accessExplainer;
private final UiApiToolbox toolbox;
private final UiApiGateway uiApiGateway;
@@ -148,6 +150,7 @@ public class AssistantRuntime
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService);
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
this.starterPrompts = new StarterPrompts(toolbox, clock);
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance);
this.uiApiGateway = new UiApiGateway()
{
@@ -259,6 +262,16 @@ public class AssistantRuntime
/*******************************************************************************
** Getter for starterPrompts (the per-user example prompts, built from what the user may read)
*******************************************************************************/
public StarterPrompts getStarterPrompts()
{
return (starterPrompts);
}
/*******************************************************************************
** Getter for profileService (personas and skills: text only, never tools)
*******************************************************************************/
@@ -26,6 +26,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
@@ -60,8 +61,12 @@ import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
@@ -81,6 +86,20 @@ public class AssistantApi
public static final int TITLE_MAX_CHARS = 60;
/////////////////////////////////////////////////////////////////////////////
// GET /config reason (and error code) when no model provider is usable: //
// no enabled provider row that can be built (a missing key, a disallowed //
// variable or URL, an uninstalled type) and no provider registered in code //
/////////////////////////////////////////////////////////////////////////////
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
public static final String NO_MODEL_PROVIDER_MESSAGE = "No model provider is configured on this server. An administrator adds an enabled provider on "
+ "Assistant > Providers (for example type Anthropic, whose API key variable ANTHROPIC_API_KEY must be set in the server environment, or a local "
+ "OpenAI-compatible server such as Ollama).";
public static final String NO_PERMISSION_MESSAGE = "You do not have access to the assistant.";
public static final String DISABLED_MESSAGE = "The assistant is turned off for this application.";
public static final String KILL_SWITCH_MESSAGE = "The assistant has been turned off by an administrator.";
private final AssistantRuntime runtime;
private final AssistantQBitConfig config;
private final AssistantStore store;
@@ -129,27 +148,39 @@ public class AssistantApi
Map<String, Object> result = new LinkedHashMap<>();
String reason = null;
String message = null;
/////////////////////////////////////////////////////////////////////////
// server-wide causes come before the user's own permission, so a //
// server without a usable model provider says so (QRun-IO/qbit- //
// assistant#82) instead of sending the user to ask for access //
/////////////////////////////////////////////////////////////////////////
if(!config.isEffectivelyEnabled())
{
reason = "DISABLED";
message = DISABLED_MESSAGE;
}
else if(activeProvider() == null)
{
reason = NOT_CONFIGURED;
message = NO_MODEL_PROVIDER_MESSAGE;
}
else if(!hasAccess())
{
reason = "NO_PERMISSION";
}
else if(activeProvider() == null)
{
reason = "NOT_CONFIGURED";
message = NO_PERMISSION_MESSAGE;
}
else if(runtime.getPolicyService().current(false).killSwitch())
{
reason = "KILL_SWITCH";
message = KILL_SWITCH_MESSAGE;
}
result.put("enabled", reason == null);
if(reason != null)
{
result.put("reason", reason);
result.put("message", message);
}
result.put("shortcut", config.getShortcut());
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
@@ -168,13 +199,39 @@ public class AssistantApi
profile.put("displayName", persona.displayName());
profile.put("icon", persona.icon());
profile.put("greeting", persona.greeting());
profile.put("starterPrompts", persona.starterPrompts());
profile.put("starterPrompts", reason == null ? starterPrompts(persona) : List.of());
result.put("profile", profile);
if(mayQueryConversationsTable())
{
result.put("conversationsTable", AssistantConversation.TABLE_NAME);
}
return (result);
}
/*******************************************************************************
** The example prompts for this user (see StarterPrompts): built from the
** tables the user may read, plus the configured ones they may see. Never
** throws.
*******************************************************************************/
private List<String> starterPrompts(ProfileService.Persona persona)
{
try
{
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(false);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials);
boolean historyOffered = runtime.getToolbox().definitions(policy.tools(), false).stream().anyMatch(d -> UiApiToolbox.RECORD_HISTORY.equals(d.getName()));
return (runtime.getStarterPrompts().forUser(context, userId, persona.starterPrompts(), historyOffered));
}
catch(QException | RuntimeException e)
{
return (List.of());
}
}
/*******************************************************************************
** GET /conversations
*******************************************************************************/
@@ -182,10 +239,13 @@ public class AssistantApi
{
requireAccess();
String effective = "ARCHIVED".equalsIgnoreCase(status) ? "ARCHIVED" : "ACTIVE";
Set<Integer> running = runningConversationIds();
List<Map<String, Object>> list = new ArrayList<>();
for(AssistantConversation conversation : store.listConversations(userId, effective))
{
list.add(toJson(conversation));
Map<String, Object> item = toJson(conversation);
item.put("running", running.contains(conversation.getId()));
list.add(item);
}
return (Map.of("conversations", list));
}
@@ -315,6 +375,18 @@ public class AssistantApi
update.setValue("status", status);
conversation.setStatus((String) status);
}
if(body.containsKey("read"))
{
////////////////////////////////////////////////////////////////////
// the panel watched the reply stream in: clear the unread count //
////////////////////////////////////////////////////////////////////
if(!Boolean.TRUE.equals(body.get("read")))
{
throw (new AssistantApiException(400, "read can only be true."));
}
update.setValue("unreadCount", 0);
conversation.setUnreadCount(0);
}
store.updateFields(AssistantConversation.TABLE_NAME, update);
return (toJson(conversation));
@@ -379,11 +451,11 @@ public class AssistantApi
ProviderService.Active active = runtime.getProviderService().resolve(policy.mainModel());
if(active == null)
{
throw (new AssistantApiException(503, "NOT_CONFIGURED", "The assistant is not configured on this server."));
throw (new AssistantApiException(503, NOT_CONFIGURED, NO_MODEL_PROVIDER_MESSAGE));
}
if(policy.killSwitch())
{
throw (new AssistantApiException(503, "KILL_SWITCH", "The assistant has been turned off by an administrator."));
throw (new AssistantApiException(503, "KILL_SWITCH", KILL_SWITCH_MESSAGE));
}
Long capMicros = ProviderService.capMicros(active.row());
@@ -480,21 +552,6 @@ public class AssistantApi
RunningTurn runningTurn = new RunningTurn(turn.getId(), userId);
TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
budget.softExceeded() ? budget.message() : null).withCredentials(credentials).withTranscript(transcript);
try
{
if(config.getRunTracker() != null)
{
///////////////////////////////////////////////////////////////////
// never the conversation title: it is derived from the prompt //
///////////////////////////////////////////////////////////////////
runningTurn.setTrackingId(config.getRunTracker().start(userId, turn.getId(), conversationId, "Assistant answer"));
config.getRunTracker().onCancel(runningTurn.getTrackingId(), () -> runningTurn.cancel("USER"));
}
}
catch(Exception e)
{
LOG.warn("Assistant run tracker start failed", logPair("turnId", turn.getId()));
}
runtime.register(runningTurn);
if(!runningTurn.isFinalized())
{
@@ -669,6 +726,53 @@ public class AssistantApi
/*******************************************************************************
** Ids of the user's conversations with an answer in progress: a RUNNING or
** CANCELLING turn that is not orphaned (orphans are recovered on the way,
** as in GET /turns/active).
*******************************************************************************/
Set<Integer> runningConversationIds() throws QException
{
Set<Integer> ids = new HashSet<>();
for(AssistantTurn turn : store.listRunningTurns(userId))
{
turn = reapIfOrphaned(turn);
if(isRunning(turn) && turn.getConversationId() != null)
{
ids.add(turn.getConversationId());
}
}
return (ids);
}
/*******************************************************************************
** Whether a turn row (of any owner) is an answer still in progress: running
** here, or running elsewhere and not orphaned. Read-only (no recovery).
*******************************************************************************/
boolean inProgress(AssistantTurn turn)
{
return (isRunning(turn) && (runtime.getRunningTurn(turn.getId()) != null || !orphaned(turn)));
}
/*******************************************************************************
** Whether this session may query the conversation table in the standard
** table UI (assistant access or admin; rows stay owner-locked).
*******************************************************************************/
private boolean mayQueryConversationsTable()
{
if(userId == null || QContext.getQInstance() == null || QContext.getQInstance().getTable(AssistantConversation.TABLE_NAME) == null)
{
return (false);
}
return (PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, TablePermissionSubType.READ));
}
/*******************************************************************************
** A remote run loses its lease, exceeds the absolute deadline, or has a
** cancellation request that its owner did not acknowledge in time.
@@ -919,6 +1023,13 @@ public class AssistantApi
{
rs.put("href", outcome.getAuditHref());
}
else if(!result.getRecordRefs().isEmpty() && TurnEngine.isAppHref(result.getRecordRefs().get(0).getHref()))
{
//////////////////////////////////////////////////////////////
// e.g. the job a confirmed bulk load launched //
//////////////////////////////////////////////////////////////
rs.put("href", result.getRecordRefs().get(0).getHref());
}
return (rs);
}
@@ -1080,11 +1191,33 @@ public class AssistantApi
{
if(!config.isEffectivelyEnabled())
{
throw (new AssistantApiException(503, "DISABLED", "The assistant is turned off for this application."));
throw (new AssistantApiException(503, "DISABLED", DISABLED_MESSAGE));
}
if(!hasAccess())
{
throw (new AssistantApiException(403, "NO_PERMISSION", "You do not have access to the assistant."));
if(activeProviderOrNull() == null)
{
throw (new AssistantApiException(503, NOT_CONFIGURED, NO_MODEL_PROVIDER_MESSAGE));
}
throw (new AssistantApiException(403, "NO_PERMISSION", NO_PERMISSION_MESSAGE));
}
}
/*******************************************************************************
** The active provider, or null when none is usable or it cannot be read
** (only used to pick the clearer refusal).
*******************************************************************************/
private ProviderService.Active activeProviderOrNull()
{
try
{
return (activeProvider());
}
catch(QException | RuntimeException e)
{
return (null);
}
}
@@ -0,0 +1,163 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.api;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** The conversation table as users use it in the standard table UI (their own
** rows, through the owner record lock):
** - update: only title, pinned and status (ACTIVE / ARCHIVED) can change;
** the other fields (owner, classification, counters, context) are the
** assistant's own. A new title is encrypted (HIPAA mode) as the panel's
** writes are; a stored, still-encrypted title sent back unchanged is kept.
** - delete: refused while the conversation has an answer in progress (as
** DELETE /conversations/{id}); afterwards its child rows are deleted the
** same way that route deletes them, so nothing is orphaned.
** The assistant's own writes (system input source) pass through unchanged.
*******************************************************************************/
public class AssistantConversationCustomizer implements TableCustomizerInterface
{
public static final Set<String> USER_EDITABLE_FIELDS = Set.of("title", "pinned", "status");
public static final String IN_PROGRESS = "This conversation has an answer in progress. Stop it first.";
/*******************************************************************************
** Users change only title, pinned and status.
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
if(updateInput.getInputSource() == QInputSource.SYSTEM)
{
return (records);
}
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
for(QRecord record : records)
{
for(String field : new ArrayList<>(record.getValues().keySet()))
{
if(!"id".equals(field) && !USER_EDITABLE_FIELDS.contains(field))
{
record.removeValue(field);
}
}
Serializable status = record.getValue("status");
if(record.getValues().containsKey("status") && !"ACTIVE".equals(status) && !"ARCHIVED".equals(status))
{
record.addError(new BadInputStatusMessage("Status must be Active or Archived."));
}
if(!isPreview && runtime != null && record.getValue("title") instanceof String title)
{
boolean unchangedStoredCiphertext = AssistantStore.isEncryptedValue(title) && record.getValueInteger("id") != null
&& oldRecordList.orElse(List.of()).stream().anyMatch(oldRecord -> record.getValueInteger("id").equals(oldRecord.getValueInteger("id"))
&& title.equals(oldRecord.getValueString("title")));
if(!unchangedStoredCiphertext)
{
record.setValue("title", runtime.getStore().encryptForStorage(AssistantConversation.TABLE_NAME, new QRecord().withValue("title", title)).getValue("title"));
}
}
}
return (records);
}
/*******************************************************************************
** A conversation with an answer in progress is not deleted.
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime == null || deleteInput.getInputSource() == QInputSource.SYSTEM || records.isEmpty())
{
return (records);
}
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
for(QRecord record : records)
{
if(hasAnswerInProgress(runtime, api, record.getValueInteger("id")))
{
record.addError(new BadInputStatusMessage(IN_PROGRESS));
}
}
return (records);
}
/*******************************************************************************
** Delete the deleted conversations' child rows, as the owner's rows.
*******************************************************************************/
@Override
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime != null)
{
for(QRecord record : records)
{
if(record.getValueInteger("id") != null && record.getValueString("userId") != null)
{
runtime.getStore().deleteConversationChildren(record.getValueString("userId"), record.getValueInteger("id"));
}
}
}
return (records);
}
/*******************************************************************************
** Whether a conversation (of any owner the session can see) has a running
** or cancelling turn that is not orphaned.
*******************************************************************************/
public static boolean hasAnswerInProgress(AssistantRuntime runtime, AssistantApi api, Integer conversationId) throws QException
{
if(conversationId == null)
{
return (false);
}
List<AssistantTurn> turns = runtime.getStore().query(AssistantTurn.TABLE_NAME, new QQueryFilter()
.withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId)
.withCriteria("status", QCriteriaOperator.IN, List.of("RUNNING", "CANCELLING")), AssistantTurn.class);
return (turns.stream().anyMatch(api::inProgress));
}
}
@@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus;
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.5.0";
public static final String EXTENSION_VERSION = "1.7.0";
public static final String PREFIX = "/assistant";
@@ -195,20 +195,24 @@ public final class AssistantSpecs
Map<String, Schema> rs = new LinkedHashMap<>();
rs.put("AssistantProfileV1", object("How the assistant presents itself (admin-configured; text only, it never changes access).",
"displayName", string("The assistant's name."), "icon", string("A Material icon name."), "greeting", string("Shown on a new conversation.").withNullable(true),
"starterPrompts", array(string(null), "Suggested first questions.")));
"starterPrompts", array(string(null), "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request.")));
rs.put("AssistantConfigResponseV1", object("Whether the assistant is available to this user, and how.",
"enabled", bool("Whether the panel may be used."),
"reason", string("Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH.").withNullable(true),
"reason", string("Why not: DISABLED, NOT_CONFIGURED (no usable model provider on the server), NO_PERMISSION or KILL_SWITCH, checked in that order.").withNullable(true),
"message", string("Since 1.7.0: when reason is set, a sentence for the user that names the cause (for NOT_CONFIGURED, what an administrator configures).").withNullable(true),
"shortcut", string("Keyboard shortcut, e.g. mod+j."),
"model", string("The model that answers."),
"limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn. When features.attachments is true also maxAttachmentBytes (the largest file read, in bytes), "
+ "maxAttachments (files per question) and attachmentTypes (human-readable type names for the file picker hint; images only when the model reads them)."),
"features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation; attachments: files uploaded with POST /qqq/v1/uploads "
+ "and sent as pageContext.attachments [{uploadRef, fileName}] are read for the question."),
"profile", ref("AssistantProfileV1")));
"profile", ref("AssistantProfileV1"),
"conversationsTable", string("Since 1.6.0: the table name of the user's conversations (assistant_conversation) for the standard table UI "
+ "(manage, archive, restore and delete); present only when this session may query that table.").withNullable(true)));
rs.put("AssistantConversationV1", object("One of the user's own conversations.",
"id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null),
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true)));
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true),
"running", bool("Since 1.6.0, in GET /conversations items only: whether an answer is in progress (a RUNNING or CANCELLING turn that is not orphaned).").withNullable(true)));
rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null)));
rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).",
"id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true),
@@ -818,7 +822,7 @@ public final class AssistantSpecs
@Override
protected String summary()
{
return ("Rename, pin or archive a conversation");
return ("Rename, pin, archive or mark read a conversation");
}
@@ -834,7 +838,8 @@ public final class AssistantSpecs
@Override
protected Schema defineRequestBodySchema()
{
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED.")));
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."),
"read", bool("Since 1.6.0: true sets unreadCount to 0 (the user watched the reply stream in). Only true is accepted.")));
}
@@ -40,7 +40,6 @@ public class RunningTurn
private volatile Thread engineThread;
private volatile Consumer<String> forceFinalizer;
private volatile long cancelledNanos;
private volatile String trackingId;
private volatile StreamHandle currentHandle;
@@ -264,24 +263,4 @@ public class RunningTurn
return (isCancelled() && cancelledNanos != 0 && System.nanoTime() - cancelledNanos >= seconds * 1_000_000_000L);
}
/*******************************************************************************
** Getter for trackingId
*******************************************************************************/
public String getTrackingId()
{
return (trackingId);
}
/*******************************************************************************
** Setter for trackingId
*******************************************************************************/
public void setTrackingId(String trackingId)
{
this.trackingId = trackingId;
}
}
@@ -0,0 +1,498 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The example prompts the panel offers on a new conversation, built per user
** from what that user can actually reach.
**
** The tables come from the same tool the model uses (list_tables, which is the
** UI API's metadata narrowed by data access), the row counts and fields from
** count_records and describe_table, all with the user's own session. So a
** prompt can only name a table the user may read through the assistant, and
** only one that has rows.
**
** Configured profile prompts are kept only when they declare the tables they
** need, as a leading tag: {@code [blog_post] Which posts are drafts?} or
** {@code [blog_post, page] ...}, and the user can read every one. A line with
** {@code [none]} names no table and always stays. A line without a tag is
** dropped, because there is no way to tell which tables it mentions.
**
** The result is stable for a user for the day (the pick is seeded by user id
** and date, not random), rechecked on every request, bounded in work and time,
** and never throws: any failure yields no generated prompts.
*******************************************************************************/
public class StarterPrompts
{
private static final QLogger LOG = QLogger.getLogger(StarterPrompts.class);
public static final int MAX_PROMPTS = 3;
public static final int MAX_COUNT_CALLS = 6;
public static final int MAX_DESCRIBE_CALLS = 3;
public static final Duration CALL_TIMEOUT = Duration.ofSeconds(3);
public static final Duration TOTAL_BUDGET = Duration.ofSeconds(8);
public static final String NO_TABLE_TAG = "none";
private static final int MAX_PROMPT_CHARS = 200;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final UiApiToolbox toolbox;
private final Clock clock;
/*******************************************************************************
** What was worked out for a user: the tables they may read and the
** generated prompts for this request.
*******************************************************************************/
private record PromptSelection(Set<String> readableTables, List<String> generated)
{
}
/*******************************************************************************
** A readable table: its name and its singular label.
*******************************************************************************/
private record TableRef(String name, String label)
{
}
/*******************************************************************************
** A table that has rows, with its date field (if any).
*******************************************************************************/
private record TableWithRows(TableRef table, String dateField)
{
}
/*******************************************************************************
**
*******************************************************************************/
public StarterPrompts(UiApiToolbox toolbox, Clock clock)
{
this.toolbox = toolbox;
this.clock = clock;
}
/*******************************************************************************
** The prompts for this user: the configured lines they may see, then
** generated ones up to {@link #MAX_PROMPTS} in all. Never throws.
**
** @param context where the calls run: the user's session and the policy
** @param userId the daily selection seed; with none, there are no generated prompts
** @param configured the profile's starter lines, tags included
** @param historyOffered whether the change-history tool is available
*******************************************************************************/
public List<String> forUser(UiApiToolbox.Context context, String userId, List<String> configured, boolean historyOffered)
{
try
{
PromptSelection entry = entryFor(context, userId, historyOffered);
List<String> rs = new ArrayList<>();
for(String line : configured == null ? List.<String>of() : configured)
{
if(rs.size() >= MAX_PROMPTS)
{
break;
}
String prompt = allowedConfigured(line, entry.readableTables());
if(prompt != null)
{
rs.add(prompt);
}
}
for(String generated : entry.generated())
{
if(rs.size() >= MAX_PROMPTS)
{
break;
}
rs.add(generated);
}
return (rs);
}
catch(RuntimeException e)
{
LOG.warn("Could not build the starter prompts; offering none", e);
return (List.of());
}
}
/*******************************************************************************
** Recompute eligibility with the current session, permissions and data.
** Table, row and field access may change without a new session or user id.
*******************************************************************************/
private PromptSelection entryFor(UiApiToolbox.Context context, String userId, boolean historyOffered)
{
Instant now = clock.instant();
LocalDate today = LocalDate.ofInstant(now, ZoneOffset.UTC);
if(userId == null)
{
return (new PromptSelection(Set.of(), List.of()));
}
try
{
return (build(limited(context), userId, today, now, historyOffered));
}
catch(RuntimeException e)
{
LOG.warn("Could not read what the user can access for starter prompts", e);
return (new PromptSelection(Set.of(), List.of()));
}
}
/*******************************************************************************
** The same context with every call held to a short timeout.
*******************************************************************************/
private static UiApiToolbox.Context limited(UiApiToolbox.Context context)
{
UiApiGateway inner = context.gateway();
UiApiGateway gateway = new UiApiGateway()
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (inner.isInvocable(method, pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
return (inner.invoke(call.withTimeout(CALL_TIMEOUT)));
}
};
return (new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), gateway, context.policy(), null, context.credentials(), null));
}
/*******************************************************************************
** Work out the readable tables and the generated prompts.
*******************************************************************************/
private PromptSelection build(UiApiToolbox.Context context, String userId, LocalDate today, Instant now, boolean historyOffered)
{
Instant deadline = now.plus(TOTAL_BUDGET);
List<TableRef> readable = readableTables(context);
Set<String> names = new LinkedHashSet<>();
readable.forEach(t -> names.add(t.name()));
List<TableRef> order = new ArrayList<>(readable);
order.sort(Comparator.comparingInt((TableRef t) -> seed(userId, today, t.name())).thenComparing(TableRef::name));
List<TableWithRows> withRows = new ArrayList<>();
int counts = 0;
for(TableRef table : order)
{
if(counts >= MAX_COUNT_CALLS || withRows.size() >= MAX_DESCRIBE_CALLS || clock.instant().isAfter(deadline))
{
break;
}
counts++;
if(hasRows(context, table))
{
withRows.add(new TableWithRows(table, clock.instant().isAfter(deadline) ? null : dateField(context, table)));
}
}
return (new PromptSelection(names, promptsFor(withRows, userId, today, historyOffered)));
}
/*******************************************************************************
** The tables the user may read through the assistant (list_tables).
*******************************************************************************/
private List<TableRef> readableTables(UiApiToolbox.Context context)
{
UiApiToolbox.Outcome outcome = toolbox.execute(context, "starter-list", UiApiToolbox.LIST_TABLES, Map.of());
Map<String, Object> data = json(outcome);
List<TableRef> rs = new ArrayList<>();
if(data != null && data.get("tables") instanceof List<?> tables)
{
for(Object item : tables)
{
if(item instanceof Map<?, ?> table && table.get("name") instanceof String name && StringUtils.hasContent(name))
{
String label = table.get("label") instanceof String l && StringUtils.hasContent(l) ? l.strip() : name;
rs.add(new TableRef(name, label));
}
}
}
return (rs);
}
/*******************************************************************************
** Whether the table has any row for this user (a count, as the user).
*******************************************************************************/
private boolean hasRows(UiApiToolbox.Context context, TableRef table)
{
Map<String, Object> filter = new LinkedHashMap<>();
filter.put("booleanOperator", "AND");
filter.put("criteria", List.of());
UiApiToolbox.Outcome outcome = toolbox.execute(context, "starter-count", UiApiToolbox.COUNT_RECORDS, Map.of("table", table.name(), "filter", filter));
if(outcome.getResult().getOutcome() != AiToolOutcome.OK || outcome.getResult().getRender() == null)
{
return (false);
}
return (outcome.getResult().getRender().get("count") instanceof Number n && n.longValue() > 0);
}
/*******************************************************************************
** The table's date-time field whose values the assistant may read (create
** date first), else a date field, else null.
*******************************************************************************/
private String dateField(UiApiToolbox.Context context, TableRef table)
{
UiApiToolbox.Outcome outcome = toolbox.execute(context, "starter-describe", UiApiToolbox.DESCRIBE_TABLE, Map.of("table", table.name()));
Map<String, Object> data = json(outcome);
if(data == null || !(data.get("fields") instanceof List<?> fields))
{
return (null);
}
String dateTime = null;
String date = null;
for(Object item : fields)
{
if(item instanceof Map<?, ?> field && field.get("name") instanceof String name && field.get("type") instanceof String type && !field.containsKey("values"))
{
String normalized = type.replace("_", "").toLowerCase(Locale.ROOT);
if("datetime".equals(normalized) && (dateTime == null || "createDate".equals(name)))
{
dateTime = name;
}
else if("date".equals(normalized) && date == null)
{
date = name;
}
}
}
return (dateTime != null ? dateTime : date);
}
/*******************************************************************************
** The tool result's JSON body (inside the data envelope), or null.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> json(UiApiToolbox.Outcome outcome)
{
if(outcome.getResult().getOutcome() != AiToolOutcome.OK || outcome.getResult().getModelContent() == null)
{
return (null);
}
String content = outcome.getResult().getModelContent();
int start = content.indexOf('{');
int end = content.lastIndexOf('}');
if(start < 0 || end <= start)
{
return (null);
}
try
{
return (MAPPER.readValue(content.substring(start, end + 1), Map.class));
}
catch(Exception e)
{
return (null);
}
}
/*******************************************************************************
** Up to MAX_PROMPTS prompts: one per table first (in the user's daily
** order), then more from the same tables when there are too few tables.
*******************************************************************************/
private static List<String> promptsFor(List<TableWithRows> tables, String userId, LocalDate today, boolean historyOffered)
{
List<List<String>> candidates = new ArrayList<>();
for(TableWithRows table : tables)
{
String plural = plural(table.table().label());
List<String> list = new ArrayList<>();
if(table.dateField() != null)
{
list.add("How many " + plural + " were added in the last 7 days?");
list.add("Show my most recent " + plural);
}
else
{
list.add("How many " + plural + " are there?");
}
if(historyOffered)
{
list.add("What changed in " + plural + " this week?");
}
Collections.rotate(list, -Math.floorMod(seed(userId, today, table.table().name()), list.size()));
candidates.add(list);
}
List<String> rs = new ArrayList<>();
for(int round = 0; rs.size() < MAX_PROMPTS; round++)
{
boolean added = false;
for(List<String> list : candidates)
{
if(round < list.size() && rs.size() < MAX_PROMPTS)
{
rs.add(truncate(list.get(round)));
added = true;
}
}
if(!added)
{
break;
}
}
return (rs);
}
/*******************************************************************************
** The configured line without its tag when the user may see it, else null.
*******************************************************************************/
static String allowedConfigured(String line, Set<String> readableTables)
{
if(line == null)
{
return (null);
}
String text = line.strip();
int close = text.indexOf(']');
if(!text.startsWith("[") || close < 0)
{
return (null);
}
String prompt = text.substring(close + 1).strip();
if(prompt.isEmpty())
{
return (null);
}
List<String> tags = new ArrayList<>();
for(String tag : text.substring(1, close).split(","))
{
if(StringUtils.hasContent(tag))
{
tags.add(tag.strip());
}
}
if(tags.size() == 1 && NO_TABLE_TAG.equalsIgnoreCase(tags.get(0)))
{
return (truncate(prompt));
}
if(tags.isEmpty() || !readableTables.containsAll(tags))
{
return (null);
}
return (truncate(prompt));
}
/*******************************************************************************
** A label as a lower-case plural ("Blog Post" becomes "blog posts").
*******************************************************************************/
static String plural(String label)
{
String word = label == null ? "" : label.strip().toLowerCase(Locale.ROOT);
if(word.isEmpty() || word.endsWith("s") || word.endsWith("media") || word.endsWith("data"))
{
return (word);
}
if(word.endsWith("x") || word.endsWith("ch") || word.endsWith("sh") || word.endsWith("z"))
{
return (word + "es");
}
if(word.length() > 1 && word.endsWith("y") && "aeiou".indexOf(word.charAt(word.length() - 2)) < 0)
{
return (word.substring(0, word.length() - 1) + "ies");
}
return (word + "s");
}
/*******************************************************************************
** A stable pseudo-random number for a user, a day and a name.
*******************************************************************************/
private static int seed(String userId, LocalDate day, String name)
{
return ((userId + "|" + day + "|" + name).hashCode());
}
/*******************************************************************************
**
*******************************************************************************/
private static String truncate(String value)
{
return (value.length() <= MAX_PROMPT_CHARS ? value : value.substring(0, MAX_PROMPT_CHARS));
}
}
@@ -792,8 +792,8 @@ public class TurnEngine implements Runnable
}
///////////////////////////////////////////////////////////////////////
// providers stream on their own (virtual) threads, which have no //
// QContext: run the delta handler in the turn's context, or progress //
// writes (JobLogWriter) fail with no instance //
// QContext: run the delta handler in the turn's context, so phase //
// and text events are recorded with the turn's instance and session //
///////////////////////////////////////////////////////////////////////
CapturedContext turnContext = QContext.capture();
ModelEventListener listener = new ModelEventListener()
@@ -1343,6 +1343,17 @@ public class TurnEngine implements Runnable
String finalErrorText = errorText;
safely(() -> emit(TurnEvent.ERROR, map("code", finalErrorCode, "message", finalErrorText)));
}
///////////////////////////////////////////////////////////////////////
// the stream ends at message.end and the panel reloads the //
// conversation at once, so the redacted transcript (message parts, //
// pending actions, events) must be stored before that event is sent //
///////////////////////////////////////////////////////////////////////
if(!alreadyTerminal)
{
safely(this::persistPendingEvents);
safely(() -> transcript.flush(store));
}
safely(() -> emit(TurnEvent.MESSAGE_END, map("messageId", assistantMessage.getId(), "status", finalStatus, "stopReason", finalStopReason)));
if(!alreadyTerminal)
{
@@ -1418,30 +1429,21 @@ public class TurnEngine implements Runnable
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
}
});
String finalStatus = status;
String finalText = errorText;
try
runtime.unregister(turn.getId());
runtime.getRateLimiter().release(userId);
if(transcript != null && !engineRunning)
{
safely(() -> config.getRunTracker().finish(running.getTrackingId(), finalStatus, finalText));
transcript.close();
}
finally
else
{
runtime.unregister(turn.getId());
runtime.getRateLimiter().release(userId);
if(transcript != null && !engineRunning)
{
transcript.close();
}
else
{
// A wedged provider can still log after forced finalization. The
// engine's finally releases its log protection when it returns.
TranscriptRedactor.detach();
}
if(previousTranscript != null && previousTranscript != transcript)
{
previousTranscript.attach();
}
// A wedged provider can still log after forced finalization. The
// engine's finally releases its log protection when it returns.
TranscriptRedactor.detach();
}
if(previousTranscript != null && previousTranscript != transcript)
{
previousTranscript.attach();
}
}
}
@@ -1491,7 +1493,6 @@ public class TurnEngine implements Runnable
running.setPhase(phase, toolName);
String label = toolName == null ? phase : ToolLabels.running(toolName);
emit(TurnEvent.TURN_PHASE, map("phase", phase, "toolName", toolName, "label", label));
safely(() -> config.getRunTracker().progress(running.getTrackingId(), phase, label));
}
@@ -1,80 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.jobs;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Publishes assistant answers as jobs. The default, JobTrackingRunTracker,
** uses QQQ core job tracking (the Jobs tray); NOOP turns it off, and hosts
** may wire their own. Every call is isolated from the turn: a failure here
** never affects the answer.
*******************************************************************************/
public interface AssistantRunTracker
{
AssistantRunTracker NOOP = new AssistantRunTracker()
{
/*******************************************************************************
** No tracking is configured.
*******************************************************************************/
@Override
public String start(String userId, Integer turnId, Integer conversationId, String label)
{
return (null);
}
/*******************************************************************************
** No tracking is configured.
*******************************************************************************/
@Override
public void progress(String trackingId, String phase, String message)
{
}
/*******************************************************************************
** No tracking is configured.
*******************************************************************************/
@Override
public void finish(String trackingId, String status, String message)
{
}
};
/*******************************************************************************
** Publish a run and return its tracking id.
*******************************************************************************/
String start(String userId, Integer turnId, Integer conversationId, String label) throws QException;
/*******************************************************************************
** Publish a changed phase.
*******************************************************************************/
void progress(String trackingId, String phase, String message);
/*******************************************************************************
** Publish the terminal outcome.
*******************************************************************************/
void finish(String trackingId, String status, String message) throws QException;
/*******************************************************************************
** Wire the host's Stop action to the owner's cancellation path.
*******************************************************************************/
default void onCancel(String trackingId, Runnable cancel) throws QException
{
}
}
@@ -1,170 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.jobs;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.jobs.model.JobStatus;
import com.kingsrook.qqq.backend.core.jobs.progress.JobPhase;
import com.kingsrook.qqq.backend.core.jobs.progress.JobProgress;
import com.kingsrook.qqq.backend.core.jobs.progress.JobTracking;
import com.kingsrook.qqq.backend.core.jobs.progress.TrackedJobRequest;
import com.kingsrook.qqq.backend.core.jobs.runtime.DurableJobsRuntime;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Publishes each assistant answer to the Jobs tray as a QQQ tracking job
** (core JobTracking, qqq#1210): processName "assistant.turn", phases
** thinking / tool / writing, the tool's label as the message, and the tray's
** Stop wired to the turn's own Stop. Never prompt or answer text. When the
** durable jobs runtime is not running on this pod, answers are simply not
** tracked.
*******************************************************************************/
public class JobTrackingRunTracker implements AssistantRunTracker
{
public static final String PROCESS_NAME = "assistant.turn";
public static final String LABEL = "Assistant answer";
private final Map<String, JobProgress> reporters = new ConcurrentHashMap<>();
/*******************************************************************************
** Start a tracking job linked to the conversation, or null when the jobs
** runtime is not running here.
*******************************************************************************/
@Override
public String start(String userId, Integer turnId, Integer conversationId, String label) throws QException
{
QInstance qInstance = QContext.getQInstance();
if(qInstance == null || DurableJobsRuntime.get(qInstance) == null)
{
return (null);
}
Integer jobId = JobTracking.start(new TrackedJobRequest()
.withUserId(userId)
.withLabel(LABEL)
.withProcessName(PROCESS_NAME)
.withTableName(AssistantConversation.TABLE_NAME)
.withRecordId(conversationId));
String trackingId = String.valueOf(jobId);
reporters.put(trackingId, JobTracking.progress(jobId).phases(
JobPhase.of("thinking", "Thinking", 1),
JobPhase.of("tool", "Using a tool", 1),
JobPhase.of("writing", "Writing", 1)));
return (trackingId);
}
/*******************************************************************************
** The current phase, with the tool's label as the message.
*******************************************************************************/
@Override
public void progress(String trackingId, String phase, String message)
{
JobProgress reporter = trackingId == null ? null : reporters.get(trackingId);
if(reporter == null || !("thinking".equals(phase) || "tool".equals(phase) || "writing".equals(phase)))
{
return;
}
reporter.phase(phase);
if("tool".equals(phase) && message != null)
{
reporter.message(message);
reporter.flush();
}
}
/*******************************************************************************
** COMPLETE -> COMPLETED, CANCELLED -> CANCELLED, anything else -> FAILED.
*******************************************************************************/
@Override
public void finish(String trackingId, String status, String message) throws QException
{
if(trackingId == null)
{
return;
}
JobProgress reporter = reporters.remove(trackingId);
if(reporter != null)
{
reporter.flush();
}
JobTracking.finish(Integer.valueOf(trackingId), jobStatus(status), finishMessage(status));
}
/*******************************************************************************
** The tray's Stop runs on this pod and stops the turn; the turn's own
** finaliser then finishes the job as CANCELLED.
*******************************************************************************/
@Override
public void onCancel(String trackingId, Runnable cancel) throws QException
{
if(trackingId != null)
{
JobTracking.onCancel(Integer.valueOf(trackingId), cancel);
}
}
/*******************************************************************************
** The jobs status for an assistant turn status.
*******************************************************************************/
static JobStatus jobStatus(String status)
{
if("COMPLETE".equals(status))
{
return (JobStatus.COMPLETED);
}
if("CANCELLED".equals(status))
{
return (JobStatus.CANCELLED);
}
return (JobStatus.FAILED);
}
/*******************************************************************************
** A fixed message (never the answer, the prompt or a provider error).
*******************************************************************************/
static String finishMessage(String status)
{
if("COMPLETE".equals(status))
{
return ("Answered.");
}
if("CANCELLED".equals(status))
{
return ("Stopped.");
}
return ("The answer did not finish.");
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.metadata;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Guards what every assistant user may use in the standard table UI (their
** own conversations and the conversation record actions): the user needs
** assistant access ("assistant.hasAccess") or "assistant.admin". Which rows
** they see is decided by the owner record lock, not by this checker.
*******************************************************************************/
public class AssistantAccessPermissionChecker implements CustomPermissionChecker
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(session == null || !(session.hasPermission(AssistantQBitConfig.ACCESS_PERMISSION) || session.hasPermission(AssistantQBitConfig.ADMIN_PERMISSION)))
{
throw (new QPermissionDeniedException("Not authorized to use the assistant."));
}
}
}
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.api.AssistantConversationCustomizer;
import com.kingsrook.qbits.assistant.health.AssistantHealthWidget;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHome;
@@ -90,7 +91,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** - The key type's all-access key (assistant_user_id_all_access) lets admins see
** everyone's rows; hosts grant it to users holding assistant.admin with
** AssistantSecurity.applySessionKeys in their authentication customizer.
** - Tables, app and process need assistant.admin in the generic UI / API.
** - Tables, app and process need assistant.admin in the generic UI / API,
** except assistant_conversation and its record actions (archive, restore,
** delete), which every assistant user may use on their own rows. The app
** (its nav) stays admin-only.
** - assistant_usage_daily rows with a null userId are the INSTANCE rollup and
** are readable by any session (the lock allows null).
** - The assistant's own tool kernel never exposes assistant_* tables to the
@@ -160,10 +164,15 @@ public class AssistantMetaDataBuilder
{
List<QTableMetaData> tables = new ArrayList<>();
QCodeReference conversationCustomizer = new QCodeReference(AssistantConversationCustomizer.class);
tables.add(owned(base(AssistantConversation.TABLE_NAME, "Assistant Conversation", "forum", AssistantConversation.class), false)
.withRecordLabelFormat("%s")
.withRecordLabelFields("title")
.withoutCapabilities(Capability.TABLE_INSERT));
.withoutCapabilities(Capability.TABLE_INSERT)
.withPermissionRules(accessRules())
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, conversationCustomizer)
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, conversationCustomizer)
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, conversationCustomizer));
text(tables.get(0), "contextJson");
tables.add(text(readOnly(owned(base(AssistantMessage.TABLE_NAME, "Assistant Message", "chat", AssistantMessage.class), false)), "partsJson")
@@ -415,6 +424,21 @@ public class AssistantMetaDataBuilder
/*******************************************************************************
** assistant access or assistant.admin, through the custom checker (explicit
** level and deny behavior, as in adminRules): for the conversation table
** and its record actions, which users use on their own (record-locked) rows.
*******************************************************************************/
public static QPermissionRules accessRules()
{
return (new QPermissionRules()
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withDenyBehavior(DenyBehavior.HIDDEN)
.withCustomPermissionChecker(new QCodeReference(AssistantAccessPermissionChecker.class)));
}
/*******************************************************************************
**
*******************************************************************************/
@@ -313,6 +313,20 @@ public class AssistantStore
** Delete a conversation and everything under it.
*******************************************************************************/
public void deleteConversation(String userId, Integer conversationId) throws QException
{
deleteConversationChildren(userId, conversationId);
delete(AssistantConversation.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, conversationId));
}
/*******************************************************************************
** Delete a conversation's child rows (turns and their events, messages,
** tool calls, feedback, data audits, pending actions), not the conversation
** itself. A table delete of the conversation calls this afterwards, so
** nothing is orphaned on a database without the cascading foreign keys.
*******************************************************************************/
public void deleteConversationChildren(String userId, Integer conversationId) throws QException
{
List<Serializable> turnIds = new ArrayList<>();
for(AssistantTurn turn : query(AssistantTurn.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId), AssistantTurn.class))
@@ -329,7 +343,6 @@ public class AssistantStore
}
delete(AssistantDataAudit.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
delete(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
delete(AssistantConversation.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, conversationId));
}
@@ -958,6 +971,27 @@ public class AssistantStore
/*******************************************************************************
** A copy of a record with the stored-encrypted fields encrypted (HIPAA
** mode), for writes that do not go through this store (the table UI).
*******************************************************************************/
public QRecord encryptForStorage(String tableName, QRecord record)
{
return (cryptRecord(tableName, record, true));
}
/*******************************************************************************
** Whether a stored value is in the encrypted form (HIPAA mode).
*******************************************************************************/
public static boolean isEncryptedValue(String value)
{
return (ConversationEncryption.isEncrypted(value));
}
/*******************************************************************************
** Copy records at the storage boundary so memory-backend records and callers are never mutated.
*******************************************************************************/
@@ -89,6 +89,16 @@ final class ConversationEncryption
/*******************************************************************************
** Whether a stored value is in this codec's encrypted form.
*******************************************************************************/
public static boolean isEncrypted(String value)
{
return (value != null && value.startsWith(PREFIX));
}
/*******************************************************************************
** Always encrypt plaintext, even strings that happen to start with enc:v1:.
*******************************************************************************/
@@ -0,0 +1,302 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantConversationCustomizer;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Archive conversations", "Restore conversations" and "Delete conversations":
** standard streamed-ETL record actions on assistant_conversation. The
** selected records come in through the user's own query (so the owner
** record lock decides which rows they act on), Review says what will
** happen, Result what happened. Same permission as the table: assistant
** access or assistant.admin. Table actions, never navigation entries.
** Delete removes a conversation with its messages, turns, events, tool
** calls, feedback and pending actions, and skips one whose answer is still
** in progress, as DELETE /conversations/{id} does.
*******************************************************************************/
public final class ConversationActionsProcess
{
public static final String ARCHIVE = "assistantConversationArchive";
public static final String RESTORE = "assistantConversationRestore";
public static final String DELETE = "assistantConversationDelete";
/*******************************************************************************
** What a process does with one selected conversation.
*******************************************************************************/
enum Outcome
{
ACT,
SKIP,
IN_PROGRESS
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ConversationActionsProcess()
{
}
/*******************************************************************************
** The three processes.
*******************************************************************************/
public static List<QProcessMetaData> processes()
{
return (List.of(
process(ARCHIVE, "Archive conversations", "archive", "The selected conversations will be archived. They leave the assistant's list; nothing is deleted."),
process(RESTORE, "Restore conversations", "unarchive", "The selected archived conversations will return to the assistant's list."),
process(DELETE, "Delete conversations", "delete", "The selected conversations will be deleted with their messages. This cannot be undone.")));
}
/*******************************************************************************
** One standard streamed-ETL record action on the conversation table.
*******************************************************************************/
private static QProcessMetaData process(String name, String label, String icon, String previewMessage)
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantConversation.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantConversation.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, previewMessage);
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values);
process.setName(name);
process.setLabel(label);
process.setIcon(new QIcon(icon));
process.setTableName(AssistantConversation.TABLE_NAME);
process.setPermissionRules(AssistantMetaDataBuilder.accessRules());
return (process);
}
/*******************************************************************************
** Classify one selected conversation for this process.
*******************************************************************************/
static Outcome outcome(String processName, QRecord record, AssistantRuntime runtime, AssistantApi api) throws QException
{
String status = record.getValueString("status");
return (switch(processName)
{
case ARCHIVE -> "ARCHIVED".equals(status) ? Outcome.SKIP : Outcome.ACT;
case RESTORE -> "ARCHIVED".equals(status) ? Outcome.ACT : Outcome.SKIP;
default -> AssistantConversationCustomizer.hasAnswerInProgress(runtime, api, record.getValueInteger("id")) ? Outcome.IN_PROGRESS : Outcome.ACT;
});
}
/*******************************************************************************
** Summary lines, worded for the process (future on Review, past on Result).
*******************************************************************************/
static Map<Outcome, ProcessSummaryLine> lines(String processName)
{
Map<Outcome, ProcessSummaryLine> lines = new EnumMap<>(Outcome.class);
switch(processName)
{
case ARCHIVE ->
{
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be archived", "conversations will be archived",
"conversation was archived", "conversations were archived"));
lines.put(Outcome.SKIP, DiscoverModelsProcess.line(Status.INFO, "conversation is already archived", "conversations are already archived",
"conversation was already archived", "conversations were already archived"));
}
case RESTORE ->
{
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be restored", "conversations will be restored",
"conversation was restored", "conversations were restored"));
lines.put(Outcome.SKIP, DiscoverModelsProcess.line(Status.INFO, "conversation is not archived", "conversations are not archived",
"conversation was not archived", "conversations were not archived"));
}
default ->
{
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be deleted with its messages", "conversations will be deleted with their messages",
"conversation was deleted", "conversations were deleted"));
lines.put(Outcome.IN_PROGRESS, DiscoverModelsProcess.line(Status.ERROR, "conversation has an answer in progress and will not be deleted",
"conversations have an answer in progress and will not be deleted", "conversation was not deleted: an answer was in progress",
"conversations were not deleted: an answer was in progress"));
}
}
return (lines);
}
/*******************************************************************************
** The lines that have a count, in a fixed order.
*******************************************************************************/
static ArrayList<ProcessSummaryLineInterface> summary(Map<Outcome, ProcessSummaryLine> lines, String nothing)
{
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
for(ProcessSummaryLine line : lines.values())
{
line.addSelfToListIfAnyCount(rs);
}
if(rs.isEmpty())
{
rs.add(new ProcessSummaryLine(Status.INFO, null, nothing));
}
return (rs);
}
/*******************************************************************************
** The running assistant (or a clear error).
*******************************************************************************/
private static AssistantRuntime runtime() throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime == null)
{
throw (new QException("The assistant is not running on this server."));
}
return (runtime);
}
/*******************************************************************************
** Review: what will happen to each selected conversation. Changes nothing.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private Map<Outcome, ProcessSummaryLine> lines;
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = runtime();
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
if(lines == null)
{
lines = lines(input.getProcessName());
}
for(QRecord record : input.getRecords())
{
lines.get(outcome(input.getProcessName(), record, runtime, api)).incrementCountAndAddPrimaryKey(record.getValue("id"));
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (summary(lines == null ? Map.of() : lines, "No conversations were selected."));
}
}
/*******************************************************************************
** Result: archive, restore or delete each conversation (checked again), as
** the user, through the assistant's store.
*******************************************************************************/
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private Map<Outcome, ProcessSummaryLine> lines;
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = runtime();
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
if(lines == null)
{
lines = lines(input.getProcessName());
}
for(QRecord record : input.getRecords())
{
Integer id = record.getValueInteger("id");
Outcome outcome = outcome(input.getProcessName(), record, runtime, api);
if(outcome == Outcome.ACT)
{
switch(input.getProcessName())
{
case ARCHIVE -> runtime.getStore().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "ARCHIVED"));
case RESTORE -> runtime.getStore().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "ACTIVE"));
default -> runtime.getStore().deleteConversation(record.getValueString("userId"), id);
}
}
lines.get(outcome).incrementCountAndAddPrimaryKey(id);
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (summary(lines == null ? Map.of() : lines, "No conversations were changed."));
}
}
}
@@ -21,14 +21,24 @@
package com.kingsrook.qbits.assistant.scheduled;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Optional Alerts notification SPI bridge. The audience is exactly one user;
** notification bodies contain only status and a link, never answer text.
** The one notification a scheduled task's owner asked for (output mode
** NOTIFICATION or BOTH): through core's non-throwing QAlerts.notify, so it
** reaches whatever alerts sink the host installed (or core's logging sink).
** The audience is exactly the task's owner; the body has only the status and
** the link, never answer text.
*******************************************************************************/
public class ScheduledAlerts
{
@@ -37,36 +47,36 @@ public class ScheduledAlerts
/*******************************************************************************
** Reflection keeps the optional notification implementation out of the ABI.
** Notify the run's owner. Never throws.
*******************************************************************************/
public void raise(AssistantScheduledRun run)
{
LOG.info("Scheduled assistant result", logPair("runId", run.getId()), logPair("status", run.getStatus()));
try
QAlerts.notify(signal(run));
}
/*******************************************************************************
** The notification for a finished run.
*******************************************************************************/
static NotificationSignal signal(AssistantScheduledRun run)
{
String status = run.getStatus() == null ? "FINISHED" : run.getStatus();
NotificationSignal signal = new NotificationSignal()
.withTitle("OK".equals(status) ? "Scheduled assistant task finished" : "Scheduled assistant task did not finish")
.withBody("Status: " + status + ". See the conversation.")
.withSeverity("OK".equals(status) ? NotificationSeverity.SUCCESS : "FAILED".equals(status) ? NotificationSeverity.ERROR : NotificationSeverity.WARNING)
.withCategory(NotificationCategory.JOB)
.withSourceName("assistant")
.withSourceKey("assistant/scheduled/run/" + run.getId())
.withAudience(NotificationAudience.user(run.getUserId()));
if(run.getConversationId() != null)
{
String prefix = "com.kingsrook.qbits.alerts.spi.";
Class<?> type = Class.forName(prefix + "NotificationSignal");
Object signal = type.getConstructor(String.class).newInstance("Scheduled assistant task finished");
type.getMethod("withSourceName", String.class).invoke(signal, "assistant");
type.getMethod("withSourceKey", String.class).invoke(signal, "assistant/scheduled/run/" + run.getId());
type.getMethod("withBody", String.class).invoke(signal, "Status: " + run.getStatus() + ". See conversation.");
Class<?> audienceType = Class.forName(prefix + "NotificationAudience");
Object audience = audienceType.getMethod("user", String.class).invoke(null, run.getUserId());
type.getMethod("withAudience", audienceType).invoke(signal, audience);
if(run.getConversationId() != null)
{
Class<?> linkType = Class.forName(prefix + "AlertLink");
Object link = linkType.getMethod("toPath", String.class, String.class).invoke(null,
"/app/assistantAdmin/assistant_conversation/" + run.getConversationId(), "See conversation");
type.getMethod("withLink", linkType).invoke(signal, link);
}
Object runtime = Class.forName("com.kingsrook.qbits.alerts.AlertsRuntime").getMethod("get").invoke(null);
Object sink = runtime.getClass().getMethod("sink").invoke(runtime);
sink.getClass().getMethod("notify", type).invoke(sink, signal);
}
catch(Exception | LinkageError e)
{
LOG.info("Scheduled assistant notification unavailable", logPair("runId", run.getId()));
signal.withSourceTableName(AssistantConversation.TABLE_NAME)
.withSourceRecordId(String.valueOf(run.getConversationId()))
.withLink(AlertLink.toRecord(AssistantConversation.TABLE_NAME, run.getConversationId(), "Open the conversation"));
}
return (signal);
}
}
@@ -607,7 +607,7 @@ public class UiApiToolbox
{
return;
}
Map<String, Object> described = asMap(asMap(response.json()).get("table"));
Map<String, Object> described = asMap(response.json());
Map<String, Object> fields = asMap(described.get("fields"));
List<Map<String, Object>> rows = new ArrayList<>();
for(Map.Entry<String, Object> entry : fields.entrySet())
@@ -30,12 +30,15 @@ import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiDataEnvelope;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiJsonSchema;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsTables;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -537,7 +540,9 @@ public class BulkLoadTools
/*******************************************************************************
** Confirmed run: through the runner seam.
** Confirmed run: through the runner seam. A launched load is a real job
** (visible in the Jobs tray and tables); its id and in-app link go into the
** result and its record refs, so the conversation links to the job.
*******************************************************************************/
private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map<String, Object> values) throws QException
{
@@ -549,6 +554,13 @@ public class BulkLoadTools
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", handle.status());
out.put("jobId", handle.jobId());
if(handle.jobId() != null)
{
String jobHref = jobHref(handle.jobId());
out.put("jobHref", jobHref);
result.getRecordRefs().add(new AiRecordRef().withTableName(DurableJobsTables.TABLE_JOB).withTableLabel("Job").withId(handle.jobId())
.withLabel("Job " + handle.jobId()).withHref(jobHref));
}
out.put("processUUID", handle.processUUID());
out.put("summary", handle.summary());
result.withOutcome(ok ? AiToolOutcome.OK : AiToolOutcome.ERROR).withSummary(shortText(handle.summary())).withPermissionCheck(call.permissionCheck())
@@ -563,6 +575,16 @@ public class BulkLoadTools
/*******************************************************************************
** The in-app link to a durable job's record.
*******************************************************************************/
public static String jobHref(Long jobId)
{
return (AiLinkBuilder.recordHref(DurableJobsTables.TABLE_JOB, jobId));
}
/*******************************************************************************
** A tool's input or result as kept in the tool-call log: profiles and
** counts, never sample values, example bullets or value mappings.
@@ -19,9 +19,11 @@ entries:
content: |
A conversation is created when a user asks their first question in the assistant panel. Everything that follows (messages, turns, tool calls, feedback, model calls) hangs off it and is listed on its screen.
Most fields are filled in automatically and cannot be edited. You can only change the **{{label:assistant_conversation.title}}** and whether it is **{{label:assistant_conversation.pinned}}**.
Most fields are filled in automatically and cannot be edited. You can only change the **{{label:assistant_conversation.title}}**, whether it is **{{label:assistant_conversation.pinned}}** and its **{{label:assistant_conversation.status}}** (Active or Archived).
Use this list to see who is using the assistant, how much it costs, and to open a conversation when you need to find out why an answer went wrong. Conversations are not kept forever: the **{{label:assistantRetention}}** action deletes conversations that have passed the configured inactivity period (90 days by default), together with their messages, turns, tool calls and feedback.
Every assistant user can open this table to manage their own conversations: only their own rows are listed. They can archive, restore and delete conversations with **{{label:assistantConversationArchive}}**, **{{label:assistantConversationRestore}}** and **{{label:assistantConversationDelete}}**. Deleting a conversation also deletes its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress cannot be deleted until the answer is stopped.
Administrators see every user's conversations here. Use this list to see who is using the assistant, how much it costs, and to open a conversation when you need to find out why an answer went wrong. Conversations are not kept forever: the **{{label:assistantRetention}}** action deletes conversations that have passed the configured inactivity period (90 days by default), together with their messages, turns, tool calls and feedback.
See also **{{label:assistant_policy}}** for limits and **{{label:assistant_provider}}** for where answers come from.
- key: table:assistant_conversation;section:conversation
@@ -1555,6 +1557,21 @@ entries:
summary: When the worker last reported that this answer was still running.
- key: table:assistant_turn;field:cancelRequestedAt
summary: When a request to stop this answer was recorded.
- key: process:assistantConversationArchive;slot:help
summary: Archive the selected conversations. They leave the assistant's list, and nothing is deleted.
content: |
Select conversations in **{{label:assistant_conversation}}** and choose **Archive conversations**. Review shows how many will be archived and how many are already archived. You can only act on conversations you can see: your own, or everyone's if you are an assistant administrator.
- key: process:assistantConversationRestore;slot:help
summary: Restore the selected archived conversations to the assistant's list.
content: |
Select archived conversations in **{{label:assistant_conversation}}** and choose **Restore conversations**. Conversations that are not archived are skipped.
- key: process:assistantConversationDelete;slot:help
summary: Delete the selected conversations with their messages. This cannot be undone.
content: |
Select conversations in **{{label:assistant_conversation}}** and choose **Delete conversations**. Each conversation is deleted with its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress is skipped. Stop the answer first, then delete it.
- key: process:assistantDataAccessAddDefaults;slot:help
summary: Add missing data access rules for application tables using their current effective access.
content: |
@@ -175,7 +175,13 @@ components:
description: "Whether the panel may be used."
type: "boolean"
reason:
description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH."
description: "Why not: DISABLED, NOT_CONFIGURED (no usable model provider\
\ on the server), NO_PERMISSION or KILL_SWITCH, checked in that order."
nullable: true
type: "string"
message:
description: "Since 1.7.0: when reason is set, a sentence for the user that\
\ names the cause (for NOT_CONFIGURED, what an administrator configures)."
nullable: true
type: "string"
shortcut:
@@ -200,6 +206,12 @@ components:
type: "object"
profile:
$ref: "#/components/schemas/AssistantProfileV1"
conversationsTable:
description: "Since 1.6.0: the table name of the user's conversations (assistant_conversation)\
\ for the standard table UI (manage, archive, restore and delete); present\
\ only when this session may query that table."
nullable: true
type: "string"
type: "object"
AssistantConversationDetailResponseV1:
description: "A conversation and a page of its messages, oldest first."
@@ -242,6 +254,11 @@ components:
description: "ISO-8601 instant."
nullable: true
type: "string"
running:
description: "Since 1.6.0, in GET /conversations items only: whether an\
\ answer is in progress (a RUNNING or CANCELLING turn that is not orphaned)."
nullable: true
type: "boolean"
type: "object"
AssistantErrorV1:
description: "Deprecated since 1.3.0: errors use BasicErrorResponseV1."
@@ -294,7 +311,9 @@ components:
nullable: true
type: "string"
starterPrompts:
description: "Suggested first questions."
description: "Up to three suggested first questions in total: permitted\
\ configured questions first, then generated questions. Table, row and\
\ field access are checked on every request."
items:
type: "string"
type: "array"
@@ -457,7 +476,7 @@ info:
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
version: "1.5.0"
version: "1.7.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
@@ -833,7 +852,7 @@ paths:
tags:
- "assistant"
patch:
description: "Rename, pin or archive a conversation"
description: "Rename, pin, archive or mark read a conversation"
operationId: "patchAssistantConversationsById"
parameters:
- description: "The conversation id."
@@ -855,6 +874,10 @@ paths:
status:
description: "ACTIVE or ARCHIVED."
type: "string"
read:
description: "Since 1.6.0: true sets unreadCount to 0 (the user\
\ watched the reply stream in). Only true is accepted."
type: "boolean"
type: "object"
required: true
responses:
@@ -916,7 +939,7 @@ paths:
- "openid"
- "profile"
- "email"
summary: "Rename, pin or archive a conversation"
summary: "Rename, pin, archive or mark read a conversation"
tags:
- "assistant"
/qqq/v1/assistant/conversations/{id}/turns:
@@ -19,10 +19,12 @@ package com.kingsrook.qbits.assistant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.metadata.AssistantAccessPermissionChecker;
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
@@ -78,7 +80,8 @@ class AssistantQBitProducerTest extends BaseAssistantTest
RecordSecurityLock lock = table.getRecordSecurityLocks().get(0);
assertThat(lock.getFieldName()).isEqualTo("userId");
assertThat(lock.getSecurityKeyType()).isEqualTo(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE);
assertThat(table.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantAdminPermissionChecker.class.getName());
assertThat(table.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantConversation.TABLE_NAME.equals(name)
? AssistantAccessPermissionChecker.class.getName() : AssistantAdminPermissionChecker.class.getName());
}
assertThat(qInstance.getTable(AssistantUsageDaily.TABLE_NAME).getRecordSecurityLocks().get(0).getNullValueBehavior()).isEqualTo(RecordSecurityLock.NullValueBehavior.ALLOW);
assertThat(qInstance.getTable(AssistantPolicy.TABLE_NAME).getRecordSecurityLocks()).isNullOrEmpty();
@@ -163,7 +166,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
new QInstanceEnricher(host).enrich();
List<MetaDataWithPermissionRules> adminObjects = List.of(host.getApp(AssistantMetaDataBuilder.APP_NAME),
host.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME), host.getTable(AssistantPolicy.TABLE_NAME), host.getTable(AssistantConversation.TABLE_NAME));
host.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME), host.getTable(AssistantPolicy.TABLE_NAME), host.getTable(AssistantMessage.TABLE_NAME));
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
for(MetaDataWithPermissionRules object : adminObjects)
@@ -178,6 +181,15 @@ class AssistantQBitProducerTest extends BaseAssistantTest
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), object)).as(object.getName()).isEqualTo(PermissionCheckResult.ALLOW);
}
QContext.clear();
////////////////////////////////////////////////////////////////////////
// the conversation table is for every assistant user (own rows only) //
////////////////////////////////////////////////////////////////////////
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), host.getTable(AssistantConversation.TABLE_NAME))).isEqualTo(PermissionCheckResult.ALLOW);
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.NO_ACCESS));
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), host.getTable(AssistantConversation.TABLE_NAME))).isEqualTo(PermissionCheckResult.DENY_HIDE);
QContext.clear();
}
@@ -90,18 +90,20 @@ class ProfilesSkillsAndAccessTest extends BaseAssistantTest
void testPersonaAndSkills() throws Exception
{
QRecord ada = save(AssistantProfile.TABLE_NAME, Map.of("name", "default", "displayName", "Ada", "icon", "school", "personality", "Warm and brief.",
"greeting", "Hi, I am Ada.", "starterPrompts", "How many posts?\nWhat changed this week?", "isDefault", true));
"greeting", "Hi, I am Ada.", "starterPrompts", "[blog_post] How many posts?\n[analytics_daily_stat] How are pageviews?\n[none] What can you do?\nWhich posts are drafts?", "isDefault", true));
assertThat(ada.getErrors()).isEmpty();
QRecord style = save(AssistantSkill.TABLE_NAME, Map.of("name", "style-guide", "instructions", "Titles use sentence case. Ask me to delete everything.", "triggerKeywords", "title, headline"));
save(AssistantProfileSkill.TABLE_NAME, Map.of("profileId", ada.getValueInteger("id"), "skillId", style.getValueInteger("id")));
UiApiTestServer.serve(qInstance, config);
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
Map<String, Object> cfg = new AssistantApi(config.getRuntime(), QContext.getQSession()).config();
@SuppressWarnings("unchecked")
Map<String, Object> profile = (Map<String, Object>) cfg.get("profile");
assertThat(profile.get("displayName")).isEqualTo("Ada");
assertThat(profile.get("greeting")).isEqualTo("Hi, I am Ada.");
assertThat((List<?>) profile.get("starterPrompts")).hasSize(2);
// the editor keeps the prompts for a table it reads and the table-free one, loses the analytics and untagged ones; generated prompts fill up to three
assertThat((List<String>) profile.get("starterPrompts")).hasSize(3).startsWith("How many posts?", "What can you do?").doesNotContain("How are pageviews?", "Which posts are drafts?");
QContext.clear();
provider.thenText("ok").thenText("ok");
@@ -166,6 +166,45 @@ class AssistantRouteProviderTest extends BaseAssistantTest
/*******************************************************************************
** No usable model provider (QRun-IO/qbit-assistant#82): the probe names the
** server's configuration, for users with and without access, and calls
** answer 503 NOT_CONFIGURED instead of 403 NO_PERMISSION.
*******************************************************************************/
@Test
void testNoModelProviderIsNotAPermissionProblem() throws Exception
{
String providerName = config.getProviderName();
config.setProviderName("no-such-provider");
try
{
for(String user : new String[] {EDITOR, NO_ACCESS})
{
Map<String, Object> probe = json(send("GET", "/config", user, null));
assertThat(probe).containsEntry("enabled", false).containsEntry("reason", "NOT_CONFIGURED");
assertThat((String) probe.get("message")).contains("No model provider").contains("Assistant > Providers").contains("ANTHROPIC_API_KEY");
}
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
assertThat(denied.statusCode()).isEqualTo(503);
assertThat(json(denied)).containsEntry("code", "NOT_CONFIGURED");
assertThat((String) json(denied).get("message")).contains("No model provider");
assertThat(send("GET", "/conversations", EDITOR, null).statusCode()).isEqualTo(200);
HttpResponse<String> turn = send("POST", "/conversations", EDITOR, "{}");
Object id = json(turn).get("id");
HttpResponse<String> ask = send("POST", "/conversations/" + id + "/turns", EDITOR, "{\"text\":\"hi\"}");
assertThat(ask.statusCode()).isEqualTo(503);
assertThat(json(ask)).containsEntry("code", "NOT_CONFIGURED");
}
finally
{
config.setProviderName(providerName);
}
}
/*******************************************************************************
**
*******************************************************************************/
@@ -178,7 +217,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest
assertThat((Map<String, Object>) config.get("limits")).containsEntry("maxTurnSeconds", 300).containsEntry("maxToolCallsPerTurn", 25);
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION")
.containsEntry("message", "You do not have access to the assistant.");
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
assertThat(denied.statusCode()).isEqualTo(403);
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")
@@ -0,0 +1,429 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.api;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.metadata.AssistantAccessPermissionChecker;
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.processes.ConversationActionsProcess;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Conversation management: the conversation table open to assistant users
** (owner-locked), its archive / restore / delete record actions, the table
** delete that leaves no child rows behind, and the UI API additions
** (running, read, conversationsTable).
*******************************************************************************/
class ConversationManagementTest extends BaseAssistantTest
{
private static final List<String> CHILD_TABLES = List.of(AssistantMessage.TABLE_NAME, AssistantTurn.TABLE_NAME, AssistantToolCall.TABLE_NAME,
AssistantFeedback.TABLE_NAME, AssistantPendingAction.TABLE_NAME);
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clearContext()
{
QContext.clear();
}
/*******************************************************************************
** Assistant users may use the conversation table (and its actions); the
** admin app, its nav and the other tables stay admin-only.
*******************************************************************************/
@Test
void tableOpenToAssistantUsersAppStaysAdminOnly()
{
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getPermissionRules().getCustomPermissionChecker().getName())
.isEqualTo(AssistantAccessPermissionChecker.class.getName());
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getRecordSecurityLocks().get(0).getFieldName()).isEqualTo("userId");
assertThat(qInstance.getTable(AssistantMessage.TABLE_NAME).getPermissionRules().getCustomPermissionChecker().getName())
.isEqualTo(AssistantAdminPermissionChecker.class.getName());
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getPermissionRules().getCustomPermissionChecker().getName())
.isEqualTo(AssistantAdminPermissionChecker.class.getName());
for(String name : List.of(ConversationActionsProcess.ARCHIVE, ConversationActionsProcess.RESTORE, ConversationActionsProcess.DELETE))
{
QProcessMetaData process = qInstance.getProcess(name);
assertThat(process.getTableName()).isEqualTo(AssistantConversation.TABLE_NAME);
assertThat(process.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantAccessPermissionChecker.class.getName());
assertThat(process.getPermissionRules().getDeniedClients()).contains("assistant");
}
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
.doesNotContain(ConversationActionsProcess.ARCHIVE, ConversationActionsProcess.RESTORE, ConversationActionsProcess.DELETE);
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
for(TablePermissionSubType type : List.of(TablePermissionSubType.READ, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE))
{
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, type)).as(type.name()).isTrue();
}
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantMessage.TABLE_NAME), AssistantMessage.TABLE_NAME, TablePermissionSubType.READ)).isFalse();
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), qInstance.getApp(AssistantMetaDataBuilder.APP_NAME))).isEqualTo(PermissionCheckResult.DENY_HIDE);
assertThat(PermissionsHelper.hasProcessPermission(new MetaDataInput(), ConversationActionsProcess.DELETE)).isTrue();
QContext.init(qInstance, AssistantTestFixture.ownerSession(NO_ACCESS, config));
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, TablePermissionSubType.READ)).isFalse();
assertThat(PermissionsHelper.hasProcessPermission(new MetaDataInput(), ConversationActionsProcess.DELETE)).isFalse();
}
/*******************************************************************************
** User A cannot see, update or delete user B's conversation through the
** table actions; their own rows they can.
*******************************************************************************/
@Test
void ownerLockOnTableActions() throws Exception
{
Integer mine = conversation(EDITOR, "Mine");
Integer theirs = conversation(OTHER_EDITOR, "Theirs");
asUser(EDITOR);
List<Serializable> visible = new QueryAction().execute(new QueryInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)).getRecords()
.stream().map(r -> r.getValue("id")).toList();
assertThat(visible).contains(mine).doesNotContain(theirs);
UpdateOutput update = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", theirs).withValue("title", "Hijacked")));
assertThat(update.getRecords()).allSatisfy(r -> assertThat(r.getErrors()).isNotEmpty());
assertThatDeleteFailsOrDoesNothing(theirs);
asUser(ADMIN);
AssistantConversation stillThere = store().getConversation(OTHER_EDITOR, theirs);
assertThat(stillThere).isNotNull();
assertThat(stillThere.getTitle()).isEqualTo("Theirs");
////////////////////////////////////////////////////////////////
// own row: title / pinned / status change; the rest is ours //
////////////////////////////////////////////////////////////////
asUser(EDITOR);
UpdateOutput giveAway = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", mine).withValue("userId", OTHER_EDITOR)));
assertThat(giveAway.getRecords().get(0).getErrors()).as("the owner lock refuses moving a row to another user").isNotEmpty();
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", mine).withValue("title", "Renamed").withValue("status", "ARCHIVED")
.withValue("unreadCount", 99).withValue("maxClassification", "NONE")));
AssistantConversation updated = store().getConversation(EDITOR, mine);
assertThat(updated.getTitle()).isEqualTo("Renamed");
assertThat(updated.getStatus()).isEqualTo("ARCHIVED");
assertThat(updated.getUnreadCount()).isNotEqualTo(99);
assertThat(updated.getUserId()).isEqualTo(EDITOR);
UpdateOutput bad = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", mine).withValue("status", "DELETED")));
assertThat(bad.getRecords().get(0).getErrors()).isNotEmpty();
assertThat(store().getConversation(EDITOR, mine).getStatus()).isEqualTo("ARCHIVED");
}
/*******************************************************************************
** A table delete removes the conversation's child rows too; a conversation
** with an answer in progress is refused.
*******************************************************************************/
@Test
void tableDeleteRemovesChildrenAndRefusesRunning() throws Exception
{
asUser(ADMIN);
new DeleteAction().execute(new DeleteInput(AssistantTurnEvent.TABLE_NAME).withQueryFilter(new QQueryFilter()));
Integer mine = conversation(EDITOR, "With children");
children(EDITOR, mine);
Integer other = conversation(EDITOR, "Sibling");
children(EDITOR, other);
asUser(EDITOR);
DeleteOutput output = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(mine)));
assertThat(output.getRecordsWithErrors()).isNullOrEmpty();
assertThat(store().getConversation(EDITOR, mine)).isNull();
for(String table : CHILD_TABLES)
{
assertThat(rows(table, mine)).as(table).isEmpty();
assertThat(rows(table, other)).as(table + " of another conversation").isNotEmpty();
}
asUser(ADMIN);
assertThat(new QueryAction().execute(new QueryInput(AssistantTurnEvent.TABLE_NAME)).getRecords()).as("only the sibling's turn event is left").hasSize(1);
Integer busy = conversation(EDITOR, "Busy");
runningTurn(EDITOR, busy, Instant.now());
asUser(EDITOR);
DeleteOutput refused = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(busy)));
assertThat(refused.getRecordsWithErrors()).isNotEmpty();
assertThat(store().getConversation(EDITOR, busy)).isNotNull();
}
/*******************************************************************************
** Archive, restore and delete as standard record actions, only on rows the
** user can see.
*******************************************************************************/
@Test
void bulkRecordActions() throws Exception
{
Integer one = conversation(EDITOR, "One");
Integer two = conversation(EDITOR, "Two");
Integer busy = conversation(EDITOR, "Busy");
Integer theirs = conversation(OTHER_EDITOR, "Theirs");
children(EDITOR, one);
runningTurn(EDITOR, busy, Instant.now());
asUser(EDITOR);
runProcess(ConversationActionsProcess.ARCHIVE, List.of(one, two, theirs));
assertThat(store().getConversation(EDITOR, one).getStatus()).isEqualTo("ARCHIVED");
assertThat(store().getConversation(EDITOR, two).getStatus()).isEqualTo("ARCHIVED");
runProcess(ConversationActionsProcess.RESTORE, List.of(two, theirs));
assertThat(store().getConversation(EDITOR, two).getStatus()).isEqualTo("ACTIVE");
assertThat(store().getConversation(EDITOR, one).getStatus()).isEqualTo("ARCHIVED");
runProcess(ConversationActionsProcess.DELETE, List.of(one, busy, theirs));
assertThat(store().getConversation(EDITOR, one)).isNull();
assertThat(rows(AssistantMessage.TABLE_NAME, one)).isEmpty();
assertThat(store().getConversation(EDITOR, busy)).as("an answer in progress is not deleted").isNotNull();
asUser(ADMIN);
AssistantConversation other = store().getConversation(OTHER_EDITOR, theirs);
assertThat(other).isNotNull();
assertThat(other.getStatus()).isEqualTo("ACTIVE");
////////////////////////////////////////////////////////
// review and result summaries use the standard lines //
////////////////////////////////////////////////////////
assertThat(ConversationActionsProcess.processes()).extracting(QProcessMetaData::getLabel)
.containsExactly("Archive conversations", "Restore conversations", "Delete conversations");
}
/*******************************************************************************
** GET /conversations items carry running; an orphaned run is not running.
*******************************************************************************/
@Test
void listShowsRunning() throws Exception
{
Integer live = conversation(EDITOR, "Live");
Integer stale = conversation(EDITOR, "Stale");
Integer idle = conversation(EDITOR, "Idle");
runningTurn(EDITOR, live, Instant.now());
runningTurn(EDITOR, stale, Instant.now().minusSeconds(3600));
AssistantApi api = asUser(EDITOR);
@SuppressWarnings("unchecked")
List<Map<String, Object>> list = (List<Map<String, Object>>) api.listConversations(null).get("conversations");
Map<Object, Object> running = list.stream().collect(Collectors.toMap(c -> c.get("id"), c -> c.get("running")));
assertThat(running).containsEntry(live, true).containsEntry(stale, false).containsEntry(idle, false);
}
/*******************************************************************************
** PATCH {"read": true} clears the unread count; anything else is a 400.
*******************************************************************************/
@Test
void patchRead() throws Exception
{
Integer id = conversation(EDITOR, "Unread");
asUser(EDITOR);
store().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("unreadCount", 3));
AssistantApi api = asUser(EDITOR);
assertThat(api.patchConversation(id, Map.of("read", true))).containsEntry("unreadCount", 0);
assertThat(store().getConversation(EDITOR, id).getUnreadCount()).isZero();
assertThatThrownBy(() -> api.patchConversation(id, Map.of("read", false)))
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(400));
}
/*******************************************************************************
** GET /config names the conversation table only when the session may
** query it.
*******************************************************************************/
@Test
void configNamesTheTable() throws Exception
{
assertThat(asUser(EDITOR).config()).containsEntry("conversationsTable", AssistantConversation.TABLE_NAME);
assertThat(asUser(ADMIN).config()).containsEntry("conversationsTable", AssistantConversation.TABLE_NAME);
assertThat(asUser(NO_ACCESS).config()).doesNotContainKey("conversationsTable");
}
/*******************************************************************************
** A delete of someone else's row either errors or deletes nothing.
*******************************************************************************/
private void assertThatDeleteFailsOrDoesNothing(Integer id)
{
try
{
DeleteOutput output = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(id)));
assertThat(output.getDeletedRecordCount()).isZero();
}
catch(QException e)
{
//////////////////////////////////
// refused outright: also fine //
//////////////////////////////////
}
}
/*******************************************************************************
** Run a record action as the current user over the given ids.
*******************************************************************************/
private void runProcess(String process, List<Integer> ids) throws Exception
{
RunProcessInput input = new RunProcessInput();
input.setProcessName(process);
input.setCallback(QProcessCallbackFactory.forPrimaryKeys("id", ids));
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
new RunProcessAction().execute(input);
}
/*******************************************************************************
** Switch the context to a user's own session; returns an API bound to it.
*******************************************************************************/
private AssistantApi asUser(String userId)
{
QSession session = AssistantTestFixture.ownerSession(userId, config);
QContext.init(qInstance, session);
return (new AssistantApi(config.getRuntime(), session));
}
/*******************************************************************************
**
*******************************************************************************/
private AssistantStore store()
{
return (config.getRuntime().getStore());
}
/*******************************************************************************
** A conversation owned by a user (created as an admin).
*******************************************************************************/
private Integer conversation(String owner, String title) throws Exception
{
asUser(ADMIN);
return (store().createConversation(owner, title, null).getId());
}
/*******************************************************************************
** One of each child row under a conversation.
*******************************************************************************/
private void children(String owner, Integer conversationId) throws Exception
{
asUser(ADMIN);
AssistantMessage message = store().insert(new AssistantMessage().withConversationId(conversationId).withUserId(owner).withRole("USER").withStatus("COMPLETE").withSeq(1).withPartsJson("[]"));
AssistantTurn turn = store().insert(new AssistantTurn().withConversationId(conversationId).withUserId(owner).withStatus("COMPLETE").withUserMessageId(message.getId()));
store().insert(new AssistantTurnEvent().withTurnId(turn.getId()).withUserId(owner).withSeq(1).withEventType("message.end"));
store().insert(new AssistantToolCall().withConversationId(conversationId).withTurnId(turn.getId()).withUserId(owner).withToolName("query"));
store().insert(new AssistantFeedback().withConversationId(conversationId).withMessageId(message.getId()).withUserId(owner).withRating(1));
store().insert(new AssistantPendingAction().withConversationId(conversationId).withTurnId(turn.getId()).withUserId(owner).withStatus(AssistantPendingAction.STATUS_PENDING));
}
/*******************************************************************************
** A RUNNING turn on another (possibly dead) pod with the given heartbeat.
*******************************************************************************/
private void runningTurn(String owner, Integer conversationId, Instant heartbeat) throws Exception
{
asUser(ADMIN);
store().insert(new AssistantTurn().withConversationId(conversationId).withUserId(owner).withStatus("RUNNING")
.withStartedAt(heartbeat).withHeartbeatAt(heartbeat).withLeaseOwner("another-pod").withPhase("thinking"));
}
/*******************************************************************************
** Rows of a child table for one conversation (any owner).
*******************************************************************************/
private List<QRecord> rows(String table, Integer conversationId) throws QException
{
QSession previous = QContext.getQSession();
asUser(ADMIN);
try
{
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter().withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId))).getRecords());
}
finally
{
QContext.init(qInstance, previous);
}
}
}
@@ -32,7 +32,6 @@ import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.jobs.AssistantRunTracker;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -208,42 +207,6 @@ class DurableTurnsTest extends BaseAssistantTest
/*******************************************************************************
** Streamed text arrives on the provider's own thread; the "writing" progress
** it triggers still runs in the turn's QContext (the job tracker writes
** through QQQ, which needs the instance).
*******************************************************************************/
@Test
void streamedProgressRunsInTheTurnContext() throws Exception
{
List<String> contexts = new CopyOnWriteArrayList<>();
config.setRunTracker(new AssistantRunTracker()
{
@Override
public String start(String userId, Integer turnId, Integer conversationId, String label)
{
return ("job-" + turnId);
}
@Override
public void progress(String trackingId, String phase, String message)
{
contexts.add(phase + ":" + (QContext.getQInstance() != null));
}
@Override
public void finish(String trackingId, String status, String message)
{
}
});
provider.thenText("A streamed answer on the provider's thread");
AssistantTurn row = completed(api.startTurn(conversation(), Map.of("text", "hello")));
assertThat(row.getStatus()).isEqualTo("COMPLETE");
assertThat(contexts).contains("writing:true").doesNotContain("writing:false");
}
/*******************************************************************************
** A throwing post-processing collaborator cannot strand a streamed answer.
*******************************************************************************/
@@ -360,8 +323,6 @@ class DurableTurnsTest extends BaseAssistantTest
{
ControlledProvider controlled = controlled(true);
controlled.emitText = false;
RecordingTracker tracker = new RecordingTracker();
config.setRunTracker(tracker);
config.setMaxTurnSeconds(1);
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wedged"));
try
@@ -373,12 +334,10 @@ class DurableTurnsTest extends BaseAssistantTest
assertThat(row.getCancelReason()).isEqualTo("TIMEOUT");
assertThat(store.getMessage(EDITOR, row.getAssistantMessageId()).getPartsJson()).contains(TurnEngine.TIMEOUT_TEXT);
assertThat(controlled.handles.getFirst().cancelled).isTrue();
assertThat(tracker.finishes.get()).isEqualTo(1);
int events = run.getLog().after(0, 1).size();
controlled.release();
await(() -> controlled.handles.getFirst().returned);
assertThat(run.getLog().after(0, 1)).hasSize(events);
assertThat(tracker.finishes.get()).isEqualTo(1);
}
finally
{
@@ -576,8 +535,6 @@ class DurableTurnsTest extends BaseAssistantTest
List<String> errors = new ArrayList<>();
config.validate(qInstance, errors);
assertThat(errors).contains("turnHeartbeatSeconds must be less than turnLeaseSeconds.", "defaultConcurrencyMode must be SUPERSEDE or CONCURRENT.");
config.withRunTracker(null);
assertThat(config.getRunTracker()).isSameAs(AssistantRunTracker.NOOP);
}
@@ -621,8 +578,6 @@ class DurableTurnsTest extends BaseAssistantTest
void rollingDeploy() throws Exception
{
ControlledProvider controlled = controlled(true);
RecordingTracker tracker = new RecordingTracker();
config.setRunTracker(tracker);
Integer conversation = conversation();
RunningTurn run = api.startTurn(conversation, Map.of("text", "wait"));
try
@@ -634,7 +589,6 @@ class DurableTurnsTest extends BaseAssistantTest
assertThat(row.getStatus()).isEqualTo("ERROR");
assertThat(row.getErrorCode()).isEqualTo("INTERRUPTED");
assertThat(store.getMessage(EDITOR, row.getAssistantMessageId()).getPartsJson()).contains(TurnEngine.RESTART_TEXT);
assertThat(tracker.finishes.get()).isEqualTo(1);
int messages = store.listMessages(EDITOR, conversation, null, 100).size();
assertThatThrownBy(() -> api.startTurn(conversation, Map.of("text", "retry")))
.isInstanceOfSatisfying(AssistantApiException.class, e ->
@@ -668,35 +622,6 @@ class DurableTurnsTest extends BaseAssistantTest
/*******************************************************************************
** Tracker progress is phase-based; its Stop callback cancels the local run.
*******************************************************************************/
@Test
void trackerAndTrackerFailures() throws Exception
{
ControlledProvider controlled = controlled(false);
RecordingTracker tracker = new RecordingTracker();
config.setRunTracker(tracker);
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
controlled.awaitCalls(1);
await(() -> tracker.cancel != null);
tracker.cancel.run();
assertThat(completed(run).getStatus()).isEqualTo("CANCELLED");
assertThat(tracker.starts.get()).isEqualTo(1);
assertThat(tracker.phases).containsExactly("thinking", "writing");
assertThat(tracker.finishes.get()).isEqualTo(1);
assertThat(tracker.label).isEqualTo("Assistant answer");
tracker.fail = true;
config.withModelProvider(provider);
config.getRuntime().getProviderService().invalidate();
provider.thenText("Still works");
RunningTurn throwing = api.startTurn(conversation(), Map.of("text", "hello"));
assertThat(completed(throwing).getStatus()).isEqualTo("COMPLETE");
assertThat(tracker.finishes.get()).isEqualTo(2);
}
/*******************************************************************************
** Build a remote persisted turn with an assistant message to recover.
*******************************************************************************/
@@ -953,69 +878,4 @@ class DurableTurnsTest extends BaseAssistantTest
.withStopReason(cancelled ? ModelResponse.STOP_CANCELLED : ModelResponse.STOP_END_TURN));
}
}
/*******************************************************************************
** Records the host job-tray contract and can throw from every callback.
*******************************************************************************/
private static class RecordingTracker implements AssistantRunTracker
{
private final AtomicInteger starts = new AtomicInteger();
private final AtomicInteger finishes = new AtomicInteger();
private final List<String> phases = new CopyOnWriteArrayList<>();
private volatile Runnable cancel;
private String label;
private boolean fail;
/*******************************************************************************
** Record publication.
*******************************************************************************/
@Override
public String start(String userId, Integer turnId, Integer conversationId, String label)
{
starts.incrementAndGet();
this.label = label;
if(fail)
{
throw (new IllegalStateException("tracker start failed"));
}
return ("job-" + turnId);
}
/*******************************************************************************
** Record a phase transition.
*******************************************************************************/
@Override
public void progress(String trackingId, String phase, String message)
{
phases.add(phase);
if(fail)
{
throw (new IllegalStateException("tracker progress failed"));
}
}
/*******************************************************************************
** Record terminal notification exactly once.
*******************************************************************************/
@Override
public void finish(String trackingId, String status, String message)
{
finishes.incrementAndGet();
if(fail)
{
throw (new IllegalStateException("tracker finish failed"));
}
}
/*******************************************************************************
** Retain the host's Stop callback.
*******************************************************************************/
@Override
public void onCancel(String trackingId, Runnable cancel)
{
this.cancel = cancel;
}
}
}
@@ -0,0 +1,363 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The per-user starter prompts: built only from tables the user can read and
** that have rows, stable for the day, freshly authorized, and never failing.
*******************************************************************************/
class StarterPromptsTest extends BaseAssistantTest
{
private static final Instant NOW = Instant.parse("2026-10-16T12:00:00Z");
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/*******************************************************************************
** The context as the route builds it, with the signed-in session.
*******************************************************************************/
private UiApiToolbox.Context context(String user, UiApiGateway gateway)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
return (new UiApiToolbox.Context(user, null, gateway, PolicyService.ToolPolicy.defaults(), null));
}
/*******************************************************************************
**
*******************************************************************************/
private StarterPrompts prompts(Instant now)
{
UiApiTestServer.serve(qInstance, config);
return (new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(now, ZoneOffset.UTC)));
}
/*******************************************************************************
** A user who can read only the analytics table is never offered a prompt
** about the blog, pages or media; an editor never one about analytics.
*******************************************************************************/
@Test
void promptsNameOnlyTablesTheUserCanRead() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
List<String> analyst = starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false);
assertThat(analyst).isNotEmpty().hasSizeLessThanOrEqualTo(3);
assertThat(String.join("|", analyst)).contains("daily stats").doesNotContain("blog").doesNotContain("page").doesNotContain("media").doesNotContain("assistant");
List<String> editor = starter.forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), false);
assertThat(editor).isNotEmpty().hasSizeLessThanOrEqualTo(3);
assertThat(String.join("|", editor)).doesNotContain("analytics").doesNotContain("assistant");
assertThat(String.join("|", editor)).containsAnyOf("blog posts", "pages", "media");
assertThat(starter.forUser(context(AssistantTestFixture.LIMITED, gateway), AssistantTestFixture.LIMITED, List.of(), false)).isEmpty();
}
/*******************************************************************************
** A table with no rows is skipped.
*******************************************************************************/
@Test
void emptyTableIsSkipped() throws Exception
{
UiApiTestServer.serve(qInstance, config);
QContext.init(qInstance, AssistantTestFixture.sessionFor("seeder-0"));
new DeleteAction().execute(new DeleteInput("page").withQueryFilter(new QQueryFilter()));
QContext.clear();
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
for(int day = 0; day < 20; day++)
{
StarterPrompts daily = new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(NOW.plusSeconds(86400L * day), ZoneOffset.UTC));
List<String> rs = daily.forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true);
assertThat(rs).isNotEmpty();
assertThat(String.join("|", rs)).doesNotContain("pages");
}
}
/*******************************************************************************
** The prompts are the same for a user all day.
*******************************************************************************/
@Test
void stableForTheDay() throws Exception
{
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
List<String> first = prompts(NOW).forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true);
List<String> later = prompts(NOW.plusSeconds(3600)).forUser(context(AssistantTestFixture.EDITOR, gateway), AssistantTestFixture.EDITOR, List.of(), true);
assertThat(later).isEqualTo(first);
assertThat(String.join("|", first)).containsAnyOf("added in the last 7 days", "most recent", "changed in");
}
/*******************************************************************************
** Errors from the UI API give no generated prompts and never throw.
*******************************************************************************/
@Test
void errorsDegradeToEmpty() throws Exception
{
UiApiGateway broken = new UiApiGateway()
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (true);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
throw (new IllegalStateException("down"));
}
};
StarterPrompts starter = prompts(NOW);
List<String> rs = starter.forUser(context(AssistantTestFixture.EDITOR, broken), AssistantTestFixture.EDITOR,
List.of("[blog_post] How many posts?", "[none] What can you do?", "How many pages?"), true);
assertThat(rs).containsExactly("What can you do?");
}
/*******************************************************************************
** Every request rechecks access with bounded tool calls.
*******************************************************************************/
@Test
void accessIsRecheckedWithBoundedCalls() throws Exception
{
UiApiTestServer.serve(qInstance, config);
UiApiGateway delegate = config.getRuntime().getUiApiGateway();
AtomicInteger calls = new AtomicInteger();
UiApiGateway counting = new UiApiGateway()
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (delegate.isInvocable(method, pathTemplate));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
calls.incrementAndGet();
return (delegate.invoke(call));
}
};
StarterPrompts starter = new StarterPrompts(config.getRuntime().getToolbox(), Clock.fixed(NOW, ZoneOffset.UTC));
starter.forUser(context(AssistantTestFixture.EDITOR, counting), AssistantTestFixture.EDITOR, List.of(), false);
int firstCalls = calls.get();
assertThat(firstCalls).isBetween(2, 1 + StarterPrompts.MAX_COUNT_CALLS + StarterPrompts.MAX_DESCRIBE_CALLS);
starter.forUser(context(AssistantTestFixture.EDITOR, counting), AssistantTestFixture.EDITOR, List.of(), false);
assertThat(calls.get()).isEqualTo(firstCalls * 2);
starter.forUser(context(AssistantTestFixture.ANALYST, counting), AssistantTestFixture.ANALYST, List.of(), false);
assertThat(calls.get()).isGreaterThan(firstCalls * 2);
}
/*******************************************************************************
** A configured prompt stays only when it declares tables the user can read.
*******************************************************************************/
@Test
void configuredPromptsNeedReadableTables()
{
Set<String> readable = Set.of("blog_post", "page");
assertThat(StarterPrompts.allowedConfigured("[blog_post] Which posts are drafts?", readable)).isEqualTo("Which posts are drafts?");
assertThat(StarterPrompts.allowedConfigured("[blog_post, page] Posts and pages?", readable)).isEqualTo("Posts and pages?");
assertThat(StarterPrompts.allowedConfigured("[blog_post, media] Posts and media?", readable)).isNull();
assertThat(StarterPrompts.allowedConfigured("[media] Which media is new?", readable)).isNull();
assertThat(StarterPrompts.allowedConfigured("Which blog posts were published this month?", readable)).isNull();
assertThat(StarterPrompts.allowedConfigured("[none] What can you help with?", readable)).isEqualTo("What can you help with?");
assertThat(StarterPrompts.allowedConfigured("[] Empty tag", readable)).isNull();
assertThat(StarterPrompts.allowedConfigured("[blog_post]", readable)).isNull();
assertThat(StarterPrompts.plural("Blog Post")).isEqualTo("blog posts");
assertThat(StarterPrompts.plural("Category")).isEqualTo("categories");
assertThat(StarterPrompts.plural("Media")).isEqualTo("media");
}
/*******************************************************************************
** Configured prompts and generated prompts share the same total limit.
*******************************************************************************/
@Test
void configuredPromptsRespectTotalLimit() throws Exception
{
StarterPrompts starter = prompts(NOW);
List<String> result = starter.forUser(context(AssistantTestFixture.LIMITED, config.getRuntime().getUiApiGateway()), AssistantTestFixture.LIMITED,
List.of("[blog_post] Denied", "[none] One", "[none] Two", "[media] Denied too", "[none] Three", "[none] Four"), false);
assertThat(result).containsExactly("One", "Two", "Three");
}
/*******************************************************************************
** A data-access update invalidates eligibility on the next config request.
*******************************************************************************/
@Test
void nextConfigReflectsRevokedDataAccess() throws Exception
{
UiApiTestServer.serve(qInstance, config);
context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
assertThat(String.valueOf(((Map<?, ?>) api.config().get("profile")).get("starterPrompts"))).contains("daily stats");
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE)
.withObjectName("analytics_daily_stat").withMode("NONE"));
config.getRuntime().getDataAccessService().invalidate();
context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
assertThat(((Map<?, ?>) api.config().get("profile")).get("starterPrompts")).isEqualTo(List.of());
}
/*******************************************************************************
** A user cannot retain suggestions from an earlier permission context.
*******************************************************************************/
@Test
void changedSessionPermissionsAndToolPolicyAreRechecked() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
assertThat(starter.forUser(context(AssistantTestFixture.EDITOR, gateway), "same-user", List.of(), false)).isNotEmpty();
assertThat(starter.forUser(context(AssistantTestFixture.LIMITED, gateway), "same-user", List.of("[blog_post] Old access"), false)).isEmpty();
UiApiToolbox.Context allowed = context(AssistantTestFixture.ANALYST, gateway);
assertThat(starter.forUser(allowed, "same-user", List.of(), false)).isNotEmpty();
UiApiToolbox.Context denied = new UiApiToolbox.Context(allowed.sessionUUID(), null, gateway,
new PolicyService.ToolPolicy(true, Set.of(UiApiToolbox.DESCRIBE_TABLE), 50, true), null);
assertThat(starter.forUser(denied, "same-user", List.of(), false)).isEmpty();
}
/*******************************************************************************
** Removing the history tool immediately removes its generated suggestions.
*******************************************************************************/
@Test
void historyAvailabilityIsNotCached() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), true))).contains("What changed");
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false))).doesNotContain("What changed");
}
/*******************************************************************************
** A date field that becomes private no longer supports recency prompts.
*******************************************************************************/
@Test
void revokedDateFieldRemovesRecencyPrompts() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiToolbox.Context context = context(AssistantTestFixture.ANALYST, config.getRuntime().getUiApiGateway());
String described = config.getRuntime().getToolbox().execute(context, "describe-date-fixture", UiApiToolbox.DESCRIBE_TABLE,
Map.of("table", "analytics_daily_stat")).getResult().getModelContent();
assertThat(described).contains("\"name\":\"statDate\"", "\"type\":\"DATE\"");
assertThat(String.join("|", starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false))).contains("recent");
for(String name : List.of("statDate", "createDate", "modifyDate"))
{
qInstance.getTable("analytics_daily_stat").getField(name)
.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
}
assertThat(starter.forUser(context, AssistantTestFixture.ANALYST, List.of(), false)).containsExactly("How many daily stats are there?");
}
/*******************************************************************************
** Tables with no currently visible rows are removed on the next request.
*******************************************************************************/
@Test
void changedVisibleRowsAreRechecked() throws Exception
{
StarterPrompts starter = prompts(NOW);
UiApiGateway gateway = config.getRuntime().getUiApiGateway();
assertThat(starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false)).isNotEmpty();
QContext.init(qInstance, AssistantTestFixture.sessionFor("seeder-0"));
new DeleteAction().execute(new DeleteInput("analytics_daily_stat").withQueryFilter(new QQueryFilter()));
assertThat(starter.forUser(context(AssistantTestFixture.ANALYST, gateway), AssistantTestFixture.ANALYST, List.of(), false)).isEmpty();
}
}
@@ -141,6 +141,55 @@ class TurnEngineTest extends BaseAssistantTest
/*******************************************************************************
** The panel reloads the conversation as soon as message.end arrives, so the
** question and the answer must already be stored then, not after it.
*******************************************************************************/
@Test
void testMessagesAreStoredBeforeMessageEnd() throws Exception
{
provider.thenText("Four posts are published.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
List<AssistantMessage> stored = runner.messagesAtEnd();
assertThat(stored).hasSize(2);
assertThat(stored).extracting(AssistantMessage::getPartsJson).allSatisfy(parts -> assertThat(parts).isNotEqualTo("[]"));
assertThat(stored.get(0).getPartsJson()).contains("how many published posts?");
assertThat(stored.get(1).getPartsJson()).contains("Four posts are published.");
}
/*******************************************************************************
** The live thread after a tool-using turn: when message.end arrives the panel
** reloads, and must find the real title, the question, the answer and the tool
** steps - not the transcript redactor's placeholders (changed:v1, [], {}).
*******************************************************************************/
@Test
void testLiveThreadIsNotPlaceholdersAtMessageEnd() throws Exception
{
provider.thenToolUse("count_records", input("table", "blog_post", "filter", filter("AND", "status", "EQUALS", List.of("PUBLISHED"))))
.thenText("Four posts are published.");
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?");
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(runner.conversationAtEnd().getTitle()).isEqualTo("how many published posts?");
List<AssistantMessage> stored = runner.messagesAtEnd();
assertThat(stored).extracting(AssistantMessage::getPartsJson).noneMatch(parts -> parts.equals("[]") || parts.contains("changed:v1"));
assertThat(stored.get(0).getPartsJson()).contains("how many published posts?");
assertThat(stored.get(stored.size() - 1).getPartsJson()).contains("Four posts are published.");
assertThat(runner.toolCallsAtEnd()).isNotEmpty().allSatisfy(call ->
{
assertThat(call.getInputJson()).isNotEqualTo("{}");
assertThat(call.getResultSummary()).doesNotContain("changed:v1");
});
}
/*******************************************************************************
** Parallel tool calls: all results in ONE user message, in order.
*******************************************************************************/
@@ -1,513 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.jobs;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.jobs.model.JobStatus;
import com.kingsrook.qqq.backend.core.jobs.progress.JobPhase;
import com.kingsrook.qqq.backend.core.jobs.progress.JobProgressSnapshot;
import com.kingsrook.qqq.backend.core.jobs.runtime.DurableJobsRuntime;
import com.kingsrook.qqq.backend.core.jobs.store.DurableJobStore;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
import static com.kingsrook.qqq.backend.core.jobs.DurableJobsTables.TABLE_JOB;
import static com.kingsrook.qqq.backend.core.jobs.DurableJobsTables.TABLE_LOG;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Real Jobs tray rows, progress and cancellation for assistant answers.
*******************************************************************************/
class JobTrackingRunTrackerTest extends BaseAssistantTest
{
private static final String PROMPT = "PRIVATE-PROMPT-JOBS-CANARY";
private static final String ANSWER = "PRIVATE-ANSWER-JOBS-CANARY";
private final CountDownLatch toolEntered = new CountDownLatch(1);
private final CountDownLatch toolRelease = new CountDownLatch(1);
private DurableJobStore jobs;
private AssistantApi api;
private ControlledProvider controlled;
/*******************************************************************************
** Add jobs only to this fixture, leaving runtime startup explicit per test.
*******************************************************************************/
@BeforeEach
void initializeJobs() throws Exception
{
new DurableJobsMetaDataProvider().defineAll(qInstance, AssistantTestFixture.BACKEND, null);
DurableJobsMetaData settings = new DurableJobsMetaData().withWorkerThreads(1).withLeaseTtlSeconds(3)
.withReaperIntervalSeconds(1).withPollIntervalMillis(50).withPodId("test-pod");
qInstance.withSupplementalMetaData(settings);
new QInstanceEnricher(qInstance).enrich();
jobs = new DurableJobStore(settings);
config.getRuntime().close();
config.withUiApiGateway(new CoreUiApiGateway(config::getUiApiBaseUrl, qInstance)
{
/*******************************************************************************
** Hold the real count call so the tool phase can be inspected in storage.
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
if("/table/{tableName}/count".equals(call.getPathTemplate()))
{
toolEntered.countDown();
try
{
assertThat(toolRelease.await(15, TimeUnit.SECONDS)).as("tool released").isTrue();
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Test tool interrupted", e));
}
}
return (super.invoke(call));
}
});
config.setRuntime(new AssistantRuntime(qInstance, config));
var session = AssistantTestFixture.ownerSession(EDITOR, config);
QContext.init(qInstance, session);
api = new AssistantApi(config.getRuntime(), session);
assertThat(config.getRunTracker()).isExactlyInstanceOf(JobTrackingRunTracker.class);
}
/*******************************************************************************
** Release every gate before draining both runtimes, even after an assertion.
*******************************************************************************/
@AfterEach
void stopJobs()
{
toolRelease.countDown();
if(controlled != null)
{
controlled.handles.forEach(handle -> handle.release.countDown());
}
config.getRuntime().close();
DurableJobsRuntime.stop(qInstance, Duration.ofSeconds(5));
}
/*******************************************************************************
** A single linked owner job progresses through thinking, tool and writing.
*******************************************************************************/
@Test
void answerTracksProgressAndCompletes() throws Exception
{
assertThat(DurableJobsRuntime.start(qInstance)).isNotNull();
ControlledHandle thinking = new ControlledHandle(new ModelResponse().withParts(List.of(
ModelPart.toolUse("count-1", "count_records", input("table", "blog_post", "filter", filter("AND")))))
.withStopReason(ModelResponse.STOP_TOOL_USE), null, false);
ControlledHandle writing = answer(false);
useProvider(thinking, writing);
Integer conversationId = (Integer) api.createConversation(Map.of()).get("id");
RunningTurn run = api.startTurn(conversationId, Map.of("text", PROMPT));
entered(thinking);
QRecord job = onlyJob();
Integer jobId = job.getValueInteger("id");
assertThat(job.getValueString("processName")).isEqualTo("assistant.turn");
assertThat(job.getValueString("label")).isEqualTo("Assistant answer");
assertThat(job.getValueString("userId")).isEqualTo(EDITOR);
assertThat(job.getValueString("tableName")).isEqualTo(AssistantConversation.TABLE_NAME);
assertThat(JsonUtils.toObject(job.getValueString("sourceJson"), Map.class)).containsEntry("recordId", conversationId);
assertPhase(jobId, "thinking", null);
thinking.release.countDown();
assertThat(toolEntered.await(15, TimeUnit.SECONDS)).as("tool entered").isTrue();
assertPhase(jobId, "tool", "Counting records");
toolRelease.countDown();
entered(writing);
assertPhase(jobId, "writing", null);
writing.release.countDown();
assertThat(completed(run).getStatus()).isEqualTo("COMPLETE");
job = onlyJob();
assertThat(job.getValueInteger("id")).isEqualTo(jobId);
assertThat(job.getValueString("status")).isEqualTo("COMPLETED");
assertThat(job.getValueString("progressMessage")).isEqualTo("Answered.");
assertThat(job.getValueString("statusReason")).isEqualTo("Answered.");
assertThat(run.getLog().after(0, 1)).filteredOn(event -> TurnEvent.TOOL_RESULT.equals(event.getType()))
.singleElement().satisfies(event -> assertThat(event.getData()).containsEntry("outcome", "OK"));
assertPrivateTextAbsent();
}
/*******************************************************************************
** The runtime's periodic tick observes the same row change as Jobs tray Stop.
*******************************************************************************/
@Test
void jobsTrayStopCancelsAnswer() throws Exception
{
DurableJobsRuntime.start(qInstance);
ControlledHandle writing = answer(false);
useProvider(writing);
RunningTurn run = startTurn();
entered(writing);
Integer jobId = onlyJob().getValueInteger("id");
jobs.updateJob(jobId, Map.of("status", "CANCELLING"));
assertThat(completed(run).getStatus()).isEqualTo("CANCELLED");
assertThat(writing.isCancelled()).isTrue();
assertThat(onlyJob().getValueString("status")).isEqualTo("CANCELLED");
assertThat(onlyJob().getValueString("progressMessage")).isEqualTo("Stopped.");
assertPrivateTextAbsent();
}
/*******************************************************************************
** Even partial output and provider errors containing private text stay out.
*******************************************************************************/
@Test
void failedAnswerHasOnlyFixedJobMessages() throws Exception
{
DurableJobsRuntime.start(qInstance);
ControlledHandle writing = answer(true);
useProvider(writing);
RunningTurn run = startTurn();
entered(writing);
writing.release.countDown();
assertThat(completed(run).getStatus()).isEqualTo("ERROR");
QRecord job = onlyJob();
assertThat(job.getValueString("status")).isEqualTo("FAILED");
assertThat(job.getValueString("progressMessage")).isEqualTo("The answer did not finish.");
assertThat(job.getValueString("statusReason")).isEqualTo("The answer did not finish.");
assertThat(jobs.query(TABLE_LOG, new QQueryFilter())).isNotEmpty();
assertPrivateTextAbsent();
}
/*******************************************************************************
** Hosts without jobs keep ordinary answers and safe null tracker callbacks.
*******************************************************************************/
@Test
void absentRuntimeDoesNotTrackOrDisruptAnswer() throws Exception
{
assertThat(DurableJobsRuntime.get(qInstance)).isNull();
AssistantRunTracker tracker = config.getRunTracker();
assertThat(tracker.start(EDITOR, 1, 1, "ignored")).isNull();
tracker.progress(null, "thinking", PROMPT);
tracker.onCancel(null, () ->
{
throw (new AssertionError("No job to cancel"));
});
tracker.finish(null, "COMPLETE", ANSWER);
provider.thenText(ANSWER);
RunningTurn run = startTurn();
assertThat(completed(run).getStatus()).isEqualTo("COMPLETE");
assertThat(jobs.query(TABLE_JOB, new QQueryFilter())).isEmpty();
assertThat(jobs.query(TABLE_LOG, new QQueryFilter())).isEmpty();
}
/*******************************************************************************
** Core mutates phase objects; every new job must start with fresh phases.
*******************************************************************************/
@Test
void jobsHaveIndependentPhaseState() throws Exception
{
DurableJobsRuntime.start(qInstance);
AssistantRunTracker tracker = config.getRunTracker();
Integer conversationId = (Integer) api.createConversation(Map.of()).get("id");
String first = tracker.start(EDITOR, 1, conversationId, PROMPT);
tracker.progress(first, "writing", ANSWER);
String second = tracker.start(EDITOR, 2, conversationId, PROMPT);
QRecord secondJob = jobs.getJob(Integer.valueOf(second));
JobProgressSnapshot snapshot = JsonUtils.toObject(secondJob.getValueString("progressJson"), JobProgressSnapshot.class);
assertThat(snapshot.getPhases()).extracting(JobPhase::getStatus)
.containsExactly(JobPhase.Status.PENDING, JobPhase.Status.PENDING, JobPhase.Status.PENDING);
tracker.progress(second, "thinking", PROMPT);
tracker.progress(first, "unknown", PROMPT);
tracker.progress("missing", "tool", PROMPT);
assertPhase(Integer.valueOf(first), "writing", null);
assertPhase(Integer.valueOf(second), "thinking", null);
tracker.finish(first, "COMPLETE", ANSWER);
tracker.finish(second, "COMPLETE", ANSWER);
assertPrivateTextAbsent();
}
/*******************************************************************************
** Terminal mapping is fixed, including unknown and absent assistant statuses.
*******************************************************************************/
@ParameterizedTest
@CsvSource({ "COMPLETE,COMPLETED,Answered.", "CANCELLED,CANCELLED,Stopped.",
"ERROR,FAILED,The answer did not finish.", "unknown,FAILED,The answer did not finish.",
",FAILED,The answer did not finish." })
void terminalMapping(String status, JobStatus expected, String message)
{
assertThat(JobTrackingRunTracker.jobStatus(status)).isEqualTo(expected);
assertThat(JobTrackingRunTracker.finishMessage(status)).isEqualTo(message);
}
/*******************************************************************************
** Use independently controlled model calls without changing the tracker.
*******************************************************************************/
private void useProvider(ControlledHandle... handles)
{
controlled = new ControlledProvider(List.of(handles));
config.withModelProvider(controlled);
}
/*******************************************************************************
** Partial text is emitted before this response is released or fails.
*******************************************************************************/
private ControlledHandle answer(boolean fail)
{
return (new ControlledHandle(new ModelResponse().withParts(List.of(ModelPart.text(ANSWER)))
.withStopReason(ModelResponse.STOP_END_TURN), ANSWER, fail));
}
/*******************************************************************************
** Create a conversation and answer as the fixture's editor.
*******************************************************************************/
private RunningTurn startTurn() throws Exception
{
Integer conversationId = (Integer) api.createConversation(Map.of()).get("id");
return (api.startTurn(conversationId, Map.of("text", PROMPT)));
}
/*******************************************************************************
** Wait until a model call has emitted its optional text and is blocked.
*******************************************************************************/
private void entered(ControlledHandle handle) throws InterruptedException
{
assertThat(handle.entered.await(15, TimeUnit.SECONDS)).as("model call entered").isTrue();
}
/*******************************************************************************
** Check persisted progress while the answer is still running.
*******************************************************************************/
private void assertPhase(Integer jobId, String phase, String message) throws QException
{
QRecord job = jobs.getJob(jobId);
assertThat(job.getValueString("status")).isEqualTo("RUNNING");
assertThat(job.getValueString("currentPhase")).isEqualTo(phase);
assertThat(job.getValueString("progressMessage")).isEqualTo(message);
}
/*******************************************************************************
** Assert uniqueness across all jobs, not just those matching this turn.
*******************************************************************************/
private QRecord onlyJob() throws QException
{
List<QRecord> rows = jobs.query(TABLE_JOB, new QQueryFilter());
assertThat(rows).hasSize(1);
return (rows.getFirst());
}
/*******************************************************************************
** Inspect every persisted job and log field for both private canaries.
*******************************************************************************/
private void assertPrivateTextAbsent() throws QException
{
for(String table : List.of(TABLE_JOB, TABLE_LOG))
{
assertThat(jobs.query(table, new QQueryFilter())).allSatisfy(row ->
assertThat(JsonUtils.toJson(row)).doesNotContain(PROMPT, ANSWER));
}
}
/*******************************************************************************
** Wait for finalization and cleanup before reading the assistant turn.
*******************************************************************************/
private AssistantTurn completed(RunningTurn run) throws Exception
{
await(() -> config.getRuntime().getRunningTurn(run.getTurnId()) == null);
assertThat(run.getLog().isClosed()).isTrue();
return (config.getRuntime().getStore().getTurn(EDITOR, run.getTurnId()));
}
/*******************************************************************************
** Bound polling by observable completion rather than fixed timing guesses.
*******************************************************************************/
private static void await(Callable<Boolean> condition) throws Exception
{
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
while(!condition.call() && System.nanoTime() < deadline)
{
Thread.sleep(10);
}
assertThat(condition.call()).as("condition reached within fifteen seconds").isTrue();
}
/*******************************************************************************
** Script model responses with separate gates for thinking and writing.
*******************************************************************************/
private static class ControlledProvider extends ScriptedModelProvider
{
private final List<ControlledHandle> handles;
private final AtomicInteger next = new AtomicInteger();
/*******************************************************************************
** Retain the scripted handles for teardown and assertions.
*******************************************************************************/
ControlledProvider(List<ControlledHandle> handles)
{
this.handles = handles;
}
/*******************************************************************************
** Publish partial text, then let the engine wait on the selected handle.
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
ControlledHandle handle = handles.get(next.getAndIncrement());
if(handle.text != null)
{
listener.onTextDelta(handle.text);
}
return (handle);
}
}
/*******************************************************************************
** A bounded, cancellable model call that can fail after streaming text.
*******************************************************************************/
private static class ControlledHandle implements StreamHandle
{
private final ModelResponse response;
private final String text;
private final boolean fail;
private final CountDownLatch entered = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private volatile boolean cancelled;
/*******************************************************************************
** Set the eventual response and optional partial-output failure.
*******************************************************************************/
ControlledHandle(ModelResponse response, String text, boolean fail)
{
this.response = response;
this.text = text;
this.fail = fail;
}
/*******************************************************************************
** Stop unblocks the engine without test intervention.
*******************************************************************************/
@Override
public void cancel()
{
cancelled = true;
release.countDown();
}
/*******************************************************************************
** Report whether the real cancellation callback reached the provider.
*******************************************************************************/
@Override
public boolean isCancelled()
{
return (cancelled);
}
/*******************************************************************************
** Wait until the test or the Jobs tray releases this model call.
*******************************************************************************/
@Override
public ModelResponse await(Duration timeout) throws ModelProviderException, InterruptedException
{
entered.countDown();
if(!release.await(timeout.toMillis(), TimeUnit.MILLISECONDS))
{
throw (new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "Test provider timed out"));
}
if(cancelled)
{
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
}
if(fail)
{
throw (new ModelProviderException(ModelProviderException.Kind.OTHER, null, PROMPT + " " + ANSWER));
}
return (response);
}
}
}
@@ -50,6 +50,8 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
@@ -166,6 +168,52 @@ class HipaaStorageTest extends BaseAssistantTest
/*******************************************************************************
** User titles are plaintext unless they match this record's stored ciphertext.
*******************************************************************************/
@Test
void tableTitleUpdatesEncryptPrefixLookingPlaintext() throws Exception
{
AssistantStore store = setupProvider();
String owner = AssistantTestFixture.ADMIN;
AssistantConversation conversation = store.createConversation(owner, "Original title", null);
Integer id = conversation.getId();
for(String title : List.of("Renamed title", "enc:v1:literal private user text"))
{
var updated = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", id).withValue("title", title)));
assertThat(updated.getRecords()).hasSize(1).allSatisfy(record -> assertThat(record.getErrors()).isEmpty());
assertThat(rawTitle(id)).startsWith("enc:v1:").isNotEqualTo(title).doesNotContain("private user text");
assertThat(store.getConversation(owner, id).getTitle()).isEqualTo(title);
}
String storedTitle = rawTitle(id);
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", id).withValue("title", storedTitle).withValue("pinned", true)));
assertThat(rawTitle(id)).isEqualTo(storedTitle);
assertThat(store.getConversation(owner, id).getTitle()).isEqualTo("enc:v1:literal private user text");
assertThat(store.getConversation(owner, id).getPinned()).isTrue();
AssistantConversation second = store.createConversation(owner, "Another title", null);
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
.withRecord(new QRecord().withValue("id", second.getId()).withValue("title", storedTitle)));
assertThat(rawTitle(second.getId())).isNotEqualTo(storedTitle);
assertThat(store.getConversation(owner, second.getId()).getTitle()).isEqualTo(storedTitle);
}
/*******************************************************************************
** Inspect stored bytes without the assistant store's decryption boundary.
*******************************************************************************/
private String rawTitle(Integer id) throws Exception
{
return (new QueryAction().execute(new QueryInput(AssistantConversation.TABLE_NAME)
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id))).getRecords().get(0).getValueString("title"));
}
/*******************************************************************************
** Authentication failure never returns ciphertext or unauthenticated plaintext; legacy values still load.
*******************************************************************************/
@@ -60,6 +60,8 @@ import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
@@ -421,6 +423,15 @@ class ScheduledTasksTest extends BaseAssistantTest
assertThat(failed.getStatus()).isEqualTo("FAILED");
assertThat(failed.getError()).doesNotContain("Private provider detail");
new ScheduledAlerts().raise(failed);
NotificationSignal signal = ScheduledAlerts.signal(failed);
assertThat(signal.getAudience().getValues()).containsExactly(AssistantTestFixture.EDITOR);
assertThat(signal.getSeverity()).isEqualTo(NotificationSeverity.ERROR);
assertThat(signal.getBody()).contains("FAILED").doesNotContain("Private provider detail");
assertThat(signal.getLink().getTableName()).isEqualTo("assistant_conversation");
assertThat(signal.getLink().getRecordId()).isEqualTo(failed.getConversationId());
assertThat(ScheduledAlerts.signal(new AssistantScheduledRun().withId(1).withUserId(AssistantTestFixture.EDITOR).withStatus("OK")).getSeverity())
.isEqualTo(NotificationSeverity.SUCCESS);
}
@@ -27,6 +27,8 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -47,6 +49,10 @@ public class TurnRunner
private final QInstance qInstance;
private final AssistantQBitConfig config;
private List<AssistantMessage> messagesAtEnd = List.of();
private AssistantConversation conversationAtEnd;
private List<AssistantToolCall> toolCallsAtEnd = List.of();
/*******************************************************************************
@@ -169,6 +175,12 @@ public class TurnRunner
{
List<TurnEvent> batch = running.getLog().after(last, 1000);
events.addAll(batch);
if(batch.stream().anyMatch(e -> TurnEvent.MESSAGE_END.equals(e.getType())))
{
messagesAtEnd = messagesOf(userId, convId);
conversationAtEnd = conversationOf(userId, convId);
toolCallsAtEnd = toolCalls(userId, running.getTurnId());
}
if(!batch.isEmpty())
{
last = batch.get(batch.size() - 1).getSeq();
@@ -194,6 +206,74 @@ public class TurnRunner
/*******************************************************************************
** The conversation's stored messages, read the moment the run's message.end
** was seen: what the panel finds when it reloads the conversation at the end
** of the stream.
*******************************************************************************/
public List<AssistantMessage> messagesAtEnd()
{
return (messagesAtEnd);
}
/*******************************************************************************
** The conversation row as stored the moment message.end was seen.
*******************************************************************************/
public AssistantConversation conversationAtEnd()
{
return (conversationAtEnd);
}
/*******************************************************************************
** The audited tool calls as stored the moment message.end was seen.
*******************************************************************************/
public List<AssistantToolCall> toolCallsAtEnd()
{
return (toolCallsAtEnd);
}
/*******************************************************************************
** One conversation of the user, read as the user.
*******************************************************************************/
private AssistantConversation conversationOf(String userId, Integer conversationId) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(userId, config));
try
{
return (new AssistantStore().getConversation(userId, conversationId));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The stored messages of a conversation (read as the user).
*******************************************************************************/
private List<AssistantMessage> messagesOf(String userId, Integer conversationId) throws Exception
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(userId, config));
try
{
return (new AssistantStore().listMessages(userId, conversationId, null, 50));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The audited tool calls of a turn (read as the user).
*******************************************************************************/
@@ -37,6 +37,8 @@ import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -62,6 +64,26 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class UiApiToolsSecurityTest extends BaseAssistantTest
{
/*******************************************************************************
** Flat v1 metadata is described without exposing hidden or NONE fields.
*******************************************************************************/
@Test
void describeReadsFlatMetadataAndKeepsExposureFiltering() throws Exception
{
qInstance.getTable("blog_post").getField("metaDescription")
.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
HttpResponse<String> response = ui("GET", "/metaData/table/blog_post", AssistantTestFixture.EDITOR, null);
assertThat(response.statusCode()).isEqualTo(200);
Map<String, Object> metadata = new UiApiResult(response.statusCode(), response.body()).json();
assertThat(metadata).containsKeys("name", "fields").doesNotContainKey("table");
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.DESCRIBE_TABLE, input("table", "blog_post"));
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(outcome.getResult().getModelContent()).contains("\"name\":\"title\"", "\"type\":\"STRING\"")
.doesNotContain("metaDescription", "internalNotes", "apiSecret");
}
/*******************************************************************************
** The default channel wall hides history, even when the user can read audits.
*******************************************************************************/
@@ -22,6 +22,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
@@ -336,6 +337,14 @@ class BulkLoadToolsTest extends BaseAssistantTest
assertThat(runs.get(0).savedBulkLoadProfileId()).isEqualTo(7);
assertThat(runs.get(0).uploadRef()).isEqualTo("up_1");
assertThat(runs.get(0).tableName()).isEqualTo("blog_post");
//////////////////////////////////////////////////////////////////
// the load is a real job: the result carries its id and link, //
// and the confirmation links the conversation to it //
//////////////////////////////////////////////////////////////////
assertThat(ran.get("href")).isEqualTo("/app/jobs_job/42");
Map<String, Object> message = JsonUtils.toObject(String.valueOf(ran.get("message")), new TypeReference<Map<String, Object>>() {});
assertThat(message).containsEntry("jobId", 42).containsEntry("jobHref", "/app/jobs_job/42");
}