@frockbot/plugin-shell 0.3.10 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -48,41 +48,42 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@cordisjs/plugin-webui": "0.8.2",
51
- "@frockbot/agent-runtime": "0.3.10",
52
- "@frockbot/application-foundation": "0.3.10",
53
- "@frockbot/catalog-core": "0.3.10",
54
- "@frockbot/client-core": "0.3.10",
55
- "@frockbot/client-ui": "0.3.10",
56
- "@frockbot/computer-core": "0.3.10",
57
- "@frockbot/computer-host-protocol": "0.3.10",
58
- "@frockbot/configuration-core": "0.3.10",
59
- "@frockbot/connection-core": "0.3.10",
60
- "@frockbot/kernel-agent-loop": "0.3.10",
61
- "@frockbot/kernel-composition": "0.3.10",
62
- "@frockbot/kernel-contracts": "0.3.10",
63
- "@frockbot/kernel-do": "0.3.10",
64
- "@frockbot/machine-protocol": "0.3.10",
65
- "@frockbot/plugin-applets": "0.3.10",
66
- "@frockbot/plugin-authoring": "0.3.10",
67
- "@frockbot/plugin-bot-template": "0.3.10",
68
- "@frockbot/plugin-computer": "0.3.10",
69
- "@frockbot/plugin-flock": "0.3.10",
70
- "@frockbot/plugin-image": "0.3.10",
71
- "@frockbot/plugin-machine-messages": "0.3.10",
72
- "@frockbot/plugin-mcp": "0.3.10",
73
- "@frockbot/plugin-memory": "0.3.10",
74
- "@frockbot/plugin-package-catalog": "0.3.10",
75
- "@frockbot/plugin-routines": "0.3.10",
76
- "@frockbot/plugin-skills": "0.3.10",
77
- "@frockbot/plugin-subagents": "0.3.10",
78
- "@frockbot/plugin-user-machine": "0.3.10",
79
- "@frockbot/protocol": "0.3.10",
51
+ "@frockbot/agent-runtime": "0.3.11",
52
+ "@frockbot/application-foundation": "0.3.11",
53
+ "@frockbot/catalog-core": "0.3.11",
54
+ "@frockbot/client-core": "0.3.11",
55
+ "@frockbot/client-ui": "0.3.11",
56
+ "@frockbot/computer-core": "0.3.11",
57
+ "@frockbot/computer-host-protocol": "0.3.11",
58
+ "@frockbot/configuration-core": "0.3.11",
59
+ "@frockbot/connection-core": "0.3.11",
60
+ "@frockbot/kernel-agent-loop": "0.3.11",
61
+ "@frockbot/kernel-composition": "0.3.11",
62
+ "@frockbot/kernel-contracts": "0.3.11",
63
+ "@frockbot/kernel-do": "0.3.11",
64
+ "@frockbot/machine-protocol": "0.3.11",
65
+ "@frockbot/plugin-applets": "0.3.11",
66
+ "@frockbot/plugin-authoring": "0.3.11",
67
+ "@frockbot/plugin-bot-template": "0.3.11",
68
+ "@frockbot/plugin-computer": "0.3.11",
69
+ "@frockbot/plugin-flock": "0.3.11",
70
+ "@frockbot/plugin-image": "0.3.11",
71
+ "@frockbot/plugin-machine-messages": "0.3.11",
72
+ "@frockbot/plugin-mcp": "0.3.11",
73
+ "@frockbot/plugin-memory": "0.3.11",
74
+ "@frockbot/plugin-package-catalog": "0.3.11",
75
+ "@frockbot/plugin-routines": "0.3.11",
76
+ "@frockbot/plugin-skills": "0.3.11",
77
+ "@frockbot/plugin-subagents": "0.3.11",
78
+ "@frockbot/plugin-user-machine": "0.3.11",
79
+ "@frockbot/protocol": "0.3.11",
80
80
  "cordis": "4.0.0-rc.8",
81
81
  "vue": "3.5.41"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@cloudflare/workers-types": "latest",
85
- "@frockbot/plugin-tools": "0.3.10",
85
+ "@frockbot/plugin-prompt": "0.3.11",
86
+ "@frockbot/plugin-tools": "0.3.11",
86
87
  "@types/bun": "1.3.6",
87
88
  "@types/node": "26.2.0",
88
89
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.test.ts CHANGED
@@ -8,9 +8,12 @@ import {
8
8
  type ToolExecutionContext,
9
9
  type TurnTypeV1,
10
10
  } from "@frockbot/kernel-contracts";
11
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
11
12
  import { ToolRegistry } from "@frockbot/plugin-tools";
12
13
  import { Context } from "cordis";
13
14
  import {
15
+ CONVERSATION_PROMPT_SECTION_V1,
16
+ CONVERSATION_PROMPT_TEXT_V1,
14
17
  shellAdmissionCeilingV1,
15
18
  shellAgentPlugin,
16
19
  PARENT_HANDOFF_CAPABILITY_V1,
@@ -31,6 +34,7 @@ interface Mounted {
31
34
  async function mount(): Promise<Mounted> {
32
35
  const root = new Context();
33
36
  await root.plugin(SessionStore);
37
+ await root.plugin(SystemPromptRegistry);
34
38
  await root.plugin(ToolRegistry);
35
39
  const session = root.sessions.create(SESSION_ID);
36
40
  session.appendBatch([
@@ -382,3 +386,65 @@ describe("wake_parent", () => {
382
386
  }
383
387
  });
384
388
  });
389
+
390
+ // The conversational contract is a prompt section and a tool description, and
391
+ // the two have to say the same thing: a model that read one and not the other
392
+ // would have half the rule.
393
+ describe("the conversation prompt section", () => {
394
+ test("is assembled into the system prompt the model reads", async () => {
395
+ const mounted = await mount();
396
+ try {
397
+ const assembled = await mounted.root.systemPrompt.assemble({
398
+ sessionId: SESSION_ID,
399
+ provider: "test",
400
+ model: "test-model",
401
+ turnType: "chat",
402
+ });
403
+
404
+ const section = assembled.sections.find(
405
+ (candidate) => candidate.id === CONVERSATION_PROMPT_SECTION_V1,
406
+ );
407
+ expect(section?.text).toBe(CONVERSATION_PROMPT_TEXT_V1);
408
+ expect(assembled.text).toContain(CONVERSATION_PROMPT_TEXT_V1);
409
+ } finally {
410
+ await mounted.dispose();
411
+ }
412
+ });
413
+
414
+ test("says acknowledge first, then beats, and never the steps between", () => {
415
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain("On it.");
416
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
417
+ "send only on a real beat: the result, a decision only the user can make, or a blocker",
418
+ );
419
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
420
+ "Never narrate what you are doing",
421
+ );
422
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
423
+ "Never leave a question or a request hanging",
424
+ );
425
+ // Short enough to be read as guidance rather than skimmed as a document.
426
+ expect(CONVERSATION_PROMPT_TEXT_V1.split("\n").length).toBeLessThanOrEqual(
427
+ 12,
428
+ );
429
+ });
430
+
431
+ test("matches what the send tool's own description tells the model", async () => {
432
+ const mounted = await mount();
433
+ try {
434
+ const schema = mounted.root.tools
435
+ .schemas({ turnType: "chat" })
436
+ .find((tool) => tool.name === SEND_TO_USER_TOOL_V1);
437
+ const description = schema?.description ?? "";
438
+
439
+ expect(description).toContain("only way to say anything the user sees");
440
+ // When to call it, not only what it does.
441
+ expect(description).toContain("Call it once, immediately");
442
+ expect(description).toContain("Call it again only on a real");
443
+ expect(description).toContain("narrate a step or a tool");
444
+ expect(description).toContain("never end your Turn leaving the");
445
+ expect(description).toContain("keep it short");
446
+ } finally {
447
+ await mounted.dispose();
448
+ }
449
+ });
450
+ });
package/src/agent.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  //
4
4
  // Two tools, and no authority of its own:
5
5
  //
6
+ // 0. One prompt section, `conversation`: when to speak and when not to. It
7
+ // is contributed beside the tool so the section and the tool description
8
+ // cannot drift into telling the model two different things.
9
+ //
6
10
  // 1. `send_to_user` (legacy alias `send_message`) — parity register row 57b.
7
11
  // One tool carrying the typed payload union, admitted on chat turns only,
8
12
  // recording each send as `send/to-user` on the durable log. Row 57c: a
@@ -127,8 +131,38 @@ function sendAcknowledgement(payload: SendToUserPayloadV1): string {
127
131
  }
128
132
  }
129
133
 
134
+ /**
135
+ * The Bot's conversational contract, in the words the model reads.
136
+ *
137
+ * It lives here rather than in a prompt Package because it is the same rule
138
+ * the send tool's own description states: one place to write it, so the
139
+ * section and the tool cannot drift into telling the model two things. The
140
+ * Shell already owns the voice; it owns how the voice is used.
141
+ */
142
+ export const CONVERSATION_PROMPT_SECTION_V1 = "conversation";
143
+ /** Ordered after identity (0), before anything a Package contributes. */
144
+ export const CONVERSATION_PROMPT_ORDER_V1 = 1;
145
+
146
+ export const CONVERSATION_PROMPT_TEXT_V1 = [
147
+ "## Talking to the user",
148
+ "",
149
+ "Everything the user sees is a `send_to_user` call; nothing else reaches them.",
150
+ 'When a request will take more than a moment, send one short line first — "On it." or "Looking into that." — then go quiet and work.',
151
+ "After that, send only on a real beat: the result, a decision only the user can make, or a blocker you cannot get past.",
152
+ "Never narrate what you are doing, what you are about to do, or which tool you are using.",
153
+ "Never leave a question or a request hanging: before you stop, the user must have the answer, the result, or the reason there isn't one.",
154
+ "When the work is finished, send the result itself, not an account of how you got it.",
155
+ "Keep every message short — a line or two, no preamble and no sign-off.",
156
+ "Don't say the same thing twice.",
157
+ ].join("\n");
158
+
130
159
  const SEND_TO_USER_DESCRIPTION = [
131
160
  "Speak to the user. This is the only way to say anything the user sees.",
161
+ "Call it once, immediately, with one short line when the request will take",
162
+ "more than a moment, then work in silence. Call it again only on a real",
163
+ "beat: the result, a decision only the user can make, or a blocker. Do not",
164
+ "call it to narrate a step or a tool, and never end your Turn leaving the",
165
+ "user's question unanswered. Each call is one message; keep it short.",
132
166
  "The payload is one of:",
133
167
  '{"type":"text","text":"…"}',
134
168
  '{"type":"attachment","url":"https://…","name":"…","mediaType":"…"}',
@@ -295,6 +329,13 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
295
329
  const userVoice = shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1);
296
330
  const parentHandoff = shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1);
297
331
  const disposers = [
332
+ // The voice and the rules for using it are contributed together, so a
333
+ // Composition that admits the send tool always carries the contract.
334
+ ctx.systemPrompt.register({
335
+ id: CONVERSATION_PROMPT_SECTION_V1,
336
+ order: CONVERSATION_PROMPT_ORDER_V1,
337
+ render: () => CONVERSATION_PROMPT_TEXT_V1,
338
+ }),
298
339
  ctx.tools.register(
299
340
  createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
300
341
  userVoice ? { admissionCeiling: userVoice } : undefined,
@@ -327,6 +368,6 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
327
368
  for (const dispose of disposers.toReversed()) dispose();
328
369
  };
329
370
  };
330
- shellAgentPlugin.inject = ["tools", "sessions"];
371
+ shellAgentPlugin.inject = ["tools", "sessions", "systemPrompt"];
331
372
 
332
373
  export default shellAgentPlugin;
@@ -8,6 +8,7 @@ import type {
8
8
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
9
9
  import { createShellBotBackendContribution } from "./backend.js";
10
10
  import { createIsolateCapabilityHost } from "./backend-isolate.js";
11
+ import { notificationIdV1 } from "./notification-id.js";
11
12
 
12
13
  class MemoryStorage {
13
14
  readonly values = new Map<string, unknown>();
@@ -601,9 +602,14 @@ describe("generic per-Turn model resolution", () => {
601
602
  await expect(contribution.listNotifications()).resolves.toEqual([
602
603
  expect.objectContaining({
603
604
  // Colons would fail the acknowledge decoder, so the mint replaces
604
- // them; a notification nobody can acknowledge 400s forever.
605
- notificationId:
606
- "package-connection-unavailable-run-1-bot-authored-flock-ai-ambient",
605
+ // them and folds in a digest of the raw parts; a notification nobody
606
+ // can acknowledge 400s forever.
607
+ notificationId: notificationIdV1(
608
+ "package-connection-unavailable",
609
+ "run-1",
610
+ "bot-authored",
611
+ "flock-ai-ambient",
612
+ ),
607
613
  title: "Connection unavailable",
608
614
  }),
609
615
  ]);
@@ -520,12 +520,9 @@ export function createPackageCatalogHost(
520
520
  index,
521
521
  catalogId: provenance.catalogId,
522
522
  });
523
- // A first-party member pins no bundle, so "the entry still offers
524
- // what the target generation recorded" is the two hashes agreeing,
525
- // both present or both absent.
526
523
  if (
527
- !targetEntry ||
528
- targetEntry.bundle?.contentHash !== provenance.contentHash
524
+ !targetEntry?.bundle ||
525
+ targetEntry.bundle.contentHash !== provenance.contentHash
529
526
  ) {
530
527
  return undoRefused(
531
528
  request,
@@ -541,9 +538,7 @@ export function createPackageCatalogHost(
541
538
  version: targetCatalogMember.version,
542
539
  catalogId: provenance.catalogId,
543
540
  catalogGeneration: provenance.catalogGeneration,
544
- ...(provenance.contentHash === undefined
545
- ? {}
546
- : { contentHash: provenance.contentHash }),
541
+ contentHash: provenance.contentHash,
547
542
  };
548
543
  }
549
544
  const receipt = await options.user.execute(command);
@@ -596,6 +591,13 @@ export function createPackageCatalogHost(
596
591
  let user: UserSettingsViewV1;
597
592
  let base: CompositionGenerationV1;
598
593
  let intent: PackageCatalogChangeIntentV1;
594
+ /**
595
+ * The entry names a compiled-in Package that is already required core in
596
+ * this Bot's Composition, so installing it is a User settings change and
597
+ * nothing else. Set on the install path and re-derived on a resumed
598
+ * intent, which carries no bundle hash precisely when this is true.
599
+ */
600
+ let firstParty = false;
599
601
  try {
600
602
  user = await options.user.read();
601
603
  base = await options.composition.lastKnownGood();
@@ -616,6 +618,12 @@ export function createPackageCatalogHost(
616
618
  );
617
619
  entry = resumed;
618
620
  intent = existingIntent;
621
+ firstParty =
622
+ existingIntent.action !== "remove" &&
623
+ !resumed.bundle &&
624
+ base.members.find(
625
+ (candidate) => candidate.packageId === resumed.packageId,
626
+ )?.provenance.kind === "first-party";
619
627
  } else if (action === "remove") {
620
628
  const packageId = request.change.input.packageId;
621
629
  const member = base.members.find(
@@ -697,7 +705,27 @@ export function createPackageCatalogHost(
697
705
  const member = base.members.find(
698
706
  (candidate) => candidate.packageId === entry.packageId,
699
707
  );
700
- if (action === "install" && member) {
708
+ // Installing a first-party entry changes the *User's* settings, not
709
+ // the Composition: the compiled-in Package is already required core
710
+ // in the bootstrap generation (every one of them is), and
711
+ // `assertRequiredCoreSet` refuses any proposal that restates a
712
+ // first-party member with catalog provenance. So "already a member"
713
+ // is the normal state here, not a conflict — the guard below is
714
+ // about a *bundle-backed* entry, whose code really would be added
715
+ // twice.
716
+ firstParty =
717
+ !entry.bundle && member?.provenance.kind === "first-party";
718
+ if (firstParty) {
719
+ // The one integrity check left for a member nothing re-hashes at
720
+ // mount: the entry must name the manifest the application actually
721
+ // compiled in, or the Catalog has drifted from this deployment.
722
+ if (member!.manifestHash !== entry.manifestHash) {
723
+ throw new Error(
724
+ `Catalog entry "${entry.catalogId}" names manifest "${entry.manifestHash}", but this deployment ships "${member!.manifestHash}"`,
725
+ );
726
+ }
727
+ }
728
+ if (action === "install" && member && !firstParty) {
701
729
  throw new Error(
702
730
  `Package "${entry.packageId}" is already in this Bot's Composition`,
703
731
  );
@@ -757,9 +785,12 @@ export function createPackageCatalogHost(
757
785
  const bundle = entry.bundle;
758
786
  await options.storage.put({
759
787
  [packageCatalogChangeIntentKey(request.effectId)]: intent,
760
- // A bundle-less entry has no manifest document to store; the mount
761
- // resolves it from the compiled-in application instead, still
762
- // against the `manifestHash` the generation records.
788
+ // A bundle-less entry stores no manifest document, because it adds
789
+ // no Composition member to mount one for: the compiled-in Package is
790
+ // already required core, running the manifest this deployment
791
+ // shipped. The entry's `manifestHash` was checked against that
792
+ // member's above, so a Catalog that has drifted from this deployment
793
+ // is refused rather than silently recorded.
763
794
  ...(bundle
764
795
  ? {
765
796
  [authorshipManifestKey(intent.manifestHash)]: {
@@ -805,11 +836,35 @@ export function createPackageCatalogHost(
805
836
 
806
837
  const parent =
807
838
  (await options.composition.read(intent.baseGenerationId)) ?? base;
839
+ // A first-party entry contributes no member: it is already required core
840
+ // in the bootstrap generation, and restating it with catalog provenance
841
+ // is exactly what `assertRequiredCoreSet` refuses. The install is the
842
+ // User settings command above, which is all the Plugins page does too,
843
+ // so the Composition is left on the generation it was already running.
844
+ if (firstParty) {
845
+ const outcome: PackageCatalogChangeOutcomeRecordV1 = {
846
+ schemaVersion: 1,
847
+ status: "recorded",
848
+ effectId: request.effectId,
849
+ action,
850
+ packageId: intent.packageId,
851
+ displayName: intent.displayName,
852
+ version: intent.version,
853
+ generationId: parent.generationId,
854
+ missingConnectionTypes: intent.missingConnectionTypes,
855
+ recordedAt: intent.recordedAt,
856
+ };
857
+ await options.storage.put({
858
+ [packageCatalogChangeOutcomeKey(request.effectId)]: outcome,
859
+ });
860
+ return recordedOutcome(outcome);
861
+ }
862
+
808
863
  const members = parent.members.filter(
809
864
  (member) => member.packageId !== intent.packageId,
810
865
  );
811
866
  if (action !== "remove") {
812
- const bundle = entry.bundle;
867
+ const bundle = entry.bundle!;
813
868
  const member: CompositionMemberV1 = {
814
869
  packageId: entry.packageId,
815
870
  specifier: `catalog:${entry.catalogId}`,
@@ -821,20 +876,14 @@ export function createPackageCatalogHost(
821
876
  version: entry.version,
822
877
  catalogId: entry.catalogId,
823
878
  catalogGeneration: intent.catalogGeneration,
824
- ...(bundle ? { contentHash: bundle.contentHash } : {}),
879
+ contentHash: bundle.contentHash,
880
+ },
881
+ artifact: {
882
+ contentHash: bundle.contentHash,
883
+ size: bundle.size,
884
+ mediaType: bundle.mediaType,
885
+ bundlerVersion: bundle.bundlerVersion,
825
886
  },
826
- // No artifact ⇒ the member mounts in the kernel isolate from the
827
- // compiled-in Package, exactly as the UI install records it.
828
- ...(bundle
829
- ? {
830
- artifact: {
831
- contentHash: bundle.contentHash,
832
- size: bundle.size,
833
- mediaType: bundle.mediaType,
834
- bundlerVersion: bundle.bundlerVersion,
835
- },
836
- }
837
- : {}),
838
887
  };
839
888
  members.push(member);
840
889
  }
package/src/backend.ts CHANGED
@@ -1239,18 +1239,7 @@ export class ShellBotBackendContribution {
1239
1239
  return { schemaVersion: 1, skills: entries };
1240
1240
  }
1241
1241
 
1242
- /**
1243
- * The one durable manifest lookup used by mounts, commands, and UI views —
1244
- * as the **stored document**, byte-for-byte what `manifestHash` was taken
1245
- * over at authoring time.
1246
- *
1247
- * Decoding rebuilds the object (`decodeV5` always writes a `configuration`
1248
- * key, for one), so a decoded manifest does not canonicalize back to the
1249
- * recorded hash. Every mount re-verifies that hash
1250
- * (`botIsolatePackageDescriptorV1`), so the raw document is the only thing
1251
- * that can be handed to it; callers that want the typed shape decode it
1252
- * themselves through `readCompositionMemberManifest`.
1253
- */
1242
+ /** The two places this Bot's manifests live; see `composition-manifest.ts`. */
1254
1243
  private compositionManifestSources(): CompositionManifestSourcesV1 {
1255
1244
  return {
1256
1245
  stored: (manifestHash) =>
@@ -1261,6 +1250,16 @@ export class ShellBotBackendContribution {
1261
1250
  };
1262
1251
  }
1263
1252
 
1253
+ /**
1254
+ * The manifest a **mount** is handed: the stored document, byte-for-byte
1255
+ * what `manifestHash` was taken over at authoring time.
1256
+ *
1257
+ * Decoding rebuilds the object (`decodeV5` always writes a `configuration`
1258
+ * key, for one), so a decoded manifest does not canonicalize back to the
1259
+ * recorded hash. Every mount re-verifies that hash
1260
+ * (`botIsolatePackageDescriptorV1`), so the raw document is the only thing
1261
+ * that can be handed to it.
1262
+ */
1264
1263
  private readCompositionMemberManifestDocument(
1265
1264
  member: CompositionMemberV1,
1266
1265
  ): Promise<unknown | undefined> {
@@ -1270,6 +1269,10 @@ export class ShellBotBackendContribution {
1270
1269
  );
1271
1270
  }
1272
1271
 
1272
+ /**
1273
+ * The same manifest as the typed shape, for the callers that are not mounts:
1274
+ * commands and UI views, which read fields rather than re-hash the document.
1275
+ */
1273
1276
  private readCompositionMemberManifest(
1274
1277
  member: CompositionMemberV1,
1275
1278
  ): Promise<FrockBotManifest | undefined> {
@@ -5386,9 +5389,30 @@ export class ShellBotBackendContribution {
5386
5389
  index.map((entry) => entry.cursor),
5387
5390
  await this.sidebarPreview(storedPreview, index),
5388
5391
  failures,
5392
+ await this.isWorking(index[0]?.runId),
5389
5393
  );
5390
5394
  }
5391
5395
 
5396
+ /**
5397
+ * Whether the Bot's newest admitted run is still going.
5398
+ *
5399
+ * The sidebar draws this as an activity ring, so somebody in another
5400
+ * conversation can see a Bot working rather than reading a quiet row as a
5401
+ * stalled one. It is the newest run only: a Bot admits one Turn at a time,
5402
+ * so an older run that is somehow still marked running is a reconciliation
5403
+ * problem and not something a ring should report. A read that fails is no
5404
+ * ring — liveness is never worth failing a sidebar poll for.
5405
+ */
5406
+ private async isWorking(runId: string | undefined): Promise<boolean> {
5407
+ if (runId === undefined) return false;
5408
+ try {
5409
+ const run = await this.authority.readRun(runId);
5410
+ return run?.status === "running";
5411
+ } catch {
5412
+ return false;
5413
+ }
5414
+ }
5415
+
5392
5416
  /**
5393
5417
  * How many stored runs a read will open to recover a missing preview. The
5394
5418
  * newest settled chat Turn is almost always the first entry; the bound is
@@ -2,6 +2,7 @@
2
2
  import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
3
  import {
4
4
  announceUiAnchor,
5
+ UiActivityRing,
5
6
  UiIcon,
6
7
  UiIconButton,
7
8
  UiMarkdown,
@@ -26,6 +27,7 @@ import {
26
27
  type WebToolActivity,
27
28
  } from "../shared.js";
28
29
  import { ComposerDraftStore } from "./composer-draft.js";
30
+ import { activityRingV1 } from "./activity-ring.js";
29
31
  import {
30
32
  TURN_TEXT_MAX_CHARACTERS_V1,
31
33
  turnTextCounterVisibleV1,
@@ -431,66 +433,22 @@ function taskChipsOf(message: WebChatMessage): Array<{
431
433
  }
432
434
 
433
435
  /**
434
- * The tools this Turn ran, as the thread draws them.
436
+ * The activity ring for one assistant line.
435
437
  *
436
- * A tool whose Package draws its own surface is shown by that surface; every
437
- * other one is a chip, because a Turn that spends a minute making tool calls
438
- * used to show the User nothing at all but a spinning avatar.
438
+ * A Turn that spends a minute making tool calls used to show the User nothing
439
+ * but a breathing avatar, and then briefly a list of tool names, which put
440
+ * the model's plumbing into a conversation. The ring is neither: it pulses
441
+ * while the Turn runs and ticks forward for every step that settles, so the
442
+ * account of an ordinary tool call is a segment of a stroke and no words at
443
+ * all. The rule lives in `activity-ring.ts`; this only reads the message.
439
444
  */
440
- function toolChipsOf(message: WebChatMessage): WebToolActivity[] {
441
- return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
442
- }
443
-
444
- /**
445
- * What a chip calls a tool, in the User's words rather than the model's.
446
- *
447
- * A tool name is an identifier — `send_to_user`, `user-Github--acme/search_issues`
448
- * — and the transcript is a conversation, so the chip drops the namespace,
449
- * un-snakes the rest and capitalises it.
450
- */
451
- function toolChipLabel(tool: WebToolActivity): string {
452
- const bare = tool.name.split("/").pop() ?? tool.name;
453
- const words = bare.replace(/[_.-]+/g, " ").trim();
454
- if (words.length === 0) return tool.name;
455
- return words.charAt(0).toUpperCase() + words.slice(1);
456
- }
457
-
458
- /**
459
- * Whether a chip is drawn as a failure.
460
- *
461
- * A tool call the model recovered from is not a failure the User has anything
462
- * to do with: a refused call followed by a Turn that went on to finish is the
463
- * Bot correcting itself, and colouring it red reports a broken Turn that
464
- * worked. Only a Turn that itself ended badly keeps the failed state.
465
- */
466
- function toolChipState(
467
- tool: WebToolActivity,
445
+ function activityRingOf(
468
446
  message: WebChatMessage,
469
- ): "running" | "completed" | "failed" | "retried" {
470
- if (tool.status !== "failed") return tool.status;
471
- return message.status === "completed" || message.status === "streaming"
472
- ? "retried"
473
- : "failed";
474
- }
475
-
476
- /** What a chip says a tool is doing. Its status, in the User's words. */
477
- function toolChipStatus(
478
- tool: WebToolActivity,
479
- message: WebChatMessage,
480
- ): string {
481
- const state = toolChipState(tool, message);
482
- if (state === "running") return "running";
483
- if (state === "retried") return "retried";
484
- return state === "failed" ? "failed" : "done";
485
- }
486
-
487
- /** Which tool chips the User has opened. Local, and per chip. */
488
- const expandedTools = ref(new Set<string>());
489
-
490
- function toggleTool(toolId: string): void {
491
- const next = new Set(expandedTools.value);
492
- if (!next.delete(toolId)) next.add(toolId);
493
- expandedTools.value = next;
447
+ ): ReturnType<typeof activityRingV1> {
448
+ return activityRingV1({
449
+ toolStatuses: message.tools.map((tool) => tool.status),
450
+ status: message.status,
451
+ });
494
452
  }
495
453
 
496
454
  /** Which chips the User has opened. Local, and per chip. */
@@ -928,28 +886,45 @@ function handleComposerKeydown(event: KeyboardEvent): void {
928
886
  </p>
929
887
  <template v-else-if="message.role === 'assistant'">
930
888
  <!--
931
- The Bot's own avatar comes from whichever Package owns Bot
932
- identity. When no Package fills the slot the sparkle tile is
933
- the only child and shows through.
889
+ The Bot's own avatar, which appears only while it is working.
890
+ Every line in this transcript is from the same Bot — there are
891
+ no group conversations yet (issue 152) — so a sheep beside a
892
+ settled reply named nobody the reader did not already know. The
893
+ one beside a running Turn carries the ring, which is the whole
894
+ point of drawing it.
895
+
896
+ The art comes from whichever Package owns Bot identity; when no
897
+ Package fills the slot the sparkle tile is the only child and
898
+ shows through.
934
899
  -->
935
- <div
936
- class="bot-avatar"
937
- :class="{
938
- 'bot-avatar-live': message.status === 'streaming',
939
- 'bot-avatar-waiting':
940
- message.status === 'streaming' && !message.text,
941
- }"
942
- >
943
- <span class="bot-avatar-fallback" aria-hidden="true"
944
- ><UiIcon name="sparkle" size="sm"
945
- /></span>
946
- <k-slot name="frockbot.bot-avatar" />
947
- </div>
900
+ <Transition name="activity-ring">
901
+ <div
902
+ v-if="activityRingOf(message).active"
903
+ class="bot-avatar bot-avatar-live"
904
+ :class="{ 'bot-avatar-waiting': !message.text }"
905
+ >
906
+ <span class="bot-avatar-fallback" aria-hidden="true"
907
+ ><UiIcon name="sparkle" size="sm"
908
+ /></span>
909
+ <k-slot name="frockbot.bot-avatar" />
910
+ <!--
911
+ What the Bot is doing, while it is doing it — a stroke round
912
+ the sheep that pulses and ticks a segment for every step the
913
+ Turn settles. It completes and fades with the avatar when
914
+ the Turn settles, and it never names a tool: the transcript
915
+ stays a conversation.
916
+ -->
917
+ <UiActivityRing
918
+ :progress="activityRingOf(message).progress"
919
+ :running="activityRingOf(message).running"
920
+ :laps="activityRingOf(message).laps"
921
+ />
922
+ </div>
923
+ </Transition>
948
924
  <!--
949
- Everything the Turn produced stacks in one column beside the
950
- avatar. The row holds exactly two children avatar, column
951
- so a bubble, a notice and a chip are stacked lines rather than
952
- side-by-side columns squeezing the reply to a few pixels.
925
+ Everything the Turn produced stacks in one column. While the Bot
926
+ is working the avatar is beside it; once the Turn settles the
927
+ column is the whole row and starts at the transcript's edge.
953
928
  -->
954
929
  <div class="message-column">
955
930
  <div v-if="message.text" class="message-bubble">
@@ -1024,40 +999,6 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1024
999
  />
1025
1000
  </div>
1026
1001
  <!--
1027
- What the Bot did, while it is doing it. The chip is the
1028
- conversation's whole account of an ordinary tool call: its
1029
- name, whether it is running, and — when the User opens it —
1030
- what it returned.
1031
- -->
1032
- <div
1033
- v-if="toolChipsOf(message).length > 0"
1034
- class="message-tools"
1035
- >
1036
- <button
1037
- v-for="tool in toolChipsOf(message)"
1038
- :key="tool.id"
1039
- type="button"
1040
- class="tool-chip"
1041
- :class="`tool-chip-${toolChipState(tool, message)}`"
1042
- :aria-expanded="expandedTools.has(tool.id)"
1043
- @click="toggleTool(tool.id)"
1044
- >
1045
- <span class="tool-chip-name">{{
1046
- toolChipLabel(tool)
1047
- }}</span>
1048
- <span class="tool-chip-status">{{
1049
- toolChipStatus(tool, message)
1050
- }}</span>
1051
- <span
1052
- v-if="
1053
- expandedTools.has(tool.id) && tool.text !== undefined
1054
- "
1055
- class="tool-chip-result"
1056
- >{{ tool.text }}</span
1057
- >
1058
- </button>
1059
- </div>
1060
- <!--
1061
1002
  The subagents this Turn dispatched. The child's own Session is
1062
1003
  never in this transcript, so the chip is the whole of what the
1063
1004
  conversation says about it; opening one shows the summary the
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACTIVITY_RING_MAX_LAPS_V1,
4
+ ACTIVITY_RING_SEGMENTS_V1,
5
+ activityRingV1,
6
+ type ActivityStepStatusV1,
7
+ } from "./activity-ring.js";
8
+
9
+ /** `count` settled steps, plus whatever is still in flight. */
10
+ function steps(settled: number, inFlight = 0): ActivityStepStatusV1[] {
11
+ return [
12
+ ...Array.from<ActivityStepStatusV1>({ length: settled }).fill("completed"),
13
+ ...Array.from<ActivityStepStatusV1>({ length: inFlight }).fill("running"),
14
+ ];
15
+ }
16
+
17
+ describe("the avatar's activity ring", () => {
18
+ test("a Turn with nothing done yet still shows a ring", () => {
19
+ // Liveness before progress: the ring is the answer to "is it paused?", so
20
+ // it is drawn from the first moment of the Turn, empty and pulsing.
21
+ const ring = activityRingV1({ toolStatuses: [], status: "streaming" });
22
+ expect(ring.active).toBe(true);
23
+ expect(ring.running).toBe(true);
24
+ expect(ring.filled).toBe(0);
25
+ expect(ring.progress).toBe(0);
26
+ });
27
+
28
+ test("a step that is still running has not ticked the ring", () => {
29
+ const ring = activityRingV1({
30
+ toolStatuses: steps(2, 1),
31
+ status: "streaming",
32
+ });
33
+ expect(ring.steps).toBe(2);
34
+ expect(ring.filled).toBe(2);
35
+ });
36
+
37
+ test("a failed step ticks like any other — it is a step that happened", () => {
38
+ const ring = activityRingV1({
39
+ toolStatuses: ["completed", "failed"],
40
+ status: "streaming",
41
+ });
42
+ expect(ring.filled).toBe(2);
43
+ });
44
+
45
+ test("each settled step fills one more segment of the lap", () => {
46
+ for (let settled = 0; settled < ACTIVITY_RING_SEGMENTS_V1; settled += 1) {
47
+ const ring = activityRingV1({
48
+ toolStatuses: steps(settled),
49
+ status: "streaming",
50
+ });
51
+ expect(ring.filled).toBe(settled);
52
+ expect(ring.progress).toBeCloseTo(settled / ACTIVITY_RING_SEGMENTS_V1);
53
+ expect(ring.laps).toBe(0);
54
+ }
55
+ });
56
+
57
+ test("a full lap leaves a faint ring behind and starts again", () => {
58
+ const ring = activityRingV1({
59
+ toolStatuses: steps(ACTIVITY_RING_SEGMENTS_V1 + 1),
60
+ status: "streaming",
61
+ });
62
+ expect(ring.laps).toBe(1);
63
+ expect(ring.filled).toBe(1);
64
+ });
65
+
66
+ test("a Turn of forty tool calls never grows a fortieth ring", () => {
67
+ // The bound is the point: the ring reports that work is happening, not how
68
+ // much, so past the last lap it loops inside the strokes it already has.
69
+ const ring = activityRingV1({
70
+ toolStatuses: steps(40),
71
+ status: "streaming",
72
+ });
73
+ expect(ring.laps).toBe(ACTIVITY_RING_MAX_LAPS_V1);
74
+ expect(ring.filled).toBeLessThan(ACTIVITY_RING_SEGMENTS_V1);
75
+ });
76
+
77
+ test("a settled Turn keeps no ring, however it ended", () => {
78
+ for (const status of [
79
+ "completed",
80
+ "failed",
81
+ "interrupted",
82
+ "reconciliation-required",
83
+ ] as const) {
84
+ const ring = activityRingV1({ toolStatuses: steps(3), status });
85
+ expect(ring.active).toBe(false);
86
+ expect(ring.running).toBe(false);
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The activity ring: how much a running Turn has done, without saying so.
3
+ *
4
+ * A Turn can spend a minute making tool calls, and the thread used to answer
5
+ * that with either nothing or a list of tool names. Neither is right — the
6
+ * first reads as a paused app, the second puts the model's plumbing into a
7
+ * conversation. The ring is the third answer: a stroke around the Bot's avatar
8
+ * that pulses while the Turn runs and advances one segment for every step that
9
+ * settles, so a person sees liveness and rough progress and reads no words at
10
+ * all.
11
+ *
12
+ * This module is the whole rule, kept out of the component so it is testable
13
+ * without mounting Vue.
14
+ */
15
+
16
+ /** Segments in one lap of the ring. A step fills one. */
17
+ export const ACTIVITY_RING_SEGMENTS_V1 = 8;
18
+
19
+ /**
20
+ * How many laps the ring will draw at once. A Turn making forty tool calls
21
+ * must not grow a fortieth ring, so the lap beyond the first is the last one:
22
+ * past it the ring keeps looping inside those two strokes.
23
+ */
24
+ export const ACTIVITY_RING_MAX_LAPS_V1 = 1;
25
+
26
+ /** A step's state, as the Turn's tool activity reports it. */
27
+ export type ActivityStepStatusV1 = "running" | "completed" | "failed";
28
+
29
+ /**
30
+ * A Turn's state, as the thread's message carries it.
31
+ *
32
+ * Deliberately the whole string rather than the union: only `streaming` means
33
+ * the Turn is still going, and every other value — including one a newer Bot
34
+ * invents — is an ending. The ring is drawn off that one positive test, so it
35
+ * cannot be left spinning by a status this file has never heard of.
36
+ */
37
+ export type ActivityTurnStatusV1 = string;
38
+
39
+ export interface ActivityRingViewV1 {
40
+ /** Whether the ring is drawn at all. A settled Turn keeps none. */
41
+ active: boolean;
42
+ /** Whether the ring pulses. False once the Turn has settled. */
43
+ running: boolean;
44
+ /** Steps that have settled in this Turn. Unbounded, and never rendered. */
45
+ steps: number;
46
+ /** Filled segments of the current lap, `0 … ACTIVITY_RING_SEGMENTS_V1`. */
47
+ filled: number;
48
+ /** Faint laps completed behind the live stroke, capped. */
49
+ laps: number;
50
+ /** The fraction of the circumference the live stroke draws, `0 … 1`. */
51
+ progress: number;
52
+ }
53
+
54
+ /**
55
+ * The ring for one assistant line.
56
+ *
57
+ * A running Turn ticks: every settled tool call advances the stroke one
58
+ * segment, and the lap that fills leaves a faint ring behind it — up to
59
+ * {@link ACTIVITY_RING_MAX_LAPS_V1}, after which the ring simply loops. A Turn
60
+ * that has settled draws no ring; the reply is the answer by then, and the
61
+ * component fades the ring out on its way off screen.
62
+ */
63
+ export function activityRingV1(input: {
64
+ toolStatuses: readonly ActivityStepStatusV1[];
65
+ status: ActivityTurnStatusV1;
66
+ }): ActivityRingViewV1 {
67
+ const running = input.status === "streaming";
68
+ const steps = input.toolStatuses.filter(
69
+ (status) => status !== "running",
70
+ ).length;
71
+ if (!running) {
72
+ return {
73
+ active: false,
74
+ running: false,
75
+ steps,
76
+ filled: 0,
77
+ laps: 0,
78
+ progress: 0,
79
+ };
80
+ }
81
+ const filled = steps % ACTIVITY_RING_SEGMENTS_V1;
82
+ const laps = Math.min(
83
+ Math.floor(steps / ACTIVITY_RING_SEGMENTS_V1),
84
+ ACTIVITY_RING_MAX_LAPS_V1,
85
+ );
86
+ return {
87
+ active: true,
88
+ running: true,
89
+ steps,
90
+ filled,
91
+ laps,
92
+ progress: filled / ACTIVITY_RING_SEGMENTS_V1,
93
+ };
94
+ }
@@ -1,4 +1,10 @@
1
1
  .frockbot-root {
2
+ /*
3
+ * The clear margin a bubble leaves on the side it is not anchored to — about
4
+ * a centimetre and a half. It is what says which side of the conversation a
5
+ * line is on, so it is the same on a phone as on a desktop.
6
+ */
7
+ --frock-bubble-gutter: 56px;
2
8
  position: fixed;
3
9
  z-index: 1000;
4
10
  inset: 0;
@@ -324,10 +330,14 @@
324
330
  }
325
331
 
326
332
  /*
327
- * An assistant Turn is its avatar and, beside it, one column holding
328
- * everything the Turn produced. The row has exactly two children: bubbles,
329
- * notices, sends and chips stack inside the column, so a one-word reply is a
330
- * bubble the width of its word rather than a sliver of a shared row.
333
+ * An assistant Turn is one column holding everything it produced: bubbles,
334
+ * notices and sends stack inside it, so a one-word reply is a bubble the width
335
+ * of its word rather than a sliver of a shared row.
336
+ *
337
+ * The avatar is beside that column only while the Bot is working. Every reply
338
+ * in this transcript is from the same Bot — there are no group conversations —
339
+ * so a sheep on every settled line said nothing and cost the column its left
340
+ * edge. The one on the running Turn does say something, which is why it stays.
331
341
  */
332
342
  .message-assistant {
333
343
  flex-direction: row;
@@ -381,20 +391,22 @@
381
391
  animation: frock-breathe 2600ms ease-in-out infinite;
382
392
  }
383
393
 
384
- .bot-avatar-live::after {
385
- position: absolute;
386
- border-radius: 12px;
387
- animation: frock-halo 2600ms ease-in-out infinite;
388
- box-shadow: 0 0 0 2px var(--frock-surface-accent);
389
- content: "";
390
- inset: -4px;
391
- opacity: 0;
392
- pointer-events: none;
394
+ .bot-avatar-waiting {
395
+ animation-duration: 1600ms;
393
396
  }
394
397
 
395
- .bot-avatar-waiting,
396
- .bot-avatar-waiting::after {
397
- animation-duration: 1600ms;
398
+ /*
399
+ * The ring's way off screen. The Turn has settled, the reply is the answer
400
+ * now, and the stroke fades rather than vanishing between two frames.
401
+ */
402
+ .activity-ring-enter-active,
403
+ .activity-ring-leave-active {
404
+ transition: opacity 420ms ease-out;
405
+ }
406
+
407
+ .activity-ring-enter-from,
408
+ .activity-ring-leave-to {
409
+ opacity: 0;
398
410
  }
399
411
 
400
412
  @keyframes frock-breathe {
@@ -408,22 +420,16 @@
408
420
  }
409
421
  }
410
422
 
411
- @keyframes frock-halo {
412
- 0%,
413
- 100% {
414
- opacity: 0;
415
- transform: scale(0.94);
416
- }
417
-
418
- 50% {
419
- opacity: 1;
420
- transform: scale(1.06);
421
- }
422
- }
423
-
423
+ /*
424
+ * A bubble runs nearly the width of the transcript, leaving one clear margin
425
+ * on the side it is not anchored to: the Bot's at the end, the User's at the
426
+ * start. That margin is the only thing distinguishing the two columns now that
427
+ * neither carries an avatar, so it is a fixed gap rather than a percentage —
428
+ * it has to stay legible at every width.
429
+ */
424
430
  .message-bubble {
425
431
  width: max-content;
426
- max-width: min(640px, 84%);
432
+ max-width: calc(100% - var(--frock-bubble-gutter));
427
433
  padding: 10px 14px;
428
434
  border: 1px solid var(--frock-border);
429
435
  border-radius: 18px 18px 18px 6px;
@@ -438,7 +444,7 @@
438
444
  }
439
445
 
440
446
  .message-user .message-bubble {
441
- max-width: min(640px, 84%);
447
+ max-width: calc(100% - var(--frock-bubble-gutter));
442
448
  border-color: var(--frock-action-primary);
443
449
  border-radius: 18px 18px 6px 18px;
444
450
  color: var(--frock-on-accent);
@@ -453,59 +459,9 @@
453
459
 
454
460
  .message-sends {
455
461
  display: flex;
462
+ width: calc(100% - var(--frock-bubble-gutter));
456
463
  flex-direction: column;
457
464
  gap: 8px;
458
- width: min(640px, 84%);
459
- }
460
-
461
- /*
462
- * Tool calls. Quieter than anything the Bot said: the User is watching work
463
- * happen, not reading a message, so a chip carries the name and its state and
464
- * opens in place to what the tool returned.
465
- */
466
-
467
- .message-tools {
468
- display: flex;
469
- flex-direction: column;
470
- gap: 6px;
471
- width: min(640px, 84%);
472
- }
473
-
474
- .tool-chip {
475
- display: flex;
476
- flex-wrap: wrap;
477
- gap: 8px;
478
- align-items: baseline;
479
- padding: 6px 10px;
480
- font: inherit;
481
- color: var(--frock-text-muted);
482
- text-align: left;
483
- cursor: pointer;
484
- background: var(--frock-surface);
485
- border: 1px solid var(--frock-border);
486
- border-radius: 10px;
487
- }
488
-
489
- .tool-chip-name {
490
- color: var(--frock-text);
491
- font-weight: 600;
492
- }
493
-
494
- .tool-chip-status {
495
- flex: 1 1 auto;
496
- font-size: var(--frock-text-xs);
497
- }
498
-
499
- .tool-chip-failed .tool-chip-status {
500
- color: var(--frock-danger-text);
501
- }
502
-
503
- .tool-chip-result {
504
- flex: 1 0 100%;
505
- max-height: 12em;
506
- overflow: auto;
507
- white-space: pre-wrap;
508
- word-break: break-word;
509
465
  }
510
466
 
511
467
  /*
@@ -516,9 +472,9 @@
516
472
 
517
473
  .message-tasks {
518
474
  display: flex;
475
+ width: calc(100% - var(--frock-bubble-gutter));
519
476
  flex-direction: column;
520
477
  gap: 6px;
521
- width: min(640px, 84%);
522
478
  }
523
479
 
524
480
  .task-chip {
@@ -1169,16 +1125,6 @@
1169
1125
  padding: 20px;
1170
1126
  }
1171
1127
 
1172
- .message-bubble,
1173
- .message-user .message-bubble {
1174
- max-width: 92%;
1175
- }
1176
-
1177
- .message-sends,
1178
- .message-tasks {
1179
- width: 92%;
1180
- }
1181
-
1182
1128
  .message-attachments img {
1183
1129
  max-width: 100%;
1184
1130
  }
@@ -50,6 +50,16 @@ describe("notificationIdV1", () => {
50
50
  expect(notificationIdV1(...parts)).toBe(notificationIdV1(...parts));
51
51
  });
52
52
 
53
+ test("parts the sanitiser would flatten together stay distinct", () => {
54
+ // Well under the length ceiling, so nothing else separates them: `a:b` and
55
+ // `a-b` both sanitise to `a-b`, and one notification silently overwrote
56
+ // the other.
57
+ expect(notificationIdV1("package", "a:b")).not.toBe(
58
+ notificationIdV1("package", "a-b"),
59
+ );
60
+ expect(notificationIdV1("a", "b")).not.toBe(notificationIdV1("a-b"));
61
+ });
62
+
53
63
  test("distinct parts stay distinct, even past the length ceiling", () => {
54
64
  const long = "x".repeat(400);
55
65
  const a = notificationIdV1("package", long, "one");
@@ -60,6 +70,22 @@ describe("notificationIdV1", () => {
60
70
  expect(acknowledge(b)).toBe(b);
61
71
  });
62
72
 
73
+ test("a legacy colon id already in the field can still be acknowledged", () => {
74
+ // Bots that failed a generation before this fix are holding notifications
75
+ // whose ids were interpolated by hand. If those stay unacknowledgeable the
76
+ // client retries them forever, so the decoder admits the older shape even
77
+ // though nothing mints it any more.
78
+ const legacy =
79
+ "composition-failure:2026-09-03T23:49:00.416Z:dc03a32d9b717619:1";
80
+ expect(acknowledge(legacy)).toBe(legacy);
81
+ });
82
+
83
+ test("an id is still bounded, whatever shape it arrives in", () => {
84
+ expect(() => acknowledge("a".repeat(200))).toThrow();
85
+ expect(() => acknowledge("../../escape")).toThrow();
86
+ expect(() => acknowledge("")).toThrow();
87
+ });
88
+
63
89
  test("parts that sanitize to nothing still mint an acknowledgeable id", () => {
64
90
  const id = notificationIdV1(":::", "::");
65
91
  expect(acknowledge(id)).toBe(id);
Binary file
@@ -5,7 +5,10 @@ import {
5
5
  type SessionEvent,
6
6
  type SkillRefV1,
7
7
  } from "@frockbot/kernel-contracts";
8
- import { isPublicIdentifier } from "@frockbot/configuration-core";
8
+ import {
9
+ isPublicIdentifier,
10
+ isRpcIdentifier,
11
+ } from "@frockbot/configuration-core";
9
12
  import { decodeRunCursorV1, RUN_CURSOR_PATTERN } from "./run-cursor.js";
10
13
  export { decodeRunCursorV1, RUN_CURSOR_PATTERN };
11
14
  import type {
@@ -1648,7 +1651,15 @@ export function decodeClientNotificationAcknowledgementCommandV1(
1648
1651
  MAX_RUN_ID_LENGTH,
1649
1652
  "notification acknowledgement command",
1650
1653
  );
1651
- if (!isPublicIdentifier(notificationId)) {
1654
+ // `isRpcIdentifier`, not `isPublicIdentifier`: the same bounded alphabet
1655
+ // plus `:` and `@`. New ids are minted through `notificationIdV1` and carry
1656
+ // neither, but Bots in the field are already holding notifications whose ids
1657
+ // were interpolated by hand — `composition-failure:<generationId>:<attempt>`
1658
+ // and three more like it. Under the stricter pattern those could never be
1659
+ // acknowledged, so the client retried them forever and the Bot answered 400
1660
+ // on every poll for the rest of its life. Accepting them here is what lets
1661
+ // an already-wedged Bot recover without a storage sweep.
1662
+ if (!isRpcIdentifier(notificationId)) {
1652
1663
  throw new Error(
1653
1664
  "notification acknowledgement command.notificationId is invalid",
1654
1665
  );
@@ -81,6 +81,12 @@ export const SETTINGS_ANCHORS_V1: readonly SettingsAnchorV1[] = [
81
81
  label: "Label",
82
82
  scope: "bot",
83
83
  },
84
+ {
85
+ anchor: "bot-pinned",
86
+ surface: "bot-settings",
87
+ label: "Pinned",
88
+ scope: "bot",
89
+ },
84
90
  {
85
91
  anchor: "bot-description",
86
92
  surface: "bot-settings",
package/src/unread.ts CHANGED
@@ -389,6 +389,15 @@ export interface BotUnreadViewV1 {
389
389
  lastViewedAt?: string;
390
390
  /** Latest settled assistant/user line, projected for the sidebar only. */
391
391
  lastMessage?: SidebarMessagePreviewV1;
392
+ /**
393
+ * Whether this Bot has a Turn running right now.
394
+ *
395
+ * The sidebar draws it as an activity ring on the row's avatar, so somebody
396
+ * reading one conversation can see another Bot still working rather than
397
+ * assuming it stalled. Optional: a view a client older than the Bot decodes,
398
+ * or one stored before this existed, simply draws no ring.
399
+ */
400
+ working?: boolean;
392
401
  }
393
402
 
394
403
  export interface BotUnreadDirectoryViewV1 {
@@ -414,6 +423,8 @@ export function projectBotUnreadViewV1(
414
423
  * so it counts here even though the firing that produced it does not.
415
424
  */
416
425
  automationFailures = 0,
426
+ /** True while a Turn of this Bot's is running. Drawn as the row's ring. */
427
+ working = false,
417
428
  ): BotUnreadViewV1 {
418
429
  const ceiling = state.lastActivityCursor;
419
430
  let counted = Math.max(0, automationFailures);
@@ -449,6 +460,7 @@ export function projectBotUnreadViewV1(
449
460
  ? {}
450
461
  : { lastViewedAt: state.lastViewedAt }),
451
462
  ...(lastMessage === undefined ? {} : { lastMessage }),
463
+ ...(working ? { working: true } : {}),
452
464
  };
453
465
  }
454
466
 
@@ -528,7 +540,13 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
528
540
  exactKeys(
529
541
  value,
530
542
  ["schemaVersion", "botId", "count", "capped", "unread", "manuallyUnread"],
531
- ["lastActivityCursor", "lastActivityAt", "lastViewedAt", "lastMessage"],
543
+ [
544
+ "lastActivityCursor",
545
+ "lastActivityAt",
546
+ "lastViewedAt",
547
+ "lastMessage",
548
+ "working",
549
+ ],
532
550
  "unread view",
533
551
  );
534
552
  if (value.schemaVersion !== 1) {
@@ -563,6 +581,9 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
563
581
  );
564
582
  const lastViewedAt = optionalTimestamp(value, "lastViewedAt", "unread view");
565
583
  const lastMessage = optionalSidebarMessagePreviewV1(value.lastMessage);
584
+ if (value.working !== undefined && typeof value.working !== "boolean") {
585
+ throw new UnreadDecodeError("unread view working is invalid");
586
+ }
566
587
  return {
567
588
  schemaVersion: 1,
568
589
  botId: value.botId,
@@ -574,6 +595,7 @@ function decodeBotUnreadViewV1(input: unknown): BotUnreadViewV1 {
574
595
  ...(lastActivityAt === undefined ? {} : { lastActivityAt }),
575
596
  ...(lastViewedAt === undefined ? {} : { lastViewedAt }),
576
597
  ...(lastMessage === undefined ? {} : { lastMessage }),
598
+ ...(value.working === true ? { working: true } : {}),
577
599
  };
578
600
  }
579
601