@jskit-ai/assistant-runtime 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/fixtures/canonical-conversation/main.js +2 -1
- package/fixtures/canonical-conversation/vite.config.mjs +11 -0
- package/fixtures/conversation/main.js +2 -1
- package/fixtures/conversation/vite.config.mjs +4 -3
- package/locales/en.json +1 -0
- package/migrations/assistant_config_initial.cjs +1 -5
- package/migrations/assistant_transcripts_initial.cjs +3 -9
- package/migrations/assistant_transcripts_summary.cjs +28 -0
- package/migrations/assistant_turn_requests.cjs +2 -1
- package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
- package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
- package/package.json +17 -10
- package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
- package/src/client/components/AssistantConversationClientElement.vue +10 -4
- package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
- package/src/client/composables/conversationGoal.js +266 -0
- package/src/client/composables/conversationHistory.js +215 -0
- package/src/client/composables/conversationRuntime.js +624 -0
- package/src/client/composables/useAssistantConversation.js +208 -840
- package/src/client/composables/useAssistantRuntime.js +60 -45
- package/src/client/providers/AssistantClientProvider.js +1 -0
- package/src/client/support/assistantRuntimeState.js +6 -34
- package/src/client/support/conversationFailures.js +30 -0
- package/src/client/support/conversationRestoreSupport.js +13 -5
- package/src/client/support/subscribeAssistantConversation.js +32 -9
- package/src/client/support/workspaceScopeSupport.js +13 -22
- package/src/client/types.js +46 -0
- package/src/server/AssistantProvider.js +45 -17
- package/src/server/actions.js +162 -201
- package/src/server/conversationOperations.js +141 -0
- package/src/server/createAssistantRuntime.js +83 -44
- package/src/server/createAssistantSettingsActions.js +69 -47
- package/src/server/hostedAssistant.js +21 -0
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +34 -11
- package/src/server/registerFastifyConversations.js +18 -6
- package/src/server/registerRoutes.js +5 -2
- package/src/server/registerSettingsRoutes.js +1 -1
- package/src/server/repositories/assistantConfigRepository.js +10 -20
- package/src/server/repositories/conversationsRepository.js +35 -10
- package/src/server/repositories/messagesRepository.js +14 -10
- package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
- package/src/server/repositories/turnRequestsRepository.js +6 -1
- package/src/server/services/assistantConfigService.js +25 -23
- package/src/server/services/chatService.js +57 -61
- package/src/server/services/transcriptService.js +15 -7
- package/src/server/sqlConversationStorage.js +90 -18
- package/src/server/support/assistantServerConfig.js +1 -1
- package/src/server/support/assistantStreamWriter.js +7 -1
- package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
- package/src/server/types.js +38 -0
- package/src/shared/assistantSurfaces.js +2 -2
- package/test/assistantConversationBinding.browser.test.js +115 -1
- package/test/assistantConversationOperations.test.js +5 -5
- package/test/assistantRuntimeState.test.js +1 -16
- package/test/chatServiceLifecycle.test.js +62 -154
- package/test/createSurfaceAwareToolCatalog.test.js +5 -0
- package/test/featureRuntime.test.js +12 -6
- package/test/lazyAppConfig.test.js +3 -5
- package/test/packageMetadata.test.js +7 -1
- package/test/sqlConversationStorage.mysql.test.js +89 -0
- package/test/sqlConversationStorage.test.js +161 -16
- package/test/support/memoryAssistantRows.js +6 -1
- package/test/support/scriptedModel.js +32 -0
- package/test/turnRequests.test.js +4 -2
- package/test/usersForeignKeysMigration.test.js +46 -0
- package/test/workspacesForeignKeysMigration.test.js +66 -0
- package/src/client/support/accessDenied.js +0 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { isDeepStrictEqual } from "node:util";
|
|
2
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
4
|
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
@@ -87,34 +88,85 @@ function requireUnambiguous(projection) {
|
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
// Rows kept in memory across every cached conversation. A conversation with more rows
|
|
92
|
+
// than this is read from the database on each access.
|
|
93
|
+
const CACHED_ROWS = 50_000;
|
|
94
|
+
|
|
95
|
+
/** The SQL rows are authoritative. Core supplies the transaction methods;
|
|
91
96
|
* the database owns locking, sequence allocation and commit/rollback together.
|
|
97
|
+
* Each conversation's rows stay cached while its transcript revision and last message
|
|
98
|
+
* sequence are unchanged. Every save that changes rows or the summary writes a new
|
|
99
|
+
* random revision under the conversation lock, so a save by another server makes the
|
|
100
|
+
* next access read the rows again, as does a row inserted without this storage; the
|
|
101
|
+
* cache takes a save only after its transaction commits.
|
|
102
|
+
* @param {object} dependencies
|
|
103
|
+
* @param {import("./types.js").ConversationsRepository} dependencies.conversationsRepository
|
|
104
|
+
* @param {import("./types.js").MessagesRepository} dependencies.messagesRepository
|
|
92
105
|
*/
|
|
93
106
|
function createSqlConversationStorage({ conversationsRepository, messagesRepository }) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
const cache = new Map();
|
|
108
|
+
let cachedRows = 0;
|
|
109
|
+
|
|
110
|
+
function forget(id) {
|
|
111
|
+
const entry = cache.get(id);
|
|
112
|
+
if (!entry) return;
|
|
113
|
+
cache.delete(id);
|
|
114
|
+
cachedRows -= entry.rows.length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function remember(id, entry) {
|
|
118
|
+
forget(id);
|
|
119
|
+
if (!entry.revision || entry.rows.length > CACHED_ROWS) return;
|
|
120
|
+
cache.set(id, entry);
|
|
121
|
+
cachedRows += entry.rows.length;
|
|
122
|
+
for (const [oldest, value] of cache) {
|
|
123
|
+
if (cachedRows <= CACHED_ROWS) break;
|
|
124
|
+
cache.delete(oldest);
|
|
125
|
+
cachedRows -= value.rows.length;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function readRows(id, conversation, trx) {
|
|
97
130
|
const rows = [];
|
|
98
131
|
for (let page = 1; ; page += 1) {
|
|
99
132
|
const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 }, { trx });
|
|
100
133
|
rows.push(...batch);
|
|
101
134
|
if (batch.length < 500) break;
|
|
102
135
|
}
|
|
103
|
-
return
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function load(id, trx, write = false) {
|
|
140
|
+
const conversation = await conversationsRepository.findById(id, { trx, forUpdate: write });
|
|
141
|
+
if (!conversation) throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
142
|
+
const { revision, summary } = await conversationsRepository.readTranscriptState(id, { trx });
|
|
143
|
+
const lastSeq = await messagesRepository.lastSequence(id, { trx });
|
|
144
|
+
let saved = cache.get(id);
|
|
145
|
+
if (saved && revision && saved.revision === revision && saved.lastSeq === lastSeq) remember(id, saved);
|
|
146
|
+
else {
|
|
147
|
+
const rows = await readRows(id, conversation, trx);
|
|
148
|
+
saved = { revision, lastSeq, rows, projection: inspectSqlConversationRows(conversation, rows) };
|
|
149
|
+
remember(id, saved);
|
|
150
|
+
}
|
|
151
|
+
const { projection } = saved;
|
|
152
|
+
const record = { ...projection.record, metadata: structuredClone(conversation.metadata || {}), ...(summary ? { summary } : {}) };
|
|
153
|
+
return { conversation, summary, lastSeq, rows: saved.rows, projection: { ...projection, record }, next: null };
|
|
104
154
|
}
|
|
105
155
|
|
|
106
156
|
async function save(id, loaded, record, trx) {
|
|
107
157
|
const { conversation, projection } = loaded;
|
|
108
158
|
let added = 0;
|
|
109
159
|
let title = conversation.title;
|
|
160
|
+
const updated = new Map();
|
|
161
|
+
const inserted = [];
|
|
110
162
|
const persist = async (previous, fields) => {
|
|
111
163
|
if (previous) {
|
|
112
164
|
if (previous.contentText !== fields.contentText || !isDeepStrictEqual(previous.metadata, fields.metadata)) {
|
|
113
|
-
await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata }, { trx });
|
|
165
|
+
updated.set(previous.id, await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata }, { trx }));
|
|
114
166
|
}
|
|
115
167
|
} else {
|
|
116
|
-
await messagesRepository.create({ conversationId: id, workspaceId: conversation.workspaceId,
|
|
117
|
-
actorUserId: conversation.createdByUserId, ...fields }, { trx });
|
|
168
|
+
inserted.push(await messagesRepository.create({ conversationId: id, workspaceId: conversation.workspaceId,
|
|
169
|
+
actorUserId: conversation.createdByUserId, ...fields }, { trx }));
|
|
118
170
|
added += 1;
|
|
119
171
|
}
|
|
120
172
|
};
|
|
@@ -152,14 +204,22 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
152
204
|
...(title !== conversation.title ? { title } : {}),
|
|
153
205
|
...(!isDeepStrictEqual(record.metadata, conversation.metadata) ? { metadata: record.metadata } : {}) };
|
|
154
206
|
if (Object.keys(patch).length) await conversationsRepository.updateById(id, patch, { trx });
|
|
207
|
+
const summary = record.summary ? { text: record.summary.text, throughTurnId: record.summary.throughTurnId } : null;
|
|
208
|
+
if (!updated.size && !inserted.length && isDeepStrictEqual(summary, loaded.summary)) return;
|
|
209
|
+
const revision = randomUUID();
|
|
210
|
+
await conversationsRepository.writeTranscriptState(id, { revision, summary }, { trx });
|
|
211
|
+
const rows = loaded.rows.map(row => updated.get(row.id) || row).concat(inserted);
|
|
212
|
+
loaded.next = { revision, lastSeq: Math.max(loaded.lastSeq, ...inserted.map(row => row.seq)), rows,
|
|
213
|
+
projection: inspectSqlConversationRows(conversation, rows) };
|
|
155
214
|
}
|
|
156
215
|
|
|
157
216
|
async function access(id, operation, write) {
|
|
158
217
|
for (let attempt = 0; ; attempt += 1) {
|
|
159
218
|
let entered = false;
|
|
219
|
+
let loaded = null;
|
|
160
220
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
221
|
+
const result = await conversationsRepository.transaction(async trx => {
|
|
222
|
+
loaded = await load(id, trx, write);
|
|
163
223
|
requireUnambiguous(loaded.projection);
|
|
164
224
|
const storage = createConversationStorage({
|
|
165
225
|
readRecord: () => loaded.projection.record,
|
|
@@ -168,6 +228,8 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
168
228
|
entered = true;
|
|
169
229
|
return storage[write ? "write" : "read"](id, operation);
|
|
170
230
|
}, { retries: 0 });
|
|
231
|
+
if (loaded.next) remember(id, loaded.next);
|
|
232
|
+
return result;
|
|
171
233
|
} catch (error) {
|
|
172
234
|
// SQLite cannot wait asynchronously for another connection's writer.
|
|
173
235
|
// Retry acquiring the snapshot/lock only; never replay a caller or save,
|
|
@@ -178,15 +240,25 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
|
|
|
178
240
|
}
|
|
179
241
|
}
|
|
180
242
|
|
|
243
|
+
function read(id, operation) {
|
|
244
|
+
return access(id, operation, false);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function write(id, operation) {
|
|
248
|
+
return access(id, operation, true);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function preflight(id) {
|
|
252
|
+
return conversationsRepository.transaction(async trx => {
|
|
253
|
+
const { rows, projection } = await load(id, trx);
|
|
254
|
+
return { conversationId: id, rowCount: rows.length, issues: projection.issues };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
181
258
|
return Object.freeze({
|
|
182
|
-
read
|
|
183
|
-
write
|
|
184
|
-
preflight
|
|
185
|
-
return conversationsRepository.transaction(async trx => {
|
|
186
|
-
const { rows, projection } = await load(id, trx);
|
|
187
|
-
return { conversationId: id, rowCount: rows.length, issues: projection.issues };
|
|
188
|
-
});
|
|
189
|
-
}
|
|
259
|
+
read,
|
|
260
|
+
write,
|
|
261
|
+
preflight
|
|
190
262
|
});
|
|
191
263
|
}
|
|
192
264
|
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
normalizeTimeoutMs
|
|
7
7
|
} from "@jskit-ai/assistant-core/server";
|
|
8
8
|
|
|
9
|
-
function resolveAssistantServerConfigMap(appConfig
|
|
9
|
+
function resolveAssistantServerConfigMap(appConfig) {
|
|
10
10
|
const source = normalizeObject(appConfig?.assistantServer);
|
|
11
11
|
const resolved = {};
|
|
12
12
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The stream writer contract
|
|
1
|
+
// The stream writer contract: the chat route builds one writer with these methods, and the chat service sends each event
|
|
2
|
+
// through it.
|
|
2
3
|
const ASSISTANT_STREAM_WRITER_METHODS = Object.freeze([
|
|
3
4
|
"sendMeta",
|
|
4
5
|
"sendAssistantDelta",
|
|
@@ -9,4 +10,9 @@ const ASSISTANT_STREAM_WRITER_METHODS = Object.freeze([
|
|
|
9
10
|
"sendDone"
|
|
10
11
|
]);
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The writer a chat stream sends its events through, one method per event kind.
|
|
15
|
+
* @typedef {Record<(typeof ASSISTANT_STREAM_WRITER_METHODS)[number], (event: object) => void>} AssistantStreamWriter
|
|
16
|
+
*/
|
|
17
|
+
|
|
12
18
|
export { ASSISTANT_STREAM_WRITER_METHODS };
|
|
@@ -12,7 +12,7 @@ function buildCatalogOptions(appConfig = {}, surfaceId = "") {
|
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function requireContextSurfaceId(context
|
|
15
|
+
function requireContextSurfaceId(context) {
|
|
16
16
|
const surfaceId = normalizeSurfaceId(context?.surface);
|
|
17
17
|
if (surfaceId) {
|
|
18
18
|
return surfaceId;
|
|
@@ -21,7 +21,19 @@ function requireContextSurfaceId(context = {}) {
|
|
|
21
21
|
throw new Error("assistant surface-aware tool catalog requires context.surface.");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The assistant's tool catalogue over an application's actions, resolved per surface. Each surface
|
|
26
|
+
* offers the actions its `assistantServer` configuration leaves in (its barred action ids and
|
|
27
|
+
* skipped prefixes taken out), and every call names its surface in `context.surface`. The assistant
|
|
28
|
+
* runtime builds its catalogue this way; an application test builds one the same way to see the
|
|
29
|
+
* tools a surface offers and to call them.
|
|
30
|
+
* @param {import("@jskit-ai/kernel/server/types").ActionCatalogue} actions The application's actions (`runtime.actions`).
|
|
31
|
+
* @param {object} [options]
|
|
32
|
+
* @param {Record<string, any>} [options.appConfig] The server configuration that holds `assistantServer`.
|
|
33
|
+
* @param {typeof createServiceToolCatalog} [options.createCatalog]
|
|
34
|
+
*/
|
|
24
35
|
function createSurfaceAwareToolCatalog(actions, { appConfig = {}, createCatalog = createServiceToolCatalog } = {}) {
|
|
36
|
+
// readable-check: allow method-probe - input validation: an application hands over its action catalogue
|
|
25
37
|
if (!actions || typeof actions.listDefinitions !== "function") {
|
|
26
38
|
throw new Error("createSurfaceAwareToolCatalog requires runtime.actions.");
|
|
27
39
|
}
|
|
@@ -40,27 +52,33 @@ function createSurfaceAwareToolCatalog(actions, { appConfig = {}, createCatalog
|
|
|
40
52
|
return nextCatalog;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
function resolveToolSet(context = {}, options) {
|
|
56
|
+
const surfaceId = requireContextSurfaceId(context);
|
|
57
|
+
return resolveCatalog(surfaceId).resolveToolSet(context, options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toOpenAiToolSchema(tool) {
|
|
61
|
+
if (!schemaCatalog) {
|
|
62
|
+
schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return schemaCatalog.toOpenAiToolSchema(tool);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function executeToolCall(payload = {}) {
|
|
69
|
+
const context = payload.context && typeof payload.context === "object" ? payload.context : {};
|
|
70
|
+
const surfaceId = requireContextSurfaceId(context);
|
|
71
|
+
return resolveCatalog(surfaceId).executeToolCall(payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
return Object.freeze({
|
|
44
75
|
get limits() {
|
|
45
76
|
if (!schemaCatalog) schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
|
|
46
77
|
return schemaCatalog.limits;
|
|
47
78
|
},
|
|
48
|
-
resolveToolSet
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
},
|
|
52
|
-
toOpenAiToolSchema(tool) {
|
|
53
|
-
if (!schemaCatalog) {
|
|
54
|
-
schemaCatalog = createCatalog(actions, buildCatalogOptions(normalizedAppConfig, ""));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return schemaCatalog.toOpenAiToolSchema(tool);
|
|
58
|
-
},
|
|
59
|
-
executeToolCall(payload = {}) {
|
|
60
|
-
const context = payload?.context && typeof payload.context === "object" ? payload.context : {};
|
|
61
|
-
const surfaceId = requireContextSurfaceId(context);
|
|
62
|
-
return resolveCatalog(surfaceId).executeToolCall(payload);
|
|
63
|
-
}
|
|
79
|
+
resolveToolSet,
|
|
80
|
+
toOpenAiToolSchema,
|
|
81
|
+
executeToolCall
|
|
64
82
|
});
|
|
65
83
|
}
|
|
66
84
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The type of the `assistant.runtime` capability, the contracts of the optional
|
|
2
|
+
// capabilities the assistant runtime reads, and the types its modules share.
|
|
3
|
+
|
|
4
|
+
/** @typedef {ReturnType<typeof import("./hostedAssistant.js").createHostedAssistant>} HostedAssistant The assistant over a conversation runtime that the application supplies as `assistant.conversations`. */
|
|
5
|
+
/** @typedef {ReturnType<typeof import("./createAssistantRuntime.js").createAssistantRuntime>} DatabaseAssistant The assistant over the package's own SQL tables. */
|
|
6
|
+
/** @typedef {HostedAssistant | DatabaseAssistant} AssistantRuntime The `assistant.runtime` capability. */
|
|
7
|
+
|
|
8
|
+
/** @typedef {import("@jskit-ai/assistant-core/server/types").ConversationRuntime} ConversationRuntime The `assistant.conversations` capability: the conversation runtime an application supplies. */
|
|
9
|
+
/** @typedef {ReturnType<typeof import("./services/assistantConfigService.js").createService>} AssistantConfigService The `assistant.settings` capability: an assistant surface's saved settings. */
|
|
10
|
+
/** @typedef {ReturnType<typeof import("./services/chatService.js").createChatService>} ChatService */
|
|
11
|
+
/** @typedef {ReturnType<typeof import("./services/transcriptService.js").createTranscriptService>} TranscriptService */
|
|
12
|
+
/** @typedef {ReturnType<typeof import("./createAssistantRuntime.js").createAssistantAiClientFactory>} AssistantAiClientFactory */
|
|
13
|
+
/** @typedef {ReturnType<typeof import("./support/createSurfaceAwareToolCatalog.js").createSurfaceAwareToolCatalog>} SurfaceAwareToolCatalog */
|
|
14
|
+
/** @typedef {ReturnType<typeof import("./repositories/assistantConfigRepository.js").createRepository>} AssistantConfigRepository */
|
|
15
|
+
/** @typedef {ReturnType<typeof import("./repositories/conversationsRepository.js").createRepository>} ConversationsRepository */
|
|
16
|
+
/** @typedef {ReturnType<typeof import("./repositories/messagesRepository.js").createRepository>} MessagesRepository */
|
|
17
|
+
/** @typedef {ReturnType<typeof import("./repositories/turnRequestsRepository.js").createRepository>} TurnRequestsRepository */
|
|
18
|
+
|
|
19
|
+
/** @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. */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The `assistant.conversation.access` capability: a host's access owner for the
|
|
23
|
+
* conversation routes and actions.
|
|
24
|
+
* @typedef {object} ConversationAccess
|
|
25
|
+
* @property {{ register: import("@jskit-ai/kernel/server/types").RuntimeRouter["register"] }} router Registers a conversation route behind the host's access check.
|
|
26
|
+
* @property {(definition: object) => object} wrapAction Wraps a conversation action definition with the host's access policy.
|
|
27
|
+
* @property {string} subscribeActionId The fixed id of the host's conversation subscription action.
|
|
28
|
+
* @property {"authenticated" | "host"} requestPolicy How a realtime subscription reads its request.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The `assistant.attachments` capability: the application's authorized files for
|
|
33
|
+
* a message.
|
|
34
|
+
* @typedef {object} AssistantAttachments
|
|
35
|
+
* @property {(request: { attachmentIds: string[], context: object, workspace: object | null, conversation: object }) => Promise<unknown>} resolve
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export {};
|
|
@@ -17,7 +17,7 @@ function defineAssistantSurface(surfaceId, { pagesRoot = "", requiresWorkspace =
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function resolveSurfaceDefinitions(appConfig
|
|
20
|
+
function resolveSurfaceDefinitions(appConfig) {
|
|
21
21
|
const source = normalizeObject(appConfig?.surfaceDefinitions);
|
|
22
22
|
const resolved = {};
|
|
23
23
|
|
|
@@ -38,7 +38,7 @@ function resolveSurfaceDefinitions(appConfig = {}) {
|
|
|
38
38
|
return Object.freeze(resolved);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function resolveAssistantSurfacesConfig(appConfig
|
|
41
|
+
function resolveAssistantSurfacesConfig(appConfig) {
|
|
42
42
|
const source = normalizeObject(appConfig?.assistantSurfaces);
|
|
43
43
|
const resolved = {};
|
|
44
44
|
|
|
@@ -393,6 +393,117 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
|
|
|
393
393
|
assert.equal((await requests()).length, 1, "An undeclared goal command is not exposed or dispatched");
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
test("Start goal stays Starting… until the goal is read after the server's answer, and no second start is sent", options, async t => {
|
|
397
|
+
const f = await fixture(t);
|
|
398
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
399
|
+
set: { delivery: "message", interruptsTurn: false }, pause: { delivery: "control", interruptsTurn: true }
|
|
400
|
+
} });
|
|
401
|
+
await f.notify("chat:1");
|
|
402
|
+
const starts = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
|
|
403
|
+
await f.primary.getByRole("button", { name: "Set goal", exact: true }).click();
|
|
404
|
+
const objective = f.page.getByRole("textbox", { name: "Goal objective" });
|
|
405
|
+
const start = f.page.locator(".assistant-goal__form button[type=submit]");
|
|
406
|
+
await objective.fill("Finish while the goal read is slow");
|
|
407
|
+
// Goal reads made after Start wait, as a slow server read does.
|
|
408
|
+
const reads = Promise.withResolvers();
|
|
409
|
+
let heldReads = 0;
|
|
410
|
+
await f.page.route("**/conversations/*/goal", async route => {
|
|
411
|
+
if (route.request().method() === "GET") {
|
|
412
|
+
heldReads += 1;
|
|
413
|
+
await reads.promise;
|
|
414
|
+
}
|
|
415
|
+
await route.continue();
|
|
416
|
+
});
|
|
417
|
+
await start.click();
|
|
418
|
+
await expect.poll(async () => (await starts()).length).toBe(1);
|
|
419
|
+
await expect.poll(() => heldReads).toBeGreaterThan(0);
|
|
420
|
+
// The server has answered; the control has not yet read the goal it started.
|
|
421
|
+
await expect(start).toHaveText("Starting…");
|
|
422
|
+
await expect(start).toBeDisabled();
|
|
423
|
+
await expect(objective).toHaveValue("Finish while the goal read is slow");
|
|
424
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "A second start" })), false);
|
|
425
|
+
reads.resolve();
|
|
426
|
+
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
|
|
427
|
+
assert.equal((await starts()).length, 1, "One Start sends one goal command");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("a Start accepted behind the running turn stays Starting… until its goal runs, and says so when it never starts", options, async t => {
|
|
431
|
+
const f = await fixture(t);
|
|
432
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
433
|
+
set: { delivery: "message", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false }
|
|
434
|
+
} });
|
|
435
|
+
await f.notify("chat:1");
|
|
436
|
+
const starts = async () => (await f.state()).requests.filter(request =>
|
|
437
|
+
request.suffix === "/goal" && request.method === "POST" && request.input.action === "set");
|
|
438
|
+
const control = name => f.primary.getByRole("button", { name, exact: true });
|
|
439
|
+
const objective = f.page.getByRole("textbox", { name: "Goal objective" });
|
|
440
|
+
const start = f.page.locator(".assistant-goal__form button[type=submit]");
|
|
441
|
+
await f.command("mode", { mode: "queue-goal" });
|
|
442
|
+
await control("Set goal").click();
|
|
443
|
+
await objective.fill("Start after the current turn");
|
|
444
|
+
await start.click();
|
|
445
|
+
await expect.poll(async () => (await starts()).length).toBe(1);
|
|
446
|
+
await f.notify("chat:1");
|
|
447
|
+
// Accepted, but its goal waits behind the running turn.
|
|
448
|
+
await expect(start).toHaveText("Starting…");
|
|
449
|
+
await expect(start).toBeDisabled();
|
|
450
|
+
await expect(objective).toHaveValue("Start after the current turn");
|
|
451
|
+
await f.page.keyboard.press("Escape");
|
|
452
|
+
await expect(control("Starting…")).toBeVisible();
|
|
453
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "A second start" })), false);
|
|
454
|
+
await f.command("start-queued-goal", { id: "chat:1" });
|
|
455
|
+
await f.notify("chat:1");
|
|
456
|
+
await expect(control("Goal running")).toBeVisible();
|
|
457
|
+
assert.equal((await starts()).length, 1);
|
|
458
|
+
|
|
459
|
+
// A Start the conversation finishes without says so and keeps its objective.
|
|
460
|
+
await control("Goal running").click();
|
|
461
|
+
await f.page.getByRole("button", { name: "Cancel goal", exact: true }).click();
|
|
462
|
+
await expect(control("Set goal")).toBeVisible();
|
|
463
|
+
await objective.fill("Never takes effect");
|
|
464
|
+
await start.click();
|
|
465
|
+
await expect.poll(async () => (await starts()).length).toBe(2);
|
|
466
|
+
await f.notify("chat:1");
|
|
467
|
+
await expect(start).toBeDisabled();
|
|
468
|
+
await f.command("finish", { id: "chat:1" });
|
|
469
|
+
await f.notify("chat:1");
|
|
470
|
+
await expect(f.page.getByRole("alert").filter({ hasText: "The goal did not start. Check the chat before starting it again." })).toBeVisible();
|
|
471
|
+
await expect(objective).toHaveValue("Never takes effect");
|
|
472
|
+
await expect(start).toBeEnabled();
|
|
473
|
+
assert.equal((await starts()).length, 2);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("Cancel stays available while a Resume waits behind the running turn and ends that wait", options, async t => {
|
|
477
|
+
const f = await fixture(t);
|
|
478
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
479
|
+
set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
|
|
480
|
+
pause: { delivery: "control", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false }
|
|
481
|
+
} });
|
|
482
|
+
await f.notify("chat:1");
|
|
483
|
+
const control = name => f.primary.getByRole("button", { name, exact: true });
|
|
484
|
+
const menu = name => f.page.getByRole("button", { name, exact: true });
|
|
485
|
+
await control("Set goal").click();
|
|
486
|
+
await f.page.getByRole("textbox", { name: "Goal objective" }).fill("Resume after the current turn");
|
|
487
|
+
await f.page.locator(".assistant-goal__form button[type=submit]").click();
|
|
488
|
+
await expect(control("Goal running")).toHaveAttribute("aria-expanded", "false");
|
|
489
|
+
await control("Goal running").click();
|
|
490
|
+
await menu("Pause goal").click();
|
|
491
|
+
await expect(control("Goal paused")).toBeVisible();
|
|
492
|
+
await f.command("mode", { mode: "queue-goal" });
|
|
493
|
+
await menu("Resume goal").click();
|
|
494
|
+
await expect(menu("Resuming…")).toBeDisabled();
|
|
495
|
+
await f.notify("chat:1");
|
|
496
|
+
await expect(menu("Cancel goal")).toBeEnabled();
|
|
497
|
+
await expect(menu("Resuming…")).toBeDisabled();
|
|
498
|
+
await menu("Cancel goal").click();
|
|
499
|
+
await expect(control("Set goal")).toBeVisible();
|
|
500
|
+
await f.command("finish", { id: "chat:1" });
|
|
501
|
+
await f.notify("chat:1");
|
|
502
|
+
await expect(f.page.getByRole("alert")).toHaveCount(0);
|
|
503
|
+
const commands = (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
|
|
504
|
+
assert.deepEqual(commands.map(request => request.input.action), ["set", "pause", "resume", "cancel"]);
|
|
505
|
+
});
|
|
506
|
+
|
|
396
507
|
test("a supplied goal view keeps its pinned target and refreshes through the retained binding without polling", options, async t => {
|
|
397
508
|
const f = await fixture(t);
|
|
398
509
|
const goal = { id: "retained-goal", objective: "The plain product objective", status: "paused",
|
|
@@ -3372,10 +3483,13 @@ test("a background goal read keeps current pinned controls available and seriali
|
|
|
3372
3483
|
await expect(f.page.getByRole("button", { name: "Cancel goal", exact: true })).toBeDisabled();
|
|
3373
3484
|
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("cancel")), false);
|
|
3374
3485
|
releaseCommand();
|
|
3375
|
-
|
|
3486
|
+
// The answered Pause stays Pausing… until a goal read made after its answer
|
|
3487
|
+
// lands, so the stale goal never offers its controls again.
|
|
3488
|
+
await expect(f.page.getByRole("button", { name: "Pausing…", exact: true })).toBeDisabled();
|
|
3376
3489
|
assert.equal(commands.length, 1, "Only the actual Pause command was admitted");
|
|
3377
3490
|
holdRead = false;
|
|
3378
3491
|
releaseRead();
|
|
3492
|
+
await expect(pause).toBeEnabled();
|
|
3379
3493
|
await f.page.evaluate(() => window.fixtureBackgroundGoalRead);
|
|
3380
3494
|
assert.deepEqual(await f.sent(), []);
|
|
3381
3495
|
} finally {
|
|
@@ -336,7 +336,7 @@ test("conversation routes keep authenticated ownership and reject unknown identi
|
|
|
336
336
|
assert.equal(accepted.payload.status, "accepted");
|
|
337
337
|
await f.dispatched[0].promise;
|
|
338
338
|
const body = JSON.parse(f.requests[0].body);
|
|
339
|
-
assert.
|
|
339
|
+
assert.match(body.messages[0].content, /^Answer briefly\.\n\nCurrent state, from the system\. /u, "The system prompt, then the engine's state note");
|
|
340
340
|
assert.equal(body.messages.at(-1).content, "Use the host configuration.");
|
|
341
341
|
assert.equal((await f.invoke("GET")).payload.engine, "api");
|
|
342
342
|
});
|
|
@@ -390,13 +390,13 @@ test("declared conversation data keeps the authored target without becoming auth
|
|
|
390
390
|
const state = (await f.invoke("GET")).payload;
|
|
391
391
|
assert.deepEqual(state.conversationLog[0].user.data, data);
|
|
392
392
|
assert.equal(state.engine, "api");
|
|
393
|
-
assert.
|
|
393
|
+
assert.match(JSON.parse(f.requests[0].body).messages[0].content, /^Answer briefly\.\n\nCurrent state, from the system\. /u);
|
|
394
394
|
const repeated = await f.invoke("POST", "/messages", { body: input });
|
|
395
395
|
assert.equal(repeated.payload.duplicate, true);
|
|
396
396
|
assert.equal(repeated.payload.turnId, accepted.payload.turnId);
|
|
397
397
|
await assert.rejects(f.invoke("POST", "/messages", { body: { ...input,
|
|
398
398
|
data: { ...data, focus: { projectSlug: "new-project" } }
|
|
399
|
-
} }), { code: "
|
|
399
|
+
} }), { code: "conversation_message_id_content_conflict" });
|
|
400
400
|
assert.equal(f.requests.length, 1, "Retargeting a captured message cannot dispatch it again");
|
|
401
401
|
});
|
|
402
402
|
|
|
@@ -407,7 +407,7 @@ test("goal operations retain capabilities and cannot forward native host or atta
|
|
|
407
407
|
assert.equal(absentGoal.status, 204, "An absent native goal uses the client's existing no-content response contract");
|
|
408
408
|
await assert.rejects(f.invoke("POST", "/goal", { body: {
|
|
409
409
|
action: "set", messageId: "goal-one", expectedSegmentId: "current", objective: "Do this"
|
|
410
|
-
} }), { code: "
|
|
410
|
+
} }), { code: "conversation_goals_unsupported" });
|
|
411
411
|
assert.equal(f.requests.length, 0, "An unsupported goal never starts inference");
|
|
412
412
|
|
|
413
413
|
const calls = [];
|
|
@@ -497,7 +497,7 @@ test("declared replacement keeps application policy and exact predecessor author
|
|
|
497
497
|
await assert.rejects(call({ replacement: { ...replacement, ...extra } }), { code: "ACTION_VALIDATION_FAILED" });
|
|
498
498
|
}
|
|
499
499
|
await assert.rejects(call({ replacement: { ...replacement, expectedSegmentId: "stale-predecessor" } }),
|
|
500
|
-
{ code: "
|
|
500
|
+
{ code: "conversation_predecessor_changed" });
|
|
501
501
|
const replaced = await call({ replacement, engine: "codex", host: { nativeTools: true }, context: { forged: true } });
|
|
502
502
|
assert.equal(replaced.status, 200);
|
|
503
503
|
assert.equal(replaced.payload.operationId, replacement.operationId);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import {
|
|
4
|
-
buildHistory,
|
|
5
4
|
interruptPendingToolEvents,
|
|
6
5
|
mapTranscriptEntriesToAssistantState
|
|
7
6
|
} from "../src/client/support/assistantRuntimeState.js";
|
|
8
7
|
|
|
9
|
-
test("restored progress narration is
|
|
8
|
+
test("restored progress narration is not rendered", () => {
|
|
10
9
|
const restored = mapTranscriptEntriesToAssistantState([
|
|
11
10
|
{
|
|
12
11
|
id: "1",
|
|
@@ -42,20 +41,6 @@ test("restored progress narration is neither rendered nor replayed", () => {
|
|
|
42
41
|
"Let me check. The second booking is also confirmed."
|
|
43
42
|
]
|
|
44
43
|
);
|
|
45
|
-
assert.deepEqual(buildHistory(restored.messages), [
|
|
46
|
-
{
|
|
47
|
-
role: "user",
|
|
48
|
-
content: "Find the booking."
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
role: "assistant",
|
|
52
|
-
content: "The booking is confirmed."
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
role: "assistant",
|
|
56
|
-
content: "Let me check. The second booking is also confirmed."
|
|
57
|
-
}
|
|
58
|
-
]);
|
|
59
44
|
});
|
|
60
45
|
|
|
61
46
|
test("restored orphaned tool calls are marked interrupted", () => {
|