@frockbot/plugin-shell 0.3.4 → 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 +61 -0
- package/src/client/index.test.ts +12 -5
- package/src/client/index.ts +110 -4
- 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 },
|
|
@@ -405,6 +405,32 @@ function taskChipsOf(message: WebChatMessage): Array<{
|
|
|
405
405
|
});
|
|
406
406
|
}
|
|
407
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
|
+
|
|
408
434
|
/** Which chips the User has opened. Local, and per chip. */
|
|
409
435
|
const expandedTasks = ref(new Set<string>());
|
|
410
436
|
|
|
@@ -431,6 +457,7 @@ function isVisible(message: WebChatMessage): boolean {
|
|
|
431
457
|
// visible act was dispatching a subagent.
|
|
432
458
|
return (
|
|
433
459
|
message.text.length > 0 ||
|
|
460
|
+
(message.notice?.length ?? 0) > 0 ||
|
|
434
461
|
message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
|
|
435
462
|
message.sends.length > 0 ||
|
|
436
463
|
(message.tasks?.length ?? 0) > 0 ||
|
|
@@ -859,6 +886,13 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
859
886
|
<div v-if="message.text" class="message-bubble">
|
|
860
887
|
<UiMarkdown :text="message.text" />
|
|
861
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>
|
|
862
896
|
<template v-for="tool in message.tools" :key="tool.id">
|
|
863
897
|
<PackageIframeHost
|
|
864
898
|
v-for="entry in iframeEntriesFor(tool)"
|
|
@@ -906,6 +940,33 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
906
940
|
:send="send"
|
|
907
941
|
/>
|
|
908
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>
|
|
909
970
|
<!--
|
|
910
971
|
The subagents this Turn dispatched. The child's own Session is
|
|
911
972
|
never in this transcript, so the chip is the whole of what the
|
package/src/client/index.test.ts
CHANGED
|
@@ -1847,14 +1847,18 @@ describe("hosted Stop", () => {
|
|
|
1847
1847
|
status: "reconciliation-required",
|
|
1848
1848
|
message:
|
|
1849
1849
|
"Stop accepted; reconciling the provider outcome before cancelling.",
|
|
1850
|
-
|
|
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,
|
|
1851
1853
|
});
|
|
1852
1854
|
|
|
1853
1855
|
await provided.value.stopRun();
|
|
1854
1856
|
expect(provided.value.activeRun).toBeUndefined();
|
|
1855
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.
|
|
1856
1860
|
expect(provided.value.messages[1]).toMatchObject({
|
|
1857
|
-
|
|
1861
|
+
notice: "Stopped by an authenticated Stop command.",
|
|
1858
1862
|
status: "aborted",
|
|
1859
1863
|
});
|
|
1860
1864
|
|
|
@@ -1911,12 +1915,15 @@ describe("hosted Stop", () => {
|
|
|
1911
1915
|
|
|
1912
1916
|
await provided.value.stopRun();
|
|
1913
1917
|
|
|
1914
|
-
|
|
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);
|
|
1915
1922
|
expect(provided.value.activeRun).toBeUndefined();
|
|
1916
1923
|
expect(provided.value.activeRunId).toBeUndefined();
|
|
1917
1924
|
expect(provided.value.messages.at(-1)).toMatchObject({
|
|
1918
1925
|
runId: "run-1",
|
|
1919
|
-
|
|
1926
|
+
notice: "Stopped by an authenticated Stop command.",
|
|
1920
1927
|
status: "aborted",
|
|
1921
1928
|
});
|
|
1922
1929
|
});
|
|
@@ -2897,7 +2904,7 @@ describe("a message sent while a Turn is running", () => {
|
|
|
2897
2904
|
// The superseded Turn keeps the quiet treatment a stopped one gets.
|
|
2898
2905
|
expect(state.messages[1]).toMatchObject({
|
|
2899
2906
|
status: "aborted",
|
|
2900
|
-
|
|
2907
|
+
notice: "Interrupted by your next message.",
|
|
2901
2908
|
});
|
|
2902
2909
|
});
|
|
2903
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),
|
|
@@ -2296,6 +2305,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2296
2305
|
const observed = web.value.activeRunId;
|
|
2297
2306
|
const supersedes = observed ? { runId: observed } : {};
|
|
2298
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;
|
|
2299
2314
|
web.value.error = undefined;
|
|
2300
2315
|
web.value.messages.push(
|
|
2301
2316
|
{
|
|
@@ -2380,6 +2395,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2380
2395
|
web.value.activeBotId !== botId
|
|
2381
2396
|
)
|
|
2382
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
|
+
}
|
|
2383
2407
|
const aborted =
|
|
2384
2408
|
error instanceof DOMException && error.name === "AbortError";
|
|
2385
2409
|
replaceMessage(web.value.messages, pendingRunId, {
|
|
@@ -2435,6 +2459,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2435
2459
|
) {
|
|
2436
2460
|
web.value.activeRunId = undefined;
|
|
2437
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
|
+
}
|
|
2438
2470
|
}
|
|
2439
2471
|
},
|
|
2440
2472
|
async resumeRun(runId: string): Promise<void> {
|
|
@@ -2606,6 +2638,70 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2606
2638
|
},
|
|
2607
2639
|
);
|
|
2608
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
|
+
|
|
2609
2705
|
return [
|
|
2610
2706
|
ctx.provide(clientSurfaceRegistryKey, surfaces),
|
|
2611
2707
|
// The shared client projection is updated by the contracts lane. This cast
|
|
@@ -2624,6 +2720,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2624
2720
|
() => {
|
|
2625
2721
|
stopEntrySync();
|
|
2626
2722
|
stopRunFollow();
|
|
2723
|
+
stopRunChannelWatch();
|
|
2724
|
+
stopRunChannel?.();
|
|
2725
|
+
stopRunObservation();
|
|
2627
2726
|
stopSourceFollow();
|
|
2628
2727
|
for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
|
|
2629
2728
|
activeRequest?.abort();
|
|
@@ -2644,6 +2743,13 @@ function replaceMessage(
|
|
|
2644
2743
|
if (index >= 0) messages[index] = replacement;
|
|
2645
2744
|
}
|
|
2646
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
|
+
|
|
2647
2753
|
export default shellClientPlugin;
|
|
2648
2754
|
|
|
2649
2755
|
/**
|
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[];
|