@frockbot/plugin-shell 0.3.5 → 0.3.7

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/frockbot.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 4,
3
3
  "id": "shell",
4
- "displayName": "FrockBot Shell",
4
+ "displayName": "FrockBot",
5
5
  "version": "0.0.1",
6
6
  "compatibility": {
7
7
  "frockbot": ">=0.0.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.5",
51
- "@frockbot/application-foundation": "0.3.5",
52
- "@frockbot/catalog-core": "0.3.5",
53
- "@frockbot/client-core": "0.3.5",
54
- "@frockbot/client-ui": "0.3.5",
55
- "@frockbot/computer-core": "0.3.5",
56
- "@frockbot/computer-host-protocol": "0.3.5",
57
- "@frockbot/configuration-core": "0.3.5",
58
- "@frockbot/connection-core": "0.3.5",
59
- "@frockbot/kernel-agent-loop": "0.3.5",
60
- "@frockbot/kernel-composition": "0.3.5",
61
- "@frockbot/kernel-contracts": "0.3.5",
62
- "@frockbot/kernel-do": "0.3.5",
63
- "@frockbot/machine-protocol": "0.3.5",
64
- "@frockbot/plugin-applets": "0.3.5",
65
- "@frockbot/plugin-authoring": "0.3.5",
66
- "@frockbot/plugin-bot-template": "0.3.5",
67
- "@frockbot/plugin-computer": "0.3.5",
68
- "@frockbot/plugin-flock": "0.3.5",
69
- "@frockbot/plugin-image": "0.3.5",
70
- "@frockbot/plugin-machine-messages": "0.3.5",
71
- "@frockbot/plugin-mcp": "0.3.5",
72
- "@frockbot/plugin-memory": "0.3.5",
73
- "@frockbot/plugin-package-catalog": "0.3.5",
74
- "@frockbot/plugin-routines": "0.3.5",
75
- "@frockbot/plugin-skills": "0.3.5",
76
- "@frockbot/plugin-subagents": "0.3.5",
77
- "@frockbot/plugin-user-machine": "0.3.5",
78
- "@frockbot/protocol": "0.3.5",
50
+ "@frockbot/agent-runtime": "0.3.7",
51
+ "@frockbot/application-foundation": "0.3.7",
52
+ "@frockbot/catalog-core": "0.3.7",
53
+ "@frockbot/client-core": "0.3.7",
54
+ "@frockbot/client-ui": "0.3.7",
55
+ "@frockbot/computer-core": "0.3.7",
56
+ "@frockbot/computer-host-protocol": "0.3.7",
57
+ "@frockbot/configuration-core": "0.3.7",
58
+ "@frockbot/connection-core": "0.3.7",
59
+ "@frockbot/kernel-agent-loop": "0.3.7",
60
+ "@frockbot/kernel-composition": "0.3.7",
61
+ "@frockbot/kernel-contracts": "0.3.7",
62
+ "@frockbot/kernel-do": "0.3.7",
63
+ "@frockbot/machine-protocol": "0.3.7",
64
+ "@frockbot/plugin-applets": "0.3.7",
65
+ "@frockbot/plugin-authoring": "0.3.7",
66
+ "@frockbot/plugin-bot-template": "0.3.7",
67
+ "@frockbot/plugin-computer": "0.3.7",
68
+ "@frockbot/plugin-flock": "0.3.7",
69
+ "@frockbot/plugin-image": "0.3.7",
70
+ "@frockbot/plugin-machine-messages": "0.3.7",
71
+ "@frockbot/plugin-mcp": "0.3.7",
72
+ "@frockbot/plugin-memory": "0.3.7",
73
+ "@frockbot/plugin-package-catalog": "0.3.7",
74
+ "@frockbot/plugin-routines": "0.3.7",
75
+ "@frockbot/plugin-skills": "0.3.7",
76
+ "@frockbot/plugin-subagents": "0.3.7",
77
+ "@frockbot/plugin-user-machine": "0.3.7",
78
+ "@frockbot/protocol": "0.3.7",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.5",
84
+ "@frockbot/plugin-tools": "0.3.7",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -18,6 +18,8 @@ import {
18
18
  authorshipUndoIntentKey,
19
19
  authorshipUndoOutcomeKey,
20
20
  AUTHORSHIP_FAILURE_PREFIX,
21
+ classifyAuthoringEffectV1,
22
+ type AuthoringEffectOutcomeV1,
21
23
  type AuthoredArtifactRecordV1,
22
24
  type AuthoredManifestRecordV1,
23
25
  type AuthoringFailureRecordV1,
@@ -234,9 +236,13 @@ describe("a Bot authoring a Package", () => {
234
236
  quota?: (request: unknown) => unknown;
235
237
  runId?: string;
236
238
  currentToolNames?: readonly string[];
239
+ bundleTimeoutMs?: number;
237
240
  }) {
238
241
  let ids = 0;
239
242
  return createPackageAuthoringHost({
243
+ ...(options.bundleTimeoutMs === undefined
244
+ ? {}
245
+ : { bundleTimeoutMs: options.bundleTimeoutMs }),
240
246
  storage,
241
247
  composition,
242
248
  bundler: options.bundler as never,
@@ -911,4 +917,70 @@ describe("a Bot authoring a Package", () => {
911
917
  }),
912
918
  );
913
919
  });
920
+
921
+ test("a bundler outage leaves the effect retryable, not poisoned", async () => {
922
+ // F11: the "could not be reached" refusal returned without writing any
923
+ // outcome, so `classifyAuthoringEffectV1` called that effectId `unknown`
924
+ // forever — "it will not be bundled again". Since the effectId is
925
+ // run + Package + source hash, retrying the identical source in the same
926
+ // run could never succeed.
927
+ const effectId = "author-0123456789abcdef";
928
+ const offline = {
929
+ calls: 0,
930
+ bundle() {
931
+ offline.calls += 1;
932
+ return Promise.reject(
933
+ new Error('Worker "frockbot-cloudflare-bundler" not found'),
934
+ );
935
+ },
936
+ };
937
+ const refusal = await host({ bundler: offline as never }).author(
938
+ requestFor(),
939
+ );
940
+ expect(refusal.status).toBe("refused");
941
+
942
+ const outcome = storage.values.get(
943
+ authorshipArtifactKey(effectId),
944
+ ) as AuthoringEffectOutcomeV1;
945
+ expect(outcome).toMatchObject({ status: "unreachable", effectId });
946
+ expect(
947
+ classifyAuthoringEffectV1({
948
+ intent: storage.values.get(authorshipIntentKey(effectId)) as never,
949
+ outcome,
950
+ }),
951
+ ).toEqual({ kind: "fresh" });
952
+
953
+ // And the identical source, in the same run, now goes through.
954
+ const recovered = await host({
955
+ bundler: countingBundler((id) => bundledResult(id)),
956
+ }).author(requestFor());
957
+ expect(recovered.status).toBe("authored");
958
+ });
959
+
960
+ test("a bundler that never answers is refused at the deadline", async () => {
961
+ // F10: `bundle()` was awaited with no deadline and no AbortSignal, and the
962
+ // bundler runs esbuild-wasm inline — a hang there hung this tool call and
963
+ // the Turn around it, indefinitely.
964
+ const hanging = {
965
+ calls: 0,
966
+ bundle() {
967
+ hanging.calls += 1;
968
+ return new Promise<never>(() => {});
969
+ },
970
+ };
971
+ const outcome = await host({
972
+ bundler: hanging as never,
973
+ bundleTimeoutMs: 10,
974
+ }).author(requestFor());
975
+
976
+ expect(outcome).toMatchObject({ status: "refused" });
977
+ if (outcome.status !== "refused") return;
978
+ expect(outcome.reason).toContain(
979
+ "the Package bundler could not be reached: the Package bundler did not answer within 10ms",
980
+ );
981
+ // Nothing ran, so the effect stays retryable rather than poisoned (F11).
982
+ expect(
983
+ storage.values.get(authorshipArtifactKey("author-0123456789abcdef")),
984
+ ).toMatchObject({ status: "unreachable" });
985
+ });
914
986
  });
@@ -143,12 +143,45 @@ export interface PackageAuthoringHostOptions {
143
143
  };
144
144
  now?(): Date;
145
145
  newId?(): string;
146
+ /** Overrides `PACKAGE_BUNDLE_TIMEOUT_MS_V1`; tests drive the deadline. */
147
+ bundleTimeoutMs?: number;
146
148
  }
147
149
 
148
150
  function errorMessage(error: unknown): string {
149
151
  return error instanceof Error ? error.message : String(error);
150
152
  }
151
153
 
154
+ /**
155
+ * How long the Package bundler has to answer. It runs esbuild-wasm inline, so
156
+ * a hang there is a hang of this tool call and of the Turn around it; the
157
+ * service binding itself imposes no deadline (finding F10). Generous, because
158
+ * a cold `createWorker` is slow, and far short of any Turn-level bound.
159
+ */
160
+ const PACKAGE_BUNDLE_TIMEOUT_MS_V1 = 60_000;
161
+
162
+ /**
163
+ * `work`, or a rejection at the deadline. The pending work is abandoned, not
164
+ * cancelled: the bundler is stateless and side-effect free (its caller owns
165
+ * every durable write), so a late answer changes nothing.
166
+ */
167
+ async function withDeadline<T>(
168
+ work: Promise<T>,
169
+ milliseconds: number,
170
+ message: string,
171
+ ): Promise<T> {
172
+ let timer: ReturnType<typeof setTimeout> | undefined;
173
+ try {
174
+ return await Promise.race([
175
+ work,
176
+ new Promise<never>((_resolve, reject) => {
177
+ timer = setTimeout(() => reject(new Error(message)), milliseconds);
178
+ }),
179
+ ]);
180
+ } finally {
181
+ if (timer !== undefined) clearTimeout(timer);
182
+ }
183
+ }
184
+
152
185
  /**
153
186
  * Builds the authoring seam one admitted Turn runs under. Constructed per
154
187
  * Turn because provenance names the run and turn that produced the artifact.
@@ -691,18 +724,40 @@ export function createPackageAuthoringHost(
691
724
  let bundled;
692
725
  try {
693
726
  // Never inside a storage transaction: bundler CPU is the bundler's.
727
+ // Bounded, because the bundler runs esbuild-wasm inline: one that
728
+ // hangs rather than rejects would hang this tool call and the Turn
729
+ // with it (F10). An expiry takes the same path as unreachability —
730
+ // nothing ran either way.
731
+ const deadline =
732
+ options.bundleTimeoutMs ?? PACKAGE_BUNDLE_TIMEOUT_MS_V1;
694
733
  bundled = decodePackageBundleResultV1(
695
- await options.bundler.bundle(bundleRequest),
734
+ await withDeadline(
735
+ options.bundler.bundle(bundleRequest),
736
+ deadline,
737
+ `the Package bundler did not answer within ${deadline}ms`,
738
+ ),
696
739
  );
697
740
  } catch (error) {
698
- return refused(
699
- await recordFailure({
741
+ const failure = await recordFailure({
742
+ effectId,
743
+ packageId,
744
+ phase: "bundle",
745
+ reason: `the Package bundler could not be reached: ${errorMessage(error)}`,
746
+ });
747
+ // The bundler never answered, so nothing about this effect is
748
+ // uncertain. Recording that explicitly is what lets an identical
749
+ // retry in the same run proceed instead of classifying `unknown`
750
+ // forever (F11).
751
+ await options.storage.put({
752
+ [authorshipArtifactKey(effectId)]: {
753
+ schemaVersion: 1,
754
+ status: "unreachable",
700
755
  effectId,
701
- packageId,
702
- phase: "bundle",
703
- reason: `the Package bundler could not be reached: ${errorMessage(error)}`,
704
- }),
705
- );
756
+ failureId: failure.failureId,
757
+ reason: failure.reason,
758
+ } satisfies AuthoringEffectOutcomeV1,
759
+ });
760
+ return refused(failure);
706
761
  }
707
762
  if (bundled.status === "failed") {
708
763
  const failure = await recordFailure({
@@ -744,15 +799,26 @@ export function createPackageAuthoringHost(
744
799
  );
745
800
  }));
746
801
  if (uiMismatch || (uiPages === undefined && returnedPages.length > 0)) {
747
- return refused(
748
- await recordFailure({
802
+ const failure = await recordFailure({
803
+ effectId,
804
+ packageId,
805
+ phase: "bundle",
806
+ reason:
807
+ "the Package bundler returned UI bytes that do not match the recorded manifest",
808
+ });
809
+ // The bundler did answer, and deterministically: the same source
810
+ // would mismatch again. That is a settled failure, not an unknown —
811
+ // it too used to leave the effect with no outcome at all (F11).
812
+ await options.storage.put({
813
+ [authorshipArtifactKey(effectId)]: {
814
+ schemaVersion: 1,
815
+ status: "failed",
749
816
  effectId,
750
- packageId,
751
- phase: "bundle",
752
- reason:
753
- "the Package bundler returned UI bytes that do not match the recorded manifest",
754
- }),
755
- );
817
+ failureId: failure.failureId,
818
+ reason: failure.reason,
819
+ } satisfies AuthoringEffectOutcomeV1,
820
+ });
821
+ return refused(failure);
756
822
  }
757
823
  // Content-addressed and immutable: writing the same hash twice is a
758
824
  // no-op, so the object write is safe to repeat and the record is not.
@@ -702,10 +702,12 @@ describe("generic per-Turn model resolution", () => {
702
702
  acceptedAt: "2026-09-02T00:01:00.000Z",
703
703
  text: "must fail",
704
704
  }),
705
- ).rejects.toThrow("not installed and enabled");
705
+ ).rejects.toThrow("Turn this model's plugin back on in Plugins");
706
706
  expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
707
707
  status: "failed",
708
- failure: expect.stringContaining("not installed and enabled"),
708
+ failure: expect.stringContaining(
709
+ "Turn this model's plugin back on in Plugins",
710
+ ),
709
711
  });
710
712
  }
711
713
 
@@ -722,10 +724,10 @@ describe("generic per-Turn model resolution", () => {
722
724
  acceptedAt: "2026-09-02T00:02:00.000Z",
723
725
  text: "must fail",
724
726
  }),
725
- ).rejects.toThrow("is revoked");
727
+ ).rejects.toThrow("needs reconnecting");
726
728
  expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
727
729
  status: "failed",
728
- failure: expect.stringContaining("is revoked"),
730
+ failure: expect.stringContaining("needs reconnecting"),
729
731
  });
730
732
  }
731
733
  });
@@ -147,7 +147,12 @@ async function bootstrap(): Promise<CompositionGenerationV1> {
147
147
  }
148
148
 
149
149
  async function fixture(
150
- options: { connected?: boolean; uiAvailable?: boolean } = {},
150
+ options: {
151
+ connected?: boolean;
152
+ uiAvailable?: boolean;
153
+ /** A deployment with no published Catalog pins no generation. */
154
+ pinned?: boolean;
155
+ } = {},
151
156
  ) {
152
157
  const log: string[] = [];
153
158
  const records = new Map<string, unknown>();
@@ -220,8 +225,9 @@ async function fixture(
220
225
  },
221
226
  ]
222
227
  : [],
223
- catalogGeneration: "catalog-1",
224
- catalogIndexHash: "d".repeat(64),
228
+ ...(options.pinned === false
229
+ ? {}
230
+ : { catalogGeneration: "catalog-1", catalogIndexHash: "d".repeat(64) }),
225
231
  };
226
232
  const commands: UserConfigurationCommandV1[] = [];
227
233
  const userAuthority = {
@@ -306,6 +312,20 @@ describe("Bot Package Catalog host", () => {
306
312
  });
307
313
  });
308
314
 
315
+ test("explains an unpinned Catalog in words a person can act on", async () => {
316
+ const { host } = await fixture({ pinned: false });
317
+ // F4: an unpinned User used to be told "this User has no pinned Package
318
+ // Catalog generation", which the Bot then relayed verbatim.
319
+ for (const call of [
320
+ () => host.search({ query: "shipping" }),
321
+ () => host.inspect({ catalogId: "parcel-tracking" }),
322
+ ]) {
323
+ await expect(call()).rejects.toThrow(
324
+ "No Package Catalog is published for this deployment, so there is nothing to search or install",
325
+ );
326
+ }
327
+ });
328
+
309
329
  test("persists the iframe manifest before the User effect, appends a summary generation, and replays", async () => {
310
330
  const test = await fixture();
311
331
  const request = {
@@ -145,7 +145,13 @@ function requiredCatalogPin(user: UserSettingsViewV1): {
145
145
  indexHash: string;
146
146
  } {
147
147
  if (!user.catalogGeneration || !user.catalogIndexHash) {
148
- throw new Error("this User has no pinned Package Catalog generation");
148
+ // A User is pinned on the first configuration read that finds a published
149
+ // Catalog, so an absent pin means the deployment has none to find. Say
150
+ // that, rather than an internal sentence about pinning the Bot then
151
+ // relays to the person who asked.
152
+ throw new Error(
153
+ "No Package Catalog is published for this deployment, so there is nothing to search or install",
154
+ );
149
155
  }
150
156
  return {
151
157
  generation: user.catalogGeneration,
@@ -354,26 +354,20 @@ describe("Bot recovery", () => {
354
354
  text: "uncertain",
355
355
  }),
356
356
  ).rejects.toThrow("response lost");
357
- expect(
358
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
359
- ).toMatchObject({ status: "reconciliation-required" });
357
+ // Ollama keeps no addressable copy of a completion, so a failure raised
358
+ // before the first stream event is definitive rather than uncertain: the
359
+ // run settles as a failed Turn instead of parking on a retrieval this
360
+ // provider can never perform.
361
+ const uncertain = await storage.get<StoredRun>("run:ollama-run-uncertain");
362
+ expect(uncertain?.status).toBe("failed");
363
+ expect(uncertain?.failure).toContain("response lost");
364
+ expect(await storage.get("active-run")).toBeUndefined();
360
365
 
366
+ // The alarm has nothing left to recover: a settled run stays settled.
361
367
  await host().alarm();
362
- expect(
363
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
364
- ).toMatchObject({ status: "reconciliation-required" });
365
- await expect(
366
- host().reconcileRun(
367
- { userId: "user-1", botId: "primary" },
368
- "ollama-run-uncertain",
369
- ),
370
- ).rejects.toThrow();
371
- expect(
372
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
373
- ).toMatchObject({
374
- status: "failed",
375
- failure: expect.stringContaining("explicitly abandoned"),
376
- });
368
+ const settled = await storage.get<StoredRun>("run:ollama-run-uncertain");
369
+ expect(settled?.status).toBe("failed");
370
+ expect(settled?.failure).toContain("response lost");
377
371
  expect(await storage.get("active-run")).toBeUndefined();
378
372
  });
379
373
 
@@ -626,7 +620,11 @@ describe("Bot recovery", () => {
626
620
  expect(await recoveredAgain.listNotifications()).toEqual(notifications);
627
621
  });
628
622
 
629
- test("preserves an unresolved request for an explicit decision", async () => {
623
+ // ADR 0028. This used to park the run and hand the person a Resolve button.
624
+ // `provider-1` has no retrieval — none of the providers this deployment
625
+ // ships does — so there was never an answer to resolve it with, and the Bot
626
+ // stayed wedged behind it.
627
+ test("settles an unresolved request no provider can be asked about", async () => {
630
628
  const storage = new MemoryStorage();
631
629
  const settings = initializeBotSettingsV1("primary");
632
630
  const events = [
@@ -690,19 +688,14 @@ describe("Bot recovery", () => {
690
688
  env: {} as never,
691
689
  });
692
690
 
693
- await expect(recovered.listRuns()).resolves.toEqual({
694
- schemaVersion: 1,
695
- runs: [
696
- expect.objectContaining({
697
- schemaVersion: 2,
698
- runId: "run-lost-marker",
699
- status: "reconciliation-required",
700
- recovery: expect.objectContaining({ action: "resume" }),
701
- }),
702
- ],
703
- page: { truncated: false },
704
- });
705
- expect(storage.values.get("active-run")).toBe("run-lost-marker");
691
+ await recovered.listRuns();
692
+
693
+ const settled = storage.values.get("run:run-lost-marker") as StoredRun;
694
+ expect(settled.status).toBe("failed");
695
+ expect(settled.failure).toContain("stopped partway");
696
+ // The Bot is released: nothing is holding the next Turn behind a decision
697
+ // nobody can make.
698
+ expect(storage.values.get("active-run")).toBeUndefined();
706
699
  expect(storage.alarmAt).toBeUndefined();
707
700
  });
708
701
 
package/src/backend.ts CHANGED
@@ -403,6 +403,7 @@ import {
403
403
  projectClientAnnouncementsV1,
404
404
  projectClientTurnV1,
405
405
  type ClientRunLookupV1,
406
+ type ClientConversationListV1,
406
407
  type ClientRunListV1,
407
408
  type ClientRunStopReceiptV1,
408
409
  type ClientRunV1,
@@ -446,6 +447,25 @@ import {
446
447
  } from "./unread.js";
447
448
  import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
448
449
 
450
+ /**
451
+ * The providers that can be asked what happened to a model request they never
452
+ * answered — that is, the ones whose Package registers an
453
+ * `LlmReconciliationCapability` (ADR 0028).
454
+ *
455
+ * It is a list rather than a lookup because recovery consults it inside the
456
+ * durable transaction that settles the run, where nothing may be mounted or
457
+ * awaited. The cost is that a provider Package which gains retrieval has to
458
+ * name itself here; the ADR carries that obligation, and the failure mode of
459
+ * forgetting is a Turn settled as failed rather than one wedged forever, which
460
+ * is the direction this deployment wants to be wrong in.
461
+ *
462
+ * Today: the in-process foundation provider, and nothing else. Ollama Cloud
463
+ * exposes no provider-bound retrieval (ADR 0010) and neither does Flock AI.
464
+ */
465
+ const RECONCILING_PROVIDER_IDS_V1: ReadonlySet<string> = new Set([
466
+ "foundation",
467
+ ]);
468
+
449
469
  export const BOT_CONFIGURATION_KEY = "bot-configuration";
450
470
  const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
451
471
  const STOP_RECEIPT_PREFIX = "stop-receipt:";
@@ -784,6 +804,8 @@ export class ShellBotBackendContribution {
784
804
  deferScheduledWork: (transaction) =>
785
805
  this.deferScheduledWork(transaction),
786
806
  settleScheduledWork: () => this.settleScheduledWork(),
807
+ providerReconciles: (providerId) =>
808
+ RECONCILING_PROVIDER_IDS_V1.has(providerId),
787
809
  },
788
810
  });
789
811
  }
@@ -4572,13 +4594,13 @@ export class ShellBotBackendContribution {
4572
4594
  if (!effectiveModel) {
4573
4595
  throw new Error(
4574
4596
  effective.binding?.failure ??
4575
- "No model is configured; enable a model Package or restore the platform model",
4597
+ "No model is set up yet. Choose one in Models.",
4576
4598
  );
4577
4599
  }
4578
4600
  const binding: ResolvedModelBindingV1 = effective.binding ?? {
4579
4601
  model: structuredClone(effectiveModel),
4580
4602
  state: "unavailable",
4581
- failure: "The resolved model Connection is unavailable",
4603
+ failure: "This Bot's model isn't available. Pick one in Models.",
4582
4604
  };
4583
4605
  if (
4584
4606
  binding.state === "unavailable" ||
@@ -4587,7 +4609,8 @@ export class ShellBotBackendContribution {
4587
4609
  !binding.packageId
4588
4610
  ) {
4589
4611
  throw new Error(
4590
- binding.failure ?? "The resolved model Connection is unavailable",
4612
+ binding.failure ??
4613
+ "This Bot's model isn't available. Pick one in Models.",
4591
4614
  );
4592
4615
  }
4593
4616
  if (
@@ -4600,7 +4623,9 @@ export class ShellBotBackendContribution {
4600
4623
  admittedRequest.modelBinding.connectionGeneration !==
4601
4624
  binding.connection.generation)
4602
4625
  ) {
4603
- throw new Error("Admitted model binding is unavailable");
4626
+ throw new Error(
4627
+ "This Bot's model changed mid-reply. Send your message again.",
4628
+ );
4604
4629
  }
4605
4630
  const bindingPackageId = binding.packageId;
4606
4631
  agentPackages.push(
@@ -5419,6 +5444,15 @@ export class ShellBotBackendContribution {
5419
5444
  ): Promise<ClientRunListV1> {
5420
5445
  const query = decodeClientRunListQueryV1(input);
5421
5446
  await this.authority.recoverActiveRun();
5447
+ // The transcript is one conversation, not every Turn the Bot has ever
5448
+ // run. Absent means the conversation the Bot is on; naming an earlier one
5449
+ // reads it exactly as it was left. A Bot whose object has not learned its
5450
+ // identity yet has no conversation to filter by and shows what it has.
5451
+ const conversationId =
5452
+ query.conversationId ??
5453
+ (await this.authority.readConversationSessionId());
5454
+ const inConversation = (run: { sessionId: string }) =>
5455
+ conversationId === undefined || run.sessionId === conversationId;
5422
5456
  const activeRunId = query.before
5423
5457
  ? undefined
5424
5458
  : await this.authority.readActiveRunId();
@@ -5433,7 +5467,7 @@ export class ShellBotBackendContribution {
5433
5467
  // An automation firing occupies the object like any other run, and is
5434
5468
  // still not part of the conversation: the visible transcript never
5435
5469
  // shows one, running or settled.
5436
- if (active && isVisibleRunV1(active))
5470
+ if (active && isVisibleRunV1(active) && inConversation(active))
5437
5471
  selected.set(active.runId, {
5438
5472
  run: projectClientRunOrDegradedV1(active),
5439
5473
  });
@@ -5447,7 +5481,8 @@ export class ShellBotBackendContribution {
5447
5481
  continue;
5448
5482
  }
5449
5483
  const stored = await this.authority.readStoredRun(candidate.runId);
5450
- if (!stored || !isVisibleRunV1(stored)) continue;
5484
+ if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
5485
+ continue;
5451
5486
  const projected = projectClientRunOrDegradedV1(stored);
5452
5487
  const tentative = [
5453
5488
  ...selected.values(),
@@ -5503,6 +5538,36 @@ export class ShellBotBackendContribution {
5503
5538
  }
5504
5539
  return page;
5505
5540
  }
5541
+ /** The conversations this Bot has had, newest first. */
5542
+ async listConversations(): Promise<ClientConversationListV1> {
5543
+ return {
5544
+ schemaVersion: 1,
5545
+ conversations: (await this.authority.listConversations()).map(
5546
+ (conversation) => ({
5547
+ schemaVersion: 1 as const,
5548
+ conversationId: conversation.sessionId,
5549
+ ordinal: conversation.ordinal,
5550
+ startedAt: conversation.startedAt,
5551
+ ...(conversation.endedAt ? { endedAt: conversation.endedAt } : {}),
5552
+ }),
5553
+ ),
5554
+ };
5555
+ }
5556
+
5557
+ /**
5558
+ * Puts this conversation down and starts the next one.
5559
+ *
5560
+ * Memory is untouched: it is not conversation history, and the point of a
5561
+ * new conversation is to prove that it is not.
5562
+ */
5563
+ async startConversation(
5564
+ identity: BotIdentity,
5565
+ ): Promise<ClientConversationListV1> {
5566
+ await this.validateIdentity(identity);
5567
+ await this.authority.startConversation(identity);
5568
+ return this.listConversations();
5569
+ }
5570
+
5506
5571
  async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
5507
5572
  const query = decodeClientRunLookupQueryV1(input);
5508
5573
  return projectClientRunLookupV1(await this.authority.readRun(query.runId));
@@ -147,7 +147,7 @@ function generationLabel(generationId: string): string {
147
147
  hour: "numeric",
148
148
  minute: "2-digit",
149
149
  });
150
- return `Live since ${when} · ${hash.slice(0, 7)}`;
150
+ return `Live since ${when}`;
151
151
  }
152
152
 
153
153
  function selectTab(next: "app" | "code"): void {
@@ -199,10 +199,8 @@ async function clearFocus(): Promise<void> {
199
199
  /></span>
200
200
  <div class="applet-canvas-title">
201
201
  <strong>{{ applet?.displayName ?? "Applet" }}</strong>
202
- <small v-if="viewer" :title="viewer.generationId">{{
203
- generationLabel(viewer.generationId)
204
- }}</small>
205
- <small v-else>No published version yet</small>
202
+ <small v-if="viewer">{{ generationLabel(viewer.generationId) }}</small>
203
+ <small v-else>Not published yet</small>
206
204
  </div>
207
205
  <div
208
206
  v-if="canShowApp"