@frockbot/plugin-shell 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -31
- package/src/backend-configuration.test.ts +3 -1
- package/src/backend-package-catalog.ts +59 -31
- package/src/backend-routines.ts +4 -0
- package/src/backend-subagent-blocking.test.ts +222 -0
- package/src/backend.ts +144 -23
- package/src/client/index.test.ts +73 -0
- package/src/client/index.ts +8 -1
- package/src/client/model-presentation.test.ts +14 -0
- package/src/client/model-presentation.ts +6 -0
- package/src/composition-manifest.test.ts +126 -0
- package/src/composition-manifest.ts +55 -0
- package/src/notification-id.test.ts +67 -0
- package/src/notification-id.ts +58 -0
- package/src/run-protocol.test.ts +81 -0
- package/src/run-protocol.ts +49 -3
- package/src/unread.test.ts +27 -0
- package/src/unread.ts +9 -1
package/src/backend.ts
CHANGED
|
@@ -214,6 +214,12 @@ import {
|
|
|
214
214
|
} from "./backend-routines.js";
|
|
215
215
|
import { RoutineInboxStore } from "@frockbot/plugin-routines/inbox-store";
|
|
216
216
|
import {
|
|
217
|
+
ROUTINE_INBOX_LIMIT,
|
|
218
|
+
ROUTINE_INBOX_PREFIX,
|
|
219
|
+
} from "@frockbot/plugin-routines/storage-keys";
|
|
220
|
+
import {
|
|
221
|
+
decodeRoutineInboxEntryV1,
|
|
222
|
+
routineFailureSentenceV1,
|
|
217
223
|
subagentAttributionV1,
|
|
218
224
|
ROUTINE_INBOX_TEXT_MAX,
|
|
219
225
|
ROUTINE_WAKE_TITLE_MAX,
|
|
@@ -409,6 +415,12 @@ import {
|
|
|
409
415
|
type ClientRunV1,
|
|
410
416
|
type ClientTurnV1,
|
|
411
417
|
} from "./run-protocol.js";
|
|
418
|
+
import { notificationIdV1 } from "./notification-id.js";
|
|
419
|
+
import {
|
|
420
|
+
type CompositionManifestSourcesV1,
|
|
421
|
+
compositionMemberManifestDocumentV1,
|
|
422
|
+
compositionMemberManifestV1,
|
|
423
|
+
} from "./composition-manifest.js";
|
|
412
424
|
import {
|
|
413
425
|
BOT_DEBUG_DEFAULT_RUN_LIMIT_V1,
|
|
414
426
|
BOT_DEBUG_EVENT_BYTES_V1,
|
|
@@ -1227,15 +1239,44 @@ export class ShellBotBackendContribution {
|
|
|
1227
1239
|
return { schemaVersion: 1, skills: entries };
|
|
1228
1240
|
}
|
|
1229
1241
|
|
|
1230
|
-
/**
|
|
1231
|
-
|
|
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
|
+
*/
|
|
1254
|
+
private compositionManifestSources(): CompositionManifestSourcesV1 {
|
|
1255
|
+
return {
|
|
1256
|
+
stored: (manifestHash) =>
|
|
1257
|
+
this.ctx.storage.get<AuthoredManifestRecordV1>(
|
|
1258
|
+
authorshipManifestKey(manifestHash),
|
|
1259
|
+
),
|
|
1260
|
+
application: (member) => this.readApplicationMemberManifest(member),
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
private readCompositionMemberManifestDocument(
|
|
1265
|
+
member: CompositionMemberV1,
|
|
1266
|
+
): Promise<unknown | undefined> {
|
|
1267
|
+
return compositionMemberManifestDocumentV1(
|
|
1268
|
+
member,
|
|
1269
|
+
this.compositionManifestSources(),
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
private readCompositionMemberManifest(
|
|
1232
1274
|
member: CompositionMemberV1,
|
|
1233
1275
|
): Promise<FrockBotManifest | undefined> {
|
|
1234
|
-
|
|
1235
|
-
|
|
1276
|
+
return compositionMemberManifestV1(
|
|
1277
|
+
member,
|
|
1278
|
+
this.compositionManifestSources(),
|
|
1236
1279
|
);
|
|
1237
|
-
if (stored) return decodeFrockBotManifest(stored.manifest);
|
|
1238
|
-
return await this.readApplicationMemberManifest(member);
|
|
1239
1280
|
}
|
|
1240
1281
|
|
|
1241
1282
|
/**
|
|
@@ -1264,6 +1305,19 @@ export class ShellBotBackendContribution {
|
|
|
1264
1305
|
return declared.manifest;
|
|
1265
1306
|
}
|
|
1266
1307
|
|
|
1308
|
+
/** The stored manifest document a mount hashes, or a modelled failure. */
|
|
1309
|
+
private async requireCompositionMemberManifestDocument(
|
|
1310
|
+
member: CompositionMemberV1,
|
|
1311
|
+
): Promise<unknown> {
|
|
1312
|
+
const document = await this.readCompositionMemberManifestDocument(member);
|
|
1313
|
+
if (document === undefined) {
|
|
1314
|
+
throw new Error(
|
|
1315
|
+
`package "${member.packageId}" manifest "${member.manifestHash}" is unavailable`,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
return document;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1267
1321
|
private async requireCompositionMemberManifest(
|
|
1268
1322
|
member: CompositionMemberV1,
|
|
1269
1323
|
): Promise<FrockBotManifest> {
|
|
@@ -1826,7 +1880,11 @@ export class ShellBotBackendContribution {
|
|
|
1826
1880
|
fallback: CompositionGenerationV1,
|
|
1827
1881
|
): Promise<void> {
|
|
1828
1882
|
await this.authority.recordNotification({
|
|
1829
|
-
notificationId:
|
|
1883
|
+
notificationId: notificationIdV1(
|
|
1884
|
+
"composition-failure",
|
|
1885
|
+
failure.generationId,
|
|
1886
|
+
failure.attempt,
|
|
1887
|
+
),
|
|
1830
1888
|
runId,
|
|
1831
1889
|
createdAt: failure.at,
|
|
1832
1890
|
title: `${settings.profile.name} kept its last working Packages`,
|
|
@@ -1877,7 +1935,8 @@ export class ShellBotBackendContribution {
|
|
|
1877
1935
|
artifacts,
|
|
1878
1936
|
this.bundledPackageArtifacts,
|
|
1879
1937
|
),
|
|
1880
|
-
manifestFor: (member) =>
|
|
1938
|
+
manifestFor: (member) =>
|
|
1939
|
+
this.requireCompositionMemberManifestDocument(member),
|
|
1881
1940
|
capabilitiesFor: (member) =>
|
|
1882
1941
|
mintCapabilities({
|
|
1883
1942
|
props: {
|
|
@@ -2686,7 +2745,12 @@ export class ShellBotBackendContribution {
|
|
|
2686
2745
|
);
|
|
2687
2746
|
if (!connection?.generation) {
|
|
2688
2747
|
await this.authority.recordNotification({
|
|
2689
|
-
notificationId:
|
|
2748
|
+
notificationId: notificationIdV1(
|
|
2749
|
+
"package-connection-unavailable",
|
|
2750
|
+
input.runId,
|
|
2751
|
+
input.packageId,
|
|
2752
|
+
input.request,
|
|
2753
|
+
),
|
|
2690
2754
|
runId: input.runId,
|
|
2691
2755
|
createdAt: new Date().toISOString(),
|
|
2692
2756
|
title: "Connection unavailable",
|
|
@@ -2714,7 +2778,11 @@ export class ShellBotBackendContribution {
|
|
|
2714
2778
|
}
|
|
2715
2779
|
const request = decodeIsolateNotificationRequestV1(input.request);
|
|
2716
2780
|
await this.authority.recordNotification({
|
|
2717
|
-
notificationId:
|
|
2781
|
+
notificationId: notificationIdV1(
|
|
2782
|
+
"package",
|
|
2783
|
+
input.packageId,
|
|
2784
|
+
request.notificationId,
|
|
2785
|
+
),
|
|
2718
2786
|
runId: input.runId,
|
|
2719
2787
|
createdAt: new Date().toISOString(),
|
|
2720
2788
|
title: request.title,
|
|
@@ -3101,14 +3169,14 @@ export class ShellBotBackendContribution {
|
|
|
3101
3169
|
await this.authority.recordNotification({
|
|
3102
3170
|
// The same id shape the completion path uses, so one firing is one
|
|
3103
3171
|
// intent however many times the alarm retries it.
|
|
3104
|
-
notificationId:
|
|
3172
|
+
notificationId: notificationIdV1("routine-failed", fire.fireId),
|
|
3105
3173
|
runId: fire.fireId,
|
|
3106
3174
|
createdAt: new Date().toISOString(),
|
|
3107
3175
|
title: `${settings.profile.name} could not run a Routine`,
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
),
|
|
3176
|
+
// The same sentence the inbox entry carries. A notification is the one
|
|
3177
|
+
// surface a person reads without asking for it, so it is the last place
|
|
3178
|
+
// a kernel invariant belongs.
|
|
3179
|
+
body: routineFailureSentenceV1(outcome.summary).slice(0, 240),
|
|
3112
3180
|
});
|
|
3113
3181
|
}
|
|
3114
3182
|
// -------------------------------------------------------------------------
|
|
@@ -3348,11 +3416,38 @@ export class ShellBotBackendContribution {
|
|
|
3348
3416
|
}
|
|
3349
3417
|
if (admission.status === "replayed") {
|
|
3350
3418
|
// The same tool call, reconciled or retried: the task it already
|
|
3351
|
-
// dispatched is the answer, never a second child.
|
|
3419
|
+
// dispatched is the answer, never a second child. A foreground call
|
|
3420
|
+
// still waits for it — the caller asked for the result, and returning
|
|
3421
|
+
// "dispatched" the instant a replay is recognised is what made a
|
|
3422
|
+
// `background:false` Task look like it completed with no output.
|
|
3423
|
+
const replayed = admission.record;
|
|
3424
|
+
const settled =
|
|
3425
|
+
replayed.outcome ??
|
|
3426
|
+
(request.background
|
|
3427
|
+
? undefined
|
|
3428
|
+
: await this.awaitBlockingTask(
|
|
3429
|
+
identity,
|
|
3430
|
+
taskAnchorIdV1(replayed.childSessionId),
|
|
3431
|
+
replayed.taskId,
|
|
3432
|
+
));
|
|
3433
|
+
if (settled) {
|
|
3434
|
+
return {
|
|
3435
|
+
status: "settled",
|
|
3436
|
+
taskId: replayed.taskId,
|
|
3437
|
+
model: replayed.model.slug,
|
|
3438
|
+
taskStatus: settled.status,
|
|
3439
|
+
...(settled.summary === undefined
|
|
3440
|
+
? {}
|
|
3441
|
+
: { summary: settled.summary }),
|
|
3442
|
+
...(settled.failure === undefined
|
|
3443
|
+
? {}
|
|
3444
|
+
: { failure: settled.failure }),
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3352
3447
|
return {
|
|
3353
3448
|
status: "dispatched",
|
|
3354
|
-
taskId:
|
|
3355
|
-
model:
|
|
3449
|
+
taskId: replayed.taskId,
|
|
3450
|
+
model: replayed.model.slug,
|
|
3356
3451
|
};
|
|
3357
3452
|
}
|
|
3358
3453
|
const reservation = await this.subagentSlots(identity).reserve({
|
|
@@ -3454,20 +3549,27 @@ export class ShellBotBackendContribution {
|
|
|
3454
3549
|
* Durable Object already does inside a Turn, and the outbound probe is the
|
|
3455
3550
|
* same call reconciliation makes.
|
|
3456
3551
|
*/
|
|
3457
|
-
|
|
3552
|
+
protected async awaitBlockingTask(
|
|
3458
3553
|
identity: BotIdentity,
|
|
3459
3554
|
anchorTaskId: string,
|
|
3460
3555
|
taskId: string,
|
|
3461
3556
|
): Promise<TaskOutcomeV1 | undefined> {
|
|
3462
3557
|
const binding = this.subagentBinding;
|
|
3463
3558
|
const deadline = Date.now() + TASK_BLOCKING_TIMEOUT_MS_V1;
|
|
3559
|
+
// A read that fails once is transient storage contention, not an answer:
|
|
3560
|
+
// abandoning the wait on the first one returned "still running" for a
|
|
3561
|
+
// child that was about to settle, and taught the model to poll. Only a
|
|
3562
|
+
// record that stays unreadable ends the wait early.
|
|
3563
|
+
const readFailureLimit = 3;
|
|
3564
|
+
let readFailures = 0;
|
|
3464
3565
|
for (;;) {
|
|
3465
3566
|
try {
|
|
3466
3567
|
const record = await this.tasks.read(taskId);
|
|
3568
|
+
readFailures = 0;
|
|
3467
3569
|
if (record.outcome) return record.outcome;
|
|
3468
3570
|
} catch {
|
|
3469
|
-
|
|
3470
|
-
return undefined;
|
|
3571
|
+
readFailures += 1;
|
|
3572
|
+
if (readFailures >= readFailureLimit) return undefined;
|
|
3471
3573
|
}
|
|
3472
3574
|
if (binding) {
|
|
3473
3575
|
try {
|
|
@@ -3620,7 +3722,7 @@ export class ShellBotBackendContribution {
|
|
|
3620
3722
|
}
|
|
3621
3723
|
if (!settings.notifications.enabled) return;
|
|
3622
3724
|
await this.authority.recordNotification({
|
|
3623
|
-
notificationId:
|
|
3725
|
+
notificationId: notificationIdV1("task-settled", task.taskId),
|
|
3624
3726
|
runId: task.taskId,
|
|
3625
3727
|
createdAt: at,
|
|
3626
3728
|
title: `${settings.profile.name} finished a subagent task`,
|
|
@@ -5260,11 +5362,30 @@ export class ShellBotBackendContribution {
|
|
|
5260
5362
|
const index = await this.authority.listRunIndex({
|
|
5261
5363
|
limit: UNREAD_COUNT_CAP + 1,
|
|
5262
5364
|
});
|
|
5365
|
+
// Counted straight off the keys rather than through `RoutineInboxStore`:
|
|
5366
|
+
// its `list()` trims the inbox, and the unread fan-out is a read every
|
|
5367
|
+
// sidebar poll makes for every Bot — it must not write, least of all into
|
|
5368
|
+
// an object that is running a Turn. An undecodable row is skipped, because
|
|
5369
|
+
// a badge is never worth failing a read for.
|
|
5370
|
+
const stored = await this.ctx.storage.list<unknown>({
|
|
5371
|
+
prefix: ROUTINE_INBOX_PREFIX,
|
|
5372
|
+
limit: ROUTINE_INBOX_LIMIT,
|
|
5373
|
+
});
|
|
5374
|
+
let failures = 0;
|
|
5375
|
+
for (const value of stored.values()) {
|
|
5376
|
+
try {
|
|
5377
|
+
const entry = decodeRoutineInboxEntryV1(value);
|
|
5378
|
+
if (entry.failure === true && !entry.acknowledged) failures += 1;
|
|
5379
|
+
} catch {
|
|
5380
|
+
continue;
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5263
5383
|
return projectBotUnreadViewV1(
|
|
5264
5384
|
identity.botId,
|
|
5265
5385
|
state,
|
|
5266
5386
|
index.map((entry) => entry.cursor),
|
|
5267
5387
|
await this.sidebarPreview(storedPreview, index),
|
|
5388
|
+
failures,
|
|
5268
5389
|
);
|
|
5269
5390
|
}
|
|
5270
5391
|
|
|
@@ -5405,7 +5526,7 @@ export class ShellBotBackendContribution {
|
|
|
5405
5526
|
// `automation_completion_inbox` row".
|
|
5406
5527
|
if (handoff === undefined) return undefined;
|
|
5407
5528
|
return {
|
|
5408
|
-
notificationId:
|
|
5529
|
+
notificationId: notificationIdV1("routine-wake", result.runId),
|
|
5409
5530
|
runId: result.runId,
|
|
5410
5531
|
createdAt: new Date().toISOString(),
|
|
5411
5532
|
title: `${settings.profile.name} finished a Routine`,
|
package/src/client/index.test.ts
CHANGED
|
@@ -1631,6 +1631,79 @@ describe("active durable Turn projection", () => {
|
|
|
1631
1631
|
});
|
|
1632
1632
|
});
|
|
1633
1633
|
|
|
1634
|
+
test("a running Turn's partial text fills the bubble it will settle into", () => {
|
|
1635
|
+
const state: Pick<
|
|
1636
|
+
FrockBotWebData,
|
|
1637
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1638
|
+
> = { messages: [] };
|
|
1639
|
+
|
|
1640
|
+
projectDurableRuns(
|
|
1641
|
+
state,
|
|
1642
|
+
[],
|
|
1643
|
+
[
|
|
1644
|
+
{
|
|
1645
|
+
runId: "run-9",
|
|
1646
|
+
input: "Explain",
|
|
1647
|
+
events: [],
|
|
1648
|
+
status: "running",
|
|
1649
|
+
partialText: "Because it",
|
|
1650
|
+
},
|
|
1651
|
+
],
|
|
1652
|
+
);
|
|
1653
|
+
expect(state.messages[1]).toMatchObject({
|
|
1654
|
+
text: "Because it",
|
|
1655
|
+
status: "streaming",
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
// One bubble: the settled answer replaces the partial one in place.
|
|
1659
|
+
projectDurableRuns(
|
|
1660
|
+
state,
|
|
1661
|
+
[],
|
|
1662
|
+
[
|
|
1663
|
+
{
|
|
1664
|
+
runId: "run-9",
|
|
1665
|
+
input: "Explain",
|
|
1666
|
+
events: [],
|
|
1667
|
+
status: "completed",
|
|
1668
|
+
responseText: "Because it is.",
|
|
1669
|
+
},
|
|
1670
|
+
],
|
|
1671
|
+
);
|
|
1672
|
+
expect(state.messages).toHaveLength(2);
|
|
1673
|
+
expect(state.messages[1]).toMatchObject({
|
|
1674
|
+
text: "Because it is.",
|
|
1675
|
+
status: "completed",
|
|
1676
|
+
});
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
test("a Turn that has already delivered a send streams nothing beside it", () => {
|
|
1680
|
+
const state: Pick<
|
|
1681
|
+
FrockBotWebData,
|
|
1682
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1683
|
+
> = { messages: [] };
|
|
1684
|
+
|
|
1685
|
+
projectDurableRuns(
|
|
1686
|
+
state,
|
|
1687
|
+
[],
|
|
1688
|
+
[
|
|
1689
|
+
{
|
|
1690
|
+
runId: "run-10",
|
|
1691
|
+
input: "Explain",
|
|
1692
|
+
events: [
|
|
1693
|
+
{
|
|
1694
|
+
type: "send/to-user",
|
|
1695
|
+
payload: { type: "text", text: "Here you go." },
|
|
1696
|
+
},
|
|
1697
|
+
],
|
|
1698
|
+
status: "running",
|
|
1699
|
+
partialText: "private scratch space",
|
|
1700
|
+
},
|
|
1701
|
+
],
|
|
1702
|
+
);
|
|
1703
|
+
expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
|
|
1704
|
+
expect(state.messages[1]?.sends).toHaveLength(1);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1634
1707
|
test("projects reconciliation-required recovery state", () => {
|
|
1635
1708
|
const reconciliation: Pick<
|
|
1636
1709
|
FrockBotWebData,
|
package/src/client/index.ts
CHANGED
|
@@ -283,10 +283,16 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
|
283
283
|
* model's own assistant text is scratch space and the thread does not draw it
|
|
284
284
|
* (issue 153): drawing both is how a one-word reply arrived twice, once as the
|
|
285
285
|
* model's text and once as the bubble that was actually delivered.
|
|
286
|
+
*
|
|
287
|
+
* A running Turn has no `responseText` yet — that is written only at
|
|
288
|
+
* settlement — so it draws the words it has written so far. They occupy the
|
|
289
|
+
* same bubble the settled answer will, and the same send gate applies to
|
|
290
|
+
* both: a Turn that has already delivered a bubble streams nothing into a
|
|
291
|
+
* second one.
|
|
286
292
|
*/
|
|
287
293
|
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
288
294
|
if (sendsFrom(run.events).length > 0) return "";
|
|
289
|
-
return run.responseText ?? fallback;
|
|
295
|
+
return run.responseText ?? run.partialText ?? fallback;
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
function isTerminalRun(run: ClientRun): boolean {
|
|
@@ -1222,6 +1228,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1222
1228
|
packageDisplayName: catalogPackage?.displayName,
|
|
1223
1229
|
connectionDisplayName: connection?.displayName,
|
|
1224
1230
|
failure: effective.binding?.failure,
|
|
1231
|
+
fallback: Boolean(effective.fallback),
|
|
1225
1232
|
});
|
|
1226
1233
|
}
|
|
1227
1234
|
|
|
@@ -35,6 +35,20 @@ describe("model runtime presentation", () => {
|
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test("names the stand-in model when the chosen one is unavailable", () => {
|
|
39
|
+
// The Bot is answering, so the line is not a failure — it names the model
|
|
40
|
+
// actually in use and says the User's own choice is not it.
|
|
41
|
+
expect(
|
|
42
|
+
modelRuntimeLabel({
|
|
43
|
+
source: "platform",
|
|
44
|
+
modelDisplayName: "Auto",
|
|
45
|
+
providerModelId: "@flock/auto",
|
|
46
|
+
packageDisplayName: "Flock AI",
|
|
47
|
+
fallback: true,
|
|
48
|
+
}),
|
|
49
|
+
).toBe("Auto · Flock AI · your chosen model is unavailable");
|
|
50
|
+
});
|
|
51
|
+
|
|
38
52
|
test("shows unavailable and backend failure states", () => {
|
|
39
53
|
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
40
54
|
"No model available — set one up in Models",
|
|
@@ -11,6 +11,11 @@ export function modelRuntimeLabel(input: {
|
|
|
11
11
|
packageDisplayName?: string;
|
|
12
12
|
connectionDisplayName?: string;
|
|
13
13
|
failure?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The chosen model could not bind — its provider Package is off, or its
|
|
16
|
+
* Connection is gone — and the platform default is answering in its place.
|
|
17
|
+
*/
|
|
18
|
+
fallback?: boolean;
|
|
14
19
|
}): string {
|
|
15
20
|
if (input.failure) return input.failure;
|
|
16
21
|
if (input.source === "none" || !input.providerModelId) {
|
|
@@ -22,5 +27,6 @@ export function modelRuntimeLabel(input: {
|
|
|
22
27
|
const runtime = provider ? `${model} · ${provider}` : model;
|
|
23
28
|
if (input.source === "bot") return `${runtime} · this Bot only`;
|
|
24
29
|
if (input.source === "account") return `${runtime} · Account model`;
|
|
30
|
+
if (input.fallback) return `${runtime} · your chosen model is unavailable`;
|
|
25
31
|
return runtime;
|
|
26
32
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Every Bot-authored Package failed to mount with `package "aud-usd" stored
|
|
2
|
+
// manifest failed hash verification`, deterministically, on byte-identical
|
|
3
|
+
// source. The mount hashed a *decoded* manifest against a hash taken over the
|
|
4
|
+
// *raw* one, so journey 4 step 2 — "use the tool you just built" — was
|
|
5
|
+
// impossible for anybody. First-party members mounted fine, because their
|
|
6
|
+
// reader already returned the raw document.
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { authoredManifestV1 } from "@frockbot/plugin-authoring/shared";
|
|
9
|
+
import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
|
|
10
|
+
import type { CompositionMemberV1 } from "@frockbot/kernel-composition/generation";
|
|
11
|
+
import { botIsolatePackageDescriptorV1 } from "@frockbot/kernel-composition/isolate";
|
|
12
|
+
import { canonicalJson, sha256 } from "@frockbot/kernel-composition/compiler";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type CompositionManifestSourcesV1,
|
|
16
|
+
compositionMemberManifestDocumentV1,
|
|
17
|
+
compositionMemberManifestV1,
|
|
18
|
+
} from "./composition-manifest.js";
|
|
19
|
+
|
|
20
|
+
/** What `package_author` writes, exactly as `backend-authoring.ts` builds it. */
|
|
21
|
+
function authoredManifest(): unknown {
|
|
22
|
+
return authoredManifestV1({
|
|
23
|
+
packageId: "aud-usd",
|
|
24
|
+
displayName: "AUD/USD",
|
|
25
|
+
version: "0.0.1",
|
|
26
|
+
tools: [
|
|
27
|
+
{
|
|
28
|
+
name: "aud_usd_rate",
|
|
29
|
+
description: "The current AUD/USD rate.",
|
|
30
|
+
inputSchema: { type: "object", properties: {} },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The durable store, as `authorship:manifest:<hash>` holds it. */
|
|
37
|
+
async function storedMember(): Promise<{
|
|
38
|
+
member: CompositionMemberV1;
|
|
39
|
+
manifest: unknown;
|
|
40
|
+
sources: CompositionManifestSourcesV1;
|
|
41
|
+
}> {
|
|
42
|
+
const manifest = authoredManifest();
|
|
43
|
+
// The one hash that matters: taken over the raw document at authoring time.
|
|
44
|
+
const manifestHash = await sha256(canonicalJson(manifest));
|
|
45
|
+
const member: CompositionMemberV1 = {
|
|
46
|
+
packageId: "aud-usd",
|
|
47
|
+
specifier: "bot:aud-usd",
|
|
48
|
+
version: "0.0.1",
|
|
49
|
+
manifestHash,
|
|
50
|
+
provenance: {
|
|
51
|
+
kind: "bot",
|
|
52
|
+
packageId: "aud-usd",
|
|
53
|
+
version: "0.0.1",
|
|
54
|
+
botId: "toolsmith",
|
|
55
|
+
sessionId: "user-1:toolsmith",
|
|
56
|
+
turnId: "turn-1",
|
|
57
|
+
runId: "run-1",
|
|
58
|
+
authoredAt: "2026-09-03T23:49:00.416Z",
|
|
59
|
+
},
|
|
60
|
+
artifact: {
|
|
61
|
+
contentHash: "b".repeat(64),
|
|
62
|
+
size: 128,
|
|
63
|
+
mediaType: "application/javascript",
|
|
64
|
+
bundlerVersion: "worker-bundler@0.2.3",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const sources: CompositionManifestSourcesV1 = {
|
|
68
|
+
stored: (hash) =>
|
|
69
|
+
Promise.resolve(hash === manifestHash ? { manifest } : undefined),
|
|
70
|
+
application: () => Promise.resolve(undefined),
|
|
71
|
+
};
|
|
72
|
+
return { member, manifest, sources };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("the manifest a mount is handed", () => {
|
|
76
|
+
test("hashes to what the Composition member recorded", async () => {
|
|
77
|
+
const { member, sources } = await storedMember();
|
|
78
|
+
|
|
79
|
+
const document = await compositionMemberManifestDocumentV1(member, sources);
|
|
80
|
+
const descriptor = await botIsolatePackageDescriptorV1(member, document);
|
|
81
|
+
|
|
82
|
+
expect(descriptor.manifest.id).toBe("aud-usd");
|
|
83
|
+
expect(descriptor.manifest.version).toBe("0.0.1");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("is the stored document, not a rebuild of it", async () => {
|
|
87
|
+
const { member, manifest, sources } = await storedMember();
|
|
88
|
+
|
|
89
|
+
const document = await compositionMemberManifestDocumentV1(member, sources);
|
|
90
|
+
|
|
91
|
+
expect(document).toBe(manifest);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("decoding first is what broke it, and still would", async () => {
|
|
95
|
+
// The guard this test exists for. `decodeFrockBotManifest` rebuilds the
|
|
96
|
+
// object — `decodeV5` always writes a `configuration` key — so the decoded
|
|
97
|
+
// manifest does not canonicalize back to the recorded hash. If the reader
|
|
98
|
+
// ever decodes again, the test above fails and this one says why.
|
|
99
|
+
const { member, manifest } = await storedMember();
|
|
100
|
+
const decoded = decodeFrockBotManifest(manifest);
|
|
101
|
+
|
|
102
|
+
expect(canonicalJson(decoded)).not.toBe(canonicalJson(manifest));
|
|
103
|
+
await expect(
|
|
104
|
+
botIsolatePackageDescriptorV1(member, decoded),
|
|
105
|
+
).rejects.toThrow("stored manifest failed hash verification");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the typed reader still answers the shape its callers want", async () => {
|
|
109
|
+
const { member, sources } = await storedMember();
|
|
110
|
+
|
|
111
|
+
const typed = await compositionMemberManifestV1(member, sources);
|
|
112
|
+
|
|
113
|
+
expect(typed?.id).toBe("aud-usd");
|
|
114
|
+
expect(typed?.version).toBe("0.0.1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a member with no stored manifest falls back to the application's", async () => {
|
|
118
|
+
const { member, manifest } = await storedMember();
|
|
119
|
+
const document = await compositionMemberManifestDocumentV1(member, {
|
|
120
|
+
stored: () => Promise.resolve(undefined),
|
|
121
|
+
application: () => Promise.resolve(manifest),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(document).toBe(manifest);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FrockBotManifest,
|
|
3
|
+
decodeFrockBotManifest,
|
|
4
|
+
} from "@frockbot/kernel-composition";
|
|
5
|
+
import type { CompositionMemberV1 } from "@frockbot/kernel-composition/generation";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where a Composition member's manifest can be found. `stored` is
|
|
9
|
+
* `authorship:manifest:<hash>` — written by the authoring path and by a
|
|
10
|
+
* Catalog install, so it covers every member a Bot or its User put into the
|
|
11
|
+
* Composition. `application` is the compiled-in manifest of a first-party
|
|
12
|
+
* artifact-backed member (ADR 0022 decision 8), which came from neither and is
|
|
13
|
+
* already in this bundle. Two *places*, never two answers: the `manifestHash`
|
|
14
|
+
* decides in both.
|
|
15
|
+
*/
|
|
16
|
+
export interface CompositionManifestSourcesV1 {
|
|
17
|
+
stored(manifestHash: string): Promise<{ manifest: unknown } | undefined>;
|
|
18
|
+
application(member: CompositionMemberV1): Promise<unknown | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A member's manifest **as the stored document** — byte-for-byte what its
|
|
23
|
+
* `manifestHash` was taken over.
|
|
24
|
+
*
|
|
25
|
+
* This is the seam that broke every Bot-authored Package. Authoring hashes
|
|
26
|
+
* `canonicalJson(rawManifest)` and stores `rawManifest` verbatim; the mount
|
|
27
|
+
* re-hashes whatever it is handed and refuses a mismatch
|
|
28
|
+
* (`botIsolatePackageDescriptorV1`). Returning a *decoded* manifest here put a
|
|
29
|
+
* rebuilt object on the mount's side of that comparison — `decodeV5` always
|
|
30
|
+
* writes a `configuration` key, among other things — so the two hashes could
|
|
31
|
+
* never agree, and every authored Package failed with `stored manifest failed
|
|
32
|
+
* hash verification` while first-party members (which were already read raw)
|
|
33
|
+
* mounted fine.
|
|
34
|
+
*
|
|
35
|
+
* Callers that want the typed shape decode it themselves, downstream of the
|
|
36
|
+
* hash check.
|
|
37
|
+
*/
|
|
38
|
+
export async function compositionMemberManifestDocumentV1(
|
|
39
|
+
member: CompositionMemberV1,
|
|
40
|
+
sources: CompositionManifestSourcesV1,
|
|
41
|
+
): Promise<unknown | undefined> {
|
|
42
|
+
const stored = await sources.stored(member.manifestHash);
|
|
43
|
+
if (stored) return stored.manifest;
|
|
44
|
+
return await sources.application(member);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The same manifest as the typed shape, for readers that are not mounts. */
|
|
48
|
+
export async function compositionMemberManifestV1(
|
|
49
|
+
member: CompositionMemberV1,
|
|
50
|
+
sources: CompositionManifestSourcesV1,
|
|
51
|
+
): Promise<FrockBotManifest | undefined> {
|
|
52
|
+
const document = await compositionMemberManifestDocumentV1(member, sources);
|
|
53
|
+
if (document === undefined) return undefined;
|
|
54
|
+
return decodeFrockBotManifest(document);
|
|
55
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { notificationIdV1 } from "./notification-id.js";
|
|
4
|
+
import { decodeClientNotificationAcknowledgementCommandV1 } from "./run-protocol.js";
|
|
5
|
+
|
|
6
|
+
/** What the acknowledge endpoint does with an id a notification carried. */
|
|
7
|
+
function acknowledge(notificationId: string): string {
|
|
8
|
+
return decodeClientNotificationAcknowledgementCommandV1({
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
action: "acknowledge",
|
|
11
|
+
notificationId,
|
|
12
|
+
}).notificationId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("notificationIdV1", () => {
|
|
16
|
+
test("a Composition failure id round-trips through acknowledgement", () => {
|
|
17
|
+
// The shape that 400'd forever: a generation id is `<ISO instant>:<hash>`,
|
|
18
|
+
// and the acknowledge decoder admits no colons.
|
|
19
|
+
const id = notificationIdV1(
|
|
20
|
+
"composition-failure",
|
|
21
|
+
"2026-09-03T23:49:00.416Z:dc03a32d9b717619",
|
|
22
|
+
1,
|
|
23
|
+
);
|
|
24
|
+
expect(id).not.toContain(":");
|
|
25
|
+
expect(acknowledge(id)).toBe(id);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("every minted id shape acknowledges", () => {
|
|
29
|
+
const minted = [
|
|
30
|
+
notificationIdV1(
|
|
31
|
+
"package-connection-unavailable",
|
|
32
|
+
crypto.randomUUID(),
|
|
33
|
+
"aud-usd",
|
|
34
|
+
"gmail",
|
|
35
|
+
),
|
|
36
|
+
notificationIdV1("package", "aud-usd", "rate:moved"),
|
|
37
|
+
notificationIdV1("routine-failed", "2026-09-03T23:49:00.416Z:fire"),
|
|
38
|
+
notificationIdV1("task-settled", crypto.randomUUID()),
|
|
39
|
+
notificationIdV1("routine-wake", crypto.randomUUID()),
|
|
40
|
+
];
|
|
41
|
+
for (const id of minted) expect(acknowledge(id)).toBe(id);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the same parts mint the same id, so a retry is one intent", () => {
|
|
45
|
+
const parts = [
|
|
46
|
+
"composition-failure",
|
|
47
|
+
"2026-09-03T23:49:00.416Z:abc",
|
|
48
|
+
2,
|
|
49
|
+
] as const;
|
|
50
|
+
expect(notificationIdV1(...parts)).toBe(notificationIdV1(...parts));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("distinct parts stay distinct, even past the length ceiling", () => {
|
|
54
|
+
const long = "x".repeat(400);
|
|
55
|
+
const a = notificationIdV1("package", long, "one");
|
|
56
|
+
const b = notificationIdV1("package", long, "two");
|
|
57
|
+
expect(a).not.toBe(b);
|
|
58
|
+
expect(a.length).toBeLessThanOrEqual(128);
|
|
59
|
+
expect(acknowledge(a)).toBe(a);
|
|
60
|
+
expect(acknowledge(b)).toBe(b);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parts that sanitize to nothing still mint an acknowledgeable id", () => {
|
|
64
|
+
const id = notificationIdV1(":::", "::");
|
|
65
|
+
expect(acknowledge(id)).toBe(id);
|
|
66
|
+
});
|
|
67
|
+
});
|