@jskit-ai/assistant-runtime 0.2.29 → 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
|
@@ -126,25 +126,25 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
async function readRows(id, conversation,
|
|
129
|
+
async function readRows(id, conversation, txOptions) {
|
|
130
130
|
const rows = [];
|
|
131
131
|
for (let page = 1; ; page += 1) {
|
|
132
|
-
const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 },
|
|
132
|
+
const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 }, txOptions);
|
|
133
133
|
rows.push(...batch);
|
|
134
134
|
if (batch.length < 500) break;
|
|
135
135
|
}
|
|
136
136
|
return rows;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
async function load(id,
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
142
|
-
const { revision, summary } =
|
|
143
|
-
const lastSeq = await messagesRepository.lastSequence(id,
|
|
139
|
+
async function load(id, txOptions, write = false) {
|
|
140
|
+
const found = await conversationsRepository.findWithTranscriptState(id, { ...txOptions, forUpdate: write });
|
|
141
|
+
if (!found) throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
142
|
+
const { conversation, revision, summary } = found;
|
|
143
|
+
const lastSeq = await messagesRepository.lastSequence(id, txOptions);
|
|
144
144
|
let saved = cache.get(id);
|
|
145
145
|
if (saved && revision && saved.revision === revision && saved.lastSeq === lastSeq) remember(id, saved);
|
|
146
146
|
else {
|
|
147
|
-
const rows = await readRows(id, conversation,
|
|
147
|
+
const rows = await readRows(id, conversation, txOptions);
|
|
148
148
|
saved = { revision, lastSeq, rows, projection: inspectSqlConversationRows(conversation, rows) };
|
|
149
149
|
remember(id, saved);
|
|
150
150
|
}
|
|
@@ -153,7 +153,8 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
153
153
|
return { conversation, summary, lastSeq, rows: saved.rows, projection: { ...projection, record }, next: null };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
// Writes under the conversation's row lock, so each new message takes the next seq after the loaded last one.
|
|
157
|
+
async function save(id, loaded, record, txOptions) {
|
|
157
158
|
const { conversation, projection } = loaded;
|
|
158
159
|
let added = 0;
|
|
159
160
|
let title = conversation.title;
|
|
@@ -162,11 +163,11 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
162
163
|
const persist = async (previous, fields) => {
|
|
163
164
|
if (previous) {
|
|
164
165
|
if (previous.contentText !== fields.contentText || !isDeepStrictEqual(previous.metadata, fields.metadata)) {
|
|
165
|
-
updated.set(previous.id, await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata },
|
|
166
|
+
updated.set(previous.id, await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata }, txOptions));
|
|
166
167
|
}
|
|
167
168
|
} else {
|
|
168
169
|
inserted.push(await messagesRepository.create({ conversationId: id, workspaceId: conversation.workspaceId,
|
|
169
|
-
actorUserId: conversation.createdByUserId, ...fields },
|
|
170
|
+
actorUserId: conversation.createdByUserId, seq: loaded.lastSeq + added + 1, ...fields }, txOptions));
|
|
170
171
|
added += 1;
|
|
171
172
|
}
|
|
172
173
|
};
|
|
@@ -203,11 +204,14 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
203
204
|
const patch = { ...(added ? { messageCount: conversation.messageCount + added } : {}),
|
|
204
205
|
...(title !== conversation.title ? { title } : {}),
|
|
205
206
|
...(!isDeepStrictEqual(record.metadata, conversation.metadata) ? { metadata: record.metadata } : {}) };
|
|
206
|
-
if (Object.keys(patch).length) await conversationsRepository.updateById(id, patch, { trx });
|
|
207
207
|
const summary = record.summary ? { text: record.summary.text, throughTurnId: record.summary.throughTurnId } : null;
|
|
208
|
-
if (!updated.size && !inserted.length && isDeepStrictEqual(summary, loaded.summary))
|
|
208
|
+
if (!updated.size && !inserted.length && isDeepStrictEqual(summary, loaded.summary)) {
|
|
209
|
+
if (Object.keys(patch).length) await conversationsRepository.updateById(id, patch, txOptions);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
209
212
|
const revision = randomUUID();
|
|
210
|
-
|
|
213
|
+
// One statement writes the transcript state and the record's changes.
|
|
214
|
+
await conversationsRepository.writeTranscriptState(id, { revision, summary, changes: patch }, txOptions);
|
|
211
215
|
const rows = loaded.rows.map(row => updated.get(row.id) || row).concat(inserted);
|
|
212
216
|
loaded.next = { revision, lastSeq: Math.max(loaded.lastSeq, ...inserted.map(row => row.seq)), rows,
|
|
213
217
|
projection: inspectSqlConversationRows(conversation, rows) };
|
|
@@ -218,12 +222,12 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
218
222
|
let entered = false;
|
|
219
223
|
let loaded = null;
|
|
220
224
|
try {
|
|
221
|
-
const result = await conversationsRepository.transaction(async
|
|
222
|
-
loaded = await load(id,
|
|
225
|
+
const result = await conversationsRepository.transaction(async txOptions => {
|
|
226
|
+
loaded = await load(id, txOptions, write);
|
|
223
227
|
requireUnambiguous(loaded.projection);
|
|
224
228
|
const storage = createConversationStorage({
|
|
225
229
|
readRecord: () => loaded.projection.record,
|
|
226
|
-
writeRecord: (_scope, record) => save(id, loaded, record,
|
|
230
|
+
writeRecord: (_scope, record) => save(id, loaded, record, txOptions)
|
|
227
231
|
});
|
|
228
232
|
entered = true;
|
|
229
233
|
return storage[write ? "write" : "read"](id, operation);
|
|
@@ -249,8 +253,8 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
249
253
|
}
|
|
250
254
|
|
|
251
255
|
function preflight(id) {
|
|
252
|
-
return conversationsRepository.transaction(async
|
|
253
|
-
const { rows, projection } = await load(id,
|
|
256
|
+
return conversationsRepository.transaction(async txOptions => {
|
|
257
|
+
const { rows, projection } = await load(id, txOptions);
|
|
254
258
|
return { conversationId: id, rowCount: rows.length, issues: projection.issues };
|
|
255
259
|
});
|
|
256
260
|
}
|
package/src/server/types.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
/** @typedef {ReturnType<typeof import("./repositories/conversationsRepository.js").createRepository>} ConversationsRepository */
|
|
17
17
|
/** @typedef {ReturnType<typeof import("./repositories/messagesRepository.js").createRepository>} MessagesRepository */
|
|
18
18
|
/** @typedef {ReturnType<typeof import("./repositories/turnRequestsRepository.js").createRepository>} TurnRequestsRepository */
|
|
19
|
+
/** @typedef {{ config: AssistantConfigRepository, conversations: ConversationsRepository, messages: MessagesRepository, turnRequests: TurnRequestsRepository }} AssistantRepositories The `assistant.repositories` capability: the database-backed assistant's repositories, which AssistantStoreFeature provides. */
|
|
19
20
|
|
|
20
21
|
/** @typedef {import("@jskit-ai/workspaces-core/server/types").WorkspacesCore["scope"]} WorkspaceScopeSupport The workspace route support of `workspaces.core`: its route params, the action input they build and the workspace an action runs in. */
|
|
21
22
|
|
|
@@ -71,6 +71,7 @@ test("settings routes and actions compose independently of the chat service", as
|
|
|
71
71
|
settingsSurfaces: ["console"],
|
|
72
72
|
assistantConfigService: {
|
|
73
73
|
getSettings(input, options) { calls.push({ input, options }); return {}; },
|
|
74
|
+
canUpdateSettings(input, options) { calls.push({ canUpdate: input, options }); return true; },
|
|
74
75
|
updateSettings(input, patch, options) { calls.push({ input, patch, options }); return {}; }
|
|
75
76
|
}
|
|
76
77
|
});
|
|
@@ -98,6 +99,7 @@ test("settings routes and actions compose independently of the chat service", as
|
|
|
98
99
|
}
|
|
99
100
|
assert.deepEqual(calls, [
|
|
100
101
|
{ input: { targetSurfaceId: "home" }, options: { context } },
|
|
102
|
+
{ canUpdate: { targetSurfaceId: "home" }, options: { context } },
|
|
101
103
|
{ input: { targetSurfaceId: "home", patch }, patch, options: { context } }
|
|
102
104
|
]);
|
|
103
105
|
});
|
|
@@ -11,14 +11,15 @@ const RUN = process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION === "1";
|
|
|
11
11
|
const FIXTURE_ROOT = fileURLToPath(new URL("../fixtures/settings/", import.meta.url));
|
|
12
12
|
const options = { skip: RUN ? false : "set JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION=1 to run it", timeout: 120_000 };
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// canUpdate is the server's answer to whether the signed-in person may change the settings.
|
|
15
|
+
function settingsDocument(targetSurfaceId, systemPrompt, workspaceId = null, { canUpdate = true } = {}) {
|
|
15
16
|
return {
|
|
16
17
|
contentType: "application/vnd.api+json",
|
|
17
18
|
json: {
|
|
18
19
|
data: {
|
|
19
20
|
type: "assistant-settings",
|
|
20
21
|
id: targetSurfaceId,
|
|
21
|
-
attributes: { targetSurfaceId, scopeKey: targetSurfaceId, workspaceId, settings: { systemPrompt } }
|
|
22
|
+
attributes: { targetSurfaceId, scopeKey: targetSurfaceId, workspaceId, settings: { systemPrompt }, canUpdate }
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
};
|
|
@@ -119,6 +120,26 @@ test("the app bar's Save submits the assistant settings, on phones and wide wind
|
|
|
119
120
|
}
|
|
120
121
|
});
|
|
121
122
|
|
|
123
|
+
test("someone the server does not let change the settings sees them read-only, without Save", options, async (t) => {
|
|
124
|
+
const fixture = await startFixture(t);
|
|
125
|
+
for (const viewport of [{ width: 390, height: 844 }, { width: 1280, height: 900 }]) {
|
|
126
|
+
const api = await openPage(fixture, {
|
|
127
|
+
viewport,
|
|
128
|
+
routes: { "GET /api/assistant/console/settings": settingsDocument("console", "Answer briefly.", null, { canUpdate: false }) }
|
|
129
|
+
});
|
|
130
|
+
const { page } = api;
|
|
131
|
+
await page.goto("/settings/console");
|
|
132
|
+
await expect(prompt(page)).toHaveValue("Answer briefly.");
|
|
133
|
+
await expect(prompt(page)).toHaveAttribute("readonly", "");
|
|
134
|
+
await expect(page.getByText("Read-only", { exact: true })).toBeVisible();
|
|
135
|
+
await expect(page.getByRole("button", { name: "Save", exact: true })).toHaveCount(0);
|
|
136
|
+
// Enter in the read-only form sends nothing.
|
|
137
|
+
await prompt(page).press("Enter");
|
|
138
|
+
assert.deepEqual(api.requests.map(({ key }) => key), ["GET /api/assistant/console/settings"]);
|
|
139
|
+
await finish(api);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
122
143
|
test("a refused save shows the prompt's message and reports the failure", options, async (t) => {
|
|
123
144
|
const fixture = await startFixture(t);
|
|
124
145
|
const api = await openPage(fixture, {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
3
4
|
import { createService } from "../src/server/services/assistantConfigService.js";
|
|
5
|
+
import { createAssistantSettingsActions } from "../src/server/createAssistantSettingsActions.js";
|
|
6
|
+
import { actionIds } from "../src/server/actionIds.js";
|
|
4
7
|
|
|
5
8
|
function createSettingsRows() {
|
|
6
9
|
const rows = new Map();
|
|
@@ -43,7 +46,8 @@ function createSettings(assistantServer, { consoleOwnerIds = [] } = {}) {
|
|
|
43
46
|
consoleService: {
|
|
44
47
|
async requireConsoleOwner(context) {
|
|
45
48
|
if (!consoleOwnerIds.includes(context.actor.id)) {
|
|
46
|
-
|
|
49
|
+
// As console-core's consoleService refuses: a 403.
|
|
50
|
+
throw new AppError(403, "Console owner required.", { code: "console.ownerRequired" });
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
},
|
|
@@ -95,6 +99,47 @@ test("a workspace assistant still needs workspace.settings.update, and a console
|
|
|
95
99
|
assert.equal((await update(settings, "ops", { actor: { id: "1" }, permissions: [] })).settings.systemPrompt, "Set by 1");
|
|
96
100
|
});
|
|
97
101
|
|
|
102
|
+
test("the settings say whether the reader may change them, by the check that refuses the change", async () => {
|
|
103
|
+
const can = (settings, targetSurfaceId, context) => settings.canUpdateSettings({ targetSurfaceId }, { context });
|
|
104
|
+
const admin = { actor: { id: "1" }, permissions: ["assistant.settings.update"] };
|
|
105
|
+
const settings = createSettings({}, { consoleOwnerIds: ["1"] });
|
|
106
|
+
assert.equal(await can(settings, "home", employee), false);
|
|
107
|
+
assert.equal(await can(settings, "home", admin), true);
|
|
108
|
+
assert.equal(await can(settings, "admin", { actor: { id: "7" }, permissions: ["workspace.settings.view"] }), false);
|
|
109
|
+
assert.equal(await can(settings, "admin", { actor: { id: "1" }, permissions: ["workspace.settings.update"] }), true);
|
|
110
|
+
assert.equal(await can(settings, "ops", employee), false);
|
|
111
|
+
assert.equal(await can(settings, "ops", { actor: { id: "1" }, permissions: [] }), true);
|
|
112
|
+
|
|
113
|
+
// An app's settingsUpdatePermission decides, as it does for the change itself.
|
|
114
|
+
const configured = createSettings({ home: { settingsUpdatePermission: "app.admin" } });
|
|
115
|
+
assert.equal(await can(configured, "home", admin), false);
|
|
116
|
+
assert.equal(await can(configured, "home", { actor: { id: "1" }, permissions: ["app.admin"] }), true);
|
|
117
|
+
assert.equal(await can(createSettings({ home: { settingsUpdatePermission: { require: "authenticated" } } }), "home", employee), true);
|
|
118
|
+
|
|
119
|
+
// A failure that is not a refusal still fails.
|
|
120
|
+
const noConsole = createService({
|
|
121
|
+
assistantConfigRepository: createSettingsRows(),
|
|
122
|
+
consoleService: null,
|
|
123
|
+
appConfig: createAppConfig({}),
|
|
124
|
+
workspaceScopeSupport: null
|
|
125
|
+
});
|
|
126
|
+
await assert.rejects(can(noConsole, "ops", admin), /requires consoleService\.requireConsoleOwner\(\)/u);
|
|
127
|
+
|
|
128
|
+
// The settings read carries the answer, and a successful change says the person may change them.
|
|
129
|
+
const definitions = createAssistantSettingsActions({ assistantConfigService: configured, settingsSurfaces: ["home"] });
|
|
130
|
+
const execute = (id, input, context) => definitions.find((definition) => definition.id === id).execute(input, context);
|
|
131
|
+
const read = async (context) => (await execute(actionIds.settingsRead, { targetSurfaceId: "home" }, context)).value;
|
|
132
|
+
assert.deepEqual(await read(admin), {
|
|
133
|
+
targetSurfaceId: "home", workspaceId: null, scopeKey: "home:", settings: { systemPrompt: "" }, canUpdate: false
|
|
134
|
+
});
|
|
135
|
+
const appAdmin = { actor: { id: "1" }, permissions: ["app.admin"] };
|
|
136
|
+
assert.equal((await read(appAdmin)).canUpdate, true);
|
|
137
|
+
const changed = await execute(actionIds.settingsUpdate, { targetSurfaceId: "home", patch: { systemPrompt: "Be brief." } }, appAdmin);
|
|
138
|
+
assert.deepEqual(changed.value, {
|
|
139
|
+
targetSurfaceId: "home", workspaceId: null, scopeKey: "home:", settings: { systemPrompt: "Be brief." }, canUpdate: true
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
98
143
|
test("a malformed settingsUpdatePermission stops the settings service from starting", () => {
|
|
99
144
|
for (const settingsUpdatePermission of [false, "", { require: "all" }, { require: "none" }, ["app.admin"]]) {
|
|
100
145
|
assert.throws(() => createSettings({ home: { settingsUpdatePermission } }), {
|
|
@@ -91,7 +91,7 @@ function createRecordingLogger() {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime,
|
|
94
|
-
once, personContext = NO_PERSON_CONTEXT, savedSystemPrompt = "", workspace = null } = {}) {
|
|
94
|
+
once, personContext = NO_PERSON_CONTEXT, savedSystemPrompt = "", workspace = null, transcriptFailures = {} } = {}) {
|
|
95
95
|
const pendingCompletions = [...completions];
|
|
96
96
|
const completionRequests = [];
|
|
97
97
|
const transcriptMessages = [];
|
|
@@ -135,6 +135,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
135
135
|
},
|
|
136
136
|
transcriptService: {
|
|
137
137
|
async createConversationForTurn() {
|
|
138
|
+
if (transcriptFailures.create) throw transcriptFailures.create;
|
|
138
139
|
return {
|
|
139
140
|
conversation: {
|
|
140
141
|
id: "conversation_1"
|
|
@@ -142,6 +143,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
142
143
|
};
|
|
143
144
|
},
|
|
144
145
|
async completeConversation(_surface, _conversationId, completion) {
|
|
146
|
+
if (transcriptFailures.complete && completion.status === "completed") throw transcriptFailures.complete;
|
|
145
147
|
completedConversations.push(structuredClone(completion));
|
|
146
148
|
}
|
|
147
149
|
},
|
|
@@ -180,6 +182,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
180
182
|
},
|
|
181
183
|
personContextPrompt: createPersonContextPrompt({ personContext, logger: logged.logger }),
|
|
182
184
|
appConfig: workspace ? WORKSPACE_APP_CONFIG : APP_CONFIG,
|
|
185
|
+
logger: logged.logger,
|
|
183
186
|
...(workspace ? { workspaceScopeSupport: { resolveWorkspace: () => workspace } } : {})
|
|
184
187
|
};
|
|
185
188
|
const conversationRuntime = createAssistantConversationRuntime({
|
|
@@ -400,6 +403,34 @@ test("a failed action's error goes back to the model, and the action stays avail
|
|
|
400
403
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["The second search found it."]);
|
|
401
404
|
});
|
|
402
405
|
|
|
406
|
+
// MariaDB's errno 1020 as the mysql2 driver reports it, its message starting with the statement and its values.
|
|
407
|
+
function databaseError() {
|
|
408
|
+
return Object.assign(new Error("UPDATE `conversations` SET `title` = 'secret-value' - Record has changed since last read"), {
|
|
409
|
+
code: "ER_CHECKREAD",
|
|
410
|
+
errno: 1020,
|
|
411
|
+
sqlMessage: "Record has changed since last read in table 'conversations'",
|
|
412
|
+
sql: "UPDATE `conversations` SET `title` = 'secret-value'"
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
test("a database error streams, saves and replays only as the kernel's generic answer, and its detail goes to the log", async () => {
|
|
417
|
+
const streamedFailure = databaseError();
|
|
418
|
+
const streamed = createHarness([textCompletion("Done.")], { transcriptFailures: { complete: streamedFailure } });
|
|
419
|
+
const result = await streamed.run();
|
|
420
|
+
assert.equal(result.status, "failed");
|
|
421
|
+
const sent = streamed.streamEvents.find(event => event.method === "sendError").payload;
|
|
422
|
+
assert.deepEqual({ code: sent.code, message: sent.message, status: sent.status }, { code: "kernel.database.concurrentWrite", message: "Another request changed the same data at the same time. Try again.", status: 409 });
|
|
423
|
+
assert.equal(JSON.stringify(streamed.streamEvents).includes("secret-value"), false);
|
|
424
|
+
assert.deepEqual(streamed.logged.filter(entry => entry.level === "error"),
|
|
425
|
+
[{ level: "error", fields: { err: streamedFailure }, message: "Assistant turn failed on a database error." }]);
|
|
426
|
+
|
|
427
|
+
// Before the stream starts, the error reaches the route as it was thrown; the saved failure a replay answers is the stand-in.
|
|
428
|
+
const earlyFailure = databaseError();
|
|
429
|
+
const early = createHarness([], { transcriptFailures: { create: earlyFailure } });
|
|
430
|
+
await assert.rejects(early.run(), error => error === earlyFailure);
|
|
431
|
+
await assert.rejects(early.run(), error => error instanceof AppError && error.status === 409 &&
|
|
432
|
+
error.code === "kernel.database.concurrentWrite");
|
|
433
|
+
});
|
|
403
434
|
|
|
404
435
|
test("answer text reaches the client before the provider finishes, then persists once", async () => {
|
|
405
436
|
const firstChunk = Promise.withResolvers();
|
|
@@ -42,7 +42,7 @@ test("AssistantFeature composes chat, transcripts, settings, routes, and action
|
|
|
42
42
|
providers: [
|
|
43
43
|
EventProvider, ActionProvider,
|
|
44
44
|
valueProvider("test.config", "runtime.config", config),
|
|
45
|
-
valueProvider("test.
|
|
45
|
+
valueProvider("test.repositories", "assistant.repositories", { config: {}, conversations: {}, messages: {}, turnRequests: {} }),
|
|
46
46
|
valueProvider("test.env", "runtime.env", {}),
|
|
47
47
|
valueProvider("test.http", "runtime.http", {
|
|
48
48
|
router: {
|
|
@@ -92,13 +92,13 @@ test("AssistantFeature uses a supplied file runtime without loading database or
|
|
|
92
92
|
import { tmpdir } from "node:os";
|
|
93
93
|
import { join } from "node:path";
|
|
94
94
|
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
95
|
-
if (["@jskit-ai/database-runtime", "knex"].some(name => specifier === name || specifier.startsWith(name + "/")) ||
|
|
95
|
+
if (["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime", "knex"].some(name => specifier === name || specifier.startsWith(name + "/")) ||
|
|
96
96
|
specifier.endsWith("/chatService.js") || specifier.endsWith("/createAssistantRuntime.js")) {
|
|
97
97
|
throw new Error("Unexpected database/legacy import: " + specifier);
|
|
98
98
|
}
|
|
99
99
|
return nextResolve(specifier, context);
|
|
100
100
|
} });
|
|
101
|
-
for (const name of ["@jskit-ai/database-runtime", "knex"]) {
|
|
101
|
+
for (const name of ["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime", "knex"]) {
|
|
102
102
|
await assert.rejects(() => import(name), /Unexpected database/);
|
|
103
103
|
}
|
|
104
104
|
const { AssistantFeature } = await import("@jskit-ai/assistant-runtime/server");
|
|
@@ -296,6 +296,20 @@ test("one AssistantFeature composes host access and declared data while settings
|
|
|
296
296
|
assert.equal((await conversation.read()).id, "persisted-local");
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
+
test("AssistantFeature without conversations or AssistantStoreFeature fails at startup, naming the feature to register", async () => {
|
|
300
|
+
let registrations = 0;
|
|
301
|
+
const host = createCapabilityRuntime({ providers: [EventProvider, ActionProvider, AssistantFeature], inputs: {
|
|
302
|
+
"runtime.logger": silentLogger,
|
|
303
|
+
"runtime.config": {}, "runtime.env": {},
|
|
304
|
+
"runtime.http": { router: { register() { registrations++; } } }
|
|
305
|
+
} });
|
|
306
|
+
await assert.rejects(host.start(), {
|
|
307
|
+
message: "AssistantFeature keeps conversations in assistant.repositories, which no feature provides: register AssistantStoreFeature " +
|
|
308
|
+
"from @jskit-ai/assistant-runtime/server in your server providers, or provide assistant.conversations (createConversationRuntime())."
|
|
309
|
+
});
|
|
310
|
+
assert.equal(registrations, 0);
|
|
311
|
+
});
|
|
312
|
+
|
|
299
313
|
test("AssistantFeature rejects incomplete host access before registering routes or actions", async () => {
|
|
300
314
|
const validAccess = { router: { register() {} }, wrapAction: definition => definition,
|
|
301
315
|
subscribeActionId: "test.host.conversation.subscribe", requestPolicy: "host" };
|