@frockbot/plugin-shell 0.3.3 → 0.3.5
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 +31 -31
- package/src/backend-recovery-integration.test.ts +10 -9
- package/src/backend.ts +91 -25
- package/src/client/FrockBotApp.vue +100 -25
- package/src/client/index.test.ts +90 -5
- package/src/client/index.ts +116 -6
- package/src/client/no-bot-model-label.test.ts +122 -0
- package/src/client/styles.css +61 -0
- package/src/run-protocol.test.ts +73 -0
- package/src/run-protocol.ts +146 -6
- package/src/shared.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -47,41 +47,41 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
50
|
-
"@frockbot/agent-runtime": "0.3.
|
|
51
|
-
"@frockbot/application-foundation": "0.3.
|
|
52
|
-
"@frockbot/catalog-core": "0.3.
|
|
53
|
-
"@frockbot/client-core": "0.3.
|
|
54
|
-
"@frockbot/client-ui": "0.3.
|
|
55
|
-
"@frockbot/computer-core": "0.3.
|
|
56
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
57
|
-
"@frockbot/configuration-core": "0.3.
|
|
58
|
-
"@frockbot/connection-core": "0.3.
|
|
59
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
60
|
-
"@frockbot/kernel-composition": "0.3.
|
|
61
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
62
|
-
"@frockbot/kernel-do": "0.3.
|
|
63
|
-
"@frockbot/machine-protocol": "0.3.
|
|
64
|
-
"@frockbot/plugin-applets": "0.3.
|
|
65
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
66
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
67
|
-
"@frockbot/plugin-computer": "0.3.
|
|
68
|
-
"@frockbot/plugin-flock": "0.3.
|
|
69
|
-
"@frockbot/plugin-image": "0.3.
|
|
70
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
71
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
72
|
-
"@frockbot/plugin-memory": "0.3.
|
|
73
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
74
|
-
"@frockbot/plugin-routines": "0.3.
|
|
75
|
-
"@frockbot/plugin-skills": "0.3.
|
|
76
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
77
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
78
|
-
"@frockbot/protocol": "0.3.
|
|
50
|
+
"@frockbot/agent-runtime": "0.3.5",
|
|
51
|
+
"@frockbot/application-foundation": "0.3.5",
|
|
52
|
+
"@frockbot/catalog-core": "0.3.5",
|
|
53
|
+
"@frockbot/client-core": "0.3.5",
|
|
54
|
+
"@frockbot/client-ui": "0.3.5",
|
|
55
|
+
"@frockbot/computer-core": "0.3.5",
|
|
56
|
+
"@frockbot/computer-host-protocol": "0.3.5",
|
|
57
|
+
"@frockbot/configuration-core": "0.3.5",
|
|
58
|
+
"@frockbot/connection-core": "0.3.5",
|
|
59
|
+
"@frockbot/kernel-agent-loop": "0.3.5",
|
|
60
|
+
"@frockbot/kernel-composition": "0.3.5",
|
|
61
|
+
"@frockbot/kernel-contracts": "0.3.5",
|
|
62
|
+
"@frockbot/kernel-do": "0.3.5",
|
|
63
|
+
"@frockbot/machine-protocol": "0.3.5",
|
|
64
|
+
"@frockbot/plugin-applets": "0.3.5",
|
|
65
|
+
"@frockbot/plugin-authoring": "0.3.5",
|
|
66
|
+
"@frockbot/plugin-bot-template": "0.3.5",
|
|
67
|
+
"@frockbot/plugin-computer": "0.3.5",
|
|
68
|
+
"@frockbot/plugin-flock": "0.3.5",
|
|
69
|
+
"@frockbot/plugin-image": "0.3.5",
|
|
70
|
+
"@frockbot/plugin-machine-messages": "0.3.5",
|
|
71
|
+
"@frockbot/plugin-mcp": "0.3.5",
|
|
72
|
+
"@frockbot/plugin-memory": "0.3.5",
|
|
73
|
+
"@frockbot/plugin-package-catalog": "0.3.5",
|
|
74
|
+
"@frockbot/plugin-routines": "0.3.5",
|
|
75
|
+
"@frockbot/plugin-skills": "0.3.5",
|
|
76
|
+
"@frockbot/plugin-subagents": "0.3.5",
|
|
77
|
+
"@frockbot/plugin-user-machine": "0.3.5",
|
|
78
|
+
"@frockbot/protocol": "0.3.5",
|
|
79
79
|
"cordis": "4.0.0-rc.8",
|
|
80
80
|
"vue": "3.5.41"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@cloudflare/workers-types": "latest",
|
|
84
|
-
"@frockbot/plugin-tools": "0.3.
|
|
84
|
+
"@frockbot/plugin-tools": "0.3.5",
|
|
85
85
|
"@types/bun": "1.3.6",
|
|
86
86
|
"@types/node": "26.2.0",
|
|
87
87
|
"@vitejs/plugin-vue": "6.0.8",
|
|
@@ -1310,17 +1310,18 @@ describe("Bot recovery", () => {
|
|
|
1310
1310
|
{ schemaVersion: 1, runId: `bounded-fence-${index}` },
|
|
1311
1311
|
);
|
|
1312
1312
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1313
|
+
// The index is a bounded FIFO: the oldest fence ages out, and the fence
|
|
1314
|
+
// itself always succeeds. Refusing it instead meant a Bot that had refused
|
|
1315
|
+
// 256 sends answered every later fence 500, and left the client looping
|
|
1316
|
+
// "Turn admission lookup failed".
|
|
1317
|
+
await contribution.fenceRunAdmission(
|
|
1318
|
+
{ userId: "user-1", botId: "primary" },
|
|
1319
|
+
{ schemaVersion: 1, runId: "fence-over-capacity" },
|
|
1320
|
+
);
|
|
1319
1321
|
const fences = await storage.get<string[]>("run-admission-fences");
|
|
1320
1322
|
expect(fences).toHaveLength(256);
|
|
1321
|
-
expect(fences).toContain("
|
|
1322
|
-
expect(fences).toContain("
|
|
1323
|
-
expect(fences).not.toContain("fence-over-capacity");
|
|
1323
|
+
expect(fences).toContain("fence-over-capacity");
|
|
1324
|
+
expect(fences).not.toContain("command-fenced");
|
|
1324
1325
|
});
|
|
1325
1326
|
|
|
1326
1327
|
test("rechecks a fence committed during execution-context resolution", async () => {
|
package/src/backend.ts
CHANGED
|
@@ -328,7 +328,11 @@ import {
|
|
|
328
328
|
type PendingBotInputV1,
|
|
329
329
|
type RoutineInboxEntryV1,
|
|
330
330
|
} from "@frockbot/plugin-routines/inbox";
|
|
331
|
-
import type {
|
|
331
|
+
import type {
|
|
332
|
+
RoutineFireOutcomeV1,
|
|
333
|
+
RoutineScheduler,
|
|
334
|
+
} from "@frockbot/plugin-routines/scheduler";
|
|
335
|
+
import type { RoutineFireV1 } from "@frockbot/plugin-routines/firing";
|
|
332
336
|
import {
|
|
333
337
|
RoutineNotFoundError,
|
|
334
338
|
type RoutineStore,
|
|
@@ -395,6 +399,7 @@ import {
|
|
|
395
399
|
isVisibleRunV1,
|
|
396
400
|
projectClientRunLookupV1,
|
|
397
401
|
projectClientRunV1,
|
|
402
|
+
projectClientRunOrDegradedV1,
|
|
398
403
|
projectClientAnnouncementsV1,
|
|
399
404
|
projectClientTurnV1,
|
|
400
405
|
type ClientRunLookupV1,
|
|
@@ -2975,17 +2980,24 @@ export class ShellBotBackendContribution {
|
|
|
2975
2980
|
}
|
|
2976
2981
|
|
|
2977
2982
|
private async settleScheduledWork(): Promise<void> {
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
this.
|
|
2988
|
-
|
|
2983
|
+
// The re-arm is in a `finally` because it is the object's only way back.
|
|
2984
|
+
// The alarm that woke this object has already been consumed by the
|
|
2985
|
+
// platform; a throw in any one settler used to skip the re-arm, and then
|
|
2986
|
+
// nothing — no Routine, no approval expiry, no owed subagent Turn — ever
|
|
2987
|
+
// woke this Bot again except by a caller's luck. One producer failing must
|
|
2988
|
+
// cost that producer its pass, never the clock.
|
|
2989
|
+
try {
|
|
2990
|
+
await this.settleRoutineFirings();
|
|
2991
|
+
await this.runOwedSubagentTurns();
|
|
2992
|
+
await this.reconcileOverdueTasks();
|
|
2993
|
+
await this.expireDueApprovals();
|
|
2994
|
+
await this.replayPendingWakeNotifications();
|
|
2995
|
+
await this.hostSettleScheduledWork?.();
|
|
2996
|
+
} finally {
|
|
2997
|
+
await this.ctx.storage.transaction((transaction) =>
|
|
2998
|
+
this.authority.refreshRecoveryAlarm(transaction),
|
|
2999
|
+
);
|
|
3000
|
+
}
|
|
2989
3001
|
}
|
|
2990
3002
|
|
|
2991
3003
|
/**
|
|
@@ -3005,21 +3017,73 @@ export class ShellBotBackendContribution {
|
|
|
3005
3017
|
// here whenever the Turn is executing in this isolate, but a durable active
|
|
3006
3018
|
// run outlives an eviction, and admitting a firing against one would burn
|
|
3007
3019
|
// the occurrence on an error instead of holding the debt.
|
|
3008
|
-
|
|
3020
|
+
//
|
|
3021
|
+
// Returning was not enough: the debt stayed past-due, so `deadlines()`
|
|
3022
|
+
// re-armed on a moment already gone and the alarm spun straight back into
|
|
3023
|
+
// this same bail-out — which is how a Routine racing a long chat Turn
|
|
3024
|
+
// failed once a minute for ever. The hold is what turns the bail-out into
|
|
3025
|
+
// a deferral: `dueAt` does not move, so the firing still lands.
|
|
3026
|
+
if (await this.authority.readActiveRunId()) {
|
|
3027
|
+
await this.ctx.storage.transaction((transaction) =>
|
|
3028
|
+
this.routineScheduler.defer(transaction),
|
|
3029
|
+
);
|
|
3030
|
+
return;
|
|
3031
|
+
}
|
|
3009
3032
|
await this.routineScheduler.settle(async (fire) => {
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3033
|
+
const outcome = await this.runOneFiring(identity, fire);
|
|
3034
|
+
await this.notifyFailedFiring(identity, fire, outcome);
|
|
3035
|
+
return outcome;
|
|
3036
|
+
});
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
private async runOneFiring(
|
|
3040
|
+
identity: BotIdentity,
|
|
3041
|
+
fire: RoutineFireV1,
|
|
3042
|
+
): Promise<RoutineFireOutcomeV1> {
|
|
3043
|
+
try {
|
|
3044
|
+
await this.authority.run(
|
|
3045
|
+
routineTurnCommandV1(identity, fire, new Date().toISOString()),
|
|
3046
|
+
);
|
|
3047
|
+
} catch (error) {
|
|
3020
3048
|
return routineFireOutcomeV1(
|
|
3021
3049
|
await this.authority.readStoredRun(fire.fireId),
|
|
3050
|
+
error,
|
|
3022
3051
|
);
|
|
3052
|
+
}
|
|
3053
|
+
return routineFireOutcomeV1(
|
|
3054
|
+
await this.authority.readStoredRun(fire.fireId),
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
/**
|
|
3059
|
+
* Tell the person that a firing did not work.
|
|
3060
|
+
*
|
|
3061
|
+
* The scheduler has already written the durable completion-inbox entry in
|
|
3062
|
+
* the transaction that settled the firing; this is the delivery half — the
|
|
3063
|
+
* same seam a hand-off uses, so a Routine that breaks reaches the same place
|
|
3064
|
+
* a Routine that finishes does instead of only a `failed` row nobody opens.
|
|
3065
|
+
* `notifications.enabled` is honoured: it is the mute on updates, and a
|
|
3066
|
+
* broken Routine is an update, not a decision the Bot is waiting on.
|
|
3067
|
+
*/
|
|
3068
|
+
private async notifyFailedFiring(
|
|
3069
|
+
identity: BotIdentity,
|
|
3070
|
+
fire: RoutineFireV1,
|
|
3071
|
+
outcome: RoutineFireOutcomeV1,
|
|
3072
|
+
): Promise<void> {
|
|
3073
|
+
if (outcome.status === "ok") return;
|
|
3074
|
+
const settings = await this.getSettings(identity);
|
|
3075
|
+
if (!settings.notifications.enabled) return;
|
|
3076
|
+
await this.authority.recordNotification({
|
|
3077
|
+
// The same id shape the completion path uses, so one firing is one
|
|
3078
|
+
// intent however many times the alarm retries it.
|
|
3079
|
+
notificationId: `routine-failed:${fire.fireId}`,
|
|
3080
|
+
runId: fire.fireId,
|
|
3081
|
+
createdAt: new Date().toISOString(),
|
|
3082
|
+
title: `${settings.profile.name} could not run a Routine`,
|
|
3083
|
+
body: (outcome.summary ?? "The firing ended without saying why.").slice(
|
|
3084
|
+
0,
|
|
3085
|
+
240,
|
|
3086
|
+
),
|
|
3023
3087
|
});
|
|
3024
3088
|
}
|
|
3025
3089
|
// -------------------------------------------------------------------------
|
|
@@ -5370,7 +5434,9 @@ export class ShellBotBackendContribution {
|
|
|
5370
5434
|
// still not part of the conversation: the visible transcript never
|
|
5371
5435
|
// shows one, running or settled.
|
|
5372
5436
|
if (active && isVisibleRunV1(active))
|
|
5373
|
-
selected.set(active.runId, {
|
|
5437
|
+
selected.set(active.runId, {
|
|
5438
|
+
run: projectClientRunOrDegradedV1(active),
|
|
5439
|
+
});
|
|
5374
5440
|
}
|
|
5375
5441
|
const available = candidates.slice(0, CLIENT_RUN_PAGE_LIMIT);
|
|
5376
5442
|
let stoppedEarly = false;
|
|
@@ -5382,7 +5448,7 @@ export class ShellBotBackendContribution {
|
|
|
5382
5448
|
}
|
|
5383
5449
|
const stored = await this.authority.readStoredRun(candidate.runId);
|
|
5384
5450
|
if (!stored || !isVisibleRunV1(stored)) continue;
|
|
5385
|
-
const projected =
|
|
5451
|
+
const projected = projectClientRunOrDegradedV1(stored);
|
|
5386
5452
|
const tentative = [
|
|
5387
5453
|
...selected.values(),
|
|
5388
5454
|
{ cursor: candidate.cursor, run: projected },
|
|
@@ -267,9 +267,31 @@ const macDesktop =
|
|
|
267
267
|
typeof navigator !== "undefined" &&
|
|
268
268
|
/Electron/u.test(navigator.userAgent) &&
|
|
269
269
|
/Mac/u.test(navigator.platform);
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
);
|
|
270
|
+
// The Bot's own name, or nothing: an account with no Bot, and a Bot whose
|
|
271
|
+
// settings have not arrived yet, must never be given a made-up name.
|
|
272
|
+
const botName = computed(() => state.value.botSettings?.profile.name ?? "");
|
|
273
|
+
const hasBot = computed(() => Boolean(state.value.activeBotId));
|
|
274
|
+
/**
|
|
275
|
+
* The greeting, the composer placeholder and the not-ready line all read off
|
|
276
|
+
* the same two facts: whether a Bot is open, and what the model resolver said.
|
|
277
|
+
* `state.modelLabel` already carries the resolver's own repairable failure
|
|
278
|
+
* sentence when the binding failed, so the surface repeats it rather than
|
|
279
|
+
* inventing "Model unavailable" of its own.
|
|
280
|
+
*/
|
|
281
|
+
const threadHeading = computed(() => {
|
|
282
|
+
if (!hasBot.value) return "No Bots yet.";
|
|
283
|
+
if (!botName.value) return state.value.modelReady ? "Ready." : "Not ready.";
|
|
284
|
+
return state.value.modelReady
|
|
285
|
+
? `${botName.value} is ready.`
|
|
286
|
+
: `${botName.value} isn't ready.`;
|
|
287
|
+
});
|
|
288
|
+
const threadHint = computed(() => {
|
|
289
|
+
if (!hasBot.value) return "Add your first sheep to start a conversation.";
|
|
290
|
+
if (state.value.modelReady) {
|
|
291
|
+
return "Start with a conversation. Cordis plugins can add the rest.";
|
|
292
|
+
}
|
|
293
|
+
return state.value.modelLabel;
|
|
294
|
+
});
|
|
273
295
|
/** A Turn is executing. The composer stays open; only Stop depends on this. */
|
|
274
296
|
const isRunning = computed(() => Boolean(state.value.runningRunId));
|
|
275
297
|
const isConnecting = computed(() => state.value.connection !== "ready");
|
|
@@ -278,6 +300,11 @@ const isConnecting = computed(() => state.value.connection !== "ready");
|
|
|
278
300
|
* running Turn. So the only things that close the composer are the ones that
|
|
279
301
|
* would make any message impossible.
|
|
280
302
|
*/
|
|
303
|
+
const composerPlaceholder = computed(() => {
|
|
304
|
+
if (isConnecting.value) return "Connecting…";
|
|
305
|
+
if (!state.value.modelReady) return state.value.modelLabel;
|
|
306
|
+
return botName.value ? `Message ${botName.value}` : "Message";
|
|
307
|
+
});
|
|
281
308
|
const canSend = computed(
|
|
282
309
|
() =>
|
|
283
310
|
state.value.connection === "ready" &&
|
|
@@ -378,6 +405,32 @@ function taskChipsOf(message: WebChatMessage): Array<{
|
|
|
378
405
|
});
|
|
379
406
|
}
|
|
380
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The tools this Turn ran, as the thread draws them.
|
|
410
|
+
*
|
|
411
|
+
* A tool whose Package draws its own surface is shown by that surface; every
|
|
412
|
+
* other one is a chip, because a Turn that spends a minute making tool calls
|
|
413
|
+
* used to show the User nothing at all but a spinning avatar.
|
|
414
|
+
*/
|
|
415
|
+
function toolChipsOf(message: WebChatMessage): WebToolActivity[] {
|
|
416
|
+
return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** What a chip says a tool is doing. Its status, in the User's words. */
|
|
420
|
+
function toolChipStatus(tool: WebToolActivity): string {
|
|
421
|
+
if (tool.status === "running") return "running";
|
|
422
|
+
return tool.status === "failed" ? "failed" : "done";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Which tool chips the User has opened. Local, and per chip. */
|
|
426
|
+
const expandedTools = ref(new Set<string>());
|
|
427
|
+
|
|
428
|
+
function toggleTool(toolId: string): void {
|
|
429
|
+
const next = new Set(expandedTools.value);
|
|
430
|
+
if (!next.delete(toolId)) next.add(toolId);
|
|
431
|
+
expandedTools.value = next;
|
|
432
|
+
}
|
|
433
|
+
|
|
381
434
|
/** Which chips the User has opened. Local, and per chip. */
|
|
382
435
|
const expandedTasks = ref(new Set<string>());
|
|
383
436
|
|
|
@@ -404,6 +457,7 @@ function isVisible(message: WebChatMessage): boolean {
|
|
|
404
457
|
// visible act was dispatching a subagent.
|
|
405
458
|
return (
|
|
406
459
|
message.text.length > 0 ||
|
|
460
|
+
(message.notice?.length ?? 0) > 0 ||
|
|
407
461
|
message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
|
|
408
462
|
message.sends.length > 0 ||
|
|
409
463
|
(message.tasks?.length ?? 0) > 0 ||
|
|
@@ -778,7 +832,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
778
832
|
<span class="bot-identity"
|
|
779
833
|
><k-slot name="frockbot.bot-identity"
|
|
780
834
|
/></span>
|
|
781
|
-
<div class="workspace-title">
|
|
835
|
+
<div v-if="hasBot" class="workspace-title">
|
|
782
836
|
<strong>{{ botName }}</strong>
|
|
783
837
|
<small>{{ state.modelLabel }}</small>
|
|
784
838
|
</div>
|
|
@@ -793,20 +847,8 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
793
847
|
>
|
|
794
848
|
<div v-if="messages.length === 0" class="empty-thread">
|
|
795
849
|
<div class="empty-mark"><UiIcon name="sparkle" size="lg" /></div>
|
|
796
|
-
<h1>
|
|
797
|
-
|
|
798
|
-
state.modelReady
|
|
799
|
-
? `${botName} is ready.`
|
|
800
|
-
: `${botName} isn't ready.`
|
|
801
|
-
}}
|
|
802
|
-
</h1>
|
|
803
|
-
<p>
|
|
804
|
-
{{
|
|
805
|
-
state.modelReady
|
|
806
|
-
? "Start with a conversation. Cordis plugins can add the rest."
|
|
807
|
-
: "Check this Bot's model Connection."
|
|
808
|
-
}}
|
|
809
|
-
</p>
|
|
850
|
+
<h1>{{ threadHeading }}</h1>
|
|
851
|
+
<p>{{ threadHint }}</p>
|
|
810
852
|
</div>
|
|
811
853
|
<article
|
|
812
854
|
v-for="message in messages"
|
|
@@ -844,6 +886,13 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
844
886
|
<div v-if="message.text" class="message-bubble">
|
|
845
887
|
<UiMarkdown :text="message.text" />
|
|
846
888
|
</div>
|
|
889
|
+
<!--
|
|
890
|
+
Why the Turn ends where it does, under whatever it had already
|
|
891
|
+
said rather than in place of it.
|
|
892
|
+
-->
|
|
893
|
+
<p v-if="message.notice" class="message-notice">
|
|
894
|
+
{{ message.notice }}
|
|
895
|
+
</p>
|
|
847
896
|
<template v-for="tool in message.tools" :key="tool.id">
|
|
848
897
|
<PackageIframeHost
|
|
849
898
|
v-for="entry in iframeEntriesFor(tool)"
|
|
@@ -891,6 +940,33 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
891
940
|
:send="send"
|
|
892
941
|
/>
|
|
893
942
|
</div>
|
|
943
|
+
<!--
|
|
944
|
+
What the Bot did, while it is doing it. The chip is the
|
|
945
|
+
conversation's whole account of an ordinary tool call: its
|
|
946
|
+
name, whether it is running, and — when the User opens it —
|
|
947
|
+
what it returned.
|
|
948
|
+
-->
|
|
949
|
+
<div v-if="toolChipsOf(message).length > 0" class="message-tools">
|
|
950
|
+
<button
|
|
951
|
+
v-for="tool in toolChipsOf(message)"
|
|
952
|
+
:key="tool.id"
|
|
953
|
+
type="button"
|
|
954
|
+
class="tool-chip"
|
|
955
|
+
:class="`tool-chip-${tool.status}`"
|
|
956
|
+
:aria-expanded="expandedTools.has(tool.id)"
|
|
957
|
+
@click="toggleTool(tool.id)"
|
|
958
|
+
>
|
|
959
|
+
<span class="tool-chip-name">{{ tool.name }}</span>
|
|
960
|
+
<span class="tool-chip-status">{{
|
|
961
|
+
toolChipStatus(tool)
|
|
962
|
+
}}</span>
|
|
963
|
+
<span
|
|
964
|
+
v-if="expandedTools.has(tool.id) && tool.text !== undefined"
|
|
965
|
+
class="tool-chip-result"
|
|
966
|
+
>{{ tool.text }}</span
|
|
967
|
+
>
|
|
968
|
+
</button>
|
|
969
|
+
</div>
|
|
894
970
|
<!--
|
|
895
971
|
The subagents this Turn dispatched. The child's own Session is
|
|
896
972
|
never in this transcript, so the chip is the whole of what the
|
|
@@ -975,7 +1051,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
975
1051
|
</div>
|
|
976
1052
|
</Transition>
|
|
977
1053
|
|
|
1054
|
+
<!--
|
|
1055
|
+
No Bot, no composer. A disabled input under a made-up Bot name reads
|
|
1056
|
+
as a broken Bot; the first-run pane above points at making one.
|
|
1057
|
+
-->
|
|
978
1058
|
<form
|
|
1059
|
+
v-if="hasBot"
|
|
979
1060
|
class="composer"
|
|
980
1061
|
:class="{ 'composer-busy': isRunning }"
|
|
981
1062
|
@submit.prevent="sendMessage"
|
|
@@ -1038,13 +1119,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1038
1119
|
ref="composerInput"
|
|
1039
1120
|
v-model="draft"
|
|
1040
1121
|
aria-label="Message"
|
|
1041
|
-
:placeholder="
|
|
1042
|
-
isConnecting
|
|
1043
|
-
? 'Connecting…'
|
|
1044
|
-
: !state.modelReady
|
|
1045
|
-
? 'Model unavailable'
|
|
1046
|
-
: `Message ${botName}`
|
|
1047
|
-
"
|
|
1122
|
+
:placeholder="composerPlaceholder"
|
|
1048
1123
|
:disabled="isConnecting || !state.modelReady"
|
|
1049
1124
|
rows="1"
|
|
1050
1125
|
role="combobox"
|
package/src/client/index.test.ts
CHANGED
|
@@ -826,6 +826,84 @@ describe("Bot selection", () => {
|
|
|
826
826
|
},
|
|
827
827
|
);
|
|
828
828
|
|
|
829
|
+
/**
|
|
830
|
+
* The composer placeholder and the not-ready line in `FrockBotApp.vue` are
|
|
831
|
+
* `state.modelLabel`, so a genuinely broken binding has to reach that label
|
|
832
|
+
* as the resolver's own repairable sentence rather than a flat "Model
|
|
833
|
+
* unavailable" the User cannot act on.
|
|
834
|
+
*/
|
|
835
|
+
test("labels a Bot whose model Connection is disabled with the resolver's failure", async () => {
|
|
836
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
837
|
+
const bot = initializeBotSettingsV1("broken-model-bot");
|
|
838
|
+
const user: UserSettingsViewV1 = {
|
|
839
|
+
schemaVersion: 1,
|
|
840
|
+
revision: 1,
|
|
841
|
+
profile: { name: "User" },
|
|
842
|
+
packages: [
|
|
843
|
+
{ packageId: "model-provider", version: "0.0.1", state: "installed" },
|
|
844
|
+
],
|
|
845
|
+
connections: [
|
|
846
|
+
{
|
|
847
|
+
connectionId: "model-connection",
|
|
848
|
+
packageId: "model-provider",
|
|
849
|
+
connectionTypeId: "model-account",
|
|
850
|
+
displayName: "Work",
|
|
851
|
+
state: "disabled",
|
|
852
|
+
providerType: "model-provider",
|
|
853
|
+
safeMetadata: {},
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
platformModel: {
|
|
857
|
+
connectionId: "model-connection",
|
|
858
|
+
providerModelId: "model-id",
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
await shellClientPlugin({
|
|
862
|
+
transport: {
|
|
863
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
864
|
+
readConfiguration: (query) =>
|
|
865
|
+
Promise.resolve(query.type === "user/get" ? user : bot),
|
|
866
|
+
},
|
|
867
|
+
slot: () => () => {},
|
|
868
|
+
inject: () => {
|
|
869
|
+
throw new Error("unexpected client provider injection");
|
|
870
|
+
},
|
|
871
|
+
provide: (_key, value) => {
|
|
872
|
+
provided = value as Ref<FrockBotWebData>;
|
|
873
|
+
return () => {};
|
|
874
|
+
},
|
|
875
|
+
});
|
|
876
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
877
|
+
provided.value.activeBotId = bot.botId;
|
|
878
|
+
provided.value.pluginCatalog = [
|
|
879
|
+
{
|
|
880
|
+
packageId: "model-provider",
|
|
881
|
+
displayName: "Model provider",
|
|
882
|
+
version: "0.0.1",
|
|
883
|
+
capabilities: [
|
|
884
|
+
{ id: "models", kind: "model", connectionTypes: ["model-account"] },
|
|
885
|
+
],
|
|
886
|
+
connectionTypes: [
|
|
887
|
+
{
|
|
888
|
+
id: "model-account",
|
|
889
|
+
displayName: "Model account",
|
|
890
|
+
allowMultiple: false,
|
|
891
|
+
authorizationKind: "api-key",
|
|
892
|
+
capabilities: ["models"],
|
|
893
|
+
},
|
|
894
|
+
],
|
|
895
|
+
settings: [],
|
|
896
|
+
},
|
|
897
|
+
];
|
|
898
|
+
|
|
899
|
+
await provided.value.loadBotSettings();
|
|
900
|
+
|
|
901
|
+
expect(provided.value.modelReady).toBe(false);
|
|
902
|
+
expect(provided.value.modelLabel).toBe(
|
|
903
|
+
'Connection "model-connection" is disabled; enable or reconnect it',
|
|
904
|
+
);
|
|
905
|
+
});
|
|
906
|
+
|
|
829
907
|
test("saves Bot-scoped Package settings through the generic command", async () => {
|
|
830
908
|
type PackageSettingsWebData = FrockBotWebData & {
|
|
831
909
|
saveBotPackageSettings(
|
|
@@ -1769,14 +1847,18 @@ describe("hosted Stop", () => {
|
|
|
1769
1847
|
status: "reconciliation-required",
|
|
1770
1848
|
message:
|
|
1771
1849
|
"Stop accepted; reconciling the provider outcome before cancelling.",
|
|
1772
|
-
|
|
1850
|
+
// A parked Turn is offered the resolve control, Stop included: hiding
|
|
1851
|
+
// it there hid it in exactly the case Stop creates.
|
|
1852
|
+
canResume: true,
|
|
1773
1853
|
});
|
|
1774
1854
|
|
|
1775
1855
|
await provided.value.stopRun();
|
|
1776
1856
|
expect(provided.value.activeRun).toBeUndefined();
|
|
1777
1857
|
expect(provided.value.activeRunId).toBeUndefined();
|
|
1858
|
+
// The Turn keeps whatever it had already said; the notice is the line
|
|
1859
|
+
// that says why it ends where it does.
|
|
1778
1860
|
expect(provided.value.messages[1]).toMatchObject({
|
|
1779
|
-
|
|
1861
|
+
notice: "Stopped by an authenticated Stop command.",
|
|
1780
1862
|
status: "aborted",
|
|
1781
1863
|
});
|
|
1782
1864
|
|
|
@@ -1833,12 +1915,15 @@ describe("hosted Stop", () => {
|
|
|
1833
1915
|
|
|
1834
1916
|
await provided.value.stopRun();
|
|
1835
1917
|
|
|
1836
|
-
|
|
1918
|
+
// Two, and deliberately: a client that finds itself holding an active run
|
|
1919
|
+
// it did not start now observes it (a reload mid-Turn, a second tab), and
|
|
1920
|
+
// Stop then observes the settlement it asked for.
|
|
1921
|
+
expect(lookups).toBe(2);
|
|
1837
1922
|
expect(provided.value.activeRun).toBeUndefined();
|
|
1838
1923
|
expect(provided.value.activeRunId).toBeUndefined();
|
|
1839
1924
|
expect(provided.value.messages.at(-1)).toMatchObject({
|
|
1840
1925
|
runId: "run-1",
|
|
1841
|
-
|
|
1926
|
+
notice: "Stopped by an authenticated Stop command.",
|
|
1842
1927
|
status: "aborted",
|
|
1843
1928
|
});
|
|
1844
1929
|
});
|
|
@@ -2819,7 +2904,7 @@ describe("a message sent while a Turn is running", () => {
|
|
|
2819
2904
|
// The superseded Turn keeps the quiet treatment a stopped one gets.
|
|
2820
2905
|
expect(state.messages[1]).toMatchObject({
|
|
2821
2906
|
status: "aborted",
|
|
2822
|
-
|
|
2907
|
+
notice: "Interrupted by your next message.",
|
|
2823
2908
|
});
|
|
2824
2909
|
});
|
|
2825
2910
|
|
package/src/client/index.ts
CHANGED
|
@@ -53,7 +53,10 @@ import {
|
|
|
53
53
|
import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
|
|
54
54
|
import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
55
55
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
ClientTurnRefusedErrorV1,
|
|
58
|
+
decodeClientTurnV1,
|
|
59
|
+
} from "../run-protocol.js";
|
|
57
60
|
import {
|
|
58
61
|
decodeApprovalDecisionReceiptV1,
|
|
59
62
|
decodeApprovalListViewV1,
|
|
@@ -244,7 +247,11 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
|
|
|
244
247
|
: (run.recovery?.message ??
|
|
245
248
|
run.failure ??
|
|
246
249
|
"This Turn requires provider reconciliation before it can continue."),
|
|
247
|
-
|
|
250
|
+
// Offered whenever the run is parked, Stop included. Hiding it there
|
|
251
|
+
// hid it in exactly the case Stop creates: a Turn that was stopped
|
|
252
|
+
// while the model was mid-answer parks, and the person was left with a
|
|
253
|
+
// banner and no way to act on it.
|
|
254
|
+
canResume: run.recovery?.action === "resume",
|
|
248
255
|
};
|
|
249
256
|
}
|
|
250
257
|
return undefined;
|
|
@@ -287,7 +294,8 @@ function assistantMessage(
|
|
|
287
294
|
id: `${run.runId}:assistant`,
|
|
288
295
|
runId: run.runId,
|
|
289
296
|
role: "assistant",
|
|
290
|
-
text: run.
|
|
297
|
+
text: run.responseText ?? "",
|
|
298
|
+
notice: run.failure ?? "Interrupted by your next message.",
|
|
291
299
|
status: "aborted",
|
|
292
300
|
tools: toolsFrom(run.events),
|
|
293
301
|
sends: sendsFrom(run.events),
|
|
@@ -314,7 +322,8 @@ function assistantMessage(
|
|
|
314
322
|
id: `${run.runId}:assistant`,
|
|
315
323
|
runId: run.runId,
|
|
316
324
|
role: "assistant",
|
|
317
|
-
text: run.
|
|
325
|
+
text: run.responseText ?? "",
|
|
326
|
+
notice: run.failure ?? "Stopped by an authenticated Stop command.",
|
|
318
327
|
status: "aborted",
|
|
319
328
|
tools: toolsFrom(run.events),
|
|
320
329
|
sends: sendsFrom(run.events),
|
|
@@ -1088,14 +1097,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1088
1097
|
function updateModelLabel(): void {
|
|
1089
1098
|
const bot = web.value.botSettings;
|
|
1090
1099
|
const user = web.value.userSettings;
|
|
1091
|
-
if (!
|
|
1100
|
+
if (!user) {
|
|
1092
1101
|
web.value.modelSource = "none";
|
|
1093
1102
|
web.value.modelReady = false;
|
|
1094
1103
|
web.value.modelLabel = modelRuntimeLabel({ source: "none" });
|
|
1095
1104
|
return;
|
|
1096
1105
|
}
|
|
1097
1106
|
const effective = resolveEffectiveBotModelV1({
|
|
1098
|
-
|
|
1107
|
+
// Before the first Bot exists — and in the window before a selected
|
|
1108
|
+
// Bot's settings arrive — the account's own effective model is still
|
|
1109
|
+
// the truth. An empty Bot scope simply declines to override it, so the
|
|
1110
|
+
// shell reports the account model instead of claiming it is unavailable.
|
|
1111
|
+
bot: bot ? toRaw(bot) : { packageValues: {} },
|
|
1099
1112
|
user: toRaw(user),
|
|
1100
1113
|
packages: toRaw(web.value.pluginCatalog).map((pkg) => ({
|
|
1101
1114
|
packageId: pkg.packageId,
|
|
@@ -2292,6 +2305,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2292
2305
|
const observed = web.value.activeRunId;
|
|
2293
2306
|
const supersedes = observed ? { runId: observed } : {};
|
|
2294
2307
|
web.value.activeRunId = pendingRunId;
|
|
2308
|
+
// Stop is offered for the whole of the Turn the User just started, not
|
|
2309
|
+
// only from the moment a projection happens to arrive. A send that
|
|
2310
|
+
// supersedes a running Turn is the exception: the Turn Stop targets is
|
|
2311
|
+
// still the one executing, and this one is queued behind it. The durable
|
|
2312
|
+
// projection corrects both the instant it arrives.
|
|
2313
|
+
if (!observed) web.value.runningRunId = pendingRunId;
|
|
2295
2314
|
web.value.error = undefined;
|
|
2296
2315
|
web.value.messages.push(
|
|
2297
2316
|
{
|
|
@@ -2376,6 +2395,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2376
2395
|
web.value.activeBotId !== botId
|
|
2377
2396
|
)
|
|
2378
2397
|
return { accepted: true, runId: pendingRunId };
|
|
2398
|
+
// A refusal is a normal answer, not an uncertain send: the Bot
|
|
2399
|
+
// declined and said why. Show that, drop the optimistic bubbles, and
|
|
2400
|
+
// let the composer give the person their text back — fencing a run
|
|
2401
|
+
// that was never admitted only threw the reason away.
|
|
2402
|
+
if (error instanceof ClientTurnRefusedErrorV1) {
|
|
2403
|
+
removeMessages(web.value.messages, pendingRunId);
|
|
2404
|
+
web.value.error = error.refusal.error;
|
|
2405
|
+
return { accepted: false, error: error.refusal.error };
|
|
2406
|
+
}
|
|
2379
2407
|
const aborted =
|
|
2380
2408
|
error instanceof DOMException && error.name === "AbortError";
|
|
2381
2409
|
replaceMessage(web.value.messages, pendingRunId, {
|
|
@@ -2431,6 +2459,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2431
2459
|
) {
|
|
2432
2460
|
web.value.activeRunId = undefined;
|
|
2433
2461
|
}
|
|
2462
|
+
// The optimistic Stop target goes with it: a Turn nobody is running is
|
|
2463
|
+
// not a Turn anybody can stop.
|
|
2464
|
+
if (
|
|
2465
|
+
web.value.runningRunId === pendingRunId &&
|
|
2466
|
+
web.value.activeRunId !== pendingRunId
|
|
2467
|
+
) {
|
|
2468
|
+
web.value.runningRunId = undefined;
|
|
2469
|
+
}
|
|
2434
2470
|
}
|
|
2435
2471
|
},
|
|
2436
2472
|
async resumeRun(runId: string): Promise<void> {
|
|
@@ -2602,6 +2638,70 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2602
2638
|
},
|
|
2603
2639
|
);
|
|
2604
2640
|
|
|
2641
|
+
/*
|
|
2642
|
+
* A running Turn reaches every browser that is looking, not only the one
|
|
2643
|
+
* holding its POST.
|
|
2644
|
+
*
|
|
2645
|
+
* The reply to `POST /api/bots/:bot/turns` is one client's copy of a Turn.
|
|
2646
|
+
* A reload, a second tab, or a dropped request has no such copy, so the
|
|
2647
|
+
* transcript would sit on a spinner until somebody reloaded again. Two
|
|
2648
|
+
* seams close that: the Bot's state channel pushes a `runs` invalidation
|
|
2649
|
+
* whenever the durable run records move, and — for any client that has no
|
|
2650
|
+
* socket — the run is polled to its terminal state. Both end in the same
|
|
2651
|
+
* `GET /api/bots/:bot/turns` projection, so neither invents client state
|
|
2652
|
+
* and the one-bubble-per-send contract is untouched.
|
|
2653
|
+
*/
|
|
2654
|
+
let stopRunChannel: (() => void) | undefined;
|
|
2655
|
+
const stopRunChannelWatch = watch(
|
|
2656
|
+
() => web.value.activeBotId,
|
|
2657
|
+
(botId) => {
|
|
2658
|
+
stopRunChannel?.();
|
|
2659
|
+
stopRunChannel = undefined;
|
|
2660
|
+
if (!botId || !ctx.transport.watchBotState) return;
|
|
2661
|
+
const generation = selectionGeneration;
|
|
2662
|
+
stopRunChannel = ctx.transport.watchBotState(botId, {
|
|
2663
|
+
async invalidate(topic) {
|
|
2664
|
+
// A reset carries no topic and means "read everything again".
|
|
2665
|
+
if (topic !== undefined && topic !== "runs") return;
|
|
2666
|
+
if (
|
|
2667
|
+
generation !== selectionGeneration ||
|
|
2668
|
+
web.value.activeBotId !== botId
|
|
2669
|
+
)
|
|
2670
|
+
return;
|
|
2671
|
+
await deliverNotifications(botId, generation);
|
|
2672
|
+
},
|
|
2673
|
+
status() {
|
|
2674
|
+
// The channel's health is not the transcript's: an unavailable
|
|
2675
|
+
// socket falls back to the observation below, which is what a
|
|
2676
|
+
// client without one uses anyway.
|
|
2677
|
+
},
|
|
2678
|
+
});
|
|
2679
|
+
},
|
|
2680
|
+
{ immediate: true },
|
|
2681
|
+
);
|
|
2682
|
+
|
|
2683
|
+
const stopRunObservation = watch(
|
|
2684
|
+
() => [web.value.activeBotId, web.value.activeRunId] as const,
|
|
2685
|
+
([botId, runId]) => {
|
|
2686
|
+
// The send path owns the run it started: its POST is the observation,
|
|
2687
|
+
// and `stopRun` starts its own. This is for every other way a client
|
|
2688
|
+
// finds itself watching a Turn it is not holding open.
|
|
2689
|
+
if (!botId || !runId || activeRequest || runObserver) return;
|
|
2690
|
+
const generation = selectionGeneration;
|
|
2691
|
+
const observer = new AbortController();
|
|
2692
|
+
runObserver = observer;
|
|
2693
|
+
void observeRunUntilTerminal(botId, runId, generation, observer.signal)
|
|
2694
|
+
.catch(() => {
|
|
2695
|
+
// `observeRunUntilTerminal` reports its own failures; a rejection
|
|
2696
|
+
// here would be an unhandled one.
|
|
2697
|
+
})
|
|
2698
|
+
.finally(() => {
|
|
2699
|
+
if (runObserver === observer) runObserver = undefined;
|
|
2700
|
+
});
|
|
2701
|
+
},
|
|
2702
|
+
{ immediate: true },
|
|
2703
|
+
);
|
|
2704
|
+
|
|
2605
2705
|
return [
|
|
2606
2706
|
ctx.provide(clientSurfaceRegistryKey, surfaces),
|
|
2607
2707
|
// The shared client projection is updated by the contracts lane. This cast
|
|
@@ -2620,6 +2720,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2620
2720
|
() => {
|
|
2621
2721
|
stopEntrySync();
|
|
2622
2722
|
stopRunFollow();
|
|
2723
|
+
stopRunChannelWatch();
|
|
2724
|
+
stopRunChannel?.();
|
|
2725
|
+
stopRunObservation();
|
|
2623
2726
|
stopSourceFollow();
|
|
2624
2727
|
for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
|
|
2625
2728
|
activeRequest?.abort();
|
|
@@ -2640,6 +2743,13 @@ function replaceMessage(
|
|
|
2640
2743
|
if (index >= 0) messages[index] = replacement;
|
|
2641
2744
|
}
|
|
2642
2745
|
|
|
2746
|
+
/** Takes back both optimistic lines of a send the Bot never admitted. */
|
|
2747
|
+
function removeMessages(messages: WebChatMessage[], runId: string): void {
|
|
2748
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
2749
|
+
if (messages[index]?.runId === runId) messages.splice(index, 1);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2643
2753
|
export default shellClientPlugin;
|
|
2644
2754
|
|
|
2645
2755
|
/**
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { plugin } from "bun";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
|
|
4
|
+
import type { Ref } from "vue";
|
|
5
|
+
import type { FrockBotWebData } from "../shared.js";
|
|
6
|
+
|
|
7
|
+
// Bun has no single-file-component loader; the shell's Vue modules stand in as
|
|
8
|
+
// empty components, exactly as `index.test.ts` does.
|
|
9
|
+
plugin({
|
|
10
|
+
name: "shell-client-vue-no-bot-loader",
|
|
11
|
+
setup(build) {
|
|
12
|
+
build.onLoad({ filter: /\.vue$/ }, () => ({
|
|
13
|
+
contents: "export default {};",
|
|
14
|
+
loader: "js",
|
|
15
|
+
}));
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const { shellClientPlugin } = await import("./index.js");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A first-run account: the platform model resolves against a ready ambient
|
|
23
|
+
* Flock AI Connection whose Catalog is fresh, and no Bot has been created yet.
|
|
24
|
+
* The account's model is available, so the shell must not tell the User it is
|
|
25
|
+
* unavailable before they have made their first Bot.
|
|
26
|
+
*/
|
|
27
|
+
test("does not report the account model unavailable before a Bot exists", async () => {
|
|
28
|
+
const user: UserSettingsViewV1 = {
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
revision: 3,
|
|
31
|
+
profile: { name: "FrockBot user" },
|
|
32
|
+
packages: [
|
|
33
|
+
{ packageId: "provider-flock-ai", version: "0.0.1", state: "installed" },
|
|
34
|
+
],
|
|
35
|
+
connections: [
|
|
36
|
+
{
|
|
37
|
+
connectionId: "flock-ai-ambient",
|
|
38
|
+
packageId: "provider-flock-ai",
|
|
39
|
+
connectionTypeId: "flock-ai-account",
|
|
40
|
+
displayName: "Flock AI",
|
|
41
|
+
state: "ready",
|
|
42
|
+
providerType: "flock-ai",
|
|
43
|
+
safeMetadata: {},
|
|
44
|
+
modelCatalog: {
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
generation: "flock-ai-static-v1",
|
|
47
|
+
state: "fresh",
|
|
48
|
+
models: [
|
|
49
|
+
{
|
|
50
|
+
providerModelId: "@flock/auto",
|
|
51
|
+
displayName: "Auto (recommended)",
|
|
52
|
+
capabilities: { tools: true, vision: false, reasoning: true },
|
|
53
|
+
source: "discovered",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
platformModel: {
|
|
60
|
+
connectionId: "flock-ai-ambient",
|
|
61
|
+
providerModelId: "@flock/auto",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
66
|
+
await shellClientPlugin({
|
|
67
|
+
transport: {
|
|
68
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
69
|
+
readApplicationManifest: () =>
|
|
70
|
+
Promise.resolve({
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
deployment: { userId: "development", applicationHash: "hash-1" },
|
|
73
|
+
applicationHash: "hash-1",
|
|
74
|
+
packages: [
|
|
75
|
+
{
|
|
76
|
+
id: "provider-flock-ai",
|
|
77
|
+
displayName: "Flock AI",
|
|
78
|
+
version: "0.0.1",
|
|
79
|
+
contributions: ["backend", "runtime"],
|
|
80
|
+
configuration: {
|
|
81
|
+
settings: [],
|
|
82
|
+
connectionTypes: [
|
|
83
|
+
{
|
|
84
|
+
id: "flock-ai-account",
|
|
85
|
+
displayName: "Flock AI",
|
|
86
|
+
allowMultiple: false,
|
|
87
|
+
authorization: { kind: "ambient-native" },
|
|
88
|
+
capabilities: ["flock-ai-models"],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
capabilities: [
|
|
92
|
+
{
|
|
93
|
+
id: "flock-ai-models",
|
|
94
|
+
kind: "model",
|
|
95
|
+
connectionTypes: ["flock-ai-account"],
|
|
96
|
+
admission: { turnTypes: ["chat"] },
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
readConfiguration: () => Promise.resolve(user),
|
|
104
|
+
},
|
|
105
|
+
slot: () => () => {},
|
|
106
|
+
inject: () => {
|
|
107
|
+
throw new Error("unexpected client provider injection");
|
|
108
|
+
},
|
|
109
|
+
provide: (_key, value) => {
|
|
110
|
+
provided = value as Ref<FrockBotWebData>;
|
|
111
|
+
return () => {};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
115
|
+
|
|
116
|
+
await provided.value.loadPluginCatalog();
|
|
117
|
+
|
|
118
|
+
// No Bot has been created, so `activeBotId` is unset.
|
|
119
|
+
expect(provided.value.activeBotId).toBeUndefined();
|
|
120
|
+
expect(provided.value.modelLabel).toBe("Auto (recommended) · Flock AI");
|
|
121
|
+
expect(provided.value.modelReady).toBe(true);
|
|
122
|
+
});
|
package/src/client/styles.css
CHANGED
|
@@ -290,6 +290,17 @@
|
|
|
290
290
|
text-align: center;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/*
|
|
294
|
+
* Why a Turn ends where it does — stopped, or replaced by a later message.
|
|
295
|
+
* Quiet, and under the answer rather than instead of it: the words the Bot
|
|
296
|
+
* already said are the Turn's content, and this is only its last line.
|
|
297
|
+
*/
|
|
298
|
+
.message-notice {
|
|
299
|
+
margin: 2px 0 0;
|
|
300
|
+
color: var(--frock-text-muted);
|
|
301
|
+
font-size: var(--frock-text-xs);
|
|
302
|
+
}
|
|
303
|
+
|
|
293
304
|
/* An assistant Turn is its avatar and, once there is text, its bubble. */
|
|
294
305
|
.message-assistant {
|
|
295
306
|
flex-direction: row;
|
|
@@ -411,6 +422,56 @@
|
|
|
411
422
|
width: min(640px, 84%);
|
|
412
423
|
}
|
|
413
424
|
|
|
425
|
+
/*
|
|
426
|
+
* Tool calls. Quieter than anything the Bot said: the User is watching work
|
|
427
|
+
* happen, not reading a message, so a chip carries the name and its state and
|
|
428
|
+
* opens in place to what the tool returned.
|
|
429
|
+
*/
|
|
430
|
+
|
|
431
|
+
.message-tools {
|
|
432
|
+
display: flex;
|
|
433
|
+
flex-direction: column;
|
|
434
|
+
gap: 6px;
|
|
435
|
+
width: min(640px, 84%);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.tool-chip {
|
|
439
|
+
display: flex;
|
|
440
|
+
flex-wrap: wrap;
|
|
441
|
+
gap: 8px;
|
|
442
|
+
align-items: baseline;
|
|
443
|
+
padding: 6px 10px;
|
|
444
|
+
font: inherit;
|
|
445
|
+
color: var(--frock-text-muted);
|
|
446
|
+
text-align: left;
|
|
447
|
+
cursor: pointer;
|
|
448
|
+
background: var(--frock-surface);
|
|
449
|
+
border: 1px solid var(--frock-border);
|
|
450
|
+
border-radius: 10px;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.tool-chip-name {
|
|
454
|
+
color: var(--frock-text);
|
|
455
|
+
font-weight: 600;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.tool-chip-status {
|
|
459
|
+
flex: 1 1 auto;
|
|
460
|
+
font-size: var(--frock-text-xs);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.tool-chip-failed .tool-chip-status {
|
|
464
|
+
color: var(--frock-danger-text);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.tool-chip-result {
|
|
468
|
+
flex: 1 0 100%;
|
|
469
|
+
max-height: 12em;
|
|
470
|
+
overflow: auto;
|
|
471
|
+
white-space: pre-wrap;
|
|
472
|
+
word-break: break-word;
|
|
473
|
+
}
|
|
474
|
+
|
|
414
475
|
/*
|
|
415
476
|
* Dispatched subagents. A chip is deliberately quiet — a subagent is work the
|
|
416
477
|
* Bot delegated, not something it said — and opens in place to the summary the
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
projectClientRunLookupV1,
|
|
23
23
|
projectClientRunListV1,
|
|
24
24
|
projectClientRunV1,
|
|
25
|
+
projectClientRunOrDegradedV1,
|
|
25
26
|
projectClientTurnV1,
|
|
26
27
|
} from "./run-protocol.js";
|
|
27
28
|
|
|
@@ -1357,6 +1358,78 @@ describe("dispatched subagents in the run projection", () => {
|
|
|
1357
1358
|
expect(() => decodeClientRunPageV1(tampered)).toThrow();
|
|
1358
1359
|
});
|
|
1359
1360
|
|
|
1361
|
+
test("a run whose record cannot be read degrades instead of failing the list", () => {
|
|
1362
|
+
// One badly written record — a resolve that wrote a shape the record does
|
|
1363
|
+
// not allow — used to answer 500 for the whole transcript, for good.
|
|
1364
|
+
const broken = {
|
|
1365
|
+
...storedRun([], "running"),
|
|
1366
|
+
status: "reconciliation-required",
|
|
1367
|
+
phase: "executing",
|
|
1368
|
+
} as unknown as StoredRun;
|
|
1369
|
+
expect(() => projectClientRunV1(broken)).toThrow();
|
|
1370
|
+
|
|
1371
|
+
const degraded = projectClientRunOrDegradedV1(broken);
|
|
1372
|
+
expect(degraded).toMatchObject({
|
|
1373
|
+
runId: "run-events",
|
|
1374
|
+
status: "failed",
|
|
1375
|
+
outcome: { type: "failed" },
|
|
1376
|
+
});
|
|
1377
|
+
// And the degraded row is itself a valid projection, so the page decodes.
|
|
1378
|
+
expect(
|
|
1379
|
+
decodeClientRunPageV1(
|
|
1380
|
+
createClientRunListV1([degraded], { truncated: false }),
|
|
1381
|
+
).runs,
|
|
1382
|
+
).toHaveLength(1);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
test("an interrupted Turn keeps the text it had already streamed", () => {
|
|
1386
|
+
const streamed: SessionEvent[] = [
|
|
1387
|
+
event({
|
|
1388
|
+
type: "assistant/chunk",
|
|
1389
|
+
seq: 0,
|
|
1390
|
+
timestamp,
|
|
1391
|
+
turn: 1,
|
|
1392
|
+
step: 1,
|
|
1393
|
+
requestId: "request-1",
|
|
1394
|
+
text: "The three things to know are",
|
|
1395
|
+
}),
|
|
1396
|
+
event({
|
|
1397
|
+
type: "assistant/chunk",
|
|
1398
|
+
seq: 1,
|
|
1399
|
+
timestamp,
|
|
1400
|
+
turn: 1,
|
|
1401
|
+
step: 1,
|
|
1402
|
+
requestId: "request-1",
|
|
1403
|
+
text: " first, that",
|
|
1404
|
+
}),
|
|
1405
|
+
];
|
|
1406
|
+
|
|
1407
|
+
for (const status of ["cancelled", "superseded"] as const) {
|
|
1408
|
+
const projected = projectClientRunV1({
|
|
1409
|
+
...storedRun(streamed, status),
|
|
1410
|
+
...(status === "superseded"
|
|
1411
|
+
? {
|
|
1412
|
+
supersededAt: "2026-08-28T00:00:05.000Z",
|
|
1413
|
+
supersededBy: "run-next",
|
|
1414
|
+
}
|
|
1415
|
+
: {}),
|
|
1416
|
+
});
|
|
1417
|
+
expect(projected.outcome).toMatchObject({
|
|
1418
|
+
type: status,
|
|
1419
|
+
text: "The three things to know are first, that",
|
|
1420
|
+
});
|
|
1421
|
+
// And it survives the wire: the client reads it as the Turn's text, with
|
|
1422
|
+
// the notice kept separately as the line that says why it stops there.
|
|
1423
|
+
const decoded = decodeClientRunPageV1(
|
|
1424
|
+
createClientRunListV1([projected], { truncated: false }),
|
|
1425
|
+
).runs[0];
|
|
1426
|
+
expect(decoded?.responseText).toBe(
|
|
1427
|
+
"The three things to know are first, that",
|
|
1428
|
+
);
|
|
1429
|
+
expect(decoded?.failure).toBeDefined();
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1360
1433
|
test("refuses a chip whose background flag is not a boolean", () => {
|
|
1361
1434
|
const page = createClientRunListV1(
|
|
1362
1435
|
[projectClientRunV1(storedRun([dispatched]))],
|
package/src/run-protocol.ts
CHANGED
|
@@ -52,6 +52,63 @@ export type ClientRunStatusV1 =
|
|
|
52
52
|
const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
|
|
53
53
|
const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Why the Bot declined to admit a Turn. A refusal is an ordinary answer — the
|
|
57
|
+
* Bot is busy with a Turn this command did not ask to replace, is holding an
|
|
58
|
+
* effect only a User can settle, or the command was fenced or already used —
|
|
59
|
+
* so the client shows the reason and keeps the person's text rather than
|
|
60
|
+
* treating it as a failure of the send.
|
|
61
|
+
*/
|
|
62
|
+
export type ClientTurnRefusalReasonV1 =
|
|
63
|
+
"busy" | "reconciliation-required" | "fenced" | "duplicate";
|
|
64
|
+
|
|
65
|
+
/** The versioned body a refused Turn answers with, decoded by the client. */
|
|
66
|
+
export interface ClientTurnRefusalV1 {
|
|
67
|
+
schemaVersion: 1;
|
|
68
|
+
status: "refused";
|
|
69
|
+
reason: ClientTurnRefusalReasonV1;
|
|
70
|
+
error: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const TURN_REFUSAL_REASONS_V1: readonly ClientTurnRefusalReasonV1[] = [
|
|
74
|
+
"busy",
|
|
75
|
+
"reconciliation-required",
|
|
76
|
+
"fenced",
|
|
77
|
+
"duplicate",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** The refusal a response body carries, or `undefined` when it carries none. */
|
|
81
|
+
export function decodeClientTurnRefusalV1(
|
|
82
|
+
value: unknown,
|
|
83
|
+
): ClientTurnRefusalV1 | undefined {
|
|
84
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
85
|
+
const body = value as Record<string, unknown>;
|
|
86
|
+
if (body.schemaVersion !== 1 || body.status !== "refused") return undefined;
|
|
87
|
+
if (typeof body.error !== "string") return undefined;
|
|
88
|
+
const reason = TURN_REFUSAL_REASONS_V1.find(
|
|
89
|
+
(candidate) => candidate === body.reason,
|
|
90
|
+
);
|
|
91
|
+
if (!reason) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
schemaVersion: 1,
|
|
94
|
+
status: "refused",
|
|
95
|
+
reason,
|
|
96
|
+
error: wireString(body, "error", MAX_FAILURE_BYTES, "turn refusal"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A refusal, as an error, because that is how a transport reports a non-2xx.
|
|
102
|
+
* The reason survives on the error so the client can tell "the Bot said no"
|
|
103
|
+
* from "the send may or may not have happened".
|
|
104
|
+
*/
|
|
105
|
+
export class ClientTurnRefusedErrorV1 extends Error {
|
|
106
|
+
constructor(readonly refusal: ClientTurnRefusalV1) {
|
|
107
|
+
super(refusal.error);
|
|
108
|
+
this.name = "ClientTurnRefusedErrorV1";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
55
112
|
export type ClientRunEventV1 =
|
|
56
113
|
| {
|
|
57
114
|
type: "run/events-truncated";
|
|
@@ -123,8 +180,13 @@ export interface ClientDynamicToolCallInputV1 {
|
|
|
123
180
|
export type ClientRunOutcomeV1 =
|
|
124
181
|
| { type: "completed"; text: string }
|
|
125
182
|
| { type: "failed"; message: string }
|
|
126
|
-
|
|
127
|
-
|
|
183
|
+
/**
|
|
184
|
+
* A Turn a Stop or a later message ended keeps what it had already said:
|
|
185
|
+
* `text` is that partial answer, and `message` is the line saying why it
|
|
186
|
+
* ends where it does (ADR 0024).
|
|
187
|
+
*/
|
|
188
|
+
| { type: "cancelled"; message: string; text?: string }
|
|
189
|
+
| { type: "superseded"; message: string; text?: string };
|
|
128
190
|
|
|
129
191
|
export interface ClientRunRecoveryV1 {
|
|
130
192
|
action: "resume";
|
|
@@ -565,6 +627,33 @@ function visibleEvents(
|
|
|
565
627
|
return projection;
|
|
566
628
|
}
|
|
567
629
|
|
|
630
|
+
/**
|
|
631
|
+
* What an interrupted Turn had already said, read back out of its journal.
|
|
632
|
+
*
|
|
633
|
+
* The kernel records a Turn's answer as it streams, so a Turn stopped or
|
|
634
|
+
* superseded mid-sentence still holds every word it sent. It never reached a
|
|
635
|
+
* `responseText`, because it never completed — but the partial answer is a
|
|
636
|
+
* fact about what the person watched arrive, not a claim that the Turn
|
|
637
|
+
* succeeded, and the thread keeps it instead of replacing it with a notice.
|
|
638
|
+
*/
|
|
639
|
+
function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
|
|
640
|
+
let requestId: string | undefined;
|
|
641
|
+
let text = run.responseText ?? "";
|
|
642
|
+
for (const event of run.events) {
|
|
643
|
+
if (event.type === "assistant/chunk") {
|
|
644
|
+
if (event.requestId !== requestId) {
|
|
645
|
+
requestId = event.requestId;
|
|
646
|
+
text = "";
|
|
647
|
+
}
|
|
648
|
+
text += event.text;
|
|
649
|
+
} else if (event.type === "assistant/message") {
|
|
650
|
+
requestId = event.requestId;
|
|
651
|
+
text = event.text;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
|
|
655
|
+
}
|
|
656
|
+
|
|
568
657
|
function runStatus(run: StoredRun): ClientRunStatusV1 {
|
|
569
658
|
return requireStoredRunV1(run).status;
|
|
570
659
|
}
|
|
@@ -589,11 +678,13 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
589
678
|
? ({
|
|
590
679
|
type: "cancelled",
|
|
591
680
|
message: CANCELLED_RUN_MESSAGE,
|
|
681
|
+
...interruptedOutcomeTextV1(run),
|
|
592
682
|
} satisfies ClientRunOutcomeV1)
|
|
593
683
|
: status === "superseded"
|
|
594
684
|
? ({
|
|
595
685
|
type: "superseded",
|
|
596
686
|
message: SUPERSEDED_RUN_MESSAGE,
|
|
687
|
+
...interruptedOutcomeTextV1(run),
|
|
597
688
|
} satisfies ClientRunOutcomeV1)
|
|
598
689
|
: undefined;
|
|
599
690
|
const recovery =
|
|
@@ -695,6 +786,39 @@ export function projectClientTurnV1(result: BotTurnCompletion): ClientTurnV1 {
|
|
|
695
786
|
};
|
|
696
787
|
}
|
|
697
788
|
|
|
789
|
+
/**
|
|
790
|
+
* One stored run on the wire, degraded rather than thrown when the record
|
|
791
|
+
* cannot be read.
|
|
792
|
+
*
|
|
793
|
+
* A single unreadable run — an older shape, or one a bug wrote badly — used to
|
|
794
|
+
* fail the whole transcript: `GET /turns` answered 500 for every request after
|
|
795
|
+
* it, and the person's entire conversation disappeared behind one bad row. The
|
|
796
|
+
* transcript keeps its shape and says which Turn it could not read.
|
|
797
|
+
*/
|
|
798
|
+
export function projectClientRunOrDegradedV1(run: StoredRun): ClientRunV1 {
|
|
799
|
+
try {
|
|
800
|
+
return projectClientRunV1(run);
|
|
801
|
+
} catch {
|
|
802
|
+
const admittedAt =
|
|
803
|
+
typeof run.acceptedAt === "string" &&
|
|
804
|
+
Number.isFinite(Date.parse(run.acceptedAt))
|
|
805
|
+
? run.acceptedAt
|
|
806
|
+
: new Date(0).toISOString();
|
|
807
|
+
return {
|
|
808
|
+
schemaVersion: 2,
|
|
809
|
+
runId: truncate(String(run.runId ?? "unknown"), MAX_RUN_ID_LENGTH),
|
|
810
|
+
admittedAt,
|
|
811
|
+
input: typeof run.input === "string" ? run.input : "",
|
|
812
|
+
status: "failed",
|
|
813
|
+
events: [],
|
|
814
|
+
outcome: {
|
|
815
|
+
type: "failed",
|
|
816
|
+
message: "This Turn's record could not be read.",
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
698
822
|
export function projectClientRunListV1(
|
|
699
823
|
runs: readonly StoredRun[],
|
|
700
824
|
): ClientRunListV1 {
|
|
@@ -1004,22 +1128,34 @@ function decodeOutcome(
|
|
|
1004
1128
|
};
|
|
1005
1129
|
}
|
|
1006
1130
|
if (outcome.type === "cancelled" && runStatus === "cancelled") {
|
|
1007
|
-
exactKeys(outcome, ["type", "message"], "run.outcome");
|
|
1131
|
+
exactKeys(outcome, ["type", "message", "text"], "run.outcome");
|
|
1008
1132
|
return {
|
|
1009
1133
|
type: "cancelled",
|
|
1010
1134
|
message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
|
|
1135
|
+
...decodeInterruptedTextV1(outcome),
|
|
1011
1136
|
};
|
|
1012
1137
|
}
|
|
1013
1138
|
if (outcome.type === "superseded" && runStatus === "superseded") {
|
|
1014
|
-
exactKeys(outcome, ["type", "message"], "run.outcome");
|
|
1139
|
+
exactKeys(outcome, ["type", "message", "text"], "run.outcome");
|
|
1015
1140
|
return {
|
|
1016
1141
|
type: "superseded",
|
|
1017
1142
|
message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
|
|
1143
|
+
...decodeInterruptedTextV1(outcome),
|
|
1018
1144
|
};
|
|
1019
1145
|
}
|
|
1020
1146
|
throw new Error("run.outcome does not match run.status");
|
|
1021
1147
|
}
|
|
1022
1148
|
|
|
1149
|
+
/** The partial answer an interrupted Turn kept, when it said anything. */
|
|
1150
|
+
function decodeInterruptedTextV1(outcome: Record<string, unknown>): {
|
|
1151
|
+
text?: string;
|
|
1152
|
+
} {
|
|
1153
|
+
if (outcome.text === undefined) return {};
|
|
1154
|
+
return {
|
|
1155
|
+
text: wireString(outcome, "text", MAX_OUTCOME_BYTES, "run.outcome"),
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1023
1159
|
function decodeRecovery(
|
|
1024
1160
|
value: unknown,
|
|
1025
1161
|
runStatus: ClientRunStatusV1,
|
|
@@ -1114,8 +1250,12 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1114
1250
|
...(run.queued === true ? { queued: true as const } : {}),
|
|
1115
1251
|
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
1116
1252
|
...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
|
|
1117
|
-
...(outcome?.type === "cancelled"
|
|
1118
|
-
|
|
1253
|
+
...(outcome?.type === "cancelled" || outcome?.type === "superseded"
|
|
1254
|
+
? {
|
|
1255
|
+
failure: outcome.message,
|
|
1256
|
+
...(outcome.text ? { responseText: outcome.text } : {}),
|
|
1257
|
+
}
|
|
1258
|
+
: {}),
|
|
1119
1259
|
...(recovery ? { failure: recovery.message, recovery } : {}),
|
|
1120
1260
|
};
|
|
1121
1261
|
}
|
package/src/shared.ts
CHANGED
|
@@ -101,6 +101,12 @@ export interface WebChatMessage {
|
|
|
101
101
|
* reached, not a state the User has to understand.
|
|
102
102
|
*/
|
|
103
103
|
pending?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* A line under the bubble saying why the Turn ends where it does — it was
|
|
106
|
+
* stopped, or a later message took its place. The text above it is what the
|
|
107
|
+
* Bot had already said, which it keeps (ADR 0024).
|
|
108
|
+
*/
|
|
109
|
+
notice?: string;
|
|
104
110
|
tools: WebToolActivity[];
|
|
105
111
|
/** The typed payloads this Turn sent to the user, oldest first. */
|
|
106
112
|
sends: WebSendPayload[];
|