@jskit-ai/assistant-runtime 0.1.164 → 0.1.166

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,5 +1,6 @@
1
1
  <script setup>
2
- import { computed, ref } from "vue";
2
+ import { useAssistantAttachments } from "@jskit-ai/assistant-core/client/conversation";
3
+ import { computed, reactive, ref } from "vue";
3
4
  import { useRoute, useRouter } from "vue-router";
4
5
  import { useTheme } from "vuetify";
5
6
  import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
@@ -12,6 +13,12 @@ const mounted = ref(true);
12
13
  const second = ref(false);
13
14
  const compact = ref(false);
14
15
  const surface = ref("admin");
16
+ const integrationId = ref("quick");
17
+ const attachments = reactive(useAssistantAttachments({
18
+ sessionId: () => String(route.query.workspace || "alpha"),
19
+ uploadAttachment: async (_scope, file) => ({ attachmentId: "fixture-file", fileName: file.name, size: file.size }),
20
+ deleteAttachment: async () => {}
21
+ }));
15
22
  const workspace = computed(() => route.query.workspace || "alpha");
16
23
  function switchWorkspace() { router.push({ query: { workspace: workspace.value === "alpha" ? "beta" : "alpha" } }); }
17
24
  </script>
@@ -30,7 +37,7 @@ function switchWorkspace() { router.push({ query: { workspace: workspace.value =
30
37
  <button @click="theme.change(theme.global.name.value === 'dark' ? 'light' : 'dark')">Toggle theme</button>
31
38
  </nav>
32
39
  <div class="fixture__panes" :class="{ 'fixture__panes--compact': compact }">
33
- <AssistantSurfaceClientElement v-if="mounted" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
40
+ <AssistantSurfaceClientElement v-if="mounted" v-model:integration-id="integrationId" :connections="[{ id: 'quick', label: 'Quick model' }, { id: 'deep', label: 'Deep model' }]" :attachments="route.query.files ? attachments : null" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
34
41
  <AssistantSurfaceClientElement v-if="second" surface-id="console" assistant-label="Second assistant" />
35
42
  </div>
36
43
  </div>
@@ -1,3 +1,4 @@
1
+ import { createChatService } from "../../src/server/services/chatService.js";
1
2
  import { defineConfig } from "vite";
2
3
  import vue from "@vitejs/plugin-vue";
3
4
  import { fileURLToPath } from "node:url";
@@ -10,15 +11,19 @@ export default defineConfig({
10
11
  configureServer(server) {
11
12
  const streams = [];
12
13
  const requests = [];
14
+ const transcript = [];
15
+ let realService = false;
13
16
  server.middlewares.use(async (req, res, next) => {
14
17
  const url = new URL(req.url, "http://fixture");
15
18
  if (!url.pathname.startsWith("/api/") && !url.pathname.startsWith("/fixture/")) return next();
16
19
  const json = (value) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(value)); };
17
20
  const send = (stream, event) => stream.res.write(`${JSON.stringify(event)}\n`);
18
21
  if (url.pathname === "/api/session") return json({ csrfToken: "fixture-only" });
19
- if (url.pathname === "/fixture/state") return json({ requests, streams: streams.map(({ closed }) => ({ closed })) });
22
+ if (url.pathname === "/fixture/service") { realService = true; return json({ ok: true }); }
23
+ if (url.pathname === "/fixture/state") return json({ requests, transcript, streams: streams.map(({ closed }) => ({ closed })) });
20
24
  if (url.pathname === "/fixture/next") {
21
25
  for (const stream of streams.filter((entry) => !entry.closed)) {
26
+ if (stream.advance) { stream.advance(url.searchParams); continue; }
22
27
  if (url.searchParams.has("fail")) {
23
28
  send(stream, { type: "error", message: "Fixture provider failed." });
24
29
  send(stream, { type: "done", status: "failed" });
@@ -43,6 +48,47 @@ export default defineConfig({
43
48
  const stream = { res, closed: false };
44
49
  streams.push(stream);
45
50
  res.on("close", () => { stream.closed = true; });
51
+ if (realService) {
52
+ const controller = new AbortController();
53
+ res.on("close", () => controller.abort());
54
+ let release;
55
+ const nextChunk = () => new Promise((resolve, reject) => {
56
+ release = resolve;
57
+ controller.signal.addEventListener("abort", () => reject(new Error("Stopped")), { once: true });
58
+ });
59
+ stream.advance = parameters => release(parameters);
60
+ const service = createChatService({
61
+ aiClientFactory: { async resolveClient(_surface, { integrationId }) {
62
+ return { enabled: true, provider: "fixture", defaultModel: integrationId, supportsAttachments: true,
63
+ async *createChatCompletionStream({ messages }) {
64
+ stream.receivedFiles = messages.at(-1).content;
65
+ await nextChunk();
66
+ yield { choices: [{ delta: { content: "Partial answer" } }] };
67
+ await nextChunk();
68
+ yield { choices: [{ delta: { content: " completed." } }] };
69
+ }
70
+ };
71
+ } },
72
+ attachments: { async resolve({ attachmentIds }) {
73
+ return { attachments: attachmentIds.map(attachmentId => ({ attachmentId, fileName: "notes.txt", size: 5 })),
74
+ content: [{ type: "text", text: "Uploaded notes" }] };
75
+ } },
76
+ transcriptService: {
77
+ async createConversationForTurn() { return { conversation: { id: "100" } }; },
78
+ async appendMessage(_surface, _id, message) { transcript.push(message); },
79
+ async completeConversation() {}
80
+ },
81
+ serviceToolCatalog: { resolveToolSet: () => ({ tools: [] }) },
82
+ assistantConfigService: { resolveSystemPrompt: async () => "Answer the user." },
83
+ appConfig: { surfaceDefinitions: { admin: { id: "admin", enabled: true, requiresWorkspace: false, accessPolicyId: "public" } },
84
+ assistantSurfaces: { admin: { settingsSurfaceId: "admin", configScope: "global" } } }
85
+ });
86
+ const streamWriter = Object.fromEntries(["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"].map(method => [method, event => send(stream, event)]));
87
+ try { await service.streamChat({ ...payload, targetSurfaceId: "admin" }, { streamWriter, abortSignal: controller.signal, context: { actor: { id: "1" } } }); }
88
+ catch (error) { if (!res.destroyed) send(stream, { type: "error", message: error.message }); }
89
+ finally { res.end(); }
90
+ return;
91
+ }
46
92
  send(stream, { type: "meta", conversationId: payload.conversationId || "100" });
47
93
  send(stream, { type: "tool_call", toolCallId: "lookup", name: "Look up records", arguments: "never-render-this-payload" });
48
94
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.164",
3
+ "version": "0.1.166",
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.171",
15
- "@jskit-ai/database-runtime": "0.1.193",
14
+ "@jskit-ai/assistant-core": "0.1.173",
15
+ "@jskit-ai/database-runtime": "0.1.195",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.191",
20
- "@jskit-ai/http-web": "0.1.38",
21
- "@jskit-ai/kernel": "0.1.193",
22
- "@jskit-ai/shell-web": "0.1.197",
19
+ "@jskit-ai/http-runtime": "0.1.193",
20
+ "@jskit-ai/http-web": "0.1.40",
21
+ "@jskit-ai/kernel": "0.1.195",
22
+ "@jskit-ai/shell-web": "0.1.199",
23
23
  "@tanstack/vue-query": "^5.90.5",
24
24
  "vue": "^3.5.13",
25
25
  "vuetify": "^4.0.0"
@@ -3,7 +3,7 @@ id: assistant/assistant-surface
3
3
  title: Assistant surface and settings
4
4
  summary: Configure an assistant runtime for one application surface and expose its chat and settings elements through ordinary pages and placements.
5
5
  keywords: ai, assistant, chat, config, environment, page, placement, settings, surface
6
- requires: @jskit-ai/assistant-runtime, @jskit-ai/shell-web
6
+ requires: @jskit-ai/assistant-runtime, @jskit-ai/shell-web, @jskit-ai/connectors-catalog, @jskit-ai/connectors-core
7
7
  ---
8
8
 
9
9
  # Assistant surface and settings
@@ -32,7 +32,8 @@ in source or a questionnaire transcript.
32
32
  - Workspace configuration is used only when both surfaces require a workspace.
33
33
  - Each runtime surface appears once in `assistantSurfaces` and
34
34
  `assistantServer`.
35
- - The server config contains an environment prefix, never a credential value.
35
+ - The server config selects an AI integration ID, never a credential value.
36
+ - The application publishes an authorized `integrations.ai` resolver capability.
36
37
  - Runtime and settings pages use the public assistant elements.
37
38
  - Environment values are supplied through the application's normal secret
38
39
  environment boundary.
@@ -48,12 +49,16 @@ and `assistantServer` in server config.
48
49
  ## Example files
49
50
 
50
51
  `example/` contains concrete public/server config, assistant and settings pages,
51
- and semantic placements for an `admin` surface. Adapt the complete files to the
52
+ semantic placements, an AI integration and its server resolver for an `admin` surface.
53
+ Install `@jskit-ai/connectors-catalog` and `@jskit-ai/connectors-core` and register
54
+ `ApplicationAiFeature` in the ordinary server feature list. Set its application ID
55
+ and AI-use policy. The example explicitly selects the catalogue's no-key model;
56
+ provider availability is independent of configuration. Adapt the complete files to the
52
57
  application's existing config and placement registries.
53
58
 
54
59
  ## Variation points
55
60
 
56
- Change surface ids, settings location, config scope, environment prefix, routes,
61
+ Change surface ids, settings location, config scope, AI integration IDs, routes,
57
62
  labels, icons, and placement targets. A workspace-scoped application must retain
58
63
  the current workspace in both routes and assistant requests through the runtime's
59
64
  public workspace support.
@@ -92,3 +97,18 @@ workspace/surface aborts the local response and discards that view's state.
92
97
  Reopening restores saved history, not a live stream. Stop cannot guarantee that
93
98
  a server-side tool already running has stopped. Custom editor conversations
94
99
  should use their own adapter and keep their existing operation/storage owner.
100
+
101
+ ## Shared optional controls
102
+
103
+ The shared status row shows “Assistant is working…” before output and answers
104
+ stream as they arrive. Pass safe `connections: [{ id, label }]` with
105
+ `v-model:integration-id` to offer the shared model chooser; list every selectable
106
+ ID in server `aiIntegrationIds`. Use `configuration-mode="hidden"` for a supplied
107
+ fixed connection or `readonly` to show its fixed choice.
108
+
109
+ The optional `suggestions`, `attachments`, `goal` and `activity` props use the
110
+ embeddable conversation contract. Goals are disabled unless a real backend
111
+ scheduler is supplied. Suggestions use their own generator and agent policy.
112
+ File bytes and upload authorization belong to the app's `assistant.attachments`
113
+ resolver and upload routes. See the Assistant and Embeddable assistant
114
+ conversations guides for exact callback shapes.
@@ -1,7 +1,7 @@
1
1
  const config = {
2
2
  assistantServer: {
3
3
  admin: {
4
- aiConfigPrefix: "ADMIN_ASSISTANT"
4
+ aiIntegrationId: "assistant"
5
5
  }
6
6
  }
7
7
  };
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "registrations": {},
4
+ "integrations": {
5
+ "assistant": {
6
+ "provider": "ai",
7
+ "accountMode": "shared",
8
+ "scopes": [],
9
+ "settings": {
10
+ "model": "opencode/big-pickle"
11
+ },
12
+ "authentication": {
13
+ "method": "none"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,20 @@
1
+ import { defineFeature } from "@jskit-ai/kernel/server/features";
2
+ import { createAiConnectionResolver } from "@jskit-ai/connectors-catalog/server/ai";
3
+ import { createEnvironmentReferenceResolver } from "@jskit-ai/connectors-core/server";
4
+ import configuration from "../integrations.json" with { type: "json" };
5
+
6
+ // Register this in the application's ordinary server feature list.
7
+ export const ApplicationAiFeature = defineFeature({
8
+ id: "app.ai", requires: { env: "runtime.env" }, provides: { ai: "integrations.ai" },
9
+ setup({ env }) {
10
+ return { ai: createAiConnectionResolver({
11
+ configuration,
12
+ authorize(context) {
13
+ if (!context.actor?.id) return null;
14
+ // Apply the app's AI-use policy here; the actor comes from authentication.
15
+ return { applicationId: "my-app", subjectId: String(context.actor.id) };
16
+ },
17
+ resolveReference: createEnvironmentReferenceResolver(env)
18
+ }) };
19
+ }
20
+ });
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, onMounted, onScopeDispose, reactive, ref, toRef, watchPostEffect } from "vue";
2
+ import { computed, onMounted, onScopeDispose, reactive, ref, toRef, watch, watchPostEffect } from "vue";
3
3
  import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
4
4
  import { useAssistantRuntime } from "../composables/useAssistantRuntime.js";
5
5
  import { mapAssistantConversationTurns } from "../support/assistantRuntimeState.js";
@@ -10,9 +10,18 @@ const props = defineProps({
10
10
  assistantLabel: { type: String, default: "Assistant" },
11
11
  welcomeMessage: { type: String, default: "What would you like to do?" },
12
12
  placeholder: { type: String, default: "Message the assistant…" },
13
- showToolActivity: { type: Boolean, default: true }
13
+ showToolActivity: { type: Boolean, default: true },
14
+ connections: { type: Array, default: () => [] },
15
+ attachments: { type: Object, default: null },
16
+ suggestions: { type: Object, default: null },
17
+ goal: { type: Object, default: null },
18
+ activity: { type: Object, default: undefined },
19
+ configurationMode: { type: String, default: "editable", validator: value => ["hidden", "readonly", "editable"].includes(value) }
14
20
  });
15
- const runtime = useAssistantRuntime({ surfaceId: toRef(props, "surfaceId") });
21
+ const integrationId = defineModel("integrationId", { type: String, default: "" });
22
+ const draftIntegrationId = ref(integrationId.value);
23
+ watch(integrationId, value => { draftIntegrationId.value = value; });
24
+ const runtime = useAssistantRuntime({ surfaceId: toRef(props, "surfaceId"), integrationId });
16
25
  const state = reactive(runtime.state);
17
26
  const conversation = ref(null);
18
27
  const root = ref(null);
@@ -34,6 +43,20 @@ onMounted(() => {
34
43
  const selectedConversation = computed(() => state.conversationHistory.find((entry) => String(entry.id) === state.activeConversationId));
35
44
  const turns = computed(() => mapAssistantConversationTurns(state.messages, state.pendingToolEvents, props));
36
45
  const adapter = computed(() => ({
46
+ attachments: props.attachments,
47
+ suggestions: props.suggestions,
48
+ goal: props.goal,
49
+ activity: props.activity,
50
+ models: props.connections.length && props.configurationMode !== "hidden" ? {
51
+ providerRows: [{ id: "application", label: "Application AI" }],
52
+ modelProviderId: "application", modelRows: props.connections, modelId: draftIntegrationId.value,
53
+ selectionSummary: props.connections.find(connection => connection.id === integrationId.value)?.label || "Application default",
54
+ changesDisabled: props.configurationMode !== "editable" || state.isStreaming,
55
+ canSave: props.configurationMode === "editable" && !state.isStreaming && draftIntegrationId.value !== integrationId.value &&
56
+ props.connections.some(connection => connection.id === draftIntegrationId.value),
57
+ selectModel(value) { draftIntegrationId.value = value; },
58
+ apply() { integrationId.value = draftIntegrationId.value; }
59
+ } : null,
37
60
  conversation: {
38
61
  turns: turns.value,
39
62
  scrollKey: `${state.scopeKey}:${state.activeConversationId || "new"}`,
@@ -43,7 +66,8 @@ const adapter = computed(() => ({
43
66
  },
44
67
  composer: {
45
68
  draft: state.input,
46
- canSend: state.canSend,
69
+ canSend: state.canSend || Boolean(props.attachments?.enabled !== false && props.attachments?.attachments?.length &&
70
+ !state.isStreaming && !state.isRestoringConversation && state.hasRuntimeScope),
47
71
  disabled: !state.hasRuntimeScope,
48
72
  canStop: state.isStreaming,
49
73
  stopPending: state.isCanceling,
@@ -55,7 +79,10 @@ const adapter = computed(() => ({
55
79
  },
56
80
  actions: {
57
81
  setDraft(value) { state.input = value; },
58
- submit: runtime.actions.sendMessage,
82
+ submit: ({ attachments }) => runtime.actions.sendMessage({
83
+ attachments,
84
+ onAccepted: () => props.attachments?.clearAttachments({ accepted: true, attachmentIds: attachments.map(file => file.attachmentId) })
85
+ }),
59
86
  stop: runtime.actions.cancelStream
60
87
  }
61
88
  }));
@@ -96,11 +123,13 @@ defineExpose({ focus: () => conversation.value?.focus() });
96
123
  </v-menu>
97
124
  </div>
98
125
  <AssistantConversationElement ref="conversation" :adapter="adapter" label="Conversation" class="assistant-surface__conversation">
126
+ <template v-if="$slots.activity" #activity="scope"><slot name="activity" v-bind="scope" /></template>
127
+ <template v-if="$slots.attachments" #attachments="scope"><slot name="attachments" v-bind="scope" /></template>
99
128
  <template #message-actions="{ message }">
100
129
  <p v-if="message.status === 'failed' && message.error" class="assistant-surface__message-error" role="status">{{ message.error }}</p>
101
130
  <p v-if="message.status === 'interrupted'" class="assistant-surface__notice" role="status">Response canceled. A tool already running may still finish.</p>
102
131
  </template>
103
- <template #hints>
132
+ <template #composer-feedback>
104
133
  <p v-if="state.error && !state.messages.some((message) => message.error === state.error)" class="assistant-surface__message-error" role="status">{{ state.error }}</p>
105
134
  </template>
106
135
  <template #composer-tools><slot name="composer-tools" :runtime="runtime" /></template>
@@ -135,7 +135,7 @@ function createRuntimeApi({ overrideApi = null, resolveBasePath, resolveSurfaceI
135
135
  });
136
136
  }
137
137
 
138
- function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
138
+ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" } = {}) {
139
139
  const runtimePolicy = resolveRuntimePolicy();
140
140
  const queryClient = useQueryClient();
141
141
  const errorRuntime = useShellWebErrorRuntime();
@@ -425,8 +425,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
425
425
  abortController.value.abort();
426
426
  }
427
427
 
428
- async function sendMessage() {
429
- const normalizedInput = normalizeText(input.value).slice(0, MAX_INPUT_CHARS);
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
430
  if (!normalizedInput || isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) {
431
431
  return;
432
432
  }
@@ -441,6 +441,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
441
441
  role: "user",
442
442
  kind: "chat",
443
443
  text: normalizedInput,
444
+ attachments,
444
445
  status: "done"
445
446
  });
446
447
 
@@ -472,6 +473,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
472
473
  messageId,
473
474
  ...(parsedConversationId ? { conversationId: parsedConversationId } : {}),
474
475
  input: normalizedInput,
476
+ ...(attachments.length ? { attachmentIds: attachments.map(file => file.attachmentId) } : {}),
477
+ ...(toValue(integrationId) ? { integrationId: toValue(integrationId) } : {}),
475
478
  history
476
479
  },
477
480
  {
@@ -481,6 +484,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
481
484
  const eventType = normalizeAssistantStreamEventType(event?.type, "");
482
485
 
483
486
  if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
487
+ onAccepted?.();
484
488
  conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
485
489
  writeStoredActiveConversationId(runtimeScope.value, conversationId.value);
486
490
  return;
@@ -503,7 +507,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
503
507
  const text = String(event?.text || "");
504
508
  updateMessage(assistantMessageId, {
505
509
  text,
506
- status: "done"
510
+ status: event.status === "streaming" ? "streaming" : "done"
507
511
  });
508
512
  return;
509
513
  }
@@ -60,7 +60,8 @@ function buildHistory(messages) {
60
60
  })
61
61
  .map((message) => ({
62
62
  role: message.role,
63
- content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
63
+ content: String(message.text || "").slice(0, MAX_INPUT_CHARS),
64
+ ...(message.role === "user" && message.attachments?.length ? { attachmentIds: message.attachments.map(file => file.attachmentId) } : {})
64
65
  }));
65
66
 
66
67
  return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
@@ -123,6 +124,7 @@ function mapTranscriptEntriesToAssistantState(entries) {
123
124
  role,
124
125
  kind: "chat",
125
126
  text,
127
+ ...(Array.isArray(metadata.attachments) ? { attachments: metadata.attachments } : {}),
126
128
  status: "done"
127
129
  });
128
130
  if (role === "user") {
@@ -1,6 +1,7 @@
1
1
  import { defineFeature } from "@jskit-ai/kernel/server/features";
2
2
  import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
- import { createAiClient } from "@jskit-ai/assistant-core/server";
3
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
4
+ import { createAiClient, createAiConnectionClient } from "@jskit-ai/assistant-core/server";
4
5
  import { createAssistantActions } from "./actions.js";
5
6
  import { registerRoutes } from "./registerRoutes.js";
6
7
  import { createRepository as createAssistantConfigRepository } from "./repositories/assistantConfigRepository.js";
@@ -9,7 +10,7 @@ import { createRepository as createMessagesRepository } from "./repositories/mes
9
10
  import { createService as createAssistantConfigService } from "./services/assistantConfigService.js";
10
11
  import { createChatService } from "./services/chatService.js";
11
12
  import { createTranscriptService } from "./services/transcriptService.js";
12
- import { resolveAssistantAiConfig } from "./support/assistantServerConfig.js";
13
+ import { resolveAssistantAiConfig, resolveAssistantServerConfig } from "./support/assistantServerConfig.js";
13
14
  import { createSurfaceAwareToolCatalog } from "./support/createSurfaceAwareToolCatalog.js";
14
15
 
15
16
  function createAssistantAiClientFactory(config = {}) {
@@ -18,12 +19,24 @@ function createAssistantAiClientFactory(config = {}) {
18
19
  const cache = new Map();
19
20
 
20
21
  return Object.freeze({
21
- resolveClient(targetSurfaceId = "") {
22
+ async resolveClient(targetSurfaceId = "", { context, integrationId } = {}) {
22
23
  const normalizedTargetSurfaceId = normalizeText(targetSurfaceId).toLowerCase();
23
24
  if (!normalizedTargetSurfaceId) {
24
25
  throw new Error("assistant.ai.client.factory.resolveClient requires targetSurfaceId.");
25
26
  }
26
27
 
28
+ const surfaceConfig = resolveAssistantServerConfig(appConfig, normalizedTargetSurfaceId);
29
+ if (surfaceConfig.aiIntegrationId) {
30
+ const selected = integrationId || surfaceConfig.aiIntegrationId;
31
+ if (![surfaceConfig.aiIntegrationId, ...surfaceConfig.aiIntegrationIds].includes(selected)) {
32
+ throw new AppError(403, "This AI connection is not available in this assistant.");
33
+ }
34
+ if (!config.aiConnections?.resolve) throw new Error("The assistant requires an authorized AI connection resolver.");
35
+ const connection = await config.aiConnections.resolve({ context, integrationId: selected });
36
+ return createAiConnectionClient(connection, { timeoutMs: surfaceConfig.timeoutMs });
37
+ }
38
+ if (integrationId) throw new AppError(403, "This assistant does not allow selecting an AI connection.");
39
+
27
40
  if (cache.has(normalizedTargetSurfaceId)) {
28
41
  return cache.get(normalizedTargetSurfaceId);
29
42
  }
@@ -48,13 +61,15 @@ function createAssistantRuntime({
48
61
  consoleRuntime,
49
62
  database,
50
63
  env,
64
+ aiConnections,
65
+ attachments,
51
66
  workspaces
52
67
  } = {}) {
53
68
  const assistantConfigRepository = createAssistantConfigRepository(database.knex);
54
69
  const conversationsRepository = createConversationsRepository(database.knex);
55
70
  const messagesRepository = createMessagesRepository(database.knex);
56
71
  const workspaceScopeSupport = workspaces?.scope || null;
57
- const aiClientFactory = createAssistantAiClientFactory({ appConfig: config, env });
72
+ const aiClientFactory = createAssistantAiClientFactory({ appConfig: config, env, aiConnections });
58
73
  const toolCatalog = createSurfaceAwareToolCatalog(actionCatalogue, { appConfig: config });
59
74
  const configService = createAssistantConfigService({
60
75
  assistantConfigRepository,
@@ -64,6 +79,7 @@ function createAssistantRuntime({
64
79
  });
65
80
  const transcriptService = createTranscriptService({ conversationsRepository, messagesRepository });
66
81
  const chatService = createChatService({
82
+ attachments,
67
83
  aiClientFactory,
68
84
  transcriptService,
69
85
  serviceToolCatalog: toolCatalog,
@@ -94,6 +110,8 @@ const AssistantFeature = defineFeature({
94
110
  http: "runtime.http"
95
111
  },
96
112
  optional: {
113
+ aiConnections: "integrations.ai",
114
+ attachments: "assistant.attachments",
97
115
  consoleRuntime: "console.core",
98
116
  workspaces: "workspaces.core"
99
117
  },
@@ -38,7 +38,8 @@ function normalizeHistory(history = []) {
38
38
 
39
39
  return {
40
40
  role,
41
- content
41
+ content,
42
+ attachmentIds: role === "user" ? item.attachmentIds : []
42
43
  };
43
44
  })
44
45
  .filter(Boolean);
@@ -71,6 +72,7 @@ function normalizeStreamInput(payload = {}) {
71
72
  messageId,
72
73
  conversationId: normalizeConversationId(source.conversationId),
73
74
  input,
75
+ attachmentIds: source.attachmentIds,
74
76
  history: normalizeHistory(source.history)
75
77
  };
76
78
  }
@@ -296,10 +298,10 @@ function sanitizeAssistantMessageText(value) {
296
298
  }
297
299
 
298
300
  const blockPatterns = [
299
- /<[^>\n]*function_calls[^>\n]*>[\s\S]*?<\/[^>\n]*function_calls>/gi,
300
- /<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?<\/[^>\n]*tool_calls?[^>\n]*>/gi,
301
- /<[^>\n]*invoke\b[^>\n]*>[\s\S]*?<\/[^>\n]*invoke>/gi,
302
- /<(?:analysis|reasoning|think)>[\s\S]*?<\/(?:analysis|reasoning|think)>/gi
301
+ /<[^>\n]*function_calls[^>\n]*>[\s\S]*?(?:<\/[^>\n]*function_calls>|$)/gi,
302
+ /<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?(?:<\/[^>\n]*tool_calls?[^>\n]*>|$)/gi,
303
+ /<[^>\n]*invoke\b[^>\n]*>[\s\S]*?(?:<\/[^>\n]*invoke>|$)/gi,
304
+ /<(?:analysis|reasoning|think)>[\s\S]*?(?:<\/(?:analysis|reasoning|think)>|$)/gi
303
305
  ];
304
306
  for (const pattern of blockPatterns) {
305
307
  source = source.replace(pattern, " ");
@@ -380,7 +382,7 @@ function parseDsmlToolCallsFromText(value = "") {
380
382
  return calls;
381
383
  }
382
384
 
383
- async function consumeCompletionStream(stream) {
385
+ async function consumeCompletionStream(stream, onText = () => {}) {
384
386
  let assistantText = "";
385
387
  const toolCallsByIndex = new Map();
386
388
 
@@ -391,6 +393,11 @@ async function consumeCompletionStream(stream) {
391
393
  const textDelta = extractTextDelta(delta.content);
392
394
  if (textDelta) {
393
395
  assistantText += textDelta;
396
+ // An opening provider tag can be split between chunks. Keep it private
397
+ // until its boundary is known; complete internal blocks are stripped below.
398
+ const tagStart = assistantText.lastIndexOf("<");
399
+ const safeText = tagStart > assistantText.lastIndexOf(">") ? assistantText.slice(0, tagStart) : assistantText;
400
+ onText(sanitizeAssistantMessageText(safeText));
394
401
  }
395
402
 
396
403
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
@@ -456,6 +463,7 @@ function buildAssistantActionContext(context = {}, assistantSurface = {}) {
456
463
 
457
464
  function createChatService({
458
465
  aiClientFactory,
466
+ attachments,
459
467
  transcriptService,
460
468
  serviceToolCatalog,
461
469
  assistantConfigService,
@@ -486,14 +494,16 @@ function createChatService({
486
494
 
487
495
  async function streamChat(payload = {}, options = {}) {
488
496
  const assistantSurface = requireAssistantSurface(resolveCurrentAppConfig(), payload?.targetSurfaceId);
489
- const aiClient = aiClientFactory.resolveClient(assistantSurface.targetSurfaceId);
497
+ const context = normalizeObject(options.context);
498
+ const assistantContext = buildAssistantActionContext(context, assistantSurface);
499
+ const workspace = resolveRuntimeWorkspace(assistantSurface, assistantContext, payload);
500
+ const aiClient = await aiClientFactory.resolveClient(assistantSurface.targetSurfaceId, {
501
+ context: assistantContext, integrationId: payload.integrationId
502
+ });
490
503
  if (!aiClient.enabled) {
491
504
  throw new AppError(503, "Assistant provider is not configured.");
492
505
  }
493
506
 
494
- const context = normalizeObject(options.context);
495
- const assistantContext = buildAssistantActionContext(context, assistantSurface);
496
- const workspace = resolveRuntimeWorkspace(assistantSurface, assistantContext, payload);
497
507
  const source = normalizeStreamInput(payload);
498
508
  const streamWriter = options.streamWriter;
499
509
  if (!hasStreamWriter(streamWriter)) {
@@ -524,6 +534,27 @@ function createChatService({
524
534
  throw new AppError(500, "Assistant failed to create conversation.");
525
535
  }
526
536
 
537
+ async function resolveFiles(attachmentIds) {
538
+ if (attachmentIds === undefined || (Array.isArray(attachmentIds) && !attachmentIds.length)) return { attachments: [], content: [] };
539
+ if (!Array.isArray(attachmentIds) || attachmentIds.length > 10 ||
540
+ attachmentIds.some(id => typeof id !== "string" || !id || id.length > 256)) {
541
+ throw new AppError(400, "Invalid attachment identifiers.");
542
+ }
543
+ if (!attachments?.resolve || !aiClient.supportsAttachments) {
544
+ throw new AppError(400, "File attachments are not configured for this assistant.");
545
+ }
546
+ // Only the application can authorize uploads and turn stored bytes into
547
+ // provider input. Browser-supplied paths, URLs and file contents are never used.
548
+ return attachments.resolve({ attachmentIds, context: assistantContext, workspace, conversation });
549
+ }
550
+ const files = await resolveFiles(source.attachmentIds);
551
+ const history = [];
552
+ for (const message of source.history) {
553
+ const previousFiles = await resolveFiles(message.attachmentIds);
554
+ history.push({ role: message.role, content: previousFiles.content.length
555
+ ? [{ type: "text", text: message.content }, ...previousFiles.content] : message.content });
556
+ }
557
+
527
558
  await transcriptService.appendMessage(
528
559
  assistantSurface,
529
560
  conversationId,
@@ -533,7 +564,8 @@ function createChatService({
533
564
  clientMessageSid: source.messageId,
534
565
  contentText: source.input,
535
566
  metadata: {
536
- surfaceId: assistantSurface.targetSurfaceId
567
+ surfaceId: assistantSurface.targetSurfaceId,
568
+ ...(files.attachments.length ? { attachments: files.attachments } : {})
537
569
  }
538
570
  },
539
571
  {
@@ -566,10 +598,10 @@ function createChatService({
566
598
  role: "system",
567
599
  content: systemPrompt
568
600
  },
569
- ...source.history,
601
+ ...history,
570
602
  {
571
603
  role: "user",
572
- content: source.input
604
+ content: files.content.length ? [{ type: "text", text: source.input }, ...files.content] : source.input
573
605
  }
574
606
  ];
575
607
 
@@ -623,6 +655,23 @@ function createChatService({
623
655
  };
624
656
  }
625
657
 
658
+ let displayedText = "";
659
+ async function consumeResponse(stream) {
660
+ if (displayedText) {
661
+ streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
662
+ displayedText = "";
663
+ }
664
+ return consumeCompletionStream(stream, text => {
665
+ if (text === displayedText) return;
666
+ if (text.startsWith(displayedText)) {
667
+ streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: text.slice(displayedText.length) });
668
+ } else {
669
+ streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text, status: "streaming" });
670
+ }
671
+ displayedText = text;
672
+ });
673
+ }
674
+
626
675
  async function executeToolCalls(toolCalls = [], { toolFailures = [], toolSuccesses = [] } = {}) {
627
676
  const roundFailures = [];
628
677
 
@@ -745,7 +794,7 @@ function createChatService({
745
794
  tools: [],
746
795
  signal: options.abortSignal
747
796
  });
748
- const completion = await consumeCompletionStream(completionStream);
797
+ const completion = await consumeResponse(completionStream);
749
798
 
750
799
  const recoveryToolCalls = completion.toolCalls.filter((entry) => entry.name);
751
800
  if (recoveryToolCalls.length > 0) {
@@ -824,7 +873,7 @@ function createChatService({
824
873
  signal: options.abortSignal
825
874
  });
826
875
 
827
- const completion = await consumeCompletionStream(completionStream);
876
+ const completion = await consumeResponse(completionStream);
828
877
 
829
878
  const toolCalls = completion.toolCalls.filter((entry) => entry.name);
830
879
  if (toolCalls.length < 1) {
@@ -18,6 +18,8 @@ function resolveAssistantServerConfigMap(appConfig = {}) {
18
18
  }
19
19
 
20
20
  resolved[targetSurfaceId] = Object.freeze({
21
+ aiIntegrationId: normalizeText(entry.aiIntegrationId),
22
+ aiIntegrationIds: Object.freeze(normalizeUniqueTextList(entry.aiIntegrationIds)),
21
23
  aiConfigPrefix: normalizeText(entry.aiConfigPrefix),
22
24
  provider: normalizeText(entry.provider).toLowerCase(),
23
25
  apiKey: normalizeText(entry.apiKey),
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
4
+ import { createAssistantAiClientFactory } from "../src/server/AssistantProvider.js";
5
+
6
+ test("assistant connections authorize each request and resolve rotated and individual credentials afresh", async t => {
7
+ const keys = { alice: "alice-first", bob: "bob-only" };
8
+ const authorized = [];
9
+ const usedKeys = [];
10
+ const aiConnections = createAiConnectionResolver({
11
+ configuration: { schemaVersion: 1, registrations: {}, integrations: {
12
+ chat: { provider: "ai", accountMode: "per-user", scopes: [],
13
+ settings: { model: "openai/gpt-4.1-mini" }, authentication: { method: "api-key", secretRef: "account:chat" } }
14
+ } },
15
+ authorize(context, request) {
16
+ authorized.push([context.actor.id, request.integrationId]);
17
+ return context.actor.id === "denied" ? null : { applicationId: "example", subjectId: context.actor.id };
18
+ },
19
+ resolveReference(_reference, owner) { return keys[owner.subjectId]; }
20
+ });
21
+ t.mock.method(globalThis, "fetch", async (_url, options) => {
22
+ usedKeys.push(new Headers(options.headers).get("authorization"));
23
+ return new Response([
24
+ { type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
25
+ { type: "response.output_text.delta", item_id: "msg_1", output_index: 0, content_index: 0, delta: "Ready" },
26
+ { type: "response.completed", response: { id: "resp_1", created_at: 1, model: "gpt-4.1-mini", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 1 } } }
27
+ ].map(data => `data: ${JSON.stringify(data)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
28
+ });
29
+ const factory = createAssistantAiClientFactory({
30
+ appConfig: { assistantServer: { admin: { aiIntegrationId: "chat" } } }, aiConnections
31
+ });
32
+ async function run(id) {
33
+ const client = await factory.resolveClient("admin", { context: { actor: { id } } });
34
+ return Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: "Hi" }] }));
35
+ }
36
+ await run("alice");
37
+ keys.alice = "alice-rotated";
38
+ await run("alice");
39
+ await run("bob");
40
+ assert.deepEqual(usedKeys, ["Bearer alice-first", "Bearer alice-rotated", "Bearer bob-only"]);
41
+ delete keys.alice;
42
+ await assert.rejects(run("alice"), /API key is unavailable/u);
43
+ await assert.rejects(run("denied"), /denied/u);
44
+ await assert.rejects(factory.resolveClient("admin", { context: { actor: { id: "bob" } }, integrationId: "not-allowed" }), /not available/u);
45
+ assert.deepEqual(authorized, [["alice", "chat"], ["alice", "chat"], ["bob", "chat"], ["alice", "chat"], ["denied", "chat"]]);
46
+ assert.equal(usedKeys.length, 3);
47
+ });
48
+
49
+ test("existing explicit environment configuration remains available and rejects connection overrides", async () => {
50
+ const factory = createAssistantAiClientFactory({
51
+ appConfig: { assistantServer: { admin: { aiConfigPrefix: "ADMIN" } } },
52
+ env: { ADMIN_AI_PROVIDER: "openai", ADMIN_AI_API_KEY: "test-key", ADMIN_AI_MODEL: "chosen-model" }
53
+ });
54
+ const first = await factory.resolveClient("admin");
55
+ assert.equal(first.defaultModel, "chosen-model");
56
+ assert.equal(await factory.resolveClient("admin"), first);
57
+ await assert.rejects(factory.resolveClient("admin", { integrationId: "chat" }), /does not allow/u);
58
+ });
@@ -32,6 +32,8 @@ test("assistant server config resolves per-surface AI config without app-level g
32
32
  };
33
33
 
34
34
  assert.deepEqual(resolveAssistantServerConfig(appConfig, "admin"), {
35
+ aiIntegrationId: "",
36
+ aiIntegrationIds: [],
35
37
  aiConfigPrefix: "ADMIN_ASSISTANT",
36
38
  provider: "anthropic",
37
39
  apiKey: "config-key",
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import test from "node:test";
4
+ import { createAssistantAiClientFactory } from "../src/server/AssistantProvider.js";
4
5
  import { parse as parseSfc } from "@vue/compiler-sfc";
5
6
 
6
7
  const exampleRoot = new URL("../patterns/assistant-surface/example/", import.meta.url);
@@ -18,7 +19,14 @@ test("assistant surface pattern keeps product config explicit and secrets extern
18
19
  settingsSurfaceId: "admin",
19
20
  configScope: "global"
20
21
  });
21
- assert.equal(serverConfig.assistantServer.admin.aiConfigPrefix, "ADMIN_ASSISTANT");
22
+ assert.equal(serverConfig.assistantServer.admin.aiIntegrationId, "assistant");
23
+ const { ApplicationAiFeature } = await import(new URL("src/ApplicationAiFeature.js", exampleRoot));
24
+ const { ai } = await ApplicationAiFeature.setup({ env: {} });
25
+ await assert.rejects(ai.resolve({ context: {}, integrationId: "assistant" }));
26
+ const client = await createAssistantAiClientFactory({ appConfig: serverConfig, env: {}, aiConnections: ai })
27
+ .resolveClient("admin", { context: { actor: { id: "fixture-user" } } });
28
+ assert.equal(client.enabled, true);
29
+ assert.equal(client.defaultModel, "big-pickle");
22
30
  assert.doesNotMatch(JSON.stringify({ publicConfig, serverConfig }), /api[_-]?key|secret/iu);
23
31
  assert.doesNotMatch(placementSource, /generator|scaffold|receipt|provenance/u);
24
32
 
@@ -69,7 +69,7 @@ async function* completionStream(completion = {}) {
69
69
  }
70
70
  }
71
71
 
72
- function createHarness(completions, { executeToolCall = null, tools: configuredTools = null } = {}) {
72
+ function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true } = {}) {
73
73
  const pendingCompletions = [...completions];
74
74
  const completionRequests = [];
75
75
  const transcriptMessages = [];
@@ -89,10 +89,12 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
89
89
  }));
90
90
 
91
91
  const chatService = createChatService({
92
+ attachments,
92
93
  aiClientFactory: {
93
94
  resolveClient() {
94
95
  return {
95
96
  enabled: true,
97
+ supportsAttachments,
96
98
  provider: "test",
97
99
  defaultModel: "test-model",
98
100
  async createChatCompletionStream(request) {
@@ -102,7 +104,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
102
104
  });
103
105
  const completion = pendingCompletions.shift();
104
106
  assert.ok(completion, "Expected a queued assistant completion.");
105
- return completionStream(completion);
107
+ return completion[Symbol.asyncIterator] ? completion : completionStream(completion);
106
108
  }
107
109
  };
108
110
  }
@@ -172,13 +174,14 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
172
174
  };
173
175
  }
174
176
 
175
- async function run(input = "Help me") {
177
+ async function run(input = "Help me", payload = {}) {
176
178
  return chatService.streamChat(
177
179
  {
178
180
  targetSurfaceId: "assistant",
179
181
  messageId: "message_1",
180
182
  input,
181
- history: []
183
+ history: [],
184
+ ...payload
182
185
  },
183
186
  {
184
187
  context: {
@@ -203,7 +206,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
203
206
 
204
207
  function assistantMessages(events) {
205
208
  return events
206
- .filter((event) => event.method === "sendAssistantMessage")
209
+ .filter((event) => event.method === "sendAssistantMessage" && event.payload.status !== "streaming")
207
210
  .map((event) => event.payload.text);
208
211
  }
209
212
 
@@ -255,7 +258,7 @@ test("an empty authorized tool set does not advertise discovery or collection ac
255
258
  assert.doesNotMatch(request.messages[0].content, /assistant_action_search|collection action/u);
256
259
  });
257
260
 
258
- test("progress-only output is retried silently and current-time prompts require a workspace clock", async () => {
261
+ test("progress-only output is replaced on retry and current-time prompts require a workspace clock", async () => {
259
262
  const harness = createHarness(
260
263
  [
261
264
  textCompletion("Let me query the current time."),
@@ -295,7 +298,7 @@ test("progress-only output is retried silently and current-time prompts require
295
298
  true
296
299
  );
297
300
  assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
298
- assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
301
+ assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
299
302
  assert.match(
300
303
  harness.completionRequests[0].messages[0].content,
301
304
  /first use any available authoritative workspace clock action/u
@@ -360,7 +363,7 @@ test("current-time preflight does not invent required tool input", async () => {
360
363
  assert.deepEqual(assistantMessages(harness.streamEvents), ["Choose a timezone before asking for its local time."]);
361
364
  });
362
365
 
363
- test("native search, contract, and execution workflows can exceed four silent tool rounds", async () => {
366
+ test("native search, contract, and execution workflows stream progress across more than four tool rounds", async () => {
364
367
  const harness = createHarness([
365
368
  toolCompletion("action_search", 1, { text: "I'll search first." }),
366
369
  toolCompletion("action_contract", 2, { text: "Let me inspect that contract." }),
@@ -377,7 +380,7 @@ test("native search, contract, and execution workflows can exceed four silent to
377
380
  ["action_search", "action_contract", "action_execute", "action_contract", "action_execute"]
378
381
  );
379
382
  assert.deepEqual(assistantMessages(harness.streamEvents), ["The requested operation completed successfully."]);
380
- assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
383
+ assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
381
384
  const assistantToolMessages = harness.completionRequests
382
385
  .flatMap((request) => request.messages)
383
386
  .filter((message) => Array.isArray(message.tool_calls));
@@ -415,7 +418,7 @@ test("tool-loop exhaustion gives the concise new-conversation instruction", asyn
415
418
  assert.equal(harness.executedTools.length, 16);
416
419
  assert.equal(harness.completionRequests.length, 19);
417
420
  assert.deepEqual(finalMessages, ["Limit reached. Start a new conversation."]);
418
- assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
421
+ assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
419
422
  });
420
423
 
421
424
  test("tool-failure recovery retains the bounded latest successful result", async () => {
@@ -461,3 +464,72 @@ test("tool-failure recovery retains the bounded latest successful result", async
461
464
  assert.match(fallback, /…\[truncated\]$/u);
462
465
  assert.doesNotMatch(fallback, /Limit reached/u);
463
466
  });
467
+
468
+
469
+ test("answer text reaches the client before the provider finishes, then persists once", async () => {
470
+ const firstChunk = Promise.withResolvers();
471
+ const continueResponse = Promise.withResolvers();
472
+ async function* response() {
473
+ yield { choices: [{ delta: { content: "The first sentence." } }] };
474
+ firstChunk.resolve();
475
+ await continueResponse.promise;
476
+ yield { choices: [{ delta: { content: " The second sentence." } }] };
477
+ }
478
+ const harness = createHarness([response()]);
479
+ const running = harness.run();
480
+ await firstChunk.promise;
481
+ try {
482
+ assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The first sentence."]);
483
+ assert.deepEqual(harness.completedConversations, []);
484
+ assert.deepEqual(harness.transcriptMessages.filter(message => message.role === "assistant"), []);
485
+ } finally {
486
+ continueResponse.resolve();
487
+ }
488
+ await running;
489
+ assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The first sentence.", " The second sentence."]);
490
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["The first sentence. The second sentence."]);
491
+ assert.deepEqual(harness.transcriptMessages.filter(message => message.role === "assistant").map(message => message.contentText), ["The first sentence. The second sentence."]);
492
+ });
493
+
494
+ test("split internal tags and tool arguments never become answer deltas", async () => {
495
+ async function* response() {
496
+ for (const content of ["<th", "ink>private reasoning", "</thi", "nk>", "<|DSML|function_", "calls><|DSML|invoke name=\"action_search\">", '{"secret":"private arguments"}', "</|DSML|invoke></|DSML|function_calls>"]) {
497
+ yield { choices: [{ delta: { content } }] };
498
+ }
499
+ }
500
+ const harness = createHarness([response(), textCompletion("The search is complete.")]);
501
+ await harness.run();
502
+ assert.deepEqual(harness.executedTools.map(request => request.toolName), ["action_search"]);
503
+ assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The search is complete."]);
504
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["The search is complete."]);
505
+ });
506
+
507
+
508
+ test("application-authorized attachments reach the model and survive transcript restore", async () => {
509
+ const calls = [];
510
+ const receipt = { attachmentId: "file-one", fileName: "notes.txt", size: 12 };
511
+ const attachments = { async resolve(request) {
512
+ calls.push(request);
513
+ assert.equal(request.context.actor.id, "user_1");
514
+ assert.equal(request.conversation.id, "conversation_1");
515
+ if (request.attachmentIds[0] !== "file-one") throw new Error("Attachment access denied");
516
+ return { attachments: [receipt], content: [{ type: "text", text: "Authorized file bytes" }] };
517
+ } };
518
+ const harness = createHarness([textCompletion("I read your notes.")], { attachments });
519
+ await harness.run("Read this", { attachmentIds: ["file-one"], history: [
520
+ { role: "user", content: "Previous file", attachmentIds: ["file-one"] }
521
+ ] });
522
+ assert.equal(calls.length, 2, "Both current and historical files must be authorized");
523
+ assert.deepEqual(harness.transcriptMessages[0].metadata.attachments, [receipt]);
524
+ assert.deepEqual(harness.completionRequests[0].messages.at(-1).content, [
525
+ { type: "text", text: "Read this" }, { type: "text", text: "Authorized file bytes" }
526
+ ]);
527
+ const denied = createHarness([textCompletion("Must not run")], { attachments });
528
+ await assert.rejects(denied.run("Read this", { attachmentIds: ["another-users-file"] }), /access denied/);
529
+ assert.deepEqual(denied.completionRequests, []);
530
+ assert.deepEqual(denied.transcriptMessages, []);
531
+ const disabled = createHarness([textCompletion("Must not run")]);
532
+ await assert.rejects(disabled.run("Read this", { attachmentIds: ["file-one"] }), /not configured/);
533
+ const legacy = createHarness([textCompletion("Must not run")], { attachments, supportsAttachments: false });
534
+ await assert.rejects(legacy.run("Read this", { attachmentIds: ["file-one"] }), /not configured/);
535
+ });
@@ -258,3 +258,44 @@ test("page assistant bounds long history in a document layout and preserves draf
258
258
  await stopProcess(vite);
259
259
  }
260
260
  });
261
+
262
+
263
+ test("the ready-made UI streams from the real chat service with selected AI connections and files", {
264
+ skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1", timeout: 90_000
265
+ }, async () => {
266
+ const vite = await startViteFixture({ fixtureRoot });
267
+ const browser = await chromium.launch(createChromiumLaunchOptions());
268
+ const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
269
+ try {
270
+ await page.request.get(`${vite.baseURL}/fixture/service`);
271
+ await page.goto(`${vite.baseURL}/?files=1`);
272
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
273
+ await expect(input).toBeEnabled();
274
+ await page.getByRole("button", { name: "Choose AI", exact: true }).click();
275
+ await page.getByRole("button", { name: "Deep model", exact: true }).click();
276
+ await page.getByRole("button", { name: "Apply", exact: true }).click();
277
+ await page.locator('input[type="file"]').setInputFiles({ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("notes") });
278
+ await expect(page.getByText("notes.txt", { exact: true })).toBeVisible();
279
+ await input.fill("Read the notes");
280
+ await input.press("Enter");
281
+ await expect(page.getByText("Assistant is working…", { exact: true }).last()).toBeVisible();
282
+ await expect(page.getByLabel("Message attachments")).toHaveCount(0);
283
+ await expect(page.getByLabel("Attached files")).toBeVisible();
284
+ await input.fill("My next draft");
285
+ await input.evaluate(element => element.setSelectionRange(3, 7));
286
+ await page.request.get(`${vite.baseURL}/fixture/next`);
287
+ await expect(page.getByText("Partial answer", { exact: true })).toBeVisible();
288
+ await expect(input).toHaveValue("My next draft");
289
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 7]);
290
+ let state = await (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
291
+ assert.equal(state.requests[0].integrationId, "deep");
292
+ assert.deepEqual(state.requests[0].attachmentIds, ["fixture-file"]);
293
+ assert.equal(state.transcript.filter(message => message.role === "assistant").length, 0);
294
+ await page.request.get(`${vite.baseURL}/fixture/next?finish=1`);
295
+ await expect(page.getByText("Partial answer completed.", { exact: true })).toHaveCount(1);
296
+ await expect(page.getByText("Assistant is working…", { exact: true })).toHaveCount(0);
297
+ state = await (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
298
+ assert.deepEqual(state.transcript[0].metadata.attachments, [{ attachmentId: "fixture-file", fileName: "notes.txt", size: 5 }]);
299
+ assert.equal(state.transcript.filter(message => message.role === "assistant").length, 1);
300
+ } finally { await browser.close(); await stopProcess(vite); }
301
+ });