@jskit-ai/assistant-core 0.2.24 → 0.2.26

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 (27) hide show
  1. package/CHANGELOG.md +6 -1
  2. package/examples/conversation/package.json +6 -6
  3. package/package.json +8 -8
  4. package/src/client/components/AssistantSettingsFormCard.vue +14 -9
  5. package/src/client/conversation/AssistantAttachmentQueue.vue +2 -4
  6. package/src/client/conversation/AssistantConversationElement.vue +2 -2
  7. package/src/client/conversation/AssistantMessageAttachments.vue +2 -2
  8. package/src/client/conversation/AssistantModelControl.vue +5 -4
  9. package/src/client/conversation/AssistantPromptInput.vue +24 -18
  10. package/src/client/conversation/AssistantTranscript.css +7 -15
  11. package/src/client/conversation/LongTextInlineParts.vue +2 -3
  12. package/src/client/conversation/LongTextPreviewBlocks.vue +7 -6
  13. package/src/server/conversation/codexProviderOwner.js +6 -1
  14. package/src/server/conversation/conversationEvents.js +30 -2
  15. package/src/server/conversation/conversationHandles.js +1 -1
  16. package/src/server/lib/providers/openAiCompatibleClient.js +12 -8
  17. package/test/assistantScroll.browser.test.js +53 -103
  18. package/test/claudeConversationSession.test.js +16 -1
  19. package/test/componentContracts.test.js +44 -0
  20. package/test/conversationAvatarThumbnail.test.js +3 -4
  21. package/test/conversationSession.test.js +63 -0
  22. package/test/fixtures/nestedListQuestionMessage.md +19 -19
  23. package/test/longTextBlocks.test.js +14 -14
  24. package/test/messageDelivery.browser.test.js +12 -14
  25. package/test/openAiLoading.test.js +25 -0
  26. package/test/opencodeConversationSession.test.js +20 -4
  27. package/test/transcriptTheme.browser.test.js +6 -6
package/CHANGELOG.md CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- - The avatar's Show and Minimise buttons set no fixed height: they are 40dp icon buttons with material-web's 48dp touch target, so `jskit check m3` passes on `AssistantConversationElement`.
5
+ - The OpenAI SDK loads with an OpenAI-compatible client's first completion instead of with the server, so a boot of an app with the assistant loads 154 fewer modules (0.13 s on their own).
6
+ - A subscriber hears a streamed delta (an in-progress message) on an authorization under a second old instead of authorizing again for each one: every delta used to wait for one `authorize` call per subscriber before the stream continued (one SELECT each in `@jskit-ai/assistant-runtime`), so a 200-delta reply to one open page cost about 200 reads and now costs as many as a 10-delta one. Every other event (admission, phase, saved message, settlement) still authorizes again, and a revoked subscriber stops hearing the turn within a second.
7
+ - The conversation's remaining boxes are Material 3 tonal containers instead of outlines, visual only: queued and sent attachments and the questions are `surface-container-high`, code blocks and inline code `surface-container-highest`, tables and details `surface-container-low` (a table keeps `outline-variant` row dividers), question numbers `secondary-container` badges, and the model menu a `surface-container` surface at elevation level 2 with `outline-variant` dividers. The composer stays Material 3's outlined text field, the only outline left.
8
+ - `AssistantSettingsFormCard` takes a `formId`: its form gets that id and the card draws no Save button of its own, so the page's app bar can carry Save as a submit button for the form (`type="submit" form="<formId>"`), as a Material 3 settings pane does. Without one, the card keeps its Save; a person who may not save still sees the read-only note.
9
+ - Material 3 containers in the conversation, visual only: the transcript area is no longer outlined (its bubbles are the only containers, and the welcome bubble is a tonal fill without an outline), the composer is Material 3's outlined text field (4dp corners, the `outline` role, a 2dp primary outline on focus, 16dp side padding) with Send and the other actions 12dp from it (8dp when compact), and `AssistantSettingsFormCard` adds no heading or divider inside the settings pane that titles it: its subtitle is the card's first line and its title names the section for assistive technology.
10
+ - The avatar's Show and Minimise buttons set no fixed size: they are 40dp icon buttons with material-web's 48dp touch target, so `jskit check m3` passes on `AssistantConversationElement`. The collapsed Show button is 40dp wide like Minimise, no longer 44dp.
6
11
  - The goal menu closes when Cancel succeeds, as it does after Start; Pause and Resume keep it open.
7
12
  - `AssistantConversationElement` has an `avatar-thumbnail` slot, with the `{ size, height }` scope of the other avatar slots: while the avatar is hidden, the **Show avatar** button draws its content instead of the anonymous account icon, such as a still miniature of the avatar, clipped to the button's round 32-pixel disc. The button keeps its label, title and 44-pixel target, and shows the icon as before without the slot.
8
13
  - A delivered Codex turn is held as not yet started only until Codex reports it: a `turn/started` that arrives before the `turn/start` answer counts, and a thread read listing exactly that turn as completed or failed settles a turn whose notifications were missed, so its answer is no longer kept waiting for a notification that never comes.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.24",
12
- "@jskit-ai/http-runtime": "0.2.10",
13
- "@jskit-ai/kernel": "0.2.10",
14
- "@jskit-ai/material-web": "0.1.2",
11
+ "@jskit-ai/assistant-core": "0.2.26",
12
+ "@jskit-ai/http-runtime": "0.2.12",
13
+ "@jskit-ai/kernel": "0.2.12",
14
+ "@jskit-ai/material-web": "0.1.3",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.11",
18
- "@jskit-ai/connectors-catalog": "0.2.11"
17
+ "@jskit-ai/connectors-core": "0.2.13",
18
+ "@jskit-ai/connectors-catalog": "0.2.13"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.24",
3
+ "version": "0.2.26",
4
4
  "type": "module",
5
5
  "scripts": {
6
- "test": "node --test --test-concurrency=1"
6
+ "test": "jskit test"
7
7
  },
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
@@ -51,9 +51,9 @@
51
51
  "@ai-sdk/perplexity": "^4.0.43",
52
52
  "@ai-sdk/togetherai": "^3.0.49",
53
53
  "@ai-sdk/xai": "^4.0.58",
54
- "@jskit-ai/agent": "0.1.1",
55
- "@jskit-ai/resource-core": "0.1.201",
56
- "@jskit-ai/resource-crud-core": "0.1.201",
54
+ "@jskit-ai/agent": "0.1.2",
55
+ "@jskit-ai/resource-core": "0.1.203",
56
+ "@jskit-ai/resource-crud-core": "0.1.203",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
59
59
  "json-rest-schema": "^1.0.17",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.10",
65
- "@jskit-ai/kernel": "0.2.10",
64
+ "@jskit-ai/http-runtime": "0.2.12",
65
+ "@jskit-ai/kernel": "0.2.12",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.3.0"
68
68
  },
@@ -220,6 +220,6 @@
220
220
  }
221
221
  },
222
222
  "devDependencies": {
223
- "@jskit-ai/material-web": "0.1.2"
223
+ "@jskit-ai/material-web": "0.1.3"
224
224
  }
225
225
  }
@@ -1,12 +1,10 @@
1
1
  <template>
2
- <section :class="props.rootClass">
2
+ <!-- The settings pane around it carries the page title, so the card adds no heading of its own: the title names the
3
+ section for assistive technology and the subtitle says what the settings apply to. -->
4
+ <section :class="props.rootClass" :aria-label="props.title">
3
5
  <v-card>
4
- <v-card-item>
5
- <v-card-title class="text-title-large">{{ props.title }}</v-card-title>
6
- <v-card-subtitle>{{ props.subtitle }}</v-card-subtitle>
7
- </v-card-item>
8
- <v-divider />
9
- <v-card-text class="pt-4">
6
+ <v-card-text>
7
+ <p class="text-body-medium on-surface-variant mb-4">{{ props.subtitle }}</p>
10
8
  <template v-if="props.showFormSkeleton">
11
9
  <v-skeleton-loader type="text@2, list-item-two-line@4, button" />
12
10
  </template>
@@ -20,10 +18,12 @@
20
18
  </p>
21
19
 
22
20
  <template v-else>
23
- <v-form @submit.prevent="props.addEdit.submit" novalidate>
21
+ <!-- With a formId, the page's app bar carries Save (a submit button for this form), as a settings pane does;
22
+ the card then shows only the read-only note. -->
23
+ <v-form :id="props.formId || undefined" @submit.prevent="props.addEdit.submit" novalidate>
24
24
  <v-progress-linear v-if="props.addEdit.isRefetching" indeterminate class="mb-4" />
25
25
  <slot />
26
- <div class="d-flex align-center justify-end ga-3 mt-2">
26
+ <div v-if="!props.formId || !props.addEdit.canSave" class="d-flex align-center justify-end ga-3 mt-2">
27
27
  <v-btn
28
28
  v-if="props.addEdit.canSave"
29
29
  type="submit"
@@ -74,6 +74,11 @@ const props = defineProps({
74
74
  showFormSkeleton: {
75
75
  type: Boolean,
76
76
  default: false
77
+ },
78
+ // The form's id, when the page's app bar carries its Save button (type="submit" form="<formId>").
79
+ formId: {
80
+ type: String,
81
+ default: ""
77
82
  }
78
83
  });
79
84
  </script>
@@ -307,10 +307,9 @@ onBeforeUnmount(() => {
307
307
  </script>
308
308
 
309
309
  <style scoped>
310
+ /* A Material 3 tonal container above the composer: a surface-container-high fill, no outline. */
310
311
  .assistant-attachment-queue {
311
- background: rgba(var(--v-theme-on-surface), 0.035);
312
- border: 1px solid rgba(var(--v-theme-outline), 0.2);
313
- border-bottom: 0;
312
+ background: rgb(var(--v-theme-surface-container-high));
314
313
  border-radius: 14px 14px 0 0;
315
314
  color: rgb(var(--v-theme-on-surface));
316
315
  display: grid;
@@ -332,7 +331,6 @@ onBeforeUnmount(() => {
332
331
  }
333
332
 
334
333
  .assistant-attachment-queue--standalone {
335
- border-bottom: 1px solid rgba(var(--v-theme-outline), 0.2);
336
334
  border-radius: 10px;
337
335
  }
338
336
 
@@ -8,7 +8,7 @@
8
8
  <slot name="avatar-control" :size="avatarSize" :height="avatarHeight" />
9
9
  <v-btn
10
10
  icon="$accountCircle" size="small"
11
- variant="text" :width="44" :min-width="44"
11
+ variant="text"
12
12
  class="assistant-conversation__avatar-size"
13
13
  :aria-label="t('jskit.assistant.chat.showAvatar')" :title="t('jskit.assistant.chat.showAvatar')"
14
14
  :aria-expanded="avatarSize !== 'hidden'" @click="toggleAvatar"
@@ -26,7 +26,7 @@
26
26
  <slot name="avatar-tools" :size="avatarSize" :height="avatarHeight" />
27
27
  <v-btn
28
28
  icon="$minus" size="small" variant="text" color="primary"
29
- :width="40" :min-width="40" class="assistant-conversation__avatar-size"
29
+ class="assistant-conversation__avatar-size"
30
30
  :aria-label="t('jskit.assistant.chat.minimiseAvatar')" :title="t('jskit.assistant.chat.minimiseAvatar')" :aria-expanded="true"
31
31
  @click="toggleAvatar"
32
32
  >
@@ -70,10 +70,10 @@ function attachmentKey(attachment, index) {
70
70
  width: min(19rem, 100%);
71
71
  }
72
72
 
73
+ /* Each attachment is a Material 3 tonal container: a surface-container-high fill, no outline. */
73
74
  .assistant-conversation-attachments__item {
74
75
  align-items: center;
75
- background: rgba(var(--v-theme-surface), 0.82);
76
- border: 1px solid rgba(var(--v-theme-outline), 0.2);
76
+ background: rgb(var(--v-theme-surface-container-high));
77
77
  border-radius: 10px;
78
78
  color: rgb(var(--v-theme-on-surface));
79
79
  min-height: 2.8rem;
@@ -20,11 +20,12 @@
20
20
  />
21
21
  </template>
22
22
 
23
+ <!-- A Material 3 menu surface: surface-container at elevation level 2, no outline. -->
23
24
  <v-sheet
24
25
  :aria-label="t('jskit.assistant.model.selector')"
25
- border
26
26
  class="assistant-model-control"
27
- :elevation="3"
27
+ color="surface-container"
28
+ :elevation="2"
28
29
  rounded="xl"
29
30
  >
30
31
  <header class="assistant-model-control__header">
@@ -235,7 +236,7 @@ function selectVariant(value) { if (!props.changesDisabled && !props.saving) emi
235
236
 
236
237
  .assistant-model-control__header {
237
238
  align-items: center;
238
- border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
239
+ border-bottom: 1px solid rgb(var(--v-theme-outline-variant));
239
240
  display: flex;
240
241
  gap: 0.65rem;
241
242
  padding: 0.1rem 0.1rem 0.7rem;
@@ -311,7 +312,7 @@ function selectVariant(value) { if (!props.changesDisabled && !props.saving) emi
311
312
 
312
313
  .assistant-model-control__actions {
313
314
  align-items: center;
314
- border-top: 1px solid rgba(var(--v-theme-outline), 0.12);
315
+ border-top: 1px solid rgb(var(--v-theme-outline-variant));
315
316
  display: grid;
316
317
  gap: 0.4rem;
317
318
  grid-template-columns: auto 1fr auto;
@@ -261,12 +261,13 @@ defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightFo
261
261
  width: 100%;
262
262
  }
263
263
 
264
+ /* Material 3's outlined text field: 4dp corners, a 1dp outline that becomes a 2dp primary one on focus (drawn inset, so
265
+ nothing moves), 16dp side padding, and the label in the outline. */
264
266
  .assistant-prompt-input__field {
265
267
  background: rgb(var(--v-theme-surface));
266
- border: 1px solid rgba(var(--v-theme-on-surface), 0.34);
267
- border-radius: 18px;
268
+ border: 1px solid rgb(var(--v-theme-outline));
269
+ border-radius: 4px;
268
270
  box-sizing: border-box;
269
- box-shadow: inset 0 0 0 1px rgba(var(--v-theme-on-surface), 0.08);
270
271
  display: grid;
271
272
  max-width: 100%;
272
273
  min-width: 0;
@@ -274,29 +275,33 @@ defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightFo
274
275
  transition: border-color 120ms ease, box-shadow 120ms ease;
275
276
  }
276
277
 
278
+ .assistant-prompt-input__field:not(.assistant-prompt-input__field--disabled):hover {
279
+ border-color: rgb(var(--v-theme-on-surface));
280
+ }
281
+
277
282
  .assistant-prompt-input__field:not(.assistant-prompt-input__field--disabled):focus-within {
278
283
  border-color: rgb(var(--v-theme-primary));
279
- box-shadow:
280
- 0 0 0 2px rgba(var(--v-theme-primary), 0.28),
281
- inset 0 0 0 1px rgba(var(--v-theme-primary), 0.2);
284
+ box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
282
285
  }
283
286
 
284
287
  .assistant-prompt-input__field--disabled {
285
- background: rgba(var(--v-theme-on-surface), 0.04);
286
- border-color: rgba(var(--v-theme-on-surface), 0.16);
287
- box-shadow: none;
288
+ border-color: rgba(var(--v-theme-on-surface), 0.12);
289
+ }
290
+
291
+ .assistant-prompt-input__field:not(.assistant-prompt-input__field--disabled):focus-within .assistant-prompt-input__label {
292
+ color: rgb(var(--v-theme-primary));
288
293
  }
289
294
 
290
295
  .assistant-prompt-input__label {
291
296
  align-self: start;
292
297
  background: rgb(var(--v-theme-surface));
293
- color: rgba(var(--v-theme-on-surface), 0.82);
294
- font-size: 0.78rem;
295
- line-height: 1.1;
296
- margin: -0.5rem 0 0 0.9rem;
297
- max-width: calc(100% - 1.8rem);
298
+ color: rgb(var(--v-theme-on-surface-variant));
299
+ font-size: 0.75rem;
300
+ line-height: 1rem;
301
+ margin: -0.5rem 0 0 12px;
302
+ max-width: calc(100% - 24px);
298
303
  overflow: hidden;
299
- padding-inline: 0.24rem;
304
+ padding-inline: 4px;
300
305
  text-overflow: ellipsis;
301
306
  white-space: nowrap;
302
307
  width: fit-content;
@@ -349,19 +354,20 @@ defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightFo
349
354
  opacity: 1;
350
355
  }
351
356
 
357
+ /* Send and the other actions keep 12dp from the outline (8dp in the compact composer). */
352
358
  .assistant-prompt-input__footer {
353
359
  min-width: 0;
354
- padding: 0 0.55rem 0.55rem;
360
+ padding: 0 12px 12px;
355
361
  }
356
362
 
357
363
  .assistant-prompt-input--compact .assistant-prompt-input__input {
358
364
  max-height: min(8rem, 20dvh);
359
365
  min-height: 2.5rem;
360
- padding: 0.4rem 0.65rem 0.15rem;
366
+ padding: 0.4rem 12px 0.15rem;
361
367
  }
362
368
 
363
369
  .assistant-prompt-input--compact .assistant-prompt-input__footer {
364
- padding: 0 0.3rem 0.25rem;
370
+ padding: 0 8px 8px;
365
371
  }
366
372
 
367
373
  .assistant-prompt-input__details {
@@ -1,7 +1,6 @@
1
1
  .assistant-transcript__plain-user { white-space: pre-wrap; margin: 0; }
2
+ /* The transcript is the page's own area, not a container: its bubbles are the only containers in it. */
2
3
  .assistant-transcript {
3
- border: 1px solid rgba(var(--v-theme-outline), 0.24);
4
- border-radius: 8px;
5
4
  display: grid;
6
5
  gap: 0.35rem;
7
6
  grid-template-rows: minmax(0, 1fr);
@@ -87,7 +86,6 @@
87
86
 
88
87
  .assistant-transcript__welcome {
89
88
  background: rgba(var(--v-theme-primary), 0.08);
90
- border: 1px solid rgba(var(--v-theme-primary), 0.18);
91
89
  border-radius: 16px;
92
90
  color: rgb(var(--v-theme-on-surface));
93
91
  display: grid;
@@ -239,10 +237,6 @@
239
237
  padding: 0;
240
238
  }
241
239
 
242
- .assistant-transcript--task {
243
- border-color: rgba(126, 87, 194, 0.32);
244
- }
245
-
246
240
  .assistant-transcript--task .assistant-transcript__avatar--assistant {
247
241
  background: #7e57c2;
248
242
  }
@@ -416,10 +410,9 @@
416
410
  .assistant-transcript__message--user :deep(.studio-long-text-review__table th > code),
417
411
  .assistant-transcript__message--user :deep(.studio-long-text-review__table td > code),
418
412
  .assistant-transcript__message--user :deep(.studio-long-text-review__details-summary code) {
419
- background: rgba(var(--v-theme-primary), 0.07);
420
- border: 1px solid rgba(var(--v-theme-primary), 0.14);
413
+ background: rgb(var(--v-theme-surface-container-highest));
421
414
  border-radius: 3px;
422
- color: rgba(var(--v-theme-on-surface), 0.9);
415
+ color: rgb(var(--v-theme-on-surface));
423
416
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
424
417
  font-size: 0.9em;
425
418
  padding: 0.02rem 0.2rem;
@@ -436,10 +429,10 @@
436
429
  padding: 0;
437
430
  }
438
431
 
432
+ /* Questions are tonal containers (surface-container-high), their numbers secondary-container badges: no outlines. */
439
433
  .assistant-transcript__question {
440
434
  align-items: start;
441
- background: rgba(var(--v-theme-surface), 0.62);
442
- border: 1px solid rgba(var(--v-theme-outline), 0.2);
435
+ background: rgb(var(--v-theme-surface-container-high));
443
436
  border-radius: 8px;
444
437
  box-sizing: border-box;
445
438
  display: grid;
@@ -452,10 +445,9 @@
452
445
 
453
446
  .assistant-transcript__question-number {
454
447
  align-items: center;
455
- background: rgba(var(--v-theme-primary), 0.1);
456
- border: 1px solid rgba(var(--v-theme-primary), 0.2);
448
+ background: rgb(var(--v-theme-secondary-container));
457
449
  border-radius: 999px;
458
- color: rgb(var(--v-theme-primary));
450
+ color: rgb(var(--v-theme-on-secondary-container));
459
451
  display: inline-flex;
460
452
  font-size: 0.72rem;
461
453
  font-weight: 760;
@@ -44,10 +44,9 @@ function handleLinkClick(event, part = {}) {
44
44
 
45
45
  <style scoped>
46
46
  code {
47
- background: rgba(var(--v-theme-primary), 0.07);
48
- border: 1px solid rgba(var(--v-theme-primary), 0.14);
47
+ background: rgb(var(--v-theme-surface-container-highest));
49
48
  border-radius: 3px;
50
- color: rgba(var(--v-theme-on-surface), 0.9);
49
+ color: rgb(var(--v-theme-on-surface));
51
50
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
52
51
  font-size: 0.9em;
53
52
  overflow-wrap: anywhere;
@@ -240,9 +240,10 @@ h5.studio-long-text-review__heading {
240
240
  font-size: 0.8rem;
241
241
  }
242
242
 
243
+ /* Code, tables and details are Material 3 tonal containers (surface-container tones), not outlined boxes; a
244
+ table keeps outline-variant dividers between its rows. */
243
245
  .studio-long-text-review__code {
244
- background: rgba(var(--v-theme-surface-variant), 0.58);
245
- border: 1px solid rgba(var(--v-border-color), 0.26);
246
+ background: rgb(var(--v-theme-surface-container-highest));
246
247
  border-radius: 8px;
247
248
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
248
249
  font-size: 0.82rem;
@@ -256,7 +257,7 @@ h5.studio-long-text-review__heading {
256
257
  }
257
258
 
258
259
  .studio-long-text-review__table-wrap {
259
- border: 1px solid rgba(var(--v-border-color), 0.28);
260
+ background: rgb(var(--v-theme-surface-container-low));
260
261
  border-radius: 8px;
261
262
  inline-size: fit-content;
262
263
  margin-block: 0.35rem;
@@ -282,7 +283,7 @@ h5.studio-long-text-review__heading {
282
283
 
283
284
  .studio-long-text-review__table th,
284
285
  .studio-long-text-review__table td {
285
- border-bottom: 1px solid rgba(var(--v-border-color), 0.22);
286
+ border-bottom: 1px solid rgb(var(--v-theme-outline-variant));
286
287
  max-inline-size: 28rem;
287
288
  padding: 0.55rem 0.75rem;
288
289
  text-align: left;
@@ -290,7 +291,7 @@ h5.studio-long-text-review__heading {
290
291
  }
291
292
 
292
293
  .studio-long-text-review__table th {
293
- background: rgba(var(--v-theme-surface-variant), 0.48);
294
+ background: rgb(var(--v-theme-surface-container-high));
294
295
  color: rgb(var(--v-theme-on-surface));
295
296
  font-weight: 760;
296
297
  position: sticky;
@@ -320,7 +321,7 @@ h5.studio-long-text-review__heading {
320
321
  }
321
322
 
322
323
  .studio-long-text-review__details {
323
- border: 1px solid rgba(var(--v-border-color), 0.34);
324
+ background: rgb(var(--v-theme-surface-container-low));
324
325
  border-radius: 8px;
325
326
  display: block;
326
327
  min-width: 0;
@@ -550,7 +550,12 @@ export function createCodexAppServerProviderOwner({
550
550
  assertOpen();
551
551
  if (!record.observationStop || record.observationStop.error !== error) {
552
552
  reportRecoveryEvent(record, "lost", error);
553
- const stop = { error, promise: invalidation.suspendUnobservedProvider(record, error) };
553
+ // A shared stop of this native runtime gives each of its peers the failure that started it, and records their
554
+ // barriers and completion itself. A peer that then loses its own connection to the stopped process joins that
555
+ // stop instead of stopping the runtime again, which would reopen its stopped barrier while the first finishes.
556
+ const runtimeKey = codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions);
557
+ const shared = runtimePeers(runtimeKey).find(peer => peer !== record && peer.observationStop?.error === error);
558
+ const stop = { error, promise: shared?.observationStop.promise || invalidation.suspendUnobservedProvider(record, error) };
554
559
  record.observationStop = stop;
555
560
  const tracked = stop.promise.catch(failure => {
556
561
  reportRecoveryEvent(record, "stopFailed", failure);
@@ -4,6 +4,9 @@ import { submittedMessage } from "./conversationReads.js";
4
4
 
5
5
  /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
6
6
 
7
+ // How long a subscriber's authorization covers the streamed deltas that follow it.
8
+ const STREAMED_DELTA_AUTHORIZATION_MS = 1000;
9
+
7
10
  /**
8
11
  * Runs one conversation entry's operations in order: each waits for the one before it.
9
12
  * @param {ConversationEntry} entry
@@ -36,16 +39,40 @@ function createConversationEvents({ authorize, isClosed, storage, streams, compl
36
39
  * @param {Set<string>} [grant] The operations this call already authorized.
37
40
  */
38
41
  async function access(context, id, operation, entry, grant) {
39
- if (isClosed() || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_handle_closed", 409, { messageCode: "assistant.conversation.handleClosed" });
42
+ assertOpen(entry);
40
43
  if (!grant?.has(operation)) {
41
44
  if (!await authorize({ context, conversationId: id, operation })) {
42
45
  throw failure("This conversation is not available to this identity.", "conversation_forbidden", 403);
43
46
  }
44
47
  grant?.add(operation);
45
48
  }
49
+ assertOpen(entry);
50
+ }
51
+
52
+ /** @param {ConversationEntry} [entry] */
53
+ function assertOpen(entry) {
46
54
  if (isClosed() || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_handle_closed", 409, { messageCode: "assistant.conversation.handleClosed" });
47
55
  }
48
56
 
57
+ /**
58
+ * A subscriber hears an event once it is authorized again. A streamed delta (an in-progress
59
+ * message) uses the subscriber's last authorization while it is under a second old: a reply
60
+ * streams hundreds of deltas, each authorization is a read the stream waits for, and a
61
+ * revoked subscriber still stops hearing the turn within a second. Every other event (an
62
+ * admission, a phase, a saved message, a settlement) authorizes again.
63
+ * @param {ConversationEntry} entry
64
+ * @param {{ context: object, authorizedAt?: number }} subscription
65
+ * @param {boolean} streamed
66
+ */
67
+ async function admitSubscriber(entry, subscription, streamed) {
68
+ if (streamed && Date.now() - subscription.authorizedAt < STREAMED_DELTA_AUTHORIZATION_MS) {
69
+ assertOpen(entry);
70
+ return;
71
+ }
72
+ await access(subscription.context, entry.id, "subscribe", entry);
73
+ subscription.authorizedAt = Date.now();
74
+ }
75
+
49
76
  /**
50
77
  * @param {ConversationEntry} entry
51
78
  * @param {object} event
@@ -80,8 +107,9 @@ function createConversationEvents({ authorize, isClosed, storage, streams, compl
80
107
  .some(message => message.messageId === payload.messageId && message.completedEnvelope === true && message.origin === "application");
81
108
  if (marked) payload.completedEnvelope = true;
82
109
  }
110
+ const streamed = payload.type === "message" && payload.status === "inProgress";
83
111
  await Promise.all([...entry.listeners].map(async subscription => {
84
- try { await access(subscription.context, entry.id, "subscribe", entry); }
112
+ try { await admitSubscriber(entry, subscription, streamed); }
85
113
  catch { entry.listeners.delete(subscription); return; }
86
114
  if (isCurrent && !isCurrent()) return;
87
115
  // Presentation failures cannot interrupt persistence or provider work.
@@ -212,7 +212,7 @@ function createConversationHandles({ entries, storage, defaultHost, events, read
212
212
  await events.access(context, id, "subscribe", entry);
213
213
  if (entry.conversation?.native?.scoped) throw failure("This scoped conversation retains its original event delivery.", "conversation_event_delivery_retained", 409, { messageCode: "assistant.conversation.eventDeliveryRetained" });
214
214
  if (typeof listener !== "function") throw new TypeError("A conversation listener is required.");
215
- const subscription = { context, listener };
215
+ const subscription = { context, listener, authorizedAt: Date.now() };
216
216
  entry.listeners.add(subscription);
217
217
  return () => entry.listeners.delete(subscription);
218
218
  },
@@ -1,4 +1,3 @@
1
- import OpenAI from "openai";
2
1
  import { createOpenAI } from "@ai-sdk/openai";
3
2
  import { chatModel } from "@jskit-ai/agent/server";
4
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
@@ -37,11 +36,16 @@ function createOpenAiCompatibleClient({
37
36
  const normalizedBaseUrl = normalizeOptionalHttpUrl(baseUrl, {
38
37
  context: `assistant ${normalizedProvider} baseUrl`
39
38
  });
40
- const client = new OpenAI({
41
- apiKey: normalizedApiKey,
42
- ...(normalizedBaseUrl ? { baseURL: normalizedBaseUrl } : {}),
43
- timeout: normalizeTimeoutMs(timeoutMs)
44
- });
39
+ // The OpenAI SDK (150 modules) loads with the first completion, not with every server that could ask for one.
40
+ let clientPromise = null;
41
+ const chatCompletions = async () => {
42
+ clientPromise ??= import("openai").then(({ default: OpenAI }) => new OpenAI({
43
+ apiKey: normalizedApiKey,
44
+ ...(normalizedBaseUrl ? { baseURL: normalizedBaseUrl } : {}),
45
+ timeout: normalizeTimeoutMs(timeoutMs)
46
+ }));
47
+ return (await clientPromise).chat.completions;
48
+ };
45
49
 
46
50
  const baseURL = normalizedBaseUrl || undefined;
47
51
 
@@ -54,11 +58,11 @@ function createOpenAiCompatibleClient({
54
58
  stream: false
55
59
  };
56
60
 
57
- return client.chat.completions.create(requestPayload, signal ? { signal } : undefined);
61
+ return (await chatCompletions()).create(requestPayload, signal ? { signal } : undefined);
58
62
  }
59
63
 
60
64
  async function createChatCompletionStream({ messages = [], tools = [], signal, temperature = 0.2 } = {}) {
61
- return client.chat.completions.create(
65
+ return (await chatCompletions()).create(
62
66
  {
63
67
  model: normalizedModel,
64
68
  messages,