@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
@@ -1,37 +1,37 @@
1
- Here's how I'd run a final sweep in three steps: I do a systematic pass on my own first, then a short session with you for the parts that need you, then I tidy up.
1
+ Here's how I'd run a final check of the garden planner in three steps: I do a careful pass on my own first, then a short session with you for the parts that need you, then I tidy up.
2
2
 
3
- **Step 1: I sweep on my own (no need for you)**
4
- - **Click everything.** I go through every visible control and compare it against the specs: every button, the + menu, every right-click menu (bots, messages, sections, Library items), every keyboard shortcut on the shortcuts sheet, and every settings row. Anything with no spec is a gap.
5
- - **Gather the "unverified" lists.** Every spec has one, and I'll merge them all into a single gap list. Each item gets labelled as one of three kinds:
6
- - **I can observe it now:** for example, editing and deleting pages, or a bot made from a template.
7
- - **Needs you:** real accounts, audio, a second account, payment, your phone, links that open in a browser.
8
- - **Can't be observed at all.**
9
- - **Output:** one gap list, saved to your Session Dropzone, with every item marked done, observable, needs you, or impossible.
3
+ **Step 1: I check on my own (no need for you)**
4
+ - **Try every screen.** I open every screen and compare it against the design notes: every button, the + menu, every right-click menu (beds, plants, seasons, saved plans), every keyboard shortcut on the help sheet, and every settings row. Anything with no note is a gap.
5
+ - **Gather the "open question" lists.** Every design note has one, and I'll merge them all into a single gap list. Each item gets labelled as one of three kinds:
6
+ - **I can check it now:** for example, renaming and deleting beds, or a plan made from a template.
7
+ - **Needs you:** real accounts, printing, a second account, payment, your tablet, links that open in a browser.
8
+ - **Can't be checked at all.**
9
+ - **Output:** one gap list, saved to the shared folder, with every item marked done, checkable, needs you, or impossible.
10
10
 
11
11
  **Step 2: a session with you, for the "needs you" items and things only you know about**
12
- - **You drive the reference over VNC**, using it as you normally would: your knowledge of features, the test Google account, the phone app, links opening in your browser.
13
- - **I record the screen** at a low frame rate while you work, and capture stills when you tell me something interesting is happening. Just tell me what you did in plain words ("now I'm connecting Gmail", "this is the share link in my browser").
14
- - **Things outside the reference machine:** put screenshots in the Session Dropzone, for example from the mobile app, the help pages, or emails it sends. I'll file them.
12
+ - **You use the planner on your own computer**, as you normally would: your knowledge of features, the test account, the tablet app, links opening in your browser.
13
+ - **I take notes as you go** and capture screenshots when you tell me something interesting is happening. Just tell me what you did in plain words ("now I'm printing a plan", "this is the share link in my browser").
14
+ - **Things outside the planner itself:** put screenshots in the shared folder, for example from the tablet app, the help pages, or emails it sends. I'll file them.
15
15
  - **What I need from you:**
16
16
  - which features you know or suspect exist that I haven't covered (I'll ask about specific suspects from my list)
17
- - the test Google account, so I can see the real connected states
17
+ - the test account, so I can see the real signed-in states
18
18
  - your call on anything that sends real data or costs money
19
19
 
20
20
  **Step 3: I tidy up**
21
- - **For mercbot:**
22
- - Newly discovered areas go to mercbot as additions to the feature map.
23
- - Each one gets a spec, sent when you or mercbot ask for it. The phase-2 rules allow that.
21
+ - **For the design notes:**
22
+ - Newly found screens go into the notes as additions to the screen map.
23
+ - Each one gets a short note, written when you or the team ask for it. The release plan allows that.
24
24
  - **For you:** a final coverage summary.
25
25
 
26
- **One thing to keep in mind:** you'll see more of the original than mercbot ever should. During this phase, keep sending findings through me rather than straight to mercbot.
26
+ **One thing to keep in mind:** some screens still show sample data. During this phase, treat anything odd there as a question for me rather than a bug.
27
27
 
28
- [1] Shall I start Step 1 (the solo sweep and gap list) now?
28
+ [1] Shall I start Step 1 (the solo check and gap list) now?
29
29
  Possible answers:
30
30
  - Yes, start now
31
31
  - Wait until I'm ready to do Step 2 as well
32
32
 
33
33
  [2] For Step 2, how would you like to work?
34
34
  Possible answers:
35
- - I drive over VNC while you record
36
- - I'll put screenshots in the Dropzone
35
+ - I use the planner while you take notes
36
+ - I'll put screenshots in the shared folder
37
37
  - A mix of both
@@ -22,34 +22,34 @@ function outline(blocks = []) {
22
22
  test("a question message keeps its intro paragraphs and nested lists apart from the question cards", () => {
23
23
  const prompt = parseNumberedQuestionPrompt(message);
24
24
  assert.deepEqual(prompt.questions.map((question) => [question.label, question.choices.length]), [
25
- ["Shall I start Step 1 (the solo sweep and gap list) now?", 2],
25
+ ["Shall I start Step 1 (the solo check and gap list) now?", 2],
26
26
  ["For Step 2, how would you like to work?", 3]
27
27
  ]);
28
28
  assert.equal(prompt.outro, "");
29
29
 
30
30
  assert.deepEqual(outline(parseLongTextReviewBlocks(prompt.intro)), [
31
- "paragraph: Here's how I'd run a final sweep in thre",
32
- "paragraph: **Step 1: I sweep on my own (no need for",
31
+ "paragraph: Here's how I'd run a final check of the ",
32
+ "paragraph: **Step 1: I check on my own (no need for",
33
33
  {
34
34
  ul: [
35
- "**Click everything.** I go through every",
35
+ "**Try every screen.** I open every scree",
36
36
  {
37
- "**Gather the \"unverified\" lists.** Every": [
38
- { ul: ["**I can observe it now:** for example, e", "**Needs you:** real accounts, audio, a s", "**Can't be observed at all.**"] }
37
+ "**Gather the \"open question\" lists.** Ev": [
38
+ { ul: ["**I can check it now:** for example, ren", "**Needs you:** real accounts, printing, ", "**Can't be checked at all.**"] }
39
39
  ]
40
40
  },
41
- "**Output:** one gap list, saved to your "
41
+ "**Output:** one gap list, saved to the s"
42
42
  ]
43
43
  },
44
44
  "paragraph: **Step 2: a session with you, for the \"n",
45
45
  {
46
46
  ul: [
47
- "**You drive the reference over VNC**, us",
48
- "**I record the screen** at a low frame r",
49
- "**Things outside the reference machine:*",
47
+ "**You use the planner on your own comput",
48
+ "**I take notes as you go** and capture s",
49
+ "**Things outside the planner itself:** p",
50
50
  {
51
51
  "**What I need from you:**": [
52
- { ul: ["which features you know or suspect exist", "the test Google account, so I can see th", "your call on anything that sends real da"] }
52
+ { ul: ["which features you know or suspect exist", "the test account, so I can see the real ", "your call on anything that sends real da"] }
53
53
  ]
54
54
  }
55
55
  ]
@@ -58,14 +58,14 @@ test("a question message keeps its intro paragraphs and nested lists apart from
58
58
  {
59
59
  ul: [
60
60
  {
61
- "**For mercbot:**": [
62
- { ul: ["Newly discovered areas go to mercbot as ", "Each one gets a spec, sent when you or m"] }
61
+ "**For the design notes:**": [
62
+ { ul: ["Newly found screens go into the notes as", "Each one gets a short note, written when"] }
63
63
  ]
64
64
  },
65
65
  "**For you:** a final coverage summary."
66
66
  ]
67
67
  },
68
- "paragraph: **One thing to keep in mind:** you'll se"
68
+ "paragraph: **One thing to keep in mind:** some scre"
69
69
  ]);
70
70
  });
71
71
 
@@ -1,14 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
- import { chromium, expect } from "@playwright/test";
5
- import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
4
+ import { expect } from "../../../tooling/testUtils/browserExpect.mjs";
5
+ import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
6
+
7
+ const openFixture = sharedBrowserFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
6
8
 
7
9
  test("the shared element renders delayed sends and supplies retry, edit and cancel without application handlers", {
8
10
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
9
- }, async () => {
10
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
11
- const browser = await chromium.launch(createChromiumLaunchOptions());
11
+ }, async (t) => {
12
+ const { vite, browser } = await openFixture(t);
12
13
  try {
13
14
  for (const width of [390, 800, 1365]) {
14
15
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -102,16 +103,14 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
102
103
  }
103
104
  } finally {
104
105
  await browser.close();
105
- await stopProcess(vite);
106
106
  }
107
107
  });
108
108
 
109
109
 
110
110
  test("chronological messages and Cancel during a delivery check work on phone and desktop", {
111
111
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
112
- }, async () => {
113
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
114
- const browser = await chromium.launch(createChromiumLaunchOptions());
112
+ }, async (t) => {
113
+ const { vite, browser } = await openFixture(t);
115
114
  try {
116
115
  for (const width of [390, 1365]) {
117
116
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -148,14 +147,13 @@ test("chronological messages and Cancel during a delivery check work on phone an
148
147
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
149
148
  await page.close();
150
149
  }
151
- } finally { await browser.close(); await stopProcess(vite); }
150
+ } finally { await browser.close(); }
152
151
  });
153
152
 
154
153
  test("accepted local messages precede later replies before their history page arrives", {
155
154
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
156
- }, async () => {
157
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
158
- const browser = await chromium.launch(createChromiumLaunchOptions());
155
+ }, async (t) => {
156
+ const { vite, browser } = await openFixture(t);
159
157
  try {
160
158
  for (const width of [390, 1365]) {
161
159
  const page = await browser.newPage({ viewport: { width, height: 900 } });
@@ -178,5 +176,5 @@ test("accepted local messages precede later replies before their history page ar
178
176
  assert.deepEqual(errors, []);
179
177
  await page.close();
180
178
  }
181
- } finally { await browser.close(); await stopProcess(vite); }
179
+ } finally { await browser.close(); }
182
180
  });
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { registerHooks } from "node:module";
3
+ import test from "node:test";
4
+
5
+ // Every module this process loads from here on; assistant-core is imported after the hook.
6
+ const loadedUrls = [];
7
+ registerHooks({
8
+ load(url, context, nextLoad) {
9
+ loadedUrls.push(url);
10
+ return nextLoad(url, context);
11
+ }
12
+ });
13
+ const openAiSdkModules = () => loadedUrls.filter((url) => /\/node_modules\/openai\//u.test(url));
14
+
15
+ test("the OpenAI SDK loads with an OpenAI-compatible client's first completion, not with the server", async () => {
16
+ await import("../src/server/index.js");
17
+ const { createOpenAiCompatibleClient } = await import("../src/server/lib/providers/openAiCompatibleClient.js");
18
+ const client = createOpenAiCompatibleClient({ apiKey: "test-only-key", baseUrl: "http://127.0.0.1:9/v1", model: "gpt-test" });
19
+ assert.equal(client.enabled, true);
20
+ assert.deepEqual(openAiSdkModules(), []);
21
+
22
+ const aborted = AbortSignal.abort();
23
+ await assert.rejects(client.createChatCompletion({ messages: [{ role: "user", content: "Hello" }], signal: aborted }));
24
+ assert.ok(openAiSdkModules().length > 0);
25
+ });
@@ -77,6 +77,7 @@ async function fixture(t, options = {}) {
77
77
  const directory = await mkdtemp(path.join(tmpdir(), "jskit-opencode-conversation-"));
78
78
  const command = path.join(directory, "opencode.mjs");
79
79
  const trace = path.join(directory, "trace.jsonl");
80
+ const partialSeen = path.join(directory, "partial-seen");
80
81
  await writeFile(command, `#!${process.execPath}
81
82
  import { createServer } from "node:http";
82
83
  import { appendFileSync, readFileSync, writeFileSync, existsSync } from "node:fs";
@@ -90,6 +91,7 @@ async function fixture(t, options = {}) {
90
91
  const streams = new Set();
91
92
  const assistantResponses = ${JSON.stringify(options.assistantResponses || [])};
92
93
  const busy = new Set();
94
+ const heldAnswers = new Map();
93
95
  let sequence = [...sessions.values()].reduce((sum, session) => sum + session.messages.length, 0);
94
96
  const row = (role, id, parts, extra = {}) => ({ info: { role, id, time: { created: ++sequence }, ...extra }, parts });
95
97
  const server = createServer(async (req, res) => {
@@ -132,6 +134,12 @@ async function fixture(t, options = {}) {
132
134
  }
133
135
  if (req.url.includes("/message")) {
134
136
  if (${Boolean(options.historyUnavailable)}) return reply({ message: "Native history unavailable" }, 503);
137
+ // A held answer completes at the first read after the test saw its partial streamed, or after 10 s, so a
138
+ // partial that never streams fails the test instead of hanging it.
139
+ const held = heldAnswers.get(session.id);
140
+ if (held && (existsSync(${JSON.stringify(partialSeen)}) || Date.now() - held.since > 10_000)) {
141
+ heldAnswers.delete(session.id); held.complete();
142
+ }
135
143
  return reply(session.messages);
136
144
  }
137
145
  if (req.url.endsWith("/prompt_async")) {
@@ -217,11 +225,13 @@ async function fixture(t, options = {}) {
217
225
  log({ system: output.system, sessionId: session.id });
218
226
  const answer = row("assistant", randomUUID(), [{ type: "reasoning", text: "Reasoning summary" }, { type: "text", text: "Answer: " }]);
219
227
  session.messages.push(answer); save();
220
- setTimeout(() => {
228
+ const complete = () => {
221
229
  answer.parts[1].text = answerText;
222
230
  answer.info.finish = "stop"; answer.info.time.completed = Date.now(); save();
223
231
  busy.delete(session.id);
224
- }, 100);
232
+ };
233
+ if (${Boolean(options.holdPartialAnswer)}) heldAnswers.set(session.id, { complete, since: Date.now() });
234
+ else setTimeout(complete, 100);
225
235
  return;
226
236
  }
227
237
  reply({ message: "Unexpected route" }, 404);
@@ -259,6 +269,7 @@ async function fixture(t, options = {}) {
259
269
  limits: { admissionTimeoutMs: 250, ...options.limits }
260
270
  },
261
271
  binding: () => storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding),
272
+ releasePartialAnswer: () => writeFile(partialSeen, ""),
262
273
  async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
263
274
  }
264
275
 
@@ -321,9 +332,14 @@ test("OpenCode structured output counts its schema framing before native dispatc
321
332
  });
322
333
 
323
334
  test("OpenCode admits once, streams and saves native reasoning through the common runtime", async t => {
324
- const f = await fixture(t);
335
+ // OpenCode text streams only through the runtime's history reads, so the native answer stays partial until this
336
+ // subscriber has seen it streamed, instead of for a fixed 100 ms that a read under load could miss.
337
+ const f = await fixture(t, { holdPartialAnswer: true });
325
338
  const events = [];
326
- await f.conversation.subscribe(event => events.push(event));
339
+ await f.conversation.subscribe(event => {
340
+ events.push(event);
341
+ if (event.type === "message" && event.text === "Answer: ") void f.releasePartialAnswer();
342
+ });
327
343
  assert.equal((await f.conversation.send(input)).status, "accepted");
328
344
  const state = await f.conversation.wait();
329
345
  assert.equal(state.conversationLog[0].metadata.runtime.status, "complete");
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { fileURLToPath } from "node:url";
4
- import { chromium, expect } from "@playwright/test";
5
- import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
4
+ import { expect } from "@playwright/test";
5
+ import { sharedBrowserFixture } from "../../../tooling/testUtils/browserFixture.mjs";
6
6
 
7
7
  // An element's text colour, the theme values it should come from, and its WCAG contrast over what is painted under
8
8
  // it: each translucent background is laid over the next one out, down to the first opaque one (white when none is).
@@ -43,11 +43,12 @@ function readText(element) {
43
43
  };
44
44
  }
45
45
 
46
+ const openFixture = sharedBrowserFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
47
+
46
48
  test("the task transcript's user bubble, header time and footer follow the light and dark themes", {
47
49
  skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
48
- }, async () => {
49
- const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
50
- const browser = await chromium.launch(createChromiumLaunchOptions());
50
+ }, async (t) => {
51
+ const { vite, browser } = await openFixture(t);
51
52
  try {
52
53
  for (const theme of ["light", "dark"]) {
53
54
  const page = await browser.newPage({ viewport: { width: 800, height: 900 } });
@@ -84,6 +85,5 @@ test("the task transcript's user bubble, header time and footer follow the light
84
85
  }
85
86
  } finally {
86
87
  await browser.close();
87
- await stopProcess(vite);
88
88
  }
89
89
  });