@jskit-ai/assistant-runtime 0.2.9 → 0.2.11
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/README.md +1 -1
- package/fixtures/canonical-conversation/main.js +2 -1
- package/fixtures/conversation/main.js +2 -1
- package/fixtures/conversation/vite.config.mjs +4 -3
- package/migrations/assistant_config_initial.cjs +1 -5
- package/migrations/assistant_transcripts_initial.cjs +3 -9
- package/migrations/assistant_transcripts_summary.cjs +28 -0
- package/migrations/assistant_turn_requests.cjs +2 -1
- package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
- package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
- package/package.json +17 -10
- package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
- package/src/client/components/AssistantConversationClientElement.vue +10 -4
- package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
- package/src/client/composables/conversationGoal.js +266 -0
- package/src/client/composables/conversationHistory.js +215 -0
- package/src/client/composables/conversationRuntime.js +624 -0
- package/src/client/composables/useAssistantConversation.js +208 -884
- package/src/client/composables/useAssistantRuntime.js +60 -45
- package/src/client/providers/AssistantClientProvider.js +1 -0
- package/src/client/support/assistantRuntimeState.js +6 -34
- package/src/client/support/conversationFailures.js +30 -0
- package/src/client/support/conversationRestoreSupport.js +13 -5
- package/src/client/support/subscribeAssistantConversation.js +32 -9
- package/src/client/support/workspaceScopeSupport.js +13 -22
- package/src/client/types.js +46 -0
- package/src/server/AssistantProvider.js +45 -17
- package/src/server/actions.js +162 -201
- package/src/server/conversationOperations.js +141 -0
- package/src/server/createAssistantRuntime.js +83 -44
- package/src/server/createAssistantSettingsActions.js +69 -47
- package/src/server/hostedAssistant.js +21 -0
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +34 -11
- package/src/server/registerFastifyConversations.js +18 -6
- package/src/server/registerRoutes.js +5 -2
- package/src/server/registerSettingsRoutes.js +1 -1
- package/src/server/repositories/assistantConfigRepository.js +10 -20
- package/src/server/repositories/conversationsRepository.js +35 -10
- package/src/server/repositories/messagesRepository.js +14 -10
- package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
- package/src/server/repositories/turnRequestsRepository.js +6 -1
- package/src/server/services/assistantConfigService.js +25 -23
- package/src/server/services/chatService.js +57 -61
- package/src/server/services/transcriptService.js +15 -7
- package/src/server/sqlConversationStorage.js +90 -18
- package/src/server/support/assistantServerConfig.js +1 -1
- package/src/server/support/assistantStreamWriter.js +7 -1
- package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
- package/src/server/types.js +38 -0
- package/src/shared/assistantSurfaces.js +2 -2
- package/test/assistantConversationOperations.test.js +5 -5
- package/test/assistantRuntimeState.test.js +1 -16
- package/test/chatServiceLifecycle.test.js +62 -154
- package/test/createSurfaceAwareToolCatalog.test.js +5 -0
- package/test/featureRuntime.test.js +12 -6
- package/test/lazyAppConfig.test.js +3 -5
- package/test/packageMetadata.test.js +7 -1
- package/test/sqlConversationStorage.mysql.test.js +89 -0
- package/test/sqlConversationStorage.test.js +161 -16
- package/test/support/memoryAssistantRows.js +6 -1
- package/test/support/scriptedModel.js +32 -0
- package/test/turnRequests.test.js +4 -2
- package/test/usersForeignKeysMigration.test.js +46 -0
- package/test/workspacesForeignKeysMigration.test.js +66 -0
- package/src/client/support/accessDenied.js +0 -6
|
@@ -8,6 +8,7 @@ import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
|
8
8
|
import { createConversationTranscript } from "@jskit-ai/assistant-core/server/conversation";
|
|
9
9
|
import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
|
|
10
10
|
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
11
|
+
import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
|
|
11
12
|
import claimsMigration from "../migrations/assistant_turn_requests.cjs";
|
|
12
13
|
import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
|
|
13
14
|
import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
|
|
@@ -16,6 +17,7 @@ import { createSqlConversationStorage } from "../src/server/sqlConversationStora
|
|
|
16
17
|
import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
|
|
17
18
|
import { createTranscriptService } from "../src/server/services/transcriptService.js";
|
|
18
19
|
import { createChatService } from "../src/server/services/chatService.js";
|
|
20
|
+
import { createScriptedModel } from "./support/scriptedModel.js";
|
|
19
21
|
|
|
20
22
|
async function fixture(t) {
|
|
21
23
|
const directory = await mkdtemp(path.join(tmpdir(), "assistant-sql-runtime-"));
|
|
@@ -35,6 +37,7 @@ async function fixture(t) {
|
|
|
35
37
|
await first.db.schema.createTable("users", table => table.bigInteger("id").primary());
|
|
36
38
|
await first.db("users").insert([{ id: 1 }, { id: 2 }]);
|
|
37
39
|
await transcriptsMigration.up(first.db);
|
|
40
|
+
await summaryMigration.up(first.db);
|
|
38
41
|
await claimsMigration.up(first.db);
|
|
39
42
|
await first.db.raw("PRAGMA journal_mode = WAL");
|
|
40
43
|
const conversation = actor => first.conversationsRepository.create({ createdByUserId: actor || "1", surfaceId: "assistant", metadata: {} });
|
|
@@ -139,7 +142,7 @@ test("historical SQL projection preserves exact rows and blocks ambiguous histor
|
|
|
139
142
|
assert.deepEqual((await f.storage.preflight(interrupted.id)).issues, [{ code: "unconfirmed_historical_tool", rowIds: [call.id] }]);
|
|
140
143
|
});
|
|
141
144
|
|
|
142
|
-
test("the SQL facade uses common inference and durable tool rows
|
|
145
|
+
test("the SQL facade uses common inference and durable tool rows, and the model's history is the conversation's saved rows", async t => {
|
|
143
146
|
const f = await fixture(t);
|
|
144
147
|
const conversation = await f.conversation();
|
|
145
148
|
await f.messagesRepository.create({ conversationId: conversation.id, role: "user", clientMessageSid: "previous", contentText: "Canonical history outside the selected context" });
|
|
@@ -149,14 +152,14 @@ test("the SQL facade uses common inference and durable tool rows while supplied
|
|
|
149
152
|
let executions = 0;
|
|
150
153
|
const aiClientFactory = { resolveClient(surface, input) {
|
|
151
154
|
resolved.push({ surface, integrationId: input.integrationId, actor: input.context.actor.id });
|
|
152
|
-
return { enabled: true, provider: "test", defaultModel: "fixture-model",
|
|
155
|
+
return { enabled: true, provider: "test", defaultModel: "fixture-model", model: createScriptedModel(request => {
|
|
153
156
|
requests.push(structuredClone(request.messages));
|
|
154
157
|
const tool = requests.length === 1;
|
|
155
158
|
return (async function* () {
|
|
156
159
|
yield { choices: [{ delta: tool ? { tool_calls: [{ index: 0, id: "lookup", function: { name: "inventory_lookup", arguments: "{}" } }] }
|
|
157
160
|
: { content: "Stored once." }, finish_reason: tool ? "tool_calls" : "stop" }] };
|
|
158
161
|
})();
|
|
159
|
-
} };
|
|
162
|
+
}, { model: "fixture-model" }) };
|
|
160
163
|
} };
|
|
161
164
|
const catalog = { limits: { maxToolArgumentBytes: 4096 },
|
|
162
165
|
resolveToolSet: () => ({ tools: [{ name: "inventory_lookup", parameters: { type: "object" } }] }),
|
|
@@ -179,7 +182,7 @@ test("the SQL facade uses common inference and durable tool rows while supplied
|
|
|
179
182
|
assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
|
|
180
183
|
const service = createChatService(dependencies);
|
|
181
184
|
const input = { targetSurfaceId: "assistant", conversationId: conversation.id, messageId: "order/42:review", input: "Read the record",
|
|
182
|
-
integrationId: "allowed-choice"
|
|
185
|
+
integrationId: "allowed-choice" };
|
|
183
186
|
const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
|
|
184
187
|
["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
|
|
185
188
|
.map(name => [name, event => events.push({ name, event })])) };
|
|
@@ -187,26 +190,24 @@ test("the SQL facade uses common inference and durable tool rows while supplied
|
|
|
187
190
|
assert.equal(executions, 1);
|
|
188
191
|
assert.equal(requests.length, 2);
|
|
189
192
|
assert.equal(resolved.every(value => value.surface === "assistant" && value.integrationId === "allowed-choice" && value.actor === "1"), true);
|
|
190
|
-
assert.
|
|
191
|
-
|
|
193
|
+
assert.deepEqual(requests[0].slice(1, 3), [{ role: "user", content: "Canonical history outside the selected context" },
|
|
194
|
+
{ role: "assistant", content: "Previous saved answer" }], "The model reads the conversation's saved history, not the browser's");
|
|
192
195
|
assert.deepEqual(JSON.parse(requests[1].find(message => message.role === "tool").content), { found: 1 },
|
|
193
196
|
"The model receives the original action result body, while its durable receipt keeps the full envelope");
|
|
194
197
|
const saved = await rows(f.db);
|
|
195
198
|
assert.equal(saved.messages.filter(row => row.kind === "tool_call").length, 1);
|
|
196
199
|
assert.equal(saved.messages.filter(row => row.kind === "tool_result").length, 1);
|
|
197
200
|
assert.equal(saved.messages.filter(row => row.client_message_sid === input.messageId).length, 1);
|
|
198
|
-
assert.equal(saved.messages.some(row => row.content_text === "Selected request context only"), false);
|
|
199
|
-
assert.doesNotMatch(saved.conversations[0].metadata_json, /Selected request context only/);
|
|
200
201
|
assert.doesNotMatch(saved.messages.find(row => row.client_message_sid === input.messageId).metadata_json, /applicationTools/);
|
|
201
202
|
const claimed = await f.db("assistant_turn_requests").first();
|
|
202
203
|
assert.equal(claimed.status, "completed");
|
|
203
|
-
assert.equal(JSON.parse(claimed.request_json)
|
|
204
|
+
assert.equal(Object.hasOwn(JSON.parse(claimed.request_json), "history"), false);
|
|
204
205
|
assert.equal(events.at(-1).name, "sendDone");
|
|
205
206
|
assert.equal((await service.streamChat(input, options)).status, "completed");
|
|
206
207
|
assert.equal(requests.length, 2, "The original SQL claim replays without inference or duplicate effects");
|
|
207
|
-
for (const change of [{ actor: { id: "2" } }, { surface: "other" }, { assistantRequest: { conversation, workspace: { id: "77" }
|
|
208
|
+
for (const change of [{ actor: { id: "2" } }, { surface: "other" }, { assistantRequest: { conversation, workspace: { id: "77" } } }]) {
|
|
208
209
|
await assert.rejects(runtime.open({ id: conversation.id,
|
|
209
|
-
context: { actor: { id: "1" }, surface: "assistant", assistantRequest: { conversation, workspace: null
|
|
210
|
+
context: { actor: { id: "1" }, surface: "assistant", assistantRequest: { conversation, workspace: null }, ...change } }),
|
|
210
211
|
{ code: "conversation_forbidden" });
|
|
211
212
|
}
|
|
212
213
|
});
|
|
@@ -223,7 +224,8 @@ test("the SQL facade replays historical inspection failures with their exact pub
|
|
|
223
224
|
let inference = 0;
|
|
224
225
|
let effects = 0;
|
|
225
226
|
const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
|
|
226
|
-
|
|
227
|
+
model: createScriptedModel(() => { inference++; assert.fail("Historical inspection cannot invoke inference"); },
|
|
228
|
+
{ once: () => { inference++; assert.fail("Historical inspection cannot invoke inference"); }, model: "test" }) }) };
|
|
227
229
|
const catalog = { resolveToolSet: () => ({ tools: [] }),
|
|
228
230
|
executeToolCall() { effects++; assert.fail("Historical inspection cannot invoke application effects"); } };
|
|
229
231
|
const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
|
|
@@ -235,7 +237,7 @@ test("the SQL facade replays historical inspection failures with their exact pub
|
|
|
235
237
|
appConfig: { surfaceDefinitions: { assistant: { enabled: true, requiresWorkspace: false } },
|
|
236
238
|
assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
|
|
237
239
|
const input = { targetSurfaceId: "assistant", conversationId: conversation.id,
|
|
238
|
-
messageId: "inspect-legacy-history", input: "Continue"
|
|
240
|
+
messageId: "inspect-legacy-history", input: "Continue" };
|
|
239
241
|
const events = [];
|
|
240
242
|
const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
|
|
241
243
|
["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
|
|
@@ -266,7 +268,7 @@ test("the SQL facade replays historical inspection failures with their exact pub
|
|
|
266
268
|
const oldFailure = { message: "Previously recorded failure", status: 409 };
|
|
267
269
|
const oldInput = { ...input, messageId: "old-failure-receipt" };
|
|
268
270
|
const oldClaim = await turnRequests.claim({ actorUserId: "1", surfaceId: "assistant", workspaceId: null },
|
|
269
|
-
{ conversationId: conversation.id, messageId: oldInput.messageId, input: oldInput.input,
|
|
271
|
+
{ conversationId: conversation.id, messageId: oldInput.messageId, input: oldInput.input, integrationId: "" });
|
|
270
272
|
await turnRequests.update(oldClaim, { failure: oldFailure }, "failed");
|
|
271
273
|
await assert.rejects(restarted.streamChat(oldInput, options), error => {
|
|
272
274
|
assert.equal(error.status, oldFailure.status);
|
|
@@ -326,13 +328,13 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
|
|
|
326
328
|
const requests = [];
|
|
327
329
|
let effects = 0;
|
|
328
330
|
const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "fixture-model",
|
|
329
|
-
|
|
331
|
+
model: createScriptedModel(request => {
|
|
330
332
|
assert.equal(allowFreshInference, true, "Historical refusal and foreign admission must not start inference");
|
|
331
333
|
requests.push(structuredClone(request.messages));
|
|
332
334
|
return (async function* () {
|
|
333
335
|
yield { choices: [{ delta: { content: "Fresh answer." }, finish_reason: "stop" }] };
|
|
334
336
|
})();
|
|
335
|
-
} }) };
|
|
337
|
+
}, { model: "fixture-model" }) }) };
|
|
336
338
|
const catalog = { limits: { maxToolArgumentBytes: 4096 }, resolveToolSet: () => ({ tools: [] }),
|
|
337
339
|
toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
|
|
338
340
|
executeToolCall() { effects++; assert.fail("Retained history cannot repeat an application effect"); } };
|
|
@@ -428,3 +430,146 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
|
|
|
428
430
|
assert.deepEqual((await restarted.getConversationMessages(conversation.id, {}, options)).entries, historical);
|
|
429
431
|
});
|
|
430
432
|
}
|
|
433
|
+
|
|
434
|
+
function recordQueries(db) {
|
|
435
|
+
const queries = [];
|
|
436
|
+
const listener = query => queries.push(query.sql);
|
|
437
|
+
db.on("query", listener);
|
|
438
|
+
return { queries, stop: () => db.removeListener("query", listener) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const readsWholeConversation = sql => /from [`"]assistant_messages[`"]/u.test(sql) && /order by/u.test(sql);
|
|
442
|
+
|
|
443
|
+
async function writeTurns(storage, conversationId, count, prefix = "turn") {
|
|
444
|
+
await storage.write(conversationId, async transaction => {
|
|
445
|
+
for (let index = 0; index < count; index += 1) {
|
|
446
|
+
const turnId = await transaction.nextTurnId();
|
|
447
|
+
const at = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString();
|
|
448
|
+
await transaction.appendMessage(turnId, { role: "user", messageId: `${prefix}-${index}`, text: `Question ${index}`, at,
|
|
449
|
+
turnMetadata: { runtime: { status: "complete" } } });
|
|
450
|
+
await transaction.appendMessage(turnId, { role: "assistant", messageId: `${prefix}-${index}:reply-1`, text: `Answer ${index}`, at });
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
test("a long conversation's rows are read once, then each write appends its rows without reading the conversation again", async t => {
|
|
456
|
+
const f = await fixture(t);
|
|
457
|
+
const conversation = await f.conversation();
|
|
458
|
+
await writeTurns(f.storage, conversation.id, 300);
|
|
459
|
+
const cold = f.connect();
|
|
460
|
+
let recorded = recordQueries(cold.db);
|
|
461
|
+
assert.equal((await cold.storage.read(conversation.id, transaction => transaction.listTurnIds())).length, 300);
|
|
462
|
+
recorded.stop();
|
|
463
|
+
assert.equal(recorded.queries.filter(readsWholeConversation).length, 2, "A cold server reads the 600 rows once, in pages of 500");
|
|
464
|
+
|
|
465
|
+
const measure = async operation => {
|
|
466
|
+
recorded = recordQueries(cold.db);
|
|
467
|
+
const result = await operation();
|
|
468
|
+
recorded.stop();
|
|
469
|
+
return { result, queries: recorded.queries };
|
|
470
|
+
};
|
|
471
|
+
const appendTurn = messageId => cold.storage.write(conversation.id, async transaction => {
|
|
472
|
+
const turnId = await transaction.nextTurnId();
|
|
473
|
+
await transaction.appendMessage(turnId, { role: "user", messageId, text: "One more", at: "2026-02-01T00:00:00.000Z" });
|
|
474
|
+
await transaction.updateTurnMetadata(turnId, { runtime: { status: "running" } });
|
|
475
|
+
return turnId;
|
|
476
|
+
});
|
|
477
|
+
const write = await measure(() => appendTurn("after-300"));
|
|
478
|
+
assert.equal(write.result, "000301");
|
|
479
|
+
assert.deepEqual(write.queries.filter(readsWholeConversation), []);
|
|
480
|
+
assert.ok(write.queries.length <= 12, `A write issues a constant number of queries, not one per stored row (${write.queries.length})`);
|
|
481
|
+
const read = await measure(() => cold.storage.read(conversation.id, transaction => transaction.readTurn("000301")));
|
|
482
|
+
assert.equal(read.result.user.text, "One more");
|
|
483
|
+
assert.deepEqual(read.result.metadata, { runtime: { status: "running" } });
|
|
484
|
+
assert.deepEqual(read.queries.filter(readsWholeConversation), []);
|
|
485
|
+
assert.ok(read.queries.length <= 5, `A read issues a constant number of queries (${read.queries.length})`);
|
|
486
|
+
|
|
487
|
+
await writeTurns(cold.storage, conversation.id, 50, "later");
|
|
488
|
+
const longer = await measure(() => appendTurn("after-350"));
|
|
489
|
+
assert.equal(longer.result, "000352");
|
|
490
|
+
assert.deepEqual(longer.queries, write.queries, "The same write issues the same queries however long the conversation is");
|
|
491
|
+
const answer = await measure(() => cold.storage.write(conversation.id, transaction =>
|
|
492
|
+
transaction.replaceAssistant("000301", { role: "assistant", messageId: "after-300:reply-1", text: "Answered", at: "2026-02-01T00:00:01.000Z" })));
|
|
493
|
+
assert.deepEqual(answer.queries.filter(readsWholeConversation), []);
|
|
494
|
+
|
|
495
|
+
const fresh = f.connect();
|
|
496
|
+
const expected = await fresh.storage.read(conversation.id, async transaction =>
|
|
497
|
+
Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id))));
|
|
498
|
+
const cached = await cold.storage.read(conversation.id, async transaction =>
|
|
499
|
+
Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id))));
|
|
500
|
+
assert.equal(expected.length, 352);
|
|
501
|
+
assert.deepEqual(cached, expected, "Cached rows project exactly as a fresh read of the database");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("a server sees another server's saves, direct row inserts and conversation updates at once", async t => {
|
|
505
|
+
const f = await fixture(t);
|
|
506
|
+
const conversation = await f.conversation();
|
|
507
|
+
const other = f.connect();
|
|
508
|
+
await writeTurns(f.storage, conversation.id, 2);
|
|
509
|
+
assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002"]);
|
|
510
|
+
await writeTurns(other.storage, conversation.id, 1, "other");
|
|
511
|
+
assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002", "000003"]);
|
|
512
|
+
await other.storage.write(conversation.id, transaction => transaction.updateTurnMetadata("000001", { product: { edited: true } }));
|
|
513
|
+
assert.deepEqual((await f.storage.read(conversation.id, transaction => transaction.readTurn("000001"))).metadata,
|
|
514
|
+
{ runtime: { status: "complete" }, product: { edited: true } });
|
|
515
|
+
assert.equal(await f.storage.write(conversation.id, transaction => transaction.nextTurnId()), "000004");
|
|
516
|
+
|
|
517
|
+
await f.messagesRepository.create({ conversationId: conversation.id, actorUserId: "1", role: "user", kind: "chat",
|
|
518
|
+
clientMessageSid: "imported", contentText: "Imported question",
|
|
519
|
+
metadata: { surfaceId: "assistant", conversationRuntime: { turnId: "000009" } } });
|
|
520
|
+
assert.equal((await f.storage.read(conversation.id, transaction => transaction.readTurn("000009"))).user.text, "Imported question");
|
|
521
|
+
|
|
522
|
+
await f.conversationsRepository.updateById(conversation.id, { metadata: { runtime: { external: true } } });
|
|
523
|
+
assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.readMetadata()), { runtime: { external: true } });
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("a save that fails to commit leaves the cached rows on what the database holds", async t => {
|
|
527
|
+
const f = await fixture(t);
|
|
528
|
+
const conversation = await f.conversation();
|
|
529
|
+
let fail = false;
|
|
530
|
+
const storage = createSqlConversationStorage({ conversationsRepository: f.conversationsRepository,
|
|
531
|
+
messagesRepository: { ...f.messagesRepository, async create(input, options) {
|
|
532
|
+
const result = await f.messagesRepository.create(input, options);
|
|
533
|
+
if (fail) throw new Error("forced-row-commit-failure");
|
|
534
|
+
return result;
|
|
535
|
+
} } });
|
|
536
|
+
await writeTurns(storage, conversation.id, 2);
|
|
537
|
+
assert.equal((await storage.read(conversation.id, transaction => transaction.listTurnIds())).length, 2);
|
|
538
|
+
fail = true;
|
|
539
|
+
await assert.rejects(writeTurns(storage, conversation.id, 1, "failed"), /forced-row-commit-failure/);
|
|
540
|
+
fail = false;
|
|
541
|
+
assert.deepEqual(await storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002"]);
|
|
542
|
+
await writeTurns(storage, conversation.id, 1, "next");
|
|
543
|
+
const saved = await f.messagesRepository.listByConversationScope(conversation.id);
|
|
544
|
+
assert.deepEqual(saved.map(row => row.seq), [1, 2, 3, 4, 5, 6]);
|
|
545
|
+
assert.equal(saved.at(-2).clientMessageSid, "next-0");
|
|
546
|
+
assert.equal((await f.conversationsRepository.findById(conversation.id)).messageCount, 6);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("the summary and the last turn it covers are saved on the conversation row, and a new server reads only the turns after it", async t => {
|
|
550
|
+
const f = await fixture(t);
|
|
551
|
+
const conversation = await f.conversation();
|
|
552
|
+
await writeTurns(f.storage, conversation.id, 4);
|
|
553
|
+
const before = await rows(f.db);
|
|
554
|
+
await f.storage.write(conversation.id, transaction => transaction.writeSummary({ text: "Turns one to three.", throughTurnId: "000003" }));
|
|
555
|
+
const saved = await f.db("assistant_conversations").where({ id: conversation.id }).first();
|
|
556
|
+
assert.equal(saved.summary_text, "Turns one to three.");
|
|
557
|
+
assert.equal(saved.summary_through_turn_id, "000003");
|
|
558
|
+
assert.notEqual(saved.transcript_revision, before.conversations[0].transcript_revision);
|
|
559
|
+
assert.deepEqual((await rows(f.db)).messages, before.messages, "Compaction never deletes or changes messages");
|
|
560
|
+
assert.doesNotMatch(saved.metadata_json || "", /Turns one to three/u);
|
|
561
|
+
assert.equal(Object.hasOwn(await f.conversationsRepository.findById(conversation.id), "summary"), false,
|
|
562
|
+
"Conversation records sent to the browser do not carry the summary");
|
|
563
|
+
|
|
564
|
+
const restarted = f.connect();
|
|
565
|
+
const later = await restarted.storage.read(conversation.id, async transaction => {
|
|
566
|
+
const summary = await transaction.readSummary();
|
|
567
|
+
return { summary, turns: await transaction.readTurnsAfter(summary.throughTurnId) };
|
|
568
|
+
});
|
|
569
|
+
assert.deepEqual(later.summary, { text: "Turns one to three.", throughTurnId: "000003" });
|
|
570
|
+
assert.deepEqual(later.turns.map(turn => [turn.turnId, turn.user.text, turn.assistant.text]), [["000004", "Question 3", "Answer 3"]]);
|
|
571
|
+
|
|
572
|
+
await restarted.storage.write(conversation.id, transaction => transaction.writeSummary({ text: "Turns one to four.", throughTurnId: "000004" }));
|
|
573
|
+
assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.readSummary()),
|
|
574
|
+
{ text: "Turns one to four.", throughTurnId: "000004" }, "Another server's summary is read at once");
|
|
575
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "assistant", messages = [] } = {}) {
|
|
5
5
|
let state = { conversation: { id, workspaceId: null, createdByUserId: "1", surfaceId,
|
|
6
|
-
title: "New conversation", status: "active", messageCount: 0, metadata: {} }, messages: [] };
|
|
6
|
+
title: "New conversation", status: "active", messageCount: 0, metadata: {} }, transcript: { revision: "", summary: null }, messages: [] };
|
|
7
7
|
let pending = Promise.resolve();
|
|
8
8
|
const read = options => options?.trx || state;
|
|
9
9
|
const conversationsRepository = {
|
|
@@ -18,6 +18,10 @@ export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "
|
|
|
18
18
|
Object.assign(target.conversation, structuredClone(patch));
|
|
19
19
|
return structuredClone(target.conversation);
|
|
20
20
|
},
|
|
21
|
+
async readTranscriptState(value, options) { return value === id ? structuredClone(read(options).transcript) : null; },
|
|
22
|
+
async writeTranscriptState(value, transcript, options) {
|
|
23
|
+
if (value === id) read(options).transcript = structuredClone(transcript);
|
|
24
|
+
},
|
|
21
25
|
transaction(operation) {
|
|
22
26
|
const next = pending.then(async () => {
|
|
23
27
|
const trx = structuredClone(state);
|
|
@@ -34,6 +38,7 @@ export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "
|
|
|
34
38
|
async listByConversationScope(_id, _scope, { page, pageSize }, options) {
|
|
35
39
|
return structuredClone(read(options).messages.slice((page - 1) * pageSize, page * pageSize));
|
|
36
40
|
},
|
|
41
|
+
async lastSequence(_id, options) { return read(options).messages.length; },
|
|
37
42
|
async create(payload, options) {
|
|
38
43
|
const rows = read(options).messages;
|
|
39
44
|
const row = { ...structuredClone(payload), id: String(rows.length + 1), seq: rows.length + 1,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createAiConnectionClient } from "@jskit-ai/assistant-core/server";
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
|
|
5
|
+
/** An AI SDK model on an OpenAI-compatible endpoint that answers from a script. A streamed
|
|
6
|
+
* request (a reply step) gets `stream(body)`: OpenAI stream chunks (an async iterable), passed on
|
|
7
|
+
* as they come. The engine's other calls (checks, accounts, summaries) get `once(body)` as one
|
|
8
|
+
* message; by default an empty one, which a check reads as nothing to report.
|
|
9
|
+
*/
|
|
10
|
+
export function createScriptedModel(stream, { once = () => "", model = "test-model" } = {}) {
|
|
11
|
+
const fetch = async (_url, request) => {
|
|
12
|
+
const body = JSON.parse(request.body);
|
|
13
|
+
if (!body.stream) {
|
|
14
|
+
return Response.json({ id: "scripted", object: "chat.completion", created: 0, model,
|
|
15
|
+
choices: [{ index: 0, message: { role: "assistant", content: await once(body) }, finish_reason: "stop" }],
|
|
16
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
|
|
17
|
+
}
|
|
18
|
+
const chunks = (await stream(body))[Symbol.asyncIterator]();
|
|
19
|
+
const frame = chunk => ({ id: "scripted", object: "chat.completion.chunk", created: 0, model, ...chunk });
|
|
20
|
+
return new Response(new ReadableStream({
|
|
21
|
+
async pull(controller) {
|
|
22
|
+
const { value, done } = await chunks.next();
|
|
23
|
+
if (done) {
|
|
24
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
25
|
+
controller.close();
|
|
26
|
+
} else controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame(value))}\n\n`));
|
|
27
|
+
}
|
|
28
|
+
}), { headers: { "content-type": "text/event-stream" } });
|
|
29
|
+
};
|
|
30
|
+
return createAiConnectionClient({ providerId: "test", model, sdkPackage: "@ai-sdk/openai-compatible",
|
|
31
|
+
apiKey: "test", baseURL: "http://test.invalid/v1" }, { fetch }).model;
|
|
32
|
+
}
|
|
@@ -9,6 +9,7 @@ import { createRepository } from "../src/server/repositories/turnRequestsReposit
|
|
|
9
9
|
import { createChatService } from "../src/server/services/chatService.js";
|
|
10
10
|
import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
|
|
11
11
|
import { createMemoryAssistantRows } from "./support/memoryAssistantRows.js";
|
|
12
|
+
import { createScriptedModel } from "./support/scriptedModel.js";
|
|
12
13
|
|
|
13
14
|
async function database(t) {
|
|
14
15
|
const dir = await mkdtemp(path.join(tmpdir(), "assistant-requests-"));
|
|
@@ -27,7 +28,7 @@ async function database(t) {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const scope = { actorUserId: "1", surfaceId: "assistant", workspaceId: null };
|
|
30
|
-
const request = { messageId: "message-1", input: "Do this once."
|
|
31
|
+
const request = { messageId: "message-1", input: "Do this once." };
|
|
31
32
|
|
|
32
33
|
test("migration preserves existing claims and database uniqueness arbitrates independent connections", async t => {
|
|
33
34
|
const { db, connect } = await database(t);
|
|
@@ -49,7 +50,8 @@ test("migration preserves existing claims and database uniqueness arbitrates ind
|
|
|
49
50
|
function service(repository, { provider, transcript = [] } = {}) {
|
|
50
51
|
const dependencies = {
|
|
51
52
|
turnRequests: repository,
|
|
52
|
-
aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
|
|
53
|
+
aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
|
|
54
|
+
model: provider && createScriptedModel(body => provider({ messages: body.messages }), { model: "test" }) }) },
|
|
53
55
|
transcriptService: {
|
|
54
56
|
async createConversationForTurn() { return { conversation: { id: "100" } }; },
|
|
55
57
|
async completeConversation() {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knex from "knex";
|
|
4
|
+
import configMigration from "../migrations/assistant_config_initial.cjs";
|
|
5
|
+
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
6
|
+
import turnRequestsMigration from "../migrations/assistant_turn_requests.cjs";
|
|
7
|
+
import usersForeignKeysMigration from "../migrations/constraints/assistant_users_foreign_keys.cjs";
|
|
8
|
+
|
|
9
|
+
const USER_KEYS = [
|
|
10
|
+
{ table: "assistant_conversations", from: "created_by_user_id", on_delete: "SET NULL" },
|
|
11
|
+
{ table: "assistant_messages", from: "actor_user_id", on_delete: "SET NULL" },
|
|
12
|
+
{ table: "assistant_turn_requests", from: "actor_user_id", on_delete: "CASCADE" }
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
async function usersKeys(db) {
|
|
16
|
+
const keys = [];
|
|
17
|
+
for (const { table } of USER_KEYS) {
|
|
18
|
+
for (const key of await db.raw("PRAGMA foreign_key_list(??)", [table])) {
|
|
19
|
+
if (key.table === "users") keys.push({ table, from: key.from, on_delete: key.on_delete });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return keys;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("the assistant tables are created before users exists and get their users keys in the constraints phase", async (t) => {
|
|
26
|
+
const db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
27
|
+
t.after(() => db.destroy());
|
|
28
|
+
|
|
29
|
+
// Package table migrations run before the application's migrations, which may create users.
|
|
30
|
+
await configMigration.up(db);
|
|
31
|
+
await transcriptsMigration.up(db);
|
|
32
|
+
await turnRequestsMigration.up(db);
|
|
33
|
+
assert.deepEqual(await usersKeys(db), []);
|
|
34
|
+
|
|
35
|
+
await assert.rejects(usersForeignKeysMigration.up(db), /the users table does not exist/u);
|
|
36
|
+
await db.schema.createTable("users", (table) => table.bigInteger("id").primary());
|
|
37
|
+
await usersForeignKeysMigration.up(db);
|
|
38
|
+
assert.deepEqual(await usersKeys(db), USER_KEYS);
|
|
39
|
+
|
|
40
|
+
// Tables that already have their users keys, as databases migrated before this phase do, gain no duplicate.
|
|
41
|
+
await usersForeignKeysMigration.up(db);
|
|
42
|
+
assert.deepEqual(await usersKeys(db), USER_KEYS);
|
|
43
|
+
|
|
44
|
+
await usersForeignKeysMigration.down(db);
|
|
45
|
+
assert.deepEqual(await usersKeys(db), []);
|
|
46
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knex from "knex";
|
|
4
|
+
import configMigration from "../migrations/assistant_config_initial.cjs";
|
|
5
|
+
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
6
|
+
import workspacesForeignKeysMigration from "../migrations/constraints/assistant_workspaces_foreign_keys.cjs";
|
|
7
|
+
|
|
8
|
+
const TABLES = ["assistant_config", "assistant_conversations", "assistant_messages"];
|
|
9
|
+
|
|
10
|
+
async function workspaceKeys(db) {
|
|
11
|
+
const keys = [];
|
|
12
|
+
for (const table of TABLES) {
|
|
13
|
+
for (const key of await db.raw("PRAGMA foreign_key_list(??)", [table])) {
|
|
14
|
+
if (key.table === "workspaces") keys.push({ table, from: key.from, on_delete: key.on_delete });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return keys;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function freshTables(t) {
|
|
21
|
+
const db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
|
|
22
|
+
t.after(() => db.destroy());
|
|
23
|
+
// Package table migrations run before workspaces-core's, so no workspaces table exists yet.
|
|
24
|
+
await configMigration.up(db);
|
|
25
|
+
await transcriptsMigration.up(db);
|
|
26
|
+
assert.deepEqual(await workspaceKeys(db), []);
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("the assistant tables get their workspaces keys in the constraints phase, once", async (t) => {
|
|
31
|
+
const db = await freshTables(t);
|
|
32
|
+
await db.schema.createTable("workspaces", (table) => table.bigInteger("id").primary());
|
|
33
|
+
await workspacesForeignKeysMigration.up(db);
|
|
34
|
+
const keys = TABLES.map((table) => ({ table, from: "workspace_id", on_delete: "CASCADE" }));
|
|
35
|
+
assert.deepEqual(await workspaceKeys(db), keys);
|
|
36
|
+
|
|
37
|
+
// Tables that already have their workspaces keys, as databases migrated before this phase do, are unchanged.
|
|
38
|
+
await workspacesForeignKeysMigration.up(db);
|
|
39
|
+
assert.deepEqual(await workspaceKeys(db), keys);
|
|
40
|
+
|
|
41
|
+
await workspacesForeignKeysMigration.down(db);
|
|
42
|
+
assert.deepEqual(await workspaceKeys(db), []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an application without a workspaces table gets no workspaces keys", async (t) => {
|
|
46
|
+
const db = await freshTables(t);
|
|
47
|
+
await workspacesForeignKeysMigration.up(db);
|
|
48
|
+
assert.deepEqual(await workspaceKeys(db), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rows whose workspace is gone stop the migration with their table, column and count, and stay", async (t) => {
|
|
52
|
+
const db = await freshTables(t);
|
|
53
|
+
await db.schema.createTable("workspaces", (table) => table.bigInteger("id").primary());
|
|
54
|
+
await db("workspaces").insert({ id: 1 });
|
|
55
|
+
await db("assistant_config").insert([
|
|
56
|
+
{ target_surface_id: "app", scope_key: "workspace:1", workspace_id: 1 },
|
|
57
|
+
{ target_surface_id: "app", scope_key: "workspace:7", workspace_id: 7 },
|
|
58
|
+
{ target_surface_id: "app", scope_key: "workspace:8", workspace_id: 8 }
|
|
59
|
+
]);
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
workspacesForeignKeysMigration.up(db),
|
|
62
|
+
/assistant_config\.workspace_id foreign key to workspaces\.id: 2 assistant_config row\(s\) have a workspace_id with no workspaces row/u
|
|
63
|
+
);
|
|
64
|
+
assert.equal((await db("assistant_config")).length, 3);
|
|
65
|
+
assert.deepEqual(await workspaceKeys(db), []);
|
|
66
|
+
});
|