@jskit-ai/assistant-runtime 0.1.234 → 0.1.235

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 (31) hide show
  1. package/README.md +36 -0
  2. package/fixtures/conversation/vite.config.mjs +2 -2
  3. package/package.json +9 -10
  4. package/src/client/components/AssistantSettingsClientElement.vue +0 -3
  5. package/src/client/components/AssistantSurfaceClientElement.vue +2 -5
  6. package/src/client/composables/useAssistantConversation.js +57 -55
  7. package/src/client/composables/useAssistantRuntime.js +14 -29
  8. package/src/client/support/accessDenied.js +6 -0
  9. package/src/client/support/subscribeAssistantConversation.js +4 -5
  10. package/src/server/actions.js +3 -36
  11. package/src/server/createAssistantSettingsActions.js +0 -8
  12. package/src/server/inputSchemas.js +3 -8
  13. package/src/server/registerConversationSubscriptions.js +13 -15
  14. package/src/server/registerRoutes.js +15 -39
  15. package/src/server/repositories/conversationsRepository.js +9 -47
  16. package/src/server/repositories/messagesRepository.js +5 -22
  17. package/src/server/repositories/repositoryPersistenceUtils.js +34 -1
  18. package/src/server/services/assistantConfigService.js +4 -17
  19. package/src/server/services/chatService.js +14 -37
  20. package/src/server/services/transcriptService.js +1 -54
  21. package/src/server/support/assistantErrors.js +13 -0
  22. package/src/server/support/assistantRouteContext.js +1 -10
  23. package/src/server/support/assistantStreamWriter.js +12 -0
  24. package/src/server/support/assistantSurface.js +13 -0
  25. package/src/server/support/createSurfaceAwareToolCatalog.js +5 -6
  26. package/src/shared/assistantRuntimeConfig.js +3 -1
  27. package/src/shared/assistantSurfaces.js +18 -0
  28. package/test/actionDefinitions.test.js +0 -1
  29. package/test/assistantSurfaces.test.js +29 -0
  30. package/test/chatServiceLifecycle.test.js +12 -3
  31. package/test/turnRequests.test.js +0 -1
@@ -2,12 +2,12 @@ import { AppError, parsePositiveInteger } from "@jskit-ai/kernel/server/runtime"
2
2
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
3
  import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import { normalizeConversationStatus } from "@jskit-ai/assistant-core/shared";
5
+ import { DEFAULT_CONVERSATION_TITLE } from "../../shared/assistantRuntimeConfig.js";
5
6
 
6
7
  const DEFAULT_PAGE_SIZE = 20;
7
8
  const MAX_PAGE_SIZE = 200;
8
9
  const DEFAULT_MESSAGES_PAGE_SIZE = 200;
9
10
  const MAX_MESSAGES_PAGE_SIZE = 500;
10
- const DEFAULT_CONVERSATION_TITLE = "New conversation";
11
11
  const MAX_CONVERSATION_TITLE_LENGTH = 80;
12
12
 
13
13
  function resolveWorkspaceId(workspace, { required = false } = {}) {
@@ -141,58 +141,6 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
141
141
  };
142
142
  }
143
143
 
144
- async function appendMessage(assistantSurface, conversationId, payload = {}, options = {}) {
145
- const resolvedAssistantSurface = requireAssistantSurface(assistantSurface);
146
- const normalizedConversationId = normalizeRecordId(conversationId, { fallback: null });
147
- if (!normalizedConversationId) {
148
- throw new TypeError("appendMessage requires conversationId.");
149
- }
150
-
151
- const source = normalizeObject(payload);
152
- const context = normalizeObject(options.context);
153
- const actorUserId = normalizeRecordId(source.actorUserId, { fallback: null }) || resolveActorUserId(context.actor);
154
- const workspaceId = resolveExpectedWorkspaceId(resolvedAssistantSurface, options.workspace || context.workspace);
155
- return conversationsRepository.transaction(async trx => {
156
- const conversation = await conversationsRepository.findByIdForActorScope(normalizedConversationId, {
157
- workspaceId,
158
- actorUserId,
159
- surfaceId: resolvedAssistantSurface.targetSurfaceId
160
- }, { trx, forUpdate: true });
161
- if (!conversation) {
162
- throw new AppError(404, "Conversation not found.");
163
- }
164
-
165
- const createdMessage = await messagesRepository.create({
166
- conversationId: normalizedConversationId,
167
- workspaceId: conversation.workspaceId,
168
- role: normalizeText(source.role).toLowerCase(),
169
- kind: normalizeText(source.kind).toLowerCase() || "chat",
170
- clientMessageSid: normalizeText(source.clientMessageSid),
171
- actorUserId,
172
- contentText: source.contentText == null ? null : String(source.contentText),
173
- metadata: normalizeObject(source.metadata)
174
- }, { trx });
175
-
176
- await conversationsRepository.updateById(normalizedConversationId, { messageCount: conversation.messageCount + 1 }, { trx });
177
-
178
- const messageRole = normalizeText(source.role).toLowerCase();
179
- const messageKind = normalizeText(source.kind).toLowerCase() || "chat";
180
- if (messageRole === "user" && messageKind === "chat" && isDefaultConversationTitle(conversation.title)) {
181
- const derivedTitle = deriveConversationTitleFromMessage(source.contentText);
182
- if (derivedTitle) {
183
- await conversationsRepository.updateById(normalizedConversationId, {
184
- title: derivedTitle
185
- }, { trx });
186
- }
187
- }
188
-
189
- return {
190
- conversationId: normalizedConversationId,
191
- message: createdMessage
192
- };
193
- });
194
- }
195
-
196
144
  async function completeConversation(assistantSurface, conversationId, payload = {}, options = {}) {
197
145
  const resolvedAssistantSurface = requireAssistantSurface(assistantSurface);
198
146
  const normalizedConversationId = normalizeRecordId(conversationId, { fallback: null });
@@ -323,7 +271,6 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
323
271
 
324
272
  return Object.freeze({
325
273
  createConversationForTurn,
326
- appendMessage,
327
274
  completeConversation,
328
275
  listConversationsForUser,
329
276
  getConversationMessagesForUser
@@ -0,0 +1,13 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
2
+
3
+ function errorStatus(error) {
4
+ return Number(error?.status || error?.statusCode || 500);
5
+ }
6
+
7
+ // AppError details are withheld from clients when an action permission is denied.
8
+ function exposedAppErrorDetails(error) {
9
+ if (!(error instanceof AppError) || !error.details) return undefined;
10
+ return String(error.code || "").trim() === "ACTION_PERMISSION_DENIED" ? undefined : error.details;
11
+ }
12
+
13
+ export { errorStatus, exposedAppErrorDetails };
@@ -1,7 +1,7 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime";
2
2
  import { composeSchemaDefinitions } from "@jskit-ai/kernel/shared/validators";
3
3
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
4
- import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
4
+ import { requireAssistantSurface } from "./assistantSurface.js";
5
5
  import { assistantSurfaceRouteParamsValidator } from "../inputSchemas.js";
6
6
 
7
7
  function requireWorkspaceAssistantRouteParams(workspaceScopeSupport = null) {
@@ -30,15 +30,6 @@ function readWorkspaceInput(request, requiresWorkspace, workspaceScopeSupport =
30
30
  return workspaceScopeSupport.buildInputFromRouteParams(request?.input?.params);
31
31
  }
32
32
 
33
- function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
34
- const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
35
- if (assistantSurface) {
36
- return assistantSurface;
37
- }
38
-
39
- throw new AppError(404, "Assistant not found.");
40
- }
41
-
42
33
  function requireHostSurfaceId(request) {
43
34
  const headerValue = Array.isArray(request?.headers?.["x-jskit-surface"])
44
35
  ? request.headers["x-jskit-surface"][0]
@@ -0,0 +1,12 @@
1
+ // The stream writer contract shared by the chat route and the chat service.
2
+ const ASSISTANT_STREAM_WRITER_METHODS = Object.freeze([
3
+ "sendMeta",
4
+ "sendAssistantDelta",
5
+ "sendAssistantMessage",
6
+ "sendToolCall",
7
+ "sendToolResult",
8
+ "sendError",
9
+ "sendDone"
10
+ ]);
11
+
12
+ export { ASSISTANT_STREAM_WRITER_METHODS };
@@ -0,0 +1,13 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
2
+ import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
3
+
4
+ function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
5
+ const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
6
+ if (assistantSurface) {
7
+ return assistantSurface;
8
+ }
9
+
10
+ throw new AppError(404, "Assistant not found.");
11
+ }
12
+
13
+ export { requireAssistantSurface };
@@ -21,13 +21,12 @@ function requireContextSurfaceId(context = {}) {
21
21
  throw new Error("assistant surface-aware tool catalog requires context.surface.");
22
22
  }
23
23
 
24
- function createSurfaceAwareToolCatalog(actions, { appConfig = {}, resolveAppConfig = null, createCatalog = createServiceToolCatalog } = {}) {
24
+ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, createCatalog = createServiceToolCatalog } = {}) {
25
25
  if (!actions || typeof actions.listDefinitions !== "function") {
26
26
  throw new Error("createSurfaceAwareToolCatalog requires runtime.actions.");
27
27
  }
28
28
 
29
- const resolveCurrentAppConfig =
30
- typeof resolveAppConfig === "function" ? () => normalizeObject(resolveAppConfig()) : () => normalizeObject(appConfig);
29
+ const normalizedAppConfig = normalizeObject(appConfig);
31
30
  const cache = new Map();
32
31
  let schemaCatalog = null;
33
32
 
@@ -36,14 +35,14 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, resolveAppConf
36
35
  return cache.get(surfaceId);
37
36
  }
38
37
 
39
- const nextCatalog = createCatalog(actions, buildCatalogOptions(resolveCurrentAppConfig(), surfaceId));
38
+ const nextCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, surfaceId));
40
39
  cache.set(surfaceId, nextCatalog);
41
40
  return nextCatalog;
42
41
  }
43
42
 
44
43
  return Object.freeze({
45
44
  get limits() {
46
- if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(resolveCurrentAppConfig(), ""));
45
+ if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
47
46
  return schemaCatalog.limits;
48
47
  },
49
48
  resolveToolSet(context = {}, options) {
@@ -52,7 +51,7 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, resolveAppConf
52
51
  },
53
52
  toOpenAiToolSchema(tool) {
54
53
  if (!schemaCatalog) {
55
- schemaCatalog = createCatalog(actions, buildCatalogOptions(resolveCurrentAppConfig(), ""));
54
+ schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
56
55
  }
57
56
 
58
57
  return schemaCatalog.toOpenAiToolSchema(tool);
@@ -5,4 +5,6 @@ const assistantRuntimeConfig = Object.freeze({
5
5
  turnRequestsTable: "assistant_turn_requests"
6
6
  });
7
7
 
8
- export { assistantRuntimeConfig };
8
+ const DEFAULT_CONVERSATION_TITLE = "New conversation";
9
+
10
+ export { DEFAULT_CONVERSATION_TITLE, assistantRuntimeConfig };
@@ -1,3 +1,4 @@
1
+ import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
1
2
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
2
3
  import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
4
 
@@ -102,9 +103,26 @@ function resolveAssistantSurfaceConfig(appConfig = {}, targetSurfaceId = "") {
102
103
  });
103
104
  }
104
105
 
106
+ /**
107
+ * The runtime route of one resolved surface. Only the surface decides whether
108
+ * a workspace segment exists, so HTTP and realtime always name the same route;
109
+ * a workspace slug offered for a surface without workspaces is ignored.
110
+ */
111
+ function resolveAssistantRuntimeRoute(surface, workspaceSlug = "") {
112
+ const requiresWorkspace = surface?.runtimeSurfaceRequiresWorkspace === true;
113
+ const workspace = requiresWorkspace ? normalizeText(workspaceSlug) : "";
114
+ return Object.freeze({
115
+ workspaceSlug: workspace,
116
+ configured: Boolean(surface) && (!requiresWorkspace || Boolean(workspace)),
117
+ endpoint: buildAssistantApiPath({ requiresWorkspace, workspaceSlug: workspace,
118
+ suffix: `/${normalizeText(surface?.targetSurfaceId)}` })
119
+ });
120
+ }
121
+
105
122
  export {
106
123
  defineAssistantSurface,
107
124
  normalizeAssistantConfigScope,
125
+ resolveAssistantRuntimeRoute,
108
126
  resolveAssistantSurfaceConfig,
109
127
  resolveAssistantSurfacesConfig,
110
128
  resolveSurfaceDefinitions
@@ -120,7 +120,6 @@ test("host access wraps only canonical conversation definitions and receives the
120
120
  actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
121
121
  ]);
122
122
  assert.equal(findDefinition(definitions, actionIds.conversationSubscribe), undefined);
123
- assert.equal(findDefinition(definitions, subscribeActionId).audit.actionName, subscribeActionId);
124
123
  for (const definition of wrapped) {
125
124
  const adapted = findDefinition(definitions, definition.id);
126
125
  assert.equal(adapted.input, definition.input);
@@ -1,8 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
+ import { readdir, readFile } from "node:fs/promises";
2
3
  import test from "node:test";
3
4
  import {
4
5
  defineAssistantSurface,
5
6
  normalizeAssistantConfigScope,
7
+ resolveAssistantRuntimeRoute,
6
8
  resolveAssistantSurfaceConfig
7
9
  } from "../src/shared/assistantSurfaces.js";
8
10
 
@@ -71,3 +73,30 @@ test("single-surface shorthand preserves explicit routing, scope and disabled su
71
73
  assert.equal(resolveAssistantSurfaceConfig(defineAssistantSurface("home", { configScope: "workspace" }), "home"), null);
72
74
  assert.throws(() => defineAssistantSurface(""), /surface id is required/u);
73
75
  });
76
+
77
+ test("only the surface decides the runtime route, so HTTP and realtime name the same one", () => {
78
+ const home = resolveAssistantSurfaceConfig(defineAssistantSurface("home"), "home");
79
+ const admin = resolveAssistantSurfaceConfig(defineAssistantSurface("admin", { requiresWorkspace: true }), "admin");
80
+ // A slug offered to a surface without workspaces is ignored rather than
81
+ // producing a workspace URL the server answers with 404.
82
+ assert.deepEqual(resolveAssistantRuntimeRoute(home, "acme"),
83
+ { workspaceSlug: "", configured: true, endpoint: "/api/assistant/home" });
84
+ assert.deepEqual(resolveAssistantRuntimeRoute(admin, " acme "),
85
+ { workspaceSlug: "acme", configured: true, endpoint: "/api/w/acme/assistant/admin" });
86
+ assert.equal(resolveAssistantRuntimeRoute(admin, "").configured, false);
87
+ assert.equal(resolveAssistantRuntimeRoute(null, "acme").configured, false);
88
+ });
89
+
90
+ test("client runtime code routes from surface configuration and names no application surface", async () => {
91
+ const root = new URL("../src/client/", import.meta.url);
92
+ const files = (await readdir(root, { recursive: true })).filter(file => /\.(js|vue)$/u.test(file));
93
+ for (const file of files) {
94
+ const source = await readFile(new URL(file, root), "utf8");
95
+ assert.doesNotMatch(source, /["']admin["']|isAdminSurface/u, `${file} hard-codes an application surface`);
96
+ }
97
+ for (const file of ["composables/useAssistantConversation.js", "composables/useAssistantRuntime.js"]) {
98
+ const source = await readFile(new URL(file, root), "utf8");
99
+ assert.match(source, /resolveAssistantRuntimeRoute\(/u);
100
+ assert.doesNotMatch(source, /buildAssistantApiPath\(/u, `${file} must route through resolveAssistantRuntimeRoute`);
101
+ }
102
+ });
@@ -122,9 +122,6 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
122
122
  }
123
123
  };
124
124
  },
125
- async appendMessage(_surface, _conversationId, message) {
126
- transcriptMessages.push(structuredClone(message));
127
- },
128
125
  async completeConversation(_surface, _conversationId, completion) {
129
126
  completedConversations.push(structuredClone(completion));
130
127
  }
@@ -546,3 +543,15 @@ test("application-authorized attachments reach the model and survive transcript
546
543
  const legacy = createHarness([textCompletion("Must not run")], { attachments, supportsAttachments: false });
547
544
  await assert.rejects(legacy.run("Read this", { attachmentIds: ["file-one"] }), /not configured/);
548
545
  });
546
+
547
+ test("attachment identifiers use the conversation runtime's shape before any file is resolved", async () => {
548
+ const calls = [];
549
+ const attachments = { async resolve(request) { calls.push(request); return { attachments: [], content: [] }; } };
550
+ for (const attachmentIds of [["file-one", "file-one"], [" "], Array.from({ length: 11 }, (_, index) => `file-${index}`)]) {
551
+ const harness = createHarness([textCompletion("Must not run")], { attachments });
552
+ await assert.rejects(harness.run("Read this", { attachmentIds }), error =>
553
+ error.status === 400 && /Invalid attachment identifiers/u.test(error.message));
554
+ assert.deepEqual(harness.completionRequests, []);
555
+ }
556
+ assert.deepEqual(calls, []);
557
+ });
@@ -52,7 +52,6 @@ function service(repository, { provider, transcript = [] } = {}) {
52
52
  aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test", createChatCompletionStream: provider }) },
53
53
  transcriptService: {
54
54
  async createConversationForTurn() { return { conversation: { id: "100" } }; },
55
- async appendMessage(_surface, _id, message) { transcript.push(message); },
56
55
  async completeConversation() {}
57
56
  },
58
57
  serviceToolCatalog: { limits: { maxToolArgumentBytes: 64 * 1024 }, resolveToolSet: () => ({ tools: [] }),