@jskit-ai/assistant-core 0.1.204 → 0.1.205

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.204",
12
- "@jskit-ai/http-runtime": "0.1.224",
13
- "@jskit-ai/kernel": "0.1.226",
11
+ "@jskit-ai/assistant-core": "0.1.205",
12
+ "@jskit-ai/http-runtime": "0.1.225",
13
+ "@jskit-ai/kernel": "0.1.227",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.1.43",
18
- "@jskit-ai/connectors-catalog": "0.1.43"
17
+ "@jskit-ai/connectors-core": "0.1.44",
18
+ "@jskit-ai/connectors-catalog": "0.1.44"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -1,10 +1,21 @@
1
1
  <script setup>
2
2
  import { computed, reactive, ref } from "vue";
3
+ import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
3
4
  import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
5
  import { useAssistantAttachments } from "../../src/client/conversation/useAssistantAttachments.js";
5
6
  import { useAssistantSuggestions } from "../../src/client/conversation/useAssistantSuggestions.js";
6
7
  const phase = ref("idle");
8
+ const previewFixture = new URLSearchParams(location.search).has("preview");
9
+ const temporaryWords = ref("");
10
+ const temporaryPresent = ref(false);
11
+ const temporaryEditing = ref(false);
12
+ const newerWords = ref("");
13
+ const newerPresent = ref(false);
14
+ const newerEditing = ref(false);
15
+ const previewCanTake = ref(true);
16
+ const previewDelivery = createAssistantMessageDelivery();
7
17
  const avatarFixture = new URLSearchParams(location.search).has("avatar");
18
+ const avatarToolsFixture = new URLSearchParams(location.search).has("avatarTools");
8
19
  const showAvatar = ref(avatarFixture);
9
20
  const avatarSize = ref("compact");
10
21
  const short = ref(avatarFixture);
@@ -63,8 +74,57 @@ const goalState = reactive({
63
74
  resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
64
75
  });
65
76
  const adapter = reactive({
77
+ delivery: previewFixture ? previewDelivery : undefined,
66
78
  conversation: {
67
79
  assistantLabel,
80
+ previewMessages: computed(() => {
81
+ const pending = adapter.conversation.previewMessage;
82
+ const previews = pending ? [pending] : [];
83
+ if (newerPresent.value) previews.push({ id: "newer", text: newerWords.value, actions: {
84
+ canDiscard: previewCanTake.value,
85
+ canEdit: previewCanTake.value && !temporaryPresent.value,
86
+ editing: newerEditing.value,
87
+ discard: id => { if (id === "newer" && previewCanTake.value) newerPresent.value = false; },
88
+ edit: id => { if (id === "newer" && previewCanTake.value && !temporaryPresent.value) newerEditing.value = true; },
89
+ update: (id, text) => { if (id === "newer" && newerEditing.value) newerWords.value = text; },
90
+ send: newerEditing.value ? id => {
91
+ if (id !== "newer" || !previewCanTake.value || !newerWords.value.trim()) return;
92
+ adapter.conversation.turns = [...adapter.conversation.turns, { turnId: id,
93
+ user: { messageId: id, role: "user", text: newerWords.value } }];
94
+ newerPresent.value = false;
95
+ } : null,
96
+ canSend: previewCanTake.value && Boolean(newerWords.value.trim()), sending: false
97
+ } });
98
+ return previews;
99
+ }),
100
+ previewMessage: computed(() => temporaryPresent.value ? {
101
+ id: "temporary", text: temporaryWords.value,
102
+ actions: {
103
+ canDiscard: previewCanTake.value,
104
+ canEdit: previewCanTake.value,
105
+ editing: temporaryEditing.value,
106
+ canSend: previewCanTake.value && Boolean(temporaryWords.value.trim()),
107
+ sending: false,
108
+ discard(id) {
109
+ if (id !== "temporary" || !previewCanTake.value) return;
110
+ temporaryPresent.value = false;
111
+ if (previewDelivery.find(id)?.status === "failed") previewDelivery.cancel(id);
112
+ },
113
+ edit(id) {
114
+ if (id !== "temporary" || !previewCanTake.value) return;
115
+ temporaryEditing.value = true;
116
+ if (previewDelivery.find(id)?.status === "failed") previewDelivery.cancel(id);
117
+ },
118
+ update(id, text) {
119
+ if (id === "temporary" && previewCanTake.value) temporaryWords.value = text;
120
+ },
121
+ send(id) {
122
+ if (id !== "temporary" || !previewCanTake.value || !temporaryWords.value.trim()) return;
123
+ adapter.conversation.turns = [{ turnId: id, user: { messageId: id, role: "user", text: temporaryWords.value } }];
124
+ temporaryPresent.value = false;
125
+ }
126
+ }
127
+ } : null),
68
128
  turns: attribution ? [
69
129
  { turnId: "one", assistantLabel: "First assistant", assistantDetails: "First model",
70
130
  assistant: { role: "assistant", text: "First answer" } },
@@ -76,7 +136,7 @@ const adapter = reactive({
76
136
  assistant: { messageId: `answer-${index}`, outputId: `output-${index}`, role: "assistant",
77
137
  text: `Support answer ${index + 1}: Keep your recovery email available.\n\nRead the **account instructions** and follow the [recovery link](https://example.com/help). A long message wraps inside this narrow conversation without taking space from the composer.` }
78
138
  })) : [],
79
- visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
139
+ visible: true, reloadable: avatarFixture, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture"
80
140
  },
81
141
  composer: {
82
142
  draft,
@@ -111,12 +171,21 @@ const adapter = reactive({
111
171
  <button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
112
172
  <button v-if="capabilitiesEnabled" @click="finishUploads">Finish uploads</button>
113
173
  <button @click="goalEnabled = !goalEnabled">Toggle goals</button>
174
+ <template v-if="previewFixture">
175
+ <button @click="temporaryWords = 'Exact temporary words'; temporaryPresent = true; temporaryEditing = false">Add temporary message</button>
176
+ <button @click="newerWords = 'Keep newer live words'; newerPresent = true; newerEditing = false">Add newer temporary message</button>
177
+ <button @click="previewCanTake = !previewCanTake">Toggle preview eligibility</button>
178
+ <button @click="previewDelivery.send({ message: temporaryWords, request: { text: temporaryWords, steer: true } }, { messageId: 'temporary', deliver: async () => ({ ok: false, error: 'No active turn; explicit Edit may author a new message.' }) })">Reject preview</button>
179
+ <button @click="adapter.conversation.turns = [{ turnId: 'temporary', user: { messageId: 'temporary', role: 'user', text: temporaryWords } }]">Accept preview</button>
180
+ <button @click="adapter.conversation.turns = [{ turnId: 'temporary', user: { messageId: 'temporary', role: 'user', text: temporaryWords, receipt: false } }]">Unadmitted preview</button>
181
+ </template>
114
182
  <template v-if="avatarFixture">
115
183
  <button @click="showAvatar = !showAvatar">Toggle avatar slot</button>
116
184
  <button @click="short = !short">Resize height</button>
117
185
  <button @click="showQuestions = !showQuestions">Toggle questions</button>
118
186
  <button @click="attachments.status.value = 'An upload failed. Retry or remove it.'">Show attachment error</button>
119
- <output>Avatar requested: {{ avatarSize }}</output>
187
+ <select v-model="avatarSize" aria-label="Avatar preference"><option v-for="size in ['hidden', 'compact', 'standard', 'large']" :key="size" :value="size">{{ size }}</option></select>
188
+ <output class="avatar-preference">Avatar requested: {{ avatarSize }}</output>
120
189
  </template>
121
190
  <output>{{ phase }}; submitted {{ submissions }}</output>
122
191
  <output v-if="capabilitiesEnabled">Model: {{ appliedModel }}; sent files: {{ attachmentReceipts.map(item => item.fileName).join(', ') }}</output>
@@ -124,6 +193,12 @@ const adapter = reactive({
124
193
  <main class="fixture" :class="{ narrow, 'fixture--avatar': avatarFixture, 'fixture--short': short }">
125
194
  <AssistantConversationElement v-model:avatar-size="avatarSize" :adapter="adapter">
126
195
  <template v-if="showAvatar" #avatar><svg viewBox="0 0 100 100" role="img" aria-label="Support assistant avatar"><circle cx="50" cy="50" r="45" fill="#6750a4" /><path d="M30 62Q50 80 70 62M35 40h1m28 0h1" fill="none" stroke="white" stroke-width="6" stroke-linecap="round" /></svg></template>
196
+ <template v-if="avatarToolsFixture" #avatar-tools="scope">
197
+ <button class="fixture-avatar-tool" aria-label="Avatar tool" :data-size="scope.size" :data-height="scope.height">Tool</button>
198
+ </template>
199
+ <template v-if="avatarToolsFixture" #avatar-control="scope">
200
+ <button class="fixture-avatar-tool" aria-label="Retained avatar control" :data-size="scope.size">Mic</button>
201
+ </template>
127
202
  <template v-if="customActivity" #activity="{ activity }"><strong>Custom activity: {{ activity.label }}</strong></template>
128
203
  <template #composer-feedback><p v-if="feedback" class="feedback" role="status">Describe what you want to change.</p></template>
129
204
  </AssistantConversationElement>
@@ -134,9 +209,11 @@ const adapter = reactive({
134
209
  <style scoped>
135
210
  .controls { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
136
211
  .controls button { border: 1px solid; padding: 4px; }
212
+ .avatar-preference { flex: 0 0 14rem; }
137
213
  .fixture { height: 70vh; width: min(900px, 100%); margin: auto; }
138
214
  .fixture.narrow { width: min(320px, 100%); }
139
215
  .fixture--avatar { width: min(320px, 100%); }
216
+ .fixture-avatar-tool { flex: 0 0 48px; min-width: 48px; min-height: 48px; }
140
217
  .fixture--short { height: 420px; }
141
218
  .feedback { flex: 1 1 24rem; min-width: 0; margin: 0; }
142
219
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.204",
3
+ "version": "0.1.205",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -45,8 +45,8 @@
45
45
  "@ai-sdk/perplexity": "^4.0.43",
46
46
  "@ai-sdk/togetherai": "^3.0.49",
47
47
  "@ai-sdk/xai": "^4.0.58",
48
- "@jskit-ai/resource-core": "0.1.168",
49
- "@jskit-ai/resource-crud-core": "0.1.168",
48
+ "@jskit-ai/resource-core": "0.1.169",
49
+ "@jskit-ai/resource-crud-core": "0.1.169",
50
50
  "@mdi/js": "^7.4.47",
51
51
  "@openrouter/ai-sdk-provider": "^3.0.0",
52
52
  "ai": "^7.0.101",
@@ -55,8 +55,8 @@
55
55
  "ws": "^8.20.1"
56
56
  },
57
57
  "peerDependencies": {
58
- "@jskit-ai/http-runtime": "0.1.224",
59
- "@jskit-ai/kernel": "0.1.226",
58
+ "@jskit-ai/http-runtime": "0.1.225",
59
+ "@jskit-ai/kernel": "0.1.227",
60
60
  "vue": "^3.5.13",
61
61
  "vuetify": "^4.0.0"
62
62
  },
@@ -1,32 +1,74 @@
1
1
  <template>
2
2
  <section ref="container" class="assistant-conversation" :aria-label="label">
3
- <section v-if="$slots.avatar" class="assistant-conversation__avatar" aria-label="Conversation avatar">
4
- <div v-if="$slots.composer || !adapter.composer" ref="avatarControls" class="assistant-conversation__avatar-controls">
3
+ <section
4
+ v-if="$slots.avatar" class="assistant-conversation__avatar"
5
+ :class="{ 'assistant-conversation__avatar--hidden': avatarSize === 'hidden' }" aria-label="Conversation avatar"
6
+ >
7
+ <div v-show="avatarSize === 'hidden'" ref="avatarControls" class="assistant-conversation__avatar-controls">
8
+ <slot name="avatar-control" :size="avatarSize" :height="avatarHeight" />
5
9
  <v-btn
6
- ref="avatarControl" class="assistant-conversation__avatar-size" aria-label="Avatar size" :title="`Avatar size: ${avatarSize}`"
7
- aria-haspopup="menu" :aria-expanded="avatarMenuOpen" :icon="mdiAccountCircleOutline" size="small" variant="text"
8
- />
10
+ :icon="mdiAccountCircleOutline" size="small"
11
+ variant="text" :width="44" :min-width="44" :min-height="44"
12
+ class="assistant-conversation__avatar-size"
13
+ aria-label="Show avatar" title="Show avatar"
14
+ :aria-expanded="avatarSize !== 'hidden'" @click="toggleAvatar"
15
+ >
16
+ <span class="assistant-conversation__avatar-size-disc">
17
+ <v-icon size="20" :icon="mdiAccountCircleOutline" />
18
+ </span>
19
+ </v-btn>
9
20
  </div>
10
- <v-menu v-model="avatarMenuOpen" :activator="avatarControl?.$el">
11
- <v-list role="menu" aria-label="Avatar size">
12
- <v-list-item
13
- v-for="size in avatarSizes" :key="size.value" role="menuitem" :title="size.title"
14
- :active="size.value === avatarSize" @click="$emit('update:avatarSize', size.value)"
15
- />
16
- </v-list>
17
- </v-menu>
18
- <div v-if="avatarSize !== 'hidden'" class="assistant-conversation__avatar-visual" :style="{ height: `${avatarHeight}px` }">
19
- <slot name="avatar" :size="avatarSize" :height="avatarHeight" />
21
+ <div v-show="avatarSize !== 'hidden'" class="assistant-conversation__avatar-presentation">
22
+ <div v-if="avatarSize !== 'hidden'" class="assistant-conversation__avatar-visual" :style="{ height: `${avatarHeight}px`, width: `${avatarHeight}px` }">
23
+ <slot name="avatar" :size="avatarSize" :height="avatarHeight" />
24
+ </div>
25
+ <div class="assistant-conversation__avatar-tools">
26
+ <slot name="avatar-tools" :size="avatarSize" :height="avatarHeight" />
27
+ <v-btn
28
+ :icon="mdiMinus" size="small" variant="text" color="primary"
29
+ :width="40" :min-width="40" :min-height="44" class="assistant-conversation__avatar-size"
30
+ aria-label="Minimise avatar" title="Minimise avatar" :aria-expanded="true"
31
+ @click="toggleAvatar"
32
+ >
33
+ <span class="assistant-conversation__avatar-size-disc assistant-conversation__avatar-size-disc--minimise">
34
+ <v-icon size="20" :icon="mdiMinus" />
35
+ </span>
36
+ </v-btn>
37
+ </div>
20
38
  </div>
21
39
  </section>
22
40
  <AssistantTranscript
23
- v-bind="conversation" :working="working" class="assistant-conversation__transcript"
41
+ v-bind="conversation" :preview-message="adapter.conversation.previewMessage || null" :working="working" class="assistant-conversation__transcript"
24
42
  @load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
25
43
  @resend-turn="resend($event)" @cancel-turn="cancel($event)"
26
44
  @check-delivery="adapter.actions?.checkDelivery?.($event)"
27
45
  @edit-turn="edit($event)" @link-click="adapter.actions?.openLink?.($event)"
28
46
  >
29
47
  <template v-for="name in transcriptSlots" #[name]="scope"><slot :name="name" v-bind="scope" /></template>
48
+ <template #user-message-actions="scope">
49
+ <div v-if="scope.turn.preview && scope.turn.previewMessage?.actions" class="assistant-conversation__preview-actions">
50
+ <v-btn
51
+ v-if="scope.turn.previewMessage.actions.discard" :icon="mdiClose" size="small" variant="text"
52
+ aria-label="Discard unsent message" title="Discard unsent message"
53
+ :disabled="!scope.turn.previewMessage.actions.canDiscard"
54
+ @click="scope.turn.previewMessage.actions.discard(scope.turn.turnId)"
55
+ />
56
+ <v-btn
57
+ v-if="scope.turn.previewMessage.actions.edit" :icon="mdiPencil" size="small" variant="text"
58
+ aria-label="Edit unsent message" title="Edit unsent message"
59
+ :disabled="!scope.turn.previewMessage.actions.canEdit"
60
+ @click="scope.turn.previewMessage.actions.edit(scope.turn.turnId)"
61
+ />
62
+ <v-btn
63
+ v-if="scope.turn.previewMessage.actions.send" size="small" color="primary"
64
+ :disabled="!scope.turn.previewMessage.actions.canSend"
65
+ @click="scope.turn.previewMessage.actions.send(scope.turn.turnId)"
66
+ >
67
+ {{ scope.turn.previewMessage.actions.sending ? 'Sending…' : 'Send' }}
68
+ </v-btn>
69
+ </div>
70
+ <slot name="user-message-actions" v-bind="scope" />
71
+ </template>
30
72
  </AssistantTranscript>
31
73
  <div v-if="adapter.goal && adapter.goal.enabled !== false" class="assistant-conversation__goal">
32
74
  <AssistantGoalControl :state="adapter.goal" />
@@ -99,10 +141,6 @@
99
141
  @update:model-value="updateConfiguration({ ...configuration, [field.name]: $event })"
100
142
  />
101
143
  </slot>
102
- <v-btn
103
- v-if="$slots.avatar" ref="avatarControl" class="assistant-conversation__avatar-size" aria-label="Avatar size" :title="`Avatar size: ${avatarSize}`"
104
- aria-haspopup="menu" :aria-expanded="avatarMenuOpen" :icon="mdiAccountCircleOutline" size="small" variant="text"
105
- />
106
144
  <slot name="composer-tools" :adapter="adapter" />
107
145
  <template #feedback>
108
146
  <p v-if="attachmentsEnabled && adapter.attachments.status" role="alert" class="assistant-conversation__attachment-error">{{ adapter.attachments.status }}</p>
@@ -116,7 +154,7 @@
116
154
  </template>
117
155
  <script setup>
118
156
  import { computed, onBeforeUnmount, onMounted, onUpdated, ref, useId, useSlots, watch } from "vue";
119
- import { mdiAccountCircleOutline, mdiPaperclip } from "@mdi/js";
157
+ import { mdiAccountCircleOutline, mdiClose, mdiMinus, mdiPaperclip, mdiPencil } from "@mdi/js";
120
158
  import AssistantTranscript from "./AssistantTranscript.vue";
121
159
  import AssistantPromptInput from "./AssistantPromptInput.vue";
122
160
  import AssistantComposerActions from "./AssistantComposerActions.vue";
@@ -133,17 +171,22 @@ const props = defineProps({
133
171
  configurationFields: { type: Array, default: () => [] },
134
172
  configurationMode: { type: String, default: "hidden", validator: (value) => ["hidden", "readonly", "editable"].includes(value) }
135
173
  });
136
- defineEmits(["update:avatarSize"]);
174
+ const emit = defineEmits(["update:avatarSize"]);
137
175
  const slots = useSlots();
138
176
  const container = ref(null);
139
177
  const avatarControls = ref(null);
140
- const avatarControl = ref(null);
141
- const avatarMenuOpen = ref(false);
178
+ const visibleAvatarSize = ref("compact");
179
+ watch(() => props.avatarSize, (size) => {
180
+ if (size !== "hidden") visibleAvatarSize.value = size;
181
+ }, { immediate: true });
182
+ function toggleAvatar() {
183
+ emit("update:avatarSize", props.avatarSize === "hidden" ? visibleAvatarSize.value : "hidden");
184
+ }
142
185
  const avatarSizes = [
143
- { title: "Hidden", value: "hidden", height: 0 },
144
- { title: "Compact", value: "compact", height: 64 },
145
- { title: "Standard", value: "standard", height: 112 },
146
- { title: "Large", value: "large", height: 176 }
186
+ { value: "hidden", height: 0 },
187
+ { value: "compact", height: 96 },
188
+ { value: "standard", height: 112 },
189
+ { value: "large", height: 176 }
147
190
  ];
148
191
  const avatarSpace = ref(0);
149
192
  const avatarHeight = computed(() => Math.min(avatarSpace.value, avatarSizes.find(size => size.value === props.avatarSize).height));
@@ -198,8 +241,8 @@ const delivery = ref(null);
198
241
  const statusId = `assistant-status-${useId()}`;
199
242
  const conversation = computed(() => {
200
243
  const saved = props.adapter.conversation;
201
- if (!props.adapter.delivery && !saved.previewMessage && !saved.interimReply) return saved;
202
- const { previewMessage, interimReply, ...display } = saved;
244
+ if (!props.adapter.delivery && !saved.previewMessage && !saved.previewMessages?.length && !saved.interimReply) return saved;
245
+ const { previewMessage, previewMessages, interimReply, ...display } = saved;
203
246
  let turns = props.adapter.delivery ? props.adapter.delivery.turns(saved.turns || []) : saved.turns || [];
204
247
  if (interimReply?.text && interimReply.turnId) {
205
248
  const assistant = { ...interimReply, messageId: interimReply.id, role: "assistant" };
@@ -212,13 +255,30 @@ const conversation = computed(() => {
212
255
  (message.outputId || message.messageId) !== (interimReply.outputId || interimReply.id)).concat(assistant) } : {})
213
256
  });
214
257
  }
215
- const messageId = String(previewMessage?.messageId || previewMessage?.id || "").trim();
216
- const alreadyShown = messageId && (props.adapter.delivery?.find?.(messageId) || turns.some(turn => [
217
- turn.system, turn.user, turn.assistant, ...(turn.thinking || []), ...(turn.commentary || []), ...(turn.messages || [])
218
- ].some(message => String(message?.messageId || message?.id || "") === messageId)));
219
- if (messageId && previewMessage?.text && !alreadyShown) {
220
- const user = { messageId, role: "user", text: String(previewMessage.text) };
221
- turns = [...turns, { turnId: messageId, user, messages: [user], optimistic: { id: messageId, status: "pending" } }];
258
+ // This list projects existing capture/pending owners; it never queues delivery.
259
+ const previews = Array.isArray(previewMessages) ? previewMessages : previewMessage ? [previewMessage] : [];
260
+ for (const preview of previews) {
261
+ const messageId = String(preview?.messageId || preview?.id || "").trim();
262
+ const localDelivery = messageId && props.adapter.delivery?.find?.(messageId);
263
+ if ((!localDelivery || localDelivery.status === "failed") && preview?.actions?.send) {
264
+ // Keep one unadmitted bubble and its real status until explicit Edit/discard.
265
+ // A receipt:false user row is not acknowledgement of the pending words.
266
+ turns = turns.map(turn => {
267
+ const failed = turn.optimistic?.id === messageId && turn.optimistic.status === "failed";
268
+ const unadmitted = turn.user?.receipt === false && String(turn.user.messageId || turn.user.id || "") === messageId;
269
+ if (!failed && !unadmitted) return turn;
270
+ return { ...turn, preview: true, previewMessage: preview, user: { ...turn.user, text: String(preview.text || "") } };
271
+ });
272
+ }
273
+ const alreadyShown = messageId && (localDelivery || turns.some(turn => [
274
+ turn.system, turn.user, turn.assistant, ...(turn.thinking || []), ...(turn.commentary || []), ...(turn.messages || [])
275
+ ].some(message => String(message?.messageId || message?.id || "") === messageId &&
276
+ (message.receipt !== false || turn.preview))));
277
+ if (messageId && (preview?.text || preview?.actions?.editing) && !alreadyShown) {
278
+ const user = { messageId, role: "user", text: String(preview.text || "") };
279
+ turns = [...turns, { turnId: messageId, user, messages: [user], preview: true, previewMessage: preview,
280
+ optimistic: { id: messageId, status: "pending" } }];
281
+ }
222
282
  }
223
283
  // Transcription and interim replies are display only. The receipt watcher
224
284
  // receives canonical turns, never these presentation or delivery overlays.
@@ -327,10 +387,16 @@ function updateConfiguration(value) {
327
387
  defineExpose({ focus: () => input.value?.focus(), submit, stop });
328
388
  </script>
329
389
  <style scoped>
330
- .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
331
- .assistant-conversation__avatar { flex: 0 0 auto; min-width: 0; }
332
- .assistant-conversation__avatar-controls { display: flex; align-items: center; justify-content: flex-end; min-height: 48px; }
333
- .assistant-conversation__avatar-size { min-width: 48px; min-height: 48px; }
390
+ .assistant-conversation__preview-actions { display: flex; justify-content: flex-end; }
391
+ .assistant-conversation { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
392
+ .assistant-conversation__avatar { position: absolute; top: 0; right: 0; z-index: 3; max-width: 100%; min-width: 0; }
393
+ .assistant-conversation__avatar-controls { position: absolute; top: 0; right: 100%; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }
394
+ .assistant-conversation__avatar--hidden .assistant-conversation__avatar-controls { right: 0; }
395
+ .assistant-conversation__avatar-size { min-width: 44px; min-height: 44px; }
396
+ .assistant-conversation__avatar-size-disc { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; background: rgba(var(--v-theme-on-surface), .08); }
397
+ .assistant-conversation__avatar-size-disc--minimise { background: rgba(var(--v-theme-primary), .12); }
398
+ .assistant-conversation__avatar-presentation { display: flex; flex-direction: column; align-items: center; min-width: 0; }
399
+ .assistant-conversation__avatar-tools { display: flex; align-items: center; gap: 0; }
334
400
  .assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; }
335
401
  .assistant-conversation__avatar-visual :deep(> *) { max-width: 100%; max-height: 100%; }
336
402
  .assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
@@ -136,7 +136,13 @@
136
136
  <div
137
137
  class="assistant-transcript__user-content"
138
138
  >
139
- <p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
139
+ <v-textarea
140
+ v-if="entry.turn.preview && previewForTurn(entry.turn)?.actions?.editing"
141
+ class="assistant-transcript__preview-editor" :model-value="previewForTurn(entry.turn).text" label="Review your message"
142
+ :disabled="!previewForTurn(entry.turn).actions.canEdit" density="compact" :rows="1" :max-rows="2"
143
+ auto-grow hide-details @update:model-value="previewForTurn(entry.turn).actions.update(entry.turn.turnId, $event)"
144
+ />
145
+ <p v-else-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
140
146
  <LongTextPreviewBlocks
141
147
  v-else
142
148
  :blocks="userMessageExpanded(entry.turn) ? entry.message.blocks : (entry.message.previewBlocks || entry.message.blocks)"
@@ -160,12 +166,13 @@
160
166
  <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
161
167
  <span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
162
168
  </div>
169
+ <slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
163
170
  <div
164
171
  v-if="entry.turn.optimistic?.status === 'failed'"
165
172
  class="assistant-transcript__optimistic-failure"
166
173
  >
167
174
  <span role="status">Failed: {{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
168
- <div class="assistant-transcript__optimistic-actions">
175
+ <div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
169
176
  <v-btn
170
177
  class="assistant-transcript__delivery-action"
171
178
  color="primary"
@@ -311,8 +318,14 @@ import {
311
318
  normalizeThinkingMessageText
312
319
  } from "../../shared/conversation/thinkingText.js";
313
320
 
321
+ function previewForTurn(turn) {
322
+ const preview = turn.previewMessage || props.previewMessage;
323
+ return (preview?.messageId || preview?.id) === turn.turnId ? preview : null;
324
+ }
325
+
314
326
  const props = defineProps({
315
327
  systemLabel: { type: String, default: "Status" },
328
+ previewMessage: { type: Object, default: null },
316
329
  working: { type: Boolean, default: undefined },
317
330
  progressPreviewLimit: { type: Number, default: 2 },
318
331
  userMessageFormat: { type: String, default: "formatted", validator: (value) => ["plain", "formatted"].includes(value) },
@@ -156,7 +156,8 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
156
156
 
157
157
  const completion = (async () => {
158
158
  try {
159
- for await (const frame of readClaudeJsonFrames(stream)) {
159
+ // Teardown belongs to this client after queued events finish, not the iterator.
160
+ for await (const frame of readClaudeJsonFrames(stream.iterator({ destroyOnReturn: false }))) {
160
161
  if (frame.type === "control_response") {
161
162
  const response = frame.response;
162
163
  const pending = requests.get(response?.request_id);
@@ -630,17 +630,21 @@ test("an optional avatar clamps in a short conversation without consuming its pr
630
630
  try {
631
631
  for (const width of [390, 800, 1365]) {
632
632
  const page = await browser.newPage({ viewport: { width, height: 900 } });
633
- await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&capabilities=1`);
633
+ await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&avatarTools=1&capabilities=1`);
634
634
  const panel = page.locator(".fixture");
635
635
  const input = page.getByRole("textbox", { name: "Message AI assistant" });
636
636
  const body = page.locator(".assistant-transcript__body");
637
637
  const artwork = page.locator(".assistant-conversation__avatar-visual");
638
- const sizeControl = page.getByRole("button", { name: "Avatar size", exact: true });
638
+ const sizeControl = page.getByRole("button", { name: /^(Show|Minimise) avatar$/u });
639
+ await expect(page.getByRole("button", { name: "Avatar size", exact: true })).toHaveCount(0);
640
+ const avatarTool = page.getByRole("button", { name: "Avatar tool", exact: true, includeHidden: true });
641
+ await expect(sizeControl).toHaveCount(1);
642
+ assert.equal(await sizeControl.evaluate(element => Boolean(element.closest(".assistant-conversation__composer"))), false,
643
+ "Avatar visibility stays outside the original composer");
644
+ assert.equal(await avatarTool.evaluate(element => Boolean(element.closest(".assistant-conversation__avatar"))), true,
645
+ "Avatar tools share the artwork region outside the composer");
639
646
  const selectSize = async name => {
640
- await sizeControl.focus();
641
- await sizeControl.press("Enter");
642
- await page.getByRole("menuitem", { name, exact: true }).click();
643
- await expect(page.getByRole("menu", { name: "Avatar size", exact: true })).toBeHidden();
647
+ await page.getByRole("combobox", { name: "Avatar preference", exact: true }).selectOption(name.toLowerCase());
644
648
  await expect(page.getByText(`Avatar requested: ${name.toLowerCase()}`, { exact: true })).toBeVisible();
645
649
  };
646
650
  const assertComposerFits = async () => {
@@ -648,23 +652,51 @@ test("an optional avatar clamps in a short conversation without consuming its pr
648
652
  const send = await page.getByRole("button", { name: "Send", exact: true }).boundingBox();
649
653
  const feedback = await page.getByText("Describe what you want to change.", { exact: true }).boundingBox();
650
654
  const avatarButton = await sizeControl.boundingBox();
655
+ const toolBounds = await avatarTool.boundingBox();
651
656
  assert.ok(send.y + send.height <= bounds.y + bounds.height + 1, "Send stays inside the supplied container");
652
657
  assert.ok(feedback.y + feedback.height <= bounds.y + bounds.height + 1, "Feedback stays inside the supplied container");
653
658
  assert.ok((await input.boundingBox()).height >= 40);
654
- assert.ok(avatarButton.width >= 48 && avatarButton.height >= 48, "Avatar sizing retains a reachable touch target");
659
+ const avatarExpanded = await sizeControl.getAttribute("aria-expanded") === "true";
660
+ assert.equal(avatarButton.width, avatarExpanded ? 40 : 44, "Expanded minimise is compact; collapsed Show retains its wider target");
661
+ assert.ok(avatarButton.height >= 44, "Avatar visibility retains its original target height");
662
+ assert.ok(toolBounds.width >= 48 && toolBounds.height >= 48, "Avatar tools retain a reachable touch target");
655
663
  await expect.poll(async () => (await body.boundingBox()).height, { message: "The short container retains readable history" }).toBeGreaterThanOrEqual(100);
656
664
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
657
665
  };
658
666
  assert.equal(Math.round((await panel.boundingBox()).width), 320);
659
667
  assert.equal(Math.round((await panel.boundingBox()).height), 420);
660
- await expect(artwork).toHaveCSS("height", "64px");
668
+ await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(64);
669
+ assert.ok((await artwork.boundingBox()).height <= 96, "Compact artwork preserves the existing short-container clamp");
670
+ await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
671
+ await expect(artwork).toHaveCSS("height", "96px");
672
+ await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
673
+ await expect.poll(async () => (await panel.boundingBox()).height).toBe(420);
674
+ await expect(page.locator(".assistant-conversation__avatar")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
675
+ const chatBounds = await page.locator(".assistant-conversation").boundingBox();
676
+ const faceBounds = await artwork.boundingBox();
677
+ const hideBounds = await sizeControl.boundingBox();
678
+ assert.equal(faceBounds.y, chatBounds.y, "The face starts at the top of the chat without a controls row above it");
679
+ const avatarToolBounds = await avatarTool.boundingBox();
680
+ assert.ok(Math.abs(hideBounds.y + hideBounds.height / 2 - avatarToolBounds.y - avatarToolBounds.height / 2) < 1,
681
+ "The round minimise control shares the tools row below the face");
682
+ assert.equal(hideBounds.x, avatarToolBounds.x + avatarToolBounds.width, "Minimise directly follows the supplied tools without a gap");
683
+ await expect(sizeControl).toHaveText("");
684
+ await expect(sizeControl.locator('.assistant-conversation__avatar-size-disc')).toHaveCSS('border-radius', '50%');
661
685
  await expect(page.locator('[data-message-role="assistant"]')).toHaveCount(20);
662
686
  await page.evaluate(() => {
663
687
  window.avatarElements = [document.querySelector(".assistant-conversation"), document.querySelector("textarea"), document.querySelector(".assistant-transcript__body")];
688
+ window.avatarTool = document.querySelector(".assistant-conversation__avatar-presentation .fixture-avatar-tool");
664
689
  });
665
690
  await input.fill("Keep my typed support question.");
666
691
  await input.evaluate(element => element.setSelectionRange(5, 9));
692
+ const compactAvatarBounds = await artwork.boundingBox();
693
+ const composerBounds = [await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()];
694
+ const fixtureLayout = await panel.evaluate(element => ({
695
+ panel: element.getBoundingClientRect().toJSON(),
696
+ controls: document.querySelector(".controls").getBoundingClientRect().toJSON()
697
+ }));
667
698
  await body.evaluate(element => { element.scrollTop = 100; });
699
+ assert.deepEqual(await artwork.boundingBox(), compactAvatarBounds, "The avatar stays fixed within the chat while messages scroll behind it");
668
700
  const before = await body.evaluate(element => element.scrollTop);
669
701
  await selectSize("Large");
670
702
  await expect.poll(async () => (await artwork.boundingBox()).height).toBeLessThan(176);
@@ -673,7 +705,35 @@ test("an optional avatar clamps in a short conversation without consuming its pr
673
705
  await selectSize("Hidden");
674
706
  await expect(artwork).toHaveCount(0);
675
707
  await expect(sizeControl).toBeVisible();
708
+ const showBounds = await sizeControl.boundingBox();
709
+ assert.equal(showBounds.width, 44, "Collapsed Show retains its original wider target");
710
+ assert.ok(showBounds.height >= 44);
711
+ const collapsedControls = await page.locator(".assistant-conversation__avatar-controls").boundingBox();
712
+ assert.equal(collapsedControls.y, chatBounds.y, "The collapsed controls stay at the chat top");
713
+ assert.equal(showBounds.y + showBounds.height / 2, collapsedControls.y + collapsedControls.height / 2,
714
+ "Show aligns with the existing taller host control in the top row");
715
+ assert.equal(showBounds.x + showBounds.width, chatBounds.x + chatBounds.width, "Show stays at the chat right edge");
716
+ await expect(avatarTool).toHaveCount(1);
717
+ await expect(avatarTool).toBeHidden();
718
+ await expect(page.getByRole("button", { name: "Retained avatar control", exact: true })).toBeVisible();
719
+ assert.equal(await page.evaluate(() => window.avatarTool.isConnected), true, "Hiding retains the local tools target");
720
+ if (process.env.JSKIT_ASSISTANT_CORE_BROWSER_ARTIFACTS) {
721
+ await page.screenshot({ path: path.join(process.env.JSKIT_ASSISTANT_CORE_BROWSER_ARTIFACTS, `hidden-${width}.png`) });
722
+ }
723
+ assert.deepEqual([await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()], composerBounds,
724
+ `Collapsing changes no original input or Send rectangle: ${JSON.stringify({ before: fixtureLayout, after: await panel.evaluate(element => ({
725
+ panel: element.getBoundingClientRect().toJSON(), controls: document.querySelector(".controls").getBoundingClientRect().toJSON()
726
+ })) })}`);
727
+ await page.getByRole("button", { name: "Show avatar", exact: true }).click();
728
+ await expect(page.getByText("Avatar requested: large", { exact: true })).toBeVisible();
729
+ await expect(avatarTool).toBeVisible();
730
+ assert.deepEqual([await input.boundingBox(), await page.getByRole("button", { name: "Send", exact: true }).boundingBox()], composerBounds,
731
+ "Showing the last visible size changes no original input or Send rectangle");
732
+ await page.getByRole("button", { name: "Minimise avatar", exact: true }).click();
676
733
  await selectSize("Standard");
734
+ await expect(avatarTool).toBeVisible();
735
+ await expect(avatarTool).toHaveAttribute("data-size", "standard");
736
+ assert.equal(await avatarTool.evaluate(element => element === window.avatarTool), true, "Showing reuses the original tools node");
677
737
  await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(0);
678
738
  await selectSize("Large");
679
739
  await input.focus();
@@ -721,3 +781,117 @@ test("an optional avatar clamps in a short conversation without consuming its pr
721
781
  await stopProcess(vite);
722
782
  }
723
783
  });
784
+
785
+
786
+ test("unsent preview edits stay in their bubble and disappear for the canonical message", {
787
+ skip: !RUN_BROWSER_TEST, timeout: 60_000
788
+ }, async () => {
789
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
790
+ const browser = await chromium.launch(createChromiumLaunchOptions());
791
+ try {
792
+ const page = await browser.newPage({ viewport: { width: 320, height: 844 } });
793
+ await page.goto(`${vite.baseURL}/?conversation=1&preview=1`);
794
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
795
+ const discard = page.getByRole("button", { name: "Discard unsent message", exact: true });
796
+ const edit = page.getByRole("button", { name: "Edit unsent message", exact: true });
797
+ await page.getByRole("button", { name: "External active", exact: true }).click();
798
+ await input.fill("Keep this typed draft");
799
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
800
+ const bubble = page.locator(".assistant-transcript__message--user");
801
+ await expect(bubble).toContainText("Exact temporary words");
802
+ await expect(bubble.getByRole("button", { name: "Discard unsent message" })).toBeVisible();
803
+ await expect(edit).toBeEnabled();
804
+ await expect(discard).toBeEnabled();
805
+ await page.getByRole("button", { name: "Toggle preview eligibility" }).click();
806
+ await expect(discard).toBeDisabled();
807
+ await page.getByRole("button", { name: "Toggle preview eligibility" }).click();
808
+ await discard.click();
809
+ await expect(bubble).toHaveCount(0);
810
+ await expect(input).toHaveValue("Keep this typed draft");
811
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
812
+ await page.getByRole("button", { name: "Reject preview", exact: true }).click();
813
+ await expect(bubble).toHaveCount(1);
814
+ await expect(bubble).toContainText("No active turn; explicit Edit may author a new message.");
815
+ await expect(bubble.locator(".assistant-transcript__optimistic-actions")).toHaveCount(0);
816
+ await expect(bubble.locator(".assistant-conversation__preview-actions")).toHaveCount(1);
817
+ await expect(edit).toBeEnabled();
818
+ await edit.click();
819
+ await expect(bubble).not.toContainText("No active turn; explicit Edit may author a new message.");
820
+ const editor = bubble.getByRole("textbox", { name: "Review your message" });
821
+ const send = bubble.getByRole("button", { name: "Send", exact: true });
822
+ await expect(editor).toHaveValue("Exact temporary words");
823
+ await expect(input).toHaveValue("Keep this typed draft");
824
+ await expect(page.getByRole("textbox")).toHaveCount(2);
825
+ await expect(bubble.locator(".assistant-conversation__preview-actions")).toHaveCount(1);
826
+ await editor.fill("");
827
+ await expect(editor).toHaveValue("");
828
+ await expect(bubble).toHaveCount(1);
829
+ await expect(send).toBeDisabled();
830
+ await editor.fill("Corrected speech\nwith a second line");
831
+ await expect(send).toBeEnabled();
832
+ await expect(input).toHaveValue("Keep this typed draft");
833
+ await send.click();
834
+ await expect(bubble).toContainText("Corrected speech");
835
+ await expect(editor).toHaveCount(0);
836
+ await expect(discard).toHaveCount(0);
837
+ await expect(input).toHaveValue("Keep this typed draft");
838
+ await page.reload();
839
+ await input.fill("Keep this typed draft");
840
+ await page.getByRole("button", { name: "External active", exact: true }).click();
841
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
842
+ await page.getByRole("button", { name: "Unadmitted preview" }).click();
843
+ await expect(bubble).toHaveCount(1);
844
+ await edit.click();
845
+ await expect(bubble.getByRole("textbox", { name: "Review your message" })).toHaveValue("Exact temporary words");
846
+ await expect(input).toHaveValue("Keep this typed draft");
847
+ await page.getByRole("button", { name: "Accept preview" }).click();
848
+ await expect(bubble).toHaveCount(1);
849
+ await expect(bubble).toContainText("Exact temporary words");
850
+ await expect(discard).toHaveCount(0);
851
+ await expect(edit).toHaveCount(0);
852
+ await expect(input).toHaveValue("Keep this typed draft");
853
+ await expect(page.locator("output").last()).toHaveText("active; submitted 0");
854
+ for (const recovery of ["edit", "discard"]) {
855
+ await page.reload();
856
+ await input.fill("Keep this typed draft");
857
+ await page.getByRole("button", { name: "External active", exact: true }).click();
858
+ await page.getByRole("button", { name: "Add temporary message", exact: true }).click();
859
+ await page.getByRole("button", { name: "Reject preview", exact: true }).click();
860
+ await page.getByRole("button", { name: "Add newer temporary message", exact: true }).click();
861
+ await expect(bubble).toHaveCount(2);
862
+ const retained = bubble.first();
863
+ const newer = bubble.last();
864
+ await expect(retained).toContainText("No active turn; explicit Edit may author a new message.");
865
+ await expect(retained.locator(".assistant-transcript__optimistic-actions")).toHaveCount(0);
866
+ await expect(newer).toContainText("Keep newer live words");
867
+ await expect(newer.getByRole("button", { name: "Edit unsent message", exact: true })).toBeDisabled();
868
+ await expect(newer.getByRole("button", { name: "Discard unsent message", exact: true })).toBeEnabled();
869
+ if (recovery === "edit") {
870
+ await retained.getByRole("button", { name: "Edit unsent message", exact: true }).click();
871
+ await expect(newer).toContainText("Keep newer live words");
872
+ const retainedEditor = retained.getByRole("textbox", { name: "Review your message" });
873
+ await expect(retainedEditor).toHaveValue("Exact temporary words");
874
+ await retainedEditor.fill("Edited retained words");
875
+ await expect(input).toHaveValue("Keep this typed draft");
876
+ await retained.getByRole("button", { name: "Send", exact: true }).click();
877
+ await expect(retained).toContainText("Edited retained words");
878
+ await expect(retained.getByRole("button", { name: "Edit unsent message", exact: true })).toHaveCount(0);
879
+ await expect(bubble).toHaveCount(2);
880
+ await expect(newer).toContainText("Keep newer live words");
881
+ } else {
882
+ await retained.getByRole("button", { name: "Discard unsent message", exact: true }).click();
883
+ await expect(bubble).toHaveCount(1);
884
+ await expect(bubble).toContainText("Keep newer live words");
885
+ }
886
+ const current = bubble.last();
887
+ await expect(current.getByRole("button", { name: "Edit unsent message", exact: true })).toBeEnabled();
888
+ await expect(input).toHaveValue("Keep this typed draft");
889
+ await current.getByRole("button", { name: "Discard unsent message", exact: true }).click();
890
+ await expect(bubble).toHaveCount(recovery === "edit" ? 1 : 0);
891
+ await expect(input).toHaveValue("Keep this typed draft");
892
+ }
893
+ } finally {
894
+ await browser.close();
895
+ await stopProcess(vite);
896
+ }
897
+ });
@@ -79,6 +79,47 @@ test("Claude control replies bypass slow event persistence while events remain o
79
79
  }
80
80
  });
81
81
 
82
+ test("normal Claude EOF drains held events before closing the half-open stream", async () => {
83
+ const entered = Promise.withResolvers();
84
+ const release = Promise.withResolvers();
85
+ const ended = Promise.withResolvers();
86
+ const observed = [];
87
+ const failures = [];
88
+ const stream = fakeStream(() => {});
89
+ stream.once("end", ended.resolve);
90
+ const client = createClaudeJsonClient({
91
+ stream,
92
+ async onEvent(frame) {
93
+ if (frame.number === 1) {
94
+ entered.resolve();
95
+ await release.promise;
96
+ }
97
+ observed.push(frame.number);
98
+ },
99
+ onFailure(error) { failures.push(error); }
100
+ });
101
+ try {
102
+ stream.push('{"type":"event","number":1}\n{"type":"event","number":2}\n');
103
+ stream.push(null);
104
+ await entered.promise;
105
+ await ended.promise;
106
+ await new Promise(resolve => setImmediate(resolve));
107
+ assert.equal(stream.writableEnded, false, "EOF does not close the original writable side");
108
+ assert.equal(stream.destroyed, false, "The stream survives while event persistence is held");
109
+ assert.deepEqual(observed, []);
110
+ assert.deepEqual(failures, []);
111
+ release.resolve();
112
+ await client.completion;
113
+ assert.deepEqual(observed, [1, 2]);
114
+ assert.equal(failures.length, 1);
115
+ assert.equal(failures[0].code, "assistant_claude_observation_lost");
116
+ assert.equal(stream.destroyed, true, "The original client still owns final teardown");
117
+ } finally {
118
+ release.resolve();
119
+ client.close();
120
+ }
121
+ });
122
+
82
123
  test("Claude interrupt uses the common 30-second control deadline", async (t) => {
83
124
  t.mock.timers.enable({ apis: ["setTimeout"] });
84
125
  const client = createClaudeJsonClient({ stream: fakeStream(() => {}) });