@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
package/src/run-protocol.ts
CHANGED
|
@@ -42,9 +42,15 @@ export const CLIENT_RUN_PAGE_LIMIT = 32;
|
|
|
42
42
|
export const CLIENT_RUN_LIST_MAX_BYTES = 512_000;
|
|
43
43
|
|
|
44
44
|
export type ClientRunStatusV1 =
|
|
45
|
-
|
|
45
|
+
| "running"
|
|
46
|
+
| "completed"
|
|
47
|
+
| "failed"
|
|
48
|
+
| "cancelled"
|
|
49
|
+
| "superseded"
|
|
50
|
+
| "reconciliation-required";
|
|
46
51
|
|
|
47
52
|
const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
|
|
53
|
+
const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
|
|
48
54
|
|
|
49
55
|
export type ClientRunEventV1 =
|
|
50
56
|
| {
|
|
@@ -53,7 +59,11 @@ export type ClientRunEventV1 =
|
|
|
53
59
|
}
|
|
54
60
|
| {
|
|
55
61
|
type: "tool/call";
|
|
56
|
-
call: {
|
|
62
|
+
call: {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
input?: ClientDynamicToolCallInputV1;
|
|
66
|
+
};
|
|
57
67
|
}
|
|
58
68
|
| {
|
|
59
69
|
type: "tool/result";
|
|
@@ -102,10 +112,19 @@ export type ClientRunEventV1 =
|
|
|
102
112
|
background: boolean;
|
|
103
113
|
};
|
|
104
114
|
|
|
115
|
+
/** The bounded, public identity needed to present a dynamic tool call. */
|
|
116
|
+
export interface ClientDynamicToolCallInputV1 {
|
|
117
|
+
namespace: string;
|
|
118
|
+
toolName: string;
|
|
119
|
+
/** JSON keeps this cross-runtime DTO shallow while preserving tool input. */
|
|
120
|
+
argumentsJson?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
export type ClientRunOutcomeV1 =
|
|
106
124
|
| { type: "completed"; text: string }
|
|
107
125
|
| { type: "failed"; message: string }
|
|
108
|
-
| { type: "cancelled"; message: string }
|
|
126
|
+
| { type: "cancelled"; message: string }
|
|
127
|
+
| { type: "superseded"; message: string };
|
|
109
128
|
|
|
110
129
|
export interface ClientRunRecoveryV1 {
|
|
111
130
|
action: "resume";
|
|
@@ -126,6 +145,12 @@ export interface ClientRunV1 {
|
|
|
126
145
|
events: ClientRunEventV1[];
|
|
127
146
|
/** Durable Stop intent, projected independently of the run status. */
|
|
128
147
|
stopRequestedAt?: string;
|
|
148
|
+
/**
|
|
149
|
+
* True while the Turn is admitted and waiting rather than running. The
|
|
150
|
+
* thread draws it as an ordinary message the Bot has not reached yet, and
|
|
151
|
+
* the flag is durable state, so a reload draws the same thing.
|
|
152
|
+
*/
|
|
153
|
+
queued?: true;
|
|
129
154
|
outcome?: ClientRunOutcomeV1;
|
|
130
155
|
recovery?: ClientRunRecoveryV1;
|
|
131
156
|
}
|
|
@@ -175,6 +200,20 @@ export interface ClientTurnCommandV1 {
|
|
|
175
200
|
* by pretending to invoke one.
|
|
176
201
|
*/
|
|
177
202
|
skills?: SkillRefV1[];
|
|
203
|
+
/**
|
|
204
|
+
* The explicit authenticated intent to replace whatever the Bot is doing
|
|
205
|
+
* with this message. Without it a second command is refused exactly as it
|
|
206
|
+
* always was, so a reconnecting client never interrupts a Turn by accident.
|
|
207
|
+
*
|
|
208
|
+
* `runId` is provenance, not the target, and it is optional. The composer
|
|
209
|
+
* sends this intent on every send, because "replace what you are doing with
|
|
210
|
+
* this" is what a person means by typing — and whether the client had yet
|
|
211
|
+
* *observed* a run when they pressed send is a race, not a decision they
|
|
212
|
+
* made. A composer that names no run still supersedes whatever is actually
|
|
213
|
+
* active; one that names a run may name a stale one, and the Bot Durable
|
|
214
|
+
* Object supersedes the active Turn either way.
|
|
215
|
+
*/
|
|
216
|
+
supersedes?: { runId?: string };
|
|
178
217
|
}
|
|
179
218
|
|
|
180
219
|
export interface ClientNotificationAcknowledgementCommandV1 {
|
|
@@ -282,7 +321,10 @@ function publicEventId(value: string, label: string): string {
|
|
|
282
321
|
|
|
283
322
|
function isTerminalRunStatus(status: ClientRunStatusV1): boolean {
|
|
284
323
|
return (
|
|
285
|
-
status === "completed" ||
|
|
324
|
+
status === "completed" ||
|
|
325
|
+
status === "failed" ||
|
|
326
|
+
status === "cancelled" ||
|
|
327
|
+
status === "superseded"
|
|
286
328
|
);
|
|
287
329
|
}
|
|
288
330
|
|
|
@@ -313,6 +355,67 @@ interface ProjectionUnitV1 {
|
|
|
313
355
|
droppable: boolean;
|
|
314
356
|
}
|
|
315
357
|
|
|
358
|
+
function dynamicToolCallInput(
|
|
359
|
+
value: unknown,
|
|
360
|
+
): ClientDynamicToolCallInputV1 | undefined {
|
|
361
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
const input = value as Record<string, unknown>;
|
|
365
|
+
if (
|
|
366
|
+
typeof input.namespace !== "string" ||
|
|
367
|
+
typeof input.toolName !== "string"
|
|
368
|
+
) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const argumentsJson = Object.hasOwn(input, "arguments")
|
|
372
|
+
? JSON.stringify(input.arguments)
|
|
373
|
+
: undefined;
|
|
374
|
+
return {
|
|
375
|
+
namespace: truncateWireString(input.namespace, MAX_EVENT_NAME_BYTES),
|
|
376
|
+
toolName: truncateWireString(input.toolName, MAX_EVENT_NAME_BYTES),
|
|
377
|
+
...(argumentsJson !== undefined &&
|
|
378
|
+
wireBytes(argumentsJson) <= MAX_INPUT_BYTES
|
|
379
|
+
? { argumentsJson }
|
|
380
|
+
: {}),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function decodeDynamicToolCallInput(
|
|
385
|
+
value: unknown,
|
|
386
|
+
): ClientDynamicToolCallInputV1 {
|
|
387
|
+
const input = record(value, "run event.call.input");
|
|
388
|
+
exactKeys(
|
|
389
|
+
input,
|
|
390
|
+
["namespace", "toolName", "argumentsJson"],
|
|
391
|
+
"run event.call.input",
|
|
392
|
+
);
|
|
393
|
+
return {
|
|
394
|
+
namespace: wireString(
|
|
395
|
+
input,
|
|
396
|
+
"namespace",
|
|
397
|
+
MAX_EVENT_NAME_BYTES,
|
|
398
|
+
"run event.call.input",
|
|
399
|
+
),
|
|
400
|
+
toolName: wireString(
|
|
401
|
+
input,
|
|
402
|
+
"toolName",
|
|
403
|
+
MAX_EVENT_NAME_BYTES,
|
|
404
|
+
"run event.call.input",
|
|
405
|
+
),
|
|
406
|
+
...(Object.hasOwn(input, "argumentsJson")
|
|
407
|
+
? {
|
|
408
|
+
argumentsJson: wireString(
|
|
409
|
+
input,
|
|
410
|
+
"argumentsJson",
|
|
411
|
+
MAX_INPUT_BYTES,
|
|
412
|
+
"run event.call.input",
|
|
413
|
+
),
|
|
414
|
+
}
|
|
415
|
+
: {}),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
316
419
|
function projectionUnits(
|
|
317
420
|
events: readonly SessionEvent[],
|
|
318
421
|
status: ClientRunStatusV1,
|
|
@@ -328,11 +431,16 @@ function projectionUnits(
|
|
|
328
431
|
);
|
|
329
432
|
}
|
|
330
433
|
callCount += 1;
|
|
434
|
+
const dynamicInput =
|
|
435
|
+
event.name === "call_dynamic_tool"
|
|
436
|
+
? dynamicToolCallInput(event.input)
|
|
437
|
+
: undefined;
|
|
331
438
|
const call: ClientToolCallV1 = {
|
|
332
439
|
type: "tool/call",
|
|
333
440
|
call: {
|
|
334
441
|
id: `tool-${callCount}`,
|
|
335
442
|
name: truncateWireString(event.name, MAX_EVENT_NAME_BYTES),
|
|
443
|
+
...(dynamicInput ? { input: dynamicInput } : {}),
|
|
336
444
|
},
|
|
337
445
|
};
|
|
338
446
|
const unit: ProjectionUnitV1 = { events: [call], droppable: false };
|
|
@@ -482,7 +590,12 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
482
590
|
type: "cancelled",
|
|
483
591
|
message: CANCELLED_RUN_MESSAGE,
|
|
484
592
|
} satisfies ClientRunOutcomeV1)
|
|
485
|
-
:
|
|
593
|
+
: status === "superseded"
|
|
594
|
+
? ({
|
|
595
|
+
type: "superseded",
|
|
596
|
+
message: SUPERSEDED_RUN_MESSAGE,
|
|
597
|
+
} satisfies ClientRunOutcomeV1)
|
|
598
|
+
: undefined;
|
|
486
599
|
const recovery =
|
|
487
600
|
status === "reconciliation-required"
|
|
488
601
|
? ({
|
|
@@ -508,6 +621,9 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
508
621
|
stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
|
|
509
622
|
}
|
|
510
623
|
: {}),
|
|
624
|
+
...(status === "running" && run.phase === "queued"
|
|
625
|
+
? { queued: true as const }
|
|
626
|
+
: {}),
|
|
511
627
|
...(outcome ? { outcome } : {}),
|
|
512
628
|
...(recovery ? { recovery } : {}),
|
|
513
629
|
};
|
|
@@ -516,9 +632,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
516
632
|
function lookupState(
|
|
517
633
|
status: ClientRunStatusV1,
|
|
518
634
|
): Exclude<ClientRunLookupStateV1, "not-admitted"> {
|
|
519
|
-
if (status
|
|
520
|
-
return "terminal";
|
|
521
|
-
}
|
|
635
|
+
if (isTerminalRunStatus(status)) return "terminal";
|
|
522
636
|
if (status === "reconciliation-required") {
|
|
523
637
|
return "reconciliation-required";
|
|
524
638
|
}
|
|
@@ -687,6 +801,7 @@ function status(value: unknown): ClientRunStatusV1 {
|
|
|
687
801
|
value !== "completed" &&
|
|
688
802
|
value !== "failed" &&
|
|
689
803
|
value !== "cancelled" &&
|
|
804
|
+
value !== "superseded" &&
|
|
690
805
|
value !== "reconciliation-required"
|
|
691
806
|
) {
|
|
692
807
|
throw new Error("run.status is invalid");
|
|
@@ -714,7 +829,21 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
|
|
|
714
829
|
if (event.type === "tool/call") {
|
|
715
830
|
exactKeys(event, ["type", "call"], "run event");
|
|
716
831
|
const call = record(event.call, "run event.call");
|
|
717
|
-
exactKeys(call, ["id", "name"], "run event.call");
|
|
832
|
+
exactKeys(call, ["id", "name", "input"], "run event.call");
|
|
833
|
+
const name = wireString(
|
|
834
|
+
call,
|
|
835
|
+
"name",
|
|
836
|
+
MAX_EVENT_NAME_BYTES,
|
|
837
|
+
"run event.call",
|
|
838
|
+
);
|
|
839
|
+
const input = Object.hasOwn(call, "input")
|
|
840
|
+
? decodeDynamicToolCallInput(call.input)
|
|
841
|
+
: undefined;
|
|
842
|
+
if (input && name !== "call_dynamic_tool") {
|
|
843
|
+
throw new Error(
|
|
844
|
+
"run event.call.input is valid only for a dynamic tool call",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
718
847
|
return {
|
|
719
848
|
type: "tool/call",
|
|
720
849
|
call: {
|
|
@@ -722,7 +851,8 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
|
|
|
722
851
|
string(call, "id", MAX_EVENT_ID_LENGTH, "run event.call"),
|
|
723
852
|
"run event.call.id",
|
|
724
853
|
),
|
|
725
|
-
name
|
|
854
|
+
name,
|
|
855
|
+
...(input ? { input } : {}),
|
|
726
856
|
},
|
|
727
857
|
};
|
|
728
858
|
}
|
|
@@ -880,6 +1010,13 @@ function decodeOutcome(
|
|
|
880
1010
|
message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
|
|
881
1011
|
};
|
|
882
1012
|
}
|
|
1013
|
+
if (outcome.type === "superseded" && runStatus === "superseded") {
|
|
1014
|
+
exactKeys(outcome, ["type", "message"], "run.outcome");
|
|
1015
|
+
return {
|
|
1016
|
+
type: "superseded",
|
|
1017
|
+
message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
883
1020
|
throw new Error("run.outcome does not match run.status");
|
|
884
1021
|
}
|
|
885
1022
|
|
|
@@ -919,6 +1056,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
919
1056
|
"status",
|
|
920
1057
|
"events",
|
|
921
1058
|
"stopRequestedAt",
|
|
1059
|
+
"queued",
|
|
922
1060
|
"outcome",
|
|
923
1061
|
"recovery",
|
|
924
1062
|
],
|
|
@@ -960,6 +1098,12 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
960
1098
|
if (runStatus === "cancelled" && stopRequestedAt === undefined) {
|
|
961
1099
|
throw new Error("cancelled run.stopRequestedAt is required");
|
|
962
1100
|
}
|
|
1101
|
+
if (run.queued !== undefined && run.queued !== true) {
|
|
1102
|
+
throw new Error("run.queued is invalid");
|
|
1103
|
+
}
|
|
1104
|
+
if (run.queued === true && runStatus !== "running") {
|
|
1105
|
+
throw new Error("only a running run may be queued");
|
|
1106
|
+
}
|
|
963
1107
|
return {
|
|
964
1108
|
runId,
|
|
965
1109
|
admittedAt,
|
|
@@ -967,9 +1111,11 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
967
1111
|
status: runStatus,
|
|
968
1112
|
events: decodeEvents(run.events, runStatus),
|
|
969
1113
|
...(stopRequestedAt ? { stopRequestedAt } : {}),
|
|
1114
|
+
...(run.queued === true ? { queued: true as const } : {}),
|
|
970
1115
|
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
971
1116
|
...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
|
|
972
1117
|
...(outcome?.type === "cancelled" ? { failure: outcome.message } : {}),
|
|
1118
|
+
...(outcome?.type === "superseded" ? { failure: outcome.message } : {}),
|
|
973
1119
|
...(recovery ? { failure: recovery.message, recovery } : {}),
|
|
974
1120
|
};
|
|
975
1121
|
}
|
|
@@ -1109,7 +1255,7 @@ export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
|
|
|
1109
1255
|
const command = record(input, "turn command");
|
|
1110
1256
|
exactKeys(
|
|
1111
1257
|
command,
|
|
1112
|
-
["schemaVersion", "commandId", "text", "skills"],
|
|
1258
|
+
["schemaVersion", "commandId", "text", "skills", "supersedes"],
|
|
1113
1259
|
"turn command",
|
|
1114
1260
|
);
|
|
1115
1261
|
if (command.schemaVersion !== 1) {
|
|
@@ -1131,12 +1277,42 @@ export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
|
|
|
1131
1277
|
"turn command",
|
|
1132
1278
|
).trim();
|
|
1133
1279
|
if (!text) throw new Error("turn command.text is required");
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1280
|
+
let supersedes: { runId?: string } | undefined;
|
|
1281
|
+
if (command.supersedes !== undefined) {
|
|
1282
|
+
const named = record(command.supersedes, "turn command.supersedes");
|
|
1283
|
+
exactKeys(named, ["runId"], "turn command.supersedes");
|
|
1284
|
+
if (named.runId === undefined) {
|
|
1285
|
+
// Intent with no provenance: the composer sent while it had observed no
|
|
1286
|
+
// running Turn. It still means "replace whatever you are doing".
|
|
1287
|
+
supersedes = {};
|
|
1288
|
+
} else {
|
|
1289
|
+
try {
|
|
1290
|
+
supersedes = {
|
|
1291
|
+
runId: decodeRunIdV1(
|
|
1292
|
+
string(
|
|
1293
|
+
named,
|
|
1294
|
+
"runId",
|
|
1295
|
+
MAX_RUN_ID_LENGTH,
|
|
1296
|
+
"turn command.supersedes",
|
|
1297
|
+
),
|
|
1298
|
+
),
|
|
1299
|
+
};
|
|
1300
|
+
} catch {
|
|
1301
|
+
throw new Error("turn command.supersedes.runId is invalid");
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
const skills =
|
|
1306
|
+
command.skills === undefined
|
|
1307
|
+
? []
|
|
1308
|
+
: decodeSkillRefsV1(command.skills, "turn command.skills");
|
|
1309
|
+
return {
|
|
1310
|
+
schemaVersion: 1,
|
|
1311
|
+
commandId,
|
|
1312
|
+
text,
|
|
1313
|
+
...(skills.length > 0 ? { skills } : {}),
|
|
1314
|
+
...(supersedes ? { supersedes } : {}),
|
|
1315
|
+
};
|
|
1140
1316
|
}
|
|
1141
1317
|
|
|
1142
1318
|
export function decodeClientNotificationAcknowledgementCommandV1(
|
package/src/shared.ts
CHANGED
|
@@ -45,6 +45,7 @@ export interface WebToolAttachment {
|
|
|
45
45
|
export interface WebToolActivity {
|
|
46
46
|
id: string;
|
|
47
47
|
name: string;
|
|
48
|
+
input?: unknown;
|
|
48
49
|
status: "running" | "completed" | "failed";
|
|
49
50
|
text?: string;
|
|
50
51
|
attachments?: WebToolAttachment[];
|
|
@@ -93,6 +94,13 @@ export interface WebChatMessage {
|
|
|
93
94
|
| "error"
|
|
94
95
|
| "interrupted"
|
|
95
96
|
| "reconciliation-required";
|
|
97
|
+
/**
|
|
98
|
+
* True while this line's Turn is admitted but has not started, because the
|
|
99
|
+
* User sent it while the Bot was still on the previous one. The thread
|
|
100
|
+
* greys it and nothing else: it is an ordinary message the Bot has not
|
|
101
|
+
* reached, not a state the User has to understand.
|
|
102
|
+
*/
|
|
103
|
+
pending?: boolean;
|
|
96
104
|
tools: WebToolActivity[];
|
|
97
105
|
/** The typed payloads this Turn sent to the user, oldest first. */
|
|
98
106
|
sends: WebSendPayload[];
|
|
@@ -161,7 +169,17 @@ export interface FrockBotWebData {
|
|
|
161
169
|
activeBotId?: string;
|
|
162
170
|
composerContext?: unknown;
|
|
163
171
|
messages: WebChatMessage[];
|
|
172
|
+
/**
|
|
173
|
+
* The newest Turn that has not settled — running, or admitted and waiting.
|
|
174
|
+
* A message sent now supersedes this one.
|
|
175
|
+
*/
|
|
164
176
|
activeRunId?: string;
|
|
177
|
+
/**
|
|
178
|
+
* The Turn actually executing, which is what Stop targets. It differs from
|
|
179
|
+
* `activeRunId` only while a message the User sent mid-Turn is waiting: Stop
|
|
180
|
+
* cancels what the Bot is doing and never discards what they just sent.
|
|
181
|
+
*/
|
|
182
|
+
runningRunId?: string;
|
|
165
183
|
activeRun?: WebActiveRun;
|
|
166
184
|
error?: string;
|
|
167
185
|
botSettings?: BotSettingsViewV1;
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// records where the Turn earned one — a firing with two inbox entries — and
|
|
7
7
|
// nobody would notice until they counted.
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
shellTerminalRecordsV1,
|
|
11
|
+
supersededTurnRecordsV1,
|
|
12
|
+
} from "./terminal-records.js";
|
|
10
13
|
import { SIDEBAR_PREVIEW_KEY, UNREAD_STATE_KEY } from "./unread.js";
|
|
11
14
|
import { approvalKeyV1, decodeApprovalRecordV1 } from "./approvals.js";
|
|
12
15
|
import { decodeRoutineInboxEntryV1 } from "@frockbot/plugin-routines/inbox";
|
|
@@ -215,3 +218,51 @@ describe("the settling transaction's records", () => {
|
|
|
215
218
|
);
|
|
216
219
|
});
|
|
217
220
|
});
|
|
221
|
+
|
|
222
|
+
describe("what a superseded Turn leaves for the Turn that replaced it", () => {
|
|
223
|
+
const run = {
|
|
224
|
+
runId: "run-1",
|
|
225
|
+
sessionId: "user-1:primary",
|
|
226
|
+
acceptedAt: "2026-09-03T00:00:00.000Z",
|
|
227
|
+
input: "first",
|
|
228
|
+
events: [] as { type: string }[],
|
|
229
|
+
};
|
|
230
|
+
const now = "2026-09-03T00:00:05.000Z";
|
|
231
|
+
const read = <T>(): Promise<T | undefined> => Promise.resolve(undefined);
|
|
232
|
+
|
|
233
|
+
test("one durable input, keyed by the Turn it replaced", async () => {
|
|
234
|
+
const records = await supersededTurnRecordsV1({ run, now, read });
|
|
235
|
+
|
|
236
|
+
const values = Object.values(records);
|
|
237
|
+
expect(values).toHaveLength(2);
|
|
238
|
+
expect(values).toContainEqual({
|
|
239
|
+
schemaVersion: 1,
|
|
240
|
+
kind: "superseded-turn",
|
|
241
|
+
runId: "run-1",
|
|
242
|
+
unfinishedWork: false,
|
|
243
|
+
createdAt: now,
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a Turn that dispatched a subagent says so, because it is still running", async () => {
|
|
248
|
+
const records = await supersededTurnRecordsV1({
|
|
249
|
+
run: { ...run, events: [{ type: "task/dispatched" }] },
|
|
250
|
+
now,
|
|
251
|
+
read,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(Object.values(records)).toContainEqual(
|
|
255
|
+
expect.objectContaining({ unfinishedWork: true }),
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("an automation Turn contributes nothing: a firing is not the conversation", async () => {
|
|
260
|
+
expect(
|
|
261
|
+
await supersededTurnRecordsV1({
|
|
262
|
+
run: { ...run, admission: { turnType: "automation" } },
|
|
263
|
+
now,
|
|
264
|
+
read,
|
|
265
|
+
}),
|
|
266
|
+
).toEqual({});
|
|
267
|
+
});
|
|
268
|
+
});
|
package/src/terminal-records.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
SIDEBAR_PREVIEW_KEY,
|
|
31
31
|
UNREAD_STATE_KEY,
|
|
32
32
|
} from "./unread.js";
|
|
33
|
+
import { enqueuePendingBotInputV1 } from "@frockbot/plugin-routines/inbox-store";
|
|
34
|
+
import type { PendingBotInputV1 } from "@frockbot/plugin-routines/inbox";
|
|
33
35
|
import { approvalTerminalRecordsV1 } from "./approvals.js";
|
|
34
36
|
import { routineTerminalRecordsForRunV1 } from "./backend-routines.js";
|
|
35
37
|
|
|
@@ -132,6 +134,52 @@ const SHELL_TERMINAL_PRODUCERS_V1 = [
|
|
|
132
134
|
approvalRecordsV1,
|
|
133
135
|
] as const;
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* What a Turn the User's next message replaced leaves behind.
|
|
139
|
+
*
|
|
140
|
+
* One durable input, drained once by the next conversational Turn. The session
|
|
141
|
+
* log already carries what the Turn sent and what its tools returned; this is
|
|
142
|
+
* the part that is *not* in the log — that it was cut off, that nothing still
|
|
143
|
+
* in flight completed, and that a subagent it dispatched is still working.
|
|
144
|
+
* Background work survives a supersede, so the reminder is how the Bot learns
|
|
145
|
+
* that an answer is still coming rather than losing track of it.
|
|
146
|
+
*
|
|
147
|
+
* An automation Turn contributes nothing: a firing is not the conversation,
|
|
148
|
+
* and it reaches the User through its own inbox entry.
|
|
149
|
+
*/
|
|
150
|
+
export async function supersededTurnRecordsV1(input: {
|
|
151
|
+
run: ShellTerminalRunV1;
|
|
152
|
+
now: string;
|
|
153
|
+
read<T>(key: string): Promise<T | undefined>;
|
|
154
|
+
}): Promise<Record<string, unknown>> {
|
|
155
|
+
if ((input.run.admission?.turnType ?? "chat") !== "chat") return {};
|
|
156
|
+
const pending = {
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
kind: "superseded-turn",
|
|
159
|
+
runId: input.run.runId,
|
|
160
|
+
unfinishedWork: input.run.events.some(
|
|
161
|
+
(event) => event.type === "task/dispatched",
|
|
162
|
+
),
|
|
163
|
+
createdAt: input.now,
|
|
164
|
+
} satisfies PendingBotInputV1;
|
|
165
|
+
const records: Record<string, unknown> = {};
|
|
166
|
+
await enqueuePendingBotInputV1(
|
|
167
|
+
{
|
|
168
|
+
get: <T>(key: string) => input.read<T>(key),
|
|
169
|
+
// A settling transaction cannot list. De-duplication is by the input's
|
|
170
|
+
// id, which is this run's, and a run settles once.
|
|
171
|
+
list: <T>() => Promise.resolve(new Map<string, T>()),
|
|
172
|
+
put: (key: string, value: unknown) => {
|
|
173
|
+
records[key] = value;
|
|
174
|
+
return Promise.resolve();
|
|
175
|
+
},
|
|
176
|
+
delete: () => Promise.resolve(false),
|
|
177
|
+
},
|
|
178
|
+
pending,
|
|
179
|
+
);
|
|
180
|
+
return records;
|
|
181
|
+
}
|
|
182
|
+
|
|
135
183
|
export async function shellTerminalRecordsV1(
|
|
136
184
|
input: ShellTerminalInputV1,
|
|
137
185
|
): Promise<Record<string, unknown>> {
|