@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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/canonical-conversation/vite.config.mjs +11 -0
  4. package/fixtures/conversation/main.js +2 -1
  5. package/fixtures/conversation/vite.config.mjs +4 -3
  6. package/locales/en.json +1 -0
  7. package/migrations/assistant_config_initial.cjs +1 -5
  8. package/migrations/assistant_transcripts_initial.cjs +3 -9
  9. package/migrations/assistant_transcripts_summary.cjs +28 -0
  10. package/migrations/assistant_turn_requests.cjs +2 -1
  11. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  12. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  13. package/package.json +17 -10
  14. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  15. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  16. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  17. package/src/client/composables/conversationGoal.js +266 -0
  18. package/src/client/composables/conversationHistory.js +215 -0
  19. package/src/client/composables/conversationRuntime.js +624 -0
  20. package/src/client/composables/useAssistantConversation.js +208 -840
  21. package/src/client/composables/useAssistantRuntime.js +60 -45
  22. package/src/client/providers/AssistantClientProvider.js +1 -0
  23. package/src/client/support/assistantRuntimeState.js +6 -34
  24. package/src/client/support/conversationFailures.js +30 -0
  25. package/src/client/support/conversationRestoreSupport.js +13 -5
  26. package/src/client/support/subscribeAssistantConversation.js +32 -9
  27. package/src/client/support/workspaceScopeSupport.js +13 -22
  28. package/src/client/types.js +46 -0
  29. package/src/server/AssistantProvider.js +45 -17
  30. package/src/server/actions.js +162 -201
  31. package/src/server/conversationOperations.js +141 -0
  32. package/src/server/createAssistantRuntime.js +83 -44
  33. package/src/server/createAssistantSettingsActions.js +69 -47
  34. package/src/server/hostedAssistant.js +21 -0
  35. package/src/server/index.js +1 -0
  36. package/src/server/registerConversationSubscriptions.js +34 -11
  37. package/src/server/registerFastifyConversations.js +18 -6
  38. package/src/server/registerRoutes.js +5 -2
  39. package/src/server/registerSettingsRoutes.js +1 -1
  40. package/src/server/repositories/assistantConfigRepository.js +10 -20
  41. package/src/server/repositories/conversationsRepository.js +35 -10
  42. package/src/server/repositories/messagesRepository.js +14 -10
  43. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  44. package/src/server/repositories/turnRequestsRepository.js +6 -1
  45. package/src/server/services/assistantConfigService.js +25 -23
  46. package/src/server/services/chatService.js +57 -61
  47. package/src/server/services/transcriptService.js +15 -7
  48. package/src/server/sqlConversationStorage.js +90 -18
  49. package/src/server/support/assistantServerConfig.js +1 -1
  50. package/src/server/support/assistantStreamWriter.js +7 -1
  51. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  52. package/src/server/types.js +38 -0
  53. package/src/shared/assistantSurfaces.js +2 -2
  54. package/test/assistantConversationBinding.browser.test.js +115 -1
  55. package/test/assistantConversationOperations.test.js +5 -5
  56. package/test/assistantRuntimeState.test.js +1 -16
  57. package/test/chatServiceLifecycle.test.js +62 -154
  58. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  59. package/test/featureRuntime.test.js +12 -6
  60. package/test/lazyAppConfig.test.js +3 -5
  61. package/test/packageMetadata.test.js +7 -1
  62. package/test/sqlConversationStorage.mysql.test.js +89 -0
  63. package/test/sqlConversationStorage.test.js +161 -16
  64. package/test/support/memoryAssistantRows.js +6 -1
  65. package/test/support/scriptedModel.js +32 -0
  66. package/test/turnRequests.test.js +4 -2
  67. package/test/usersForeignKeysMigration.test.js +46 -0
  68. package/test/workspacesForeignKeysMigration.test.js +66 -0
  69. 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
- /** Existing SQL rows remain authoritative. Core supplies the transaction methods;
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
- 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.", { code: "assistant.runtime.conversation.notFound" });
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 { conversation, rows, projection: inspectSqlConversationRows(conversation, rows) };
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
- return await conversationsRepository.transaction(async trx => {
162
- const loaded = await load(id, trx, write);
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: (id, operation) => access(id, operation, false),
183
- write: (id, operation) => access(id, operation, true),
184
- preflight(id) {
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 shared by the chat route and the chat service.
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(context = {}, options) {
49
- const surfaceId = requireContextSurfaceId(context);
50
- return resolveCatalog(surfaceId).resolveToolSet(context, options);
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
- await expect(pause).toBeEnabled();
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.equal(body.messages[0].content, "Answer briefly.");
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.equal(JSON.parse(f.requests[0].body).messages[0].content, "Answer briefly.");
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: "conversation_message_conflict" });
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: "conversation_unsupported" });
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: "conversation_replacement_conflict" });
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 neither rendered nor replayed", () => {
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", () => {