@frockbot/plugin-shell 0.3.11 → 0.3.13
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 +35 -33
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +130 -2
- package/src/backend-configuration.test.ts +26 -26
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +405 -75
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +128 -21
- package/src/client/index.ts +359 -114
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +69 -19
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +50 -7
- package/src/run-protocol.ts +152 -43
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +11 -2
- package/src/shared.ts +36 -0
- package/tsconfig.json +1 -2
- package/src/client/activity-ring.test.ts +0 -89
- package/src/client/activity-ring.ts +0 -94
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
3
|
+
import { MODEL_FIRST_BYTE_DEADLINE_REASON_V1 } from "@frockbot/kernel-contracts";
|
|
4
|
+
import {
|
|
5
|
+
knownFailureCopyV1,
|
|
6
|
+
RUN_FAILURE_COPY_V1,
|
|
7
|
+
RUN_FAILURE_FALLBACK_COPY_V1,
|
|
8
|
+
runFailureCopyV1,
|
|
9
|
+
USER_FACING_FAILURE_REASONS_V1,
|
|
10
|
+
} from "./run-failure-copy.js";
|
|
11
|
+
import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
|
|
12
|
+
import type { StoredRun } from "./backend-contracts.js";
|
|
13
|
+
import { projectClientRunV1 } from "./run-protocol.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Words that describe the machine. Every one of them reached a chat bubble
|
|
17
|
+
* before this: the verification run read "Reconciliation was explicitly
|
|
18
|
+
* abandoned: Bot turn ended with outcome model-error: Flock AI keeps no durable
|
|
19
|
+
* copy of an interrupted response, so it cannot be recovered".
|
|
20
|
+
*/
|
|
21
|
+
const FORBIDDEN_V1 = [
|
|
22
|
+
"reconcil",
|
|
23
|
+
"outcome",
|
|
24
|
+
"durable",
|
|
25
|
+
"supersede",
|
|
26
|
+
"admission",
|
|
27
|
+
"provider",
|
|
28
|
+
"model-error",
|
|
29
|
+
"tool-error",
|
|
30
|
+
"turn/end",
|
|
31
|
+
"session event",
|
|
32
|
+
"run id",
|
|
33
|
+
"runid",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function assertPlainV1(copy: string): void {
|
|
37
|
+
const lowered = copy.toLowerCase();
|
|
38
|
+
for (const word of FORBIDDEN_V1) {
|
|
39
|
+
expect(lowered.includes(word)).toBe(false);
|
|
40
|
+
}
|
|
41
|
+
// A bare " run " is jargon; "running" and the like are not, so the check is
|
|
42
|
+
// on the word rather than the substring.
|
|
43
|
+
expect(/\brun(s|id)?\b/.test(lowered)).toBe(false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TIMESTAMP = "2026-09-04T00:00:00.000Z";
|
|
47
|
+
let seq = 0;
|
|
48
|
+
const turnEnd = (outcome: string) =>
|
|
49
|
+
({
|
|
50
|
+
type: "turn/end",
|
|
51
|
+
turn: 1,
|
|
52
|
+
outcome,
|
|
53
|
+
seq: (seq += 1),
|
|
54
|
+
timestamp: TIMESTAMP,
|
|
55
|
+
}) as unknown as SessionEvent;
|
|
56
|
+
|
|
57
|
+
function failedRun(failure: string, events: SessionEvent[]): StoredRun {
|
|
58
|
+
return {
|
|
59
|
+
runId: "run-1",
|
|
60
|
+
commandFingerprint: "fingerprint",
|
|
61
|
+
sessionId: "user:primary",
|
|
62
|
+
acceptedAt: TIMESTAMP,
|
|
63
|
+
input: "make me an applet",
|
|
64
|
+
events,
|
|
65
|
+
effectAdmissions: [],
|
|
66
|
+
status: "failed",
|
|
67
|
+
phase: "executing",
|
|
68
|
+
compositionGenerationId: "test-composition-generation",
|
|
69
|
+
configurationSnapshot: initializeBotSettingsV1("primary"),
|
|
70
|
+
previousEventCount: 0,
|
|
71
|
+
failure,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("runFailureCopyV1", () => {
|
|
76
|
+
test("every mapped sentence is written for a person", () => {
|
|
77
|
+
for (const copy of Object.values(RUN_FAILURE_COPY_V1)) assertPlainV1(copy);
|
|
78
|
+
assertPlainV1(RUN_FAILURE_FALLBACK_COPY_V1);
|
|
79
|
+
for (const reason of USER_FACING_FAILURE_REASONS_V1) assertPlainV1(reason);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the stored diagnostic never reaches the copy", () => {
|
|
83
|
+
const failure =
|
|
84
|
+
"Reconciliation was explicitly abandoned: Bot turn ended with outcome model-error: Flock AI keeps no durable copy of an interrupted response, so it cannot be recovered";
|
|
85
|
+
const copy = runFailureCopyV1({
|
|
86
|
+
failure,
|
|
87
|
+
events: [turnEnd("interrupted")],
|
|
88
|
+
});
|
|
89
|
+
expect(copy).toBe(RUN_FAILURE_COPY_V1.interrupted);
|
|
90
|
+
assertPlainV1(copy);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a kernel sentence written for a person survives its wrapper", () => {
|
|
94
|
+
const copy = runFailureCopyV1({
|
|
95
|
+
failure: `Model request "abc" has no durable provider outcome: Model response outcome is uncertain: ${MODEL_FIRST_BYTE_DEADLINE_REASON_V1}`,
|
|
96
|
+
events: [turnEnd("interrupted")],
|
|
97
|
+
});
|
|
98
|
+
expect(copy).toBe(MODEL_FIRST_BYTE_DEADLINE_REASON_V1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a Turn with no terminal event still says something plain", () => {
|
|
102
|
+
expect(runFailureCopyV1({ failure: "boom" })).toBe(
|
|
103
|
+
RUN_FAILURE_FALLBACK_COPY_V1,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The client's own guard, because a `ClientRun` can arrive from an older
|
|
108
|
+
// backend that forwarded the raw diagnostic, and the thread must not render
|
|
109
|
+
// a provider's words as though the Bot said them.
|
|
110
|
+
test("the thread accepts only sentences the product wrote", () => {
|
|
111
|
+
for (const written of [
|
|
112
|
+
...Object.values(RUN_FAILURE_COPY_V1),
|
|
113
|
+
...USER_FACING_FAILURE_REASONS_V1,
|
|
114
|
+
]) {
|
|
115
|
+
expect(knownFailureCopyV1(written)).toBe(written);
|
|
116
|
+
}
|
|
117
|
+
for (const diagnostic of [
|
|
118
|
+
undefined,
|
|
119
|
+
"",
|
|
120
|
+
"Provider reconciliation is required",
|
|
121
|
+
"Bot turn ended with outcome model-error: Model request failed (401)",
|
|
122
|
+
'Skill "bot/no-such-skill" is unknown',
|
|
123
|
+
]) {
|
|
124
|
+
const copy = knownFailureCopyV1(diagnostic);
|
|
125
|
+
expect(copy).toBe(RUN_FAILURE_FALLBACK_COPY_V1);
|
|
126
|
+
assertPlainV1(copy);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the projection sends the copy, not the diagnostic", () => {
|
|
131
|
+
const projected = projectClientRunV1(
|
|
132
|
+
failedRun(
|
|
133
|
+
"Reconciliation was explicitly abandoned: Bot turn ended with outcome model-error",
|
|
134
|
+
[
|
|
135
|
+
{
|
|
136
|
+
type: "turn/start",
|
|
137
|
+
turn: 1,
|
|
138
|
+
seq: (seq += 1),
|
|
139
|
+
timestamp: TIMESTAMP,
|
|
140
|
+
} as unknown as SessionEvent,
|
|
141
|
+
turnEnd("interrupted"),
|
|
142
|
+
],
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
expect(projected.outcome?.type).toBe("failed");
|
|
146
|
+
if (projected.outcome?.type !== "failed") throw new Error("unreachable");
|
|
147
|
+
assertPlainV1(projected.outcome.message);
|
|
148
|
+
expect(projected.outcome.message).toBe(RUN_FAILURE_COPY_V1.interrupted);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { SessionEvent, TurnOutcome } from "@frockbot/kernel-contracts";
|
|
2
|
+
import {
|
|
3
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
4
|
+
MODEL_IDLE_DEADLINE_REASON_V1,
|
|
5
|
+
} from "@frockbot/kernel-contracts";
|
|
6
|
+
import { TURN_DEADLINE_REASON_V1 } from "@frockbot/kernel-agent-loop";
|
|
7
|
+
import { UNRECONCILABLE_RUN_FAILURE_V1 } from "@frockbot/kernel-do";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one place a Turn that did not finish is turned into a sentence for the
|
|
11
|
+
* person who was waiting on it.
|
|
12
|
+
*
|
|
13
|
+
* A run's stored `failure` is a diagnostic. It is composed by whoever settled
|
|
14
|
+
* the run, out of whatever the layer below handed up, and it reads like it:
|
|
15
|
+
* "Reconciliation was explicitly abandoned: Bot turn ended with outcome
|
|
16
|
+
* model-error: Flock AI keeps no durable copy of an interrupted response, so it
|
|
17
|
+
* cannot be recovered". Every word of that is useful — on the debug surface,
|
|
18
|
+
* where it stays, unchanged. None of it belongs in a chat bubble. A person
|
|
19
|
+
* asked for a countdown applet; they should not have to learn what
|
|
20
|
+
* reconciliation is to find out that the model gave up.
|
|
21
|
+
*
|
|
22
|
+
* So the projection stops forwarding the diagnostic and picks the sentence
|
|
23
|
+
* instead. Two inputs, in order:
|
|
24
|
+
*
|
|
25
|
+
* 1. The kernel's own user-facing reasons. A handful of failures already have
|
|
26
|
+
* a sentence written for a person — the model deadlines, the Turn deadline,
|
|
27
|
+
* the unretrievable settlement — and those say something the outcome alone
|
|
28
|
+
* cannot, so a stored failure that carries one hands it straight through.
|
|
29
|
+
* 2. Otherwise the Turn's terminal outcome, which is a closed set, mapped
|
|
30
|
+
* below. It says less, and it can never leak.
|
|
31
|
+
*
|
|
32
|
+
* The diagnostic is never the answer, not even as a fallback: an unmapped
|
|
33
|
+
* outcome gets the generic line rather than whatever prose happened to be
|
|
34
|
+
* stored.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sentences the kernel writes for the person rather than for the log. They are
|
|
39
|
+
* matched as substrings because the layer that settles a run wraps the reason
|
|
40
|
+
* it was handed — the sentence survives the wrapping, the wrapper does not.
|
|
41
|
+
*/
|
|
42
|
+
export const USER_FACING_FAILURE_REASONS_V1: readonly string[] = [
|
|
43
|
+
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
44
|
+
MODEL_IDLE_DEADLINE_REASON_V1,
|
|
45
|
+
TURN_DEADLINE_REASON_V1,
|
|
46
|
+
UNRECONCILABLE_RUN_FAILURE_V1,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What each terminal outcome says. Total over `TurnOutcome` so adding one to
|
|
51
|
+
* the kernel's union is a type error here rather than a silent generic line.
|
|
52
|
+
*/
|
|
53
|
+
export const RUN_FAILURE_COPY_V1: Record<TurnOutcome, string> = {
|
|
54
|
+
completed: "This Bot couldn't finish its reply. Try again.",
|
|
55
|
+
blocked: "This Bot wouldn't do that. Try asking a different way.",
|
|
56
|
+
cancelled: "You stopped this.",
|
|
57
|
+
interrupted: "This reply stopped before it finished. Try again.",
|
|
58
|
+
"model-error": "The model couldn't finish its reply. Try again.",
|
|
59
|
+
"tool-error": "Something the Bot was using didn't work. Try again.",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** What a Turn says when nothing more specific is known about how it ended. */
|
|
63
|
+
export const RUN_FAILURE_FALLBACK_COPY_V1 =
|
|
64
|
+
"This Bot couldn't finish its reply. Try again.";
|
|
65
|
+
|
|
66
|
+
/** The outcome the run's own log records, or `undefined` on an unclosed Turn. */
|
|
67
|
+
function terminalTurnOutcomeV1(
|
|
68
|
+
events: readonly SessionEvent[],
|
|
69
|
+
): TurnOutcome | undefined {
|
|
70
|
+
const terminal = events.findLast((event) => event.type === "turn/end");
|
|
71
|
+
return terminal?.type === "turn/end" ? terminal.outcome : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function runFailureCopyV1(input: {
|
|
75
|
+
failure?: string;
|
|
76
|
+
events?: readonly SessionEvent[];
|
|
77
|
+
}): string {
|
|
78
|
+
const failure = input.failure ?? "";
|
|
79
|
+
const written = USER_FACING_FAILURE_REASONS_V1.find((reason) =>
|
|
80
|
+
failure.includes(reason),
|
|
81
|
+
);
|
|
82
|
+
if (written) return written;
|
|
83
|
+
const outcome = terminalTurnOutcomeV1(input.events ?? []);
|
|
84
|
+
return outcome ? RUN_FAILURE_COPY_V1[outcome] : RUN_FAILURE_FALLBACK_COPY_V1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The product's own sentences, as a set a client can check a string against.
|
|
89
|
+
*
|
|
90
|
+
* The projection maps every failure through {@link runFailureCopyV1} on the way
|
|
91
|
+
* to the wire, so what reaches a client is already copy. The client still must
|
|
92
|
+
* not *trust* that: a `ClientRun` can arrive from an older backend that
|
|
93
|
+
* forwarded the raw diagnostic, and a provider's words under a bubble read as
|
|
94
|
+
* part of what the Bot was saying. So the thread accepts a failure only when it
|
|
95
|
+
* recognises it as something the product wrote, and otherwise says the generic
|
|
96
|
+
* line — which keeps the specific sentences (the model deadlines say something
|
|
97
|
+
* the outcome alone cannot) without ever letting an unknown string through.
|
|
98
|
+
*/
|
|
99
|
+
const KNOWN_FAILURE_COPY_V1 = new Set<string>([
|
|
100
|
+
...Object.values(RUN_FAILURE_COPY_V1),
|
|
101
|
+
...USER_FACING_FAILURE_REASONS_V1,
|
|
102
|
+
RUN_FAILURE_FALLBACK_COPY_V1,
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/** The failure if the product wrote it, else the line every failure can use. */
|
|
106
|
+
export function knownFailureCopyV1(failure: string | undefined): string {
|
|
107
|
+
return failure && KNOWN_FAILURE_COPY_V1.has(failure)
|
|
108
|
+
? failure
|
|
109
|
+
: RUN_FAILURE_FALLBACK_COPY_V1;
|
|
110
|
+
}
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type SessionEvent } from "@frockbot/kernel-contracts";
|
|
|
3
3
|
import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
|
|
4
4
|
import type { StoredRun } from "./backend-contracts.js";
|
|
5
5
|
import { planBotRunRecovery } from "./backend-recovery.js";
|
|
6
|
+
import { RUN_FAILURE_COPY_V1 } from "./run-failure-copy.js";
|
|
6
7
|
import {
|
|
7
8
|
createClientRunStopReceiptV1,
|
|
8
9
|
decodeClientNotificationAcknowledgementCommandV1,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
decodeClientRunLookupQueryV1,
|
|
11
12
|
decodeClientRunReconciliationCommandV1,
|
|
12
13
|
decodeClientRunStopCommandV1,
|
|
14
|
+
RESUMABLE_RUN_MESSAGE_V1,
|
|
13
15
|
decodeClientRunStopReceiptV1,
|
|
14
16
|
decodeClientTurnCommandV1,
|
|
15
17
|
decodeClientRunLookupV1,
|
|
@@ -285,7 +287,7 @@ describe("client run protocol v1", () => {
|
|
|
285
287
|
stopRequestedAt: "2026-08-28T00:00:05.000Z",
|
|
286
288
|
outcome: {
|
|
287
289
|
type: "cancelled",
|
|
288
|
-
message: "
|
|
290
|
+
message: "You stopped this.",
|
|
289
291
|
},
|
|
290
292
|
});
|
|
291
293
|
expect(projectClientRunLookupV1(storedRun([], "cancelled"))).toMatchObject({
|
|
@@ -297,7 +299,7 @@ describe("client run protocol v1", () => {
|
|
|
297
299
|
).run,
|
|
298
300
|
).toMatchObject({
|
|
299
301
|
status: "cancelled",
|
|
300
|
-
failure: "
|
|
302
|
+
failure: "You stopped this.",
|
|
301
303
|
});
|
|
302
304
|
|
|
303
305
|
expect(() =>
|
|
@@ -745,7 +747,7 @@ describe("client run protocol v1", () => {
|
|
|
745
747
|
],
|
|
746
748
|
recovery: {
|
|
747
749
|
action: "resume",
|
|
748
|
-
message:
|
|
750
|
+
message: RESUMABLE_RUN_MESSAGE_V1,
|
|
749
751
|
},
|
|
750
752
|
},
|
|
751
753
|
],
|
|
@@ -758,10 +760,10 @@ describe("client run protocol v1", () => {
|
|
|
758
760
|
input: "continue",
|
|
759
761
|
status: "reconciliation-required",
|
|
760
762
|
events: projected.runs[0]?.events,
|
|
761
|
-
failure:
|
|
763
|
+
failure: RESUMABLE_RUN_MESSAGE_V1,
|
|
762
764
|
recovery: {
|
|
763
765
|
action: "resume",
|
|
764
|
-
message:
|
|
766
|
+
message: RESUMABLE_RUN_MESSAGE_V1,
|
|
765
767
|
},
|
|
766
768
|
},
|
|
767
769
|
]);
|
|
@@ -1278,14 +1280,18 @@ describe("client run protocol v1", () => {
|
|
|
1278
1280
|
events,
|
|
1279
1281
|
failure: plan.failure,
|
|
1280
1282
|
});
|
|
1283
|
+
// The provider's own words — its name, its status code — stay on the
|
|
1284
|
+
// stored record, which is what the debug surface reads. What crosses to a
|
|
1285
|
+
// chat bubble is the sentence for the outcome, and nothing else.
|
|
1281
1286
|
expect(decodeClientRunLookupV1(structuredClone(lookup))).toMatchObject({
|
|
1282
1287
|
state: "terminal",
|
|
1283
1288
|
run: {
|
|
1284
1289
|
status: "failed",
|
|
1285
|
-
failure:
|
|
1286
|
-
"Bot turn ended with outcome model-error: Ollama Cloud responded 401: invalid api key",
|
|
1290
|
+
failure: RUN_FAILURE_COPY_V1["model-error"],
|
|
1287
1291
|
},
|
|
1288
1292
|
});
|
|
1293
|
+
expect(JSON.stringify(lookup)).not.toContain("Ollama Cloud");
|
|
1294
|
+
expect(JSON.stringify(lookup)).not.toContain("model-error");
|
|
1289
1295
|
});
|
|
1290
1296
|
|
|
1291
1297
|
test("carries rename announcements beside the Turns, and refuses a bad one", () => {
|
|
@@ -1335,6 +1341,43 @@ describe("client run protocol v1", () => {
|
|
|
1335
1341
|
}),
|
|
1336
1342
|
).toThrow("run list.announcement.at is invalid");
|
|
1337
1343
|
});
|
|
1344
|
+
|
|
1345
|
+
test("announces a compaction without putting its summary on the wire", () => {
|
|
1346
|
+
const announcements = projectClientAnnouncementsV1([
|
|
1347
|
+
{ type: "turn/start", seq: 0, timestamp, turn: 1 },
|
|
1348
|
+
{
|
|
1349
|
+
type: "conversation/compacted",
|
|
1350
|
+
seq: 4,
|
|
1351
|
+
timestamp,
|
|
1352
|
+
effectId: "compaction-1",
|
|
1353
|
+
fromTurn: 1,
|
|
1354
|
+
throughTurn: 6,
|
|
1355
|
+
summary: "## Summary\nsomething private to the model",
|
|
1356
|
+
identifiers: ["applet-9f2c"],
|
|
1357
|
+
provider: "ollama-cloud",
|
|
1358
|
+
model: "kimi-k2",
|
|
1359
|
+
},
|
|
1360
|
+
]);
|
|
1361
|
+
expect(announcements).toEqual([
|
|
1362
|
+
{
|
|
1363
|
+
type: "conversation/compacted",
|
|
1364
|
+
announcementId: "compaction-4",
|
|
1365
|
+
at: timestamp,
|
|
1366
|
+
throughTurn: 6,
|
|
1367
|
+
},
|
|
1368
|
+
]);
|
|
1369
|
+
const page = createClientRunListV1([], { truncated: false }, announcements);
|
|
1370
|
+
expect(JSON.stringify(page)).not.toContain("something private");
|
|
1371
|
+
expect(decodeClientRunPageV1(structuredClone(page)).announcements).toEqual(
|
|
1372
|
+
announcements,
|
|
1373
|
+
);
|
|
1374
|
+
expect(() =>
|
|
1375
|
+
decodeClientRunPageV1({
|
|
1376
|
+
...page,
|
|
1377
|
+
announcements: [{ ...announcements[0], throughTurn: 0 }],
|
|
1378
|
+
}),
|
|
1379
|
+
).toThrow("run list.announcement.throughTurn is invalid");
|
|
1380
|
+
});
|
|
1338
1381
|
});
|
|
1339
1382
|
|
|
1340
1383
|
describe("dispatched subagents in the run projection", () => {
|
package/src/run-protocol.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type BotTurnCompletion,
|
|
25
25
|
type StoredRun,
|
|
26
26
|
} from "./backend-contracts.js";
|
|
27
|
+
import { runFailureCopyV1 } from "./run-failure-copy.js";
|
|
27
28
|
|
|
28
29
|
const MAX_RUN_ID_LENGTH = 128;
|
|
29
30
|
const MAX_TIMESTAMP_LENGTH = 64;
|
|
@@ -54,8 +55,15 @@ export type ClientRunStatusV1 =
|
|
|
54
55
|
| "superseded"
|
|
55
56
|
| "reconciliation-required";
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
// Both are sentences for the person, not descriptions of the mechanism: the
|
|
59
|
+
// wire outcome is what a client with no copy of its own renders verbatim, and
|
|
60
|
+
// "Stopped by an authenticated Stop command" told somebody who pressed Stop
|
|
61
|
+
// about the authentication of their own button press.
|
|
62
|
+
const CANCELLED_RUN_MESSAGE = "You stopped this.";
|
|
58
63
|
const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
|
|
64
|
+
/** What a Turn waiting on a person's "Try again" says while it waits. */
|
|
65
|
+
export const RESUMABLE_RUN_MESSAGE_V1 =
|
|
66
|
+
"This reply stopped partway. Try again to continue it.";
|
|
59
67
|
|
|
60
68
|
/**
|
|
61
69
|
* Why the Bot declined to admit a Turn. A refusal is an ordinary answer — the
|
|
@@ -244,14 +252,30 @@ export interface ClientRunPageV1 {
|
|
|
244
252
|
* A durable Session event that belongs to no Turn. The WebUI renders it as a
|
|
245
253
|
* system line in the conversation.
|
|
246
254
|
*/
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
/**
|
|
256
|
+
* A session-level line in the transcript that belongs to neither party.
|
|
257
|
+
*
|
|
258
|
+
* `conversation/compacted` is ADR 0030's one user-visible surface: the earlier
|
|
259
|
+
* Turns are still there and still readable, and this says plainly that the
|
|
260
|
+
* model now carries a summary of them instead of the Turns themselves. ADR
|
|
261
|
+
* 0027's "not summarised" notice still stands where Turns were genuinely
|
|
262
|
+
* evicted, so the two never claim each other's ground.
|
|
263
|
+
*/
|
|
264
|
+
export type ClientAnnouncementV1 =
|
|
265
|
+
| {
|
|
266
|
+
type: "bot/renamed";
|
|
267
|
+
announcementId: string;
|
|
268
|
+
at: string;
|
|
269
|
+
from: string;
|
|
270
|
+
to: string;
|
|
271
|
+
namedBy: "user" | "bot";
|
|
272
|
+
}
|
|
273
|
+
| {
|
|
274
|
+
type: "conversation/compacted";
|
|
275
|
+
announcementId: string;
|
|
276
|
+
at: string;
|
|
277
|
+
throughTurn: number;
|
|
278
|
+
};
|
|
255
279
|
|
|
256
280
|
export interface ClientRunListV1 {
|
|
257
281
|
schemaVersion: 1;
|
|
@@ -286,6 +310,19 @@ export interface ClientConversationListV1 {
|
|
|
286
310
|
conversations: ClientConversationV1[];
|
|
287
311
|
}
|
|
288
312
|
|
|
313
|
+
/**
|
|
314
|
+
* The answer to "start a new conversation": the list, or the reason not now.
|
|
315
|
+
*
|
|
316
|
+
* A refusal is a value, not an exception. The request crosses a Durable Object
|
|
317
|
+
* boundary and a Worker boundary to get here, and an exception that crosses
|
|
318
|
+
* either is logged by workerd as `Uncaught Error` — the log then showed the
|
|
319
|
+
* isolate going down with a broken pipe behind it. Carrying the refusal as
|
|
320
|
+
* data means the only thing that reaches the client is the 409 it expects.
|
|
321
|
+
*/
|
|
322
|
+
export type ClientConversationOutcomeV1 =
|
|
323
|
+
| ({ status: "started" } & ClientConversationListV1)
|
|
324
|
+
| { status: "refused"; schemaVersion: 1; reason: string };
|
|
325
|
+
|
|
289
326
|
export function decodeClientConversationListV1(
|
|
290
327
|
input: unknown,
|
|
291
328
|
): ClientConversationListV1 {
|
|
@@ -807,8 +844,11 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
807
844
|
: status === "failed"
|
|
808
845
|
? ({
|
|
809
846
|
type: "failed",
|
|
847
|
+
// The stored `failure` is a diagnostic and stays one: it is what
|
|
848
|
+
// the debug surface reads. What crosses to a chat bubble is the
|
|
849
|
+
// sentence written for the person — see `runFailureCopyV1`.
|
|
810
850
|
message: truncateWireString(
|
|
811
|
-
run.failure
|
|
851
|
+
runFailureCopyV1({ failure: run.failure, events: run.events }),
|
|
812
852
|
MAX_FAILURE_BYTES,
|
|
813
853
|
),
|
|
814
854
|
...interruptedOutcomeTextV1(run),
|
|
@@ -830,11 +870,10 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
|
830
870
|
status === "reconciliation-required"
|
|
831
871
|
? ({
|
|
832
872
|
action: "resume",
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
),
|
|
873
|
+
// The stored failure is the diagnostic the debug surface reads; a
|
|
874
|
+
// person offered a "Try again" needs the sentence, not the reason
|
|
875
|
+
// the Bot cannot answer it on its own.
|
|
876
|
+
message: RESUMABLE_RUN_MESSAGE_V1,
|
|
838
877
|
} satisfies ClientRunRecoveryV1)
|
|
839
878
|
: undefined;
|
|
840
879
|
return {
|
|
@@ -983,24 +1022,74 @@ export function createClientRunListV1(
|
|
|
983
1022
|
const MAX_ANNOUNCEMENTS = 64;
|
|
984
1023
|
const MAX_ANNOUNCEMENT_NAME_BYTES = 400;
|
|
985
1024
|
|
|
986
|
-
/**
|
|
1025
|
+
/**
|
|
1026
|
+
* Where each Turn ended, by Turn number.
|
|
1027
|
+
*
|
|
1028
|
+
* A compaction is written at the end of the Turn that crossed the threshold,
|
|
1029
|
+
* which is the *newest* Turn — so its own timestamp would place its marker at
|
|
1030
|
+
* the bottom of the thread, far from the range it describes. The boundary it
|
|
1031
|
+
* actually names is the end of `throughTurn`, and that is what the marker is
|
|
1032
|
+
* dated with.
|
|
1033
|
+
*/
|
|
1034
|
+
function turnEndTimestampsV1(
|
|
1035
|
+
session: readonly SessionEvent[],
|
|
1036
|
+
): Map<number, string> {
|
|
1037
|
+
const ends = new Map<number, string>();
|
|
1038
|
+
for (const event of session) {
|
|
1039
|
+
if (event.type === "turn/end") ends.set(event.turn, event.timestamp);
|
|
1040
|
+
}
|
|
1041
|
+
return ends;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Projects the Bot's durable announcement events onto the wire.
|
|
1046
|
+
*
|
|
1047
|
+
* `session` is the conversation's own log, used only to date a compaction
|
|
1048
|
+
* marker at the boundary it covers. Omitting it dates the marker by when the
|
|
1049
|
+
* compaction was written, which is where it used to sit.
|
|
1050
|
+
*/
|
|
987
1051
|
export function projectClientAnnouncementsV1(
|
|
988
1052
|
events: readonly SessionEvent[],
|
|
1053
|
+
session: readonly SessionEvent[] = events,
|
|
989
1054
|
): ClientAnnouncementV1[] {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1055
|
+
const turnEnds = turnEndTimestampsV1(session);
|
|
1056
|
+
return events.flatMap((event): ClientAnnouncementV1[] => {
|
|
1057
|
+
if (event.type === "bot/renamed") {
|
|
1058
|
+
return [
|
|
1059
|
+
{
|
|
1060
|
+
type: "bot/renamed" as const,
|
|
1061
|
+
announcementId: `announcement-${event.seq}`,
|
|
1062
|
+
at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
|
|
1063
|
+
from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
|
|
1064
|
+
to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
|
|
1065
|
+
namedBy: event.namedBy,
|
|
1066
|
+
},
|
|
1067
|
+
];
|
|
1068
|
+
}
|
|
1069
|
+
if (event.type === "conversation/compacted") {
|
|
1070
|
+
// The summary itself is deliberately not on the wire. A person can read
|
|
1071
|
+
// every Turn it covers, unchanged, immediately above this line; the
|
|
1072
|
+
// summary is what the model carries, and it belongs to the audit view.
|
|
1073
|
+
return [
|
|
1074
|
+
{
|
|
1075
|
+
type: "conversation/compacted" as const,
|
|
1076
|
+
// A distinct prefix: a compaction is numbered by the session log and
|
|
1077
|
+
// a rename by this Bot's announcement log, and the two counters would
|
|
1078
|
+
// otherwise collide on an id the client upserts by.
|
|
1079
|
+
announcementId: `compaction-${event.seq}`,
|
|
1080
|
+
// Dated where the covered range ends, not when the summariser ran,
|
|
1081
|
+
// so the marker sits between the last compacted Turn and the first
|
|
1082
|
+
// verbatim one and stays there as newer Turns arrive.
|
|
1083
|
+
at: truncate(
|
|
1084
|
+
turnEnds.get(event.throughTurn) ?? event.timestamp,
|
|
1085
|
+
MAX_TIMESTAMP_LENGTH,
|
|
1086
|
+
),
|
|
1087
|
+
throughTurn: event.throughTurn,
|
|
1088
|
+
},
|
|
1089
|
+
];
|
|
1090
|
+
}
|
|
1091
|
+
return [];
|
|
1092
|
+
});
|
|
1004
1093
|
}
|
|
1005
1094
|
|
|
1006
1095
|
export function clientRunListWireBytes(value: ClientRunListV1): number {
|
|
@@ -1828,14 +1917,19 @@ export function decodeClientRunLookupV1(input: unknown): ClientRunLookup {
|
|
|
1828
1917
|
|
|
1829
1918
|
function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
|
|
1830
1919
|
const announcement = record(value, "run list.announcement");
|
|
1920
|
+
if (
|
|
1921
|
+
announcement.type !== "bot/renamed" &&
|
|
1922
|
+
announcement.type !== "conversation/compacted"
|
|
1923
|
+
) {
|
|
1924
|
+
throw new Error("run list.announcement.type is invalid");
|
|
1925
|
+
}
|
|
1831
1926
|
exactKeys(
|
|
1832
1927
|
announcement,
|
|
1833
|
-
|
|
1928
|
+
announcement.type === "conversation/compacted"
|
|
1929
|
+
? ["type", "announcementId", "at", "throughTurn"]
|
|
1930
|
+
: ["type", "announcementId", "at", "from", "to", "namedBy"],
|
|
1834
1931
|
"run list.announcement",
|
|
1835
1932
|
);
|
|
1836
|
-
if (announcement.type !== "bot/renamed") {
|
|
1837
|
-
throw new Error("run list.announcement.type is invalid");
|
|
1838
|
-
}
|
|
1839
1933
|
const at = string(
|
|
1840
1934
|
announcement,
|
|
1841
1935
|
"at",
|
|
@@ -1845,20 +1939,35 @@ function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
|
|
|
1845
1939
|
if (!Number.isFinite(Date.parse(at))) {
|
|
1846
1940
|
throw new Error("run list.announcement.at is invalid");
|
|
1847
1941
|
}
|
|
1942
|
+
const announcementId = publicEventId(
|
|
1943
|
+
string(
|
|
1944
|
+
announcement,
|
|
1945
|
+
"announcementId",
|
|
1946
|
+
MAX_EVENT_ID_LENGTH,
|
|
1947
|
+
"run list.announcement",
|
|
1948
|
+
),
|
|
1949
|
+
"run list.announcement.announcementId",
|
|
1950
|
+
);
|
|
1951
|
+
if (announcement.type === "conversation/compacted") {
|
|
1952
|
+
if (
|
|
1953
|
+
!Number.isSafeInteger(announcement.throughTurn) ||
|
|
1954
|
+
(announcement.throughTurn as number) < 1
|
|
1955
|
+
) {
|
|
1956
|
+
throw new Error("run list.announcement.throughTurn is invalid");
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
type: "conversation/compacted",
|
|
1960
|
+
announcementId,
|
|
1961
|
+
at,
|
|
1962
|
+
throughTurn: announcement.throughTurn as number,
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1848
1965
|
if (announcement.namedBy !== "user" && announcement.namedBy !== "bot") {
|
|
1849
1966
|
throw new Error("run list.announcement.namedBy is invalid");
|
|
1850
1967
|
}
|
|
1851
1968
|
return {
|
|
1852
1969
|
type: "bot/renamed",
|
|
1853
|
-
announcementId
|
|
1854
|
-
string(
|
|
1855
|
-
announcement,
|
|
1856
|
-
"announcementId",
|
|
1857
|
-
MAX_EVENT_ID_LENGTH,
|
|
1858
|
-
"run list.announcement",
|
|
1859
|
-
),
|
|
1860
|
-
"run list.announcement.announcementId",
|
|
1861
|
-
),
|
|
1970
|
+
announcementId,
|
|
1862
1971
|
at,
|
|
1863
1972
|
from: wireString(
|
|
1864
1973
|
announcement,
|
|
@@ -62,10 +62,16 @@ describe("settings link scheme", () => {
|
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
test("
|
|
65
|
+
test("a fragment belonging to another surface opens that surface", () => {
|
|
66
|
+
// The row is the specific request; the query parameter is where the link
|
|
67
|
+
// was written from, and rows move between surfaces.
|
|
66
68
|
expect(
|
|
67
69
|
decodeSettingsLinkV1("/?settings=bot-settings#bot-info-computer"),
|
|
68
|
-
).toEqual({ surface: "bot-
|
|
70
|
+
).toEqual({ surface: "bot-panel", anchor: "bot-info-computer" });
|
|
71
|
+
expect(decodeSettingsLinkV1("/?settings=bot-panel#bot-routines")).toEqual({
|
|
72
|
+
surface: "bot-settings",
|
|
73
|
+
anchor: "bot-routines",
|
|
74
|
+
});
|
|
69
75
|
});
|
|
70
76
|
|
|
71
77
|
test("drops an anchor nobody registered", () => {
|