@frockbot/plugin-shell 0.3.8 → 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 +69 -10
- package/src/client/index.test.ts +73 -0
- package/src/client/index.ts +7 -1
- package/src/run-protocol.test.ts +81 -0
- package/src/run-protocol.ts +49 -3
- package/src/unread.test.ts +27 -0
- package/src/unread.ts +9 -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,
|
|
@@ -3105,10 +3111,10 @@ export class ShellBotBackendContribution {
|
|
|
3105
3111
|
runId: fire.fireId,
|
|
3106
3112
|
createdAt: new Date().toISOString(),
|
|
3107
3113
|
title: `${settings.profile.name} could not run a Routine`,
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
),
|
|
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),
|
|
3112
3118
|
});
|
|
3113
3119
|
}
|
|
3114
3120
|
// -------------------------------------------------------------------------
|
|
@@ -3348,11 +3354,38 @@ export class ShellBotBackendContribution {
|
|
|
3348
3354
|
}
|
|
3349
3355
|
if (admission.status === "replayed") {
|
|
3350
3356
|
// The same tool call, reconciled or retried: the task it already
|
|
3351
|
-
// 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
|
+
}
|
|
3352
3385
|
return {
|
|
3353
3386
|
status: "dispatched",
|
|
3354
|
-
taskId:
|
|
3355
|
-
model:
|
|
3387
|
+
taskId: replayed.taskId,
|
|
3388
|
+
model: replayed.model.slug,
|
|
3356
3389
|
};
|
|
3357
3390
|
}
|
|
3358
3391
|
const reservation = await this.subagentSlots(identity).reserve({
|
|
@@ -3454,20 +3487,27 @@ export class ShellBotBackendContribution {
|
|
|
3454
3487
|
* Durable Object already does inside a Turn, and the outbound probe is the
|
|
3455
3488
|
* same call reconciliation makes.
|
|
3456
3489
|
*/
|
|
3457
|
-
|
|
3490
|
+
protected async awaitBlockingTask(
|
|
3458
3491
|
identity: BotIdentity,
|
|
3459
3492
|
anchorTaskId: string,
|
|
3460
3493
|
taskId: string,
|
|
3461
3494
|
): Promise<TaskOutcomeV1 | undefined> {
|
|
3462
3495
|
const binding = this.subagentBinding;
|
|
3463
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;
|
|
3464
3503
|
for (;;) {
|
|
3465
3504
|
try {
|
|
3466
3505
|
const record = await this.tasks.read(taskId);
|
|
3506
|
+
readFailures = 0;
|
|
3467
3507
|
if (record.outcome) return record.outcome;
|
|
3468
3508
|
} catch {
|
|
3469
|
-
|
|
3470
|
-
return undefined;
|
|
3509
|
+
readFailures += 1;
|
|
3510
|
+
if (readFailures >= readFailureLimit) return undefined;
|
|
3471
3511
|
}
|
|
3472
3512
|
if (binding) {
|
|
3473
3513
|
try {
|
|
@@ -5260,11 +5300,30 @@ export class ShellBotBackendContribution {
|
|
|
5260
5300
|
const index = await this.authority.listRunIndex({
|
|
5261
5301
|
limit: UNREAD_COUNT_CAP + 1,
|
|
5262
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
|
+
}
|
|
5263
5321
|
return projectBotUnreadViewV1(
|
|
5264
5322
|
identity.botId,
|
|
5265
5323
|
state,
|
|
5266
5324
|
index.map((entry) => entry.cursor),
|
|
5267
5325
|
await this.sidebarPreview(storedPreview, index),
|
|
5326
|
+
failures,
|
|
5268
5327
|
);
|
|
5269
5328
|
}
|
|
5270
5329
|
|
package/src/client/index.test.ts
CHANGED
|
@@ -1631,6 +1631,79 @@ describe("active durable Turn projection", () => {
|
|
|
1631
1631
|
});
|
|
1632
1632
|
});
|
|
1633
1633
|
|
|
1634
|
+
test("a running Turn's partial text fills the bubble it will settle into", () => {
|
|
1635
|
+
const state: Pick<
|
|
1636
|
+
FrockBotWebData,
|
|
1637
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1638
|
+
> = { messages: [] };
|
|
1639
|
+
|
|
1640
|
+
projectDurableRuns(
|
|
1641
|
+
state,
|
|
1642
|
+
[],
|
|
1643
|
+
[
|
|
1644
|
+
{
|
|
1645
|
+
runId: "run-9",
|
|
1646
|
+
input: "Explain",
|
|
1647
|
+
events: [],
|
|
1648
|
+
status: "running",
|
|
1649
|
+
partialText: "Because it",
|
|
1650
|
+
},
|
|
1651
|
+
],
|
|
1652
|
+
);
|
|
1653
|
+
expect(state.messages[1]).toMatchObject({
|
|
1654
|
+
text: "Because it",
|
|
1655
|
+
status: "streaming",
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
// One bubble: the settled answer replaces the partial one in place.
|
|
1659
|
+
projectDurableRuns(
|
|
1660
|
+
state,
|
|
1661
|
+
[],
|
|
1662
|
+
[
|
|
1663
|
+
{
|
|
1664
|
+
runId: "run-9",
|
|
1665
|
+
input: "Explain",
|
|
1666
|
+
events: [],
|
|
1667
|
+
status: "completed",
|
|
1668
|
+
responseText: "Because it is.",
|
|
1669
|
+
},
|
|
1670
|
+
],
|
|
1671
|
+
);
|
|
1672
|
+
expect(state.messages).toHaveLength(2);
|
|
1673
|
+
expect(state.messages[1]).toMatchObject({
|
|
1674
|
+
text: "Because it is.",
|
|
1675
|
+
status: "completed",
|
|
1676
|
+
});
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
test("a Turn that has already delivered a send streams nothing beside it", () => {
|
|
1680
|
+
const state: Pick<
|
|
1681
|
+
FrockBotWebData,
|
|
1682
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1683
|
+
> = { messages: [] };
|
|
1684
|
+
|
|
1685
|
+
projectDurableRuns(
|
|
1686
|
+
state,
|
|
1687
|
+
[],
|
|
1688
|
+
[
|
|
1689
|
+
{
|
|
1690
|
+
runId: "run-10",
|
|
1691
|
+
input: "Explain",
|
|
1692
|
+
events: [
|
|
1693
|
+
{
|
|
1694
|
+
type: "send/to-user",
|
|
1695
|
+
payload: { type: "text", text: "Here you go." },
|
|
1696
|
+
},
|
|
1697
|
+
],
|
|
1698
|
+
status: "running",
|
|
1699
|
+
partialText: "private scratch space",
|
|
1700
|
+
},
|
|
1701
|
+
],
|
|
1702
|
+
);
|
|
1703
|
+
expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
|
|
1704
|
+
expect(state.messages[1]?.sends).toHaveLength(1);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1634
1707
|
test("projects reconciliation-required recovery state", () => {
|
|
1635
1708
|
const reconciliation: Pick<
|
|
1636
1709
|
FrockBotWebData,
|
package/src/client/index.ts
CHANGED
|
@@ -283,10 +283,16 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
|
283
283
|
* model's own assistant text is scratch space and the thread does not draw it
|
|
284
284
|
* (issue 153): drawing both is how a one-word reply arrived twice, once as the
|
|
285
285
|
* model's text and once as the bubble that was actually delivered.
|
|
286
|
+
*
|
|
287
|
+
* A running Turn has no `responseText` yet — that is written only at
|
|
288
|
+
* settlement — so it draws the words it has written so far. They occupy the
|
|
289
|
+
* same bubble the settled answer will, and the same send gate applies to
|
|
290
|
+
* both: a Turn that has already delivered a bubble streams nothing into a
|
|
291
|
+
* second one.
|
|
286
292
|
*/
|
|
287
293
|
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
288
294
|
if (sendsFrom(run.events).length > 0) return "";
|
|
289
|
-
return run.responseText ?? fallback;
|
|
295
|
+
return run.responseText ?? run.partialText ?? fallback;
|
|
290
296
|
}
|
|
291
297
|
|
|
292
298
|
function isTerminalRun(run: ClientRun): boolean {
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -1476,6 +1476,87 @@ describe("dispatched subagents in the run projection", () => {
|
|
|
1476
1476
|
}
|
|
1477
1477
|
});
|
|
1478
1478
|
|
|
1479
|
+
test("a running Turn projects the words it has written so far", () => {
|
|
1480
|
+
const streamed: SessionEvent[] = [
|
|
1481
|
+
event({
|
|
1482
|
+
type: "assistant/chunk",
|
|
1483
|
+
seq: 0,
|
|
1484
|
+
timestamp,
|
|
1485
|
+
turn: 1,
|
|
1486
|
+
step: 1,
|
|
1487
|
+
requestId: "request-1",
|
|
1488
|
+
text: "Half a",
|
|
1489
|
+
}),
|
|
1490
|
+
event({
|
|
1491
|
+
type: "assistant/chunk",
|
|
1492
|
+
seq: 1,
|
|
1493
|
+
timestamp,
|
|
1494
|
+
turn: 1,
|
|
1495
|
+
step: 1,
|
|
1496
|
+
requestId: "request-1",
|
|
1497
|
+
text: " thought",
|
|
1498
|
+
}),
|
|
1499
|
+
];
|
|
1500
|
+
|
|
1501
|
+
const projected = projectClientRunV1(storedRun(streamed, "running"));
|
|
1502
|
+
expect(projected.partialText).toBe("Half a thought");
|
|
1503
|
+
expect(projected.outcome).toBeUndefined();
|
|
1504
|
+
|
|
1505
|
+
// And it survives the wire, so the thread draws it while the Turn runs.
|
|
1506
|
+
const decoded = decodeClientRunPageV1(
|
|
1507
|
+
createClientRunListV1([projected], { truncated: false }),
|
|
1508
|
+
).runs[0];
|
|
1509
|
+
expect(decoded?.partialText).toBe("Half a thought");
|
|
1510
|
+
expect(decoded?.responseText).toBeUndefined();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
test("a running Turn that has said nothing carries no partial text", () => {
|
|
1514
|
+
expect(projectClientRunV1(storedRun([], "running")).partialText).toBe(
|
|
1515
|
+
undefined,
|
|
1516
|
+
);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
test("a later request restarts the partial answer", () => {
|
|
1520
|
+
const streamed: SessionEvent[] = [
|
|
1521
|
+
event({
|
|
1522
|
+
type: "assistant/chunk",
|
|
1523
|
+
seq: 0,
|
|
1524
|
+
timestamp,
|
|
1525
|
+
turn: 1,
|
|
1526
|
+
step: 1,
|
|
1527
|
+
requestId: "request-1",
|
|
1528
|
+
text: "scratch",
|
|
1529
|
+
}),
|
|
1530
|
+
event({
|
|
1531
|
+
type: "assistant/chunk",
|
|
1532
|
+
seq: 1,
|
|
1533
|
+
timestamp,
|
|
1534
|
+
turn: 1,
|
|
1535
|
+
step: 2,
|
|
1536
|
+
requestId: "request-2",
|
|
1537
|
+
text: "the answer",
|
|
1538
|
+
}),
|
|
1539
|
+
];
|
|
1540
|
+
expect(projectClientRunV1(storedRun(streamed, "running")).partialText).toBe(
|
|
1541
|
+
"the answer",
|
|
1542
|
+
);
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("a settled Turn carries its answer once, as an outcome", () => {
|
|
1546
|
+
const projected = projectClientRunV1(storedRun([], "completed"));
|
|
1547
|
+
expect(projected.partialText).toBeUndefined();
|
|
1548
|
+
expect(projected.outcome).toMatchObject({ type: "completed" });
|
|
1549
|
+
|
|
1550
|
+
const page = createClientRunListV1([projected], { truncated: false });
|
|
1551
|
+
const tampered = structuredClone(page) as unknown as {
|
|
1552
|
+
runs: Array<Record<string, unknown>>;
|
|
1553
|
+
};
|
|
1554
|
+
tampered.runs[0]!.partialText = "words";
|
|
1555
|
+
expect(() => decodeClientRunPageV1(tampered)).toThrow(
|
|
1556
|
+
"only a running run may carry partial text",
|
|
1557
|
+
);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1479
1560
|
test("refuses a chip whose background flag is not a boolean", () => {
|
|
1480
1561
|
const page = createClientRunListV1(
|
|
1481
1562
|
[projectClientRunV1(storedRun([dispatched]))],
|
package/src/run-protocol.ts
CHANGED
|
@@ -220,6 +220,14 @@ export interface ClientRunV1 {
|
|
|
220
220
|
* the flag is durable state, so a reload draws the same thing.
|
|
221
221
|
*/
|
|
222
222
|
queued?: true;
|
|
223
|
+
/**
|
|
224
|
+
* The answer the Bot has written so far, present only while the run is still
|
|
225
|
+
* running and has produced text. The thread draws it in the bubble it is
|
|
226
|
+
* already drawing for the Turn, so a reply appears as it is written instead
|
|
227
|
+
* of arriving whole at settlement. A settled run carries its answer in
|
|
228
|
+
* `outcome` instead, and never both.
|
|
229
|
+
*/
|
|
230
|
+
partialText?: string;
|
|
223
231
|
outcome?: ClientRunOutcomeV1;
|
|
224
232
|
recovery?: ClientRunRecoveryV1;
|
|
225
233
|
}
|
|
@@ -738,10 +746,13 @@ function visibleEvents(
|
|
|
738
746
|
* fact about what the person watched arrive, not a claim that the Turn
|
|
739
747
|
* succeeded, and the thread keeps it instead of replacing it with a notice.
|
|
740
748
|
*/
|
|
741
|
-
function
|
|
749
|
+
export function assistantTextSoFarV1(
|
|
750
|
+
events: readonly SessionEvent[],
|
|
751
|
+
responseText = "",
|
|
752
|
+
): string {
|
|
742
753
|
let requestId: string | undefined;
|
|
743
|
-
let text =
|
|
744
|
-
for (const event of
|
|
754
|
+
let text = responseText;
|
|
755
|
+
for (const event of events) {
|
|
745
756
|
if (event.type === "assistant/chunk") {
|
|
746
757
|
if (event.requestId !== requestId) {
|
|
747
758
|
requestId = event.requestId;
|
|
@@ -753,9 +764,31 @@ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
|
|
|
753
764
|
text = event.text;
|
|
754
765
|
}
|
|
755
766
|
}
|
|
767
|
+
return text;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
|
|
771
|
+
const text = assistantTextSoFarV1(run.events, run.responseText ?? "");
|
|
756
772
|
return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
|
|
757
773
|
}
|
|
758
774
|
|
|
775
|
+
/**
|
|
776
|
+
* What a still-running Turn has said so far, read out of the same journal an
|
|
777
|
+
* interrupted one is read from.
|
|
778
|
+
*
|
|
779
|
+
* The kernel appends an `assistant/chunk` per provider text delta and each
|
|
780
|
+
* append lands on the run record, so the words are already durable while the
|
|
781
|
+
* Turn runs; nothing here is a second copy and nothing crosses the channel.
|
|
782
|
+
* Bounded exactly as an outcome is, because a long answer must not be able to
|
|
783
|
+
* grow the run list past its wire budget.
|
|
784
|
+
*/
|
|
785
|
+
function partialTextV1(run: StoredRun): { partialText?: string } {
|
|
786
|
+
const text = assistantTextSoFarV1(run.events);
|
|
787
|
+
return text
|
|
788
|
+
? { partialText: truncateWireString(text, MAX_OUTCOME_BYTES) }
|
|
789
|
+
: {};
|
|
790
|
+
}
|
|
791
|
+
|
|
759
792
|
function runStatus(run: StoredRun): ClientRunStatusV1 {
|
|
760
793
|
return requireStoredRunV1(run).status;
|
|
761
794
|
}
|
|
@@ -810,6 +843,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
810
843
|
input: truncateWireString(run.input, MAX_INPUT_BYTES),
|
|
811
844
|
status,
|
|
812
845
|
events: visibleEvents(run.events, status),
|
|
846
|
+
...(status === "running" ? partialTextV1(run) : {}),
|
|
813
847
|
...(run.stopRequestedAt
|
|
814
848
|
? {
|
|
815
849
|
stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
|
|
@@ -1300,6 +1334,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1300
1334
|
"events",
|
|
1301
1335
|
"stopRequestedAt",
|
|
1302
1336
|
"queued",
|
|
1337
|
+
"partialText",
|
|
1303
1338
|
"outcome",
|
|
1304
1339
|
"recovery",
|
|
1305
1340
|
],
|
|
@@ -1347,6 +1382,16 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1347
1382
|
if (run.queued === true && runStatus !== "running") {
|
|
1348
1383
|
throw new Error("only a running run may be queued");
|
|
1349
1384
|
}
|
|
1385
|
+
let partialText: string | undefined;
|
|
1386
|
+
if (run.partialText !== undefined) {
|
|
1387
|
+
// A settled run's answer is its outcome. Carrying both would give the
|
|
1388
|
+
// thread two sources for one bubble, which is the duplication the
|
|
1389
|
+
// one-bubble contract exists to prevent.
|
|
1390
|
+
if (runStatus !== "running") {
|
|
1391
|
+
throw new Error("only a running run may carry partial text");
|
|
1392
|
+
}
|
|
1393
|
+
partialText = wireString(run, "partialText", MAX_OUTCOME_BYTES, "run");
|
|
1394
|
+
}
|
|
1350
1395
|
return {
|
|
1351
1396
|
runId,
|
|
1352
1397
|
admittedAt,
|
|
@@ -1355,6 +1400,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1355
1400
|
events: decodeEvents(run.events),
|
|
1356
1401
|
...(stopRequestedAt ? { stopRequestedAt } : {}),
|
|
1357
1402
|
...(run.queued === true ? { queued: true as const } : {}),
|
|
1403
|
+
...(partialText ? { partialText } : {}),
|
|
1358
1404
|
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
1359
1405
|
...(outcome?.type === "failed"
|
|
1360
1406
|
? {
|
package/src/unread.test.ts
CHANGED
|
@@ -236,6 +236,33 @@ describe("the unread projection", () => {
|
|
|
236
236
|
expect(view).toMatchObject({ count: 0, capped: false, unread: false });
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
// A Routine failing every minute left the badge at zero, because an
|
|
240
|
+
// automation Turn never advances the activity cursor.
|
|
241
|
+
test("badges a Bot whose Routine is failing, with nothing else unread", () => {
|
|
242
|
+
const view = projectBotUnreadViewV1(
|
|
243
|
+
"alpha",
|
|
244
|
+
emptyUnreadStateV1(),
|
|
245
|
+
index(3),
|
|
246
|
+
undefined,
|
|
247
|
+
2,
|
|
248
|
+
);
|
|
249
|
+
expect(view).toMatchObject({ count: 2, unread: true, capped: false });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("adds Routine failures to the unread chat Turns", () => {
|
|
253
|
+
const state: UnreadStateV1 = {
|
|
254
|
+
schemaVersion: 1,
|
|
255
|
+
lastActivityCursor: cursor(3),
|
|
256
|
+
lastActivityAt: "2026-08-31T00:03:00.000Z",
|
|
257
|
+
lastSeenCursor: cursor(1),
|
|
258
|
+
lastViewedAt: "2026-08-31T00:01:00.000Z",
|
|
259
|
+
manuallyUnread: false,
|
|
260
|
+
};
|
|
261
|
+
expect(
|
|
262
|
+
projectBotUnreadViewV1("alpha", state, index(4), undefined, 1),
|
|
263
|
+
).toMatchObject({ count: 3, unread: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
239
266
|
test("carries the already-bounded latest message without deriving it", () => {
|
|
240
267
|
const preview = decodeSidebarMessagePreviewV1({
|
|
241
268
|
schemaVersion: 1,
|
package/src/unread.ts
CHANGED
|
@@ -406,9 +406,17 @@ export function projectBotUnreadViewV1(
|
|
|
406
406
|
state: UnreadStateV1,
|
|
407
407
|
cursors: readonly string[],
|
|
408
408
|
lastMessage?: SidebarMessagePreviewV1,
|
|
409
|
+
/**
|
|
410
|
+
* Unacknowledged Routine failures. An automation Turn deliberately does not
|
|
411
|
+
* advance the activity cursor, so a Routine failing every minute badged
|
|
412
|
+
* nothing at all — the one Bot the User most needed to look at was the one
|
|
413
|
+
* the sidebar stayed quiet about. A failure is the Bot addressing its User,
|
|
414
|
+
* so it counts here even though the firing that produced it does not.
|
|
415
|
+
*/
|
|
416
|
+
automationFailures = 0,
|
|
409
417
|
): BotUnreadViewV1 {
|
|
410
418
|
const ceiling = state.lastActivityCursor;
|
|
411
|
-
let counted = 0;
|
|
419
|
+
let counted = Math.max(0, automationFailures);
|
|
412
420
|
if (ceiling !== undefined) {
|
|
413
421
|
for (const cursor of cursors) {
|
|
414
422
|
if (cursor > ceiling) continue;
|