@frockbot/plugin-shell 0.3.15 → 0.3.17

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.
@@ -363,6 +363,13 @@ function assistantMessage(
363
363
  run: ClientRun,
364
364
  notification: ClientNotificationIntent | undefined,
365
365
  ): WebChatMessage {
366
+ const syncNotice = run.events.find(
367
+ (event) => event.type === "computer/sync" && event.message,
368
+ )?.message;
369
+ const notice = (primary?: string): string | undefined =>
370
+ [primary, syncNotice]
371
+ .filter((part): part is string => Boolean(part))
372
+ .join(" ") || undefined;
366
373
  if (run.status === "running") {
367
374
  // A streaming Turn carries only the text the model has produced. Until
368
375
  // there is any, the thread shows the animated avatar and no bubble.
@@ -375,6 +382,7 @@ function assistantMessage(
375
382
  // A Turn that has not started shows nothing of its own: the greyed user
376
383
  // message is the whole of what the thread says about it.
377
384
  ...(run.queued ? { pending: true } : {}),
385
+ ...(syncNotice ? { notice: syncNotice } : {}),
378
386
  tools: toolsFrom(run.events),
379
387
  sends: [],
380
388
  tasks: tasksFrom(run.events),
@@ -391,6 +399,7 @@ function assistantMessage(
391
399
  role: "assistant",
392
400
  text: visibleAssistantText(run),
393
401
  status: "aborted",
402
+ ...(syncNotice ? { notice: syncNotice } : {}),
394
403
  tools: toolsFrom(run.events),
395
404
  sends: [],
396
405
  tasks: tasksFrom(run.events),
@@ -409,7 +418,7 @@ function assistantMessage(
409
418
  * exactly like the Bot speaking.
410
419
  */
411
420
  text: visibleAssistantText(run),
412
- notice: "This reply stopped partway. Try again to continue it.",
421
+ notice: notice("This reply stopped partway. Try again to continue it."),
413
422
  status: "reconciliation-required",
414
423
  tools: toolsFrom(run.events),
415
424
  sends: [],
@@ -422,7 +431,7 @@ function assistantMessage(
422
431
  runId: run.runId,
423
432
  role: "assistant",
424
433
  text: visibleAssistantText(run),
425
- notice: "You stopped this.",
434
+ notice: notice("You stopped this."),
426
435
  status: "aborted",
427
436
  tools: toolsFrom(run.events),
428
437
  sends: [],
@@ -447,7 +456,7 @@ function assistantMessage(
447
456
  // wire, so this keeps whatever that chose — the model-deadline copy says
448
457
  // something the outcome alone cannot — and falls back to the same line a
449
458
  // reply-less failure gets.
450
- notice: knownFailureCopyV1(run.failure),
459
+ notice: notice(knownFailureCopyV1(run.failure)),
451
460
  status: "error",
452
461
  tools: toolsFrom(run.events),
453
462
  sends: [],
@@ -465,8 +474,10 @@ function assistantMessage(
465
474
  // Why the Turn ends there, under whatever it had already said — never as
466
475
  // the bubble's own text, which reads as the Bot saying it.
467
476
  ...(run.status === "failed"
468
- ? { notice: knownFailureCopyV1(run.failure) }
469
- : {}),
477
+ ? { notice: notice(knownFailureCopyV1(run.failure)) }
478
+ : syncNotice
479
+ ? { notice: syncNotice }
480
+ : {}),
470
481
  status: run.status === "failed" ? "error" : "completed",
471
482
  tools: toolsFrom(run.events),
472
483
  sends: [],
@@ -561,6 +572,7 @@ export function projectDurableRuns(
561
572
  ? { at: existingUser.at }
562
573
  : {}),
563
574
  status: "completed",
575
+ ...(run.via ? { via: run.via } : {}),
564
576
  // Greyed while its Turn waits, ordinary the moment it is running. The
565
577
  // flag comes from durable run state, so a reload draws the same thing.
566
578
  ...(run.queued ? { pending: true } : {}),
@@ -3070,6 +3082,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3070
3082
  * and the one-bubble-per-send contract is untouched.
3071
3083
  */
3072
3084
  let stopRunChannel: (() => void) | undefined;
3085
+ // The channel's own health, for the observation below: `fallback` means the
3086
+ // socket is gone and the transcript is flying blind, and each return to
3087
+ // `open` means it was gone for a while. Either is a reason to read the
3088
+ // running Turn from authority rather than trust a POST that may have died
3089
+ // with the same connection — which is how a phone kept drawing the working
3090
+ // trail for a Turn the server had already settled (2026-09-04).
3091
+ const channelFallback = ref(false);
3092
+ const channelReconnects = ref(0);
3073
3093
  const stopRunChannelWatch = watch(
3074
3094
  () => web.value.activeBotId,
3075
3095
  (botId) => {
@@ -3096,10 +3116,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3096
3116
  restoredWithoutRead = undefined;
3097
3117
  await deliverNotifications(botId, generation);
3098
3118
  },
3099
- status() {
3119
+ status(status) {
3100
3120
  // The channel's health is not the transcript's: an unavailable
3101
3121
  // socket falls back to the observation below, which is what a
3102
3122
  // client without one uses anyway.
3123
+ if (generation !== selectionGeneration) return;
3124
+ const wasDown = channelFallback.value;
3125
+ channelFallback.value = status === "fallback";
3126
+ if (status === "open" && wasDown) channelReconnects.value += 1;
3103
3127
  },
3104
3128
  });
3105
3129
  },
@@ -3107,12 +3131,21 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
3107
3131
  );
3108
3132
 
3109
3133
  const stopRunObservation = watch(
3110
- () => [web.value.activeBotId, web.value.activeRunId] as const,
3111
- ([botId, runId]) => {
3134
+ () =>
3135
+ [
3136
+ web.value.activeBotId,
3137
+ web.value.activeRunId,
3138
+ channelFallback.value,
3139
+ channelReconnects.value,
3140
+ ] as const,
3141
+ ([botId, runId, fallback]) => {
3112
3142
  // The send path owns the run it started: its POST is the observation,
3113
3143
  // and `stopRun` starts its own. This is for every other way a client
3114
- // finds itself watching a Turn it is not holding open.
3115
- if (!botId || !runId || activeRequest || runObserver) return;
3144
+ // finds itself watching a Turn it is not holding open — and, once the
3145
+ // state channel has dropped or come back, for the Turn it *is* holding
3146
+ // open, because that POST shared the connection that just failed.
3147
+ if (!botId || !runId || runObserver) return;
3148
+ if (activeRequest && !fallback && channelReconnects.value === 0) return;
3116
3149
  const generation = selectionGeneration;
3117
3150
  const observer = new AbortController();
3118
3151
  runObserver = observer;
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ const styles = await Bun.file(new URL("./styles.css", import.meta.url)).text();
4
+
5
+ describe("phone safe-area layout", () => {
6
+ test("keeps the conversation header and thread below the status bar", () => {
7
+ expect(styles).toContain(
8
+ "height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));",
9
+ );
10
+ expect(styles).toContain("padding-top: calc(var(--frock-safe-top) + 0px);");
11
+ expect(styles).toContain(
12
+ "top: calc(var(--frock-titlebar-height) + var(--frock-safe-top));",
13
+ );
14
+ });
15
+
16
+ test("keeps drawers, Settings, and the composer clear of both bars", () => {
17
+ expect(styles).toContain(
18
+ "padding-bottom: calc(10px + var(--frock-safe-bottom));",
19
+ );
20
+ expect(styles).toContain(
21
+ "padding-bottom: calc(16px + var(--frock-safe-bottom));",
22
+ );
23
+ expect(styles).toContain("bottom: calc(10px + var(--frock-safe-bottom));");
24
+ });
25
+ });
@@ -499,6 +499,17 @@
499
499
  background: var(--frock-action-primary);
500
500
  }
501
501
 
502
+ .message-user-column {
503
+ display: grid;
504
+ justify-items: end;
505
+ gap: 4px;
506
+ }
507
+
508
+ .message-via {
509
+ color: var(--frock-text-muted);
510
+ font-size: var(--frock-text-xs);
511
+ }
512
+
502
513
  /*
503
514
  * User-facing sends. They stack under the Bot's avatar in the same column the
504
515
  * text bubble occupies, so a Turn that only sent a widget still reads as the
@@ -1118,6 +1129,7 @@
1118
1129
  /* `calc` so the declaration is a length the checker can type; `env` alone
1119
1130
  is not one it knows. */
1120
1131
  padding-top: calc(var(--frock-safe-top) + 0px);
1132
+ padding-bottom: calc(10px + var(--frock-safe-bottom));
1121
1133
  box-shadow: var(--frock-shadow-panel);
1122
1134
  transform: translateX(-100%);
1123
1135
  visibility: hidden;
@@ -1189,12 +1201,27 @@
1189
1201
  /* No window chrome to clear, but the panel toggle still sits at the
1190
1202
  trailing edge, so the row ends before it rather than under it. */
1191
1203
  .topbar {
1204
+ height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1192
1205
  gap: 8px;
1193
1206
  padding: 0 52px 0 4px;
1207
+ padding-top: calc(var(--frock-safe-top) + 0px);
1194
1208
  }
1195
1209
 
1196
1210
  .window-actions {
1211
+ height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1197
1212
  padding: 0 8px;
1213
+ padding-top: calc(var(--frock-safe-top) + 0px);
1214
+ }
1215
+
1216
+ .right-panel-header,
1217
+ .panel-surface-header {
1218
+ height: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1219
+ padding-top: calc(var(--frock-safe-top) + 0px);
1220
+ }
1221
+
1222
+ .right-panel-body,
1223
+ .panel-surface-content {
1224
+ padding-bottom: calc(16px + var(--frock-safe-bottom));
1198
1225
  }
1199
1226
 
1200
1227
  .brand-mark {
@@ -1211,6 +1238,7 @@
1211
1238
  * vertical anchors all clear the home indicator.
1212
1239
  */
1213
1240
  .thread {
1241
+ top: calc(var(--frock-titlebar-height) + var(--frock-safe-top));
1214
1242
  bottom: calc(76px + var(--frock-safe-bottom));
1215
1243
  padding: 12px 12px 20px;
1216
1244
  }
@@ -15,6 +15,7 @@ import {
15
15
  parseCompactionSummaryV1,
16
16
  PRUNED_TOOL_RESULT_V1,
17
17
  pruneToolOutputsV1,
18
+ renderCompactionSummaryV1,
18
19
  runCompactionV1,
19
20
  COMPACTION_TRIGGER_RATIO_V1,
20
21
  } from "./compaction.js";
@@ -385,6 +386,31 @@ describe("injecting a compaction", () => {
385
386
  });
386
387
 
387
388
  describe("reading the summariser's answer", () => {
389
+ test("renders the validated fields into the durable heading format", () => {
390
+ expect(
391
+ renderCompactionSummaryV1({
392
+ summary: "Work on the Applet.",
393
+ decisions: ["Keep the first design."],
394
+ openItems: [],
395
+ identifiers: ["applet-9f2c"],
396
+ }),
397
+ ).toBe(
398
+ [
399
+ "## Summary",
400
+ "Work on the Applet.",
401
+ "",
402
+ "## Decisions",
403
+ "- Keep the first design.",
404
+ "",
405
+ "## Open items",
406
+ "- none",
407
+ "",
408
+ "## Identifiers mentioned",
409
+ "- applet-9f2c",
410
+ ].join("\n"),
411
+ );
412
+ });
413
+
388
414
  test("lifts the identifiers out of their heading", () => {
389
415
  const parsed = parseCompactionSummaryV1(
390
416
  [
package/src/compaction.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  type ModelBindingSnapshot,
27
27
  type Session,
28
28
  type SessionEvent,
29
+ type StructuredOutputSchemaV1,
29
30
  } from "@frockbot/kernel-contracts";
30
31
 
31
32
  /**
@@ -262,27 +263,58 @@ export function assessCompactionV1(input: {
262
263
  * ids in passing, and the list is what the event stores.
263
264
  */
264
265
  export const COMPACTION_SYSTEM_PROMPT_V1 = [
265
- "You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt. Write the summary, and nothing else.",
266
+ "You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt.",
266
267
  "",
267
268
  "CRITICAL: You MUST preserve ALL opaque identifiers exactly as they appear. That includes UUIDs, hashes, full URLs with their query parameters, file and Workspace paths, Package ids, Applet ids, Bot ids, Session ids, tool call ids, model names and version strings. Do NOT paraphrase, abbreviate, or generalise an identifier. Copy it exactly.",
268
269
  "",
269
- "Use exactly these headings, in this order, and omit none of them:",
270
- "",
271
- "## Summary",
272
- "What the conversation is about and what has happened, in a few short paragraphs or bullets.",
273
- "",
274
- "## Decisions",
275
- "Decisions made and the reason for each. Where a decision was later changed, keep only the latest and say it superseded an earlier one.",
276
- "",
277
- "## Open items",
278
- "Work that is pending, promised, or unfinished. Be specific about what is owed and by whom.",
279
- "",
280
- "## Identifiers mentioned",
281
- "A bullet list of every opaque identifier that appeared, one per line, copied exactly. Write `- none` if there were none.",
270
+ "Put the gist in `summary`, decisions and their reasons in `decisions`, pending work in `openItems`, and every opaque identifier copied exactly in `identifiers`. Keep only the latest decision where one superseded another.",
282
271
  "",
283
272
  "Leave out pleasantries, repetition, and superseded detail. Do not invent anything that is not in the transcript. Do not address the user.",
284
273
  ].join("\n");
285
274
 
275
+ export interface CompactionSummaryPayloadV1 {
276
+ summary: string;
277
+ decisions: string[];
278
+ openItems: string[];
279
+ identifiers: string[];
280
+ }
281
+
282
+ /** The actual production consumer of the shared structured-output seam. */
283
+ export const COMPACTION_RESPONSE_SCHEMA_V1 = {
284
+ type: "object",
285
+ properties: {
286
+ summary: { type: "string" },
287
+ decisions: { type: "array", items: { type: "string" } },
288
+ openItems: { type: "array", items: { type: "string" } },
289
+ identifiers: { type: "array", items: { type: "string" } },
290
+ },
291
+ required: ["summary", "decisions", "openItems", "identifiers"],
292
+ additionalProperties: false,
293
+ } as const satisfies StructuredOutputSchemaV1;
294
+
295
+ /** Keeps the durable summary format readable while model I/O stays typed. */
296
+ export function renderCompactionSummaryV1(
297
+ payload: CompactionSummaryPayloadV1,
298
+ ): string {
299
+ const bullets = (values: readonly string[]) =>
300
+ values.length > 0
301
+ ? values.map((value) => `- ${value}`).join("\n")
302
+ : "- none";
303
+ return [
304
+ "## Summary",
305
+ payload.summary,
306
+ "",
307
+ "## Decisions",
308
+ bullets(payload.decisions),
309
+ "",
310
+ "## Open items",
311
+ bullets(payload.openItems),
312
+ "",
313
+ "## Identifiers mentioned",
314
+ bullets(payload.identifiers),
315
+ ].join("\n");
316
+ }
317
+
286
318
  /** The transcript one summariser call is given, flattened to plain text. */
287
319
  export function compactionTranscriptV1(
288
320
  messages: readonly LlmMessage[],
@@ -20,9 +20,10 @@ export const BOT_DEBUG_RUN_LIMIT_V1 = 20;
20
20
  export const BOT_DEBUG_DEFAULT_RUN_LIMIT_V1 = 5;
21
21
  export const BOT_DEBUG_GENERATION_LIMIT_V1 = 5;
22
22
  /**
23
- * The event budget one snapshot spends. Session events carry whole prompts, so
24
- * a handful of runs can be megabytes; past this the *oldest* events of a run
25
- * are dropped, because a failure is described by the tail of its log.
23
+ * The event budget one snapshot spends. Large durable events are already
24
+ * bounded diagnostic projections, but a handful of runs can still accumulate
25
+ * many of them; past this the *oldest* events of a run are dropped, because a
26
+ * failure is described by the tail of its log.
26
27
  */
27
28
  export const BOT_DEBUG_EVENT_BYTES_V1 = 512_000;
28
29
 
package/src/history.ts CHANGED
@@ -175,7 +175,10 @@ export function chatWindowV1(
175
175
  ): ChatWindowV1 {
176
176
  const turns = messageTurnsV1(events);
177
177
  const types = turnTypesByTurnV1(events);
178
- const chat = (turn: number) => (types.get(turn) ?? "chat") === "chat";
178
+ const chat = (turn: number) => {
179
+ const type = types.get(turn) ?? "chat";
180
+ return type === "chat" || type === "agent";
181
+ };
179
182
  const state = compactionStateV1(events);
180
183
  const current = currentTurnV1(events);
181
184
  // A compaction never covers the Turn being assembled, whatever the log says:
@@ -229,7 +232,10 @@ export function turnScopedMessagesV1(
229
232
  }
230
233
  const types = turnTypesByTurnV1(input.events);
231
234
  const current = currentTurnV1(input.events);
232
- const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
235
+ const chatTurn = (turn: number) => {
236
+ const type = types.get(turn) ?? "chat";
237
+ return type === "chat" || type === "agent";
238
+ };
233
239
  if (chatTurn(current)) {
234
240
  const window = chatWindowV1(input.events, input.messages);
235
241
  // Tier 1 of ADR 0030, and the only one that costs nothing: a tool result
@@ -1,3 +1,4 @@
1
+ import { STEP_LIMIT_REASON_V1 } from "@frockbot/kernel-agent-loop";
1
2
  import { describe, expect, test } from "bun:test";
2
3
  import type { SessionEvent } from "@frockbot/kernel-contracts";
3
4
  import { MODEL_FIRST_BYTE_DEADLINE_REASON_V1 } from "@frockbot/kernel-contracts";
@@ -157,3 +158,13 @@ describe("runFailureCopyV1", () => {
157
158
  expect(projected.outcome.message).toBe(RUN_FAILURE_COPY_V1.interrupted);
158
159
  });
159
160
  });
161
+
162
+ test("a reply that ran out of steps says so in plain words", () => {
163
+ // `kernel-do` wraps the loop's reason; the sentence survives the wrapper.
164
+ expect(
165
+ runFailureCopyV1({
166
+ failure: `Bot turn ended with outcome interrupted: ${STEP_LIMIT_REASON_V1}`,
167
+ }),
168
+ ).toBe(STEP_LIMIT_REASON_V1);
169
+ expect(knownFailureCopyV1(STEP_LIMIT_REASON_V1)).toBe(STEP_LIMIT_REASON_V1);
170
+ });
@@ -7,7 +7,10 @@ import {
7
7
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
8
8
  MODEL_IDLE_DEADLINE_REASON_V1,
9
9
  } from "@frockbot/kernel-contracts";
10
- import { TURN_DEADLINE_REASON_V1 } from "@frockbot/kernel-agent-loop";
10
+ import {
11
+ STEP_LIMIT_REASON_V1,
12
+ TURN_DEADLINE_REASON_V1,
13
+ } from "@frockbot/kernel-agent-loop";
11
14
  import { UNRECONCILABLE_RUN_FAILURE_V1 } from "@frockbot/kernel-do";
12
15
 
13
16
  /**
@@ -47,6 +50,7 @@ export const USER_FACING_FAILURE_REASONS_V1: readonly string[] = [
47
50
  MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
48
51
  MODEL_IDLE_DEADLINE_REASON_V1,
49
52
  TURN_DEADLINE_REASON_V1,
53
+ STEP_LIMIT_REASON_V1,
50
54
  UNRECONCILABLE_RUN_FAILURE_V1,
51
55
  ];
52
56
 
@@ -24,6 +24,7 @@ import {
24
24
  projectClientRunLookupV1,
25
25
  projectClientRunListV1,
26
26
  projectClientRunV1,
27
+ isVisibleRunV1,
27
28
  projectClientRunOrDegradedV1,
28
29
  projectClientTurnV1,
29
30
  UNRECORDED_TOOL_RESULT_TEXT_V1,
@@ -88,6 +89,37 @@ function storedRun(
88
89
  }
89
90
 
90
91
  describe("client run protocol v1", () => {
92
+ test("projects an agent Turn with its Bot origin marker", () => {
93
+ const agent = {
94
+ ...storedRun([]),
95
+ admission: {
96
+ schemaVersion: 1 as const,
97
+ turnType: "agent" as const,
98
+ origin: {
99
+ kind: "bot" as const,
100
+ fromBotId: "researcher",
101
+ fromBotName: "Researcher",
102
+ messageId: "message-1",
103
+ },
104
+ },
105
+ };
106
+
107
+ expect(isVisibleRunV1(agent)).toBe(true);
108
+ const projected = projectClientRunV1(agent);
109
+ expect(projected).toMatchObject({
110
+ schemaVersion: 3,
111
+ input: "continue",
112
+ via: { kind: "bot", name: "Researcher", botId: "researcher" },
113
+ });
114
+ expect(
115
+ decodeClientRunListV1({
116
+ schemaVersion: 1,
117
+ runs: [projected],
118
+ page: { truncated: false },
119
+ })[0]?.via,
120
+ ).toEqual(projected.via);
121
+ });
122
+
91
123
  test("rejects durable runs missing current admission fields", () => {
92
124
  const complete = storedRun([], "running");
93
125
  for (const field of [
@@ -728,7 +760,7 @@ describe("client run protocol v1", () => {
728
760
  schemaVersion: 1,
729
761
  runs: [
730
762
  {
731
- schemaVersion: 2,
763
+ schemaVersion: 3,
732
764
  runId: "run-1",
733
765
  admittedAt: timestamp,
734
766
  input: "continue",
@@ -977,9 +1009,8 @@ describe("client run protocol v1", () => {
977
1009
  ]),
978
1010
  );
979
1011
 
980
- // Version 2 is what carries the two new event types; a client pinned to 1
981
- // still decodes the body it produces.
982
- expect(projected.schemaVersion).toBe(2);
1012
+ // Version 3 carries agent-origin markers; older bodies still decode.
1013
+ expect(projected.schemaVersion).toBe(3);
983
1014
  expect(projected.events).toEqual([
984
1015
  { type: "send/to-user", payload: { type: "text", text: "On it." } },
985
1016
  { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
@@ -1614,3 +1645,41 @@ describe("dispatched subagents in the run projection", () => {
1614
1645
  );
1615
1646
  });
1616
1647
  });
1648
+
1649
+ describe("Computer sync degradation in the run projection", () => {
1650
+ const degraded: SessionEvent = {
1651
+ type: "computer/sync",
1652
+ seq: 4,
1653
+ timestamp,
1654
+ turn: 1,
1655
+ reason: "open",
1656
+ status: "degraded",
1657
+ detail: "Excluded 2 reproducible Workspace items from sync.",
1658
+ pulled: 0,
1659
+ pushed: 1,
1660
+ restored: 0,
1661
+ removed: 0,
1662
+ adopted: 0,
1663
+ conflicts: 0,
1664
+ failures: 0,
1665
+ ignored: 2,
1666
+ omitted: 0,
1667
+ };
1668
+
1669
+ test("projects one plain-language event and round-trips it", () => {
1670
+ const projected = projectClientRunV1(
1671
+ storedRun([degraded, { ...degraded, seq: 5, reason: "turn-end" }]),
1672
+ );
1673
+ expect(projected.events).toEqual([
1674
+ {
1675
+ type: "computer/sync",
1676
+ status: "degraded",
1677
+ message: "Excluded 2 reproducible Workspace items from sync.",
1678
+ },
1679
+ ]);
1680
+ const page = createClientRunListV1([projected], { truncated: false });
1681
+ expect(
1682
+ decodeClientRunPageV1(structuredClone(page)).runs[0]?.events,
1683
+ ).toEqual(projected.events);
1684
+ });
1685
+ });