@aistrike-dev/ui-shell-dev 2.0.3 → 2.1.1

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 (45) hide show
  1. package/README.md +41 -17
  2. package/dist/args.d.ts +9 -1
  3. package/dist/args.js +51 -5
  4. package/dist/cell.d.ts +29 -0
  5. package/dist/cell.js +194 -0
  6. package/dist/cli.js +25 -1
  7. package/dist/devfile.d.ts +25 -9
  8. package/dist/devfile.js +75 -30
  9. package/dist/hosts.d.ts +17 -0
  10. package/dist/hosts.js +51 -0
  11. package/dist/inject.js +0 -3
  12. package/dist/platform/host.d.ts +5 -7
  13. package/dist/platform/host.js +4 -4
  14. package/dist/platform/list.d.ts +2 -2
  15. package/dist/platform/list.js +6 -5
  16. package/dist/platform/server.d.ts +3 -2
  17. package/dist/platform/server.js +21 -10
  18. package/dist/ports.d.ts +12 -0
  19. package/dist/ports.js +14 -0
  20. package/dist/preamble.d.ts +15 -0
  21. package/dist/preamble.js +44 -0
  22. package/dist/serve.d.ts +28 -0
  23. package/dist/serve.js +161 -0
  24. package/dist/token.d.ts +6 -1
  25. package/dist/token.js +2 -2
  26. package/dist/vite/plugin.d.ts +12 -3
  27. package/dist/vite/plugin.js +126 -27
  28. package/dist/vite/targets.d.ts +27 -0
  29. package/dist/vite/targets.js +35 -0
  30. package/package.json +2 -2
  31. package/platform/assistant/mock.js +1809 -222
  32. package/platform/assistant/ui/chunks/ui-CnxV0v5I.js +280 -0
  33. package/platform/assistant/ui/entry.js +2 -2
  34. package/platform/home/ui/chunks/ui-BJZEfOZI.js +174 -0
  35. package/platform/home/ui/entry.js +1 -1
  36. package/platform/settings/ui/chunks/ui-BRCsapmB.js +271 -0
  37. package/platform/settings/ui/entry.js +2 -2
  38. package/shell/assets/LabHost-D3o-OJsx.js +2 -0
  39. package/shell/assets/index-DW0Z5YeB.js +185 -0
  40. package/shell/index.html +1 -1
  41. package/platform/assistant/ui/chunks/ui-C9pWqM_D.js +0 -182
  42. package/platform/home/ui/chunks/ui-SNPmLvIE.js +0 -174
  43. package/platform/settings/ui/chunks/ui-C21hUUVD.js +0 -271
  44. package/shell/assets/LabHost-BiiVtiYJ.js +0 -2
  45. package/shell/assets/index-CqB5Mswt.js +0 -184
@@ -1,33 +1,351 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- //#region src/mock/conversations.ts
3
- /** `cnv_` and a UUID's 32 hex digits (agent conversations API 3, ids). */
4
- var newConversationId = () => `cnv_${randomUUID().replace(/-/g, "")}`;
5
- /** The mock's conversations, by id, in memory: at most `limit` (default 100). */
6
- function createConversations(limit = 100, newId = newConversationId) {
7
- const byId = /* @__PURE__ */ new Map();
2
+ //#region src/mock/http.ts
3
+ /** The sessions API's media type (session pane design 9); `application/json` alone gets the latest version. */
4
+ var MEDIA_TYPE = "application/vnd.aistrike.sessions+json; version=1.0";
5
+ /** The body's bytes, or `'too_large'` once they pass `cap`; the rest is read and dropped so the client can finish sending. */
6
+ async function readRaw(req, cap) {
7
+ const declared = Number(req.headers["content-length"]);
8
+ let over = Number.isFinite(declared) && declared > cap;
9
+ const chunks = [];
10
+ let size = 0;
11
+ for await (const chunk of req) {
12
+ if (over) continue;
13
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
14
+ size += buffer.length;
15
+ if (size > cap) {
16
+ over = true;
17
+ chunks.length = 0;
18
+ } else chunks.push(buffer);
19
+ }
20
+ return over ? "too_large" : Buffer.concat(chunks);
21
+ }
22
+ /** A JSON body; `undefined` when it does not parse. */
23
+ async function readJson(req, cap = 1048576) {
24
+ const raw = await readRaw(req, cap);
25
+ if (raw === "too_large") return;
26
+ try {
27
+ return JSON.parse(raw.toString("utf8"));
28
+ } catch {
29
+ return;
30
+ }
31
+ }
32
+ /** The client may read these on a cross-origin response: the dev servers answer CORS but expose nothing by default. */
33
+ var EXPOSED = "ETag, Location, Content-Disposition";
34
+ function sendJson(res, status, body, contentType = "application/json") {
35
+ res.statusCode = status;
36
+ res.setHeader("Content-Type", contentType);
37
+ res.setHeader("Access-Control-Expose-Headers", EXPOSED);
38
+ res.end(JSON.stringify(body));
39
+ }
40
+ /** An error in the API's envelope: `errors: [{ id, message, referenceId }]` and no `data`. */
41
+ function refuse(res, status, id, message) {
42
+ sendJson(res, status, { errors: [{
43
+ id,
44
+ message,
45
+ referenceId: randomUUID()
46
+ }] });
47
+ }
48
+ /** The vendor type when the request asked for it, else plain JSON. */
49
+ function contentTypeFor(req) {
50
+ return /application\/vnd\.aistrike\.sessions\+json/i.test(req.headers.accept ?? "") ? MEDIA_TYPE : "application/json";
51
+ }
52
+ /** A JSON answer with an `ETag` over its bytes; `304` when the client already holds it. */
53
+ function sendTagged(req, res, status, body) {
54
+ const text = JSON.stringify(body);
55
+ const etag = `"${createHash("sha256").update(text).digest("hex").slice(0, 32)}"`;
56
+ res.setHeader("ETag", etag);
57
+ res.setHeader("Access-Control-Expose-Headers", EXPOSED);
58
+ if (status === 200 && req.headers["if-none-match"] === etag) {
59
+ res.statusCode = 304;
60
+ res.end();
61
+ return;
62
+ }
63
+ res.statusCode = status;
64
+ res.setHeader("Content-Type", contentTypeFor(req));
65
+ res.end(text);
66
+ }
67
+ /** Bytes as a download: their type, `content-disposition: attachment`, and no sniffing. */
68
+ function sendBytes(res, bytes, type, disposition) {
69
+ res.statusCode = 200;
70
+ res.setHeader("Content-Type", type);
71
+ res.setHeader("Content-Length", String(bytes.length));
72
+ res.setHeader("Content-Disposition", disposition);
73
+ res.setHeader("X-Content-Type-Options", "nosniff");
74
+ res.setHeader("Access-Control-Expose-Headers", EXPOSED);
75
+ res.end(bytes);
76
+ }
77
+ var pause = async (ms) => new Promise((resolve) => {
78
+ setTimeout(resolve, ms);
79
+ });
80
+ //#endregion
81
+ //#region src/mock/access.ts
82
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
83
+ var MODES = /* @__PURE__ */ new Set([
84
+ "auto",
85
+ "ask",
86
+ "plan",
87
+ "agent"
88
+ ]);
89
+ var isSkillRef = (value) => value === void 0 || isObject(value) && typeof value.id === "string";
90
+ var isIdList = (value) => value === void 0 || Array.isArray(value) && value.every((id) => typeof id === "string");
91
+ function isInput(value) {
92
+ if (!isObject(value)) return false;
93
+ switch (value.kind) {
94
+ case "message": return typeof value.text === "string" && Array.isArray(value.refs) && isSkillRef(value.skill) && isIdList(value.attachments);
95
+ case "page_tool_results": return Array.isArray(value.results) && value.results.every((result) => isObject(result) && typeof result.id === "string");
96
+ case "approval": return typeof value.id === "string" && typeof value.approve === "boolean";
97
+ default: return false;
98
+ }
99
+ }
100
+ /** Enough of the context's shape for the responder to read it without throwing. */
101
+ function isContext(value) {
102
+ if (!isObject(value)) return false;
103
+ const { route, title, screen, domains, pageTools, suggestions } = value;
104
+ const screenOk = screen === null || isObject(screen) && Array.isArray(screen.entities) && Array.isArray(screen.selection);
105
+ return isObject(route) && typeof title === "string" && screenOk && [
106
+ domains,
107
+ pageTools,
108
+ suggestions
109
+ ].every((list) => Array.isArray(list));
110
+ }
111
+ /** `{ tenantIds: [...] }` with at least one id, or `{ all: true }`, and nothing else (authorization standard). */
112
+ function isTenantScope(value) {
113
+ if (!isObject(value)) return false;
114
+ const keys = Object.keys(value);
115
+ if (keys.length === 1 && value.all === true) return true;
116
+ const ids = value.tenantIds;
117
+ return keys.length === 1 && Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string" && id !== "") && new Set(ids).size === ids.length;
118
+ }
119
+ var isScope = (value) => isObject(value) && typeof value.orgId === "string" && value.orgId !== "" && isTenantScope(value.tenantScope);
120
+ var tenantsKey = (scope) => "all" in scope ? "*" : [...scope.tenantIds].sort().join("\n");
121
+ /** The same org and the same tenants, in any order. */
122
+ function sameScope(a, b) {
123
+ return a.orgId === b.orgId && tenantsKey(a.tenantScope) === tenantsKey(b.tenantScope);
124
+ }
125
+ var isRequest = (value) => isScope(value) && isObject(value) && MODES.has(value.mode) && isContext(value.context) && isInput(value.input);
126
+ /**
127
+ * The scope a request names in its query: `orgId` and one or more `tenantId`, or `orgId` and `all=true` (agent
128
+ * conversations API 5.3, open question 2). `null` when it names none; `'invalid'` when it names one badly.
129
+ */
130
+ function queryScope(url) {
131
+ const orgId = url.searchParams.get("orgId");
132
+ const tenants = url.searchParams.getAll("tenantId");
133
+ const all = url.searchParams.get("all");
134
+ if (orgId === null && tenants.length === 0 && all === null) return null;
135
+ const scope = {
136
+ orgId,
137
+ tenantScope: all === "true" ? { all: true } : { tenantIds: tenants }
138
+ };
139
+ return (all === null || all === "true" && tenants.length === 0) && isScope(scope) ? scope : "invalid";
140
+ }
141
+ var SCOPE_RULE = "The scope is orgId with tenantId, or orgId with all=true";
142
+ /** Unknown, deleted, or in another scope: the same answer (agent conversations API 4). */
143
+ var notFound = (res) => refuse(res, 404, "session_not_found", "No such session");
144
+ /**
145
+ * A session the request may see: it exists, and the scope the query names, if it names one, is its own. Otherwise the
146
+ * answer is sent and the result is `null`.
147
+ */
148
+ function lookup(url, id, res, sessions) {
149
+ const scope = queryScope(url);
150
+ if (scope === "invalid") {
151
+ refuse(res, 400, "invalid_request", SCOPE_RULE);
152
+ return null;
153
+ }
154
+ const session = sessions.get(id);
155
+ if (session === void 0 || scope !== null && !sameScope(session.scope, scope)) {
156
+ notFound(res);
157
+ return null;
158
+ }
159
+ return session;
160
+ }
161
+ //#endregion
162
+ //#region src/mock/multipart.ts
163
+ /** The boundary of a `multipart/form-data` content type, or `null` when it is not one or has none. */
164
+ function boundaryOf(contentType) {
165
+ if (contentType === void 0 || !/^multipart\/form-data\s*(;|$)/i.test(contentType.trim())) return null;
166
+ const match = /;\s*boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
167
+ const boundary = match?.[1] ?? match?.[2] ?? null;
168
+ return boundary !== null && boundary.length >= 1 && boundary.length <= 70 ? boundary : null;
169
+ }
170
+ /** A header parameter, quoted or not; `filename*` (RFC 5987, `UTF-8''…`) wins over `filename`. */
171
+ function parameter(header, name) {
172
+ const extended = new RegExp(`;\\s*${name}\\*\\s*=\\s*([^']*)'[^']*'([^;]*)`, "i").exec(header);
173
+ if (extended !== null && /^utf-8$/i.test(extended[1])) try {
174
+ return decodeURIComponent(extended[2].trim());
175
+ } catch {
176
+ return null;
177
+ }
178
+ const plain = new RegExp(`;\\s*${name}\\s*=\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^;]*))`, "i").exec(header);
179
+ if (plain === null) return null;
180
+ return plain[1] === void 0 ? plain[2].trim() : plain[1].replace(/\\(.)/g, "$1");
181
+ }
182
+ function headersOf(block) {
183
+ const headers = {};
184
+ for (const line of block.split("\r\n")) {
185
+ const colon = line.indexOf(":");
186
+ if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
187
+ }
188
+ return headers;
189
+ }
190
+ var CRLF = Buffer.of(13, 10);
191
+ var HEADERS_END = Buffer.concat([CRLF, CRLF]);
192
+ /** The part whose headers start at `at` (just past its delimiter's CRLF), and where it ends; `null` if malformed. */
193
+ function partAt(body, at, next) {
194
+ const headersEnd = body.indexOf(HEADERS_END, at);
195
+ if (headersEnd === -1) return null;
196
+ const headers = headersOf(body.subarray(at, headersEnd).toString("utf8"));
197
+ const dataStart = headersEnd + HEADERS_END.length;
198
+ const dataEnd = body.indexOf(next, dataStart);
199
+ const disposition = headers["content-disposition"] ?? "";
200
+ const name = /^form-data\b/i.test(disposition) ? parameter(disposition, "name") : null;
201
+ if (dataEnd === -1 || name === null) return null;
8
202
  return {
9
- get: (id) => byId.get(id),
10
- create(scope) {
11
- const now = (/* @__PURE__ */ new Date()).toISOString();
12
- const conversation = {
13
- id: newId(),
14
- scope,
15
- createdAt: now,
16
- updatedAt: now,
17
- busy: false,
18
- user: null,
19
- context: null,
20
- pending: null
21
- };
22
- byId.set(conversation.id, conversation);
23
- while (byId.size > limit) {
24
- const oldest = byId.keys().next().value;
25
- if (oldest === void 0) break;
26
- byId.delete(oldest);
27
- }
28
- return conversation;
29
- }
203
+ part: {
204
+ name,
205
+ filename: parameter(disposition, "filename"),
206
+ contentType: headers["content-type"] ?? null,
207
+ data: body.subarray(dataStart, dataEnd)
208
+ },
209
+ end: dataEnd + next.length
210
+ };
211
+ }
212
+ /** Where the first part starts: past the first delimiter, which starts the body or follows a preamble's CRLF; -1 if none. */
213
+ function firstPart(body, delimiter, next) {
214
+ if (body.subarray(0, delimiter.length).equals(delimiter)) return delimiter.length;
215
+ const found = body.indexOf(next);
216
+ return found === -1 ? -1 : found + next.length;
217
+ }
218
+ /** The parts of a body, or `null` when it is not well-formed multipart with this boundary. */
219
+ function parseMultipart(body, boundary) {
220
+ const delimiter = Buffer.from(`--${boundary}`);
221
+ const next = Buffer.concat([CRLF, delimiter]);
222
+ let at = firstPart(body, delimiter, next);
223
+ const parts = [];
224
+ while (at !== -1) {
225
+ if (body.subarray(at, at + 2).toString("latin1") === "--") return parts;
226
+ while (body[at] === 32 || body[at] === 9) at += 1;
227
+ const found = body.subarray(at, at + 2).equals(CRLF) ? partAt(body, at + 2, next) : null;
228
+ if (found === null) return null;
229
+ parts.push(found.part);
230
+ at = found.end;
231
+ }
232
+ return null;
233
+ }
234
+ /**
235
+ * A file name fit to show and to put in a header: its last path segment, without control characters, at most 255
236
+ * characters; `file` when nothing is left. It is shown as text and never interpreted.
237
+ */
238
+ function safeFileName(raw) {
239
+ const cleaned = ((raw ?? "").split(/[\\/]/).at(-1) ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 255);
240
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? "file" : cleaned;
241
+ }
242
+ /** `content-disposition: attachment` for a name: an ASCII fallback and the exact name in `filename*` (RFC 6266). */
243
+ function attachmentDisposition(name) {
244
+ return `attachment; filename="${name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`;
245
+ }
246
+ //#endregion
247
+ //#region src/mock/files.ts
248
+ /** Limits (session pane design 9.4, 14). */
249
+ var MAX_FILE = 20971520;
250
+ var MAX_ATTACHMENTS = 100;
251
+ /** Refused to exercise the UI's `415`. */
252
+ var REFUSED_TYPES = /* @__PURE__ */ new Set(["application/x-msdownload"]);
253
+ var ONE_FILE = "The body is multipart/form-data with one file part";
254
+ var TOO_LARGE = "A file is at most 20 MiB";
255
+ /** The one `file` part of an upload, `'too_large'`, or `null` when the body is not one file part. */
256
+ async function fileOf(req, boundary) {
257
+ const raw = await readRaw(req, 21037056);
258
+ if (raw === "too_large") return raw;
259
+ const files = parseMultipart(raw, boundary)?.filter((part) => part.name === "file") ?? [];
260
+ if (files.length !== 1) return null;
261
+ return files[0].data.length > MAX_FILE ? "too_large" : files[0];
262
+ }
263
+ /** The part's type, as `type/subtype`; `application/octet-stream` when it has none or a malformed one. */
264
+ function mimeOf(part) {
265
+ const declared = (part.contentType ?? "").split(";")[0].trim().toLowerCase();
266
+ return /^[a-z0-9][\w.+-]*\/[\w.+-]+$/.test(declared) ? declared : "application/octet-stream";
267
+ }
268
+ /** `POST /api/sessions/{id}/attachments`: one `file` part, kept in memory only (9.8). */
269
+ async function upload(req, res, session) {
270
+ if (session.attachments.size >= MAX_ATTACHMENTS) {
271
+ refuse(res, 409, "too_many_attachments", `A session holds at most ${MAX_ATTACHMENTS} attachments`);
272
+ return;
273
+ }
274
+ const boundary = boundaryOf(req.headers["content-type"]);
275
+ const file = boundary === null ? null : await fileOf(req, boundary);
276
+ if (file === null || file === "too_large") {
277
+ refuse(res, file === null ? 400 : 413, file === null ? "invalid_request" : "request_too_large", file === null ? ONE_FILE : TOO_LARGE);
278
+ return;
279
+ }
280
+ const mime = mimeOf(file);
281
+ if (REFUSED_TYPES.has(mime)) {
282
+ refuse(res, 415, "unsupported_media_type", `${mime} is not accepted`);
283
+ return;
284
+ }
285
+ if (session.deleted) {
286
+ notFound(res);
287
+ return;
288
+ }
289
+ const id = `att_${randomUUID().replace(/-/g, "")}`;
290
+ const attachment = {
291
+ id,
292
+ name: safeFileName(file.filename),
293
+ mime,
294
+ size: file.data.length,
295
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
296
+ url: `sessions/${session.id}/attachments/${id}`
30
297
  };
298
+ session.attachments.set(id, {
299
+ attachment,
300
+ bytes: Buffer.from(file.data),
301
+ usedBy: null
302
+ });
303
+ res.setHeader("Location", `/api/${attachment.url}`);
304
+ sendJson(res, 201, { data: attachment }, contentTypeFor(req));
305
+ }
306
+ /** `GET` (the bytes, as a download) and `DELETE` (until a turn sends it) of one attachment. */
307
+ function attachmentRoute(req, res, session, attachmentId) {
308
+ const stored = session.attachments.get(attachmentId);
309
+ if (stored === void 0) {
310
+ refuse(res, 404, "not_found", "No such attachment");
311
+ return;
312
+ }
313
+ if (req.method === "GET") {
314
+ sendBytes(res, stored.bytes, stored.attachment.mime, attachmentDisposition(stored.attachment.name));
315
+ return;
316
+ }
317
+ if (stored.usedBy !== null) {
318
+ refuse(res, 409, "attachment_in_use", "A turn has sent this attachment");
319
+ return;
320
+ }
321
+ session.attachments.delete(attachmentId);
322
+ sendJson(res, 200, { data: { id: attachmentId } }, contentTypeFor(req));
323
+ }
324
+ /** One CSV cell (RFC 4180); text a spreadsheet would read as a formula is prefixed with `'`. */
325
+ function cell(value) {
326
+ if (value === null || value === void 0) return "";
327
+ let text = typeof value === "string" ? value : JSON.stringify(value);
328
+ if (typeof value === "string" && /^[=+\-@\t\r]/.test(text)) text = `'${text}`;
329
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
330
+ }
331
+ /** `GET /api/sessions/{id}/artifacts/{artifactId}` (9.5): a table as CSV, an image's or a file's bytes. */
332
+ function artifactRoute(res, session, artifactId) {
333
+ const found = session.artifacts.find((candidate) => candidate.id === artifactId);
334
+ if (found === void 0) {
335
+ refuse(res, 404, "not_found", "No such artifact");
336
+ return;
337
+ }
338
+ if (found.kind === "table") {
339
+ const lines = [found.columns.map((column) => cell(column.label)).join(",")].concat(found.rows.map((row) => found.columns.map((column) => cell(row[column.id])).join(",")));
340
+ sendBytes(res, Buffer.from(`${lines.join("\r\n")}\r\n`), "text/csv; charset=utf-8", attachmentDisposition(`${safeFileName(found.title)}.csv`));
341
+ return;
342
+ }
343
+ const bytes = session.artifactBytes.get(artifactId);
344
+ if ((found.kind === "image" || found.kind === "file") && bytes !== void 0) {
345
+ sendBytes(res, bytes, found.mime, attachmentDisposition(safeFileName(found.title)));
346
+ return;
347
+ }
348
+ refuse(res, 400, "invalid_request", "Only table, image and file artifacts have content to fetch");
31
349
  }
32
350
  //#endregion
33
351
  //#region ../../packages/sdk/dist/assistant/refs.js
@@ -201,7 +519,46 @@ var SEED_PROMPTS = [{
201
519
  tool: "search_alerts",
202
520
  input: { query: "credential dumping" }
203
521
  }]
522
+ }, {
523
+ domainId: "lab",
524
+ id: "alerts-by-host",
525
+ text: "Alerts by host",
526
+ label: "Alerts by host",
527
+ expects: []
204
528
  }];
529
+ /** Alert counts per host over a day, for the chart and the table the mock answers "Alerts by host" with. Synthetic. */
530
+ var HOSTS = [
531
+ {
532
+ host: "web-01",
533
+ alerts: 42,
534
+ critical: 3
535
+ },
536
+ {
537
+ host: "db-02",
538
+ alerts: 27,
539
+ critical: 1
540
+ },
541
+ {
542
+ host: "build-07",
543
+ alerts: 19,
544
+ critical: 0
545
+ },
546
+ {
547
+ host: "vpn-gw-1",
548
+ alerts: 14,
549
+ critical: 2
550
+ },
551
+ {
552
+ host: "mail-03",
553
+ alerts: 9,
554
+ critical: 0
555
+ },
556
+ {
557
+ host: "hr-laptop-12",
558
+ alerts: 5,
559
+ critical: 1
560
+ }
561
+ ];
205
562
  var SEED_SKILLS = [{
206
563
  id: "triage-alert",
207
564
  title: "Triage an alert",
@@ -400,7 +757,7 @@ var CHANGE = /\b(suppress|select|highlight|close|assign|delete|disable)\b/i;
400
757
  * Runs the calls: its own at once, reported as steps, then what they found; the page's asked of the UI, whose results
401
758
  * close the turn.
402
759
  */
403
- function run(calls, refs, conversation) {
760
+ function run(calls, refs, session) {
404
761
  const out = [];
405
762
  const lines = [];
406
763
  const asked = [];
@@ -436,12 +793,13 @@ function run(calls, refs, conversation) {
436
793
  type: "done",
437
794
  stop: "end"
438
795
  }];
439
- conversation.pending = {
796
+ session.pending = {
440
797
  kind: "page_tool",
441
798
  calls: calls.filter((c) => c.tool.kind === "page").map((c) => {
442
799
  return {
443
800
  id: c.id,
444
- tool: qualified(c.tool)
801
+ tool: qualified(c.tool),
802
+ input: c.input
445
803
  };
446
804
  })
447
805
  };
@@ -451,7 +809,7 @@ function run(calls, refs, conversation) {
451
809
  }];
452
810
  }
453
811
  /** Holds the calls for the person's approval: each as a step that waits, then the approval itself. */
454
- function propose(calls, conversation) {
812
+ function propose(calls, session) {
455
813
  const id = nextId();
456
814
  const steps = calls.map((c) => {
457
815
  return {
@@ -462,7 +820,7 @@ function propose(calls, conversation) {
462
820
  state: "awaiting-approval"
463
821
  };
464
822
  });
465
- conversation.pending = {
823
+ session.pending = {
466
824
  kind: "approval",
467
825
  id,
468
826
  calls: calls.map((c) => {
@@ -499,13 +857,13 @@ function rules(user, context, tools) {
499
857
  * The mode's say on what the rules decided (plan decision 2): Ask refuses a change in text, Plan holds a write for
500
858
  * approval, Agent runs it, and Auto runs one change and plans more than one.
501
859
  */
502
- function actOn(decided, mode, user, conversation) {
860
+ function actOn(decided, mode, user, session) {
503
861
  if ("text" in decided) return [modeEvent(mode, actingFor(mode, decided.acting, 0), decided.reason), ...say(decided.text)];
504
862
  const changes = decided.calls.filter((c) => c.tool.effect !== "read").length;
505
863
  const acts = actingFor(mode, decided.acting, changes);
506
864
  const head = modeEvent(mode, acts, acts === decided.acting ? decided.reason : "more than one change, so a plan first");
507
- if (changes === 0 || acts === "agent") return [head, ...run(decided.calls, user.refs, conversation)];
508
- return acts === "plan" ? [head, ...propose(decided.calls, conversation)] : [head, ...say(ASK_REFUSAL)];
865
+ if (changes === 0 || acts === "agent") return [head, ...run(decided.calls, user.refs, session)];
866
+ return acts === "plan" ? [head, ...propose(decided.calls, session)] : [head, ...say(ASK_REFUSAL)];
509
867
  }
510
868
  /** A skill chosen by id: the scripted reply names it and echoes the argument, or names it alone for empty text. */
511
869
  function useSkill(skill, user, mode) {
@@ -514,27 +872,161 @@ function useSkill(skill, user, mode) {
514
872
  const reply = argument === "" ? `${title}. Nothing more was given, so I used the page as it is.` : `${title}: ${argument}…`;
515
873
  return [modeEvent(mode, actingFor(mode, "ask", 0), QUESTION), ...say(`${reply} This is the mock's scripted reply; it runs no skill.`)];
516
874
  }
875
+ /** The chart and the table of alerts by host, as the mock's answer and its seeded session both hold them. */
876
+ function hostChart(id) {
877
+ return {
878
+ id,
879
+ kind: "chart",
880
+ title: "Alerts by host",
881
+ config: {
882
+ title: "Alerts by host",
883
+ subtitle: "Last 24 hours",
884
+ chartType: "bar",
885
+ legend: {
886
+ show: true,
887
+ position: "bottom"
888
+ },
889
+ xAxes: [{
890
+ id: "host",
891
+ type: "category",
892
+ name: "Host"
893
+ }],
894
+ yAxes: [{
895
+ id: "alerts",
896
+ type: "value",
897
+ name: "Alerts",
898
+ min: 0
899
+ }],
900
+ series: [{
901
+ id: "alerts",
902
+ name: "Alerts",
903
+ fields: {
904
+ x: "host",
905
+ y: "alerts"
906
+ }
907
+ }, {
908
+ id: "critical",
909
+ name: "Critical",
910
+ fields: {
911
+ x: "host",
912
+ y: "critical"
913
+ }
914
+ }]
915
+ },
916
+ rows: HOSTS.map((row) => {
917
+ return { ...row };
918
+ })
919
+ };
920
+ }
921
+ function hostTable(sessionId, id) {
922
+ return {
923
+ id,
924
+ kind: "table",
925
+ title: "Alerts by host",
926
+ columns: [
927
+ {
928
+ id: "host",
929
+ label: "Host"
930
+ },
931
+ {
932
+ id: "alerts",
933
+ label: "Alerts"
934
+ },
935
+ {
936
+ id: "critical",
937
+ label: "Critical"
938
+ }
939
+ ],
940
+ rows: HOSTS.map((row) => {
941
+ return { ...row };
942
+ }),
943
+ downloadUrl: `sessions/${sessionId}/artifacts/${id}`
944
+ };
945
+ }
946
+ var ALERTS_BY_HOST = /\balerts by host\b/i;
947
+ var CRITICAL_ALERTS = /\bshow critical alerts\b/i;
948
+ var MY_APPEARANCE = /\bmy appearance\b/i;
949
+ /** "Alerts by host": a chart and a table of the same counts (session pane design 10). */
950
+ function chartingHosts(mode, session) {
951
+ const [top] = [...HOSTS].sort((a, b) => b.alerts - a.alerts);
952
+ return [
953
+ modeEvent(mode, actingFor(mode, "ask", 0), QUESTION),
954
+ ...speak(`${top.host} raised the most alerts in the last 24 hours (${top.alerts}). The chart and the table hold the same counts.`),
955
+ {
956
+ type: "artifact",
957
+ ...hostChart(nextId())
958
+ },
959
+ {
960
+ type: "artifact",
961
+ ...hostTable(session.id, nextId())
962
+ },
963
+ {
964
+ type: "done",
965
+ stop: "end"
966
+ }
967
+ ];
968
+ }
969
+ /** "Show critical alerts": a view the person can open in the UI. */
970
+ function showingCritical(mode) {
971
+ return [
972
+ modeEvent(mode, actingFor(mode, "ask", 0), QUESTION),
973
+ ...speak("3 alerts are critical. Open them in Alerts to triage them."),
974
+ {
975
+ type: "view",
976
+ id: nextId(),
977
+ route: "/lab/alerts?severity=critical",
978
+ label: "Open Alerts filtered to critical",
979
+ reason: "The critical alerts you asked for"
980
+ },
981
+ {
982
+ type: "done",
983
+ stop: "end"
984
+ }
985
+ ];
986
+ }
987
+ /** "Open my appearance": a view of a page every dev host serves, so View in UI can succeed there. */
988
+ function showingAppearance(mode) {
989
+ return [
990
+ modeEvent(mode, actingFor(mode, "ask", 0), QUESTION),
991
+ ...speak("Your appearance settings hold your theme and interaction mode. Open them to review."),
992
+ {
993
+ type: "view",
994
+ id: nextId(),
995
+ route: "/settings/appearance",
996
+ label: "Open your appearance settings",
997
+ reason: "The page you asked for"
998
+ },
999
+ {
1000
+ type: "done",
1001
+ stop: "end"
1002
+ }
1003
+ ];
1004
+ }
517
1005
  /** The first answer to a message: the rules, in order, then the mode's say on what they decided. */
518
- function answer(request, user, conversation) {
1006
+ function answer(request, user, session) {
519
1007
  const { mode, context } = request;
520
1008
  if (user.skill !== void 0) return useSkill(user.skill, user, mode);
1009
+ const asked = stripMarkup(user.text);
1010
+ if (ALERTS_BY_HOST.test(asked)) return chartingHosts(mode, session);
1011
+ if (CRITICAL_ALERTS.test(asked)) return showingCritical(mode);
1012
+ if (MY_APPEARANCE.test(asked)) return showingAppearance(mode);
521
1013
  const tools = toolsOf(context);
522
1014
  const suggested = following(user, context, tools);
523
1015
  if (suggested === null && mode === "ask" && CHANGE.test(user.text)) return [modeEvent(mode, "ask", QUESTION), ...say(ASK_REFUSAL)];
524
- return actOn(suggested ?? rules(user, context, tools), mode, user, conversation);
1016
+ return actOn(suggested ?? rules(user, context, tools), mode, user, session);
525
1017
  }
526
- function afterPageTools(results, conversation) {
527
- const pending = conversation.pending;
1018
+ function afterPageTools(results, session) {
1019
+ const pending = session.pending;
528
1020
  if (pending?.kind !== "page_tool") return {
529
1021
  status: 409,
530
- message: "No page tool is waiting in this conversation"
1022
+ message: "No page tool is waiting in this session"
531
1023
  };
532
- conversation.pending = null;
1024
+ session.pending = null;
533
1025
  const tools = new Map(pending.calls.map((c) => [c.id, c.tool]));
534
1026
  return say(results.map((r) => sentence(tools.get(r.id) ?? "tool", r.ok, r.result, r.error)).join("\n\n"));
535
1027
  }
536
1028
  /** Runs the approved calls with the tools offered now; one the page no longer offers fails as the shell would say. */
537
- function approved(planned, context, conversation) {
1029
+ function approved(planned, context, session) {
538
1030
  const tools = toolsOf(context);
539
1031
  const calls = [];
540
1032
  const missing = [];
@@ -555,17 +1047,17 @@ function approved(planned, context, conversation) {
555
1047
  input: input ?? {}
556
1048
  });
557
1049
  }
558
- return [...missing, ...run(calls, conversation.user?.refs ?? [], conversation)];
1050
+ return [...missing, ...run(calls, session.user?.refs ?? [], session)];
559
1051
  }
560
- function afterApproval(request, input, conversation) {
561
- const pending = conversation.pending;
1052
+ function afterApproval(request, input, session) {
1053
+ const pending = session.pending;
562
1054
  if (pending?.kind !== "approval" || pending.id !== input.id) return {
563
1055
  status: 409,
564
- message: `No approval ${input.id} is waiting in this conversation`
1056
+ message: `No approval ${input.id} is waiting in this session`
565
1057
  };
566
- conversation.pending = null;
567
- conversation.context = request.context;
568
- if (input.approve) return approved(pending.calls, request.context, conversation);
1058
+ session.pending = null;
1059
+ session.context = request.context;
1060
+ if (input.approve) return approved(pending.calls, request.context, session);
569
1061
  return [...pending.calls.map((c) => {
570
1062
  return {
571
1063
  type: "step",
@@ -577,18 +1069,84 @@ function afterApproval(request, input, conversation) {
577
1069
  }), ...say("Understood, I left it alone.")];
578
1070
  }
579
1071
  /**
1072
+ * What the harness used for a turn: one of each kind, each running and then done, so the column fills in a demo. Memory
1073
+ * and a connector it pulled data through are sources; an agent and a workflow are actions.
1074
+ */
1075
+ var RESOURCES = [
1076
+ {
1077
+ kind: "memory",
1078
+ use: "source",
1079
+ label: "Triage preferences",
1080
+ detail: "Read 3 notes"
1081
+ },
1082
+ {
1083
+ kind: "connector",
1084
+ use: "source",
1085
+ label: "Lab API",
1086
+ detail: "Searched alerts"
1087
+ },
1088
+ {
1089
+ kind: "agent",
1090
+ use: "action",
1091
+ label: "Alert triage",
1092
+ detail: "Ranked the alerts in view"
1093
+ },
1094
+ {
1095
+ kind: "workflow",
1096
+ use: "action",
1097
+ label: "Suppress duplicates",
1098
+ detail: "Dry run: nothing changed"
1099
+ }
1100
+ ];
1101
+ function resources() {
1102
+ return RESOURCES.flatMap((resource) => {
1103
+ const id = nextId();
1104
+ return [{
1105
+ type: "resource",
1106
+ id,
1107
+ ...resource,
1108
+ state: "running"
1109
+ }, {
1110
+ type: "resource",
1111
+ id,
1112
+ ...resource,
1113
+ state: "done"
1114
+ }];
1115
+ });
1116
+ }
1117
+ /** A size as a person reads it: bytes, KB or MB, at 1024. */
1118
+ function sizeOf(bytes) {
1119
+ if (bytes < 1024) return `${bytes} B`;
1120
+ return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
1121
+ }
1122
+ /** A message's attachments, named with their sizes, ahead of the rest of the answer. */
1123
+ function attached(input, session) {
1124
+ const files = (input.attachments ?? []).flatMap((id) => {
1125
+ const stored = session.attachments.get(id);
1126
+ return stored === void 0 ? [] : [`${stored.attachment.name} (${sizeOf(stored.attachment.size)})`];
1127
+ });
1128
+ if (files.length === 0) return [];
1129
+ return speak(`You attached ${files.length} ${files.length === 1 ? "file" : "files"}: ${files.join(", ")}.\n\n`);
1130
+ }
1131
+ /**
580
1132
  * One request of a turn: a message starts the turn afresh, discarding anything it waited on; page tool results and an
581
- * approval answer what the conversation waits on, and anything else is a 409.
1133
+ * approval answer what the session waits on, and anything else is a 409.
582
1134
  */
583
- function respond(request, conversation) {
1135
+ function respond(request, session) {
584
1136
  const { input } = request;
585
1137
  if (input.kind === "message") {
586
- conversation.user = input;
587
- conversation.context = request.context;
588
- conversation.pending = null;
589
- return answer(request, input, conversation);
1138
+ session.user = input;
1139
+ session.context = request.context;
1140
+ session.pending = null;
1141
+ const [head, ...rest] = answer(request, input, session);
1142
+ return [
1143
+ head,
1144
+ ...resources(),
1145
+ ...attached(input, session),
1146
+ ...rest
1147
+ ];
590
1148
  }
591
- return input.kind === "page_tool_results" ? afterPageTools(input.results, conversation) : afterApproval(request, input, conversation);
1149
+ return input.kind === "page_tool_results" ? afterPageTools(input.results, session) : afterApproval(request, input, session);
592
1150
  }
593
1151
  /**
594
1152
  * `GET /api/prompts`: a domain's seeded prompts, without what they expect, which stays on the backend. In Ask, a
@@ -604,87 +1162,135 @@ function promptsFor(domainId, mode, prompts = SEED_PROMPTS) {
604
1162
  };
605
1163
  });
606
1164
  }
607
- //#endregion
608
- //#region src/mock/chatHandler.ts
609
- async function readBody(req) {
610
- const chunks = [];
611
- for await (const chunk of req) chunks.push(chunk);
612
- try {
613
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
614
- } catch {
615
- return;
616
- }
1165
+ var createChannel = (retain = 500, lastEventId = 0) => {
1166
+ return {
1167
+ lastEventId,
1168
+ retained: [],
1169
+ subscribers: /* @__PURE__ */ new Set(),
1170
+ retain
1171
+ };
1172
+ };
1173
+ /** Publishes one event: the next id, kept in the ring, sent to every subscriber. */
1174
+ function publish(channel, type, data) {
1175
+ channel.lastEventId += 1;
1176
+ const event = {
1177
+ id: channel.lastEventId,
1178
+ type,
1179
+ data
1180
+ };
1181
+ channel.retained.push(event);
1182
+ if (channel.retained.length > channel.retain) channel.retained.splice(0, channel.retained.length - channel.retain);
1183
+ for (const subscriber of [...channel.subscribers]) subscriber.send(event);
1184
+ return event;
617
1185
  }
618
- var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
619
- var MODES = /* @__PURE__ */ new Set([
620
- "auto",
621
- "ask",
622
- "plan",
623
- "agent"
624
- ]);
625
- var isSkillRef = (value) => value === void 0 || isObject(value) && typeof value.id === "string";
626
- function isInput(value) {
627
- if (!isObject(value)) return false;
628
- switch (value.kind) {
629
- case "message": return typeof value.text === "string" && Array.isArray(value.refs) && isSkillRef(value.skill);
630
- case "page_tool_results": return Array.isArray(value.results) && value.results.every((result) => isObject(result) && typeof result.id === "string");
631
- case "approval": return typeof value.id === "string" && typeof value.approve === "boolean";
632
- default: return false;
633
- }
1186
+ /**
1187
+ * The retained events after `after`, or `'expired'` when some of them are no longer held: `after` is older than the
1188
+ * ring reaches back, or newer than anything published (a mock restarted since the client last read it).
1189
+ */
1190
+ function replay(channel, after) {
1191
+ if (after > channel.lastEventId) return "expired";
1192
+ if (after === channel.lastEventId) return [];
1193
+ const oldest = channel.retained[0]?.id;
1194
+ if (oldest === void 0 || after < oldest - 1) return "expired";
1195
+ return channel.retained.filter((event) => event.id > after);
634
1196
  }
635
- /** Enough of the context's shape for the responder to read it without throwing. */
636
- function isContext(value) {
637
- if (!isObject(value)) return false;
638
- const { route, title, screen, domains, pageTools, suggestions } = value;
639
- const screenOk = screen === null || isObject(screen) && Array.isArray(screen.entities) && Array.isArray(screen.selection);
640
- return isObject(route) && typeof title === "string" && screenOk && [
641
- domains,
642
- pageTools,
643
- suggestions
644
- ].every((list) => Array.isArray(list));
1197
+ function subscribe(channel, subscriber) {
1198
+ channel.subscribers.add(subscriber);
1199
+ return () => {
1200
+ channel.subscribers.delete(subscriber);
1201
+ };
645
1202
  }
646
- /** `{ tenantIds: [...] }` with at least one id, or `{ all: true }`, and nothing else (authorization standard). */
647
- function isTenantScope(value) {
648
- if (!isObject(value)) return false;
649
- const keys = Object.keys(value);
650
- if (keys.length === 1 && value.all === true) return true;
651
- const ids = value.tenantIds;
652
- return keys.length === 1 && Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string" && id !== "") && new Set(ids).size === ids.length;
1203
+ /** Publishes a last event, then ends every stream: the session was deleted or left the store. */
1204
+ function close(channel, type, data) {
1205
+ publish(channel, type, data);
1206
+ for (const subscriber of [...channel.subscribers]) subscriber.end();
1207
+ channel.subscribers.clear();
653
1208
  }
654
- var isScope = (value) => isObject(value) && typeof value.orgId === "string" && value.orgId !== "" && isTenantScope(value.tenantScope);
655
- /** The same org and the same tenants, in any order. */
656
- function sameScope(a, b) {
657
- const tenants = (scope) => "all" in scope ? "*" : [...scope.tenantIds].sort().join("\n");
658
- return a.orgId === b.orgId && tenants(a.tenantScope) === tenants(b.tenantScope);
1209
+ /** One event as `text/event-stream` text; with no id, no `id:` line. */
1210
+ function frame(type, data, id) {
1211
+ return `${id === void 0 ? "" : `id: ${id}\n`}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
659
1212
  }
660
- var isRequest = (value) => isScope(value) && isObject(value) && MODES.has(value.mode) && isContext(value.context) && isInput(value.input);
661
- var pause = async (ms) => new Promise((resolve) => {
662
- setTimeout(resolve, ms);
663
- });
664
- function sendJson(res, status, body) {
665
- res.statusCode = status;
666
- res.setHeader("Content-Type", "application/json");
667
- res.end(JSON.stringify(body));
1213
+ //#endregion
1214
+ //#region src/mock/sessions.ts
1215
+ /** `ses_` and a UUID's 32 hex digits (the API's ids). */
1216
+ var newSessionId = () => `ses_${randomUUID().replace(/-/g, "")}`;
1217
+ var DELETED_REMEMBERED = 1e3;
1218
+ /** The mock's sessions, by id, in memory: at most `limit` (default 100). */
1219
+ function createSessions(limit = 100, newId = newSessionId) {
1220
+ const byId = /* @__PURE__ */ new Map();
1221
+ const deleted = /* @__PURE__ */ new Set();
1222
+ const forget = (session) => {
1223
+ session.deleted = true;
1224
+ session.busy = false;
1225
+ byId.delete(session.id);
1226
+ deleted.add(session.id);
1227
+ if (deleted.size > DELETED_REMEMBERED) {
1228
+ const first = deleted.values().next().value;
1229
+ if (first !== void 0) deleted.delete(first);
1230
+ }
1231
+ close(session.channel, "session.deleted", { id: session.id });
1232
+ };
1233
+ const evict = () => {
1234
+ while (byId.size > limit) {
1235
+ const oldest = [...byId.values()].filter((session) => !session.busy).reduce((found, session) => found === void 0 || session.updatedAt < found.updatedAt ? session : found, void 0);
1236
+ if (oldest === void 0) break;
1237
+ forget(oldest);
1238
+ }
1239
+ };
1240
+ return {
1241
+ get: (id) => byId.get(id),
1242
+ wasDeleted: (id) => deleted.has(id),
1243
+ all: () => [...byId.values()],
1244
+ create(scope, at = {}) {
1245
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1246
+ const createdAt = at.createdAt ?? now;
1247
+ const session = {
1248
+ id: at.id ?? newId(),
1249
+ scope,
1250
+ title: null,
1251
+ createdAt,
1252
+ updatedAt: at.updatedAt ?? createdAt,
1253
+ archivedAt: null,
1254
+ busy: false,
1255
+ deleted: false,
1256
+ messages: [],
1257
+ artifacts: [],
1258
+ resources: [],
1259
+ views: [],
1260
+ attachments: /* @__PURE__ */ new Map(),
1261
+ artifactBytes: /* @__PURE__ */ new Map(),
1262
+ channel: createChannel(),
1263
+ turn: null,
1264
+ user: null,
1265
+ context: null,
1266
+ pending: null
1267
+ };
1268
+ byId.set(session.id, session);
1269
+ evict();
1270
+ return session;
1271
+ },
1272
+ remove(id) {
1273
+ const session = byId.get(id);
1274
+ if (session !== void 0) forget(session);
1275
+ return session;
1276
+ }
1277
+ };
668
1278
  }
669
- /** An error in the API's envelope (agent conversations API 8). */
670
- function refuse(res, status, id, message) {
671
- sendJson(res, status, { errors: [{
672
- id,
673
- message,
674
- referenceId: randomUUID()
675
- }] });
1279
+ /** Marks a change: `updatedAt` moves to now, and always forward, so a precondition read before it never matches after. */
1280
+ function touch(session) {
1281
+ const next = Math.max(Date.now(), Date.parse(session.updatedAt) + 1);
1282
+ session.updatedAt = new Date(next).toISOString();
1283
+ return session.updatedAt;
676
1284
  }
677
- var notFound = (res) => refuse(res, 404, "conversation_not_found", "No such conversation");
678
- /** A conversation as the API returns it (agent conversations API 5.1). */
679
- function resourceOf(conversation) {
680
- const { pending } = conversation;
681
- let waiting = null;
682
- if (pending?.kind === "approval") waiting = {
1285
+ /** What the session waits on from the UI, as the API states it. */
1286
+ function waitingOf(session) {
1287
+ const { pending } = session;
1288
+ if (pending?.kind === "approval") return {
683
1289
  kind: "approval",
684
1290
  id: pending.id,
685
1291
  steps: pending.calls.map((call) => call.id)
686
1292
  };
687
- else if (pending?.kind === "page_tool") waiting = {
1293
+ if (pending?.kind === "page_tool") return {
688
1294
  kind: "page_tool",
689
1295
  calls: pending.calls.map((call) => {
690
1296
  const [domainId, name] = call.tool.split("__");
@@ -692,89 +1298,681 @@ function resourceOf(conversation) {
692
1298
  id: call.id,
693
1299
  domainId,
694
1300
  name,
695
- input: {}
1301
+ input: call.input
696
1302
  };
697
1303
  })
698
1304
  };
1305
+ return null;
1306
+ }
1307
+ function summaryOf(session) {
1308
+ return {
1309
+ id: session.id,
1310
+ title: session.title,
1311
+ createdAt: session.createdAt,
1312
+ updatedAt: session.updatedAt,
1313
+ archivedAt: session.archivedAt,
1314
+ orgId: session.scope.orgId,
1315
+ tenantScope: session.scope.tenantScope,
1316
+ lastEventId: session.channel.lastEventId
1317
+ };
1318
+ }
1319
+ var resourceOf = (session) => {
699
1320
  return {
700
- id: conversation.id,
701
- title: null,
702
- createdAt: conversation.createdAt,
703
- updatedAt: conversation.updatedAt,
704
- archivedAt: null,
705
- ...conversation.scope,
706
- waiting
707
- };
708
- }
709
- /** `POST /api/conversations`: a new conversation in the body's scope. */
710
- async function create(req, res, conversations) {
711
- const body = await readBody(req);
1321
+ ...summaryOf(session),
1322
+ waiting: waitingOf(session)
1323
+ };
1324
+ };
1325
+ /** `GET sessions/{id}`: the session with its transcript and everything collected in it. */
1326
+ function transcriptOf(session) {
1327
+ const truncated = session.messages.length > 500;
1328
+ return {
1329
+ ...resourceOf(session),
1330
+ messages: truncated ? session.messages.slice(-500) : session.messages,
1331
+ artifacts: session.artifacts,
1332
+ resources: session.resources,
1333
+ views: session.views,
1334
+ ...truncated ? { truncated: true } : {}
1335
+ };
1336
+ }
1337
+ //#endregion
1338
+ //#region src/mock/sessionRoutes.ts
1339
+ var LIST_LIMIT = {
1340
+ default: 20,
1341
+ max: 100
1342
+ };
1343
+ var MAX_IDS = 8;
1344
+ var SORTS = ["updatedAt:desc", "createdAt:desc"];
1345
+ var ARCHIVED = [
1346
+ "false",
1347
+ "true",
1348
+ "any"
1349
+ ];
1350
+ /** `POST /api/sessions`: a new session in the body's scope. */
1351
+ async function create(req, res, sessions) {
1352
+ const body = await readJson(req);
712
1353
  if (!isScope(body)) {
713
1354
  refuse(res, 400, "invalid_request", "The body must be { orgId, tenantScope }");
714
1355
  return;
715
1356
  }
716
- const conversation = conversations.create({
1357
+ const session = sessions.create({
717
1358
  orgId: body.orgId,
718
1359
  tenantScope: body.tenantScope
719
1360
  });
720
- res.setHeader("Location", `/api/conversations/${conversation.id}`);
721
- sendJson(res, 201, { data: resourceOf(conversation) });
1361
+ res.setHeader("Location", `/api/sessions/${session.id}`);
1362
+ sendTagged(req, res, 201, { data: resourceOf(session) });
1363
+ }
1364
+ /** A keyset cursor (REST standard): the sort and filters it was issued under, then the last item's sort key and id. */
1365
+ var cursorOf$1 = (query, key, id) => Buffer.from(JSON.stringify([
1366
+ query.sort,
1367
+ query.archived,
1368
+ `${query.scope.orgId}|${tenantsKey(query.scope.tenantScope)}`,
1369
+ key,
1370
+ id
1371
+ ])).toString("base64url");
1372
+ /** The keyset in a cursor this API issued for the same sort, filter and scope; `null` for any other. */
1373
+ function keysetOf(cursor, query) {
1374
+ try {
1375
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
1376
+ if (!Array.isArray(parsed) || parsed.length !== 5 || !parsed.every((part) => typeof part === "string")) return null;
1377
+ const [, , , key, id] = parsed;
1378
+ return cursorOf$1(query, key, id) === cursor ? {
1379
+ key,
1380
+ id
1381
+ } : null;
1382
+ } catch {
1383
+ return null;
1384
+ }
1385
+ }
1386
+ var oneOf = (allowed, value) => allowed.includes(value);
1387
+ /** The list's query, or `null` when any part of it is not one the API takes. */
1388
+ /** The page size: a whole number from 1 to the maximum, the default if none; 0 for anything else. */
1389
+ function limitOf$1(url) {
1390
+ const raw = url.searchParams.get("limit") ?? String(LIST_LIMIT.default);
1391
+ const limit = /^\d+$/.test(raw) ? Number(raw) : 0;
1392
+ return limit >= 1 && limit <= LIST_LIMIT.max ? limit : 0;
1393
+ }
1394
+ function listQuery(url, scope) {
1395
+ const limit = limitOf$1(url);
1396
+ const archived = url.searchParams.get("archived") ?? "false";
1397
+ const sort = url.searchParams.get("sort") ?? "updatedAt:desc";
1398
+ if (limit === 0 || !oneOf(ARCHIVED, archived) || !oneOf(SORTS, sort)) return null;
1399
+ const cursor = url.searchParams.get("cursor");
1400
+ const after = cursor === null ? null : keysetOf(cursor, {
1401
+ scope,
1402
+ sort,
1403
+ archived
1404
+ });
1405
+ return cursor !== null && after === null ? null : {
1406
+ scope,
1407
+ limit,
1408
+ sort,
1409
+ archived,
1410
+ after
1411
+ };
1412
+ }
1413
+ /** One page of the sessions the query selects, newest first, the id breaking ties. */
1414
+ function pageOf(sessions, query) {
1415
+ const { scope, sort, archived, after, limit } = query;
1416
+ const keyOf = (session) => sort === "createdAt:desc" ? session.createdAt : session.updatedAt;
1417
+ const kept = (session) => archived === "any" || archived === "true" === (session.archivedAt !== null);
1418
+ const beyond = (session) => after === null || keyOf(session) < after.key || keyOf(session) === after.key && session.id < after.id;
1419
+ const matches = sessions.all().filter((session) => sameScope(session.scope, scope) && kept(session)).sort((a, b) => keyOf(b).localeCompare(keyOf(a)) || b.id.localeCompare(a.id)).filter(beyond);
1420
+ const page = matches.slice(0, limit);
1421
+ const hasMore = matches.length > limit;
1422
+ const last = page.at(-1);
1423
+ const nextCursor = hasMore && last !== void 0 ? cursorOf$1(query, keyOf(last), last.id) : null;
1424
+ return {
1425
+ data: page.map(summaryOf),
1426
+ nextCursor,
1427
+ hasMore
1428
+ };
1429
+ }
1430
+ /** With `ids`: exactly those sessions' summaries (at most 8, in the order asked, whatever their archive state). */
1431
+ function byIds(res, ids, scope, sessions) {
1432
+ const wanted = ids.split(",").map((id) => id.trim());
1433
+ if (wanted.length > MAX_IDS || wanted.some((id) => id === "")) {
1434
+ refuse(res, 400, "invalid_request", `ids is 1 to ${MAX_IDS} comma-separated session ids`);
1435
+ return null;
1436
+ }
1437
+ return {
1438
+ data: [...new Set(wanted)].map((id) => sessions.get(id)).filter((session) => session !== void 0 && sameScope(session.scope, scope)).map(summaryOf),
1439
+ nextCursor: null,
1440
+ hasMore: false
1441
+ };
1442
+ }
1443
+ /** `GET /api/sessions`: the caller's sessions in the scope the query names, paged by a keyset cursor. */
1444
+ function list(req, res, url, sessions) {
1445
+ const scope = queryScope(url);
1446
+ if (scope === null || scope === "invalid") {
1447
+ refuse(res, 400, "invalid_request", SCOPE_RULE);
1448
+ return;
1449
+ }
1450
+ const ids = url.searchParams.get("ids");
1451
+ if (ids !== null) {
1452
+ const answer = byIds(res, ids, scope, sessions);
1453
+ if (answer !== null) sendTagged(req, res, 200, answer);
1454
+ return;
1455
+ }
1456
+ const query = listQuery(url, scope);
1457
+ if (query === null) {
1458
+ refuse(res, 400, "invalid_request", `${`limit is 1 to ${LIST_LIMIT.max}; archived is ${ARCHIVED.join(", ")}; sort is ${SORTS.join(" or ")}`}; cursor is one this API issued for the same query`);
1459
+ return;
1460
+ }
1461
+ sendTagged(req, res, 200, pageOf(sessions, query));
1462
+ }
1463
+ /** `GET /api/sessions/{id}`: the session, its transcript and what it collected. */
1464
+ function read(req, res, session) {
1465
+ sendTagged(req, res, 200, { data: transcriptOf(session) });
722
1466
  }
723
- /** Why a message's skill is refused (agent conversations API 6.1), or `null`: it must be offered, and comes alone. */
1467
+ var PATCHABLE = /* @__PURE__ */ new Set(["title", "archived"]);
1468
+ /** A title of 1 to 80 characters once trimmed; `null` for anything else. */
1469
+ function titleOf$1(value) {
1470
+ const title = typeof value === "string" ? value.trim() : "";
1471
+ return title.length >= 1 && title.length <= 80 ? title : null;
1472
+ }
1473
+ /** A `PATCH` body: a title of 1 to 80 characters once trimmed, `archived`, or both; `null` for anything else. */
1474
+ function patchOf(body) {
1475
+ if (!isObject(body) || Object.keys(body).length === 0 || !Object.keys(body).every((key) => PATCHABLE.has(key))) return null;
1476
+ const patch = {};
1477
+ if ("title" in body) {
1478
+ const title = titleOf$1(body.title);
1479
+ if (title === null) return null;
1480
+ patch.title = title;
1481
+ }
1482
+ if ("archived" in body) {
1483
+ if (typeof body.archived !== "boolean") return null;
1484
+ patch.archived = body.archived;
1485
+ }
1486
+ return patch;
1487
+ }
1488
+ /** `PATCH /api/sessions/{id}`: rename or (un)archive, against the `updatedAt` the caller read (REST standard). */
1489
+ async function change(req, res, session) {
1490
+ const since = req.headers["if-unmodified-since"];
1491
+ if (since === void 0) {
1492
+ refuse(res, 428, "precondition_required", "Send if-unmodified-since with the updatedAt you read");
1493
+ return;
1494
+ }
1495
+ const patch = patchOf(await readJson(req));
1496
+ if (patch === null) {
1497
+ refuse(res, 400, "invalid_request", "The body is { title?: 1 to 80 characters, archived?: boolean }, with at least one");
1498
+ return;
1499
+ }
1500
+ if (session.deleted) {
1501
+ notFound(res);
1502
+ return;
1503
+ }
1504
+ const readAt = Date.parse(since);
1505
+ if (Number.isNaN(readAt) || readAt !== Date.parse(session.updatedAt)) {
1506
+ refuse(res, 412, "precondition_failed", "The session changed since you read it");
1507
+ return;
1508
+ }
1509
+ const updatedAt = touch(session);
1510
+ const changed = {};
1511
+ if (patch.title !== void 0) {
1512
+ session.title = patch.title;
1513
+ changed.title = patch.title;
1514
+ }
1515
+ if (patch.archived !== void 0) {
1516
+ session.archivedAt = patch.archived ? session.archivedAt ?? updatedAt : null;
1517
+ changed.archivedAt = session.archivedAt;
1518
+ }
1519
+ publish(session.channel, "session", {
1520
+ ...changed,
1521
+ updatedAt
1522
+ });
1523
+ sendTagged(req, res, 200, { data: resourceOf(session) });
1524
+ }
1525
+ /** `DELETE /api/sessions/{id}`: gone from now on; a turn in progress stops, and every events stream ends. */
1526
+ function remove(req, res, session, sessions) {
1527
+ sessions.remove(session.id);
1528
+ sendJson(res, 200, { data: { id: session.id } }, contentTypeFor(req));
1529
+ }
1530
+ //#endregion
1531
+ //#region src/mock/stream.ts
1532
+ /** Where a stream starts: `Last-Event-ID` (a reconnect), else `after`; `null` for neither, `'invalid'` for a bad one. */
1533
+ function startOf(req, url) {
1534
+ const header = req.headers["last-event-id"];
1535
+ const raw = (typeof header === "string" ? header : void 0) ?? url.searchParams.get("after");
1536
+ if (raw === null) return null;
1537
+ return /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : "invalid";
1538
+ }
1539
+ /**
1540
+ * `GET /api/sessions/{id}/events` (9.6): the session's events, long-lived. `after` (or `Last-Event-ID`, which wins)
1541
+ * replays what the ring still holds after it, then live events follow; with neither, live events only. `: ping` every
1542
+ * `pingMs`. A deleted session answers 410; an `after` the ring no longer reaches, 409 `events_expired`.
1543
+ */
1544
+ function events(req, res, url, id, context) {
1545
+ if (context.sessions.wasDeleted(id)) {
1546
+ refuse(res, 410, "gone", "This session was deleted");
1547
+ return;
1548
+ }
1549
+ const session = lookup(url, id, res, context.sessions);
1550
+ if (session === null) return;
1551
+ const start = startOf(req, url);
1552
+ if (start === "invalid") {
1553
+ refuse(res, 400, "invalid_request", "after is an event id");
1554
+ return;
1555
+ }
1556
+ const replayed = replay(session.channel, start ?? session.channel.lastEventId);
1557
+ if (replayed === "expired") {
1558
+ refuse(res, 409, "events_expired", "Events after that id are no longer held; read the session again");
1559
+ return;
1560
+ }
1561
+ res.statusCode = 200;
1562
+ res.setHeader("Content-Type", "text/event-stream");
1563
+ res.setHeader("Cache-Control", "no-cache");
1564
+ res.flushHeaders();
1565
+ for (const event of replayed) res.write(frame(event.type, event.data, event.id));
1566
+ let open = true;
1567
+ const ping = setInterval(() => {
1568
+ res.write(": ping\n\n");
1569
+ }, context.pingMs);
1570
+ let unsubscribe = () => void 0;
1571
+ const cleanup = () => {
1572
+ if (open) {
1573
+ open = false;
1574
+ clearInterval(ping);
1575
+ unsubscribe();
1576
+ }
1577
+ };
1578
+ unsubscribe = subscribe(session.channel, {
1579
+ send(event) {
1580
+ res.write(frame(event.type, event.data, event.id));
1581
+ },
1582
+ end() {
1583
+ cleanup();
1584
+ res.end();
1585
+ }
1586
+ });
1587
+ res.on("close", cleanup);
1588
+ }
1589
+ //#endregion
1590
+ //#region src/mock/transcript.ts
1591
+ /** The turn's answer in the transcript, made on its first event: one assistant message per turn, across its requests. */
1592
+ function answerOf(session, turn) {
1593
+ const found = session.messages.find((message) => message.role === "assistant" && message.id === turn.answerId);
1594
+ if (found !== void 0) return found;
1595
+ const answer = {
1596
+ role: "assistant",
1597
+ id: `${turn.messageId}_a`,
1598
+ turnId: turn.id,
1599
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1600
+ blocks: []
1601
+ };
1602
+ turn.answerId = answer.id;
1603
+ session.messages.push(answer);
1604
+ return answer;
1605
+ }
1606
+ function upsertStep(answer, step) {
1607
+ const block = {
1608
+ kind: "step",
1609
+ ...step
1610
+ };
1611
+ const index = answer.blocks.findIndex((candidate) => candidate.kind === "step" && candidate.id === step.id);
1612
+ if (index === -1) answer.blocks.push(block);
1613
+ else answer.blocks[index] = block;
1614
+ }
1615
+ function upsert(list, item) {
1616
+ const index = list.findIndex((candidate) => candidate.id === item.id);
1617
+ if (index === -1) list.push(item);
1618
+ else list[index] = item;
1619
+ }
1620
+ /** An event's data: everything but its type. */
1621
+ function bodyOf$1(event) {
1622
+ return Object.fromEntries(Object.entries(event).filter(([key]) => key !== "type"));
1623
+ }
1624
+ function appendText(answer, delta) {
1625
+ const last = answer.blocks.at(-1);
1626
+ if (last?.kind === "text") last.text += delta;
1627
+ else answer.blocks.push({
1628
+ kind: "text",
1629
+ text: delta
1630
+ });
1631
+ }
1632
+ function setMode(answer, event) {
1633
+ answer.acting = event.acting;
1634
+ if (event.reason === void 0) delete answer.reason;
1635
+ else answer.reason = event.reason;
1636
+ }
1637
+ /** A block that refers to an artifact or a view, once. */
1638
+ function refer(answer, kind, id) {
1639
+ if (!answer.blocks.some((block) => block.kind === kind && block.id === id)) answer.blocks.push({
1640
+ kind,
1641
+ id
1642
+ });
1643
+ }
1644
+ /** How an event that draws something changes the turn's answer, and the session's collections. */
1645
+ function draw(session, turn, answer, event) {
1646
+ switch (event.type) {
1647
+ case "text":
1648
+ appendText(answer, event.delta);
1649
+ return;
1650
+ case "mode":
1651
+ setMode(answer, event);
1652
+ return;
1653
+ case "step":
1654
+ upsertStep(answer, bodyOf$1(event));
1655
+ return;
1656
+ case "page_tool":
1657
+ upsertStep(answer, {
1658
+ id: event.id,
1659
+ tool: `${event.domainId}__${event.name}`,
1660
+ input: event.input,
1661
+ state: "running"
1662
+ });
1663
+ return;
1664
+ case "approval":
1665
+ answer.approval = {
1666
+ id: event.id,
1667
+ state: "proposed"
1668
+ };
1669
+ return;
1670
+ case "artifact":
1671
+ upsert(session.artifacts, {
1672
+ ...bodyOf$1(event),
1673
+ turnId: turn.id
1674
+ });
1675
+ refer(answer, "artifact", event.id);
1676
+ return;
1677
+ default:
1678
+ upsert(session.views, {
1679
+ ...bodyOf$1(event),
1680
+ turnId: turn.id
1681
+ });
1682
+ refer(answer, "view", event.id);
1683
+ }
1684
+ }
1685
+ /**
1686
+ * Applies one of a turn's events to the session's transcript and collections, so a session read back (9.1) holds what
1687
+ * the person saw: text, steps (a page tool the UI ran shows as a step), the approval, artifacts, views and resources.
1688
+ */
1689
+ function record(session, turn, event) {
1690
+ if (event.type === "done") return;
1691
+ if (event.type === "resource") {
1692
+ upsert(session.resources, {
1693
+ ...bodyOf$1(event),
1694
+ turnId: turn.id
1695
+ });
1696
+ return;
1697
+ }
1698
+ draw(session, turn, answerOf(session, turn), event);
1699
+ }
1700
+ /** The person's decision on the turn's plan, as the transcript keeps it. */
1701
+ function decide(session, turn, approve) {
1702
+ const answer = answerOf(session, turn);
1703
+ if (answer.approval !== void 0) answer.approval = {
1704
+ ...answer.approval,
1705
+ state: approve ? "approved" : "declined"
1706
+ };
1707
+ }
1708
+ //#endregion
1709
+ //#region src/mock/turns.ts
1710
+ var MAX_MESSAGE_ATTACHMENTS = 10;
1711
+ var MAX_TURN_BODY = 1048576;
1712
+ var hexId = (prefix) => `${prefix}_${randomUUID().replace(/-/g, "")}`;
1713
+ /** Why a message's skill is refused, or `null`: it must be offered, and comes alone. */
724
1714
  function skillRefusal(input) {
725
1715
  if (input.kind !== "message" || input.skill === void 0) return null;
726
1716
  if (input.suggestion !== void 0 || input.markup !== void 0) return "A skill does not come with a suggestion or markup";
727
1717
  const { id } = input.skill;
728
1718
  return SEED_SKILLS.some((skill) => skill.id === id) ? null : `Unknown skill ${id}`;
729
1719
  }
730
- /**
731
- * `POST /api/conversations/{id}/turns`: one request of a turn, answered with the responder's events, streamed with a
732
- * little pacing. An unknown id, or one in another scope, is a 404 whatever the input (agent conversations API 4).
733
- */
734
- async function turn(req, res, id, conversations, delayMs) {
735
- const body = await readBody(req);
1720
+ /** Why a message's attachments are refused, or `null`: at most 10, each this session's (9.2). */
1721
+ function attachmentRefusal(input, session) {
1722
+ if (input.kind !== "message" || input.attachments === void 0) return null;
1723
+ if (input.attachments.length > MAX_MESSAGE_ATTACHMENTS) return `At most ${MAX_MESSAGE_ATTACHMENTS} attachments per message`;
1724
+ const unknown = input.attachments.find((id) => !session.attachments.has(id));
1725
+ return unknown === void 0 ? null : `No attachment ${unknown} in this session`;
1726
+ }
1727
+ async function bodyOf(req) {
1728
+ const raw = await readRaw(req, MAX_TURN_BODY);
1729
+ if (raw === "too_large") return raw;
1730
+ try {
1731
+ return JSON.parse(raw.toString("utf8"));
1732
+ } catch {
1733
+ return;
1734
+ }
1735
+ }
1736
+ /** The request and its session when the turn may run; otherwise the refusal is sent and the result is `null`. */
1737
+ async function admit(req, res, id, context) {
1738
+ const body = await bodyOf(req);
1739
+ if (body === "too_large") {
1740
+ refuse(res, 413, "request_too_large", "The turn is over 1 MiB");
1741
+ return null;
1742
+ }
736
1743
  if (!isRequest(body)) {
737
1744
  refuse(res, 400, "invalid_request", "The body is not a turn request");
738
- return;
1745
+ return null;
739
1746
  }
740
- const conversation = conversations.get(id);
741
- if (conversation === void 0 || !sameScope(conversation.scope, body)) {
1747
+ const session = context.sessions.get(id);
1748
+ if (session === void 0 || !sameScope(session.scope, body)) {
742
1749
  notFound(res);
743
- return;
1750
+ return null;
744
1751
  }
745
- const refusal = skillRefusal(body.input);
1752
+ const refusal = skillRefusal(body.input) ?? attachmentRefusal(body.input, session);
746
1753
  if (refusal !== null) {
747
1754
  refuse(res, 400, "invalid_request", refusal);
748
- return;
1755
+ return null;
749
1756
  }
750
- if (conversation.busy) {
751
- refuse(res, 409, "turn_in_progress", "A turn is in progress on this conversation");
752
- return;
1757
+ if (session.busy) {
1758
+ refuse(res, 409, "turn_in_progress", "A turn is in progress on this session");
1759
+ return null;
753
1760
  }
754
- const answer = respond(body, conversation);
755
- if (!Array.isArray(answer)) {
756
- refuse(res, answer.status, "not_waiting", answer.message);
757
- return;
758
- }
759
- conversation.busy = true;
760
- conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1761
+ return {
1762
+ session,
1763
+ body
1764
+ };
1765
+ }
1766
+ /** A title from the first message: its text as a person reads it, else its skill or its files; at most 80 characters. */
1767
+ function titleOf(input, session) {
1768
+ const skill = input.skill === void 0 ? void 0 : SEED_SKILLS.find((seeded) => seeded.id === input.skill?.id)?.title;
1769
+ const text = stripMarkup(input.text).replace(/\s+/g, " ").trim();
1770
+ const files = (input.attachments ?? []).flatMap((id) => {
1771
+ const name = session.attachments.get(id)?.attachment.name;
1772
+ return name === void 0 ? [] : [name];
1773
+ });
1774
+ const title = [skill ?? "", text].filter((part) => part !== "").join(": ") || files.join(", ");
1775
+ if (title === "") return null;
1776
+ return title.length <= 80 ? title : `${title.slice(0, 79).trimEnd()}…`;
1777
+ }
1778
+ /** A message starts a turn: its user message joins the transcript, and the attachments it sends are in use from now. */
1779
+ function startTurn(session, input) {
1780
+ const turn = {
1781
+ id: hexId("turn"),
1782
+ messageId: hexId("msg"),
1783
+ seq: 0,
1784
+ answerId: null
1785
+ };
1786
+ const attachments = (input.attachments ?? []).flatMap((id) => {
1787
+ const stored = session.attachments.get(id);
1788
+ if (stored === void 0) return [];
1789
+ stored.usedBy = turn.id;
1790
+ return [stored.attachment];
1791
+ });
1792
+ const skill = input.skill === void 0 ? void 0 : SEED_SKILLS.find((seeded) => seeded.id === input.skill?.id);
1793
+ const message = {
1794
+ role: "user",
1795
+ id: turn.messageId,
1796
+ turnId: turn.id,
1797
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1798
+ text: input.text,
1799
+ refs: input.refs,
1800
+ ...input.markup === void 0 ? {} : { markup: input.markup },
1801
+ ...input.suggestion === void 0 ? {} : { suggestion: input.suggestion },
1802
+ ...skill === void 0 ? {} : { skill: {
1803
+ id: skill.id,
1804
+ title: skill.title
1805
+ } },
1806
+ attachments
1807
+ };
1808
+ session.messages.push(message);
1809
+ session.turn = turn;
1810
+ return {
1811
+ turn,
1812
+ message
1813
+ };
1814
+ }
1815
+ /** The page tools the UI ran, as steps every device can draw: the results close the steps the `page_tool` events opened. */
1816
+ function settledSteps(input, before) {
1817
+ if (input.kind !== "page_tool_results" || before?.kind !== "page_tool") return [];
1818
+ return input.results.flatMap((result) => {
1819
+ const call = before.calls.find((candidate) => candidate.id === result.id);
1820
+ if (call === void 0) return [];
1821
+ const step = {
1822
+ type: "step",
1823
+ id: call.id,
1824
+ tool: call.tool,
1825
+ input: call.input
1826
+ };
1827
+ return [result.ok ? {
1828
+ ...step,
1829
+ state: "done",
1830
+ result: result.result
1831
+ } : {
1832
+ ...step,
1833
+ state: "error",
1834
+ error: result.error ?? "Failed"
1835
+ }];
1836
+ });
1837
+ }
1838
+ function emitterOf(res, session, turn) {
1839
+ let ended = false;
1840
+ let gone = false;
761
1841
  res.on("close", () => {
762
- conversation.busy = false;
1842
+ if (!ended) gone = true;
763
1843
  });
764
1844
  res.statusCode = 200;
765
1845
  res.setHeader("Content-Type", "text/event-stream");
766
1846
  res.setHeader("Cache-Control", "no-cache");
767
- let seq = 0;
768
- for (const event of answer) {
769
- if (res.destroyed) break;
770
- const { type, ...data } = event;
771
- seq += 1;
772
- res.write(`id: ${seq}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
1847
+ return {
1848
+ emit(type, data, seq, hub = {
1849
+ type,
1850
+ data
1851
+ }) {
1852
+ const published = publish(session.channel, hub.type, {
1853
+ ...hub.data,
1854
+ turnId: turn.id,
1855
+ seq
1856
+ });
1857
+ if (!gone) res.write(frame(type, {
1858
+ ...data,
1859
+ turnId: turn.id,
1860
+ seq
1861
+ }, published.id));
1862
+ },
1863
+ gone: () => gone,
1864
+ end() {
1865
+ ended = true;
1866
+ res.end();
1867
+ }
1868
+ };
1869
+ }
1870
+ /** Sends one of the responder's events: recorded, stamped, streamed; a page tool goes to the hub as a running step. */
1871
+ function send(emitter, session, turn, event) {
1872
+ record(session, turn, event);
1873
+ turn.seq += 1;
1874
+ const { type, ...data } = event;
1875
+ if (event.type === "page_tool") {
1876
+ const step = {
1877
+ id: event.id,
1878
+ tool: `${event.domainId}__${event.name}`,
1879
+ input: event.input,
1880
+ state: "running"
1881
+ };
1882
+ emitter.emit(type, data, turn.seq, {
1883
+ type: "step",
1884
+ data: step
1885
+ });
1886
+ } else emitter.emit(type, data, turn.seq);
1887
+ }
1888
+ /** Streams the answer with a little pacing; `true` when it reached `done`, `false` when stopped. */
1889
+ async function play(emitter, session, turn, events, title, delayMs) {
1890
+ for (const event of events) {
1891
+ if (emitter.gone() || session.deleted) return false;
1892
+ if (event.type === "done" && title !== null) {
1893
+ session.title = title;
1894
+ turn.seq += 1;
1895
+ emitter.emit("title", { title }, turn.seq);
1896
+ }
1897
+ send(emitter, session, turn, event);
773
1898
  await pause(delayMs);
774
1899
  }
775
- conversation.busy = false;
776
- res.end();
1900
+ return true;
1901
+ }
1902
+ /** The turn a request belongs to: a message starts one; page tools' results and an approval carry on the session's. */
1903
+ function begin(session, input) {
1904
+ if (input.kind === "message") return startTurn(session, input);
1905
+ const turn = session.turn ?? {
1906
+ id: hexId("turn"),
1907
+ messageId: hexId("msg"),
1908
+ seq: 0,
1909
+ answerId: null
1910
+ };
1911
+ session.turn = turn;
1912
+ if (input.kind === "approval") decide(session, turn, input.approve);
1913
+ return {
1914
+ turn,
1915
+ message: null
1916
+ };
1917
+ }
1918
+ /** Closes the turn's response: a deleted session says so; a stopped turn leaves the session idle, for every device. */
1919
+ function end(emitter, res, session, turn, finished) {
1920
+ if (session.deleted) {
1921
+ if (!emitter.gone()) res.write(frame("session.deleted", { id: session.id }));
1922
+ } else if (!finished) {
1923
+ session.pending = null;
1924
+ turn.seq += 1;
1925
+ publish(session.channel, "done", {
1926
+ stop: "end",
1927
+ turnId: turn.id,
1928
+ seq: turn.seq
1929
+ });
1930
+ }
1931
+ session.busy = false;
1932
+ emitter.end();
777
1933
  }
1934
+ /**
1935
+ * `POST /api/sessions/{id}/turns`: one request of a turn. A message starts a turn (`turn`, `seq` 0); a page tool's
1936
+ * results or an approval continue it: the same `turnId`, `seq` going on from where it stopped, no second `turn`. Every
1937
+ * event goes to this response and to the session's hub (so to every events stream), stamped with `turnId` and `seq`
1938
+ * and with the hub's event id as `id:`; a `page_tool` goes only to this response, and the hub gets a running `step` for
1939
+ * the same call under the same `seq` and id, so neither stream has a gap.
1940
+ */
1941
+ async function turnRequest(req, res, id, context) {
1942
+ const admitted = await admit(req, res, id, context);
1943
+ if (admitted === null) return;
1944
+ const { session, body: { input }, body } = admitted;
1945
+ const before = session.pending;
1946
+ const answer = respond(body, session);
1947
+ if (!Array.isArray(answer)) {
1948
+ refuse(res, answer.status, "not_waiting", answer.message);
1949
+ return;
1950
+ }
1951
+ const { turn, message } = begin(session, input);
1952
+ session.busy = true;
1953
+ const updatedAt = touch(session);
1954
+ const emitter = emitterOf(res, session, turn);
1955
+ if (message !== null) emitter.emit("turn", { messageId: turn.messageId }, 0, {
1956
+ type: "turn",
1957
+ data: {
1958
+ messageId: turn.messageId,
1959
+ message
1960
+ }
1961
+ });
1962
+ if (session.archivedAt !== null) {
1963
+ session.archivedAt = null;
1964
+ turn.seq += 1;
1965
+ emitter.emit("session", {
1966
+ archivedAt: null,
1967
+ updatedAt
1968
+ }, turn.seq);
1969
+ }
1970
+ const title = input.kind !== "message" || session.title !== null ? null : titleOf(input, session);
1971
+ end(emitter, res, session, turn, await play(emitter, session, turn, [...settledSteps(input, before), ...answer], title, context.delayMs));
1972
+ }
1973
+ //#endregion
1974
+ //#region src/mock/chatHandler.ts
1975
+ var SKILLS_LIMIT = 50;
778
1976
  /** `GET /api/prompts?domain=&mode=`: the domain's seeded prompts in the mode. */
779
1977
  function prompts(url, res) {
780
1978
  const domain = url.searchParams.get("domain");
@@ -785,7 +1983,6 @@ function prompts(url, res) {
785
1983
  }
786
1984
  sendJson(res, 200, { data: { prompts: promptsFor(domain, mode) } });
787
1985
  }
788
- var SKILLS_LIMIT = 50;
789
1986
  /** A page position as an opaque cursor, and back; `null` when the cursor is not one this handler issued. */
790
1987
  var cursorOf = (index) => Buffer.from(String(index)).toString("base64url");
791
1988
  function indexOf(cursor) {
@@ -809,72 +2006,462 @@ function skills(url, req, res) {
809
2006
  }
810
2007
  const end = start + limit;
811
2008
  const hasMore = end < SEED_SKILLS.length;
812
- const data = SEED_SKILLS.slice(start, end).map(({ id, title, description }) => {
813
- return {
814
- id,
815
- title,
816
- description
817
- };
818
- });
819
- const body = JSON.stringify({
820
- data,
2009
+ sendTagged(req, res, 200, {
2010
+ data: SEED_SKILLS.slice(start, end).map(({ id, title, description }) => {
2011
+ return {
2012
+ id,
2013
+ title,
2014
+ description
2015
+ };
2016
+ }),
821
2017
  nextCursor: hasMore ? cursorOf(end) : null,
822
2018
  hasMore
823
2019
  });
824
- const etag = `"${createHash("sha256").update(body).digest("hex").slice(0, 32)}"`;
825
- res.setHeader("ETag", etag);
826
- if (req.headers["if-none-match"] === etag) {
827
- res.statusCode = 304;
828
- res.end();
829
- return;
2020
+ }
2021
+ /**
2022
+ * Runs the method's action, or answers 405 naming the methods the path takes. A synchronous action answers before
2023
+ * this returns (an events stream is subscribed at once); a failure is a 500 in the envelope.
2024
+ */
2025
+ function route(req, res, actions) {
2026
+ const failed = () => {
2027
+ if (res.headersSent) res.end();
2028
+ else refuse(res, 500, "internal_error", "The mock failed");
2029
+ };
2030
+ const action = actions[(req.method ?? "").toLowerCase()];
2031
+ if (action === void 0) {
2032
+ const allowed = Object.keys(actions).map((method) => method.toUpperCase()).join(", ");
2033
+ res.setHeader("Allow", allowed);
2034
+ refuse(res, 405, "method_not_allowed", `Only ${allowed}`);
2035
+ return true;
830
2036
  }
831
- res.statusCode = 200;
832
- res.setHeader("Content-Type", "application/json");
833
- res.end(body);
2037
+ try {
2038
+ const pending = action();
2039
+ if (pending instanceof Promise) pending.catch(failed);
2040
+ } catch {
2041
+ failed();
2042
+ }
2043
+ return true;
2044
+ }
2045
+ /** A path segment decoded; a malformed one stays as it is, and so names no session. */
2046
+ function decode(segment) {
2047
+ try {
2048
+ return decodeURIComponent(segment);
2049
+ } catch {
2050
+ return segment;
2051
+ }
2052
+ }
2053
+ var SESSION = /^\/api\/sessions\/([^/]+)$/;
2054
+ var SUBRESOURCE = /^\/api\/sessions\/([^/]+)\/(turns|events|attachments|artifacts)(?:\/([^/]+))?$/;
2055
+ /** Runs `then` on the session the path names, when the request may see it. */
2056
+ var withSession = ({ res, url, context }, id, then) => async () => {
2057
+ const session = lookup(url, id, res, context.sessions);
2058
+ if (session !== null) await then(session);
2059
+ };
2060
+ /** `/api/sessions/{id}`: read, change, delete. */
2061
+ function sessionRoute(request, id) {
2062
+ const { req, res, context } = request;
2063
+ return route(req, res, {
2064
+ get: withSession(request, id, (session) => read(req, res, session)),
2065
+ patch: withSession(request, id, async (session) => change(req, res, session)),
2066
+ delete: withSession(request, id, (session) => remove(req, res, session, context.sessions))
2067
+ });
2068
+ }
2069
+ /** `/api/sessions/{id}/turns`, `…/events`, `…/attachments[/{attachmentId}]`, `…/artifacts/{artifactId}`. */
2070
+ function subresourceRoute(request, id, kind, item) {
2071
+ const { req, res, url, context } = request;
2072
+ if (item === void 0) switch (kind) {
2073
+ case "turns": return route(req, res, { post: async () => turnRequest(req, res, id, context) });
2074
+ case "events": return route(req, res, { get: () => events(req, res, url, id, context) });
2075
+ case "attachments": return route(req, res, { post: withSession(request, id, async (session) => upload(req, res, session)) });
2076
+ default: return false;
2077
+ }
2078
+ if (kind === "attachments") {
2079
+ const action = withSession(request, id, (session) => attachmentRoute(req, res, session, item));
2080
+ return route(req, res, {
2081
+ get: action,
2082
+ delete: action
2083
+ });
2084
+ }
2085
+ return kind === "artifacts" && route(req, res, { get: withSession(request, id, (session) => artifactRoute(res, session, item)) });
834
2086
  }
835
- var TURNS = /^\/api\/conversations\/([^/]+)\/turns$/;
836
2087
  /**
837
- * The dev-only agent backend (assistant endpoints design, decision 23), speaking the agent conversations API:
838
- * `POST /api/conversations`, `POST /api/conversations/{id}/turns`, `GET /api/prompts` and `GET /api/skills`. Each handler keeps its own
839
- * conversations, in memory.
2088
+ * The dev-only agent backend (assistant endpoints design, decision 23), speaking the agent sessions API (session pane
2089
+ * design 9): sessions with their transcripts, turns, events, attachments and artifacts, and `GET /api/prompts` and `GET
2090
+ * /api/skills`. Each handler keeps its own sessions, in memory.
840
2091
  */
841
2092
  function createChatHandler(options = {}) {
842
- const delayMs = options.delayMs ?? 40;
843
- const conversations = options.conversations ?? createConversations();
844
- const failed = (res) => () => {
845
- if (!res.headersSent) refuse(res, 500, "internal_error", "The mock failed");
846
- else res.end();
2093
+ const context = {
2094
+ sessions: options.sessions ?? createSessions(),
2095
+ delayMs: options.delayMs ?? 40,
2096
+ pingMs: options.pingMs ?? 25e3
847
2097
  };
848
2098
  return (req, res) => {
849
2099
  const url = new URL(req.url ?? "/", "http://mock.invalid");
850
- const only = (method, then) => {
851
- if (req.method === method) then();
852
- else {
853
- res.setHeader("Allow", method);
854
- refuse(res, 405, "method_not_allowed", `Only ${method}`);
855
- }
856
- return true;
2100
+ const request = {
2101
+ req,
2102
+ res,
2103
+ url,
2104
+ context
857
2105
  };
858
- if (url.pathname === "/api/prompts") return only("GET", () => prompts(url, res));
859
- if (url.pathname === "/api/skills") return only("GET", () => skills(url, req, res));
860
- if (url.pathname === "/api/conversations") return only("POST", () => {
861
- create(req, res, conversations).catch(failed(res));
862
- });
863
- const match = TURNS.exec(url.pathname);
864
- if (match === null) return false;
865
- return only("POST", () => {
866
- turn(req, res, decodeURIComponent(match[1]), conversations, delayMs).catch(failed(res));
867
- });
2106
+ switch (url.pathname) {
2107
+ case "/api/prompts": return route(req, res, { get: () => prompts(url, res) });
2108
+ case "/api/skills": return route(req, res, { get: () => skills(url, req, res) });
2109
+ case "/api/sessions": return route(req, res, {
2110
+ get: () => list(req, res, url, context.sessions),
2111
+ post: async () => create(req, res, context.sessions)
2112
+ });
2113
+ }
2114
+ const one = SESSION.exec(url.pathname);
2115
+ if (one !== null) return sessionRoute(request, decode(one[1]));
2116
+ const sub = SUBRESOURCE.exec(url.pathname);
2117
+ if (sub === null) return false;
2118
+ return subresourceRoute(request, decode(sub[1]), sub[2], sub[3] === void 0 ? void 0 : decode(sub[3]));
868
2119
  };
869
2120
  }
870
2121
  //#endregion
2122
+ //#region src/mock/seedSessions.ts
2123
+ /** The dev token's scope: its org and default tenant, as the assistant names it (`runtime.ts`, `scopeOf`). */
2124
+ var DEV_SCOPE = {
2125
+ orgId: "org_dev",
2126
+ tenantScope: { tenantIds: ["acme"] }
2127
+ };
2128
+ var MINUTE = 6e4;
2129
+ var HOUR = 60 * MINUTE;
2130
+ var DAY = 24 * HOUR;
2131
+ var minutes = (count) => count * MINUTE;
2132
+ var days = (count, hours = 0) => count * DAY + hours * HOUR;
2133
+ /** A stable id per seed, so a tab left open across a dev-server restart still finds its session. */
2134
+ var stableId = (prefix, seed) => `${prefix}_${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`;
2135
+ /** Plausible, synthetic sessions: 5 today, 8 in the previous 7 days, 12 older (assistant recent conversations design 7). */
2136
+ var SEEDS = [
2137
+ {
2138
+ title: "Alerts by host",
2139
+ ago: minutes(4),
2140
+ question: "Alerts by host",
2141
+ reply: "web-01 raised the most alerts in the last 24 hours (42). The chart and the table hold the same counts."
2142
+ },
2143
+ {
2144
+ title: "Suppress duplicate beaconing alerts",
2145
+ ago: minutes(12),
2146
+ question: "Suppress a4 and a5, they are duplicates of a2",
2147
+ reply: ""
2148
+ },
2149
+ {
2150
+ title: "Triage credential dumping on web-01",
2151
+ ago: minutes(26),
2152
+ question: "What happened on web-01 around 09:00?",
2153
+ reply: "Two alerts fired within four minutes: credential dumping, then a new scheduled task. Both point at the same service account."
2154
+ },
2155
+ {
2156
+ title: "Impossible travel for j.doe",
2157
+ ago: minutes(41),
2158
+ question: "Is the impossible travel alert for j.doe real?",
2159
+ reply: "The second sign-in came from a known VPN exit. It is likely benign; the VPN log confirms the session."
2160
+ },
2161
+ {
2162
+ title: "Noisy rules this week",
2163
+ ago: minutes(55),
2164
+ question: "Which rules were noisiest this week?",
2165
+ reply: "Three rules account for 61 % of alerts: DNS beaconing, failed MFA bursts and new admin share access."
2166
+ },
2167
+ {
2168
+ title: "Phishing report from finance",
2169
+ ago: days(1, 2),
2170
+ question: "Summarise the phishing report from finance",
2171
+ reply: "One user clicked; no credentials were entered. The sender domain was registered two days ago."
2172
+ },
2173
+ {
2174
+ title: "VPN gateway brute force",
2175
+ ago: days(1, 7),
2176
+ question: "Is vpn-gw-1 under brute force?",
2177
+ reply: "1,840 failed logins from 12 addresses in an hour. Lockout held; no successful sign-in."
2178
+ },
2179
+ {
2180
+ title: "Open investigations by owner",
2181
+ ago: days(2),
2182
+ question: "Who owns the open investigations?",
2183
+ reply: "Seven are open: four with the tier-2 rota, two with you, one unassigned."
2184
+ },
2185
+ {
2186
+ title: "Malware sample on build-07",
2187
+ ago: days(3),
2188
+ question: "What did the sandbox say about the sample on build-07?",
2189
+ reply: "The sample is a known loader. It reached out to one address, which is now blocked."
2190
+ },
2191
+ {
2192
+ title: "Weekend alert summary",
2193
+ ago: days(3, 5),
2194
+ question: "Summarise the weekend's alerts",
2195
+ reply: "212 alerts, 9 critical, all triaged. Nothing is waiting on you."
2196
+ },
2197
+ {
2198
+ title: "Service account review",
2199
+ ago: days(4),
2200
+ question: "Which service accounts logged in interactively?",
2201
+ reply: "Two did: svc-backup and svc-report. Both from jump hosts, both within change windows."
2202
+ },
2203
+ {
2204
+ title: "Mail rule forwarding outside",
2205
+ ago: days(5),
2206
+ question: "Find mailboxes forwarding to outside domains",
2207
+ reply: "Three mailboxes forward externally. One rule was created yesterday from an unfamiliar address."
2208
+ },
2209
+ {
2210
+ title: "Data egress from db-02",
2211
+ ago: days(6),
2212
+ question: "Did db-02 send unusual volumes out?",
2213
+ reply: "Egress peaked at 3.1 GB at 02:00, matching the nightly backup to the usual target."
2214
+ },
2215
+ {
2216
+ title: "New admin accounts in October",
2217
+ ago: days(9),
2218
+ question: "List admin accounts created in October",
2219
+ reply: "Four were created, all through the access request flow, each with an approver."
2220
+ },
2221
+ {
2222
+ title: "Endpoint isolation checklist",
2223
+ ago: days(12),
2224
+ question: "What do I check before isolating a laptop?",
2225
+ reply: "Confirm the owner, that a ticket exists, and that the device is not a server; then isolate and note why."
2226
+ },
2227
+ {
2228
+ title: "Lateral movement from hr-laptop-12",
2229
+ ago: days(15),
2230
+ question: "Did hr-laptop-12 move laterally?",
2231
+ reply: "It reached two file shares it uses daily and nothing else. No sign of lateral movement."
2232
+ },
2233
+ {
2234
+ title: "Firewall change review",
2235
+ ago: days(18),
2236
+ question: "Review last week's firewall changes",
2237
+ reply: "Eleven changes, all ticketed. One opened a port wider than its ticket asked for."
2238
+ },
2239
+ {
2240
+ title: "DNS tunnelling false positives",
2241
+ ago: days(21),
2242
+ question: "Why does the DNS tunnelling rule fire on the CDN?",
2243
+ reply: "Long subdomains from the CDN look like encoded data. An exception for its zone would quiet it."
2244
+ },
2245
+ {
2246
+ title: "Quarterly access review prep",
2247
+ ago: days(25),
2248
+ question: "Prepare the quarterly access review",
2249
+ reply: "Exported 312 grants across 41 groups; 17 have not been used in 90 days."
2250
+ },
2251
+ {
2252
+ title: "Ransomware tabletop notes",
2253
+ ago: days(30),
2254
+ question: "Summarise the ransomware tabletop",
2255
+ reply: "Detection was quick; isolation took 40 minutes because the runbook named an old tool."
2256
+ },
2257
+ {
2258
+ title: "Okta MFA fatigue alerts",
2259
+ ago: days(34),
2260
+ question: "Explain the MFA fatigue alerts",
2261
+ reply: "One user got 23 pushes in ten minutes and approved none. The password was reset."
2262
+ },
2263
+ {
2264
+ title: "Cloud storage made public",
2265
+ ago: days(40),
2266
+ question: "Which buckets were made public?",
2267
+ reply: "One bucket, for 6 minutes, by a deploy script. It is private again."
2268
+ },
2269
+ {
2270
+ title: "Old: printer beaconing",
2271
+ ago: days(47),
2272
+ archived: true,
2273
+ question: "Why is the printer beaconing?",
2274
+ reply: "Its firmware checks for updates every five minutes. Expected behaviour."
2275
+ },
2276
+ {
2277
+ title: "Old: test alert cleanup",
2278
+ ago: days(53),
2279
+ archived: true,
2280
+ question: "Clean up the test alerts",
2281
+ reply: "There were 48 test alerts; all are closed."
2282
+ },
2283
+ {
2284
+ title: "Old: onboarding questions",
2285
+ ago: days(60),
2286
+ archived: true,
2287
+ question: "How do I read an alert's timeline?",
2288
+ reply: "Newest at the top; each entry names its source, and a click opens the raw event."
2289
+ }
2290
+ ];
2291
+ var iso = (ms) => new Date(ms).toISOString();
2292
+ function seedTurn(session, index, seed, at) {
2293
+ const turnId = stableId("turn", `seed-${index}`);
2294
+ const messageId = stableId("msg", `seed-${index}`);
2295
+ const user = {
2296
+ role: "user",
2297
+ id: messageId,
2298
+ turnId,
2299
+ createdAt: iso(at - MINUTE),
2300
+ text: seed.question,
2301
+ refs: [],
2302
+ attachments: []
2303
+ };
2304
+ const answer = {
2305
+ role: "assistant",
2306
+ id: `${messageId}_a`,
2307
+ turnId,
2308
+ createdAt: iso(at),
2309
+ acting: "ask",
2310
+ blocks: seed.reply === "" ? [] : [{
2311
+ kind: "text",
2312
+ text: seed.reply
2313
+ }]
2314
+ };
2315
+ session.messages.push(user, answer);
2316
+ return {
2317
+ turnId,
2318
+ messageId
2319
+ };
2320
+ }
2321
+ /** The session waiting on a plan: two suppressions proposed, and the mock ready to run or decline them. */
2322
+ function waitOnApproval(session, turnId, messageId) {
2323
+ const answer = session.messages.at(-1);
2324
+ if (answer?.role !== "assistant") return;
2325
+ const approvalId = stableId("apr", session.id);
2326
+ const calls = ["a4", "a5"].map((id) => {
2327
+ return {
2328
+ id: stableId("step", `${session.id}-${id}`),
2329
+ tool: "lab__suppress_alert",
2330
+ input: { id }
2331
+ };
2332
+ });
2333
+ answer.acting = "plan";
2334
+ answer.reason = "more than one change, so a plan first";
2335
+ answer.blocks = calls.map((call) => {
2336
+ return {
2337
+ kind: "step",
2338
+ id: call.id,
2339
+ tool: call.tool,
2340
+ input: call.input,
2341
+ state: "awaiting-approval"
2342
+ };
2343
+ });
2344
+ answer.approval = {
2345
+ id: approvalId,
2346
+ state: "proposed"
2347
+ };
2348
+ session.pending = {
2349
+ kind: "approval",
2350
+ id: approvalId,
2351
+ calls
2352
+ };
2353
+ session.turn = {
2354
+ id: turnId,
2355
+ messageId,
2356
+ seq: 3 + calls.length,
2357
+ answerId: answer.id
2358
+ };
2359
+ session.user = {
2360
+ kind: "message",
2361
+ text: "Suppress a4 and a5, they are duplicates of a2",
2362
+ refs: []
2363
+ };
2364
+ }
2365
+ /**
2366
+ * The session holding a chart and a table, the four resources the harness reports, and a connector it acted through:
2367
+ * sources and actions both, so the column's two sections fill in dev.
2368
+ */
2369
+ function collect(session, turnId) {
2370
+ const answer = session.messages.at(-1);
2371
+ if (answer?.role !== "assistant") return;
2372
+ const chart = {
2373
+ ...hostChart(stableId("art", `${session.id}-chart`)),
2374
+ turnId
2375
+ };
2376
+ const table = {
2377
+ ...hostTable(session.id, stableId("art", `${session.id}-table`)),
2378
+ turnId
2379
+ };
2380
+ session.artifacts.push(chart, table);
2381
+ answer.blocks.push({
2382
+ kind: "artifact",
2383
+ id: chart.id
2384
+ }, {
2385
+ kind: "artifact",
2386
+ id: table.id
2387
+ });
2388
+ session.resources.push(...[
2389
+ {
2390
+ kind: "memory",
2391
+ use: "source",
2392
+ label: "Triage preferences",
2393
+ detail: "Read 3 notes",
2394
+ state: "done"
2395
+ },
2396
+ {
2397
+ kind: "connector",
2398
+ use: "source",
2399
+ label: "Lab API",
2400
+ detail: "Searched alerts",
2401
+ state: "done"
2402
+ },
2403
+ {
2404
+ kind: "agent",
2405
+ use: "action",
2406
+ label: "Alert triage",
2407
+ detail: "Ranked the alerts in view",
2408
+ state: "done"
2409
+ },
2410
+ {
2411
+ kind: "workflow",
2412
+ use: "action",
2413
+ label: "Suppress duplicates",
2414
+ detail: "Dry run: nothing changed",
2415
+ state: "done"
2416
+ },
2417
+ {
2418
+ kind: "connector",
2419
+ use: "action",
2420
+ label: "Ticketing",
2421
+ detail: "Drafted a ticket; not filed",
2422
+ state: "done"
2423
+ }
2424
+ ].map((resource) => {
2425
+ return {
2426
+ ...resource,
2427
+ id: stableId("res", `${session.id}-${resource.kind}-${resource.use}`),
2428
+ turnId
2429
+ };
2430
+ }));
2431
+ }
2432
+ /**
2433
+ * Seeds 25 sessions in the dev token's scope: 3 archived, 1 waiting on an approval, 1 holding a chart, a table and
2434
+ * five resources, updated today, in the previous 7 days and earlier. Every word in them is made up.
2435
+ */
2436
+ function seedSessions(sessions, scope = DEV_SCOPE, now = Date.now()) {
2437
+ for (const [index, seed] of [...SEEDS.entries()].reverse()) {
2438
+ const at = now - seed.ago;
2439
+ const session = sessions.create(scope, {
2440
+ id: stableId("ses", `seed-${index}`),
2441
+ createdAt: iso(at - 2 * MINUTE),
2442
+ updatedAt: iso(at)
2443
+ });
2444
+ session.title = seed.title;
2445
+ const { turnId, messageId } = seedTurn(session, index, seed, at);
2446
+ if (seed.archived === true) {
2447
+ session.archivedAt = iso(at + MINUTE);
2448
+ session.updatedAt = session.archivedAt;
2449
+ }
2450
+ if (index === 0) collect(session, turnId);
2451
+ if (index === 1) waitOnApproval(session, turnId, messageId);
2452
+ }
2453
+ }
2454
+ //#endregion
871
2455
  //#region src/mock/index.ts
872
2456
  /**
873
- * The assistant's dev API, fresh state per call. Its own dev server and the dev host's bundled copy
874
- * (`@aistrike-dev/ui-shell-dev`, which loads the build of this file) both run it. Never part of the MFE's build.
2457
+ * The assistant's dev API, fresh state per call: the agent sessions API with 25 seeded sessions in the dev token's
2458
+ * scope. Its own dev server and the dev host's bundled copy (`@aistrike-dev/ui-shell-dev`, which loads the build of
2459
+ * this file) both run it. Never part of the MFE's build.
875
2460
  */
876
2461
  function createMockHandlers() {
877
- return [createChatHandler()];
2462
+ const sessions = createSessions();
2463
+ seedSessions(sessions);
2464
+ return [createChatHandler({ sessions })];
878
2465
  }
879
2466
  //#endregion
880
2467
  export { createMockHandlers };