@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 +32 -31
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +42 -1
- package/src/backend-configuration.test.ts +9 -3
- package/src/backend-package-catalog.ts +75 -26
- package/src/backend.ts +36 -12
- package/src/client/FrockBotApp.vue +52 -111
- package/src/client/activity-ring.test.ts +89 -0
- package/src/client/activity-ring.ts +94 -0
- package/src/client/styles.css +39 -93
- package/src/notification-id.test.ts +26 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.ts +13 -2
- package/src/settings-links.ts +6 -0
- package/src/unread.ts +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
52
|
-
"@frockbot/application-foundation": "0.3.
|
|
53
|
-
"@frockbot/catalog-core": "0.3.
|
|
54
|
-
"@frockbot/client-core": "0.3.
|
|
55
|
-
"@frockbot/client-ui": "0.3.
|
|
56
|
-
"@frockbot/computer-core": "0.3.
|
|
57
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
58
|
-
"@frockbot/configuration-core": "0.3.
|
|
59
|
-
"@frockbot/connection-core": "0.3.
|
|
60
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
61
|
-
"@frockbot/kernel-composition": "0.3.
|
|
62
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
63
|
-
"@frockbot/kernel-do": "0.3.
|
|
64
|
-
"@frockbot/machine-protocol": "0.3.
|
|
65
|
-
"@frockbot/plugin-applets": "0.3.
|
|
66
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
67
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
68
|
-
"@frockbot/plugin-computer": "0.3.
|
|
69
|
-
"@frockbot/plugin-flock": "0.3.
|
|
70
|
-
"@frockbot/plugin-image": "0.3.
|
|
71
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
72
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
73
|
-
"@frockbot/plugin-memory": "0.3.
|
|
74
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
75
|
-
"@frockbot/plugin-routines": "0.3.
|
|
76
|
-
"@frockbot/plugin-skills": "0.3.
|
|
77
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
78
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
79
|
-
"@frockbot/protocol": "0.3.
|
|
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-
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
761
|
-
//
|
|
762
|
-
//
|
|
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
|
-
|
|
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
|
|
436
|
+
* The activity ring for one assistant line.
|
|
435
437
|
*
|
|
436
|
-
* A
|
|
437
|
-
*
|
|
438
|
-
*
|
|
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
|
|
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
|
-
):
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
<
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
'bot-avatar-waiting':
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
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
|
+
}
|
package/src/client/styles.css
CHANGED
|
@@ -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
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
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-
|
|
385
|
-
|
|
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
|
-
|
|
396
|
-
.
|
|
397
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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:
|
|
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:
|
|
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);
|
package/src/notification-id.ts
CHANGED
|
Binary file
|
package/src/run-protocol.ts
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
type SessionEvent,
|
|
6
6
|
type SkillRefV1,
|
|
7
7
|
} from "@frockbot/kernel-contracts";
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
);
|
package/src/settings-links.ts
CHANGED
|
@@ -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
|
-
[
|
|
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
|
|