@jskit-ai/assistant-runtime 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.
@@ -1,7 +1,8 @@
1
1
  const assistantRuntimeConfig = Object.freeze({
2
2
  configTable: "assistant_config",
3
3
  conversationsTable: "assistant_conversations",
4
- messagesTable: "assistant_messages"
4
+ messagesTable: "assistant_messages",
5
+ turnRequestsTable: "assistant_turn_requests"
5
6
  });
6
7
 
7
8
  export { assistantRuntimeConfig };
@@ -1,3 +1,4 @@
1
+ import { createMemoryTurnRequests } from "./support/memoryTurnRequests.js";
1
2
  import assert from "node:assert/strict";
2
3
  import test from "node:test";
3
4
  import { createChatService } from "../src/server/services/chatService.js";
@@ -89,6 +90,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
89
90
  }));
90
91
 
91
92
  const chatService = createChatService({
93
+ turnRequests: createMemoryTurnRequests(),
92
94
  attachments,
93
95
  aiClientFactory: {
94
96
  resolveClient() {
@@ -186,7 +188,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
186
188
  {
187
189
  context: {
188
190
  actor: {
189
- id: "user_1"
191
+ id: "1"
190
192
  }
191
193
  },
192
194
  streamWriter
@@ -510,7 +512,7 @@ test("application-authorized attachments reach the model and survive transcript
510
512
  const receipt = { attachmentId: "file-one", fileName: "notes.txt", size: 12 };
511
513
  const attachments = { async resolve(request) {
512
514
  calls.push(request);
513
- assert.equal(request.context.actor.id, "user_1");
515
+ assert.equal(request.context.actor.id, "1");
514
516
  assert.equal(request.conversation.id, "conversation_1");
515
517
  if (request.attachmentIds[0] !== "file-one") throw new Error("Attachment access denied");
516
518
  return { attachments: [receipt], content: [{ type: "text", text: "Authorized file bytes" }] };
@@ -299,3 +299,53 @@ test("the ready-made UI streams from the real chat service with selected AI conn
299
299
  assert.equal(state.transcript.filter(message => message.role === "assistant").length, 1);
300
300
  } finally { await browser.close(); await stopProcess(vite); }
301
301
  });
302
+
303
+ test("pending delivery appears immediately and retries the original request without replacing a newer draft", {
304
+ skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1",
305
+ timeout: 60_000
306
+ }, async () => {
307
+ const vite = await startViteFixture({ fixtureRoot });
308
+ const browser = await chromium.launch(createChromiumLaunchOptions());
309
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
310
+ const held = Promise.withResolvers();
311
+ const requests = [];
312
+ const errors = [];
313
+ page.on("pageerror", error => errors.push(error.message));
314
+ try {
315
+ await page.route("**/chat/stream", async route => {
316
+ requests.push(route.request().postDataJSON());
317
+ if (requests.length === 1) {
318
+ await held.promise;
319
+ await route.fulfill({ status: 503, json: { error: "Temporarily unavailable." } });
320
+ } else {
321
+ await route.continue();
322
+ }
323
+ });
324
+ await page.goto(vite.baseURL);
325
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
326
+ await input.fill("Show this immediately.");
327
+ await input.press("Enter");
328
+ await expect(page.getByText("Show this immediately.", { exact: true })).toHaveCount(1);
329
+ await expect(page.locator(".assistant-composer-support__assistant-status")).toHaveText("Sending to assistant…");
330
+ await expect(input).toHaveValue("");
331
+ await input.fill("Keep this newer draft.");
332
+ held.resolve();
333
+ await expect(page.getByRole("button", { name: "Resend", exact: true })).toBeVisible();
334
+ await expect(input).toHaveValue("Keep this newer draft.");
335
+ await page.getByRole("button", { name: "Resend", exact: true }).click();
336
+ await expect(page.getByLabel("Fixture assistant progress")).toBeVisible();
337
+ assert.equal(requests.length, 2);
338
+ assert.deepEqual(requests[1], requests[0]);
339
+ await expect(page.getByText("Show this immediately.", { exact: true })).toHaveCount(1);
340
+ await expect(page.getByRole("button", { name: "Resend", exact: true })).toHaveCount(0);
341
+ await expect(input).toHaveValue("Keep this newer draft.");
342
+ await page.request.get(`${vite.baseURL}/fixture/next?finish=1`);
343
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
344
+ await expect(input).toHaveValue("Keep this newer draft.");
345
+ assert.deepEqual(errors, []);
346
+ } finally {
347
+ held.resolve();
348
+ await browser.close();
349
+ await stopProcess(vite);
350
+ }
351
+ });
@@ -1,3 +1,4 @@
1
+ import { createMemoryTurnRequests } from "./support/memoryTurnRequests.js";
1
2
  import assert from "node:assert/strict";
2
3
  import test from "node:test";
3
4
  import { createSchema } from "json-rest-schema";
@@ -338,6 +339,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
338
339
 
339
340
  test("chat service uses explicit app config when conversations are listed", async () => {
340
341
  const chatService = createChatService({
342
+ turnRequests: createMemoryTurnRequests(),
341
343
  aiClientFactory: {
342
344
  resolveClient() {
343
345
  throw new Error("resolveClient should not be called when listing conversations.");
@@ -388,6 +390,7 @@ test("chat service uses explicit app config when conversations are listed", asyn
388
390
 
389
391
  test("chat service rejects workspace-scoped assistant surfaces when workspace support is unavailable", async () => {
390
392
  const chatService = createChatService({
393
+ turnRequests: createMemoryTurnRequests(),
391
394
  aiClientFactory: {
392
395
  resolveClient() {
393
396
  throw new Error("resolveClient should not be called when listing conversations.");
@@ -22,7 +22,8 @@ test("assistant-runtime registers providers without install-time authoring machi
22
22
  assert.deepEqual(packageMetadata.migrations, { directories: ["migrations"] });
23
23
  assert.deepEqual((await readdir(path.join(PACKAGE_ROOT, "migrations"))).sort(), [
24
24
  "assistant_config_initial.cjs",
25
- "assistant_transcripts_initial.cjs"
25
+ "assistant_transcripts_initial.cjs",
26
+ "assistant_turn_requests.cjs"
26
27
  ]);
27
28
 
28
29
  const publicConfig = await readFile(
@@ -0,0 +1,19 @@
1
+ // A test double for lifecycle tests; runtime admission uses the database repository.
2
+ export function createMemoryTurnRequests() {
3
+ const records = new Map();
4
+ const key = (scope, messageId) => JSON.stringify([scope, messageId]);
5
+ return {
6
+ async find(scope, messageId) { return records.get(key(scope, messageId)) || null; },
7
+ async claim(scope, request) {
8
+ const id = key(scope, request.messageId);
9
+ const existing = records.get(id);
10
+ if (existing) return { ...existing, acquired: false };
11
+ const record = { id, request: JSON.parse(JSON.stringify(request)), status: "running", response: null };
12
+ records.set(id, record);
13
+ return { ...record, acquired: true };
14
+ },
15
+ async update(claim, response, status = "running") {
16
+ Object.assign(records.get(claim.id), { response: structuredClone(response), status });
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,122 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import knex from "knex";
7
+ import migration from "../migrations/assistant_turn_requests.cjs";
8
+ import { createRepository } from "../src/server/repositories/turnRequestsRepository.js";
9
+ import { createChatService } from "../src/server/services/chatService.js";
10
+
11
+ async function database(t) {
12
+ const dir = await mkdtemp(path.join(tmpdir(), "assistant-requests-"));
13
+ const connections = [];
14
+ const connect = () => {
15
+ const db = knex({ client: "better-sqlite3", connection: { filename: path.join(dir, "requests.db") }, useNullAsDefault: true });
16
+ connections.push(db);
17
+ return db;
18
+ };
19
+ t.after(async () => { for (const db of connections) await db.destroy(); await rm(dir, { recursive: true, force: true }); });
20
+ const db = connect();
21
+ await db.schema.createTable("users", table => table.bigInteger("id").primary());
22
+ await db("users").insert([{ id: 1 }, { id: 2 }]);
23
+ await migration.up(db);
24
+ return { db, connect };
25
+ }
26
+
27
+ const scope = { actorUserId: "1", surfaceId: "assistant", workspaceId: null };
28
+ const request = { messageId: "message-1", input: "Do this once.", history: [] };
29
+
30
+ test("migration preserves existing claims and database uniqueness arbitrates independent connections", async t => {
31
+ const { db, connect } = await database(t);
32
+ const first = createRepository(db);
33
+ const second = createRepository(connect());
34
+ const claims = await Promise.all([first.claim(scope, request), second.claim(scope, request)]);
35
+ assert.equal(claims.filter(claim => claim.acquired).length, 1);
36
+ assert.equal((await db("assistant_turn_requests")).length, 1);
37
+ await migration.up(db);
38
+ assert.equal((await db("assistant_turn_requests")).length, 1);
39
+ assert.equal((await second.claim({ ...scope, actorUserId: "2" }, request)).acquired, true);
40
+ assert.equal((await second.claim({ ...scope, workspaceId: "10" }, request)).acquired, true);
41
+ assert.equal((await second.claim({ ...scope, surfaceId: "other" }, request)).acquired, true);
42
+ await migration.down(db);
43
+ assert.equal(await db.schema.hasTable("assistant_turn_requests"), false);
44
+ assert.equal((await db("users")).length, 2);
45
+ });
46
+
47
+ function service(repository, { provider, transcript = [] } = {}) {
48
+ return createChatService({
49
+ turnRequests: repository,
50
+ aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test", createChatCompletionStream: provider }) },
51
+ transcriptService: {
52
+ async createConversationForTurn() { return { conversation: { id: "100" } }; },
53
+ async appendMessage(_surface, _id, message) { transcript.push(message); },
54
+ async completeConversation() {}
55
+ },
56
+ serviceToolCatalog: { resolveToolSet: () => ({ tools: [] }) },
57
+ assistantConfigService: { resolveSystemPrompt: async () => "Answer." },
58
+ appConfig: {
59
+ surfaceDefinitions: { assistant: { id: "assistant", enabled: true, requiresWorkspace: false, accessPolicyId: "public" } },
60
+ assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } }
61
+ }
62
+ });
63
+ }
64
+
65
+ function run(chat, input = request, { events = [], disconnectAfterCompletion = false } = {}) {
66
+ const streamWriter = Object.fromEntries(["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
67
+ .map(method => [method, event => {
68
+ if (disconnectAfterCompletion && method === "sendDone") throw new Error("Response lost.");
69
+ events.push({ method, event });
70
+ }]));
71
+ return chat.streamChat({ targetSurfaceId: "assistant", ...input }, { context: { actor: { id: "1" } }, streamWriter });
72
+ }
73
+
74
+ test("concurrent submissions, lost completion responses and a fresh service replay one provider execution", async t => {
75
+ const { db, connect } = await database(t);
76
+ const waiting = Promise.withResolvers();
77
+ const started = Promise.withResolvers();
78
+ let calls = 0;
79
+ const transcript = [];
80
+ const provider = async function* () {
81
+ calls++;
82
+ started.resolve();
83
+ await waiting.promise;
84
+ yield { choices: [{ delta: { content: "Completed once." } }] };
85
+ };
86
+ const first = service(createRepository(db), { provider, transcript });
87
+ const pending = run(first, request, { disconnectAfterCompletion: true });
88
+ const failedResponse = assert.rejects(pending, /Response lost/);
89
+ await started.promise;
90
+ const second = service(createRepository(connect()), { provider, transcript });
91
+ const inFlight = [];
92
+ assert.equal((await run(second, request, { events: inFlight })).status, "unconfirmed");
93
+ assert.equal(inFlight.find(item => item.method === "sendMeta").event.conversationId, "100");
94
+ assert.equal(calls, 1);
95
+ waiting.resolve();
96
+ await failedResponse;
97
+ const replay = [];
98
+ const restarted = service(createRepository(connect()), { provider: () => { throw new Error("Replay must not invoke the provider."); } });
99
+ assert.equal((await run(restarted, request, { events: replay })).status, "completed");
100
+ assert.equal(replay.find(item => item.method === "sendAssistantMessage").event.text, "Completed once.");
101
+ assert.equal(calls, 1);
102
+ assert.deepEqual(transcript.map(message => message.role), ["user", "assistant"]);
103
+ await assert.rejects(run(restarted, { ...request, input: "Different request" }), /different request/);
104
+ });
105
+
106
+ test("an interrupted claim is never stolen after restart, and provider failures replay without executing again", async t => {
107
+ const { db, connect } = await database(t);
108
+ const repository = createRepository(db);
109
+ await repository.claim(scope, { ...request, conversationId: null, integrationId: "" });
110
+ let calls = 0;
111
+ const chat = service(createRepository(connect()), { provider: () => { calls++; throw new Error("Provider failed."); } });
112
+ assert.equal((await run(chat)).status, "unconfirmed");
113
+ assert.equal(calls, 0);
114
+ const failure = { ...request, messageId: "failing-message" };
115
+ assert.equal((await run(chat, failure)).status, "failed");
116
+ const replay = [];
117
+ assert.equal((await run(chat, failure, { events: replay })).status, "failed");
118
+ assert.equal(calls, 1);
119
+ assert.match(replay.find(item => item.method === "sendError").event.message, /Provider failed/);
120
+ await assert.rejects(run(chat, { ...request, messageId: "x".repeat(129) }), /Validation failed/);
121
+ assert.equal(calls, 1);
122
+ });