@jskit-ai/assistant-core 0.1.167 → 0.1.169
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.
- package/examples/conversation/package.json +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +45 -0
- package/fixtures/responsive-assistant/main.js +2 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantPromptInput.vue +20 -6
- package/src/server/conversation/codexClient.js +28 -11
- package/test/assistantScroll.browser.test.js +47 -0
- package/test/codexClient.test.js +91 -0
|
@@ -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
|
@@ -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(
|
|
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
|
|
109
|
-
|
|
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) =>
|
|
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
|
-
|
|
125
|
-
|
|
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.
|
|
244
|
-
this.close();
|
|
251
|
+
this.disconnect(error);
|
|
245
252
|
return;
|
|
246
253
|
}
|
|
247
254
|
message = JSON.parse(text);
|
|
248
|
-
|
|
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,12 @@ class CodexAppServerJsonRpcClient {
|
|
|
316
327
|
this.pendingRequests.clear();
|
|
317
328
|
}
|
|
318
329
|
|
|
330
|
+
disconnect(error) {
|
|
331
|
+
this.rejectPendingRequests(error);
|
|
332
|
+
this.close();
|
|
333
|
+
this.onDisconnect?.(error);
|
|
334
|
+
}
|
|
335
|
+
|
|
319
336
|
close() {
|
|
320
337
|
this.rejectPendingRequests(new Error("Codex app-server connection closed."));
|
|
321
338
|
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,91 @@
|
|
|
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
|
+
});
|
|
83
|
+
|
|
84
|
+
test("transport failure preserves its error code for pending requests", async () => {
|
|
85
|
+
const client = new CodexAppServerJsonRpcClient({ endpoint: "ws://fixture", WebSocketImpl: Socket, maxMessageBytes: 100 });
|
|
86
|
+
await client.connect();
|
|
87
|
+
const result = client.request("read");
|
|
88
|
+
client.socket.emit("message", "x".repeat(101));
|
|
89
|
+
await assert.rejects(result, { code: "assistant_codex_app_server_message_too_large" });
|
|
90
|
+
assert.equal(client.isOpen(), false);
|
|
91
|
+
});
|