@jskit-ai/assistant-runtime 0.1.192 → 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
@@ -0,0 +1,188 @@
1
+ <script setup>
2
+ import { computed, defineComponent, effectScope, inject, nextTick, onMounted, onScopeDispose, ref, shallowRef } from "vue";
3
+ import { useAssistantAttachments } from "@jskit-ai/assistant-core/client/conversation-attachments";
4
+ import { useAssistantSuggestions } from "@jskit-ai/assistant-core/client/conversation-suggestions";
5
+ import AssistantConversationClientElement from "../../src/client/components/AssistantConversationClientElement.vue";
6
+ import { useAssistantConversation, useAssistantConversationFactory } from "../../src/client/composables/useAssistantConversation.js";
7
+ import DraftConversation from "./DraftConversation.vue";
8
+
9
+ const { placement, socket } = inject("fixture");
10
+ const factoryScope = effectScope();
11
+ const acquireConversation = factoryScope.run(() => useAssistantConversationFactory());
12
+ const fixtureOptions = new URLSearchParams(window.location.search);
13
+ const conversationId = ref(fixtureOptions.has("unresolved") ? "" : "chat:1");
14
+ const visible = ref(true);
15
+ const multiple = ref(false);
16
+ const mirror = ref(false);
17
+ const conversation = ref(null);
18
+ const voice = shallowRef(null);
19
+ const acceptedDraft = fixtureOptions.has("acceptedDraft");
20
+ const draftWhileLoading = fixtureOptions.has("draftWhileLoading");
21
+ const presentation = ref({});
22
+ const dispatches = [];
23
+ const recordDispatch = messageId => dispatches.push(messageId);
24
+ const eventReaders = ref([]);
25
+ const readerEvents = [];
26
+ const EventReader = defineComponent({
27
+ props: { reader: { type: Object, required: true } },
28
+ setup(props) {
29
+ const binding = useAssistantConversation({
30
+ conversationId, active: () => props.reader.active !== false,
31
+ goal: () => props.reader.goal === true,
32
+ onEvent(event) {
33
+ const streaming = binding.runtime.value?.snapshot.value?.streaming;
34
+ readerEvents.push({ reader: props.reader.id, type: event.type, marker: event.marker,
35
+ revision: streaming?.revision ?? null, text: streaming?.messages.at(-1)?.text || "" });
36
+ if (props.reader.id === "first" && event.failure === "throw") throw new Error("Fixture presentation failed.");
37
+ if (props.reader.id === "first" && event.failure === "reject") return Promise.reject(new Error("Fixture refresh failed."));
38
+ }
39
+ });
40
+ return () => null;
41
+ }
42
+ });
43
+ const authoredData = ref(undefined);
44
+ const attachmentsEnabled = ref(false);
45
+ const attachmentActor = ref("42");
46
+ const uploads = [];
47
+ const deleted = [];
48
+ const acknowledged = [];
49
+ const heldUploads = [];
50
+ const attachments = useAssistantAttachments({
51
+ sessionId: computed(() => `${attachmentActor.value}/${conversationId.value}`),
52
+ canUpload: attachmentsEnabled,
53
+ uploadAttachment(sessionId, file, { onProgress }) {
54
+ const receipt = { attachmentId: crypto.randomUUID(), fileName: file.name, size: file.size, sessionId };
55
+ uploads.push({ sessionId, fileName: file.name, attachmentId: receipt.attachmentId });
56
+ onProgress({ bytesSent: file.size / 2, totalBytes: file.size });
57
+ if (file.name.startsWith("held-")) return new Promise(resolve => heldUploads.push(() => resolve(receipt)));
58
+ return Promise.resolve(receipt);
59
+ },
60
+ deleteAttachment(sessionId, attachmentId) { deleted.push({ sessionId, attachmentId }); return { ok: true }; }
61
+ });
62
+ const clearAttachments = attachments.clearAttachments;
63
+ attachments.clearAttachments = options => {
64
+ acknowledged.push([...options.attachmentIds]);
65
+ return clearAttachments(options);
66
+ };
67
+ const suggestionsEnabled = ref(false);
68
+ const suggestionMode = ref("ready");
69
+ const suggestionModel = ref("suggestions-small");
70
+ const suggestionRequests = [];
71
+ const target = computed(() => conversation.value?.runtime || null);
72
+ const modelsEnabled = ref(false);
73
+ const modelId = ref("small");
74
+ const appliedModel = ref("small");
75
+ const modelSaving = ref(false);
76
+ const modelCatalogError = ref("");
77
+ const modelSelections = [];
78
+ const modelChangesDisabled = computed(() => target.value?.snapshot.value?.status === "working");
79
+ let finishModelApply;
80
+ const models = {
81
+ providerRows: computed(() => [{ id: "application", label: "Application choices" }]),
82
+ modelProviderId: ref("application"),
83
+ modelRows: computed(() => [{ id: "small", label: "Small model" }, { id: "large", label: "Large model" }]),
84
+ modelId, selectionSummary: computed(() => `Selected ${appliedModel.value}`),
85
+ changesDisabled: modelChangesDisabled,
86
+ canSave: computed(() => !modelChangesDisabled.value && !modelCatalogError.value && modelId.value !== appliedModel.value), saving: modelSaving,
87
+ catalogLoading: ref(false), catalogError: modelCatalogError,
88
+ selectModel(value) { modelId.value = value; },
89
+ async apply() {
90
+ const selected = modelId.value;
91
+ modelSelections.push(selected);
92
+ modelSaving.value = true;
93
+ await new Promise(resolve => { finishModelApply = resolve; });
94
+ appliedModel.value = selected;
95
+ modelSaving.value = false;
96
+ },
97
+ reload() { modelCatalogError.value = ""; }
98
+ };
99
+ const questions = ref(false);
100
+ const suggestions = useAssistantSuggestions({
101
+ active: () => Boolean(suggestionsEnabled.value && visible.value && target.value?.available.value &&
102
+ target.value.snapshot.value?.status === "ready"),
103
+ requestKey: () => target.value?.identity || null,
104
+ draft: () => target.value?.draft.value || "",
105
+ configuration: () => ({ integrationId: "suggestions", model: suggestionModel.value }),
106
+ debounceMs: 0,
107
+ generate(input) {
108
+ const request = { ...input, target: { ...target.value.identity } };
109
+ suggestionRequests.push(request);
110
+ if (suggestionMode.value === "hold") return new Promise(resolve => { request.resolve = resolve; });
111
+ return [{ label: "Suggested next step", prompt: `Suggested by ${input.configuration.model}` }];
112
+ },
113
+ onSelect(prompt) {
114
+ if (!target.value?.available.value) return false;
115
+ target.value.draft.value = prompt;
116
+ return true;
117
+ }
118
+ });
119
+ let retained;
120
+ onMounted(() => {
121
+ window.conversationFixture = {
122
+ socket,
123
+ acquireConversation,
124
+ releaseFactory() { factoryScope.stop(); },
125
+ target(id) { conversationId.value = id; },
126
+ show(value) { visible.value = value; },
127
+ multiple(value) { multiple.value = value; },
128
+ mirror(value) { mirror.value = value; },
129
+ async eventReaders(value) { eventReaders.value = value; await nextTick(); },
130
+ readerEvents() { return [...readerEvents]; },
131
+ actor(id) { placement.setContext({ user: id ? { id } : null }); attachmentActor.value = id; },
132
+ current() { return conversation.value?.runtime; },
133
+ presentation(value) { presentation.value = value; },
134
+ adapter() { return conversation.value?.adapter; },
135
+ submit() { return conversation.value.submit(); },
136
+ dispatches() { return [...dispatches]; },
137
+ retainVoice() { retained?.release(); retained = conversation.value.runtime.retain(); voice.value = retained.runtime; },
138
+ releaseVoice() { retained?.release(); retained = null; voice.value = null; },
139
+ sendVoice(text, data) { return voice.value.send({ message: text, ...(data !== undefined ? { data } : {}) }); },
140
+ data(value) { authoredData.value = value; },
141
+ changeFocus(value) { authoredData.value.focus = value; },
142
+ attachments(enabled) { attachmentsEnabled.value = enabled; },
143
+ finishUploads() { for (const resolve of heldUploads.splice(0)) resolve(); },
144
+ suggestions(enabled, mode = "ready") { suggestionMode.value = mode; suggestionsEnabled.value = enabled; },
145
+ questions(options) { questions.value = options; },
146
+ models(enabled) { modelsEnabled.value = enabled; },
147
+ modelError(message) { modelCatalogError.value = message; },
148
+ finishModelApply() { finishModelApply?.(); finishModelApply = null; },
149
+ modelState() { return { selected: modelId.value, applied: appliedModel.value, saving: modelSaving.value, selections: [...modelSelections] }; },
150
+ suggestionModel(value) { suggestionModel.value = value; },
151
+ resolveSuggestion(index, prompt) { suggestionRequests[index]?.resolve?.([{ label: "Suggested next step", prompt }]); },
152
+ suggestionState() { return { items: suggestions.items.value, preview: suggestions.preview.value,
153
+ requests: suggestionRequests.map(({ target, draft, configuration, signal }) => ({ target, draft, configuration, aborted: signal.aborted })) }; },
154
+ attachmentState() { return { ready: attachments.attachments.value.map(file => ({ ...file })),
155
+ queue: attachments.queueItems.value.map(row => ({ fileName: row.fileName, phase: row.phase })),
156
+ uploads, deleted, acknowledged }; },
157
+ voiceState() { return voice.value && { current: voice.value.current.value, available: voice.value.available.value,
158
+ id: voice.value.identity.conversationId, draft: voice.value.draft.value, turns: voice.value.delivery.turns(voice.value.turns.value) }; }
159
+ };
160
+ });
161
+ onScopeDispose(() => retained?.release());
162
+ </script>
163
+
164
+ <template>
165
+ <v-app>
166
+ <main class="fixture">
167
+ <EventReader v-for="eventReader in eventReaders" :key="eventReader.id" :reader="eventReader" />
168
+ <div v-if="visible" class="fixture__conversation" data-primary>
169
+ <DraftConversation v-if="acceptedDraft" ref="conversation" :conversation-id="conversationId" :data="authoredData" :attachments="attachmentsEnabled ? attachments : null" :presentation="presentation" :on-dispatch="recordDispatch" :draft-while-loading="draftWhileLoading" />
170
+ <AssistantConversationClientElement v-else ref="conversation" :conversation-id="conversationId" :data="authoredData" :attachments="attachmentsEnabled ? attachments : null" :suggestions="suggestionsEnabled ? suggestions : null" :models="modelsEnabled ? models : null" :questions="questions" assistant-label="Primary conversation" />
171
+ </div>
172
+ <div v-if="mirror" class="fixture__conversation" data-mirror>
173
+ <DraftConversation v-if="acceptedDraft" :conversation-id="conversationId" :data="authoredData" :attachments="attachmentsEnabled ? attachments : null" :presentation="presentation" :on-dispatch="recordDispatch" :draft-while-loading="draftWhileLoading" />
174
+ <AssistantConversationClientElement v-else :conversation-id="conversationId" :data="authoredData" :attachments="attachmentsEnabled ? attachments : null" :suggestions="suggestionsEnabled ? suggestions : null" :models="modelsEnabled ? models : null" :questions="questions" assistant-label="Mirror conversation" />
175
+ </div>
176
+ <div v-for="index in (multiple ? [2, 3, 4, 5] : [])" :key="index" class="fixture__conversation" :data-chat="index">
177
+ <AssistantConversationClientElement :conversation-id="`chat:${index}`" :assistant-label="`Conversation ${index}`" />
178
+ </div>
179
+ <output v-if="voice" data-voice>{{ voice.identity.conversationId }} {{ voice.current.value ? 'current' : 'retired' }}</output>
180
+ </main>
181
+ </v-app>
182
+ </template>
183
+
184
+ <style>
185
+ html, body, #app { margin: 0; height: 100%; }
186
+ .fixture { display: grid; gap: 1rem; padding: 1rem; max-width: 72rem; width: 100%; margin: 0 auto; }
187
+ .fixture__conversation { height: min(720px, calc(100dvh - 3rem)); min-height: 360px; min-width: 0; }
188
+ </style>
@@ -0,0 +1,32 @@
1
+ <script setup>
2
+ import { toRef } from "vue";
3
+ import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
4
+ import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
5
+ import { useAssistantConversation } from "../../src/client/composables/useAssistantConversation.js";
6
+
7
+ const props = defineProps({
8
+ conversationId: { type: String, required: true },
9
+ data: { type: Object, default: undefined },
10
+ attachments: { type: Object, default: null },
11
+ presentation: { type: Object, default: () => ({}) },
12
+ draftWhileLoading: { type: Boolean, default: false },
13
+ onDispatch: { type: Function, required: true }
14
+ });
15
+ const api = createAssistantApi({ request: assistantHttpClient.request,
16
+ resolveBasePath: () => "/api/assistant/home", resolveSurfaceId: () => "home" });
17
+ const binding = useAssistantConversation({
18
+ conversationId: toRef(props, "conversationId"), data: toRef(props, "data"),
19
+ presentation: toRef(props, "presentation"),
20
+ attachments: toRef(props, "attachments"),
21
+ api: { ...api, sendConversationMessage(id, input) {
22
+ props.onDispatch(input.messageId);
23
+ return api.sendConversationMessage(id, input);
24
+ } },
25
+ clearDraftOn: "accepted", queueWhileSending: false, draftWhileLoading: props.draftWhileLoading
26
+ });
27
+ defineExpose({ runtime: binding.runtime, adapter: binding.adapter, submit: () => binding.adapter.value.actions.submit() });
28
+ </script>
29
+
30
+ <template>
31
+ <AssistantConversationElement :adapter="binding.adapter.value" label="Accepted draft conversation" style="height: 100%" />
32
+ </template>
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>
@@ -0,0 +1,61 @@
1
+ import { createApp, ref } from "vue";
2
+ import { createRouter, createWebHistory } from "vue-router";
3
+ import { createVuetify } from "vuetify";
4
+ import * as components from "vuetify/components";
5
+ import * as directives from "vuetify/directives";
6
+ import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
7
+ import "vuetify/styles";
8
+ import App from "./App.vue";
9
+ import { createWebPlacementRuntime } from "../../../shell-web/src/client/placement/runtime.js";
10
+ import { createFixtureSocket } from "./socket.js";
11
+ import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
12
+ import { configureAssistantConversations, useAssistantConversation } from "../../src/client/composables/useAssistantConversation.js";
13
+
14
+ globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
15
+ surfaceDefinitions: { home: { requiresWorkspace: false } },
16
+ assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
17
+ };
18
+ const placement = createWebPlacementRuntime({ components: new Map() });
19
+ placement.setContext({ user: { id: "42" }, surfaceConfig: {
20
+ defaultSurfaceId: "home", surfacesById: { home: { id: "home", routeBase: "/", requiresWorkspace: false } }
21
+ } });
22
+ const socket = createFixtureSocket();
23
+ const app = createApp(App);
24
+ const router = createRouter({ history: createWebHistory(), routes: [{ path: "/:pathMatch(.*)*", component: App }] });
25
+ if (new URLSearchParams(window.location.search).has("applicationDefaults")) {
26
+ const actor = ref("application-user");
27
+ const requests = [], overrideRequests = [];
28
+ configureAssistantConversations(app, {
29
+ actorKey: actor, clearDraftOn: "accepted",
30
+ request(path, options) { requests.push(path); return assistantHttpClient.request(path, options); }
31
+ });
32
+ const overrideApi = createAssistantApi({
33
+ request(path, options) { overrideRequests.push(path); return assistantHttpClient.request(path, options); },
34
+ resolveBasePath: () => "/api/assistant/home", resolveSurfaceId: () => "home"
35
+ });
36
+ let otherApp, otherBinding, otherRoot;
37
+ window.conversationDefaultsFixture = {
38
+ actor(value) { actor.value = value; }, requests, overrideRequests, overrideApi,
39
+ mountOtherApp() {
40
+ otherApp = createApp({ setup() {
41
+ otherBinding = useAssistantConversation({ conversationId: "chat:1" });
42
+ return () => null;
43
+ } });
44
+ otherApp.provide("jskit.shell-web.runtime.web-placement.client", placement);
45
+ otherApp.provide("jskit.realtime.runtime.client.socket", socket);
46
+ otherApp.use(router);
47
+ otherRoot = document.createElement("div");
48
+ document.body.append(otherRoot);
49
+ otherApp.mount(otherRoot);
50
+ },
51
+ other() { return otherBinding.runtime.value; },
52
+ unmountOtherApp() { otherApp.unmount(); otherRoot.remove(); }
53
+ };
54
+ }
55
+ app.provide("jskit.shell-web.runtime.web-placement.client", placement);
56
+ app.provide("jskit.realtime.runtime.client.socket", socket);
57
+ app.provide("fixture", { placement, socket });
58
+ app.use(router).use(createVuetify({ theme: { defaultTheme: "light" }, components, directives,
59
+ icons: { defaultSet: "mdi", aliases, sets: { mdi } } }));
60
+ await router.isReady();
61
+ app.mount("#app");
@@ -0,0 +1,50 @@
1
+ import { ASSISTANT_CONVERSATION_EVENT, ASSISTANT_CONVERSATION_SUBSCRIBE, ASSISTANT_CONVERSATION_UNSUBSCRIBE } from "../../src/shared/conversationRealtime.js";
2
+
3
+ // Controlled transport fixture. The production Socket.IO/auth/lifetime contract
4
+ // is exercised separately by assistantConversationSubscriptions.test.js.
5
+ export function createFixtureSocket() {
6
+ const listeners = new Map();
7
+ const subscriptions = new Map();
8
+ const calls = [];
9
+ const fire = (event, payload) => { for (const listener of [...listeners.get(event) || []]) listener(payload); };
10
+ const socket = {
11
+ connected: true,
12
+ on(event, listener) {
13
+ if (!listeners.has(event)) listeners.set(event, new Set());
14
+ listeners.get(event).add(listener);
15
+ },
16
+ off(event, listener) { listeners.get(event)?.delete(listener); },
17
+ timeout() { return socket; },
18
+ async emit(event, input, acknowledge) {
19
+ calls.push({ event, input: structuredClone(input) });
20
+ if (event === ASSISTANT_CONVERSATION_UNSUBSCRIBE) { subscriptions.delete(input.subscriptionId); return; }
21
+ if (event !== ASSISTANT_CONVERSATION_SUBSCRIBE) throw new Error(`Unexpected fixture socket command ${event}`);
22
+ const subscription = { ...input, epoch: crypto.randomUUID(), revision: 0 };
23
+ subscriptions.set(input.subscriptionId, subscription);
24
+ const response = await fetch(`/api/assistant/${input.targetSurfaceId}/conversations/${encodeURIComponent(input.conversationId)}`, {
25
+ headers: { "x-jskit-surface": input.hostSurfaceId }
26
+ });
27
+ const state = await response.json();
28
+ if (!subscriptions.has(input.subscriptionId)) return;
29
+ acknowledge(null, response.ok ? { ok: true, streamEpoch: subscription.epoch, state }
30
+ : { ok: false, error: state.error, status: response.status, code: state.code });
31
+ },
32
+ notify(conversationId, event = { type: "settled" }) {
33
+ for (const subscription of subscriptions.values()) {
34
+ if (conversationId && subscription.conversationId !== conversationId) continue;
35
+ fire(ASSISTANT_CONVERSATION_EVENT, { subscriptionId: subscription.subscriptionId,
36
+ conversationId: subscription.conversationId, streamEpoch: subscription.epoch,
37
+ streamRevision: ++subscription.revision, event });
38
+ }
39
+ },
40
+ reconnect() {
41
+ socket.connected = false;
42
+ subscriptions.clear();
43
+ fire("disconnect", "transport close");
44
+ socket.connected = true;
45
+ fire("connect");
46
+ },
47
+ inspect() { return { active: subscriptions.size, calls, listeners: [...listeners.values()].reduce((sum, set) => sum + set.size, 0) }; }
48
+ };
49
+ return socket;
50
+ }
@@ -0,0 +1,178 @@
1
+ import { defineConfig } from "vite";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createConversationTranscript } from "@jskit-ai/assistant-core/server/conversation";
5
+
6
+ export default defineConfig({
7
+ root: fileURLToPath(new URL("./", import.meta.url)),
8
+ server: { host: "127.0.0.1", strictPort: true },
9
+ plugins: [vue(), {
10
+ name: "controlled-canonical-assistant-api",
11
+ configureServer(server) {
12
+ let states, requests, held, mode, denied;
13
+ const pageLimits = new Map();
14
+ // Exercise the existing page reader over this fixture's controlled rows.
15
+ const transcript = createConversationTranscript({ storage: {
16
+ read(id, callback) {
17
+ const turns = states.get(id).conversationLog;
18
+ return callback({ listTurnIds: () => turns.map(turn => turn.turnId),
19
+ readTurn: turnId => turns.find(turn => turn.turnId === turnId) });
20
+ },
21
+ write() { throw new Error("The fixture owns its controlled rows."); }
22
+ } });
23
+ function reset() {
24
+ states = new Map(Array.from({ length: 5 }, (_, index) => {
25
+ const id = `chat:${index + 1}`;
26
+ return [id, { id, engine: "api", segmentId: `segment-${index + 1}`, status: "ready", conversationLog: [],
27
+ streaming: { revision: 0, messages: [] }, capabilities: { steering: false, goals: false, attachments: true } }];
28
+ }));
29
+ requests = []; held = []; mode = "accepted"; denied = false; pageLimits.clear();
30
+ }
31
+ reset();
32
+ function accept(state, input) {
33
+ state.pendingRequest = null;
34
+ state.status = "working";
35
+ const turn = { turnId: input.messageId, user: { messageId: input.messageId, text: input.text,
36
+ role: "user", at: new Date().toISOString(), attachments: input.attachments || (input.attachmentIds || []).map(attachmentId => ({ attachmentId })) },
37
+ assistant: null, metadata: { runtime: { status: "running" } } };
38
+ state.conversationLog.push(turn);
39
+ return { status: "accepted", messageId: input.messageId, turnId: turn.turnId };
40
+ }
41
+ function changeGoal(state, input) {
42
+ if (input.action === "set") {
43
+ if (state.segmentId === null) state.segmentId = `first-native-thread-${state.id}`;
44
+ state.goal = { id: `goal-${input.messageId || state.segmentId}`, objective: input.objective,
45
+ status: "active", timeUsedSeconds: 10, updatedAt: new Date().toISOString(), tokenBudget: input.tokenBudget ?? null };
46
+ }
47
+ if (input.action === "resume") state.goal.status = "active";
48
+ if (input.action === "pause") state.goal.status = "paused";
49
+ if (input.action === "cancel") state.goal = null;
50
+ if (state.capabilities.goalCommands[input.action].interruptsTurn) state.status = "ready";
51
+ }
52
+ server.middlewares.use(async (request, response, next) => {
53
+ const url = new URL(request.url, "http://fixture");
54
+ if (!url.pathname.startsWith("/api/") && !url.pathname.startsWith("/fixture/")) return next();
55
+ const json = (value, status = 200) => { response.statusCode = status; response.setHeader("Content-Type", "application/json"); response.end(JSON.stringify(value)); };
56
+ let body = "";
57
+ for await (const part of request) body += part;
58
+ const input = body ? JSON.parse(body) : {};
59
+ if (url.pathname === "/api/session") return json({ csrfToken: "fixture-only" });
60
+ if (url.pathname === "/fixture/reset") { reset(); return json({ ok: true }); }
61
+ if (url.pathname === "/fixture/state") return json({ requests, states: Object.fromEntries(states), held: held.length });
62
+ if (url.pathname === "/fixture/mode") { mode = input.mode; return json({ ok: true }); }
63
+ if (url.pathname === "/fixture/deny") { denied = input.denied; return json({ ok: true }); }
64
+ if (url.pathname === "/fixture/error") { states.get(input.id).error = input.error; return json({ ok: true }); }
65
+ if (url.pathname === "/fixture/interim-reply") { states.get(input.id).interimReply = input.reply; return json({ ok: true }); }
66
+ if (url.pathname === "/fixture/history") {
67
+ states.get(input.id).conversationLog = input.turns;
68
+ pageLimits.set(input.id, input.limit);
69
+ return json({ ok: true });
70
+ }
71
+ if (url.pathname === "/fixture/question") {
72
+ const state = states.get(input.id);
73
+ const assistant = { messageId: "fixture-question", role: "assistant", text: input.text,
74
+ status: input.pending ? "inProgress" : "completed" };
75
+ state.status = input.pending ? "working" : "ready";
76
+ state.conversationLog = [{ turnId: "fixture-question", assistant, messages: [assistant] }];
77
+ return json({ ok: true });
78
+ }
79
+ if (url.pathname === "/fixture/attachments") { states.get(input.id).capabilities.attachments = input.enabled; return json({ ok: true }); }
80
+ if (url.pathname === "/fixture/steering") { states.get(input.id).capabilities.steering = input.enabled; return json({ ok: true }); }
81
+ if (url.pathname === "/fixture/goals") {
82
+ const state = states.get(input.id);
83
+ state.engine = "fixture-native";
84
+ state.segmentId = Object.hasOwn(input, "segmentId") ? input.segmentId : `${state.segmentId}-next`;
85
+ state.status = "ready";
86
+ state.goal = null;
87
+ state.capabilities = { ...state.capabilities, goals: true, goalBudgets: input.budgets,
88
+ goalCommands: input.commands };
89
+ return json({ ok: true });
90
+ }
91
+ if (url.pathname === "/fixture/confirm") {
92
+ const state = states.get(input.id);
93
+ const pending = state.pendingRequest;
94
+ const receipt = accept(state, pending);
95
+ if (pending.goal) changeGoal(state, { ...pending.goal, messageId: pending.messageId });
96
+ return json(receipt);
97
+ }
98
+ if (url.pathname === "/fixture/accept") {
99
+ for (const pending of held.splice(0)) {
100
+ const receipt = accept(states.get(pending.id), pending.input);
101
+ if (input.holdResponse) held.push({ ...pending, receipt });
102
+ else pending.respond(receipt);
103
+ }
104
+ return json({ ok: true });
105
+ }
106
+ if (url.pathname === "/fixture/release-response") {
107
+ for (const pending of held.splice(0)) pending.respond(pending.receipt);
108
+ return json({ ok: true });
109
+ }
110
+ if (url.pathname === "/fixture/finish") {
111
+ const state = states.get(input.id);
112
+ state.status = "ready";
113
+ const last = state.conversationLog.at(-1);
114
+ if (last) { last.assistant = { role: "assistant", messageId: `${last.turnId}:answer`, text: input.text || "Completed answer." }; last.metadata.runtime.status = "complete"; }
115
+ return json({ ok: true });
116
+ }
117
+ const match = /^\/api\/assistant\/home\/conversations\/([^/]+)(.*)$/u.exec(url.pathname);
118
+ if (!match) return json({ error: "Unknown fixture route" }, 404);
119
+ const id = decodeURIComponent(match[1]);
120
+ const state = states.get(id);
121
+ requests.push({ method: request.method, id, suffix: match[2], surface: request.headers["x-jskit-surface"], input, query: Object.fromEntries(url.searchParams) });
122
+ if (denied || !state) return json({ error: "Access denied.", code: "conversation_forbidden" }, 403);
123
+ if (request.method === "GET" && !match[2]) {
124
+ return json(pageLimits.has(id) ? { ...state, ...await transcript.readConversationLogPage(id, {
125
+ beforeTurnId: url.searchParams.get("beforeTurnId") || "",
126
+ limit: url.searchParams.get("limit") || pageLimits.get(id)
127
+ }) } : state);
128
+ }
129
+ if (match[2] === "/goal") {
130
+ if (request.method === "GET") return json(state.goal || null, state.goal ? 200 : 204);
131
+ const command = state.capabilities.goalCommands?.[input.action];
132
+ if (!command) return json({ error: "Unsupported goal command.", code: "conversation_unsupported" }, 400);
133
+ if (input.expectedSegmentId !== state.segmentId || state.segmentId === null && input.action !== "set") {
134
+ return json({ error: "The native conversation changed.", code: "conversation_goal_changed" }, 409);
135
+ }
136
+ if (command.delivery === "message" && mode === "uncertain") {
137
+ state.status = "unconfirmed";
138
+ state.pendingRequest = { messageId: input.messageId, text: input.objective || "Resume the goal.",
139
+ origin: "user", attachments: [], goal: input, error: "The goal's receipt was lost." };
140
+ return json({ error: state.pendingRequest.error }, 503);
141
+ }
142
+ const receipt = command.delivery === "message" ? accept(state, { messageId: input.messageId,
143
+ text: input.objective || (input.action === "resume" ? "Resume the goal." : "Cancel the goal.") }) : null;
144
+ changeGoal(state, input);
145
+ const result = receipt || state.goal || null;
146
+ return json(result, result === null ? 204 : 200);
147
+ }
148
+ if (match[2] === "/messages") {
149
+ if (mode === "product-rejected") return json({ ok: false, delivered: false,
150
+ messageId: input.messageId, code: "product_owner_conflict", error: "This turn belongs to another user.",
151
+ operationOutcome: "active_turn_owned_by_another_user", retryable: true, refreshRecommended: true }, 202);
152
+ if (mode === "product-native-duplicate") return json({ ok: true, delivered: true, messageId: input.messageId }, 202);
153
+ if (mode === "product-accepted") {
154
+ accept(state, input);
155
+ return json({ ok: true, delivered: true, messageId: input.messageId }, 202);
156
+ }
157
+ if (mode === "rejected") return json({ error: "The message was rejected before admission.", code: "ACTION_VALIDATION_FAILED" }, 400);
158
+ if (mode === "uncertain") {
159
+ state.status = "unconfirmed";
160
+ state.pendingRequest = { messageId: input.messageId, text: input.text, origin: "user",
161
+ ...(input.data !== undefined ? { data: input.data } : {}),
162
+ attachments: (input.attachmentIds || []).map(attachmentId => ({ attachmentId })), error: "Connection closed before the receipt arrived." };
163
+ return json({ error: state.pendingRequest.error }, 503);
164
+ }
165
+ if (mode === "hold") { held.push({ id, input, respond: value => json(value, 202) }); return; }
166
+ return json(accept(state, input), 202);
167
+ }
168
+ if (match[2] === "/cancel") { state.status = "ready"; return json({ stopped: true }); }
169
+ if (match[2].startsWith("/deliveries/") && match[2].endsWith("/inspect")) {
170
+ const messageId = decodeURIComponent(match[2].split("/")[2]);
171
+ const receipt = state.conversationLog.find(turn => turn.user?.messageId === messageId);
172
+ return json(receipt ? { status: "accepted", messageId, turnId: receipt.turnId } : { status: "unknown", messageId });
173
+ }
174
+ return json({ error: "Unknown fixture operation" }, 404);
175
+ });
176
+ }
177
+ }]
178
+ });
@@ -1,5 +1,7 @@
1
1
  import { createMemoryTurnRequests } from "../../test/support/memoryTurnRequests.js";
2
2
  import { createChatService } from "../../src/server/services/chatService.js";
3
+ import { createAssistantConversationRuntime } from "../../src/server/createAssistantRuntime.js";
4
+ import { createMemoryAssistantRows } from "../../test/support/memoryAssistantRows.js";
3
5
  import { defineConfig } from "vite";
4
6
  import vue from "@vitejs/plugin-vue";
5
7
  import { fileURLToPath } from "node:url";
@@ -59,7 +61,7 @@ export default defineConfig({
59
61
  controller.signal.addEventListener("abort", () => reject(new Error("Stopped")), { once: true });
60
62
  });
61
63
  stream.advance = parameters => release(parameters);
62
- const service = createChatService({
64
+ const dependencies = {
63
65
  turnRequests,
64
66
  aiClientFactory: { async resolveClient(_surface, { integrationId }) {
65
67
  return { enabled: true, provider: "fixture", defaultModel: integrationId, supportsAttachments: true,
@@ -69,6 +71,7 @@ export default defineConfig({
69
71
  yield { choices: [{ delta: { content: "Partial answer" } }] };
70
72
  await nextChunk();
71
73
  yield { choices: [{ delta: { content: " completed." } }] };
74
+ yield { choices: [{ delta: {}, finish_reason: "stop" }] };
72
75
  }
73
76
  };
74
77
  } },
@@ -81,11 +84,18 @@ export default defineConfig({
81
84
  async appendMessage(_surface, _id, message) { transcript.push(message); },
82
85
  async completeConversation() {}
83
86
  },
84
- serviceToolCatalog: { resolveToolSet: () => ({ tools: [] }) },
87
+ serviceToolCatalog: { limits: { maxToolArgumentBytes: 64 * 1024 }, resolveToolSet: () => ({ tools: [] }),
88
+ toOpenAiToolSchema: tool => ({ type: "function", function: tool }) },
85
89
  assistantConfigService: { resolveSystemPrompt: async () => "Answer the user." },
86
90
  appConfig: { surfaceDefinitions: { admin: { id: "admin", enabled: true, requiresWorkspace: false, accessPolicyId: "public" } },
87
91
  assistantSurfaces: { admin: { settingsSurfaceId: "admin", configScope: "global" } } }
92
+ };
93
+ const conversationRuntime = createAssistantConversationRuntime({
94
+ ...createMemoryAssistantRows({ id: "100", surfaceId: "admin", messages: transcript }),
95
+ aiClientFactory: dependencies.aiClientFactory, toolCatalog: dependencies.serviceToolCatalog,
96
+ attachments: dependencies.attachments
88
97
  });
98
+ const service = createChatService({ ...dependencies, conversationRuntime });
89
99
  const streamWriter = Object.fromEntries(["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"].map(method => [method, event => send(stream, event)]));
90
100
  try { await service.streamChat({ ...payload, targetSurfaceId: "admin" }, { streamWriter, abortSignal: controller.signal, context: { actor: { id: "1" } } }); }
91
101
  catch (error) { if (!res.destroyed) send(stream, { type: "error", message: error.message }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.192",
3
+ "version": "0.1.194",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -8,21 +8,23 @@
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
10
10
  "./shared": "./src/shared/index.js",
11
- "./server/actionIds": "./src/server/actionIds.js"
11
+ "./server/actionIds": "./src/server/actionIds.js",
12
+ "./server": "./src/server/index.js"
12
13
  },
13
14
  "dependencies": {
14
- "@jskit-ai/assistant-core": "0.1.199",
15
- "@jskit-ai/database-runtime": "0.1.221",
15
+ "@jskit-ai/assistant-core": "0.1.201",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.219",
20
- "@jskit-ai/http-web": "0.1.66",
21
- "@jskit-ai/kernel": "0.1.221",
22
- "@jskit-ai/shell-web": "0.1.225",
19
+ "@jskit-ai/http-runtime": "0.1.221",
20
+ "@jskit-ai/http-web": "0.1.68",
21
+ "@jskit-ai/kernel": "0.1.223",
22
+ "@jskit-ai/realtime": "0.1.220",
23
+ "@jskit-ai/shell-web": "0.1.227",
23
24
  "@tanstack/vue-query": "^5.90.5",
24
25
  "vue": "^3.5.13",
25
- "vuetify": "^4.0.0"
26
+ "vuetify": "^4.0.0",
27
+ "@jskit-ai/database-runtime": "0.1.223"
26
28
  },
27
29
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
28
30
  "jskit": {
@@ -34,7 +36,6 @@
34
36
  "requires": [
35
37
  "runtime.actions",
36
38
  "runtime.config",
37
- "runtime.database",
38
39
  "runtime.env",
39
40
  "runtime.http",
40
41
  "runtime.http-web",
@@ -91,6 +92,10 @@
91
92
  {
92
93
  "subpath": "./server/actionIds",
93
94
  "summary": "Exports assistant runtime action identifiers."
95
+ },
96
+ {
97
+ "subpath": "./server",
98
+ "summary": "Provides conversation routes, authorized shared realtime subscriptions, explicit Fastify hosting shorthand and optional database integration."
94
99
  }
95
100
  ],
96
101
  "capabilities": [
@@ -106,5 +111,13 @@
106
111
  "migrations"
107
112
  ]
108
113
  }
114
+ },
115
+ "peerDependenciesMeta": {
116
+ "@jskit-ai/realtime": {
117
+ "optional": true
118
+ },
119
+ "@jskit-ai/database-runtime": {
120
+ "optional": true
121
+ }
109
122
  }
110
123
  }