@frockbot/plugin-shell 0.3.12 → 0.3.13
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 +33 -32
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +65 -1
- package/src/backend-configuration.test.ts +20 -20
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +67 -67
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +103 -16
- package/src/client/index.ts +180 -79
- package/src/client/styles.css +12 -61
- package/src/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +13 -7
- package/src/run-protocol.ts +17 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"./notification-id": "./src/notification-id.ts",
|
|
35
35
|
"./package.json": "./package.json",
|
|
36
36
|
"./run-cursor": "./src/run-cursor.ts",
|
|
37
|
+
"./run-failure-copy": "./src/run-failure-copy.ts",
|
|
37
38
|
"./run-protocol": "./src/run-protocol.ts",
|
|
38
39
|
"./settings-links": "./src/settings-links.ts",
|
|
39
40
|
"./shared": "./src/shared.ts",
|
|
@@ -49,42 +50,42 @@
|
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
52
|
-
"@frockbot/agent-runtime": "0.3.
|
|
53
|
-
"@frockbot/application-foundation": "0.3.
|
|
54
|
-
"@frockbot/catalog-core": "0.3.
|
|
55
|
-
"@frockbot/client-core": "0.3.
|
|
56
|
-
"@frockbot/client-ui": "0.3.
|
|
57
|
-
"@frockbot/computer-core": "0.3.
|
|
58
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
59
|
-
"@frockbot/configuration-core": "0.3.
|
|
60
|
-
"@frockbot/connection-core": "0.3.
|
|
61
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
62
|
-
"@frockbot/kernel-composition": "0.3.
|
|
63
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
64
|
-
"@frockbot/kernel-do": "0.3.
|
|
65
|
-
"@frockbot/machine-protocol": "0.3.
|
|
66
|
-
"@frockbot/plugin-applets": "0.3.
|
|
67
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
68
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
69
|
-
"@frockbot/plugin-computer": "0.3.
|
|
70
|
-
"@frockbot/plugin-flock": "0.3.
|
|
71
|
-
"@frockbot/plugin-image": "0.3.
|
|
72
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
73
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
74
|
-
"@frockbot/plugin-memory": "0.3.
|
|
75
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
76
|
-
"@frockbot/plugin-routines": "0.3.
|
|
77
|
-
"@frockbot/plugin-skills": "0.3.
|
|
78
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
79
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
80
|
-
"@frockbot/protocol": "0.3.
|
|
53
|
+
"@frockbot/agent-runtime": "0.3.13",
|
|
54
|
+
"@frockbot/application-foundation": "0.3.13",
|
|
55
|
+
"@frockbot/catalog-core": "0.3.13",
|
|
56
|
+
"@frockbot/client-core": "0.3.13",
|
|
57
|
+
"@frockbot/client-ui": "0.3.13",
|
|
58
|
+
"@frockbot/computer-core": "0.3.13",
|
|
59
|
+
"@frockbot/computer-host-protocol": "0.3.13",
|
|
60
|
+
"@frockbot/configuration-core": "0.3.13",
|
|
61
|
+
"@frockbot/connection-core": "0.3.13",
|
|
62
|
+
"@frockbot/kernel-agent-loop": "0.3.13",
|
|
63
|
+
"@frockbot/kernel-composition": "0.3.13",
|
|
64
|
+
"@frockbot/kernel-contracts": "0.3.13",
|
|
65
|
+
"@frockbot/kernel-do": "0.3.13",
|
|
66
|
+
"@frockbot/machine-protocol": "0.3.13",
|
|
67
|
+
"@frockbot/plugin-applets": "0.3.13",
|
|
68
|
+
"@frockbot/plugin-authoring": "0.3.13",
|
|
69
|
+
"@frockbot/plugin-bot-template": "0.3.13",
|
|
70
|
+
"@frockbot/plugin-computer": "0.3.13",
|
|
71
|
+
"@frockbot/plugin-flock": "0.3.13",
|
|
72
|
+
"@frockbot/plugin-image": "0.3.13",
|
|
73
|
+
"@frockbot/plugin-machine-messages": "0.3.13",
|
|
74
|
+
"@frockbot/plugin-mcp": "0.3.13",
|
|
75
|
+
"@frockbot/plugin-memory": "0.3.13",
|
|
76
|
+
"@frockbot/plugin-package-catalog": "0.3.13",
|
|
77
|
+
"@frockbot/plugin-routines": "0.3.13",
|
|
78
|
+
"@frockbot/plugin-skills": "0.3.13",
|
|
79
|
+
"@frockbot/plugin-subagents": "0.3.13",
|
|
80
|
+
"@frockbot/plugin-user-machine": "0.3.13",
|
|
81
|
+
"@frockbot/protocol": "0.3.13",
|
|
81
82
|
"cordis": "4.0.0-rc.8",
|
|
82
83
|
"vue": "3.5.41"
|
|
83
84
|
},
|
|
84
85
|
"devDependencies": {
|
|
85
86
|
"@cloudflare/workers-types": "latest",
|
|
86
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
87
|
-
"@frockbot/plugin-tools": "0.3.
|
|
87
|
+
"@frockbot/plugin-prompt": "0.3.13",
|
|
88
|
+
"@frockbot/plugin-tools": "0.3.13",
|
|
88
89
|
"@types/bun": "1.3.6",
|
|
89
90
|
"@types/node": "26.2.0",
|
|
90
91
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/agent.test.ts
CHANGED
|
@@ -448,3 +448,81 @@ describe("the conversation prompt section", () => {
|
|
|
448
448
|
}
|
|
449
449
|
});
|
|
450
450
|
});
|
|
451
|
+
|
|
452
|
+
// Two ends of one defect. The prompt tells the model to acknowledge with the
|
|
453
|
+
// call; the promotion catches the model that acknowledges in text anyway. The
|
|
454
|
+
// verification run watched a real model do exactly that — "On it — building
|
|
455
|
+
// the 2027 countdown applet now." as plain assistant text, then three tool
|
|
456
|
+
// calls — and nobody ever saw the line.
|
|
457
|
+
describe("the acknowledgement reaches the user", () => {
|
|
458
|
+
test("the prompt names the call, not just the line", () => {
|
|
459
|
+
expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
|
|
460
|
+
"Your own text is not shown to the user",
|
|
461
|
+
);
|
|
462
|
+
expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
|
|
463
|
+
"your first action is a `send_to_user` call",
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("assistant text in a tool-calling step is promoted to one send", async () => {
|
|
468
|
+
const mounted = await mount();
|
|
469
|
+
try {
|
|
470
|
+
await mounted.root.serial(
|
|
471
|
+
"agent/assistant-text",
|
|
472
|
+
{ session: mounted.session } as never,
|
|
473
|
+
"On it — building the 2027 countdown applet now.",
|
|
474
|
+
{ turn: 4, step: 2, requestId: "request-1" },
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
const sends = mounted.session.events.filter(
|
|
478
|
+
(event) => event.type === "send/to-user",
|
|
479
|
+
);
|
|
480
|
+
expect(sends).toHaveLength(1);
|
|
481
|
+
expect(sends[0]).toMatchObject({
|
|
482
|
+
turn: 4,
|
|
483
|
+
step: 2,
|
|
484
|
+
occurrenceId: "assistant-text:request-1",
|
|
485
|
+
payload: {
|
|
486
|
+
type: "text",
|
|
487
|
+
text: "On it — building the 2027 countdown applet now.",
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
} finally {
|
|
491
|
+
await mounted.dispose();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("a step that already spoke is left alone, and a replay adds nothing", async () => {
|
|
496
|
+
const mounted = await mount();
|
|
497
|
+
try {
|
|
498
|
+
await invoke(
|
|
499
|
+
mounted,
|
|
500
|
+
"chat",
|
|
501
|
+
call(SEND_TO_USER_TOOL_V1, {
|
|
502
|
+
payload: { type: "text", text: "On it." },
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
await mounted.root.serial(
|
|
506
|
+
"agent/assistant-text",
|
|
507
|
+
{ session: mounted.session } as never,
|
|
508
|
+
"On it — building the countdown applet now.",
|
|
509
|
+
{ turn: 4, step: 2, requestId: "request-1" },
|
|
510
|
+
);
|
|
511
|
+
// The same step replayed after an eviction promotes nothing new either.
|
|
512
|
+
await mounted.root.serial(
|
|
513
|
+
"agent/assistant-text",
|
|
514
|
+
{ session: mounted.session } as never,
|
|
515
|
+
"On it — building the countdown applet now.",
|
|
516
|
+
{ turn: 4, step: 2, requestId: "request-1" },
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const sends = mounted.session.events.filter(
|
|
520
|
+
(event) => event.type === "send/to-user",
|
|
521
|
+
);
|
|
522
|
+
expect(sends).toHaveLength(1);
|
|
523
|
+
expect(sends[0]).toMatchObject({ payload: { text: "On it." } });
|
|
524
|
+
} finally {
|
|
525
|
+
await mounted.dispose();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
});
|
package/src/agent.ts
CHANGED
|
@@ -124,6 +124,60 @@ function openStepPositionV1(
|
|
|
124
124
|
return { turn: started.turn, step: started.step };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** The longest acknowledgement worth promoting; longer is not an ack. */
|
|
128
|
+
export const PROMOTED_ASSISTANT_TEXT_LIMIT_V1 = 600;
|
|
129
|
+
|
|
130
|
+
/** The occurrence id a promoted send is recorded under, per model request. */
|
|
131
|
+
export function promotedSendOccurrenceIdV1(requestId: string): string {
|
|
132
|
+
return `assistant-text:${requestId}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Promotes a step's assistant text to a send when the model wrote to the
|
|
137
|
+
* person and then called tools without sending.
|
|
138
|
+
*
|
|
139
|
+
* The verification run is the whole argument for this. The model did exactly
|
|
140
|
+
* what the contract asks — "On it — building the 2027 countdown applet now.",
|
|
141
|
+
* one short line, no narration — and then called its tools. It went nowhere:
|
|
142
|
+
* the thread draws one bubble per send (issue 153) and there was no send, so
|
|
143
|
+
* the person watched a spinner and then a failure, with the acknowledgement
|
|
144
|
+
* they were owed sitting in the log where only the debug surface could see it.
|
|
145
|
+
*
|
|
146
|
+
* A step that already sent is left alone: the model's own text is scratch
|
|
147
|
+
* space once it has spoken, and promoting it there is how the same line
|
|
148
|
+
* arrives twice. The occurrence id is derived from the model request, so a
|
|
149
|
+
* step replayed after an eviction promotes the same text to the same
|
|
150
|
+
* occurrence rather than a second bubble.
|
|
151
|
+
*
|
|
152
|
+
* The prompt asks for the call and this does not replace it — a model that
|
|
153
|
+
* calls `send_to_user` never reaches here.
|
|
154
|
+
*/
|
|
155
|
+
export async function promoteAssistantTextToSendV1(
|
|
156
|
+
session: Session,
|
|
157
|
+
text: string,
|
|
158
|
+
position: { turn: number; step: number; requestId: string },
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const trimmed = text.trim();
|
|
161
|
+
if (trimmed.length === 0 || trimmed.length > PROMOTED_ASSISTANT_TEXT_LIMIT_V1)
|
|
162
|
+
return;
|
|
163
|
+
const occurrenceId = promotedSendOccurrenceIdV1(position.requestId);
|
|
164
|
+
const spokeThisStep = session.events.some(
|
|
165
|
+
(event) =>
|
|
166
|
+
event.type === "send/to-user" &&
|
|
167
|
+
event.turn === position.turn &&
|
|
168
|
+
event.step === position.step,
|
|
169
|
+
);
|
|
170
|
+
if (spokeThisStep) return;
|
|
171
|
+
session.append({
|
|
172
|
+
type: "send/to-user",
|
|
173
|
+
turn: position.turn,
|
|
174
|
+
step: position.step,
|
|
175
|
+
occurrenceId,
|
|
176
|
+
payload: { type: "text", text: trimmed },
|
|
177
|
+
});
|
|
178
|
+
await session.flush();
|
|
179
|
+
}
|
|
180
|
+
|
|
127
181
|
/** What a recorded send tells the model it did. */
|
|
128
182
|
function sendAcknowledgement(payload: SendToUserPayloadV1): string {
|
|
129
183
|
switch (payload.type) {
|
|
@@ -160,7 +214,11 @@ export const CONVERSATION_PROMPT_TEXT_V1 = [
|
|
|
160
214
|
"## Talking to the user",
|
|
161
215
|
"",
|
|
162
216
|
"Everything the user sees is a `send_to_user` call; nothing else reaches them.",
|
|
163
|
-
|
|
217
|
+
// The first line said sends are the only thing the user sees, and a model
|
|
218
|
+
// still wrote its acknowledgement as plain assistant text and went straight
|
|
219
|
+
// on to call tools — nobody saw it. So the acknowledgement names the call.
|
|
220
|
+
"Your own text is not shown to the user. Writing a line in your reply instead of calling `send_to_user` means nobody reads it.",
|
|
221
|
+
'When a request will take more than a moment, your first action is a `send_to_user` call with one short line — "On it." or "Looking into that." — and then you go quiet and work.',
|
|
164
222
|
"After that, send only on a real beat: the result, a decision only the user can make, or a blocker you cannot get past.",
|
|
165
223
|
"Never narrate what you are doing, what you are about to do, or which tool you are using.",
|
|
166
224
|
"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.",
|
|
@@ -361,6 +419,12 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
|
|
|
361
419
|
createWakeParentTool(ctx.sessions),
|
|
362
420
|
parentHandoff ? { admissionCeiling: parentHandoff } : undefined,
|
|
363
421
|
),
|
|
422
|
+
// The safety net under the prompt: a model that acknowledges the request
|
|
423
|
+
// in its own text instead of calling `send_to_user` still reaches the
|
|
424
|
+
// person. See `promoteAssistantTextToSendV1`.
|
|
425
|
+
ctx.on("agent/assistant-text", async (agent, text, position) => {
|
|
426
|
+
await promoteAssistantTextToSendV1(agent.session, text, position);
|
|
427
|
+
}),
|
|
364
428
|
// ADR 0030. `ctx.inject` rather than a declared dependency: a host that
|
|
365
429
|
// mounts the Shell without a model still gets its tools and its transcript
|
|
366
430
|
// seam, and simply never compacts. The hook is evaluated after `turn/end`
|
|
@@ -701,16 +701,18 @@ describe("generic per-Turn model resolution", () => {
|
|
|
701
701
|
};
|
|
702
702
|
for (const bot of bots) {
|
|
703
703
|
const runId = `${bot.botId}-disabled-package`;
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
|
|
704
|
+
// "Closed" is the durable record, not a rejected call. The Turn settles
|
|
705
|
+
// itself and the caller is handed that settlement: rethrowing over the
|
|
706
|
+
// top of it is what answered 500 and logged an uncaught error for a
|
|
707
|
+
// failure the Bot had already recorded properly.
|
|
708
|
+
await bot.contribution.run({
|
|
709
|
+
userId: "user-1",
|
|
710
|
+
botId: bot.botId,
|
|
711
|
+
runId,
|
|
712
|
+
sessionId: `user-1:${bot.botId}`,
|
|
713
|
+
acceptedAt: "2026-09-02T00:01:00.000Z",
|
|
714
|
+
text: "must fail",
|
|
715
|
+
});
|
|
714
716
|
expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
|
|
715
717
|
status: "failed",
|
|
716
718
|
failure: expect.stringContaining(
|
|
@@ -723,16 +725,14 @@ describe("generic per-Turn model resolution", () => {
|
|
|
723
725
|
user.connections[0] = { ...user.connections[0]!, state: "revoked" };
|
|
724
726
|
for (const bot of bots) {
|
|
725
727
|
const runId = `${bot.botId}-revoked-connection`;
|
|
726
|
-
await
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}),
|
|
735
|
-
).rejects.toThrow("needs reconnecting");
|
|
728
|
+
await bot.contribution.run({
|
|
729
|
+
userId: "user-1",
|
|
730
|
+
botId: bot.botId,
|
|
731
|
+
runId,
|
|
732
|
+
sessionId: `user-1:${bot.botId}`,
|
|
733
|
+
acceptedAt: "2026-09-02T00:02:00.000Z",
|
|
734
|
+
text: "must fail",
|
|
735
|
+
});
|
|
736
736
|
expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
|
|
737
737
|
status: "failed",
|
|
738
738
|
failure: expect.stringContaining("needs reconnecting"),
|
|
@@ -344,16 +344,16 @@ describe("Bot recovery", () => {
|
|
|
344
344
|
};
|
|
345
345
|
|
|
346
346
|
failRequests = true;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
)
|
|
347
|
+
// The call resolves: the Turn settled itself, and the caller is handed
|
|
348
|
+
// that settlement rather than a rejection thrown over the top of it.
|
|
349
|
+
await host().run({
|
|
350
|
+
userId: "user-1",
|
|
351
|
+
botId: "primary",
|
|
352
|
+
runId: "ollama-run-uncertain",
|
|
353
|
+
sessionId: "user-1:primary",
|
|
354
|
+
acceptedAt: "2026-08-30T00:02:00.000Z",
|
|
355
|
+
text: "uncertain",
|
|
356
|
+
});
|
|
357
357
|
// Ollama keeps no addressable copy of a completion, so a failure raised
|
|
358
358
|
// before the first stream event is definitive rather than uncertain: the
|
|
359
359
|
// run settles as a failed Turn instead of parking on a retrieval this
|
|
@@ -18,7 +18,10 @@ import { UiIcon, UiIconButton, UiSkeleton } from "@frockbot/client-ui";
|
|
|
18
18
|
import { computed, inject, onBeforeUnmount, ref, watch } from "vue";
|
|
19
19
|
import { frockBotWebDataKey } from "../shared.js";
|
|
20
20
|
import { appletsBridgeStateV2 } from "./applets-state.js";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
appletSourceFingerprintV1,
|
|
23
|
+
mostRecentlyChangedFileV1,
|
|
24
|
+
} from "./applets-client.js";
|
|
22
25
|
import { packageIframePagesForSlotV1 } from "./package-iframe-entries.js";
|
|
23
26
|
import PackageIframeHost from "./PackageIframeHost.vue";
|
|
24
27
|
|
|
@@ -70,20 +73,30 @@ watch(appletId, () => {
|
|
|
70
73
|
userSelectedTab.value = undefined;
|
|
71
74
|
followedTab.value = "code";
|
|
72
75
|
});
|
|
73
|
-
// A generation becoming active is the moment the Applet is worth looking at
|
|
76
|
+
// A generation becoming active is the moment the Applet is worth looking at —
|
|
77
|
+
// and so is opening a page on an Applet that already has one. This runs
|
|
78
|
+
// immediately because a second window, or a reload, mounts the canvas with the
|
|
79
|
+
// viewer credential already in hand: waiting for a *change* left those pages
|
|
80
|
+
// on the code view of an Applet that has been live for minutes.
|
|
74
81
|
watch(
|
|
75
82
|
() => viewer.value?.generationId,
|
|
76
83
|
(generationId) => {
|
|
77
84
|
if (generationId) followedTab.value = "app";
|
|
78
85
|
},
|
|
86
|
+
{ immediate: true },
|
|
79
87
|
);
|
|
80
|
-
// A Turn writing source is the moment the code is.
|
|
88
|
+
// A Turn writing source is the moment the code is. The fingerprint is what
|
|
89
|
+
// says a file changed: the canvas re-reads the source on every poll and gets a
|
|
90
|
+
// fresh view object each time, so a watcher on the view itself fired on Turns
|
|
91
|
+
// that wrote nothing — an Applet's own tool being called, say — and threw the
|
|
92
|
+
// User off the live Applet mid-use. The first source to arrive is a load, not
|
|
93
|
+
// a write, so it never moves the tab either.
|
|
81
94
|
watch(
|
|
82
|
-
() => source.value
|
|
83
|
-
() => {
|
|
95
|
+
() => appletSourceFingerprintV1(source.value),
|
|
96
|
+
(fingerprint, previous) => {
|
|
97
|
+
if (!previous || fingerprint === previous) return;
|
|
84
98
|
if (isRunning.value) followedTab.value = "code";
|
|
85
99
|
},
|
|
86
|
-
{ deep: true },
|
|
87
100
|
);
|
|
88
101
|
|
|
89
102
|
/** The file the code view is on, following the most recently changed one. */
|
|
@@ -542,16 +542,18 @@ let trailRunId: string | undefined;
|
|
|
542
542
|
let trailSeq = 0;
|
|
543
543
|
let trailTick = 0;
|
|
544
544
|
|
|
545
|
-
/**
|
|
546
|
-
|
|
547
|
-
|
|
545
|
+
/**
|
|
546
|
+
* The Turn still going, if any. Only one executes at a time, but a Turn queued
|
|
547
|
+
* behind it is streaming-shaped too and has produced nothing yet, so the
|
|
548
|
+
* executing one wins: the trail keeps reading the words that are arriving
|
|
549
|
+
* rather than restarting on a Turn that has not begun.
|
|
550
|
+
*/
|
|
551
|
+
const workingMessage = computed(() => {
|
|
552
|
+
const streaming = messages.value.filter(
|
|
548
553
|
(message) => message.role === "assistant" && message.status === "streaming",
|
|
549
|
-
)
|
|
550
|
-
);
|
|
551
|
-
|
|
552
|
-
function isWorking(message: WebChatMessage): boolean {
|
|
553
|
-
return message.id === workingMessage.value?.id;
|
|
554
|
-
}
|
|
554
|
+
);
|
|
555
|
+
return streaming.find((message) => !message.pending) ?? streaming.at(-1);
|
|
556
|
+
});
|
|
555
557
|
|
|
556
558
|
const workingSample = computed(() => {
|
|
557
559
|
const message = workingMessage.value;
|
|
@@ -559,7 +561,16 @@ const workingSample = computed(() => {
|
|
|
559
561
|
return activityTrailSampleV1({
|
|
560
562
|
text: message.text,
|
|
561
563
|
toolStatuses: message.tools.map((tool) => tool.status),
|
|
562
|
-
|
|
564
|
+
// Counted across the whole Turn, because every send the Bot delivers is a
|
|
565
|
+
// message of its own and the working line carries none of them. A send is
|
|
566
|
+
// still a beat the trail has to feel.
|
|
567
|
+
sends: messages.value.reduce(
|
|
568
|
+
(total, candidate) =>
|
|
569
|
+
candidate.runId === message.runId
|
|
570
|
+
? total + candidate.sends.length
|
|
571
|
+
: total,
|
|
572
|
+
0,
|
|
573
|
+
),
|
|
563
574
|
status: message.status,
|
|
564
575
|
});
|
|
565
576
|
});
|
|
@@ -1183,23 +1194,6 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1183
1194
|
<div v-if="message.text" class="message-bubble">
|
|
1184
1195
|
<UiMarkdown :text="message.text" />
|
|
1185
1196
|
</div>
|
|
1186
|
-
<!--
|
|
1187
|
-
The working state. Until the model has produced a word there
|
|
1188
|
-
was nothing beside the avatar at all, for twenty seconds and
|
|
1189
|
-
occasionally for two minutes, and the only other signal — the
|
|
1190
|
-
composer's stop button — is at the far end of the window from
|
|
1191
|
-
where the reply will appear.
|
|
1192
|
-
-->
|
|
1193
|
-
<div
|
|
1194
|
-
v-else-if="message.status === 'streaming'"
|
|
1195
|
-
class="message-bubble message-working"
|
|
1196
|
-
role="status"
|
|
1197
|
-
aria-label="Working on a reply"
|
|
1198
|
-
>
|
|
1199
|
-
<span class="working-dots" aria-hidden="true">
|
|
1200
|
-
<i></i><i></i><i></i>
|
|
1201
|
-
</span>
|
|
1202
|
-
</div>
|
|
1203
1197
|
<!--
|
|
1204
1198
|
Why the Turn ends where it does, under whatever it had
|
|
1205
1199
|
already said rather than in place of it.
|
|
@@ -1324,49 +1318,55 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1324
1318
|
</button>
|
|
1325
1319
|
</div>
|
|
1326
1320
|
</div>
|
|
1327
|
-
<!--
|
|
1328
|
-
The working row: the Bot's own avatar on its own line at the
|
|
1329
|
-
end of the thread, with the comet trail streaming off to its
|
|
1330
|
-
right. It appears only while the Turn is running. Every line in
|
|
1331
|
-
this transcript is from the same Bot — there are no group
|
|
1332
|
-
conversations yet (issue 152) — so a sheep beside a settled
|
|
1333
|
-
reply named nobody the reader did not already know; the one
|
|
1334
|
-
under a running Turn is the whole account of what the Bot is
|
|
1335
|
-
doing.
|
|
1336
|
-
|
|
1337
|
-
The art comes from whichever Package owns Bot identity; when no
|
|
1338
|
-
Package fills the slot the sparkle tile is the only child and
|
|
1339
|
-
shows through. The row carries the status role, and the canvas
|
|
1340
|
-
beside it is hidden from assistive technology: the trail is a
|
|
1341
|
-
picture of a fact the label already states.
|
|
1342
|
-
-->
|
|
1343
|
-
<Transition name="bot-working">
|
|
1344
|
-
<div
|
|
1345
|
-
v-if="isWorking(message)"
|
|
1346
|
-
class="bot-working"
|
|
1347
|
-
role="status"
|
|
1348
|
-
aria-label="Working"
|
|
1349
|
-
>
|
|
1350
|
-
<div
|
|
1351
|
-
class="bot-avatar bot-avatar-live"
|
|
1352
|
-
:class="{ 'bot-avatar-waiting': !message.text }"
|
|
1353
|
-
>
|
|
1354
|
-
<span class="bot-avatar-fallback" aria-hidden="true"
|
|
1355
|
-
><UiIcon name="sparkle" size="sm"
|
|
1356
|
-
/></span>
|
|
1357
|
-
<k-slot name="frockbot.bot-avatar" />
|
|
1358
|
-
</div>
|
|
1359
|
-
<UiActivityTrail
|
|
1360
|
-
class="bot-working-indicator"
|
|
1361
|
-
:rate="trailRate"
|
|
1362
|
-
:bursts="trailBursts"
|
|
1363
|
-
:state="trailState"
|
|
1364
|
-
/>
|
|
1365
|
-
</div>
|
|
1366
|
-
</Transition>
|
|
1367
1321
|
</template>
|
|
1368
1322
|
<div v-else class="message-bubble">{{ message.text }}</div>
|
|
1369
1323
|
</article>
|
|
1324
|
+
<!--
|
|
1325
|
+
The working row: the Bot's own avatar on its own line at the end of
|
|
1326
|
+
the thread, with the comet trail streaming off to its right. It
|
|
1327
|
+
appears only while a Turn is running. Every line in this transcript
|
|
1328
|
+
is from the same Bot — there are no group conversations yet (issue
|
|
1329
|
+
152) — so a sheep beside a settled reply named nobody the reader did
|
|
1330
|
+
not already know; the one under a running Turn is the whole account
|
|
1331
|
+
of what the Bot is doing.
|
|
1332
|
+
|
|
1333
|
+
It is a child of the thread rather than of the running Turn's
|
|
1334
|
+
article, so it is always the last thing in the transcript. Send a
|
|
1335
|
+
message while the Bot is still winding down and the new message
|
|
1336
|
+
lands above the sheep, where a reader looking at the bottom of the
|
|
1337
|
+
thread expects the newest thing to be — not underneath a running
|
|
1338
|
+
Turn that is already over.
|
|
1339
|
+
|
|
1340
|
+
The art comes from whichever Package owns Bot identity; when no
|
|
1341
|
+
Package fills the slot the sparkle tile is the only child and shows
|
|
1342
|
+
through. The row carries the status role, and the canvas beside it
|
|
1343
|
+
is hidden from assistive technology: the trail is a picture of a
|
|
1344
|
+
fact the label already states.
|
|
1345
|
+
-->
|
|
1346
|
+
<Transition name="bot-working">
|
|
1347
|
+
<div
|
|
1348
|
+
v-if="workingMessage"
|
|
1349
|
+
class="bot-working"
|
|
1350
|
+
role="status"
|
|
1351
|
+
aria-label="Working"
|
|
1352
|
+
>
|
|
1353
|
+
<div
|
|
1354
|
+
class="bot-avatar bot-avatar-live"
|
|
1355
|
+
:class="{ 'bot-avatar-waiting': !workingMessage.text }"
|
|
1356
|
+
>
|
|
1357
|
+
<span class="bot-avatar-fallback" aria-hidden="true"
|
|
1358
|
+
><UiIcon name="sparkle" size="sm"
|
|
1359
|
+
/></span>
|
|
1360
|
+
<k-slot name="frockbot.bot-avatar" />
|
|
1361
|
+
</div>
|
|
1362
|
+
<UiActivityTrail
|
|
1363
|
+
class="bot-working-indicator"
|
|
1364
|
+
:rate="trailRate"
|
|
1365
|
+
:bursts="trailBursts"
|
|
1366
|
+
:state="trailState"
|
|
1367
|
+
/>
|
|
1368
|
+
</div>
|
|
1369
|
+
</Transition>
|
|
1370
1370
|
</section>
|
|
1371
1371
|
|
|
1372
1372
|
<Transition name="banner">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
appletSourceFingerprintV1,
|
|
3
4
|
mostRecentlyChangedFileV1,
|
|
4
5
|
readAppletList,
|
|
5
6
|
readAppletSource,
|
|
@@ -119,6 +120,67 @@ describe("Applet routes", () => {
|
|
|
119
120
|
}),
|
|
120
121
|
).toBe("ui.tsx");
|
|
121
122
|
});
|
|
123
|
+
|
|
124
|
+
test("the source fingerprint is the same for a re-read of the same files", () => {
|
|
125
|
+
// The canvas moves the User to the code when a Turn *writes* source. It
|
|
126
|
+
// re-reads the source on every poll and gets a fresh view object each
|
|
127
|
+
// time, so the identity that decides has to be the files themselves —
|
|
128
|
+
// otherwise a Turn that only called an Applet's own tool throws the User
|
|
129
|
+
// off the live Applet.
|
|
130
|
+
const file = {
|
|
131
|
+
path: "server.ts",
|
|
132
|
+
text: "export default class {}",
|
|
133
|
+
generationId: "w-1",
|
|
134
|
+
changedAt: "2026-09-03T00:01:00.000Z",
|
|
135
|
+
};
|
|
136
|
+
const view = {
|
|
137
|
+
appletId: "u1abc.todo",
|
|
138
|
+
truncated: false,
|
|
139
|
+
files: [file, { ...file, path: "ui.tsx", generationId: "w-2" }],
|
|
140
|
+
};
|
|
141
|
+
expect(appletSourceFingerprintV1(view)).toBe(
|
|
142
|
+
appletSourceFingerprintV1(structuredClone(view)),
|
|
143
|
+
);
|
|
144
|
+
// Order is not a change either: two reads may sort the store differently.
|
|
145
|
+
expect(
|
|
146
|
+
appletSourceFingerprintV1({ ...view, files: view.files.toReversed() }),
|
|
147
|
+
).toBe(appletSourceFingerprintV1(view));
|
|
148
|
+
expect(appletSourceFingerprintV1(undefined)).toBe("");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the source fingerprint changes when a Turn writes a file", () => {
|
|
152
|
+
const view = {
|
|
153
|
+
appletId: "u1abc.todo",
|
|
154
|
+
truncated: false,
|
|
155
|
+
files: [
|
|
156
|
+
{
|
|
157
|
+
path: "server.ts",
|
|
158
|
+
text: "",
|
|
159
|
+
generationId: "w-1",
|
|
160
|
+
changedAt: "2026-09-03T00:01:00.000Z",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
expect(
|
|
165
|
+
appletSourceFingerprintV1({
|
|
166
|
+
...view,
|
|
167
|
+
files: [
|
|
168
|
+
{
|
|
169
|
+
...view.files[0]!,
|
|
170
|
+
generationId: "w-2",
|
|
171
|
+
changedAt: "2026-09-03T00:02:00.000Z",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
).not.toBe(appletSourceFingerprintV1(view));
|
|
176
|
+
// A new file is a write too.
|
|
177
|
+
expect(
|
|
178
|
+
appletSourceFingerprintV1({
|
|
179
|
+
...view,
|
|
180
|
+
files: [...view.files, { ...view.files[0]!, path: "ui.tsx" }],
|
|
181
|
+
}),
|
|
182
|
+
).not.toBe(appletSourceFingerprintV1(view));
|
|
183
|
+
});
|
|
122
184
|
});
|
|
123
185
|
|
|
124
186
|
describe("the applets feed a page receives", () => {
|
|
@@ -137,3 +137,22 @@ export function mostRecentlyChangedFileV1(
|
|
|
137
137
|
});
|
|
138
138
|
return ordered[0]?.path;
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A stable identity for the source the canvas is showing.
|
|
143
|
+
*
|
|
144
|
+
* The canvas follows the Turn: a Turn that writes source lands the User on the
|
|
145
|
+
* code. "Wrote source" has to be a fact about the files, though, not about the
|
|
146
|
+
* store having been re-read — `refreshAppletCanvas` assigns a fresh view object
|
|
147
|
+
* on every poll, so a watcher on the array itself fired on Turns that touched
|
|
148
|
+
* no file at all and yanked the User off the live Applet.
|
|
149
|
+
*/
|
|
150
|
+
export function appletSourceFingerprintV1(
|
|
151
|
+
source: AppletSourceViewV1 | undefined,
|
|
152
|
+
): string {
|
|
153
|
+
if (!source) return "";
|
|
154
|
+
return source.files
|
|
155
|
+
.map((file) => `${file.path}@${file.generationId}@${file.changedAt ?? ""}`)
|
|
156
|
+
.toSorted()
|
|
157
|
+
.join("\n");
|
|
158
|
+
}
|