@frockbot/plugin-shell 0.3.7 → 0.3.9
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-routines.ts +4 -0
- package/src/backend-subagent-blocking.test.ts +222 -0
- package/src/backend.ts +110 -19
- package/src/client/FrockBotApp.vue +53 -1
- package/src/client/index.test.ts +205 -0
- package/src/client/index.ts +80 -8
- package/src/client/styles.css +34 -0
- package/src/client/turn-limits.test.ts +41 -0
- package/src/client/turn-limits.ts +40 -0
- package/src/client/uncertain-admission.test.ts +69 -0
- package/src/client/uncertain-admission.ts +78 -0
- package/src/debug-protocol.test.ts +38 -2
- package/src/debug-protocol.ts +39 -6
- package/src/run-protocol.test.ts +130 -3
- package/src/run-protocol.ts +86 -18
- package/src/shared.ts +7 -0
- package/src/unread.test.ts +95 -0
- package/src/unread.ts +62 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
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.9",
|
|
51
|
+
"@frockbot/application-foundation": "0.3.9",
|
|
52
|
+
"@frockbot/catalog-core": "0.3.9",
|
|
53
|
+
"@frockbot/client-core": "0.3.9",
|
|
54
|
+
"@frockbot/client-ui": "0.3.9",
|
|
55
|
+
"@frockbot/computer-core": "0.3.9",
|
|
56
|
+
"@frockbot/computer-host-protocol": "0.3.9",
|
|
57
|
+
"@frockbot/configuration-core": "0.3.9",
|
|
58
|
+
"@frockbot/connection-core": "0.3.9",
|
|
59
|
+
"@frockbot/kernel-agent-loop": "0.3.9",
|
|
60
|
+
"@frockbot/kernel-composition": "0.3.9",
|
|
61
|
+
"@frockbot/kernel-contracts": "0.3.9",
|
|
62
|
+
"@frockbot/kernel-do": "0.3.9",
|
|
63
|
+
"@frockbot/machine-protocol": "0.3.9",
|
|
64
|
+
"@frockbot/plugin-applets": "0.3.9",
|
|
65
|
+
"@frockbot/plugin-authoring": "0.3.9",
|
|
66
|
+
"@frockbot/plugin-bot-template": "0.3.9",
|
|
67
|
+
"@frockbot/plugin-computer": "0.3.9",
|
|
68
|
+
"@frockbot/plugin-flock": "0.3.9",
|
|
69
|
+
"@frockbot/plugin-image": "0.3.9",
|
|
70
|
+
"@frockbot/plugin-machine-messages": "0.3.9",
|
|
71
|
+
"@frockbot/plugin-mcp": "0.3.9",
|
|
72
|
+
"@frockbot/plugin-memory": "0.3.9",
|
|
73
|
+
"@frockbot/plugin-package-catalog": "0.3.9",
|
|
74
|
+
"@frockbot/plugin-routines": "0.3.9",
|
|
75
|
+
"@frockbot/plugin-skills": "0.3.9",
|
|
76
|
+
"@frockbot/plugin-subagents": "0.3.9",
|
|
77
|
+
"@frockbot/plugin-user-machine": "0.3.9",
|
|
78
|
+
"@frockbot/protocol": "0.3.9",
|
|
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.9",
|
|
85
85
|
"@types/bun": "1.3.6",
|
|
86
86
|
"@types/node": "26.2.0",
|
|
87
87
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/backend-routines.ts
CHANGED
|
@@ -301,6 +301,10 @@ export function routineInboxEntryViewV1(
|
|
|
301
301
|
...(entry.acknowledgedAt === undefined
|
|
302
302
|
? {}
|
|
303
303
|
: { acknowledgedAt: entry.acknowledgedAt }),
|
|
304
|
+
...(entry.repeatCount === undefined
|
|
305
|
+
? {}
|
|
306
|
+
: { repeatCount: entry.repeatCount }),
|
|
307
|
+
...(entry.failure === undefined ? {} : { failure: entry.failure }),
|
|
304
308
|
};
|
|
305
309
|
}
|
|
306
310
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The foreground `Task` wait (memory-v2 F4).
|
|
2
|
+
//
|
|
3
|
+
// A `Task {background:false}` that answered "dispatched" the instant anything
|
|
4
|
+
// went wrong read to the model as a call that had finished with no output. It
|
|
5
|
+
// then spent two more steps on `task_check` and `task_resume`. The wait must
|
|
6
|
+
// hold until the child settles, and give up only when the record is really
|
|
7
|
+
// unreadable.
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
|
|
10
|
+
import type {
|
|
11
|
+
TaskOutcomeV1,
|
|
12
|
+
TaskRecordV1,
|
|
13
|
+
} from "@frockbot/plugin-subagents/records";
|
|
14
|
+
import { taskKeyV1 } from "@frockbot/plugin-subagents/storage-keys";
|
|
15
|
+
import type { ShellBotBackendHost } from "./backend.js";
|
|
16
|
+
|
|
17
|
+
const identity = { userId: "user-1", botId: "primary" };
|
|
18
|
+
const TASK_ID = "task-1";
|
|
19
|
+
|
|
20
|
+
class MemoryStorage {
|
|
21
|
+
readonly values = new Map<string, unknown>();
|
|
22
|
+
/** Reads to fail before the first success, to stand in for contention. */
|
|
23
|
+
failReads = 0;
|
|
24
|
+
reads = 0;
|
|
25
|
+
|
|
26
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
27
|
+
this.reads += 1;
|
|
28
|
+
if (this.failReads > 0) {
|
|
29
|
+
this.failReads -= 1;
|
|
30
|
+
return Promise.reject(new Error("storage is busy"));
|
|
31
|
+
}
|
|
32
|
+
return Promise.resolve(
|
|
33
|
+
structuredClone(this.values.get(key)) as T | undefined,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
38
|
+
if (typeof key === "string") this.values.set(key, structuredClone(value));
|
|
39
|
+
else {
|
|
40
|
+
for (const [entry, item] of Object.entries(key)) {
|
|
41
|
+
this.values.set(entry, structuredClone(item));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
delete(key: string): Promise<boolean> {
|
|
48
|
+
return Promise.resolve(this.values.delete(key));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
|
|
52
|
+
return Promise.resolve(
|
|
53
|
+
new Map(
|
|
54
|
+
[...this.values.entries()].filter(([key]) =>
|
|
55
|
+
key.startsWith(options.prefix ?? ""),
|
|
56
|
+
) as Array<[string, T]>,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
|
|
62
|
+
return callback(this);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setAlarm(): Promise<void> {
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
deleteAlarm(): Promise<void> {
|
|
70
|
+
return Promise.resolve();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const user: UserSettingsViewV1 = {
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
revision: 0,
|
|
77
|
+
profile: { name: "User" },
|
|
78
|
+
packages: [],
|
|
79
|
+
connections: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function host(storage: MemoryStorage): ShellBotBackendHost {
|
|
83
|
+
return {
|
|
84
|
+
state: { storage } as unknown as DurableObjectState,
|
|
85
|
+
env: {
|
|
86
|
+
USER_CONFIGURATIONS: {
|
|
87
|
+
idFromName: () => "user-id",
|
|
88
|
+
get: () => ({ readConfiguration: () => Promise.resolve(user) }),
|
|
89
|
+
},
|
|
90
|
+
} as unknown as ShellBotBackendHost["env"],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface Waiting {
|
|
95
|
+
sleeps: number;
|
|
96
|
+
materializeSettings(
|
|
97
|
+
identity: { userId: string; botId: string },
|
|
98
|
+
profile: { name: string },
|
|
99
|
+
): Promise<unknown>;
|
|
100
|
+
wait(taskId: string): Promise<TaskOutcomeV1 | undefined>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Exposes the wait and removes its sleep, which is the whole point of it.
|
|
105
|
+
*
|
|
106
|
+
* The class is reached through a dynamic import rather than a top-level one:
|
|
107
|
+
* the Shell's backend and the foundation application import each other, and a
|
|
108
|
+
* static import from a test module reads the class while it is still
|
|
109
|
+
* initializing.
|
|
110
|
+
*/
|
|
111
|
+
async function waiting(host: ShellBotBackendHost): Promise<Waiting> {
|
|
112
|
+
// The application first: it is the half of the cycle that has to finish
|
|
113
|
+
// initializing before the Shell's class is readable.
|
|
114
|
+
await import("@frockbot/application-foundation/contributions");
|
|
115
|
+
const { ShellBotBackendContribution } = await import("./backend.js");
|
|
116
|
+
return new (class extends ShellBotBackendContribution {
|
|
117
|
+
sleeps = 0;
|
|
118
|
+
|
|
119
|
+
protected override sleep(): Promise<void> {
|
|
120
|
+
this.sleeps += 1;
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
wait(taskId: string): Promise<TaskOutcomeV1 | undefined> {
|
|
125
|
+
return this.awaitBlockingTask(identity, taskId, taskId);
|
|
126
|
+
}
|
|
127
|
+
})(host) as unknown as Waiting;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function record(overrides: Partial<TaskRecordV1> = {}): TaskRecordV1 {
|
|
131
|
+
return {
|
|
132
|
+
schemaVersion: 1,
|
|
133
|
+
taskId: TASK_ID,
|
|
134
|
+
type: "executor",
|
|
135
|
+
description: "read it",
|
|
136
|
+
promptDigest: "sha256:digest",
|
|
137
|
+
model: {
|
|
138
|
+
binding: {
|
|
139
|
+
packageId: "models",
|
|
140
|
+
capabilityId: "llm",
|
|
141
|
+
connectionId: "connection-1",
|
|
142
|
+
provider: "foundation",
|
|
143
|
+
providerModelId: "foundation-model",
|
|
144
|
+
},
|
|
145
|
+
slug: "foundation-model",
|
|
146
|
+
},
|
|
147
|
+
compositionGenerationId: "generation-1",
|
|
148
|
+
background: false,
|
|
149
|
+
depth: 1,
|
|
150
|
+
status: "running",
|
|
151
|
+
dispatch: {
|
|
152
|
+
runId: "run-1",
|
|
153
|
+
turnId: "turn-1",
|
|
154
|
+
sessionId: "user-1:primary",
|
|
155
|
+
},
|
|
156
|
+
childSessionId: `task:${TASK_ID}`,
|
|
157
|
+
attachments: [],
|
|
158
|
+
createdAt: "2026-09-03T00:00:00.000Z",
|
|
159
|
+
deadlineAt: "2026-09-03T00:30:00.000Z",
|
|
160
|
+
...overrides,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function fixture(
|
|
165
|
+
stored: TaskRecordV1,
|
|
166
|
+
): Promise<{ storage: MemoryStorage; contribution: Waiting }> {
|
|
167
|
+
const storage = new MemoryStorage();
|
|
168
|
+
const contribution = await waiting(host(storage));
|
|
169
|
+
await contribution.materializeSettings(identity, { name: "Primary" });
|
|
170
|
+
storage.values.set(taskKeyV1(stored.taskId), structuredClone(stored));
|
|
171
|
+
return { storage, contribution };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
describe("the foreground Task wait", () => {
|
|
175
|
+
test("answers with the child's settled outcome", async () => {
|
|
176
|
+
const { contribution } = await fixture(
|
|
177
|
+
record({
|
|
178
|
+
status: "completed",
|
|
179
|
+
outcome: {
|
|
180
|
+
status: "completed",
|
|
181
|
+
settledAt: "2026-09-03T00:00:10.000Z",
|
|
182
|
+
summary: "The root has six entries.",
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(await contribution.wait(TASK_ID)).toMatchObject({
|
|
188
|
+
status: "completed",
|
|
189
|
+
summary: "The root has six entries.",
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The regression: one failed read used to end the wait, and the Task tool
|
|
194
|
+
// then told the model the child was still running — for a child that had
|
|
195
|
+
// already settled.
|
|
196
|
+
test("rides out a transient read failure instead of giving up", async () => {
|
|
197
|
+
const { storage, contribution } = await fixture(
|
|
198
|
+
record({
|
|
199
|
+
status: "completed",
|
|
200
|
+
outcome: {
|
|
201
|
+
status: "completed",
|
|
202
|
+
settledAt: "2026-09-03T00:00:10.000Z",
|
|
203
|
+
summary: "The root has six entries.",
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
storage.failReads = 2;
|
|
208
|
+
|
|
209
|
+
expect(await contribution.wait(TASK_ID)).toMatchObject({
|
|
210
|
+
status: "completed",
|
|
211
|
+
summary: "The root has six entries.",
|
|
212
|
+
});
|
|
213
|
+
expect(contribution.sleeps).toBeGreaterThan(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("gives up when the record stays unreadable", async () => {
|
|
217
|
+
const { storage, contribution } = await fixture(record());
|
|
218
|
+
storage.failReads = Number.MAX_SAFE_INTEGER;
|
|
219
|
+
|
|
220
|
+
expect(await contribution.wait(TASK_ID)).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
package/src/backend.ts
CHANGED
|
@@ -214,6 +214,12 @@ import {
|
|
|
214
214
|
} from "./backend-routines.js";
|
|
215
215
|
import { RoutineInboxStore } from "@frockbot/plugin-routines/inbox-store";
|
|
216
216
|
import {
|
|
217
|
+
ROUTINE_INBOX_LIMIT,
|
|
218
|
+
ROUTINE_INBOX_PREFIX,
|
|
219
|
+
} from "@frockbot/plugin-routines/storage-keys";
|
|
220
|
+
import {
|
|
221
|
+
decodeRoutineInboxEntryV1,
|
|
222
|
+
routineFailureSentenceV1,
|
|
217
223
|
subagentAttributionV1,
|
|
218
224
|
ROUTINE_INBOX_TEXT_MAX,
|
|
219
225
|
ROUTINE_WAKE_TITLE_MAX,
|
|
@@ -437,6 +443,7 @@ import {
|
|
|
437
443
|
optionalSidebarMessagePreviewV1,
|
|
438
444
|
optionalUnreadStateV1,
|
|
439
445
|
projectBotUnreadViewV1,
|
|
446
|
+
sidebarMessagePreviewFromRunsV1,
|
|
440
447
|
SIDEBAR_PREVIEW_KEY,
|
|
441
448
|
unreadReceiptKeyV1,
|
|
442
449
|
UNREAD_COUNT_CAP,
|
|
@@ -444,6 +451,8 @@ import {
|
|
|
444
451
|
type BotUnreadCommandV1,
|
|
445
452
|
type BotUnreadReceiptV1,
|
|
446
453
|
type BotUnreadViewV1,
|
|
454
|
+
type SidebarMessagePreviewV1,
|
|
455
|
+
type SidebarPreviewRunV1,
|
|
447
456
|
} from "./unread.js";
|
|
448
457
|
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
449
458
|
|
|
@@ -3102,10 +3111,10 @@ export class ShellBotBackendContribution {
|
|
|
3102
3111
|
runId: fire.fireId,
|
|
3103
3112
|
createdAt: new Date().toISOString(),
|
|
3104
3113
|
title: `${settings.profile.name} could not run a Routine`,
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
),
|
|
3114
|
+
// The same sentence the inbox entry carries. A notification is the one
|
|
3115
|
+
// surface a person reads without asking for it, so it is the last place
|
|
3116
|
+
// a kernel invariant belongs.
|
|
3117
|
+
body: routineFailureSentenceV1(outcome.summary).slice(0, 240),
|
|
3109
3118
|
});
|
|
3110
3119
|
}
|
|
3111
3120
|
// -------------------------------------------------------------------------
|
|
@@ -3345,11 +3354,38 @@ export class ShellBotBackendContribution {
|
|
|
3345
3354
|
}
|
|
3346
3355
|
if (admission.status === "replayed") {
|
|
3347
3356
|
// The same tool call, reconciled or retried: the task it already
|
|
3348
|
-
// dispatched is the answer, never a second child.
|
|
3357
|
+
// dispatched is the answer, never a second child. A foreground call
|
|
3358
|
+
// still waits for it — the caller asked for the result, and returning
|
|
3359
|
+
// "dispatched" the instant a replay is recognised is what made a
|
|
3360
|
+
// `background:false` Task look like it completed with no output.
|
|
3361
|
+
const replayed = admission.record;
|
|
3362
|
+
const settled =
|
|
3363
|
+
replayed.outcome ??
|
|
3364
|
+
(request.background
|
|
3365
|
+
? undefined
|
|
3366
|
+
: await this.awaitBlockingTask(
|
|
3367
|
+
identity,
|
|
3368
|
+
taskAnchorIdV1(replayed.childSessionId),
|
|
3369
|
+
replayed.taskId,
|
|
3370
|
+
));
|
|
3371
|
+
if (settled) {
|
|
3372
|
+
return {
|
|
3373
|
+
status: "settled",
|
|
3374
|
+
taskId: replayed.taskId,
|
|
3375
|
+
model: replayed.model.slug,
|
|
3376
|
+
taskStatus: settled.status,
|
|
3377
|
+
...(settled.summary === undefined
|
|
3378
|
+
? {}
|
|
3379
|
+
: { summary: settled.summary }),
|
|
3380
|
+
...(settled.failure === undefined
|
|
3381
|
+
? {}
|
|
3382
|
+
: { failure: settled.failure }),
|
|
3383
|
+
};
|
|
3384
|
+
}
|
|
3349
3385
|
return {
|
|
3350
3386
|
status: "dispatched",
|
|
3351
|
-
taskId:
|
|
3352
|
-
model:
|
|
3387
|
+
taskId: replayed.taskId,
|
|
3388
|
+
model: replayed.model.slug,
|
|
3353
3389
|
};
|
|
3354
3390
|
}
|
|
3355
3391
|
const reservation = await this.subagentSlots(identity).reserve({
|
|
@@ -3451,20 +3487,27 @@ export class ShellBotBackendContribution {
|
|
|
3451
3487
|
* Durable Object already does inside a Turn, and the outbound probe is the
|
|
3452
3488
|
* same call reconciliation makes.
|
|
3453
3489
|
*/
|
|
3454
|
-
|
|
3490
|
+
protected async awaitBlockingTask(
|
|
3455
3491
|
identity: BotIdentity,
|
|
3456
3492
|
anchorTaskId: string,
|
|
3457
3493
|
taskId: string,
|
|
3458
3494
|
): Promise<TaskOutcomeV1 | undefined> {
|
|
3459
3495
|
const binding = this.subagentBinding;
|
|
3460
3496
|
const deadline = Date.now() + TASK_BLOCKING_TIMEOUT_MS_V1;
|
|
3497
|
+
// A read that fails once is transient storage contention, not an answer:
|
|
3498
|
+
// abandoning the wait on the first one returned "still running" for a
|
|
3499
|
+
// child that was about to settle, and taught the model to poll. Only a
|
|
3500
|
+
// record that stays unreadable ends the wait early.
|
|
3501
|
+
const readFailureLimit = 3;
|
|
3502
|
+
let readFailures = 0;
|
|
3461
3503
|
for (;;) {
|
|
3462
3504
|
try {
|
|
3463
3505
|
const record = await this.tasks.read(taskId);
|
|
3506
|
+
readFailures = 0;
|
|
3464
3507
|
if (record.outcome) return record.outcome;
|
|
3465
3508
|
} catch {
|
|
3466
|
-
|
|
3467
|
-
return undefined;
|
|
3509
|
+
readFailures += 1;
|
|
3510
|
+
if (readFailures >= readFailureLimit) return undefined;
|
|
3468
3511
|
}
|
|
3469
3512
|
if (binding) {
|
|
3470
3513
|
try {
|
|
@@ -5254,18 +5297,67 @@ export class ShellBotBackendContribution {
|
|
|
5254
5297
|
]),
|
|
5255
5298
|
);
|
|
5256
5299
|
const state = optionalUnreadStateV1(storedState);
|
|
5257
|
-
const preview = optionalSidebarMessagePreviewV1(storedPreview);
|
|
5258
5300
|
const index = await this.authority.listRunIndex({
|
|
5259
5301
|
limit: UNREAD_COUNT_CAP + 1,
|
|
5260
5302
|
});
|
|
5303
|
+
// Counted straight off the keys rather than through `RoutineInboxStore`:
|
|
5304
|
+
// its `list()` trims the inbox, and the unread fan-out is a read every
|
|
5305
|
+
// sidebar poll makes for every Bot — it must not write, least of all into
|
|
5306
|
+
// an object that is running a Turn. An undecodable row is skipped, because
|
|
5307
|
+
// a badge is never worth failing a read for.
|
|
5308
|
+
const stored = await this.ctx.storage.list<unknown>({
|
|
5309
|
+
prefix: ROUTINE_INBOX_PREFIX,
|
|
5310
|
+
limit: ROUTINE_INBOX_LIMIT,
|
|
5311
|
+
});
|
|
5312
|
+
let failures = 0;
|
|
5313
|
+
for (const value of stored.values()) {
|
|
5314
|
+
try {
|
|
5315
|
+
const entry = decodeRoutineInboxEntryV1(value);
|
|
5316
|
+
if (entry.failure === true && !entry.acknowledged) failures += 1;
|
|
5317
|
+
} catch {
|
|
5318
|
+
continue;
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5261
5321
|
return projectBotUnreadViewV1(
|
|
5262
5322
|
identity.botId,
|
|
5263
5323
|
state,
|
|
5264
5324
|
index.map((entry) => entry.cursor),
|
|
5265
|
-
|
|
5325
|
+
await this.sidebarPreview(storedPreview, index),
|
|
5326
|
+
failures,
|
|
5266
5327
|
);
|
|
5267
5328
|
}
|
|
5268
5329
|
|
|
5330
|
+
/**
|
|
5331
|
+
* How many stored runs a read will open to recover a missing preview. The
|
|
5332
|
+
* newest settled chat Turn is almost always the first entry; the bound is
|
|
5333
|
+
* what keeps a Bot whose recent Turns are all automations from turning one
|
|
5334
|
+
* sidebar read into a scan of its whole history.
|
|
5335
|
+
*/
|
|
5336
|
+
private static readonly SIDEBAR_PREVIEW_BACKFILL_RUNS_V1 = 5;
|
|
5337
|
+
|
|
5338
|
+
/**
|
|
5339
|
+
* The preview record, or the same line derived from the runs when there is
|
|
5340
|
+
* none. A Bot whose Turns settled before the preview projection existed has
|
|
5341
|
+
* a full transcript and no record, and the row read "No messages yet" over
|
|
5342
|
+
* it. A read never writes what it derives: the next settlement stores it.
|
|
5343
|
+
*/
|
|
5344
|
+
private async sidebarPreview(
|
|
5345
|
+
storedPreview: unknown,
|
|
5346
|
+
index: readonly { runId: string }[],
|
|
5347
|
+
): Promise<SidebarMessagePreviewV1 | undefined> {
|
|
5348
|
+
const stored = optionalSidebarMessagePreviewV1(storedPreview);
|
|
5349
|
+
if (stored) return stored;
|
|
5350
|
+
const runs: SidebarPreviewRunV1[] = [];
|
|
5351
|
+
for (const entry of index.slice(
|
|
5352
|
+
0,
|
|
5353
|
+
ShellBotBackendContribution.SIDEBAR_PREVIEW_BACKFILL_RUNS_V1,
|
|
5354
|
+
)) {
|
|
5355
|
+
const run = await this.authority.readRun(entry.runId);
|
|
5356
|
+
if (run) runs.push(run);
|
|
5357
|
+
}
|
|
5358
|
+
return sidebarMessagePreviewFromRunsV1(runs);
|
|
5359
|
+
}
|
|
5360
|
+
|
|
5269
5361
|
/**
|
|
5270
5362
|
* `bot/mark-read` and `bot/mark-unread`. Idempotent on the command id and
|
|
5271
5363
|
* monotonic in the cursor, so a replay or an out-of-order delivery can only
|
|
@@ -5294,9 +5386,7 @@ export class ShellBotBackendContribution {
|
|
|
5294
5386
|
}
|
|
5295
5387
|
return {
|
|
5296
5388
|
state: optionalUnreadStateV1(existing.state),
|
|
5297
|
-
preview:
|
|
5298
|
-
await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5299
|
-
),
|
|
5389
|
+
preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5300
5390
|
};
|
|
5301
5391
|
}
|
|
5302
5392
|
const current = optionalUnreadStateV1(
|
|
@@ -5320,9 +5410,7 @@ export class ShellBotBackendContribution {
|
|
|
5320
5410
|
});
|
|
5321
5411
|
return {
|
|
5322
5412
|
state: next,
|
|
5323
|
-
preview:
|
|
5324
|
-
await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5325
|
-
),
|
|
5413
|
+
preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5326
5414
|
};
|
|
5327
5415
|
});
|
|
5328
5416
|
const index = await this.authority.listRunIndex({
|
|
@@ -5336,7 +5424,10 @@ export class ShellBotBackendContribution {
|
|
|
5336
5424
|
identity.botId,
|
|
5337
5425
|
stored.state,
|
|
5338
5426
|
index.map((entry) => entry.cursor),
|
|
5339
|
-
|
|
5427
|
+
// The open Bot is the one that gets marked read, so this receipt is
|
|
5428
|
+
// the sidebar row it renders from: it owes the same derived preview
|
|
5429
|
+
// the fan-out gives every other Bot.
|
|
5430
|
+
await this.sidebarPreview(stored.preview, index),
|
|
5340
5431
|
),
|
|
5341
5432
|
};
|
|
5342
5433
|
}
|
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
type WebToolActivity,
|
|
27
27
|
} from "../shared.js";
|
|
28
28
|
import { ComposerDraftStore } from "./composer-draft.js";
|
|
29
|
+
import {
|
|
30
|
+
TURN_TEXT_MAX_CHARACTERS_V1,
|
|
31
|
+
turnTextCounterVisibleV1,
|
|
32
|
+
turnTextRemainingV1,
|
|
33
|
+
turnTextTooLongV1,
|
|
34
|
+
} from "./turn-limits.js";
|
|
29
35
|
import SendPayloadView from "./SendPayloadView.vue";
|
|
30
36
|
import AppletCanvas from "./AppletCanvas.vue";
|
|
31
37
|
import PackageIframeHost from "./PackageIframeHost.vue";
|
|
@@ -305,12 +311,31 @@ const composerPlaceholder = computed(() => {
|
|
|
305
311
|
if (!state.value.modelReady) return state.value.modelLabel;
|
|
306
312
|
return botName.value ? `Message ${botName.value}` : "Message";
|
|
307
313
|
});
|
|
314
|
+
/*
|
|
315
|
+
* The send route's size rule, enforced where the person can still do something
|
|
316
|
+
* about it. Past the limit the server answers 413 and nothing is sent, so the
|
|
317
|
+
* composer says how much is left before the button closes rather than letting
|
|
318
|
+
* a long message make the round trip to be refused.
|
|
319
|
+
*/
|
|
320
|
+
const draftText = computed(() => draft.value.trim());
|
|
321
|
+
const draftTooLong = computed(() => turnTextTooLongV1(draftText.value));
|
|
322
|
+
const draftCounterVisible = computed(() =>
|
|
323
|
+
turnTextCounterVisibleV1(draftText.value),
|
|
324
|
+
);
|
|
325
|
+
const draftCounterLabel = computed(() => {
|
|
326
|
+
const remaining = turnTextRemainingV1(draftText.value);
|
|
327
|
+
const count = Math.abs(remaining).toLocaleString("en-US");
|
|
328
|
+
return remaining < 0
|
|
329
|
+
? `${count} characters over the ${TURN_TEXT_MAX_CHARACTERS_V1.toLocaleString("en-US")} limit`
|
|
330
|
+
: `${count} characters left`;
|
|
331
|
+
});
|
|
308
332
|
const canSend = computed(
|
|
309
333
|
() =>
|
|
310
334
|
state.value.connection === "ready" &&
|
|
311
335
|
state.value.modelReady &&
|
|
312
336
|
Boolean(state.value.activeBotId) &&
|
|
313
|
-
|
|
337
|
+
draftText.value.length > 0 &&
|
|
338
|
+
!draftTooLong.value,
|
|
314
339
|
);
|
|
315
340
|
/**
|
|
316
341
|
* Stop takes the button only while there is nothing to send. The moment the
|
|
@@ -937,6 +962,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
937
962
|
<p v-if="message.notice" class="message-notice">
|
|
938
963
|
{{ message.notice }}
|
|
939
964
|
</p>
|
|
965
|
+
<!--
|
|
966
|
+
The way out of an ending the person cannot otherwise act on:
|
|
967
|
+
the client could not reach the Bot, their text is back in the
|
|
968
|
+
composer, and this sends it again.
|
|
969
|
+
-->
|
|
970
|
+
<button
|
|
971
|
+
v-if="message.retry === 'resend'"
|
|
972
|
+
type="button"
|
|
973
|
+
class="message-retry"
|
|
974
|
+
:disabled="!canSend"
|
|
975
|
+
@click="sendMessage()"
|
|
976
|
+
>
|
|
977
|
+
Retry
|
|
978
|
+
</button>
|
|
940
979
|
<template v-for="tool in message.tools" :key="tool.id">
|
|
941
980
|
<PackageIframeHost
|
|
942
981
|
v-for="entry in iframeEntriesFor(tool)"
|
|
@@ -1188,6 +1227,19 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1188
1227
|
@click="refreshSkillPopover"
|
|
1189
1228
|
@blur="closeSkillPopover"
|
|
1190
1229
|
/>
|
|
1230
|
+
<!--
|
|
1231
|
+
Silent until the budget is nearly spent, then it says how much is
|
|
1232
|
+
left — and, past the limit, how much has to go before the send
|
|
1233
|
+
button opens again.
|
|
1234
|
+
-->
|
|
1235
|
+
<p
|
|
1236
|
+
v-if="draftCounterVisible"
|
|
1237
|
+
class="composer-counter"
|
|
1238
|
+
:class="{ 'composer-counter-over': draftTooLong }"
|
|
1239
|
+
aria-live="polite"
|
|
1240
|
+
>
|
|
1241
|
+
{{ draftCounterLabel }}
|
|
1242
|
+
</p>
|
|
1191
1243
|
</div>
|
|
1192
1244
|
<!--
|
|
1193
1245
|
Start a new conversation. Sits beside the composer because that is
|