@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.
- package/fixtures/conversation/vite.config.mjs +3 -0
- package/migrations/assistant_turn_requests.cjs +20 -0
- package/package.json +10 -7
- package/src/client/components/AssistantSurfaceClientElement.vue +14 -4
- package/src/client/composables/useAssistantRuntime.js +62 -37
- package/src/server/AssistantProvider.js +5 -1
- package/src/server/repositories/turnRequestsRepository.js +35 -0
- package/src/server/services/chatService.js +415 -359
- package/src/shared/assistantRuntimeConfig.js +2 -1
- package/test/chatServiceLifecycle.test.js +4 -2
- package/test/conversation.browser.test.js +50 -0
- package/test/lazyAppConfig.test.js +3 -0
- package/test/packageMetadata.test.js +2 -1
- package/test/support/memoryTurnRequests.js +19 -0
- package/test/turnRequests.test.js +122 -0
|
@@ -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: "
|
|
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, "
|
|
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
|
+
});
|