@frockbot/plugin-shell 0.3.2 → 0.3.3
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/agent.test.ts +15 -3
- package/src/backend-authoring.ts +14 -1
- package/src/backend-completion.ts +4 -2
- package/src/backend-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +59 -8
- package/src/client/FrockBotApp.vue +26 -6
- package/src/client/index.test.ts +208 -0
- package/src/client/index.ts +90 -5
- package/src/client/styles.css +11 -0
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +18 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a superseded Turn does to the durable state the Shell owns: how its
|
|
3
|
+
* unsettled effects are classified, what it leaves for the Turn that replaced
|
|
4
|
+
* it, and what a Stop arriving afterwards is allowed to touch.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
8
|
+
import {
|
|
9
|
+
initializeBotSettingsV1,
|
|
10
|
+
type UserSettingsViewV1,
|
|
11
|
+
} from "@frockbot/configuration-core";
|
|
12
|
+
import {
|
|
13
|
+
createShellBotBackendContribution,
|
|
14
|
+
type ShellBotBackendHost,
|
|
15
|
+
} from "./backend.js";
|
|
16
|
+
import {
|
|
17
|
+
botTurnCommandFingerprintV1,
|
|
18
|
+
type StoredRun,
|
|
19
|
+
} from "./backend-contracts.js";
|
|
20
|
+
import { planInterruptedRunRecoveryV1 } from "./backend-recovery.js";
|
|
21
|
+
import { projectClientRunV1 } from "./run-protocol.js";
|
|
22
|
+
import { TaskStore } from "@frockbot/plugin-subagents/store";
|
|
23
|
+
|
|
24
|
+
class MemoryStorage {
|
|
25
|
+
readonly values = new Map<string, unknown>();
|
|
26
|
+
alarmAt: number | undefined;
|
|
27
|
+
|
|
28
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
29
|
+
return Promise.resolve(
|
|
30
|
+
structuredClone(this.values.get(key)) as T | undefined,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
35
|
+
if (typeof key === "string") this.values.set(key, structuredClone(value));
|
|
36
|
+
else {
|
|
37
|
+
for (const [entry, item] of Object.entries(key)) {
|
|
38
|
+
this.values.set(entry, structuredClone(item));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Promise.resolve();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
delete(key: string): Promise<boolean> {
|
|
45
|
+
return Promise.resolve(this.values.delete(key));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
|
|
49
|
+
return Promise.resolve(
|
|
50
|
+
new Map(
|
|
51
|
+
[...this.values.entries()].filter(([key]) =>
|
|
52
|
+
key.startsWith(options.prefix ?? ""),
|
|
53
|
+
) as Array<[string, T]>,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
|
|
59
|
+
return callback(this);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setAlarm(timestamp: number): Promise<void> {
|
|
63
|
+
this.alarmAt = timestamp;
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
deleteAlarm(): Promise<void> {
|
|
68
|
+
this.alarmAt = undefined;
|
|
69
|
+
return Promise.resolve();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const user: UserSettingsViewV1 = {
|
|
74
|
+
schemaVersion: 1,
|
|
75
|
+
revision: 0,
|
|
76
|
+
profile: { name: "User" },
|
|
77
|
+
packages: [],
|
|
78
|
+
connections: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function host(storage: MemoryStorage): ShellBotBackendHost {
|
|
82
|
+
return {
|
|
83
|
+
state: { storage } as unknown as DurableObjectState,
|
|
84
|
+
env: {
|
|
85
|
+
USER_CONFIGURATIONS: {
|
|
86
|
+
idFromName: () => "user-id",
|
|
87
|
+
get: () => ({ readConfiguration: () => Promise.resolve(user) }),
|
|
88
|
+
},
|
|
89
|
+
} as unknown as ShellBotBackendHost["env"],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const identity = { userId: "user-1", botId: "primary" };
|
|
94
|
+
const turn = {
|
|
95
|
+
...identity,
|
|
96
|
+
runId: "run-1",
|
|
97
|
+
sessionId: "user-1:primary",
|
|
98
|
+
acceptedAt: "2026-09-03T00:00:00.000Z",
|
|
99
|
+
text: "hello",
|
|
100
|
+
};
|
|
101
|
+
const timestamp = "2026-09-03T00:00:01.000Z";
|
|
102
|
+
|
|
103
|
+
function events(...inputs: Record<string, unknown>[]): SessionEvent[] {
|
|
104
|
+
return inputs.map(
|
|
105
|
+
(event, seq) => ({ ...event, seq, timestamp }) as SessionEvent,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A Turn that had dispatched a model request and asked a tool to run. */
|
|
110
|
+
function toolIntentEvents(): SessionEvent[] {
|
|
111
|
+
return events(
|
|
112
|
+
{ type: "session/created", createdAt: timestamp },
|
|
113
|
+
{ type: "turn/start", turn: 1 },
|
|
114
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
115
|
+
{
|
|
116
|
+
type: "model/request",
|
|
117
|
+
turn: 1,
|
|
118
|
+
step: 1,
|
|
119
|
+
request: {
|
|
120
|
+
requestId: "request-1",
|
|
121
|
+
provider: "foundation",
|
|
122
|
+
model: "foundation-model",
|
|
123
|
+
system: "system",
|
|
124
|
+
messages: [{ role: "user", content: "hello" }],
|
|
125
|
+
tools: [],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: "assistant/message",
|
|
130
|
+
turn: 1,
|
|
131
|
+
step: 1,
|
|
132
|
+
requestId: "request-1",
|
|
133
|
+
text: "on it",
|
|
134
|
+
toolCalls: [{ id: "provider-call", name: "effect", input: {} }],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: "tool/call",
|
|
138
|
+
turn: 1,
|
|
139
|
+
step: 1,
|
|
140
|
+
occurrenceId: "tool:1:1:0",
|
|
141
|
+
name: "effect",
|
|
142
|
+
input: {},
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function storedRun(overrides: Partial<StoredRun> = {}): StoredRun {
|
|
148
|
+
return {
|
|
149
|
+
runId: turn.runId,
|
|
150
|
+
commandFingerprint: botTurnCommandFingerprintV1(turn),
|
|
151
|
+
sessionId: turn.sessionId,
|
|
152
|
+
acceptedAt: turn.acceptedAt,
|
|
153
|
+
input: turn.text,
|
|
154
|
+
events: [],
|
|
155
|
+
effectAdmissions: [],
|
|
156
|
+
status: "running",
|
|
157
|
+
phase: "executing",
|
|
158
|
+
compositionGenerationId: "generation-1",
|
|
159
|
+
configurationSnapshot: initializeBotSettingsV1(identity.botId),
|
|
160
|
+
previousEventCount: 0,
|
|
161
|
+
...overrides,
|
|
162
|
+
} as StoredRun;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function fixture(run: StoredRun = storedRun()): Promise<{
|
|
166
|
+
storage: MemoryStorage;
|
|
167
|
+
contribution: ReturnType<typeof createShellBotBackendContribution>;
|
|
168
|
+
}> {
|
|
169
|
+
const storage = new MemoryStorage();
|
|
170
|
+
const contribution = createShellBotBackendContribution(host(storage));
|
|
171
|
+
await contribution.materializeSettings(identity, { name: "Primary" });
|
|
172
|
+
storage.values.set(`run:${run.runId}`, structuredClone(run));
|
|
173
|
+
storage.values.set("active-run", run.runId);
|
|
174
|
+
return { storage, contribution };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("a superseded run settles its effects exactly as a stopped one does", () => {
|
|
178
|
+
test("a tool effect that was never admitted is interrupted, never re-run", () => {
|
|
179
|
+
const run = storedRun({
|
|
180
|
+
events: toolIntentEvents(),
|
|
181
|
+
supersededAt: timestamp,
|
|
182
|
+
supersededBy: "run-2",
|
|
183
|
+
effectAdmissions: [
|
|
184
|
+
{ kind: "model", effectId: "request-1", outcome: "admitted" },
|
|
185
|
+
{ kind: "tool", effectId: "tool:1:1:0", outcome: "fenced" },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const plan = planInterruptedRunRecoveryV1(run, run.events);
|
|
190
|
+
|
|
191
|
+
expect(plan.kind).toBe("cancel");
|
|
192
|
+
if (plan.kind !== "cancel") throw new Error("expected cancellation");
|
|
193
|
+
expect(
|
|
194
|
+
plan.events.find((event) => event.type === "tool/result"),
|
|
195
|
+
).toMatchObject({ status: "interrupted", isError: true });
|
|
196
|
+
expect(plan.events.at(-1)).toMatchObject({
|
|
197
|
+
type: "turn/end",
|
|
198
|
+
outcome: "interrupted",
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a tool effect that was admitted reconciles rather than settling", () => {
|
|
203
|
+
const run = storedRun({
|
|
204
|
+
events: toolIntentEvents(),
|
|
205
|
+
supersededAt: timestamp,
|
|
206
|
+
supersededBy: "run-2",
|
|
207
|
+
effectAdmissions: [
|
|
208
|
+
{ kind: "model", effectId: "request-1", outcome: "admitted" },
|
|
209
|
+
{ kind: "tool", effectId: "tool:1:1:0", outcome: "admitted" },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Identical to Stop: an effect that may already have run is retrieved, not
|
|
214
|
+
// assumed away, and the Turn that replaced it waits for the answer.
|
|
215
|
+
expect(planInterruptedRunRecoveryV1(run, run.events)).toEqual({
|
|
216
|
+
kind: "reconcile",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a run carrying neither intent is refused a plan", () => {
|
|
221
|
+
expect(() =>
|
|
222
|
+
planInterruptedRunRecoveryV1(
|
|
223
|
+
storedRun({ events: toolIntentEvents() }),
|
|
224
|
+
toolIntentEvents(),
|
|
225
|
+
),
|
|
226
|
+
).toThrow(`run "${turn.runId}" has no durable stop or supersede intent`);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("Stop and supersede on the same Turn", () => {
|
|
231
|
+
test("Stop still records its intent on a superseded Turn", async () => {
|
|
232
|
+
const { storage, contribution } = await fixture(
|
|
233
|
+
storedRun({
|
|
234
|
+
events: toolIntentEvents(),
|
|
235
|
+
supersededAt: timestamp,
|
|
236
|
+
supersededBy: "run-2",
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const receipt = await contribution.stopRun(identity, {
|
|
241
|
+
schemaVersion: 1,
|
|
242
|
+
action: "stop",
|
|
243
|
+
commandId: "stop-1",
|
|
244
|
+
runId: turn.runId,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(receipt.run.stopRequestedAt).toBeString();
|
|
248
|
+
const stored = storage.values.get(`run:${turn.runId}`) as StoredRun;
|
|
249
|
+
expect(stored.stopRequestedAt).toBeString();
|
|
250
|
+
// Both intents stand. Stop is the outcome the settlement writes, because
|
|
251
|
+
// the User asked for this Turn to stop and a later message does not turn
|
|
252
|
+
// their cancellation into something else.
|
|
253
|
+
expect(stored.supersededAt).toBe(timestamp);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("Stop leaves a Turn already admitted as the next one alone", async () => {
|
|
257
|
+
const { storage, contribution } = await fixture(
|
|
258
|
+
storedRun({ events: toolIntentEvents(), supersededAt: timestamp }),
|
|
259
|
+
);
|
|
260
|
+
const queued = storedRun({
|
|
261
|
+
runId: "run-2",
|
|
262
|
+
acceptedAt: "2026-09-03T00:00:02.000Z",
|
|
263
|
+
input: "second",
|
|
264
|
+
phase: "queued",
|
|
265
|
+
previousEventCount: 0,
|
|
266
|
+
});
|
|
267
|
+
storage.values.set("run:run-2", structuredClone(queued));
|
|
268
|
+
storage.values.set("pending-run", "run-2");
|
|
269
|
+
|
|
270
|
+
await contribution.stopRun(identity, {
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
action: "stop",
|
|
273
|
+
commandId: "stop-1",
|
|
274
|
+
runId: turn.runId,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(storage.values.get("pending-run")).toBe("run-2");
|
|
278
|
+
expect((storage.values.get("run:run-2") as StoredRun).status).toBe(
|
|
279
|
+
"running",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("background work outlives the Turn that dispatched it", () => {
|
|
285
|
+
test("a subagent of a superseded Turn still settles, and is recorded", async () => {
|
|
286
|
+
const { storage, contribution } = await fixture(
|
|
287
|
+
storedRun({
|
|
288
|
+
events: [
|
|
289
|
+
...toolIntentEvents(),
|
|
290
|
+
...events({
|
|
291
|
+
type: "task/dispatched",
|
|
292
|
+
turn: 1,
|
|
293
|
+
step: 1,
|
|
294
|
+
occurrenceId: "tool:1:1:0",
|
|
295
|
+
taskId: "tk-1",
|
|
296
|
+
taskType: "executor",
|
|
297
|
+
description: "Read the release notes",
|
|
298
|
+
model: "foundation/foundation-model",
|
|
299
|
+
background: true,
|
|
300
|
+
}),
|
|
301
|
+
],
|
|
302
|
+
supersededAt: timestamp,
|
|
303
|
+
supersededBy: "run-2",
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
const tasks = new TaskStore(
|
|
307
|
+
storage as unknown as ConstructorParameters<typeof TaskStore>[0],
|
|
308
|
+
);
|
|
309
|
+
const admitted = await tasks.admit({
|
|
310
|
+
taskId: "tk-1",
|
|
311
|
+
type: "executor",
|
|
312
|
+
description: "Read the release notes",
|
|
313
|
+
promptDigest: "a".repeat(64),
|
|
314
|
+
model: {
|
|
315
|
+
binding: {
|
|
316
|
+
packageId: "provider-foundation",
|
|
317
|
+
capabilityId: "foundation",
|
|
318
|
+
connectionId: "cn-1",
|
|
319
|
+
provider: "foundation",
|
|
320
|
+
providerModelId: "foundation-model",
|
|
321
|
+
},
|
|
322
|
+
slug: "provider-foundation/foundation-model",
|
|
323
|
+
},
|
|
324
|
+
compositionGenerationId: "generation-1",
|
|
325
|
+
background: true,
|
|
326
|
+
attachments: [],
|
|
327
|
+
dispatch: {
|
|
328
|
+
runId: turn.runId,
|
|
329
|
+
turnId: turn.runId,
|
|
330
|
+
sessionId: turn.sessionId,
|
|
331
|
+
},
|
|
332
|
+
now: new Date(timestamp),
|
|
333
|
+
});
|
|
334
|
+
expect(admitted.status).toBe("admitted");
|
|
335
|
+
|
|
336
|
+
// The parent Turn was superseded; nothing asked the child to stop, and its
|
|
337
|
+
// settlement is written exactly as it would have been.
|
|
338
|
+
const settled = await contribution.settleTask(identity, "tk-1", {
|
|
339
|
+
status: "completed",
|
|
340
|
+
settledAt: "2026-09-03T00:00:09.000Z",
|
|
341
|
+
summary: "The notes mention two breaking changes.",
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(settled.status).toBe("settled");
|
|
345
|
+
expect(await tasks.read("tk-1")).toMatchObject({
|
|
346
|
+
taskId: "tk-1",
|
|
347
|
+
status: "completed",
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("the projection tells the three states apart", () => {
|
|
353
|
+
test("queued, running, and superseded each project distinctly", () => {
|
|
354
|
+
const queued = projectClientRunV1(
|
|
355
|
+
storedRun({ runId: "run-2", phase: "queued", input: "second" }),
|
|
356
|
+
);
|
|
357
|
+
expect(queued).toMatchObject({ status: "running", queued: true });
|
|
358
|
+
|
|
359
|
+
const running = projectClientRunV1(storedRun({ phase: "executing" }));
|
|
360
|
+
expect(running.status).toBe("running");
|
|
361
|
+
expect(running.queued).toBeUndefined();
|
|
362
|
+
|
|
363
|
+
const superseded = projectClientRunV1(
|
|
364
|
+
storedRun({
|
|
365
|
+
status: "superseded",
|
|
366
|
+
supersededAt: timestamp,
|
|
367
|
+
supersededBy: "run-2",
|
|
368
|
+
events: [],
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
expect(superseded).toMatchObject({
|
|
372
|
+
status: "superseded",
|
|
373
|
+
outcome: { type: "superseded" },
|
|
374
|
+
});
|
|
375
|
+
expect(superseded.queued).toBeUndefined();
|
|
376
|
+
});
|
|
377
|
+
});
|
package/src/backend.ts
CHANGED
|
@@ -119,7 +119,7 @@ import {
|
|
|
119
119
|
eventsForFailedRun,
|
|
120
120
|
latestModelRequestJournalState,
|
|
121
121
|
planBotRunRecovery,
|
|
122
|
-
|
|
122
|
+
planInterruptedRunRecoveryV1,
|
|
123
123
|
} from "./backend-recovery.js";
|
|
124
124
|
import {
|
|
125
125
|
bootstrapCompositionGeneration,
|
|
@@ -379,7 +379,10 @@ import {
|
|
|
379
379
|
projectPackageIframeCompositionV1,
|
|
380
380
|
} from "./composition-views.js";
|
|
381
381
|
import { executeBotTurn, executeDirectToolTurn } from "./backend-runner.js";
|
|
382
|
-
import {
|
|
382
|
+
import {
|
|
383
|
+
shellTerminalRecordsV1,
|
|
384
|
+
supersededTurnRecordsV1,
|
|
385
|
+
} from "./terminal-records.js";
|
|
383
386
|
import {
|
|
384
387
|
CLIENT_RUN_LIST_MAX_BYTES,
|
|
385
388
|
CLIENT_RUN_PAGE_LIMIT,
|
|
@@ -472,7 +475,10 @@ interface StoredStopReceipt {
|
|
|
472
475
|
|
|
473
476
|
function isTerminalStoredRunStatus(status: StoredRunStatus): boolean {
|
|
474
477
|
return (
|
|
475
|
-
status === "completed" ||
|
|
478
|
+
status === "completed" ||
|
|
479
|
+
status === "failed" ||
|
|
480
|
+
status === "cancelled" ||
|
|
481
|
+
status === "superseded"
|
|
476
482
|
);
|
|
477
483
|
}
|
|
478
484
|
|
|
@@ -678,7 +684,8 @@ export class ShellBotBackendContribution {
|
|
|
678
684
|
subagentRole?: string;
|
|
679
685
|
mounted: ShellMountedComposition;
|
|
680
686
|
signal: AbortSignal;
|
|
681
|
-
|
|
687
|
+
/** `detail` is recorded on the Turn's `turn/end`, never interpreted. */
|
|
688
|
+
cancel(detail?: string): void;
|
|
682
689
|
}
|
|
683
690
|
| undefined;
|
|
684
691
|
/**
|
|
@@ -762,6 +769,9 @@ export class ShellBotBackendContribution {
|
|
|
762
769
|
notification: (snapshot, result) =>
|
|
763
770
|
this.createNotification(snapshot, result),
|
|
764
771
|
terminalRecords: (input) => this.terminalPackageRecords(input),
|
|
772
|
+
supersededRecords: (input) => this.supersededPackageRecords(input),
|
|
773
|
+
interruptTurn: (runId, reason) =>
|
|
774
|
+
this.interruptActiveTurn(runId, reason),
|
|
765
775
|
scheduledDeadlines: (transaction) =>
|
|
766
776
|
this.scheduledDeadlines(transaction),
|
|
767
777
|
scheduledWorkInFlight: () =>
|
|
@@ -1639,9 +1649,9 @@ export class ShellBotBackendContribution {
|
|
|
1639
1649
|
: {}),
|
|
1640
1650
|
mounted: activation.mounted,
|
|
1641
1651
|
signal: controller.signal,
|
|
1642
|
-
cancel: () => {
|
|
1652
|
+
cancel: (detail?: string) => {
|
|
1643
1653
|
controller.abort("user");
|
|
1644
|
-
activation.mounted.runtime.agent.agent.cancel("user");
|
|
1654
|
+
activation.mounted.runtime.agent.agent.cancel("user", detail);
|
|
1645
1655
|
},
|
|
1646
1656
|
};
|
|
1647
1657
|
this.activeTurn = active;
|
|
@@ -1750,6 +1760,7 @@ export class ShellBotBackendContribution {
|
|
|
1750
1760
|
private cancelActiveTurn(cancellation: {
|
|
1751
1761
|
sessionId: string;
|
|
1752
1762
|
runId: string;
|
|
1763
|
+
detail?: string;
|
|
1753
1764
|
}): boolean {
|
|
1754
1765
|
const active = this.activeTurn;
|
|
1755
1766
|
if (
|
|
@@ -1759,10 +1770,24 @@ export class ShellBotBackendContribution {
|
|
|
1759
1770
|
) {
|
|
1760
1771
|
return false;
|
|
1761
1772
|
}
|
|
1762
|
-
active.cancel();
|
|
1773
|
+
active.cancel(cancellation.detail);
|
|
1763
1774
|
return true;
|
|
1764
1775
|
}
|
|
1765
1776
|
|
|
1777
|
+
/**
|
|
1778
|
+
* The kernel's advisory interrupt, bound to this object's resident Agent.
|
|
1779
|
+
*
|
|
1780
|
+
* It runs only after the durable intent that justifies it is written, and it
|
|
1781
|
+
* changes nothing durable itself: a Turn whose Agent is no longer resident
|
|
1782
|
+
* is stopped by the effect fence on its next external effect instead, which
|
|
1783
|
+
* is the same outcome by a slower road.
|
|
1784
|
+
*/
|
|
1785
|
+
private interruptActiveTurn(runId: string, reason: string): void {
|
|
1786
|
+
const active = this.activeTurn;
|
|
1787
|
+
if (!active || active.runId !== runId) return;
|
|
1788
|
+
active.cancel(reason);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1766
1791
|
/** The visible half of failing closed, through the Bot's notifications. */
|
|
1767
1792
|
private async recordCompositionFailureNotification(
|
|
1768
1793
|
settings: BotSettingsViewV1,
|
|
@@ -5296,6 +5321,27 @@ export class ShellBotBackendContribution {
|
|
|
5296
5321
|
});
|
|
5297
5322
|
}
|
|
5298
5323
|
|
|
5324
|
+
/**
|
|
5325
|
+
* What a superseded Turn leaves for the Turn that replaced it.
|
|
5326
|
+
*
|
|
5327
|
+
* One durable input, drained once by the next conversational Turn. The
|
|
5328
|
+
* session log already carries what the Turn sent and what its tools
|
|
5329
|
+
* returned; this is the part that is not in the log — that it was cut off,
|
|
5330
|
+
* that nothing in flight completed, and that a subagent it dispatched is
|
|
5331
|
+
* still working. "A firing's outcome is delivered to the Bot's next
|
|
5332
|
+
* conversational Turn as durable input" and a superseded Turn's is too.
|
|
5333
|
+
*/
|
|
5334
|
+
private supersededPackageRecords(input: {
|
|
5335
|
+
run: StoredRun;
|
|
5336
|
+
read<T>(key: string): Promise<T | undefined>;
|
|
5337
|
+
}): Promise<Record<string, unknown>> {
|
|
5338
|
+
return supersededTurnRecordsV1({
|
|
5339
|
+
run: input.run,
|
|
5340
|
+
now: new Date().toISOString(),
|
|
5341
|
+
read: input.read,
|
|
5342
|
+
});
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5299
5345
|
async alarm(): Promise<void> {
|
|
5300
5346
|
// One alarm: the kernel defers while work is in flight, settles Package
|
|
5301
5347
|
// scheduled work, and recovers the active run. A run left
|
|
@@ -5953,7 +5999,12 @@ export class ShellBotBackendContribution {
|
|
|
5953
5999
|
`effect admission "${effect.effectId}" does not match durable intent`,
|
|
5954
6000
|
);
|
|
5955
6001
|
}
|
|
5956
|
-
|
|
6002
|
+
// Supersede fences exactly as Stop does. It is what makes an interrupt
|
|
6003
|
+
// durable rather than advisory: a Turn whose Agent never got the signal
|
|
6004
|
+
// — because the object was evicted and resumed — still starts no new
|
|
6005
|
+
// provider call or tool effect once the intent is recorded.
|
|
6006
|
+
const outcome =
|
|
6007
|
+
run.stopRequestedAt || run.supersededAt ? "fenced" : "admitted";
|
|
5957
6008
|
const next = requireStoredRunV1({
|
|
5958
6009
|
...run,
|
|
5959
6010
|
effectAdmissions: [
|
|
@@ -270,16 +270,26 @@ const macDesktop =
|
|
|
270
270
|
const botName = computed(
|
|
271
271
|
() => state.value.botSettings?.profile.name ?? "Barebones",
|
|
272
272
|
);
|
|
273
|
-
|
|
273
|
+
/** A Turn is executing. The composer stays open; only Stop depends on this. */
|
|
274
|
+
const isRunning = computed(() => Boolean(state.value.runningRunId));
|
|
274
275
|
const isConnecting = computed(() => state.value.connection !== "ready");
|
|
276
|
+
/**
|
|
277
|
+
* Sending while the Bot is working is the point: the message supersedes the
|
|
278
|
+
* running Turn. So the only things that close the composer are the ones that
|
|
279
|
+
* would make any message impossible.
|
|
280
|
+
*/
|
|
275
281
|
const canSend = computed(
|
|
276
282
|
() =>
|
|
277
283
|
state.value.connection === "ready" &&
|
|
278
284
|
state.value.modelReady &&
|
|
279
285
|
Boolean(state.value.activeBotId) &&
|
|
280
|
-
!isRunning.value &&
|
|
281
286
|
draft.value.trim().length > 0,
|
|
282
287
|
);
|
|
288
|
+
/**
|
|
289
|
+
* Stop takes the button only while there is nothing to send. The moment the
|
|
290
|
+
* User has typed something, sending it is what they mean by interrupting.
|
|
291
|
+
*/
|
|
292
|
+
const showStop = computed(() => isRunning.value && !canSend.value);
|
|
283
293
|
|
|
284
294
|
/*
|
|
285
295
|
* Tool activity is internal to the Turn. A Turn that produced only tool calls
|
|
@@ -295,8 +305,15 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
|
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
function iframeEntriesFor(tool: WebToolActivity) {
|
|
298
|
-
const
|
|
308
|
+
const separator = tool.name.indexOf("/");
|
|
309
|
+
const namespace = separator < 0 ? undefined : tool.name.slice(0, separator);
|
|
310
|
+
const toolName = separator < 0 ? tool.name : tool.name.slice(separator + 1);
|
|
311
|
+
const slot = `frockbot.tool-result:${toolName}`;
|
|
299
312
|
return (state.value.packageUi?.contributions ?? [])
|
|
313
|
+
.filter(
|
|
314
|
+
(contribution) =>
|
|
315
|
+
namespace === undefined || contribution.packageId === namespace,
|
|
316
|
+
)
|
|
300
317
|
.flatMap((contribution) =>
|
|
301
318
|
contribution.pages.flatMap((page) =>
|
|
302
319
|
page.mounts
|
|
@@ -797,7 +814,10 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
797
814
|
:id="turnAnchors.get(message.id)"
|
|
798
815
|
:key="message.id"
|
|
799
816
|
class="message"
|
|
800
|
-
:class="
|
|
817
|
+
:class="[
|
|
818
|
+
`message-${message.role}`,
|
|
819
|
+
{ 'message-pending': message.pending },
|
|
820
|
+
]"
|
|
801
821
|
>
|
|
802
822
|
<p v-if="message.role === 'system'" class="message-system-line">
|
|
803
823
|
{{ message.text }}
|
|
@@ -1025,7 +1045,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1025
1045
|
? 'Model unavailable'
|
|
1026
1046
|
: `Message ${botName}`
|
|
1027
1047
|
"
|
|
1028
|
-
:disabled="isConnecting || !state.modelReady
|
|
1048
|
+
:disabled="isConnecting || !state.modelReady"
|
|
1029
1049
|
rows="1"
|
|
1030
1050
|
role="combobox"
|
|
1031
1051
|
:aria-expanded="skillPopoverOpen"
|
|
@@ -1038,7 +1058,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1038
1058
|
/>
|
|
1039
1059
|
</div>
|
|
1040
1060
|
<UiIconButton
|
|
1041
|
-
v-if="
|
|
1061
|
+
v-if="showStop"
|
|
1042
1062
|
class="stop-button"
|
|
1043
1063
|
icon="stop"
|
|
1044
1064
|
label="Stop generating"
|