@jskit-ai/assistant-runtime 0.1.193 → 0.1.194

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/fixtures/canonical-conversation/App.vue +188 -0
  2. package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
  3. package/fixtures/canonical-conversation/index.html +1 -0
  4. package/fixtures/canonical-conversation/main.js +61 -0
  5. package/fixtures/canonical-conversation/socket.js +50 -0
  6. package/fixtures/canonical-conversation/vite.config.mjs +178 -0
  7. package/fixtures/conversation/vite.config.mjs +12 -2
  8. package/package.json +23 -10
  9. package/src/client/components/AssistantConversationClientElement.vue +57 -0
  10. package/src/client/composables/useAssistantConversation.js +874 -0
  11. package/src/client/index.js +2 -0
  12. package/src/client/support/assistantRuntimeState.js +1 -1
  13. package/src/client/support/subscribeAssistantConversation.js +205 -0
  14. package/src/server/AssistantProvider.js +82 -106
  15. package/src/server/actionIds.js +10 -0
  16. package/src/server/actions.js +287 -79
  17. package/src/server/createAssistantRuntime.js +138 -0
  18. package/src/server/createAssistantSettingsActions.js +89 -0
  19. package/src/server/index.js +6 -0
  20. package/src/server/inputSchemas.js +90 -0
  21. package/src/server/registerConversationSubscriptions.js +163 -0
  22. package/src/server/registerFastifyConversations.js +76 -0
  23. package/src/server/registerRoutes.js +220 -198
  24. package/src/server/registerSettingsRoutes.js +113 -0
  25. package/src/server/repositories/conversationsRepository.js +12 -5
  26. package/src/server/repositories/messagesRepository.js +12 -0
  27. package/src/server/services/chatService.js +88 -694
  28. package/src/server/services/transcriptService.js +56 -52
  29. package/src/server/sqlConversationStorage.js +192 -0
  30. package/src/server/support/assistantRouteContext.js +91 -0
  31. package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
  32. package/src/shared/assistantSurfaces.js +14 -0
  33. package/src/shared/conversationRealtime.js +9 -0
  34. package/src/shared/index.js +1 -0
  35. package/test/actionDefinitions.test.js +44 -0
  36. package/test/aiConnections.test.js +1 -1
  37. package/test/assistantConversationBinding.browser.test.js +1868 -0
  38. package/test/assistantConversationOperations.test.js +459 -0
  39. package/test/assistantConversationSubscriptions.test.js +692 -0
  40. package/test/assistantIntegrationBoundary.test.js +103 -0
  41. package/test/assistantRuntimeComposition.test.js +68 -0
  42. package/test/assistantSurfacePattern.test.js +1 -1
  43. package/test/assistantSurfaces.test.js +23 -0
  44. package/test/chatServiceLifecycle.test.js +12 -1
  45. package/test/featureRuntime.test.js +238 -0
  46. package/test/registerFastifyConversations.test.js +169 -0
  47. package/test/sqlConversationStorage.test.js +205 -0
  48. package/test/support/memoryAssistantRows.js +51 -0
  49. package/test/turnRequests.test.js +11 -2
  50. package/src/shared/assistantResponseText.js +0 -29
@@ -1,5 +1,6 @@
1
1
  import { createSchema } from "json-rest-schema";
2
2
  import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
3
+ import { composeSchemaDefinitions } from "@jskit-ai/kernel/shared/validators";
3
4
 
4
5
  const assistantSurfaceRouteParamsSchema = createSchema({
5
6
  surfaceId: {
@@ -38,7 +39,96 @@ const assistantTargetSurfaceInputValidator = deepFreeze({
38
39
  mode: "patch"
39
40
  });
40
41
 
42
+ const assistantConversationIdInputValidator = deepFreeze({
43
+ schema: createSchema({
44
+ conversationId: { type: "string", required: true, minLength: 1 }
45
+ }),
46
+ mode: "patch"
47
+ });
48
+
49
+ const assistantConversationMessageIdInputValidator = deepFreeze({
50
+ schema: createSchema({
51
+ messageId: { type: "string", required: true, minLength: 1, maxLength: 128 }
52
+ }),
53
+ mode: "patch"
54
+ });
55
+
56
+ const assistantConversationReadQueryValidator = deepFreeze({
57
+ schema: createSchema({
58
+ beforeTurnId: { type: "string", noTrim: false, required: false },
59
+ limit: { type: "string", noTrim: false, required: false }
60
+ }),
61
+ mode: "patch"
62
+ });
63
+
64
+ const assistantConversationSendBodyValidator = deepFreeze({
65
+ schema: createSchema({
66
+ messageId: { type: "string", required: true, minLength: 1, maxLength: 128 },
67
+ text: { type: "string", required: true },
68
+ attachmentIds: {
69
+ type: "array", required: false, maxItems: 10,
70
+ items: { type: "string", minLength: 1, maxLength: 256 }
71
+ },
72
+ steer: { type: "boolean", required: false }
73
+ }),
74
+ mode: "patch"
75
+ });
76
+
77
+ function conversationSendBodyValidator(conversationDataSchema = null) {
78
+ if (!conversationDataSchema) return assistantConversationSendBodyValidator;
79
+ return composeSchemaDefinitions([assistantConversationSendBodyValidator, {
80
+ schema: createSchema({ data: { type: "object", required: false, schema: conversationDataSchema } }),
81
+ mode: "patch"
82
+ }], { mode: "patch", context: "assistant-runtime declared conversation data" });
83
+ }
84
+
85
+ function conversationConfigureBodyValidator(configurationSchema) {
86
+ return deepFreeze({
87
+ schema: createSchema({ configuration: { type: "object", required: true, schema: configurationSchema } }),
88
+ mode: "replace"
89
+ });
90
+ }
91
+
92
+ function conversationSelectBodyValidator(selectionSchema) {
93
+ return deepFreeze({
94
+ schema: createSchema({ selection: { type: "object", required: true, schema: selectionSchema } }),
95
+ mode: "replace"
96
+ });
97
+ }
98
+
99
+ function conversationReplaceBodyValidator(replacementSchema) {
100
+ return deepFreeze({
101
+ schema: createSchema({ replacement: { type: "object", required: true, schema: replacementSchema } }),
102
+ mode: "replace"
103
+ });
104
+ }
105
+
106
+ const assistantConversationGoalBodyValidator = deepFreeze({
107
+ schema: createSchema({
108
+ action: { type: "string", required: true, enum: ["set", "resume", "pause", "cancel"] },
109
+ expectedSegmentId: { type: "string", required: true, nullable: true, minLength: 1, maxLength: 128 },
110
+ expectedGoalId: { type: "string", required: false, nullable: true, minLength: 1, maxLength: 256 },
111
+ messageId: { type: "string", required: false, minLength: 1, maxLength: 128 },
112
+ objective: { type: "string", required: false, minLength: 1, maxLength: 4000 },
113
+ tokenBudget: { type: "integer", required: false, nullable: true, min: 1, max: Number.MAX_SAFE_INTEGER },
114
+ attachmentIds: {
115
+ type: "array", required: false, maxItems: 10,
116
+ items: { type: "string", minLength: 1, maxLength: 256 }
117
+ }
118
+ }),
119
+ mode: "replace"
120
+ });
121
+
41
122
  export {
123
+ assistantConversationIdInputValidator,
124
+ assistantConversationMessageIdInputValidator,
125
+ assistantConversationReadQueryValidator,
126
+ assistantConversationSendBodyValidator,
127
+ conversationSendBodyValidator,
128
+ conversationConfigureBodyValidator,
129
+ conversationSelectBodyValidator,
130
+ conversationReplaceBodyValidator,
131
+ assistantConversationGoalBodyValidator,
42
132
  assistantSurfaceRouteParamsValidator,
43
133
  assistantTargetSurfaceInputValidator
44
134
  };
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { attachDirectRequestActionExecutor } from "@jskit-ai/kernel/server/http";
3
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
4
+ import { actionIds } from "./actionIds.js";
5
+ import { resolveRouteRequestState } from "./support/assistantRouteContext.js";
6
+ import { resolveAssistantSurfaceConfig } from "../shared/assistantSurfaces.js";
7
+ import {
8
+ ASSISTANT_CONVERSATION_EVENT,
9
+ ASSISTANT_CONVERSATION_SUBSCRIBE,
10
+ ASSISTANT_CONVERSATION_UNSUBSCRIBE
11
+ } from "../shared/conversationRealtime.js";
12
+
13
+ function requireSubscriptionId(input) {
14
+ if (typeof input?.subscriptionId !== "string" || !input.subscriptionId.trim() || input.subscriptionId.length > 128) {
15
+ throw new AppError(400, "A conversation subscription id is required.");
16
+ }
17
+ return input.subscriptionId;
18
+ }
19
+
20
+ function subscriptionError(error) {
21
+ const status = Number(error?.status || error?.statusCode || 500);
22
+ return {
23
+ ok: false,
24
+ error: status < 500 ? String(error.message || "Conversation subscription failed.") : "Conversation subscription failed.",
25
+ code: String(error?.code || "assistant_subscription_failed"),
26
+ status
27
+ };
28
+ }
29
+
30
+ // Register one conversation subscription owner per shared realtime runtime.
31
+ // A host may select its fixed action/request policy at composition; clients
32
+ // never choose either. The selected action remains the authorization boundary.
33
+ function registerConversationSubscriptions({ realtime, events, actions, config = {}, workspaceScopeSupport = null,
34
+ subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated" } = {}) {
35
+ if (typeof realtime?.onConnection !== "function" || typeof events?.publish !== "function") {
36
+ throw new TypeError("Conversation subscriptions require the shared realtime connection and event runtime.");
37
+ }
38
+ if (typeof actions?.execute !== "function" || typeof actions?.getDefinition !== "function") {
39
+ throw new TypeError("Conversation subscriptions require the assistant action catalogue.");
40
+ }
41
+ if (!["authenticated", "host"].includes(requestPolicy)) {
42
+ throw new TypeError("Conversation subscription requestPolicy must be authenticated or host.");
43
+ }
44
+ if (typeof subscribeActionId !== "string" || !subscribeActionId.trim() ||
45
+ (requestPolicy === "host" && subscribeActionId === actionIds.conversationSubscribe)) {
46
+ throw new TypeError("Host conversation subscriptions require an explicit product subscribeActionId.");
47
+ }
48
+
49
+ return realtime.onConnection(({ socket, authenticate, readRequest }) => {
50
+ const subscriptions = new Map();
51
+ let closed = false;
52
+
53
+ function release(subscriptionId) {
54
+ const subscription = subscriptions.get(subscriptionId);
55
+ if (!subscription) return;
56
+ subscriptions.delete(subscriptionId);
57
+ clearTimeout(subscription.timer);
58
+ subscription.pending.clear();
59
+ subscription.release?.();
60
+ }
61
+
62
+ async function subscribe(input, acknowledge) {
63
+ if (typeof acknowledge !== "function" || closed) return;
64
+ let subscription;
65
+ let subscriptionId;
66
+ try {
67
+ subscriptionId = requireSubscriptionId(input);
68
+ release(subscriptionId);
69
+ subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve() };
70
+ subscriptions.set(subscriptionId, subscription);
71
+ const current = () => !closed && socket.connected && subscriptions.get(subscriptionId) === subscription;
72
+ const request = await (requestPolicy === "host" ? readRequest() : authenticate());
73
+ if (!current()) return;
74
+ const assistantSurface = resolveAssistantSurfaceConfig(config, input.targetSurfaceId);
75
+ if (!assistantSurface) throw new AppError(404, "Assistant not found.");
76
+ const requiresWorkspace = assistantSurface.runtimeSurfaceRequiresWorkspace;
77
+ request.headers = { ...request.headers, "x-jskit-surface": input.hostSurfaceId };
78
+ request.input = { params: {
79
+ surfaceId: input.targetSurfaceId,
80
+ ...(Object.hasOwn(input, "workspaceSlug") ? { workspaceSlug: input.workspaceSlug } : {})
81
+ } };
82
+ request.routeOptions = { config: { visibility: requiresWorkspace ? "workspace" : "public" } };
83
+ const routeState = resolveRouteRequestState(request, {
84
+ resolveCurrentAppConfig: () => config, requiresWorkspace, workspaceScopeSupport
85
+ });
86
+ attachDirectRequestActionExecutor({ actions, request });
87
+ const actorId = request.user?.id;
88
+
89
+ function publish(event, revision) {
90
+ subscription.queue = subscription.queue.then(async () => {
91
+ if (!current()) return;
92
+ await events.publish({
93
+ type: "entity.changed", source: "assistant", entity: "conversation", operation: "updated",
94
+ entityId: input.conversationId,
95
+ ...(actorId ? { actorId, scope: { kind: "user", id: actorId } } : {}),
96
+ realtime: {
97
+ audience: { room: socket.id },
98
+ event: ASSISTANT_CONVERSATION_EVENT,
99
+ payload: { subscriptionId, conversationId: input.conversationId,
100
+ streamEpoch: subscription.epoch, streamRevision: revision, event }
101
+ }
102
+ });
103
+ }).catch(() => {}); // A notification failure cannot fail or repeat a model turn.
104
+ }
105
+ function flush() {
106
+ clearTimeout(subscription.timer);
107
+ subscription.timer = null;
108
+ for (const pending of [...subscription.pending.values()].sort((left, right) => left.revision - right.revision)) {
109
+ publish(pending.event, pending.revision);
110
+ }
111
+ subscription.pending.clear();
112
+ }
113
+ function onEvent(event) {
114
+ if (!current()) return;
115
+ const revision = ++subscription.revision;
116
+ // The Colleague publisher already coalesces transient full-text replies.
117
+ if (event.type === "message" && event.status === "inProgress") {
118
+ subscription.pending.set(event.messageId, { event, revision });
119
+ subscription.timer ||= setTimeout(flush, 25);
120
+ } else {
121
+ flush();
122
+ publish(event, revision);
123
+ }
124
+ }
125
+ const state = await request.executeAction({
126
+ actionId: subscribeActionId, channel: "internal", surface: routeState.hostSurfaceId,
127
+ input: { ...routeState.actionInput, conversationId: input.conversationId },
128
+ deps: {
129
+ onEvent,
130
+ onRelease(detach) {
131
+ if (!current()) detach();
132
+ else subscription.release = detach;
133
+ }
134
+ }
135
+ });
136
+ if (!current()) return;
137
+ acknowledge({ ok: true, subscriptionId, streamEpoch: subscription.epoch, state });
138
+ } catch (error) {
139
+ if (subscriptions.get(subscriptionId) === subscription) release(subscriptionId);
140
+ if (!closed && socket.connected) acknowledge(subscriptionError(error));
141
+ }
142
+ }
143
+
144
+ function unsubscribe(input, acknowledge) {
145
+ try {
146
+ release(requireSubscriptionId(input));
147
+ if (typeof acknowledge === "function") acknowledge({ ok: true });
148
+ } catch (error) {
149
+ if (typeof acknowledge === "function") acknowledge(subscriptionError(error));
150
+ }
151
+ }
152
+ socket.on(ASSISTANT_CONVERSATION_SUBSCRIBE, subscribe);
153
+ socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, unsubscribe);
154
+ return () => {
155
+ closed = true;
156
+ socket.off(ASSISTANT_CONVERSATION_SUBSCRIBE, subscribe);
157
+ socket.off(ASSISTANT_CONVERSATION_UNSUBSCRIBE, unsubscribe);
158
+ for (const subscriptionId of subscriptions.keys()) release(subscriptionId);
159
+ };
160
+ });
161
+ }
162
+
163
+ export { registerConversationSubscriptions };
@@ -0,0 +1,76 @@
1
+ import { createActionProvider } from "@jskit-ai/kernel/server/actions";
2
+ import { HttpProvider } from "@jskit-ai/kernel/server/http";
3
+ import { AppError, BootstrapProvider, EventProvider } from "@jskit-ai/kernel/server/runtime";
4
+ import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
5
+ import { AssistantFeature } from "./AssistantProvider.js";
6
+
7
+ /**
8
+ * Configure the existing conversation feature in a plain Fastify application.
9
+ * The caller owns runtime creation/storage and authentication. This registration
10
+ * owns its HTTP/realtime hosting and shuts it down before Fastify closes.
11
+ * JSKIT applications register AssistantFeature on their existing host instead.
12
+ */
13
+ async function registerFastifyConversations(app, {
14
+ runtime,
15
+ config,
16
+ authenticate,
17
+ bootstrap,
18
+ env = process.env
19
+ } = {}) {
20
+ if (typeof runtime?.open !== "function" || typeof authenticate !== "function" ||
21
+ !config || typeof config !== "object" || Array.isArray(config)) {
22
+ throw new TypeError("Fastify conversations require runtime, config and authenticate(request).");
23
+ }
24
+ async function requestContext(request) {
25
+ const context = await authenticate(request);
26
+ if (!context || typeof context !== "object" || Array.isArray(context)) {
27
+ throw new AppError(401, "Authentication required.");
28
+ }
29
+ return context;
30
+ }
31
+ const AccessProvider = defineProvider({
32
+ id: "assistant.fastify.access",
33
+ requires: { http: "runtime.http", bootstrap: "runtime.bootstrap", actions: "runtime.actions" },
34
+ provides: { access: "assistant.conversation.access" },
35
+ setup({ http, bootstrap: bootstrapRuntime, actions }) {
36
+ actions.registerContextContributor({
37
+ id: "assistant.fastify.authentication",
38
+ contribute: ({ context }) => requestContext(context.requestMeta?.request)
39
+ });
40
+ bootstrapRuntime.register({
41
+ id: "assistant.fastify.application",
42
+ async contribute({ request }) {
43
+ const context = await requestContext(request);
44
+ return bootstrap ? bootstrap({ request, context }) : {};
45
+ }
46
+ });
47
+ return { access: {
48
+ router: { register(method, path, contract, handler) {
49
+ http.router.register(method, path, { ...contract, auth: "public" }, async (request, reply) => {
50
+ await requestContext(request);
51
+ return handler(request, reply);
52
+ });
53
+ } },
54
+ wrapAction(definition) {
55
+ return { ...definition, permission: { require: "none" } };
56
+ },
57
+ subscribeActionId: "assistant.fastify.conversation.subscribe",
58
+ requestPolicy: "host"
59
+ } };
60
+ }
61
+ });
62
+ // Realtime remains optional for consumers using the feature on their own host.
63
+ const { RealtimeProvider } = await import("@jskit-ai/realtime/server/RealtimeProvider");
64
+ const host = createCapabilityRuntime({
65
+ providers: [EventProvider, createActionProvider(), HttpProvider, BootstrapProvider,
66
+ RealtimeProvider, AccessProvider, AssistantFeature],
67
+ inputs: { "runtime.config": config, "runtime.env": env, "runtime.fastify": app,
68
+ "runtime.logger": app.log, "assistant.conversations": runtime }
69
+ });
70
+ app.addHook("preClose", async () => {
71
+ if (host.diagnostics().lifecycleState === "started") await host.shutdown();
72
+ });
73
+ await host.start();
74
+ }
75
+
76
+ export { registerFastifyConversations };