@jskit-ai/assistant-runtime 0.1.192 → 0.1.194
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/canonical-conversation/App.vue +188 -0
- package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
- package/fixtures/canonical-conversation/index.html +1 -0
- package/fixtures/canonical-conversation/main.js +61 -0
- package/fixtures/canonical-conversation/socket.js +50 -0
- package/fixtures/canonical-conversation/vite.config.mjs +178 -0
- package/fixtures/conversation/vite.config.mjs +12 -2
- package/package.json +23 -10
- package/src/client/components/AssistantConversationClientElement.vue +57 -0
- package/src/client/composables/useAssistantConversation.js +874 -0
- package/src/client/index.js +2 -0
- package/src/client/support/assistantRuntimeState.js +1 -1
- package/src/client/support/subscribeAssistantConversation.js +205 -0
- package/src/server/AssistantProvider.js +82 -106
- package/src/server/actionIds.js +10 -0
- package/src/server/actions.js +287 -79
- package/src/server/createAssistantRuntime.js +138 -0
- package/src/server/createAssistantSettingsActions.js +89 -0
- package/src/server/index.js +6 -0
- package/src/server/inputSchemas.js +90 -0
- package/src/server/registerConversationSubscriptions.js +163 -0
- package/src/server/registerFastifyConversations.js +76 -0
- package/src/server/registerRoutes.js +220 -198
- package/src/server/registerSettingsRoutes.js +113 -0
- package/src/server/repositories/conversationsRepository.js +12 -5
- package/src/server/repositories/messagesRepository.js +12 -0
- package/src/server/services/chatService.js +88 -694
- package/src/server/services/transcriptService.js +56 -52
- package/src/server/sqlConversationStorage.js +192 -0
- package/src/server/support/assistantRouteContext.js +91 -0
- package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
- package/src/shared/assistantSurfaces.js +14 -0
- package/src/shared/conversationRealtime.js +9 -0
- package/src/shared/index.js +1 -0
- package/test/actionDefinitions.test.js +44 -0
- package/test/aiConnections.test.js +1 -1
- package/test/assistantConversationBinding.browser.test.js +1868 -0
- package/test/assistantConversationOperations.test.js +459 -0
- package/test/assistantConversationSubscriptions.test.js +692 -0
- package/test/assistantIntegrationBoundary.test.js +103 -0
- package/test/assistantRuntimeComposition.test.js +68 -0
- package/test/assistantSurfacePattern.test.js +1 -1
- package/test/assistantSurfaces.test.js +23 -0
- package/test/chatServiceLifecycle.test.js +12 -1
- package/test/featureRuntime.test.js +238 -0
- package/test/registerFastifyConversations.test.js +169 -0
- package/test/sqlConversationStorage.test.js +205 -0
- package/test/support/memoryAssistantRows.js +51 -0
- package/test/turnRequests.test.js +11 -2
- package/src/shared/assistantResponseText.js +0 -29
|
@@ -152,43 +152,45 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
152
152
|
const context = normalizeObject(options.context);
|
|
153
153
|
const actorUserId = normalizeRecordId(source.actorUserId, { fallback: null }) || resolveActorUserId(context.actor);
|
|
154
154
|
const workspaceId = resolveExpectedWorkspaceId(resolvedAssistantSurface, options.workspace || context.workspace);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const createdMessage = await messagesRepository.create({
|
|
165
|
-
conversationId: normalizedConversationId,
|
|
166
|
-
workspaceId: conversation.workspaceId,
|
|
167
|
-
role: normalizeText(source.role).toLowerCase(),
|
|
168
|
-
kind: normalizeText(source.kind).toLowerCase() || "chat",
|
|
169
|
-
clientMessageSid: normalizeText(source.clientMessageSid),
|
|
170
|
-
actorUserId,
|
|
171
|
-
contentText: source.contentText == null ? null : String(source.contentText),
|
|
172
|
-
metadata: normalizeObject(source.metadata)
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
await conversationsRepository.incrementMessageCount(normalizedConversationId, 1);
|
|
155
|
+
return conversationsRepository.transaction(async trx => {
|
|
156
|
+
const conversation = await conversationsRepository.findByIdForActorScope(normalizedConversationId, {
|
|
157
|
+
workspaceId,
|
|
158
|
+
actorUserId,
|
|
159
|
+
surfaceId: resolvedAssistantSurface.targetSurfaceId
|
|
160
|
+
}, { trx, forUpdate: true });
|
|
161
|
+
if (!conversation) {
|
|
162
|
+
throw new AppError(404, "Conversation not found.");
|
|
163
|
+
}
|
|
176
164
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
165
|
+
const createdMessage = await messagesRepository.create({
|
|
166
|
+
conversationId: normalizedConversationId,
|
|
167
|
+
workspaceId: conversation.workspaceId,
|
|
168
|
+
role: normalizeText(source.role).toLowerCase(),
|
|
169
|
+
kind: normalizeText(source.kind).toLowerCase() || "chat",
|
|
170
|
+
clientMessageSid: normalizeText(source.clientMessageSid),
|
|
171
|
+
actorUserId,
|
|
172
|
+
contentText: source.contentText == null ? null : String(source.contentText),
|
|
173
|
+
metadata: normalizeObject(source.metadata)
|
|
174
|
+
}, { trx });
|
|
175
|
+
|
|
176
|
+
await conversationsRepository.updateById(normalizedConversationId, { messageCount: conversation.messageCount + 1 }, { trx });
|
|
177
|
+
|
|
178
|
+
const messageRole = normalizeText(source.role).toLowerCase();
|
|
179
|
+
const messageKind = normalizeText(source.kind).toLowerCase() || "chat";
|
|
180
|
+
if (messageRole === "user" && messageKind === "chat" && isDefaultConversationTitle(conversation.title)) {
|
|
181
|
+
const derivedTitle = deriveConversationTitleFromMessage(source.contentText);
|
|
182
|
+
if (derivedTitle) {
|
|
183
|
+
await conversationsRepository.updateById(normalizedConversationId, {
|
|
184
|
+
title: derivedTitle
|
|
185
|
+
}, { trx });
|
|
186
|
+
}
|
|
185
187
|
}
|
|
186
|
-
}
|
|
187
188
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
return {
|
|
190
|
+
conversationId: normalizedConversationId,
|
|
191
|
+
message: createdMessage
|
|
192
|
+
};
|
|
193
|
+
});
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
async function completeConversation(assistantSurface, conversationId, payload = {}, options = {}) {
|
|
@@ -204,24 +206,26 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
204
206
|
required: true
|
|
205
207
|
});
|
|
206
208
|
const workspaceId = resolveExpectedWorkspaceId(resolvedAssistantSurface, options.workspace || context.workspace);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return conversationsRepository.updateById(normalizedConversationId, {
|
|
217
|
-
status: normalizeConversationStatus(source.status, {
|
|
218
|
-
fallback: "completed"
|
|
219
|
-
}),
|
|
220
|
-
endedAt: source.endedAt || new Date(),
|
|
221
|
-
metadata: {
|
|
222
|
-
...normalizeObject(existing.metadata),
|
|
223
|
-
...normalizeObject(source.metadata)
|
|
209
|
+
return conversationsRepository.transaction(async trx => {
|
|
210
|
+
const existing = await conversationsRepository.findByIdForActorScope(normalizedConversationId, {
|
|
211
|
+
workspaceId,
|
|
212
|
+
actorUserId,
|
|
213
|
+
surfaceId: resolvedAssistantSurface.targetSurfaceId
|
|
214
|
+
}, { trx, forUpdate: true });
|
|
215
|
+
if (!existing) {
|
|
216
|
+
throw new AppError(404, "Conversation not found.");
|
|
224
217
|
}
|
|
218
|
+
|
|
219
|
+
return conversationsRepository.updateById(normalizedConversationId, {
|
|
220
|
+
status: normalizeConversationStatus(source.status, {
|
|
221
|
+
fallback: "completed"
|
|
222
|
+
}),
|
|
223
|
+
endedAt: source.endedAt || new Date(),
|
|
224
|
+
metadata: {
|
|
225
|
+
...normalizeObject(existing.metadata),
|
|
226
|
+
...normalizeObject(source.metadata)
|
|
227
|
+
}
|
|
228
|
+
}, { trx });
|
|
225
229
|
});
|
|
226
230
|
}
|
|
227
231
|
|
|
@@ -326,4 +330,4 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
326
330
|
});
|
|
327
331
|
}
|
|
328
332
|
|
|
329
|
-
export { createTranscriptService };
|
|
333
|
+
export { createTranscriptService, deriveConversationTitleFromMessage, isDefaultConversationTitle };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
4
|
+
import { createConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
|
|
5
|
+
import { deriveConversationTitleFromMessage, isDefaultConversationTitle } from "./services/transcriptService.js";
|
|
6
|
+
|
|
7
|
+
const roles = new Set(["user", "system", "assistant", "commentary", "thinking"]);
|
|
8
|
+
const authored = row => row.kind === "chat" && ["user", "system"].includes(row.role);
|
|
9
|
+
const rowLink = row => row.metadata?.conversationRuntime;
|
|
10
|
+
|
|
11
|
+
function messageFromRow(row) {
|
|
12
|
+
const { turnId: _turnId, turnMetadata: _turnMetadata, ...fields } = rowLink(row) || {};
|
|
13
|
+
return { ...fields, role: row.role, text: row.contentText || "", at: row.createdAt,
|
|
14
|
+
messageId: authored(row) ? row.clientMessageSid : fields.messageId || "",
|
|
15
|
+
...(row.metadata?.attachments ? { attachments: structuredClone(row.metadata.attachments) } : {}) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Read-only projection. Historical ambiguities are reported, never repaired. */
|
|
19
|
+
function inspectSqlConversationRows(conversation, rows) {
|
|
20
|
+
const turns = new Map();
|
|
21
|
+
const groups = new Map();
|
|
22
|
+
const issues = [];
|
|
23
|
+
const receipts = new Map();
|
|
24
|
+
let previousLegacy = null;
|
|
25
|
+
const issue = (code, ...entries) => issues.push({ code, rowIds: entries.filter(Boolean).map(row => row.id) });
|
|
26
|
+
for (const row of rows) {
|
|
27
|
+
const link = rowLink(row);
|
|
28
|
+
if (link !== undefined && (!link || typeof link.turnId !== "string" || !link.turnId)) {
|
|
29
|
+
issue("invalid_turn_link", row);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let id = link?.turnId;
|
|
33
|
+
if (authored(row)) {
|
|
34
|
+
if (!link && previousLegacy) issue("overlapping_historical_turns", previousLegacy, row);
|
|
35
|
+
id ||= row.id;
|
|
36
|
+
if (groups.has(id)) { issue("duplicate_turn", groups.get(id).anchor, row); continue; }
|
|
37
|
+
if (row.clientMessageSid && receipts.has(row.clientMessageSid)) issue("duplicate_message_receipt", receipts.get(row.clientMessageSid), row);
|
|
38
|
+
if (row.clientMessageSid) receipts.set(row.clientMessageSid, row);
|
|
39
|
+
groups.set(id, { anchor: row, messages: [], calls: new Map(), results: new Map(), explicit: Boolean(link) });
|
|
40
|
+
turns.set(id, { messages: [], ...(link?.turnMetadata ? { metadata: structuredClone(link.turnMetadata) } : {}) });
|
|
41
|
+
previousLegacy = link ? null : row;
|
|
42
|
+
} else if (!id) {
|
|
43
|
+
id = previousLegacy?.id;
|
|
44
|
+
}
|
|
45
|
+
const group = groups.get(id);
|
|
46
|
+
if (!group) { issue("orphan_output", row); continue; }
|
|
47
|
+
if (roles.has(row.role) && (row.kind === "chat" || link && row.kind === row.role)) {
|
|
48
|
+
if (row.role === "assistant" && group.messages.some(entry => entry.role === "assistant")) {
|
|
49
|
+
issue("multiple_historical_answers", group.anchor, row);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
group.messages.push(row);
|
|
53
|
+
turns.get(id).messages.push(messageFromRow(row));
|
|
54
|
+
if (!link && row.role === "assistant") previousLegacy = null;
|
|
55
|
+
} else if (["tool_call", "tool_result"].includes(row.kind)) {
|
|
56
|
+
const callId = row.metadata?.toolCallId;
|
|
57
|
+
const target = row.kind === "tool_call" ? group.calls : group.results;
|
|
58
|
+
if (typeof callId !== "string" || !callId || target.has(callId)) issue("ambiguous_tool_identity", target.get(callId), row);
|
|
59
|
+
else target.set(callId, row);
|
|
60
|
+
} else issue("unsupported_message_shape", row);
|
|
61
|
+
}
|
|
62
|
+
for (const [id, group] of groups) {
|
|
63
|
+
const tools = [];
|
|
64
|
+
for (const [callId, row] of group.calls) {
|
|
65
|
+
const resultRow = group.results.get(callId);
|
|
66
|
+
let result;
|
|
67
|
+
if (resultRow) {
|
|
68
|
+
try { result = JSON.parse(resultRow.contentText); } catch { issue("invalid_tool_result", resultRow); }
|
|
69
|
+
if (!result || typeof result.ok !== "boolean" || resultRow.metadata.tool !== row.metadata.tool) {
|
|
70
|
+
issue("conflicting_tool_result", row, resultRow);
|
|
71
|
+
}
|
|
72
|
+
} else if (!group.explicit) issue("unconfirmed_historical_tool", row);
|
|
73
|
+
tools.push({ id: callId, name: row.metadata.tool, arguments: row.contentText || "", at: row.createdAt,
|
|
74
|
+
status: rowLink(row)?.status || (result ? !result.ok && result.error?.status >= 500 ? "unknown" : "complete" : "running"),
|
|
75
|
+
...(result ? { result } : {}) });
|
|
76
|
+
}
|
|
77
|
+
for (const [callId, row] of group.results) if (!group.calls.has(callId)) issue("orphan_tool_result", row);
|
|
78
|
+
if (tools.length) turns.get(id).metadata = { ...turns.get(id).metadata, applicationTools: tools };
|
|
79
|
+
}
|
|
80
|
+
return { record: { metadata: structuredClone(conversation.metadata || {}), turns }, groups, issues };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requireUnambiguous(projection) {
|
|
84
|
+
if (!projection.issues.length) return;
|
|
85
|
+
throw new AppError(409, "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.", {
|
|
86
|
+
code: "assistant_history_inspection_required", details: { issues: projection.issues }
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Existing SQL rows remain authoritative. Core supplies the transaction methods;
|
|
91
|
+
* the database owns locking, sequence allocation and commit/rollback together.
|
|
92
|
+
*/
|
|
93
|
+
function createSqlConversationStorage({ conversationsRepository, messagesRepository }) {
|
|
94
|
+
async function load(id, trx, write = false) {
|
|
95
|
+
const conversation = await conversationsRepository.findById(id, { trx, forUpdate: write });
|
|
96
|
+
if (!conversation) throw new AppError(404, "Conversation not found.");
|
|
97
|
+
const rows = [];
|
|
98
|
+
for (let page = 1; ; page += 1) {
|
|
99
|
+
const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 }, { trx });
|
|
100
|
+
rows.push(...batch);
|
|
101
|
+
if (batch.length < 500) break;
|
|
102
|
+
}
|
|
103
|
+
return { conversation, rows, projection: inspectSqlConversationRows(conversation, rows) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function save(id, loaded, record, trx) {
|
|
107
|
+
const { conversation, projection } = loaded;
|
|
108
|
+
let added = 0;
|
|
109
|
+
let title = conversation.title;
|
|
110
|
+
const persist = async (previous, fields) => {
|
|
111
|
+
if (previous) {
|
|
112
|
+
if (previous.contentText !== fields.contentText || !isDeepStrictEqual(previous.metadata, fields.metadata)) {
|
|
113
|
+
await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata }, { trx });
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
await messagesRepository.create({ conversationId: id, workspaceId: conversation.workspaceId,
|
|
117
|
+
actorUserId: conversation.createdByUserId, ...fields }, { trx });
|
|
118
|
+
added += 1;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
for (const [turnId, turn] of record.turns) {
|
|
122
|
+
const original = projection.record.turns.get(turnId);
|
|
123
|
+
if (isDeepStrictEqual(turn, original)) continue;
|
|
124
|
+
const group = projection.groups.get(turnId);
|
|
125
|
+
const { applicationTools = [], ...turnMetadata } = turn.metadata || {};
|
|
126
|
+
for (const message of turn.messages) {
|
|
127
|
+
const previous = group?.messages.find(row => row.role === message.role &&
|
|
128
|
+
(message.role === "assistant" || messageFromRow(row).messageId === message.messageId && row.createdAt === message.at));
|
|
129
|
+
const { role, text, at, attachments, messageId, ...fields } = message;
|
|
130
|
+
const isAuthored = ["user", "system"].includes(role);
|
|
131
|
+
const metadata = { ...previous?.metadata, ...(isAuthored ? { surfaceId: conversation.surfaceId } : {}), ...(attachments ? { attachments } : {}),
|
|
132
|
+
conversationRuntime: { turnId, ...fields, ...(!isAuthored && messageId ? { messageId } : {}),
|
|
133
|
+
...(isAuthored ? { turnMetadata } : {}) } };
|
|
134
|
+
await persist(previous, { role, kind: ["commentary", "thinking"].includes(role) ? role : "chat",
|
|
135
|
+
clientMessageSid: isAuthored ? messageId : "", contentText: text, createdAt: at, metadata });
|
|
136
|
+
if (!previous && role === "user" && isDefaultConversationTitle(title)) title = deriveConversationTitleFromMessage(text) || title;
|
|
137
|
+
}
|
|
138
|
+
for (const call of applicationTools) {
|
|
139
|
+
const previous = group?.calls.get(call.id);
|
|
140
|
+
await persist(previous, { role: "assistant", kind: "tool_call", contentText: call.arguments, createdAt: call.at,
|
|
141
|
+
metadata: { ...previous?.metadata, toolCallId: call.id, tool: call.name,
|
|
142
|
+
conversationRuntime: { turnId, status: call.status } } });
|
|
143
|
+
if (call.result) {
|
|
144
|
+
const previousResult = group?.results.get(call.id);
|
|
145
|
+
await persist(previousResult, { role: "assistant", kind: "tool_result", contentText: JSON.stringify(call.result),
|
|
146
|
+
metadata: { ...previousResult?.metadata, toolCallId: call.id, tool: call.name, ok: call.result.ok === true,
|
|
147
|
+
conversationRuntime: { turnId } } });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const patch = { ...(added ? { messageCount: conversation.messageCount + added } : {}),
|
|
152
|
+
...(title !== conversation.title ? { title } : {}),
|
|
153
|
+
...(!isDeepStrictEqual(record.metadata, conversation.metadata) ? { metadata: record.metadata } : {}) };
|
|
154
|
+
if (Object.keys(patch).length) await conversationsRepository.updateById(id, patch, { trx });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function access(id, operation, write) {
|
|
158
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
159
|
+
let entered = false;
|
|
160
|
+
try {
|
|
161
|
+
return await conversationsRepository.transaction(async trx => {
|
|
162
|
+
const loaded = await load(id, trx, write);
|
|
163
|
+
requireUnambiguous(loaded.projection);
|
|
164
|
+
const storage = createConversationStorage({
|
|
165
|
+
readRecord: () => loaded.projection.record,
|
|
166
|
+
writeRecord: (_scope, record) => save(id, loaded, record, trx)
|
|
167
|
+
});
|
|
168
|
+
entered = true;
|
|
169
|
+
return storage[write ? "write" : "read"](id, operation);
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// SQLite cannot wait asynchronously for another connection's writer.
|
|
173
|
+
// Retry acquiring the snapshot/lock only; never replay a caller or save.
|
|
174
|
+
if (entered || error.code !== "SQLITE_BUSY" || attempt >= 5) throw error;
|
|
175
|
+
await delay(10 * 2 ** attempt);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
read: (id, operation) => access(id, operation, false),
|
|
182
|
+
write: (id, operation) => access(id, operation, true),
|
|
183
|
+
preflight(id) {
|
|
184
|
+
return conversationsRepository.transaction(async trx => {
|
|
185
|
+
const { rows, projection } = await load(id, trx);
|
|
186
|
+
return { conversationId: id, rowCount: rows.length, issues: projection.issues };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export { createSqlConversationStorage, inspectSqlConversationRows };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
2
|
+
import { composeSchemaDefinitions } from "@jskit-ai/kernel/shared/validators";
|
|
3
|
+
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
4
|
+
import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
5
|
+
import { assistantSurfaceRouteParamsValidator } from "../inputSchemas.js";
|
|
6
|
+
|
|
7
|
+
function requireWorkspaceAssistantRouteParams(workspaceScopeSupport = null) {
|
|
8
|
+
if (!workspaceScopeSupport) {
|
|
9
|
+
throw new Error("Assistant workspace routes require workspace server scope support.");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return composeSchemaDefinitions(
|
|
13
|
+
[workspaceScopeSupport.params, assistantSurfaceRouteParamsValidator],
|
|
14
|
+
{
|
|
15
|
+
mode: "patch",
|
|
16
|
+
context: "assistant-runtime workspace surface route params"
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readWorkspaceInput(request, requiresWorkspace, workspaceScopeSupport = null) {
|
|
22
|
+
if (requiresWorkspace !== true) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!workspaceScopeSupport) {
|
|
27
|
+
throw new Error("Assistant workspace routes require workspace server scope support.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return workspaceScopeSupport.buildInputFromRouteParams(request?.input?.params);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
|
|
34
|
+
const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
|
|
35
|
+
if (assistantSurface) {
|
|
36
|
+
return assistantSurface;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new AppError(404, "Assistant not found.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireHostSurfaceId(request) {
|
|
43
|
+
const headerValue = Array.isArray(request?.headers?.["x-jskit-surface"])
|
|
44
|
+
? request.headers["x-jskit-surface"][0]
|
|
45
|
+
: request?.headers?.["x-jskit-surface"];
|
|
46
|
+
const hostSurfaceId = normalizeSurfaceId(headerValue);
|
|
47
|
+
if (hostSurfaceId) {
|
|
48
|
+
return hostSurfaceId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new AppError(400, "Assistant surface header x-jskit-surface is required.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveRouteRequestState(
|
|
55
|
+
request,
|
|
56
|
+
{
|
|
57
|
+
resolveCurrentAppConfig = () => ({}),
|
|
58
|
+
kind = "runtime",
|
|
59
|
+
requiresWorkspace = false,
|
|
60
|
+
workspaceScopeSupport = null
|
|
61
|
+
} = {}
|
|
62
|
+
) {
|
|
63
|
+
const appConfig = resolveCurrentAppConfig();
|
|
64
|
+
const targetSurfaceId = normalizeSurfaceId(request?.input?.params?.surfaceId);
|
|
65
|
+
const assistantSurface = requireAssistantSurface(appConfig, targetSurfaceId);
|
|
66
|
+
const hostSurfaceId = requireHostSurfaceId(request);
|
|
67
|
+
const expectsWorkspace = kind === "settings"
|
|
68
|
+
? assistantSurface.settingsSurfaceRequiresWorkspace
|
|
69
|
+
: assistantSurface.runtimeSurfaceRequiresWorkspace;
|
|
70
|
+
const expectedHostSurfaceId = kind === "settings"
|
|
71
|
+
? assistantSurface.settingsSurfaceId
|
|
72
|
+
: assistantSurface.targetSurfaceId;
|
|
73
|
+
|
|
74
|
+
if (expectsWorkspace !== (requiresWorkspace === true)) {
|
|
75
|
+
throw new AppError(404, "Assistant route not found.");
|
|
76
|
+
}
|
|
77
|
+
if (hostSurfaceId !== expectedHostSurfaceId) {
|
|
78
|
+
throw new AppError(403, "Assistant route is not available on this surface.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
assistantSurface,
|
|
83
|
+
hostSurfaceId,
|
|
84
|
+
actionInput: Object.freeze({
|
|
85
|
+
targetSurfaceId: assistantSurface.targetSurfaceId,
|
|
86
|
+
...readWorkspaceInput(request, requiresWorkspace, workspaceScopeSupport)
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { requireWorkspaceAssistantRouteParams, resolveRouteRequestState };
|
|
@@ -42,9 +42,13 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, resolveAppConf
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
return Object.freeze({
|
|
45
|
-
|
|
45
|
+
get limits() {
|
|
46
|
+
if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(resolveCurrentAppConfig(), ""));
|
|
47
|
+
return schemaCatalog.limits;
|
|
48
|
+
},
|
|
49
|
+
resolveToolSet(context = {}, options) {
|
|
46
50
|
const surfaceId = requireContextSurfaceId(context);
|
|
47
|
-
return resolveCatalog(surfaceId).resolveToolSet(context);
|
|
51
|
+
return resolveCatalog(surfaceId).resolveToolSet(context, options);
|
|
48
52
|
},
|
|
49
53
|
toOpenAiToolSchema(tool) {
|
|
50
54
|
if (!schemaCatalog) {
|
|
@@ -3,6 +3,19 @@ import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/
|
|
|
3
3
|
|
|
4
4
|
const CONSOLE_OWNER_ACCESS_POLICY_ID = "console_owner";
|
|
5
5
|
|
|
6
|
+
/** Declare one surface explicitly; full application surface maps remain supported. */
|
|
7
|
+
function defineAssistantSurface(surfaceId, { pagesRoot = "", requiresWorkspace = false,
|
|
8
|
+
configScope = requiresWorkspace ? "workspace" : "global", ...definition } = {}) {
|
|
9
|
+
const id = normalizeSurfaceId(surfaceId);
|
|
10
|
+
if (!id) throw new TypeError("An assistant surface id is required.");
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
surfaceDefinitions: Object.freeze({ [id]: Object.freeze({ ...definition, pagesRoot, requiresWorkspace }) }),
|
|
13
|
+
assistantSurfaces: Object.freeze({ [id]: Object.freeze({
|
|
14
|
+
settingsSurfaceId: id, configScope: normalizeAssistantConfigScope(configScope)
|
|
15
|
+
}) })
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
function resolveSurfaceDefinitions(appConfig = {}) {
|
|
7
20
|
const source = normalizeObject(appConfig?.surfaceDefinitions);
|
|
8
21
|
const resolved = {};
|
|
@@ -90,6 +103,7 @@ function resolveAssistantSurfaceConfig(appConfig = {}, targetSurfaceId = "") {
|
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
export {
|
|
106
|
+
defineAssistantSurface,
|
|
93
107
|
normalizeAssistantConfigScope,
|
|
94
108
|
resolveAssistantSurfaceConfig,
|
|
95
109
|
resolveAssistantSurfacesConfig,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const ASSISTANT_CONVERSATION_SUBSCRIBE = "assistant.conversation.subscribe";
|
|
2
|
+
const ASSISTANT_CONVERSATION_UNSUBSCRIBE = "assistant.conversation.unsubscribe";
|
|
3
|
+
const ASSISTANT_CONVERSATION_EVENT = "assistant.conversation.event";
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
ASSISTANT_CONVERSATION_SUBSCRIBE,
|
|
7
|
+
ASSISTANT_CONVERSATION_UNSUBSCRIBE,
|
|
8
|
+
ASSISTANT_CONVERSATION_EVENT
|
|
9
|
+
};
|
package/src/shared/index.js
CHANGED
|
@@ -97,3 +97,47 @@ test("assistant settings update keeps patch nested under one schema definition",
|
|
|
97
97
|
patch: { systemPrompt: "Be concise." }
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
test("host access wraps only canonical conversation definitions and receives the fixed subscription id", async () => {
|
|
102
|
+
const wrapped = [];
|
|
103
|
+
const subscribeActionId = "test.host.conversation.subscribe";
|
|
104
|
+
const conversation = { read: () => ({ id: "retained-conversation" }) };
|
|
105
|
+
const conversationAccess = {
|
|
106
|
+
subscribeActionId,
|
|
107
|
+
wrapAction(definition) {
|
|
108
|
+
wrapped.push(definition);
|
|
109
|
+
return { ...definition, permission: { require: "none" }, extensions: { hostAccess: true } };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const definitions = createAssistantActions({
|
|
113
|
+
config: CONFIG,
|
|
114
|
+
conversationRuntime: { open: async () => conversation },
|
|
115
|
+
conversationAccess,
|
|
116
|
+
assistantConfigService: {}
|
|
117
|
+
});
|
|
118
|
+
assert.deepEqual(wrapped.map(({ id }) => id), [
|
|
119
|
+
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, subscribeActionId,
|
|
120
|
+
actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
|
|
121
|
+
]);
|
|
122
|
+
assert.equal(findDefinition(definitions, actionIds.conversationSubscribe), undefined);
|
|
123
|
+
assert.equal(findDefinition(definitions, subscribeActionId).audit.actionName, subscribeActionId);
|
|
124
|
+
for (const definition of wrapped) {
|
|
125
|
+
const adapted = findDefinition(definitions, definition.id);
|
|
126
|
+
assert.equal(adapted.input, definition.input);
|
|
127
|
+
assert.equal(adapted.execute, definition.execute);
|
|
128
|
+
assert.deepEqual(adapted.surfaces, ["home", "admin"]);
|
|
129
|
+
}
|
|
130
|
+
for (const id of [actionIds.settingsRead, actionIds.settingsUpdate]) {
|
|
131
|
+
const settings = findDefinition(definitions, id);
|
|
132
|
+
assert.equal(settings.permission.require, "authenticated");
|
|
133
|
+
assert.equal(settings.extensions.hostAccess, undefined);
|
|
134
|
+
assert.deepEqual(settings.surfaces, ["console", "admin"]);
|
|
135
|
+
}
|
|
136
|
+
assert.deepEqual(await findDefinition(definitions, actionIds.conversationRead).execute({
|
|
137
|
+
targetSurfaceId: "home", conversationId: "retained-conversation"
|
|
138
|
+
}, {}), { id: "retained-conversation" });
|
|
139
|
+
const legacy = createAssistantActions({ config: CONFIG, chatService: {}, assistantConfigService: {},
|
|
140
|
+
conversationAccess: { wrapAction() { assert.fail("Legacy definitions must retain their own access policy."); } } });
|
|
141
|
+
assert.deepEqual(legacy.map(({ id }) => id), createDefinitions().map(({ id }) => id));
|
|
142
|
+
assert.ok(legacy.every(definition => definition.permission.require === "authenticated"));
|
|
143
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
|
|
4
|
-
import { createAssistantAiClientFactory } from "../src/server/
|
|
4
|
+
import { createAssistantAiClientFactory } from "../src/server/createAssistantRuntime.js";
|
|
5
5
|
|
|
6
6
|
test("assistant connections authorize each request and resolve rotated and individual credentials afresh", async t => {
|
|
7
7
|
const keys = { alice: "alice-first", bob: "bob-only" };
|