@jskit-ai/assistant-runtime 0.2.28 → 0.2.30
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/CHANGELOG.md +7 -0
- package/README.md +6 -3
- package/package.json +16 -10
- package/patterns/assistant-surface/PATTERN.md +5 -2
- package/src/client/components/AssistantSettingsClientElement.vue +5 -3
- package/src/server/AssistantProvider.js +8 -13
- package/src/server/AssistantStoreFeature.js +59 -0
- package/src/server/actions.js +5 -5
- package/src/server/createAssistantRuntime.js +2 -1
- package/src/server/createAssistantSettingsActions.js +17 -9
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +5 -2
- package/src/server/registerRoutes.js +11 -8
- package/src/server/registerSettingsRoutes.js +4 -4
- package/src/server/repositories/assistantConfigRepository.js +52 -67
- package/src/server/repositories/assistantResources.js +110 -0
- package/src/server/repositories/conversationsRepository.js +164 -147
- package/src/server/repositories/messagesRepository.js +136 -87
- package/src/server/repositories/repositoryPersistenceUtils.js +11 -22
- package/src/server/repositories/turnRequestsRepository.js +50 -16
- package/src/server/services/assistantConfigService.js +22 -0
- package/src/server/services/chatService.js +15 -7
- package/src/server/services/transcriptService.js +3 -3
- package/src/server/sqlConversationStorage.js +23 -19
- package/src/server/types.js +1 -0
- package/test/assistantIntegrationBoundary.test.js +2 -0
- package/test/assistantSettings.browser.test.js +23 -2
- package/test/assistantSettingsAccess.test.js +46 -1
- package/test/chatServiceLifecycle.test.js +32 -1
- package/test/featureRuntime.test.js +17 -3
- package/test/repositories.mysql.test.js +474 -0
- package/test/repositoryContracts.test.js +13 -13
- package/test/sqlConversationStorage.mysql.test.js +9 -6
- package/test/sqlConversationStorage.test.js +27 -20
- package/test/support/assistantStore.js +39 -0
- package/test/support/memoryAssistantRows.js +11 -4
- package/test/turnRequests.test.js +37 -14
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, test } from "node:test";
|
|
3
|
+
import knexLib from "knex";
|
|
4
|
+
import { requireTestDatabase } from "@jskit-ai/database-runtime/server/testDatabases";
|
|
5
|
+
import { ActionProvider } from "@jskit-ai/kernel/server/actions";
|
|
6
|
+
import { EventProvider } from "@jskit-ai/kernel/server/runtime";
|
|
7
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
8
|
+
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
9
|
+
import configMigration from "../migrations/assistant_config_initial.cjs";
|
|
10
|
+
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
11
|
+
import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
|
|
12
|
+
import { ResourceRuntimeProvider } from "@jskit-ai/resource-runtime/server";
|
|
13
|
+
import { AssistantFeature } from "../src/server/AssistantProvider.js";
|
|
14
|
+
import { AssistantStoreFeature } from "../src/server/AssistantStoreFeature.js";
|
|
15
|
+
import { createTestAssistantStore } from "./support/assistantStore.js";
|
|
16
|
+
|
|
17
|
+
// What the assistant's database repositories, and the feature that builds them on
|
|
18
|
+
// runtime.database, answer and keep today on MySQL, pinned before their tables move onto
|
|
19
|
+
// resources: record shapes and defaults, what each normalizes, scoping by surface, workspace and
|
|
20
|
+
// actor, ordering and pagination, transactions, and the errors they raise. The connection uses
|
|
21
|
+
// the options database-runtime gives mysql2 (big numbers as strings, UTC).
|
|
22
|
+
let knex = null;
|
|
23
|
+
let admin = null;
|
|
24
|
+
let store = null;
|
|
25
|
+
let databaseName = "";
|
|
26
|
+
|
|
27
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/u;
|
|
28
|
+
const ID = /^[1-9]\d*$/u;
|
|
29
|
+
|
|
30
|
+
before(async () => {
|
|
31
|
+
databaseName = `${requireTestDatabase(process.env)}_da_assistant`;
|
|
32
|
+
const server = { host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 3306, user: process.env.DB_USER, password: process.env.DB_PASSWORD };
|
|
33
|
+
admin = knexLib({ client: "mysql2", connection: server, pool: { min: 0, max: 1 } });
|
|
34
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [databaseName]);
|
|
35
|
+
await admin.raw("CREATE DATABASE ??", [databaseName]);
|
|
36
|
+
knex = knexLib({
|
|
37
|
+
client: "mysql2",
|
|
38
|
+
connection: { ...server, database: databaseName, supportBigNumbers: true, bigNumberStrings: true, dateStrings: ["DATE"], timezone: "Z" },
|
|
39
|
+
pool: { min: 0, max: 4 }
|
|
40
|
+
});
|
|
41
|
+
await configMigration.up(knex);
|
|
42
|
+
await transcriptsMigration.up(knex);
|
|
43
|
+
await summaryMigration.up(knex);
|
|
44
|
+
store = await createTestAssistantStore(knex);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
after(async () => {
|
|
48
|
+
await store?.close();
|
|
49
|
+
await knex?.destroy();
|
|
50
|
+
if (admin) {
|
|
51
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [databaseName]);
|
|
52
|
+
await admin.destroy();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the config repository keeps one record per surface and scope", async () => {
|
|
57
|
+
requireTestDatabase(process.env);
|
|
58
|
+
const repository = store.config;
|
|
59
|
+
assert.deepEqual(Object.keys(repository).sort(), ["createDefaultRecord", "findByScope", "upsertByScope", "withTransaction"]);
|
|
60
|
+
assert.deepEqual(repository.createDefaultRecord({ targetSurfaceId: " Home " }),
|
|
61
|
+
{ targetSurfaceId: "home", scopeKey: "home:global", workspaceId: null, settings: { systemPrompt: "" } });
|
|
62
|
+
assert.deepEqual(repository.createDefaultRecord({ targetSurfaceId: "home", workspaceId: 7 }),
|
|
63
|
+
{ targetSurfaceId: "home", scopeKey: "home:workspace:7", workspaceId: "7", settings: { systemPrompt: "" } });
|
|
64
|
+
assert.throws(() => repository.createDefaultRecord({}), { name: "TypeError", message: "assistantConfigRepository requires targetSurfaceId." });
|
|
65
|
+
await assert.rejects(repository.findByScope({ targetSurfaceId: " " }), { name: "TypeError", message: "assistantConfigRepository requires targetSurfaceId." });
|
|
66
|
+
await assert.rejects(repository.upsertByScope({ patch: { systemPrompt: "x" } }), { name: "TypeError" });
|
|
67
|
+
|
|
68
|
+
assert.equal(await repository.findByScope({ targetSurfaceId: "home" }), null, "nothing saved: null, not a default");
|
|
69
|
+
assert.deepEqual(await repository.upsertByScope({ targetSurfaceId: "HOME", patch: { systemPrompt: "Global prompt" } }),
|
|
70
|
+
{ targetSurfaceId: "home", scopeKey: "home:global", workspaceId: null, settings: { systemPrompt: "Global prompt" } });
|
|
71
|
+
assert.deepEqual(await repository.upsertByScope({ targetSurfaceId: "home", workspaceId: "7", patch: { systemPrompt: "Workspace 7" } }),
|
|
72
|
+
{ targetSurfaceId: "home", scopeKey: "home:workspace:7", workspaceId: "7", settings: { systemPrompt: "Workspace 7" } });
|
|
73
|
+
assert.deepEqual(await repository.findByScope({ targetSurfaceId: "home" }),
|
|
74
|
+
{ targetSurfaceId: "home", scopeKey: "home:global", workspaceId: null, settings: { systemPrompt: "Global prompt" } });
|
|
75
|
+
assert.deepEqual(await repository.findByScope({ targetSurfaceId: "home", workspaceId: 7 }),
|
|
76
|
+
{ targetSurfaceId: "home", scopeKey: "home:workspace:7", workspaceId: "7", settings: { systemPrompt: "Workspace 7" } });
|
|
77
|
+
assert.equal(await repository.findByScope({ targetSurfaceId: "home", workspaceId: 8 }), null, "another workspace has its own");
|
|
78
|
+
assert.equal(await repository.findByScope({ targetSurfaceId: "console" }), null, "and another surface");
|
|
79
|
+
|
|
80
|
+
assert.equal((await repository.upsertByScope({ targetSurfaceId: "home", patch: {} })).settings.systemPrompt, "Global prompt",
|
|
81
|
+
"a patch without systemPrompt keeps the saved one");
|
|
82
|
+
assert.equal((await repository.upsertByScope({ targetSurfaceId: "home", workspaceId: 7, patch: { systemPrompt: null } })).settings.systemPrompt, "",
|
|
83
|
+
"a null systemPrompt clears it");
|
|
84
|
+
assert.deepEqual(await repository.upsertByScope({ targetSurfaceId: "console", patch: {} }),
|
|
85
|
+
{ targetSurfaceId: "console", scopeKey: "console:global", workspaceId: null, settings: { systemPrompt: "" } });
|
|
86
|
+
assert.deepEqual(await repository.findByScope({ targetSurfaceId: "console" }),
|
|
87
|
+
{ targetSurfaceId: "console", scopeKey: "console:global", workspaceId: null, settings: { systemPrompt: "" } }, "an empty first save is a record");
|
|
88
|
+
assert.equal((await knex("assistant_config").where({ target_surface_id: "home" })).length, 2, "saving again updates, one row per scope");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("the config repository joins a caller's transaction and rolls back with it", async () => {
|
|
92
|
+
requireTestDatabase(process.env);
|
|
93
|
+
const repository = store.config;
|
|
94
|
+
await assert.rejects(repository.withTransaction({}, async (txOptions) => {
|
|
95
|
+
await repository.upsertByScope({ targetSurfaceId: "home", workspaceId: 9, patch: { systemPrompt: "Uncommitted" } }, txOptions);
|
|
96
|
+
assert.equal((await repository.findByScope({ targetSurfaceId: "home", workspaceId: 9 }, txOptions)).settings.systemPrompt, "Uncommitted",
|
|
97
|
+
"visible inside the transaction");
|
|
98
|
+
assert.equal(await repository.findByScope({ targetSurfaceId: "home", workspaceId: 9 }), null, "not outside it");
|
|
99
|
+
throw new Error("roll back");
|
|
100
|
+
}), /roll back/u);
|
|
101
|
+
assert.equal(await repository.findByScope({ targetSurfaceId: "home", workspaceId: 9 }), null);
|
|
102
|
+
assert.equal(await repository.withTransaction({}, async () => "answer"), "answer", "withTransaction answers the work's result");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a conversation record has a fixed shape, defaults and normalizations", async () => {
|
|
106
|
+
requireTestDatabase(process.env);
|
|
107
|
+
const conversations = store.conversations;
|
|
108
|
+
assert.deepEqual(Object.keys(conversations).sort(), ["create", "findById", "findByIdForActorScope", "findWithTranscriptState",
|
|
109
|
+
"listForActorScope", "transaction", "updateById", "withTransaction", "writeTranscriptState"]);
|
|
110
|
+
await assert.rejects(conversations.create({ createdByUserId: 5 }), { name: "TypeError", message: "conversationsRepository requires surfaceId." });
|
|
111
|
+
|
|
112
|
+
const plain = await conversations.create({ surfaceId: " Home ", createdByUserId: 5 });
|
|
113
|
+
assert.deepEqual(Object.keys(plain).sort(), ["createdAt", "createdByUserId", "endedAt", "id", "messageCount", "metadata", "model",
|
|
114
|
+
"provider", "startedAt", "status", "surfaceId", "title", "updatedAt", "workspaceId"]);
|
|
115
|
+
assert.match(plain.id, ID, "ids are strings");
|
|
116
|
+
assert.equal(plain.workspaceId, null);
|
|
117
|
+
assert.equal(plain.createdByUserId, "5");
|
|
118
|
+
assert.equal(plain.title, "New conversation");
|
|
119
|
+
assert.equal(plain.status, "active");
|
|
120
|
+
assert.equal(plain.provider, "");
|
|
121
|
+
assert.equal(plain.model, "");
|
|
122
|
+
assert.equal(plain.surfaceId, "home");
|
|
123
|
+
assert.equal(plain.messageCount, 0);
|
|
124
|
+
assert.deepEqual(plain.metadata, {});
|
|
125
|
+
assert.equal(plain.endedAt, null);
|
|
126
|
+
for (const key of ["startedAt", "createdAt", "updatedAt"]) assert.match(plain[key], ISO, `${key}: ISO text, whole seconds`);
|
|
127
|
+
assert.equal(plain.createdAt, plain.updatedAt);
|
|
128
|
+
|
|
129
|
+
const full = await conversations.create({
|
|
130
|
+
workspaceId: "3", createdByUserId: "5", title: " Trip ", status: " ARCHIVED ", provider: " openai ", model: " a-model ",
|
|
131
|
+
surfaceId: "home", messageCount: -4, metadata: { topic: "trip", nested: { days: 3 } },
|
|
132
|
+
startedAt: "2026-01-02T03:04:05.400Z", endedAt: "2026-01-02T04:00:00.000Z"
|
|
133
|
+
});
|
|
134
|
+
assert.deepEqual({ ...full, id: "", createdAt: "", updatedAt: "" }, {
|
|
135
|
+
id: "", workspaceId: "3", title: "Trip", createdByUserId: "5", status: "archived", provider: "openai", model: "a-model", surfaceId: "home",
|
|
136
|
+
startedAt: "2026-01-02T03:04:05.000Z", endedAt: "2026-01-02T04:00:00.000Z", messageCount: 0,
|
|
137
|
+
metadata: { topic: "trip", nested: { days: 3 } }, createdAt: "", updatedAt: ""
|
|
138
|
+
}, "text trimmed, status lowercased, a negative count is 0, sub-second time dropped");
|
|
139
|
+
const odd = await conversations.create({ surfaceId: "home", createdByUserId: "x", workspaceId: 0, metadata: ["not", "an", "object"], title: " " });
|
|
140
|
+
assert.equal(odd.createdByUserId, null, "an invalid creator id is stored as null");
|
|
141
|
+
assert.equal(odd.workspaceId, null);
|
|
142
|
+
assert.deepEqual(odd.metadata, {}, "metadata that is not an object is dropped");
|
|
143
|
+
assert.equal(odd.title, "New conversation");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("conversations are found by id, and by actor scope only in their own surface, workspace and creator", async () => {
|
|
147
|
+
requireTestDatabase(process.env);
|
|
148
|
+
const conversations = store.conversations;
|
|
149
|
+
const global = await conversations.create({ surfaceId: "home", createdByUserId: 21 });
|
|
150
|
+
const inWorkspace = await conversations.create({ surfaceId: "home", createdByUserId: 21, workspaceId: 4 });
|
|
151
|
+
assert.equal(await conversations.findById("abc"), null);
|
|
152
|
+
assert.equal(await conversations.findById(0), null);
|
|
153
|
+
assert.equal(await conversations.findById("999999999"), null);
|
|
154
|
+
assert.deepEqual(await conversations.findById(Number(global.id)), global, "a number id finds it too");
|
|
155
|
+
await assert.rejects(conversations.findById(global.id, { forUpdate: true }),
|
|
156
|
+
{ name: "TypeError", message: "Locking a conversation requires its database transaction." });
|
|
157
|
+
assert.deepEqual(await conversations.transaction((txOptions) => conversations.findById(global.id, { ...txOptions, forUpdate: true })), global,
|
|
158
|
+
"locking inside a transaction answers the same record and changes nothing");
|
|
159
|
+
|
|
160
|
+
const scope = { actorUserId: 21, surfaceId: "home" };
|
|
161
|
+
assert.deepEqual(await conversations.findByIdForActorScope(global.id, scope), global);
|
|
162
|
+
assert.equal(await conversations.findByIdForActorScope(global.id, { ...scope, actorUserId: 22 }), null, "another actor");
|
|
163
|
+
assert.equal(await conversations.findByIdForActorScope(global.id, { ...scope, surfaceId: "console" }), null, "another surface");
|
|
164
|
+
assert.equal(await conversations.findByIdForActorScope(global.id, { ...scope, workspaceId: 4 }), null, "a workspace scope misses a global one");
|
|
165
|
+
assert.equal(await conversations.findByIdForActorScope(inWorkspace.id, scope), null, "a global scope misses a workspace one");
|
|
166
|
+
assert.deepEqual(await conversations.findByIdForActorScope(inWorkspace.id, { ...scope, workspaceId: "4" }), inWorkspace);
|
|
167
|
+
assert.equal(await conversations.findByIdForActorScope(inWorkspace.id, { ...scope, workspaceId: 5 }), null, "another workspace");
|
|
168
|
+
assert.equal(await conversations.findByIdForActorScope(global.id, { surfaceId: "home" }), null, "no actor: null");
|
|
169
|
+
assert.equal(await conversations.findByIdForActorScope(global.id, { actorUserId: 21 }), null, "no surface: null");
|
|
170
|
+
assert.deepEqual(await conversations.transaction((txOptions) => conversations.findByIdForActorScope(global.id, scope, { ...txOptions, forUpdate: true })), global);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("updateById changes only the fields a patch names, and never the conversation's owner or workspace", async () => {
|
|
174
|
+
requireTestDatabase(process.env);
|
|
175
|
+
const conversations = store.conversations;
|
|
176
|
+
const made = await conversations.create({ surfaceId: "home", createdByUserId: 31, workspaceId: 6, title: "Start", metadata: { a: 1 } });
|
|
177
|
+
await knex("assistant_conversations").where({ id: made.id }).update({ updated_at: new Date(Date.UTC(2026, 0, 1)) });
|
|
178
|
+
const untouched = await conversations.updateById(made.id, {});
|
|
179
|
+
assert.equal(untouched.updatedAt, "2026-01-01T00:00:00.000Z", "an empty patch writes nothing");
|
|
180
|
+
assert.equal(await conversations.updateById("nope", { title: "x" }), null);
|
|
181
|
+
assert.equal(await conversations.updateById("999999999", { title: "x" }), null, "an unknown id answers null");
|
|
182
|
+
await assert.rejects(conversations.updateById(made.id, { surfaceId: "" }), { name: "TypeError", message: "conversationsRepository requires surfaceId." });
|
|
183
|
+
|
|
184
|
+
const changed = await conversations.updateById(made.id, {
|
|
185
|
+
title: "", status: " DONE ", provider: " p ", model: " m ", messageCount: "7", endedAt: "2026-03-01T00:00:00.000Z",
|
|
186
|
+
metadata: { b: 2 }, surfaceId: " Console ", workspaceId: 99, createdByUserId: 99, startedAt: "2020-01-01T00:00:00.000Z"
|
|
187
|
+
});
|
|
188
|
+
assert.equal(changed.title, "New conversation", "an empty title falls back to the default");
|
|
189
|
+
assert.equal(changed.status, "done");
|
|
190
|
+
assert.equal(changed.provider, "p");
|
|
191
|
+
assert.equal(changed.model, "m");
|
|
192
|
+
assert.equal(changed.messageCount, 7);
|
|
193
|
+
assert.equal(changed.endedAt, "2026-03-01T00:00:00.000Z");
|
|
194
|
+
assert.deepEqual(changed.metadata, { b: 2 }, "metadata is replaced, not merged");
|
|
195
|
+
assert.equal(changed.surfaceId, "console");
|
|
196
|
+
assert.equal(changed.workspaceId, "6", "workspaceId is not patchable");
|
|
197
|
+
assert.equal(changed.createdByUserId, "31", "nor the creator");
|
|
198
|
+
assert.equal(changed.startedAt, made.startedAt, "nor startedAt");
|
|
199
|
+
assert.notEqual(changed.updatedAt, "2026-01-01T00:00:00.000Z", "a real change moves updatedAt");
|
|
200
|
+
const cleared = await conversations.updateById(made.id, { endedAt: null, status: "", messageCount: -3, metadata: null });
|
|
201
|
+
assert.equal(cleared.endedAt, null);
|
|
202
|
+
assert.equal(cleared.status, "active", "an empty status falls back to active");
|
|
203
|
+
assert.equal(cleared.messageCount, 0);
|
|
204
|
+
assert.deepEqual(cleared.metadata, {});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("listForActorScope pages newest first by id, within one actor, surface and workspace", async () => {
|
|
208
|
+
requireTestDatabase(process.env);
|
|
209
|
+
const conversations = store.conversations;
|
|
210
|
+
const mine = [];
|
|
211
|
+
for (let index = 0; index < 3; index += 1) mine.push(await conversations.create({ surfaceId: "home", createdByUserId: 41, title: `Mine ${index}` }));
|
|
212
|
+
const archived = await conversations.create({ surfaceId: "home", createdByUserId: 41, status: "archived" });
|
|
213
|
+
await conversations.create({ surfaceId: "home", createdByUserId: 41, workspaceId: 2 });
|
|
214
|
+
await conversations.create({ surfaceId: "home", createdByUserId: 42 });
|
|
215
|
+
await conversations.create({ surfaceId: "console", createdByUserId: 41 });
|
|
216
|
+
|
|
217
|
+
const scope = { actorUserId: 41, surfaceId: "home" };
|
|
218
|
+
const all = await conversations.listForActorScope(scope);
|
|
219
|
+
assert.deepEqual(Object.keys(all).sort(), ["items", "nextCursor"]);
|
|
220
|
+
assert.deepEqual(all.items.map((item) => item.id), [archived.id, mine[2].id, mine[1].id, mine[0].id], "newest id first, global scope only");
|
|
221
|
+
assert.equal(all.nextCursor, null);
|
|
222
|
+
assert.deepEqual(all.items[1], mine[2], "items are whole conversation records");
|
|
223
|
+
|
|
224
|
+
const page = await conversations.listForActorScope({ ...scope, pagination: { limit: 2 } });
|
|
225
|
+
assert.deepEqual(page.items.map((item) => item.id), [archived.id, mine[2].id]);
|
|
226
|
+
assert.equal(page.nextCursor, mine[2].id, "the cursor is the last item's id, as a string");
|
|
227
|
+
const rest = await conversations.listForActorScope({ ...scope, pagination: { limit: 2, cursor: page.nextCursor } });
|
|
228
|
+
assert.deepEqual(rest.items.map((item) => item.id), [mine[1].id, mine[0].id]);
|
|
229
|
+
assert.equal(rest.nextCursor, null, "an exact last page has no cursor");
|
|
230
|
+
|
|
231
|
+
assert.deepEqual((await conversations.listForActorScope({ ...scope, filters: { status: " ARCHIVED " } })).items.map((item) => item.id), [archived.id]);
|
|
232
|
+
assert.deepEqual((await conversations.listForActorScope({ ...scope, workspaceId: 2 })).items.length, 1);
|
|
233
|
+
assert.deepEqual(await conversations.listForActorScope({ surfaceId: "home" }), { items: [], nextCursor: null });
|
|
234
|
+
assert.deepEqual(await conversations.listForActorScope({ actorUserId: 41 }), { items: [], nextCursor: null });
|
|
235
|
+
|
|
236
|
+
const now = new Date();
|
|
237
|
+
const many = Array.from({ length: 205 }, () => ({ created_by_user_id: 43, surface_id: "home", started_at: now, created_at: now, updated_at: now }));
|
|
238
|
+
await knex.batchInsert("assistant_conversations", many, 100);
|
|
239
|
+
const crowd = { actorUserId: 43, surfaceId: "home" };
|
|
240
|
+
assert.equal((await conversations.listForActorScope(crowd)).items.length, 20, "20 by default");
|
|
241
|
+
assert.equal((await conversations.listForActorScope({ ...crowd, pagination: { limit: 0 } })).items.length, 20, "and for a limit that is not positive");
|
|
242
|
+
const capped = await conversations.listForActorScope({ ...crowd, pagination: { limit: 1000 } });
|
|
243
|
+
assert.equal(capped.items.length, 200, "at most 200");
|
|
244
|
+
assert.match(capped.nextCursor, ID);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a conversation's transcript state is kept apart from its record, and written with the record's changes in one statement", async () => {
|
|
248
|
+
requireTestDatabase(process.env);
|
|
249
|
+
const conversations = store.conversations;
|
|
250
|
+
const made = await conversations.create({ surfaceId: "home", createdByUserId: 51 });
|
|
251
|
+
const transcriptOf = async (id, options) => {
|
|
252
|
+
const found = await conversations.findWithTranscriptState(id, options);
|
|
253
|
+
return found && { revision: found.revision, summary: found.summary };
|
|
254
|
+
};
|
|
255
|
+
assert.equal(await conversations.findWithTranscriptState("nope"), null);
|
|
256
|
+
assert.equal(await conversations.findWithTranscriptState("999999999"), null);
|
|
257
|
+
assert.deepEqual(await conversations.findWithTranscriptState(made.id), { conversation: made, revision: "", summary: null });
|
|
258
|
+
await conversations.writeTranscriptState(made.id, { revision: "r1", summary: { text: "The summary.", throughTurnId: "000003" } });
|
|
259
|
+
assert.deepEqual(await transcriptOf(made.id), { revision: "r1", summary: { text: "The summary.", throughTurnId: "000003" } });
|
|
260
|
+
assert.deepEqual(await conversations.findById(made.id), made, "the record does not show it");
|
|
261
|
+
await conversations.writeTranscriptState(made.id, { revision: "r2", summary: null });
|
|
262
|
+
assert.deepEqual(await transcriptOf(made.id), { revision: "r2", summary: null });
|
|
263
|
+
await conversations.writeTranscriptState(made.id, { revision: "r3", summary: { text: "", throughTurnId: "" } });
|
|
264
|
+
assert.deepEqual(await transcriptOf(made.id), { revision: "r3", summary: { text: "", throughTurnId: "" } }, "an empty summary is still a summary");
|
|
265
|
+
|
|
266
|
+
const statements = [];
|
|
267
|
+
const listener = (query) => statements.push(query.sql);
|
|
268
|
+
knex.on("query", listener);
|
|
269
|
+
try {
|
|
270
|
+
await conversations.writeTranscriptState(made.id, { revision: "r4", summary: null, changes: { messageCount: 2, title: " Renamed " } });
|
|
271
|
+
} finally {
|
|
272
|
+
knex.off("query", listener);
|
|
273
|
+
}
|
|
274
|
+
// One UPDATE writes both (on MySQL, json-rest-api's patch first locks the row it selects, inside its own transaction).
|
|
275
|
+
assert.equal(statements.filter((sql) => /^update /u.test(sql)).length, 1);
|
|
276
|
+
const locked = await conversations.withTransaction({}, (txOptions) => conversations.findWithTranscriptState(made.id, { ...txOptions, forUpdate: true }));
|
|
277
|
+
assert.deepEqual({ ...locked, conversation: { ...locked.conversation, updatedAt: "" } }, {
|
|
278
|
+
conversation: { ...made, title: "Renamed", messageCount: 2, updatedAt: "" }, revision: "r4", summary: null
|
|
279
|
+
});
|
|
280
|
+
assert.notEqual(locked.conversation.updatedAt, "", "the record's changes stamp updatedAt");
|
|
281
|
+
await assert.rejects(conversations.writeTranscriptState("nope", { revision: "r", summary: null }),
|
|
282
|
+
{ name: "TypeError", message: "writeTranscriptState requires conversationId." });
|
|
283
|
+
assert.equal(await conversations.writeTranscriptState("999999999", { revision: "r", summary: null }), undefined, "an unknown id is written silently");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a message record has a fixed shape, and seq counts up per conversation", async () => {
|
|
287
|
+
requireTestDatabase(process.env);
|
|
288
|
+
const conversations = store.conversations;
|
|
289
|
+
const messages = store.messages;
|
|
290
|
+
assert.deepEqual(Object.keys(messages).sort(), ["countByConversationScope", "create", "findById", "findNewestAssistantByUserId",
|
|
291
|
+
"lastSequence", "listByConversationScope", "transaction", "updateById", "withTransaction"]);
|
|
292
|
+
const first = await conversations.create({ surfaceId: "home", createdByUserId: 61 });
|
|
293
|
+
const second = await conversations.create({ surfaceId: "home", createdByUserId: 61 });
|
|
294
|
+
await assert.rejects(messages.create({ role: "user" }), { name: "TypeError", message: "messagesRepository.create requires conversationId." });
|
|
295
|
+
|
|
296
|
+
const plain = await messages.create({ conversationId: first.id, role: " USER " });
|
|
297
|
+
assert.deepEqual(Object.keys(plain).sort(), ["actorUserId", "clientMessageSid", "contentText", "conversationId", "createdAt", "id",
|
|
298
|
+
"kind", "metadata", "role", "seq", "workspaceId"]);
|
|
299
|
+
assert.deepEqual({ ...plain, id: "", createdAt: "" }, {
|
|
300
|
+
id: "", conversationId: first.id, workspaceId: null, seq: 1, role: "user", kind: "chat", clientMessageSid: "",
|
|
301
|
+
actorUserId: null, contentText: null, metadata: {}, createdAt: ""
|
|
302
|
+
});
|
|
303
|
+
assert.match(plain.id, ID);
|
|
304
|
+
assert.match(plain.createdAt, ISO);
|
|
305
|
+
|
|
306
|
+
const full = await messages.create({
|
|
307
|
+
conversationId: Number(first.id), workspaceId: 3, role: "Assistant", kind: " Tool ", clientMessageSid: " sid-1 ", actorUserId: 61,
|
|
308
|
+
contentText: 42, metadata: { tool: "search" }, createdAt: "2026-02-03T04:05:06.400Z"
|
|
309
|
+
});
|
|
310
|
+
assert.deepEqual({ ...full, id: "" }, {
|
|
311
|
+
id: "", conversationId: first.id, workspaceId: "3", seq: 2, role: "assistant", kind: "tool", clientMessageSid: "sid-1",
|
|
312
|
+
actorUserId: "61", contentText: "42", metadata: { tool: "search" }, createdAt: "2026-02-03T04:05:06.000Z"
|
|
313
|
+
});
|
|
314
|
+
assert.equal((await messages.create({ conversationId: first.id, role: "user", seq: 10 })).seq, 10, "an explicit seq is kept");
|
|
315
|
+
assert.equal((await messages.create({ conversationId: first.id, role: "user", seq: 0 })).seq, 11, "a seq that is not positive is the next one");
|
|
316
|
+
assert.equal((await messages.create({ conversationId: second.id, role: "user" })).seq, 1, "each conversation counts on its own");
|
|
317
|
+
assert.equal(await messages.lastSequence(first.id), 11);
|
|
318
|
+
assert.equal(await messages.lastSequence(second.id), 1);
|
|
319
|
+
assert.equal(await messages.lastSequence("999999999"), 0);
|
|
320
|
+
assert.equal(await messages.lastSequence("nope"), 0);
|
|
321
|
+
|
|
322
|
+
await assert.rejects(messages.create({ conversationId: first.id, role: "user", seq: 2 }), { code: "ER_DUP_ENTRY" }, "a taken seq is refused");
|
|
323
|
+
await assert.rejects(messages.create({ conversationId: "999999999", role: "user" }), { code: "ER_NO_REFERENCED_ROW_2" }, "the conversation must exist");
|
|
324
|
+
assert.equal(await messages.findById("nope"), null);
|
|
325
|
+
assert.equal(await messages.findById("999999999"), null);
|
|
326
|
+
assert.deepEqual(await messages.findById(full.id), full);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("messages are listed and counted in seq order within a conversation and workspace scope", async () => {
|
|
330
|
+
requireTestDatabase(process.env);
|
|
331
|
+
const conversations = store.conversations;
|
|
332
|
+
const messages = store.messages;
|
|
333
|
+
const conversation = await conversations.create({ surfaceId: "home", createdByUserId: 71 });
|
|
334
|
+
const other = await conversations.create({ surfaceId: "home", createdByUserId: 71 });
|
|
335
|
+
for (const seq of [3, 1, 2]) await messages.create({ conversationId: conversation.id, role: "user", seq, contentText: `seq ${seq}` });
|
|
336
|
+
await messages.create({ conversationId: conversation.id, role: "user", seq: 4, workspaceId: 8 });
|
|
337
|
+
await messages.create({ conversationId: other.id, role: "user" });
|
|
338
|
+
|
|
339
|
+
assert.deepEqual((await messages.listByConversationScope(conversation.id)).map((message) => message.seq), [1, 2, 3], "seq order; a null workspace scope sees null-workspace messages only");
|
|
340
|
+
assert.deepEqual((await messages.listByConversationScope(conversation.id, { workspaceId: 8 })).map((message) => message.seq), [4]);
|
|
341
|
+
assert.deepEqual(await messages.listByConversationScope(conversation.id, { workspaceId: 9 }), []);
|
|
342
|
+
assert.deepEqual((await messages.listByConversationScope(conversation.id, {}, { page: 2, pageSize: 2 })).map((message) => message.seq), [3]);
|
|
343
|
+
assert.deepEqual((await messages.listByConversationScope(conversation.id, {}, { page: 0, pageSize: 0 })).map((message) => message.seq), [1, 2, 3],
|
|
344
|
+
"a page or size that is not positive falls back to the defaults");
|
|
345
|
+
assert.deepEqual(await messages.listByConversationScope("nope"), []);
|
|
346
|
+
assert.equal(await messages.countByConversationScope(conversation.id), 3);
|
|
347
|
+
assert.equal(await messages.countByConversationScope(conversation.id, { workspaceId: "8" }), 1);
|
|
348
|
+
assert.equal(await messages.countByConversationScope(other.id), 1);
|
|
349
|
+
assert.equal(await messages.countByConversationScope("nope"), 0);
|
|
350
|
+
assert.equal(await messages.countByConversationScope("999999999"), 0);
|
|
351
|
+
|
|
352
|
+
const crowd = await conversations.create({ surfaceId: "home", createdByUserId: 71 });
|
|
353
|
+
await knex.batchInsert("assistant_messages", Array.from({ length: 520 }, (_, index) => ({ conversation_id: crowd.id, seq: index + 1, role: "user" })), 200);
|
|
354
|
+
assert.equal((await messages.listByConversationScope(crowd.id)).length, 200, "200 a page by default");
|
|
355
|
+
assert.equal((await messages.listByConversationScope(crowd.id, {}, { pageSize: 1000 })).length, 500, "at most 500");
|
|
356
|
+
assert.equal(await messages.countByConversationScope(crowd.id), 520);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("findNewestAssistantByUserId answers the newest assistant message in any of one user's conversations, in two reads", async () => {
|
|
360
|
+
requireTestDatabase(process.env);
|
|
361
|
+
const conversations = store.conversations;
|
|
362
|
+
const messages = store.messages;
|
|
363
|
+
const home = await conversations.create({ surfaceId: "home", createdByUserId: 81 });
|
|
364
|
+
const workspaceOne = await conversations.create({ surfaceId: "admin", workspaceId: 5, createdByUserId: 81 });
|
|
365
|
+
const someoneElse = await conversations.create({ surfaceId: "home", createdByUserId: 82 });
|
|
366
|
+
assert.equal(await messages.findNewestAssistantByUserId(81), null, "no assistant message yet");
|
|
367
|
+
|
|
368
|
+
await messages.create({ conversationId: home.id, role: "assistant", contentText: "older reply", createdAt: "2026-03-01T10:00:00.000Z" });
|
|
369
|
+
const reply = await messages.create({ conversationId: workspaceOne.id, workspaceId: 5, role: "assistant", contentText: "newest reply",
|
|
370
|
+
createdAt: "2026-03-02T10:00:00.000Z" });
|
|
371
|
+
const toolCall = await messages.create({ conversationId: workspaceOne.id, workspaceId: 5, role: "assistant", kind: "tool_call",
|
|
372
|
+
createdAt: "2026-03-02T10:00:00.000Z" });
|
|
373
|
+
await messages.create({ conversationId: home.id, role: "user", contentText: "a later question", createdAt: "2026-03-03T10:00:00.000Z" });
|
|
374
|
+
await messages.create({ conversationId: someoneElse.id, role: "assistant", contentText: "not theirs", createdAt: "2026-03-04T10:00:00.000Z" });
|
|
375
|
+
|
|
376
|
+
const statements = [];
|
|
377
|
+
const listener = (query) => statements.push(query.sql);
|
|
378
|
+
knex.on("query", listener);
|
|
379
|
+
let newest;
|
|
380
|
+
try {
|
|
381
|
+
newest = await messages.findNewestAssistantByUserId("81");
|
|
382
|
+
} finally {
|
|
383
|
+
knex.off("query", listener);
|
|
384
|
+
}
|
|
385
|
+
// The same createdAt: the higher id is newer.
|
|
386
|
+
assert.deepEqual(newest, toolCall);
|
|
387
|
+
assert.equal(statements.length, 2);
|
|
388
|
+
assert.deepEqual(await messages.findNewestAssistantByUserId(81, { kind: " Chat " }), reply);
|
|
389
|
+
assert.equal((await messages.findNewestAssistantByUserId(82)).contentText, "not theirs");
|
|
390
|
+
assert.equal(await messages.findNewestAssistantByUserId(83), null, "a user without conversations");
|
|
391
|
+
assert.equal(await messages.findNewestAssistantByUserId("nope"), null);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("updateById changes a message's text and metadata only", async () => {
|
|
395
|
+
requireTestDatabase(process.env);
|
|
396
|
+
const conversations = store.conversations;
|
|
397
|
+
const messages = store.messages;
|
|
398
|
+
const conversation = await conversations.create({ surfaceId: "home", createdByUserId: 81 });
|
|
399
|
+
const message = await messages.create({ conversationId: conversation.id, role: "assistant", contentText: "Draft", metadata: { a: 1 } });
|
|
400
|
+
assert.deepEqual(await messages.updateById(message.id, {}), message, "an empty patch changes nothing");
|
|
401
|
+
const changed = await messages.updateById(message.id, { contentText: "Final", metadata: { b: 2 }, role: "user", seq: 9, conversationId: "1" });
|
|
402
|
+
assert.deepEqual(changed, { ...message, contentText: "Final", metadata: { b: 2 } }, "role, seq and conversation stay");
|
|
403
|
+
assert.deepEqual(await messages.updateById(message.id, { contentText: null, metadata: "not an object" }), { ...message, contentText: null, metadata: {} });
|
|
404
|
+
assert.equal(await messages.updateById("nope", { contentText: "x" }), null);
|
|
405
|
+
assert.equal(await messages.updateById("999999999", { contentText: "x" }), null);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("conversations and messages written in one transaction roll back together", async () => {
|
|
409
|
+
requireTestDatabase(process.env);
|
|
410
|
+
const conversations = store.conversations;
|
|
411
|
+
const messages = store.messages;
|
|
412
|
+
let made = null;
|
|
413
|
+
await assert.rejects(conversations.transaction(async (txOptions) => {
|
|
414
|
+
made = await conversations.create({ surfaceId: "home", createdByUserId: 91 }, txOptions);
|
|
415
|
+
await messages.create({ conversationId: made.id, role: "user", contentText: "Hello" }, txOptions);
|
|
416
|
+
assert.equal(await messages.countByConversationScope(made.id, {}, txOptions), 1);
|
|
417
|
+
assert.equal(await messages.lastSequence(made.id, txOptions), 1);
|
|
418
|
+
throw new Error("roll back");
|
|
419
|
+
}), /roll back/u);
|
|
420
|
+
assert.equal(await conversations.findById(made.id), null);
|
|
421
|
+
assert.equal(await messages.countByConversationScope(made.id), 0);
|
|
422
|
+
|
|
423
|
+
const kept = await messages.transaction(async (txOptions) => {
|
|
424
|
+
const conversation = await conversations.create({ surfaceId: "home", createdByUserId: 91 }, txOptions);
|
|
425
|
+
await messages.create({ conversationId: conversation.id, role: "user" }, txOptions);
|
|
426
|
+
return conversation;
|
|
427
|
+
});
|
|
428
|
+
assert.equal(await messages.countByConversationScope(kept.id), 1, "a transaction that ends well commits, and answers the work's result");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("AssistantFeature on runtime.database keeps its settings in the database's assistant_config", async () => {
|
|
432
|
+
requireTestDatabase(process.env);
|
|
433
|
+
let assistant = null;
|
|
434
|
+
const runtime = createCapabilityRuntime({
|
|
435
|
+
inputs: { "runtime.logger": silentLogger },
|
|
436
|
+
providers: [
|
|
437
|
+
EventProvider, ActionProvider,
|
|
438
|
+
defineProvider({ id: "test.config", provides: { value: "runtime.config" }, setup: () => ({ value: {
|
|
439
|
+
surfaceDefinitions: { desk: { enabled: true, requiresWorkspace: false } },
|
|
440
|
+
assistantSurfaces: { desk: { settingsSurfaceId: "desk", configScope: "global" } },
|
|
441
|
+
assistantServer: { desk: { aiConfigPrefix: "DESK" } }
|
|
442
|
+
} }) }),
|
|
443
|
+
defineProvider({ id: "test.database", provides: { value: "runtime.database" }, setup: () => ({ value: { knex } }) }),
|
|
444
|
+
ResourceRuntimeProvider,
|
|
445
|
+
AssistantStoreFeature,
|
|
446
|
+
defineProvider({ id: "test.env", provides: { value: "runtime.env" }, setup: () => ({ value: {} }) }),
|
|
447
|
+
defineProvider({ id: "test.http", provides: { value: "runtime.http" }, setup: () => ({ value: { router: { register() {} } } }) }),
|
|
448
|
+
AssistantFeature,
|
|
449
|
+
defineProvider({
|
|
450
|
+
id: "test.observer",
|
|
451
|
+
requires: { assistantRuntime: "assistant.runtime" },
|
|
452
|
+
setup(dependencies) {
|
|
453
|
+
assistant = dependencies.assistantRuntime;
|
|
454
|
+
return {};
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
]
|
|
458
|
+
});
|
|
459
|
+
await runtime.start();
|
|
460
|
+
try {
|
|
461
|
+
const context = { actor: { id: "1" }, permissions: ["assistant.settings.update"] };
|
|
462
|
+
assert.deepEqual(await assistant.services.config.getSettings({ targetSurfaceId: "desk" }, { context }),
|
|
463
|
+
{ targetSurfaceId: "desk", scopeKey: "desk:global", workspaceId: null, settings: { systemPrompt: "" } }, "the default before any save");
|
|
464
|
+
const saved = await assistant.services.config.updateSettings({ targetSurfaceId: "desk" }, { systemPrompt: "Be brief." }, { context });
|
|
465
|
+
assert.deepEqual(saved, { targetSurfaceId: "desk", scopeKey: "desk:global", workspaceId: null, settings: { systemPrompt: "Be brief." } });
|
|
466
|
+
assert.deepEqual(await store.config.findByScope({ targetSurfaceId: "desk" }), saved, "written to runtime.database");
|
|
467
|
+
assert.equal(await assistant.services.config.resolveSystemPrompt({ targetSurfaceId: "desk" }), "Be brief.");
|
|
468
|
+
await assert.rejects(assistant.services.config.updateSettings({ targetSurfaceId: "desk" }, { systemPrompt: "No." }, { context: { actor: { id: "2" }, permissions: [] } }),
|
|
469
|
+
{ code: "assistant.runtime.settings.updateForbidden" });
|
|
470
|
+
assert.equal((await store.config.findByScope({ targetSurfaceId: "desk" })).settings.systemPrompt, "Be brief.");
|
|
471
|
+
} finally {
|
|
472
|
+
await runtime.shutdown();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
@@ -4,29 +4,29 @@ import { createRepository as createAssistantConfigRepository } from "../src/serv
|
|
|
4
4
|
import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
|
|
5
5
|
import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return work({ trxId: "trx-1" });
|
|
7
|
+
// A handle that runs no query and opens a stub transaction.
|
|
8
|
+
function createHandleStub() {
|
|
9
|
+
return {
|
|
10
|
+
async withTransaction(options, work) {
|
|
11
|
+
return work({ ...options, trxId: "trx-1" });
|
|
13
12
|
}
|
|
14
|
-
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
function systemContext() {
|
|
17
|
+
return { forUser: () => ({}), forWorkspace: () => ({}) };
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
test("assistant-runtime repositories expose withTransaction", async () => {
|
|
20
|
-
const knex = createKnexStub();
|
|
21
21
|
const repositories = [
|
|
22
|
-
createAssistantConfigRepository(
|
|
23
|
-
createConversationsRepository(
|
|
24
|
-
createMessagesRepository(
|
|
22
|
+
createAssistantConfigRepository({ assistantConfig: createHandleStub(), systemContext }),
|
|
23
|
+
createConversationsRepository({ conversations: createHandleStub(), systemContext }),
|
|
24
|
+
createMessagesRepository({ messages: createHandleStub(), conversations: createHandleStub(), systemContext })
|
|
25
25
|
];
|
|
26
26
|
|
|
27
27
|
for (const repository of repositories) {
|
|
28
28
|
assert.equal(typeof repository.withTransaction, "function");
|
|
29
|
-
const result = await repository.withTransaction(async (
|
|
29
|
+
const result = await repository.withTransaction({}, async (txOptions) => ({ id: txOptions.trxId }));
|
|
30
30
|
assert.deepEqual(result, { id: "trx-1" });
|
|
31
31
|
}
|
|
32
32
|
});
|
|
@@ -4,9 +4,8 @@ import knex from "knex";
|
|
|
4
4
|
import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
|
|
5
5
|
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
6
6
|
import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
|
|
7
|
-
import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
|
|
8
|
-
import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
|
|
9
7
|
import { createSqlConversationStorage } from "../src/server/sqlConversationStorage.js";
|
|
8
|
+
import { createTestAssistantStore } from "./support/assistantStore.js";
|
|
10
9
|
|
|
11
10
|
test("the SQL conversation storage keeps its contract, cache and summary on MySQL", {
|
|
12
11
|
skip: !process.env.TEST_DB_NAME || !process.env.DB_HOST
|
|
@@ -17,16 +16,19 @@ test("the SQL conversation storage keeps its contract, cache and summary on MySQ
|
|
|
17
16
|
password: environment.DB_PASSWORD };
|
|
18
17
|
const admin = knex({ client: "mysql2", connection });
|
|
19
18
|
const clients = [];
|
|
20
|
-
const
|
|
19
|
+
const stores = [];
|
|
20
|
+
const connect = async () => {
|
|
21
21
|
const db = knex({ client: "mysql2", connection: { ...connection, database: databaseName } });
|
|
22
22
|
clients.push(db);
|
|
23
|
-
const
|
|
23
|
+
const store = await createTestAssistantStore(db);
|
|
24
|
+
stores.push(store);
|
|
25
|
+
const repositories = { conversationsRepository: store.conversations, messagesRepository: store.messages };
|
|
24
26
|
return { db, ...repositories, storage: createSqlConversationStorage(repositories) };
|
|
25
27
|
};
|
|
26
28
|
try {
|
|
27
29
|
await admin.raw("DROP DATABASE IF EXISTS ??", [databaseName]);
|
|
28
30
|
await admin.raw("CREATE DATABASE ??", [databaseName]);
|
|
29
|
-
const f = connect();
|
|
31
|
+
const f = await connect();
|
|
30
32
|
await transcriptsMigration.up(f.db);
|
|
31
33
|
await summaryMigration.up(f.db);
|
|
32
34
|
await summaryMigration.up(f.db);
|
|
@@ -49,7 +51,7 @@ test("the SQL conversation storage keeps its contract, cache and summary on MySQ
|
|
|
49
51
|
await transaction.appendMessage(turnId, { role: "assistant", messageId: `answer-${index}`, text: `Answer ${index}`, at });
|
|
50
52
|
}
|
|
51
53
|
});
|
|
52
|
-
const servers = [connect(), connect()];
|
|
54
|
+
const servers = [await connect(), await connect()];
|
|
53
55
|
for (const server of servers) await server.storage.read(long.id, transaction => transaction.listTurnIds());
|
|
54
56
|
const queries = [];
|
|
55
57
|
const listener = query => queries.push(query.sql);
|
|
@@ -82,6 +84,7 @@ test("the SQL conversation storage keeps its contract, cache and summary on MySQ
|
|
|
82
84
|
await summaryMigration.up(f.db);
|
|
83
85
|
assert.equal(await f.db.schema.hasColumn("assistant_conversations", "transcript_revision"), true);
|
|
84
86
|
} finally {
|
|
87
|
+
for (const store of stores) await store.close();
|
|
85
88
|
for (const client of clients) await client.destroy();
|
|
86
89
|
await admin.raw("DROP DATABASE IF EXISTS ??", [databaseName]);
|
|
87
90
|
await admin.destroy();
|