@jskit-ai/assistant-runtime 0.1.168 → 0.1.170

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.
@@ -1,3 +1,4 @@
1
+ import { createMemoryTurnRequests } from "../../test/support/memoryTurnRequests.js";
1
2
  import { createChatService } from "../../src/server/services/chatService.js";
2
3
  import { defineConfig } from "vite";
3
4
  import vue from "@vitejs/plugin-vue";
@@ -12,6 +13,7 @@ export default defineConfig({
12
13
  const streams = [];
13
14
  const requests = [];
14
15
  const transcript = [];
16
+ const turnRequests = createMemoryTurnRequests();
15
17
  let realService = false;
16
18
  server.middlewares.use(async (req, res, next) => {
17
19
  const url = new URL(req.url, "http://fixture");
@@ -58,6 +60,7 @@ export default defineConfig({
58
60
  });
59
61
  stream.advance = parameters => release(parameters);
60
62
  const service = createChatService({
63
+ turnRequests,
61
64
  aiClientFactory: { async resolveClient(_surface, { integrationId }) {
62
65
  return { enabled: true, provider: "fixture", defaultModel: integrationId, supportsAttachments: true,
63
66
  async *createChatCompletionStream({ messages }) {
@@ -0,0 +1,20 @@
1
+ exports.up = async function up(knex) {
2
+ if (await knex.schema.hasTable("assistant_turn_requests")) return;
3
+ await knex.schema.createTable("assistant_turn_requests", table => {
4
+ table.bigIncrements("id").primary();
5
+ table.bigInteger("actor_user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
6
+ table.string("scope_key", 128).notNullable();
7
+ table.string("message_sid", 128).notNullable();
8
+ table.string("claim_token", 36).notNullable();
9
+ table.text("request_json", "longtext").notNullable();
10
+ table.text("response_json", "longtext").nullable();
11
+ table.string("status", 32).notNullable().defaultTo("running");
12
+ table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
13
+ table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now());
14
+ table.unique(["actor_user_id", "scope_key", "message_sid"], "uq_assistant_turn_request");
15
+ });
16
+ };
17
+
18
+ exports.down = async function down(knex) {
19
+ await knex.schema.dropTableIfExists("assistant_turn_requests");
20
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.168",
3
+ "version": "0.1.170",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,15 +11,15 @@
11
11
  "./server/actionIds": "./src/server/actionIds.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/assistant-core": "0.1.175",
15
- "@jskit-ai/database-runtime": "0.1.197",
14
+ "@jskit-ai/assistant-core": "0.1.177",
15
+ "@jskit-ai/database-runtime": "0.1.199",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.195",
20
- "@jskit-ai/http-web": "0.1.42",
21
- "@jskit-ai/kernel": "0.1.197",
22
- "@jskit-ai/shell-web": "0.1.201",
19
+ "@jskit-ai/http-runtime": "0.1.197",
20
+ "@jskit-ai/http-web": "0.1.44",
21
+ "@jskit-ai/kernel": "0.1.199",
22
+ "@jskit-ai/shell-web": "0.1.203",
23
23
  "@tanstack/vue-query": "^5.90.5",
24
24
  "vue": "^3.5.13",
25
25
  "vuetify": "^4.0.0"
@@ -71,6 +71,9 @@
71
71
  },
72
72
  {
73
73
  "tableName": "assistant_messages"
74
+ },
75
+ {
76
+ "tableName": "assistant_turn_requests"
74
77
  }
75
78
  ]
76
79
  }
@@ -43,6 +43,7 @@ onMounted(() => {
43
43
  const selectedConversation = computed(() => state.conversationHistory.find((entry) => String(entry.id) === state.activeConversationId));
44
44
  const turns = computed(() => mapAssistantConversationTurns(state.messages, state.pendingToolEvents, props));
45
45
  const adapter = computed(() => ({
46
+ delivery: runtime.delivery,
46
47
  attachments: props.attachments,
47
48
  suggestions: props.suggestions,
48
49
  goal: props.goal,
@@ -61,6 +62,7 @@ const adapter = computed(() => ({
61
62
  turns: turns.value,
62
63
  scrollKey: `${state.scopeKey}:${state.activeConversationId || "new"}`,
63
64
  loading: state.isRestoringConversation,
65
+ working: state.isStreaming && !runtime.delivery.state.sending,
64
66
  assistantLabel: props.assistantLabel,
65
67
  welcomeMessage: state.messages.length || state.isRestoringConversation ? "" : props.welcomeMessage
66
68
  },
@@ -79,14 +81,22 @@ const adapter = computed(() => ({
79
81
  },
80
82
  actions: {
81
83
  setDraft(value) { state.input = value; },
82
- submit: ({ attachments }) => runtime.actions.sendMessage({
83
- attachments,
84
- onAccepted: () => props.attachments?.clearAttachments({ accepted: true, attachmentIds: attachments.map(file => file.attachmentId) })
85
- }),
84
+ submit: ({ attachments }) => submitMessage({ attachments }),
85
+ resend: (messageId) => submitMessage({ retryMessageId: messageId }),
86
86
  stop: runtime.actions.cancelStream
87
87
  }
88
88
  }));
89
89
 
90
+ function submitMessage({ attachments = [], retryMessageId = "" }) {
91
+ const attachmentOwner = props.attachments;
92
+ const acceptedFiles = retryMessageId ? runtime.delivery.find(retryMessageId)?.payload.displayAttachments || [] : attachments;
93
+ return runtime.actions.sendMessage({
94
+ attachments,
95
+ retryMessageId,
96
+ onAccepted: () => attachmentOwner?.clearAttachments({ accepted: true, attachmentIds: acceptedFiles.map(file => file.attachmentId) })
97
+ });
98
+ }
99
+
90
100
  function conversationSubtitle(entry) {
91
101
  const details = [runtime.meta.normalizeConversationStatus(entry.status), runtime.meta.formatConversationStartedAt(entry.startedAt), `${Number(entry.messageCount || 0)} messages`];
92
102
  if (state.isAdminSurface) {
@@ -2,6 +2,7 @@ import { computed, onScopeDispose, ref, shallowRef, toValue, watch } from "vue";
2
2
  import { useQueryClient } from "@tanstack/vue-query";
3
3
  import { getClientAppConfig } from "@jskit-ai/kernel/client";
4
4
  import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { createAssistantMessageDelivery } from "@jskit-ai/assistant-core/client/conversation-delivery";
5
6
  import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
6
7
  import {
7
8
  ASSISTANT_STREAM_EVENT_TYPES,
@@ -153,6 +154,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
153
154
  const conversationId = ref(null);
154
155
  const abortController = shallowRef(null);
155
156
  const isCanceling = ref(false);
157
+ const delivery = createAssistantMessageDelivery();
156
158
  let restoreVersion = 0;
157
159
 
158
160
  const placementSnapshot = computed(() => normalizeObject(placementContext.value));
@@ -378,6 +380,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
378
380
  const restored = mapTranscriptEntriesToAssistantState(transcript.entries);
379
381
  conversationId.value = normalizedConversationId;
380
382
  writeStoredActiveConversationId(scope, normalizedConversationId);
383
+ delivery.reset();
381
384
  messages.value = restored.messages;
382
385
  pendingToolEvents.value = restored.pendingToolEvents;
383
386
  } catch (loadError) {
@@ -398,6 +401,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
398
401
 
399
402
  function clearView() {
400
403
  restoreVersion += 1;
404
+ delivery.reset();
401
405
  abortController.value?.abort();
402
406
  abortController.value = null;
403
407
  messages.value = [];
@@ -425,35 +429,47 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
425
429
  abortController.value.abort();
426
430
  }
427
431
 
428
- async function sendMessage({ attachments = [], onAccepted } = {}) {
429
- const normalizedInput = normalizeText(input.value).slice(0, MAX_INPUT_CHARS) || (attachments.length ? "Please review the attached files." : "");
430
- if (!normalizedInput || isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) {
431
- return;
432
+ async function sendMessage({ attachments = [], onAccepted, retryMessageId = "" } = {}) {
433
+ if (isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) return false;
434
+ const retry = retryMessageId ? delivery.find(retryMessageId) : null;
435
+ if (retryMessageId && retry?.status !== "failed") return false;
436
+ const message = normalizeText(input.value).slice(0, MAX_INPUT_CHARS) || (attachments.length ? "Please review the attached files." : "");
437
+ const parsedConversationId = normalizeRecordId(conversationId.value, { fallback: null });
438
+ const payload = retry?.payload || {
439
+ message,
440
+ displayAttachments: attachments,
441
+ request: {
442
+ input: message,
443
+ ...(parsedConversationId ? { conversationId: parsedConversationId } : {}),
444
+ ...(attachments.length ? { attachmentIds: attachments.map(file => file.attachmentId) } : {}),
445
+ ...(toValue(integrationId) ? { integrationId: toValue(integrationId) } : {}),
446
+ history: buildHistory(messages.value)
447
+ }
448
+ };
449
+ if (!payload.message) return false;
450
+ if (!retry) input.value = "";
451
+ try {
452
+ return await delivery.send(payload, {
453
+ messageId: retryMessageId || buildId("message"),
454
+ deliver(submission) {
455
+ const admission = Promise.withResolvers();
456
+ runStream(submission, { onAccepted, admission }).then(
457
+ () => admission.reject(new Error("Assistant did not confirm this message.")),
458
+ admission.reject
459
+ );
460
+ return admission.promise;
461
+ }
462
+ });
463
+ } catch {
464
+ // The shared delivery state owns failures before the server accepts a turn.
465
+ return false;
432
466
  }
467
+ }
433
468
 
434
- const messageId = buildId("message");
469
+ async function runStream({ messageId, message: normalizedInput, displayAttachments: attachments, request }, { onAccepted, admission }) {
435
470
  const assistantMessageId = buildId("assistant");
436
- const history = buildHistory(messages.value);
437
- const parsedConversationId = normalizeRecordId(conversationId.value, { fallback: null });
438
-
439
- appendMessage({
440
- id: buildId("user"),
441
- role: "user",
442
- kind: "chat",
443
- text: normalizedInput,
444
- attachments,
445
- status: "done"
446
- });
447
-
448
- appendMessage({
449
- id: assistantMessageId,
450
- role: "assistant",
451
- kind: "chat",
452
- text: "",
453
- status: "streaming"
454
- });
455
-
456
- input.value = "";
471
+ let accepted = false;
472
+ let admissionError = "";
457
473
  setRuntimeError("");
458
474
  isStreaming.value = true;
459
475
 
@@ -469,14 +485,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
469
485
 
470
486
  try {
471
487
  await runtimeApi.streamChat(
472
- {
473
- messageId,
474
- ...(parsedConversationId ? { conversationId: parsedConversationId } : {}),
475
- input: normalizedInput,
476
- ...(attachments.length ? { attachmentIds: attachments.map(file => file.attachmentId) } : {}),
477
- ...(toValue(integrationId) ? { integrationId: toValue(integrationId) } : {}),
478
- history
479
- },
488
+ { ...request, messageId },
480
489
  {
481
490
  signal: streamAbortController.signal,
482
491
  onEvent(event) {
@@ -484,7 +493,14 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
484
493
  const eventType = normalizeAssistantStreamEventType(event?.type, "");
485
494
 
486
495
  if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
487
- onAccepted?.();
496
+ if (!accepted) {
497
+ accepted = true;
498
+ appendMessage({ id: messageId, role: "user", kind: "chat", text: normalizedInput, attachments, status: "done" });
499
+ appendMessage({ id: assistantMessageId, role: "assistant", kind: "chat", text: "", status: "streaming" });
500
+ delivery.reconcile([{ user: { messageId } }]);
501
+ onAccepted?.();
502
+ admission.resolve(true);
503
+ }
488
504
  conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
489
505
  writeStoredActiveConversationId(runtimeScope.value, conversationId.value);
490
506
  return;
@@ -550,6 +566,10 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
550
566
  }
551
567
 
552
568
  if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ERROR) {
569
+ if (!accepted) {
570
+ admissionError = normalizeText(event?.message) || "Assistant request failed.";
571
+ return;
572
+ }
553
573
  setRuntimeError(
554
574
  normalizeText(event?.message) || "Assistant request failed.",
555
575
  "assistant.runtime:stream-event-error"
@@ -568,6 +588,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
568
588
  );
569
589
 
570
590
  if (!ownsStream()) return;
591
+ if (!accepted) throw new Error(admissionError || "Assistant did not confirm this message.");
571
592
  if (streamAbortController.signal.aborted) streamDoneStatus = "aborted";
572
593
  const assistantMessage = findMessage(assistantMessageId);
573
594
  const assistantMessageText = normalizeText(assistantMessage?.text);
@@ -589,6 +610,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
589
610
  }
590
611
  } catch (streamError) {
591
612
  if (!ownsStream()) return;
613
+ if (!accepted) throw streamError;
592
614
  if (String(streamError?.name || "") === "AbortError") {
593
615
  updateMessage(assistantMessageId, {
594
616
  status: "canceled"
@@ -611,8 +633,10 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
611
633
  abortController.value = null;
612
634
  isStreaming.value = false;
613
635
  isCanceling.value = false;
614
- await invalidateConversationScope();
615
- await refreshConversationHistory();
636
+ if (accepted) {
637
+ await invalidateConversationScope();
638
+ await refreshConversationHistory();
639
+ }
616
640
  }
617
641
  }
618
642
  }
@@ -631,6 +655,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
631
655
  normalizeConversationStatus,
632
656
  formatConversationStartedAt
633
657
  },
658
+ delivery,
634
659
  state: {
635
660
  messages,
636
661
  input,
@@ -7,6 +7,7 @@ import { registerRoutes } from "./registerRoutes.js";
7
7
  import { createRepository as createAssistantConfigRepository } from "./repositories/assistantConfigRepository.js";
8
8
  import { createRepository as createConversationsRepository } from "./repositories/conversationsRepository.js";
9
9
  import { createRepository as createMessagesRepository } from "./repositories/messagesRepository.js";
10
+ import { createRepository as createTurnRequestsRepository } from "./repositories/turnRequestsRepository.js";
10
11
  import { createService as createAssistantConfigService } from "./services/assistantConfigService.js";
11
12
  import { createChatService } from "./services/chatService.js";
12
13
  import { createTranscriptService } from "./services/transcriptService.js";
@@ -68,6 +69,7 @@ function createAssistantRuntime({
68
69
  const assistantConfigRepository = createAssistantConfigRepository(database.knex);
69
70
  const conversationsRepository = createConversationsRepository(database.knex);
70
71
  const messagesRepository = createMessagesRepository(database.knex);
72
+ const turnRequests = createTurnRequestsRepository(database.knex);
71
73
  const workspaceScopeSupport = workspaces?.scope || null;
72
74
  const aiClientFactory = createAssistantAiClientFactory({ appConfig: config, env, aiConnections });
73
75
  const toolCatalog = createSurfaceAwareToolCatalog(actionCatalogue, { appConfig: config });
@@ -79,6 +81,7 @@ function createAssistantRuntime({
79
81
  });
80
82
  const transcriptService = createTranscriptService({ conversationsRepository, messagesRepository });
81
83
  const chatService = createChatService({
84
+ turnRequests,
82
85
  attachments,
83
86
  aiClientFactory,
84
87
  transcriptService,
@@ -92,7 +95,8 @@ function createAssistantRuntime({
92
95
  repositories: Object.freeze({
93
96
  config: assistantConfigRepository,
94
97
  conversations: conversationsRepository,
95
- messages: messagesRepository
98
+ messages: messagesRepository,
99
+ turnRequests
96
100
  }),
97
101
  services: Object.freeze({ chat: chatService, config: configService, transcript: transcriptService }),
98
102
  aiClientFactory,
@@ -0,0 +1,35 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
3
+
4
+ function createRepository(knex) {
5
+ const table = assistantRuntimeConfig.turnRequestsTable;
6
+
7
+ function key(scope, messageId) {
8
+ return { actor_user_id: scope.actorUserId, scope_key: JSON.stringify([scope.surfaceId, scope.workspaceId]), message_sid: messageId };
9
+ }
10
+
11
+ async function find(scope, messageId) {
12
+ const row = await knex(table).where(key(scope, messageId)).first();
13
+ return row ? { id: row.id, token: row.claim_token, request: JSON.parse(row.request_json),
14
+ response: row.response_json ? JSON.parse(row.response_json) : null, status: row.status } : null;
15
+ }
16
+
17
+ async function claim(scope, request) {
18
+ const token = randomUUID();
19
+ await knex(table).insert({ ...key(scope, request.messageId), claim_token: token, request_json: JSON.stringify(request) })
20
+ .onConflict(["actor_user_id", "scope_key", "message_sid"]).ignore();
21
+ const record = await find(scope, request.messageId);
22
+ if (!record) throw new Error("Assistant request could not be recorded.");
23
+ return { ...record, acquired: record.token === token };
24
+ }
25
+
26
+ async function update(claim, response, status = "running") {
27
+ const changed = await knex(table).where({ id: claim.id, claim_token: claim.token, status: "running" })
28
+ .update({ response_json: JSON.stringify(response), status, updated_at: new Date() });
29
+ if (!changed) throw new Error("Assistant request ownership was lost.");
30
+ }
31
+
32
+ return Object.freeze({ find, claim, update });
33
+ }
34
+
35
+ export { createRepository };