@jskit-ai/assistant-core 0.1.167 → 0.1.168

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,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.167",
11
+ "@jskit-ai/assistant-core": "0.1.168",
12
12
  "@jskit-ai/http-runtime": "0.1.189",
13
13
  "@jskit-ai/kernel": "0.1.191",
14
14
  "@mdi/js": "^7.4.47",
@@ -0,0 +1,45 @@
1
+ <script setup>
2
+ import { computed, reactive, ref } from "vue";
3
+ import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
+ const phase = ref("idle");
5
+ const draft = ref("");
6
+ const narrow = ref(false);
7
+ const submissions = ref(0);
8
+ const adapter = reactive({
9
+ conversation: { turns: [], visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture" },
10
+ composer: {
11
+ draft,
12
+ rows: 2,
13
+ submitOnEnter: true,
14
+ canSend: computed(() => Boolean(draft.value.trim()) && ["idle", "active", "stopped"].includes(phase.value)),
15
+ canStop: computed(() => ["active", "stopping"].includes(phase.value)),
16
+ stopPending: computed(() => phase.value === "stopping"),
17
+ submitLabel: computed(() => phase.value === "reconnecting" ? "Reconnecting…" : "Send")
18
+ },
19
+ actions: {
20
+ setDraft(value) { draft.value = value; },
21
+ submit() { submissions.value += 1; draft.value = ""; phase.value = "active"; },
22
+ stop() { phase.value = "stopping"; }
23
+ }
24
+ });
25
+ </script>
26
+ <template>
27
+ <v-app>
28
+ <v-main>
29
+ <div class="controls">
30
+ <button v-for="state in ['idle', 'active', 'reconnecting', 'stopping', 'stopped']" :key="state" @click="phase = state">External {{ state }}</button>
31
+ <button @click="narrow = !narrow">Resize pane</button>
32
+ <output>{{ phase }}; submitted {{ submissions }}</output>
33
+ </div>
34
+ <main class="fixture" :class="{ narrow }">
35
+ <AssistantConversationElement :adapter="adapter" />
36
+ </main>
37
+ </v-main>
38
+ </v-app>
39
+ </template>
40
+ <style scoped>
41
+ .controls { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; }
42
+ .controls button { border: 1px solid; padding: 4px; }
43
+ .fixture { height: 70vh; width: min(900px, 100%); margin: auto; }
44
+ .fixture.narrow { width: min(320px, 100%); }
45
+ </style>
@@ -5,6 +5,7 @@ import * as components from "vuetify/components";
5
5
  import * as directives from "vuetify/directives";
6
6
  import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
7
7
  import App from "./App.vue";
8
+ import ConversationFixture from "./ConversationFixture.vue";
8
9
 
9
10
  const vuetify = createVuetify({
10
11
  components,
@@ -19,6 +20,6 @@ const vuetify = createVuetify({
19
20
  }
20
21
  });
21
22
 
22
- createApp(App)
23
+ createApp(new URLSearchParams(location.search).has("conversation") ? ConversationFixture : App)
23
24
  .use(vuetify)
24
25
  .mount("#app");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.167",
3
+ "version": "0.1.168",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -156,6 +156,7 @@ const props = defineProps({
156
156
  const textareaRef = ref(null);
157
157
  const textareaId = `assistant-prompt-${useId()}`;
158
158
  let resizeFrame = 0;
159
+ let resizeObserver = null;
159
160
  let preserveHeightForNextModelValueChange = false;
160
161
  const combinedErrorMessages = computed(() => {
161
162
  return Array.isArray(props.errorMessages)
@@ -201,9 +202,7 @@ function queueResizeTextarea() {
201
202
  if (!props.autoGrow || typeof window === "undefined") {
202
203
  return;
203
204
  }
204
- if (resizeFrame) {
205
- window.cancelAnimationFrame(resizeFrame);
206
- }
205
+ if (resizeFrame) return;
207
206
  resizeFrame = window.requestAnimationFrame(() => {
208
207
  resizeFrame = 0;
209
208
  resizeTextarea();
@@ -241,6 +240,7 @@ function handleTextareaBlur(event = {}) {
241
240
  }
242
241
 
243
242
  function handleTextareaKeydown(event = {}) {
243
+ if (props.disabled || event.isComposing) return;
244
244
  if (event.key === "Escape") {
245
245
  emit("escape", event);
246
246
  return;
@@ -292,9 +292,22 @@ function focusTextarea(options = { preventScroll: true }) {
292
292
  textareaRef.value?.focus?.(options);
293
293
  }
294
294
 
295
- onMounted(queueResizeTextarea);
295
+ onMounted(() => {
296
+ queueResizeTextarea();
297
+ let lastWidth = 0;
298
+ resizeObserver = new ResizeObserver(([entry]) => {
299
+ // Height changes are our own output. Only width changes need another layout.
300
+ const width = entry.contentRect.width;
301
+ if (width !== lastWidth) {
302
+ lastWidth = width;
303
+ queueResizeTextarea();
304
+ }
305
+ });
306
+ resizeObserver.observe(textareaRef.value);
307
+ });
296
308
 
297
309
  onBeforeUnmount(() => {
310
+ resizeObserver?.disconnect();
298
311
  if (resizeFrame && typeof window !== "undefined") {
299
312
  window.cancelAnimationFrame(resizeFrame);
300
313
  resizeFrame = 0;
@@ -306,7 +319,8 @@ watch(() => [
306
319
  props.modelValue,
307
320
  props.placeholder,
308
321
  props.placeholderAffectsHeight,
309
- props.rows
322
+ props.rows,
323
+ props.density
310
324
  ], (values, previousValues = []) => {
311
325
  const modelValueChanged = values[1] !== previousValues[1];
312
326
  if (preserveHeightForNextModelValueChange && modelValueChanged) {
@@ -314,7 +328,7 @@ watch(() => [
314
328
  return;
315
329
  }
316
330
  queueResizeTextarea();
317
- });
331
+ }, { flush: "post" });
318
332
 
319
333
  defineExpose({ inputElement: textareaRef, focus: focusTextarea, preserveHeightForNextModelValue, queueResizeTextarea });
320
334
  </script>
@@ -51,6 +51,7 @@ class CodexAppServerJsonRpcClient {
51
51
  constructor({
52
52
  endpoint = "",
53
53
  maxMessageBytes = Number.POSITIVE_INFINITY,
54
+ onDisconnect = null,
54
55
  requestTimeoutMs = CODEX_APP_SERVER_REQUEST_TIMEOUT_MS,
55
56
  WebSocketImpl = WebSocket
56
57
  } = {}) {
@@ -60,6 +61,7 @@ class CodexAppServerJsonRpcClient {
60
61
  : Number.POSITIVE_INFINITY;
61
62
  this.requestTimeoutMs = normalizePositiveInteger(requestTimeoutMs, CODEX_APP_SERVER_REQUEST_TIMEOUT_MS);
62
63
  this.WebSocketImpl = WebSocketImpl;
64
+ this.onDisconnect = onDisconnect;
63
65
  this.nextRequestId = 1;
64
66
  this.notificationSubscribers = new Set();
65
67
  this.pendingRequests = new Map();
@@ -105,11 +107,16 @@ class CodexAppServerJsonRpcClient {
105
107
  callback(value);
106
108
  };
107
109
  cleanup.push(addSocketListener(socket, "open", () => {
108
- if (this.socket === socket) {
109
- this.connected = true;
110
+ if (this.socket !== socket) {
111
+ settle(reject, new Error("Codex connection was closed before it opened."));
112
+ return;
110
113
  }
114
+ this.connected = true;
111
115
  settle(resolve);
112
116
  }));
117
+ cleanup.push(addSocketListener(socket, "close", () => {
118
+ settle(reject, new Error("Codex connection closed before it opened."));
119
+ }));
113
120
  cleanup.push(addSocketListener(socket, "error", (error) => {
114
121
  if (this.socket === socket) {
115
122
  this.connected = false;
@@ -118,13 +125,14 @@ class CodexAppServerJsonRpcClient {
118
125
  settle(reject, error?.error || error);
119
126
  }));
120
127
  });
121
- addSocketListener(socket, "message", (event) => this.handleMessage(event));
128
+ addSocketListener(socket, "message", (event) => {
129
+ if (this.socket === socket) this.handleMessage(event);
130
+ });
122
131
  addSocketListener(socket, "close", () => {
123
- if (this.socket === socket) {
124
- this.connected = false;
125
- this.socket = null;
126
- }
127
- this.rejectPendingRequests(new Error("Codex app-server connection closed."));
132
+ if (this.socket === socket) this.disconnect(new Error("Codex app-server connection closed."));
133
+ });
134
+ addSocketListener(socket, "error", (event) => {
135
+ if (this.socket === socket) this.disconnect(event?.error || new Error("Codex app-server connection failed."));
128
136
  });
129
137
  return this;
130
138
  }
@@ -240,12 +248,15 @@ class CodexAppServerJsonRpcClient {
240
248
  if (text === null) {
241
249
  const error = new Error("Codex app-server message exceeded the configured transport limit.");
242
250
  error.code = "assistant_codex_app_server_message_too_large";
243
- this.rejectPendingRequests(error);
244
- this.close();
251
+ this.disconnect(error);
245
252
  return;
246
253
  }
247
254
  message = JSON.parse(text);
248
- } catch {
255
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
256
+ throw new TypeError("Codex JSON-RPC messages must be objects.");
257
+ }
258
+ } catch (error) {
259
+ this.disconnect(new Error("Codex app-server sent an unreadable message.", { cause: error }));
249
260
  return;
250
261
  }
251
262
  if (Object.hasOwn(message, "id") && message.method) {
@@ -316,6 +327,11 @@ class CodexAppServerJsonRpcClient {
316
327
  this.pendingRequests.clear();
317
328
  }
318
329
 
330
+ disconnect(error) {
331
+ this.close();
332
+ this.onDisconnect?.(error);
333
+ }
334
+
319
335
  close() {
320
336
  this.rejectPendingRequests(new Error("Codex app-server connection closed."));
321
337
  const socket = this.socket;
@@ -137,3 +137,50 @@ test("AssistantClientElement keeps long conversations scrollable across responsi
137
137
  await stopProcess(vite);
138
138
  }
139
139
  });
140
+
141
+ test("conversation composer preserves typing and reacts to external state and pane resizing", {
142
+ skip: !RUN_BROWSER_TEST,
143
+ timeout: 60_000
144
+ }, async () => {
145
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
146
+ const browser = await chromium.launch(createChromiumLaunchOptions());
147
+ try {
148
+ const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
149
+ await page.goto(`${vite.baseURL}/?conversation=1`);
150
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
151
+ const draft = "Keep this draft while the assistant changes state. This is a longer message to verify wrapping when the chat pane becomes narrower.";
152
+ await input.fill(draft);
153
+ await input.evaluate((element) => { element.focus(); element.setSelectionRange(10, 10); });
154
+ for (const phase of ["active", "reconnecting", "stopping", "stopped", "idle"]) {
155
+ // External events must not take focus like clicking a fixture button would.
156
+ await page.getByRole("button", { name: `External ${phase}`, exact: true }).evaluate((element) => element.click());
157
+ await expect(input).toHaveValue(draft);
158
+ await expect(input).toBeFocused();
159
+ await expect(input).toBeEnabled();
160
+ assert.equal(await input.evaluate((element) => element.selectionStart), 10);
161
+ const send = page.locator(".assistant-composer-actions__send");
162
+ if (["reconnecting", "stopping"].includes(phase)) await expect(send).toBeDisabled();
163
+ else await expect(send).toBeEnabled();
164
+ if (phase === "active") await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeEnabled();
165
+ if (phase === "stopping") await expect(page.getByRole("button", { name: "Stopping…", exact: true })).toBeDisabled();
166
+ if (phase === "stopped") await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
167
+ }
168
+ const before = await input.evaluate((element) => element.clientHeight);
169
+ await page.getByRole("button", { name: "Resize pane" }).evaluate((element) => element.click());
170
+ await expect.poll(() => input.evaluate((element) => element.clientHeight)).toBeGreaterThan(before);
171
+ await input.dispatchEvent("keydown", { key: "Enter", isComposing: true });
172
+ await expect(input).toHaveValue(draft);
173
+ await input.press("Enter");
174
+ await expect(page.locator("output")).toHaveText("active; submitted 1");
175
+ await expect(input).toHaveValue("");
176
+ for (const width of [390, 800, 1224]) {
177
+ await page.setViewportSize({ width, height: 900 });
178
+ await input.fill(draft);
179
+ await expect(input).toBeVisible();
180
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
181
+ }
182
+ } finally {
183
+ await browser.close();
184
+ await stopProcess(vite);
185
+ }
186
+ });
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import test from "node:test";
4
+ import { WebSocketServer } from "ws";
5
+ import { CodexAppServerJsonRpcClient } from "../src/server/conversation/codexClient.js";
6
+
7
+ test("unexpected WebSocket closure reports observation loss; explicit close does not", async () => {
8
+ const server = new WebSocketServer({ port: 0 });
9
+ const connected = new Promise((resolve) => server.once("connection", resolve));
10
+ const lost = Promise.withResolvers();
11
+ const errors = [];
12
+ const client = new CodexAppServerJsonRpcClient({
13
+ endpoint: `ws://127.0.0.1:${server.address().port}`,
14
+ onDisconnect(error) { errors.push(error); lost.resolve(); }
15
+ });
16
+ try {
17
+ await client.connect();
18
+ (await connected).terminate();
19
+ await lost.promise;
20
+ assert.equal(client.isOpen(), false);
21
+ assert.equal(errors.length, 1);
22
+ await client.connect();
23
+ client.close();
24
+ assert.equal(errors.length, 1);
25
+ } finally {
26
+ client.close();
27
+ for (const socket of server.clients) socket.terminate();
28
+ await new Promise((resolve) => server.close(resolve));
29
+ }
30
+ });
31
+
32
+ class Socket extends EventEmitter {
33
+ constructor() { super(); queueMicrotask(() => this.emit("open")); }
34
+ close() { this.emit("close"); }
35
+ send() {}
36
+ }
37
+
38
+ test("obsolete socket messages, errors and closure cannot affect its replacement", async () => {
39
+ const errors = [];
40
+ const notifications = [];
41
+ const client = new CodexAppServerJsonRpcClient({
42
+ endpoint: "ws://fixture", WebSocketImpl: Socket, onDisconnect: (error) => errors.push(error)
43
+ });
44
+ try {
45
+ await client.connect();
46
+ const old = client.socket;
47
+ client.close();
48
+ await client.connect();
49
+ client.subscribe((event) => notifications.push(event));
50
+ const pending = client.request("read");
51
+ old.emit("message", JSON.stringify({ id: 1, result: "obsolete" }));
52
+ old.emit("message", JSON.stringify({ method: "obsolete" }));
53
+ old.emit("error", new Error("obsolete"));
54
+ old.emit("close");
55
+ client.socket.emit("message", JSON.stringify({ id: 1, result: "current" }));
56
+ assert.equal(await pending, "current");
57
+ assert.deepEqual(notifications, []);
58
+ assert.deepEqual(errors, []);
59
+ } finally { client.close(); }
60
+ });
61
+
62
+ for (const message of ["invalid JSON", "null"]) test(`unreadable output (${message}) disconnects instead of silently losing observation`, async () => {
63
+ const errors = [];
64
+ const client = new CodexAppServerJsonRpcClient({
65
+ endpoint: "ws://fixture", WebSocketImpl: Socket, onDisconnect: (error) => errors.push(error)
66
+ });
67
+ await client.connect();
68
+ client.socket.emit("message", message);
69
+ assert.equal(errors.length, 1);
70
+ assert.equal(client.isOpen(), false);
71
+ });
72
+
73
+ test("closing during connection rejects readiness instead of leaving it pending", async () => {
74
+ class PendingSocket extends EventEmitter {
75
+ close() { this.emit("close"); }
76
+ }
77
+ const client = new CodexAppServerJsonRpcClient({ endpoint: "ws://fixture", WebSocketImpl: PendingSocket });
78
+ const connected = client.connect();
79
+ client.close();
80
+ await assert.rejects(connected, /closed before it opened/);
81
+ assert.equal(client.isOpen(), false);
82
+ });