@frockbot/plugin-shell 0.3.6 → 0.3.8
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-recovery-integration.test.ts +12 -18
- package/src/backend.ts +84 -11
- package/src/client/FrockBotApp.vue +66 -1
- package/src/client/index.test.ts +132 -0
- package/src/client/index.ts +151 -21
- package/src/client/styles.css +34 -0
- package/src/client/turn-limits.test.ts +41 -0
- package/src/client/turn-limits.ts +40 -0
- package/src/client/uncertain-admission.test.ts +69 -0
- package/src/client/uncertain-admission.ts +78 -0
- package/src/connection-return.test.ts +55 -0
- package/src/debug-protocol.test.ts +38 -2
- package/src/debug-protocol.ts +39 -6
- package/src/history.test.ts +52 -0
- package/src/history.ts +79 -1
- package/src/run-protocol.test.ts +49 -3
- package/src/run-protocol.ts +134 -17
- package/src/shared.ts +72 -0
- package/src/unread.test.ts +68 -0
- package/src/unread.ts +53 -0
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
BOT_DEBUG_RUN_LIMIT_V1,
|
|
4
4
|
boundDebugEventsV1,
|
|
5
5
|
decodeBotDebugQueryV1,
|
|
6
|
+
isBotDebugQueryRefusalV1,
|
|
6
7
|
} from "./debug-protocol.js";
|
|
7
8
|
|
|
8
9
|
describe("debug query", () => {
|
|
@@ -36,13 +37,48 @@ describe("debug query", () => {
|
|
|
36
37
|
).toThrow("debug query has invalid fields");
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
test("rejects a limit past the page bound", () => {
|
|
40
|
+
test("rejects a limit past the page bound, in words that name the range", () => {
|
|
40
41
|
expect(() =>
|
|
41
42
|
decodeBotDebugQueryV1({
|
|
42
43
|
schemaVersion: 1,
|
|
43
44
|
limit: BOT_DEBUG_RUN_LIMIT_V1 + 1,
|
|
44
45
|
}),
|
|
45
|
-
).toThrow(
|
|
46
|
+
).toThrow(
|
|
47
|
+
`debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The refusal rides on the error's name, which is all a Durable Object RPC
|
|
52
|
+
// preserves: a bad query has to arrive at the gateway as a 400 and not as
|
|
53
|
+
// an uncaught failure in the Bot's isolate.
|
|
54
|
+
test("refuses a bad query as a refusal, not as an ordinary failure", () => {
|
|
55
|
+
for (const input of [
|
|
56
|
+
{ schemaVersion: 1, limit: 0 },
|
|
57
|
+
{ schemaVersion: 1, limit: BOT_DEBUG_RUN_LIMIT_V1 + 1 },
|
|
58
|
+
{ schemaVersion: 1, limit: 1.5 },
|
|
59
|
+
{ schemaVersion: 1, limit: Number.NaN },
|
|
60
|
+
{ schemaVersion: 1, sql: "select 1" },
|
|
61
|
+
{ schemaVersion: 2 },
|
|
62
|
+
"not a query",
|
|
63
|
+
]) {
|
|
64
|
+
let refusal: unknown;
|
|
65
|
+
try {
|
|
66
|
+
decodeBotDebugQueryV1(input);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
refusal = error;
|
|
69
|
+
}
|
|
70
|
+
expect(isBotDebugQueryRefusalV1(refusal)).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("accepts both ends of the allowed range", () => {
|
|
75
|
+
expect(decodeBotDebugQueryV1({ schemaVersion: 1, limit: 1 }).limit).toBe(1);
|
|
76
|
+
expect(
|
|
77
|
+
decodeBotDebugQueryV1({
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
limit: BOT_DEBUG_RUN_LIMIT_V1,
|
|
80
|
+
}).limit,
|
|
81
|
+
).toBe(BOT_DEBUG_RUN_LIMIT_V1);
|
|
46
82
|
});
|
|
47
83
|
|
|
48
84
|
test("rejects a wrong schema version", () => {
|
package/src/debug-protocol.ts
CHANGED
|
@@ -90,21 +90,50 @@ export interface BotDebugSnapshotV1 {
|
|
|
90
90
|
nextCursor?: string;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A debug query the caller got wrong: an unknown field, a `limit` past the cap.
|
|
95
|
+
* The request is what is bad, not the Bot, so the surface owes a 400 rather
|
|
96
|
+
* than an uncaught failure in the isolate. The name is what carries that
|
|
97
|
+
* across the Durable Object RPC boundary — which keeps an error's `name` and
|
|
98
|
+
* `message` and drops everything else — exactly as `BotTurnRefusedError` does
|
|
99
|
+
* for a refused admission.
|
|
100
|
+
*/
|
|
101
|
+
export class BotDebugQueryRefusedErrorV1 extends Error {
|
|
102
|
+
constructor(message: string) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = "BotDebugQueryRefusedErrorV1";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether an error — including one that has crossed RPC — is that refusal. */
|
|
109
|
+
export function isBotDebugQueryRefusalV1(error: unknown): boolean {
|
|
110
|
+
return (
|
|
111
|
+
typeof error === "object" &&
|
|
112
|
+
error !== null &&
|
|
113
|
+
"name" in error &&
|
|
114
|
+
String((error as { name: unknown }).name) === "BotDebugQueryRefusedErrorV1"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
119
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
95
120
|
}
|
|
96
121
|
|
|
97
122
|
function boundedString(value: unknown, maximum: number, field: string): string {
|
|
98
123
|
if (typeof value !== "string" || value.length < 1 || value.length > maximum) {
|
|
99
|
-
throw new
|
|
124
|
+
throw new BotDebugQueryRefusedErrorV1(`debug query ${field} is invalid`);
|
|
100
125
|
}
|
|
101
126
|
return value;
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
105
|
-
if (!isRecord(input))
|
|
130
|
+
if (!isRecord(input)) {
|
|
131
|
+
throw new BotDebugQueryRefusedErrorV1("debug query is invalid");
|
|
132
|
+
}
|
|
106
133
|
if (input.schemaVersion !== 1) {
|
|
107
|
-
throw new
|
|
134
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
135
|
+
"debug query schemaVersion is invalid",
|
|
136
|
+
);
|
|
108
137
|
}
|
|
109
138
|
const allowed = new Set([
|
|
110
139
|
"schemaVersion",
|
|
@@ -114,7 +143,7 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
|
114
143
|
"events",
|
|
115
144
|
]);
|
|
116
145
|
if (!Object.keys(input).every((key) => allowed.has(key))) {
|
|
117
|
-
throw new
|
|
146
|
+
throw new BotDebugQueryRefusedErrorV1("debug query has invalid fields");
|
|
118
147
|
}
|
|
119
148
|
const query: BotDebugQueryV1 = { schemaVersion: 1 };
|
|
120
149
|
if (input.runId !== undefined) {
|
|
@@ -129,13 +158,17 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
|
129
158
|
(input.limit as number) < 1 ||
|
|
130
159
|
(input.limit as number) > BOT_DEBUG_RUN_LIMIT_V1
|
|
131
160
|
) {
|
|
132
|
-
throw new
|
|
161
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
162
|
+
`debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
|
|
163
|
+
);
|
|
133
164
|
}
|
|
134
165
|
query.limit = input.limit as number;
|
|
135
166
|
}
|
|
136
167
|
if (input.events !== undefined) {
|
|
137
168
|
if (typeof input.events !== "boolean") {
|
|
138
|
-
throw new
|
|
169
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
170
|
+
"debug query events must be true or false",
|
|
171
|
+
);
|
|
139
172
|
}
|
|
140
173
|
query.events = input.events;
|
|
141
174
|
}
|
package/src/history.test.ts
CHANGED
|
@@ -78,6 +78,58 @@ function scoped(events: SessionEvent[]): LlmMessage[] {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function scopedWithBudget(events: SessionEvent[], budget: number) {
|
|
82
|
+
return turnScopedMessagesV1({
|
|
83
|
+
events,
|
|
84
|
+
messages: derive(events),
|
|
85
|
+
pointer: automationParentPointerV1,
|
|
86
|
+
sessionId: "bot:scout",
|
|
87
|
+
budget,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("one request carries a bounded amount of history", () => {
|
|
92
|
+
test("keeps the current Turn whole and drops the oldest, with a notice", () => {
|
|
93
|
+
const events = log([
|
|
94
|
+
...turn(1, "chat", "the oldest thing", "first reply"),
|
|
95
|
+
...turn(2, "chat", "the middle thing", "second reply"),
|
|
96
|
+
...turn(3, "chat", "the newest thing", ""),
|
|
97
|
+
]);
|
|
98
|
+
// Room for the current Turn and one older one, not for all three.
|
|
99
|
+
const messages = scopedWithBudget(events, 200);
|
|
100
|
+
|
|
101
|
+
const contents = messages.map((message) => message.content);
|
|
102
|
+
expect(contents).toContain("the newest thing");
|
|
103
|
+
expect(contents.join(" ")).not.toContain("the oldest thing");
|
|
104
|
+
expect(contents[0]).toContain("not included here");
|
|
105
|
+
expect(contents[0]).toContain("1 Turn");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("carries everything when it fits, and says nothing about omission", () => {
|
|
109
|
+
const events = log([
|
|
110
|
+
...turn(1, "chat", "morning", "hello"),
|
|
111
|
+
...turn(2, "chat", "anything new?", ""),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
scopedWithBudget(events, 100_000).map((message) => message.content),
|
|
116
|
+
).toEqual(["morning", "hello", "anything new?", ""]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("keeps the current Turn even when it alone exceeds the budget", () => {
|
|
120
|
+
const events = log([
|
|
121
|
+
...turn(1, "chat", "old", "older"),
|
|
122
|
+
...turn(2, "chat", "x".repeat(500), ""),
|
|
123
|
+
]);
|
|
124
|
+
const messages = scopedWithBudget(events, 50);
|
|
125
|
+
|
|
126
|
+
// A Turn is never split: dropping the user message and keeping the reply
|
|
127
|
+
// would be a malformed request, so the current Turn survives whole.
|
|
128
|
+
expect(messages.at(-2)!.content).toBe("x".repeat(500));
|
|
129
|
+
expect(messages[0]!.content).toContain("not included here");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
81
133
|
describe("turn-scoped prompt history", () => {
|
|
82
134
|
test("a chat Turn sees only the Turns admitted as chat", () => {
|
|
83
135
|
const events = log([
|
package/src/history.ts
CHANGED
|
@@ -68,6 +68,76 @@ export interface TurnScopedMessagesInputV1 {
|
|
|
68
68
|
/** The parent-transcript pointer, used only on a non-chat Turn. */
|
|
69
69
|
pointer(input: { sessionId: string; chatTurns: number }): string;
|
|
70
70
|
sessionId: string;
|
|
71
|
+
/**
|
|
72
|
+
* How many characters of history one request may carry. The current Turn is
|
|
73
|
+
* always whole; older Turns fill what is left. Absent means the default.
|
|
74
|
+
*/
|
|
75
|
+
budget?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How much conversation one model request carries.
|
|
80
|
+
*
|
|
81
|
+
* A number in characters, not tokens: this is a Package policy bound whose job
|
|
82
|
+
* is to stop a request growing without limit, and it does not need to agree
|
|
83
|
+
* with any provider's tokenizer to do that. Roughly 150k characters is well
|
|
84
|
+
* inside every model FrockBot resolves today while being far more history than
|
|
85
|
+
* any conversation needs.
|
|
86
|
+
*/
|
|
87
|
+
export const CHAT_HISTORY_BUDGET_CHARS_V1 = 150_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The line that stands where the dropped Turns were.
|
|
91
|
+
*
|
|
92
|
+
* It is said plainly, because a model that cannot see the beginning of a
|
|
93
|
+
* conversation and is not told so will confidently answer as though it had.
|
|
94
|
+
*/
|
|
95
|
+
export function omittedHistoryNoticeV1(turns: number): string {
|
|
96
|
+
return `Earlier in this conversation there ${turns === 1 ? "was 1 Turn" : `were ${turns} Turns`} that are not included here. They are not summarised: if you need something from them, say so or search your memory rather than guessing.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function messageChars(message: LlmMessage): number {
|
|
100
|
+
return JSON.stringify(message).length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Narrows history to a character budget, oldest Turns first.
|
|
105
|
+
*
|
|
106
|
+
* Eviction is by whole Turn on purpose. A tool result whose call has been
|
|
107
|
+
* dropped is a malformed request to every provider, and a Turn is the
|
|
108
|
+
* smallest unit that always holds both.
|
|
109
|
+
*/
|
|
110
|
+
function budgetedMessagesV1(
|
|
111
|
+
messages: readonly LlmMessage[],
|
|
112
|
+
turns: readonly number[],
|
|
113
|
+
current: number,
|
|
114
|
+
budget: number,
|
|
115
|
+
): LlmMessage[] {
|
|
116
|
+
const total = messages.reduce(
|
|
117
|
+
(sum, message) => sum + messageChars(message),
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
if (total <= budget) return [...messages];
|
|
121
|
+
const spendByTurn = new Map<number, number>();
|
|
122
|
+
for (const [index, message] of messages.entries()) {
|
|
123
|
+
const turn = turns[index]!;
|
|
124
|
+
spendByTurn.set(turn, (spendByTurn.get(turn) ?? 0) + messageChars(message));
|
|
125
|
+
}
|
|
126
|
+
const ordered = [...spendByTurn.keys()].sort((left, right) => right - left);
|
|
127
|
+
const kept = new Set<number>([current]);
|
|
128
|
+
let spent = spendByTurn.get(current) ?? 0;
|
|
129
|
+
for (const turn of ordered) {
|
|
130
|
+
if (turn === current) continue;
|
|
131
|
+
const cost = spendByTurn.get(turn) ?? 0;
|
|
132
|
+
if (spent + cost > budget) break;
|
|
133
|
+
kept.add(turn);
|
|
134
|
+
spent += cost;
|
|
135
|
+
}
|
|
136
|
+
const dropped = ordered.filter((turn) => !kept.has(turn)).length;
|
|
137
|
+
const narrowed = messages.filter((_, index) => kept.has(turns[index]!));
|
|
138
|
+
return dropped === 0
|
|
139
|
+
? narrowed
|
|
140
|
+
: [{ role: "user", content: omittedHistoryNoticeV1(dropped) }, ...narrowed];
|
|
71
141
|
}
|
|
72
142
|
|
|
73
143
|
/**
|
|
@@ -92,7 +162,15 @@ export function turnScopedMessagesV1(
|
|
|
92
162
|
const current = currentTurnV1(input.events);
|
|
93
163
|
const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
|
|
94
164
|
if (chatTurn(current)) {
|
|
95
|
-
|
|
165
|
+
const conversation = input.messages.filter((_, index) =>
|
|
166
|
+
chatTurn(turns[index]!),
|
|
167
|
+
);
|
|
168
|
+
return budgetedMessagesV1(
|
|
169
|
+
conversation,
|
|
170
|
+
turns.filter((turn) => chatTurn(turn)),
|
|
171
|
+
current,
|
|
172
|
+
input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1,
|
|
173
|
+
);
|
|
96
174
|
}
|
|
97
175
|
const own = input.messages.filter((_, index) => turns[index] === current);
|
|
98
176
|
const chatTurns = new Set(
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
projectClientRunV1,
|
|
25
25
|
projectClientRunOrDegradedV1,
|
|
26
26
|
projectClientTurnV1,
|
|
27
|
+
UNRECORDED_TOOL_RESULT_TEXT_V1,
|
|
27
28
|
} from "./run-protocol.js";
|
|
28
29
|
|
|
29
30
|
const timestamp = "2026-08-29T00:00:00.000Z";
|
|
@@ -1061,14 +1062,59 @@ describe("client run protocol v1", () => {
|
|
|
1061
1062
|
expect(() => projectClientRunListV1([storedRun([result])])).toThrow(
|
|
1062
1063
|
'tool result has no matching occurrence "tool:1:1:0"',
|
|
1063
1064
|
);
|
|
1064
|
-
expect(() => projectClientRunListV1([storedRun([call])])).toThrow(
|
|
1065
|
-
'terminal run has no result for tool call "tool-1"',
|
|
1066
|
-
);
|
|
1067
1065
|
expect(() =>
|
|
1068
1066
|
projectClientRunListV1([storedRun([call, call, result])]),
|
|
1069
1067
|
).toThrow('tool occurrence "tool:1:1:0" has duplicate intent');
|
|
1070
1068
|
});
|
|
1071
1069
|
|
|
1070
|
+
// A READ never throws on a record that is already durable. A settled Turn
|
|
1071
|
+
// whose tool call was never settled used to fail the whole transcript
|
|
1072
|
+
// endpoint — a 500 on every later request — so one malformed row bricked the
|
|
1073
|
+
// conversation for ever. It degrades to a row saying nothing was recorded.
|
|
1074
|
+
test("degrades a settled Turn's unsettled tool call instead of throwing", () => {
|
|
1075
|
+
const call = toolEvents(1)[0]!;
|
|
1076
|
+
|
|
1077
|
+
const projected = projectClientRunListV1([storedRun([call])]).runs[0];
|
|
1078
|
+
|
|
1079
|
+
expect(projected?.events).toEqual([
|
|
1080
|
+
{ type: "tool/call", call: { id: "tool-1", name: "lookup" } },
|
|
1081
|
+
{
|
|
1082
|
+
type: "tool/result",
|
|
1083
|
+
callId: "tool-1",
|
|
1084
|
+
content: UNRECORDED_TOOL_RESULT_TEXT_V1,
|
|
1085
|
+
isError: true,
|
|
1086
|
+
},
|
|
1087
|
+
]);
|
|
1088
|
+
// And the degraded row survives the wire decode, which used to refuse it
|
|
1089
|
+
// for the same reason the projection did.
|
|
1090
|
+
expect(
|
|
1091
|
+
decodeClientRunListV1({
|
|
1092
|
+
schemaVersion: 1,
|
|
1093
|
+
runs: [projected],
|
|
1094
|
+
page: { truncated: false },
|
|
1095
|
+
})[0]?.events,
|
|
1096
|
+
).toHaveLength(2);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test("accepts a settled Turn on the wire whose call carries no result", () => {
|
|
1100
|
+
const projected = projectClientRunListV1([storedRun([])]).runs[0]!;
|
|
1101
|
+
|
|
1102
|
+
expect(
|
|
1103
|
+
decodeClientRunListV1({
|
|
1104
|
+
schemaVersion: 1,
|
|
1105
|
+
runs: [
|
|
1106
|
+
{
|
|
1107
|
+
...projected,
|
|
1108
|
+
events: [
|
|
1109
|
+
{ type: "tool/call", call: { id: "tool-1", name: "lookup" } },
|
|
1110
|
+
],
|
|
1111
|
+
},
|
|
1112
|
+
],
|
|
1113
|
+
page: { truncated: false },
|
|
1114
|
+
})[0]?.events,
|
|
1115
|
+
).toEqual([{ type: "tool/call", call: { id: "tool-1", name: "lookup" } }]);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1072
1118
|
test("retains pending calls only for nonterminal runs", () => {
|
|
1073
1119
|
const call = toolEvents(1)[0]!;
|
|
1074
1120
|
const projected = projectClientRunListV1([storedRun([call], "running")])
|
package/src/run-protocol.ts
CHANGED
|
@@ -36,6 +36,8 @@ const MAX_NOTIFICATION_TITLE_BYTES = 512;
|
|
|
36
36
|
const MAX_NOTIFICATION_BODY_BYTES = 2_000;
|
|
37
37
|
const MAX_CLIENT_TURN_BYTES = 256_000;
|
|
38
38
|
const MAX_CURSOR_LENGTH = 320;
|
|
39
|
+
/** A conversation is named by its Session id, which the kernel bounds. */
|
|
40
|
+
const MAX_SESSION_ID_LENGTH = 320;
|
|
39
41
|
const MAX_TASK_DESCRIPTION_BYTES = 800;
|
|
40
42
|
const MAX_TASK_MODEL_BYTES = 512;
|
|
41
43
|
export const CLIENT_RUN_PAGE_LIMIT = 32;
|
|
@@ -251,9 +253,85 @@ export interface ClientRunListV1 {
|
|
|
251
253
|
announcements?: ClientAnnouncementV1[];
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
/**
|
|
257
|
+
* One conversation a Bot has had.
|
|
258
|
+
*
|
|
259
|
+
* A Bot holds one conversation at a time and keeps the ones before it: the
|
|
260
|
+
* transcript shows the current one, and an earlier one is still readable.
|
|
261
|
+
*/
|
|
262
|
+
export interface ClientConversationV1 {
|
|
263
|
+
schemaVersion: 1;
|
|
264
|
+
/** The Session id this conversation's Turns recorded. */
|
|
265
|
+
conversationId: string;
|
|
266
|
+
ordinal: number;
|
|
267
|
+
startedAt: string;
|
|
268
|
+
/** Absent while this is the conversation the Bot is on. */
|
|
269
|
+
endedAt?: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface ClientConversationListV1 {
|
|
273
|
+
schemaVersion: 1;
|
|
274
|
+
/** Newest first; the first entry is the conversation the Bot is on. */
|
|
275
|
+
conversations: ClientConversationV1[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function decodeClientConversationListV1(
|
|
279
|
+
input: unknown,
|
|
280
|
+
): ClientConversationListV1 {
|
|
281
|
+
const list = record(input, "conversation list");
|
|
282
|
+
exactKeys(list, ["schemaVersion", "conversations"], "conversation list");
|
|
283
|
+
if (list.schemaVersion !== 1) {
|
|
284
|
+
throw new Error("conversation list.schemaVersion is invalid");
|
|
285
|
+
}
|
|
286
|
+
if (!Array.isArray(list.conversations)) {
|
|
287
|
+
throw new Error("conversation list.conversations is invalid");
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
schemaVersion: 1,
|
|
291
|
+
conversations: list.conversations.map((entry) => {
|
|
292
|
+
const conversation = record(entry, "conversation");
|
|
293
|
+
exactKeys(
|
|
294
|
+
conversation,
|
|
295
|
+
["schemaVersion", "conversationId", "ordinal", "startedAt", "endedAt"],
|
|
296
|
+
"conversation",
|
|
297
|
+
);
|
|
298
|
+
if (conversation.schemaVersion !== 1) {
|
|
299
|
+
throw new Error("conversation.schemaVersion is invalid");
|
|
300
|
+
}
|
|
301
|
+
if (
|
|
302
|
+
typeof conversation.ordinal !== "number" ||
|
|
303
|
+
!Number.isSafeInteger(conversation.ordinal) ||
|
|
304
|
+
conversation.ordinal < 1
|
|
305
|
+
) {
|
|
306
|
+
throw new Error("conversation.ordinal is invalid");
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: 1 as const,
|
|
310
|
+
conversationId: string(
|
|
311
|
+
conversation,
|
|
312
|
+
"conversationId",
|
|
313
|
+
MAX_SESSION_ID_LENGTH,
|
|
314
|
+
"conversation",
|
|
315
|
+
),
|
|
316
|
+
ordinal: conversation.ordinal,
|
|
317
|
+
startedAt: string(conversation, "startedAt", 64, "conversation"),
|
|
318
|
+
...(conversation.endedAt === undefined
|
|
319
|
+
? {}
|
|
320
|
+
: { endedAt: string(conversation, "endedAt", 64, "conversation") }),
|
|
321
|
+
};
|
|
322
|
+
}),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
254
326
|
export interface ClientRunListQueryV1 {
|
|
255
327
|
schemaVersion: 1;
|
|
256
328
|
before?: string;
|
|
329
|
+
/**
|
|
330
|
+
* The conversation to read. Absent means the one the Bot is on now, which
|
|
331
|
+
* is what the transcript shows; an earlier conversation is named by the
|
|
332
|
+
* Session id `listConversations` gave for it.
|
|
333
|
+
*/
|
|
334
|
+
conversationId?: string;
|
|
257
335
|
}
|
|
258
336
|
|
|
259
337
|
export interface ClientTurnCommandV1 {
|
|
@@ -422,6 +500,14 @@ interface ProjectionUnitV1 {
|
|
|
422
500
|
droppable: boolean;
|
|
423
501
|
}
|
|
424
502
|
|
|
503
|
+
/**
|
|
504
|
+
* What a settled Turn's tool call shows when the durable record holds no
|
|
505
|
+
* result for it. Same register as the rest of the transcript copy: it tells
|
|
506
|
+
* the person what is missing rather than naming an occurrence id.
|
|
507
|
+
*/
|
|
508
|
+
export const UNRECORDED_TOOL_RESULT_TEXT_V1 =
|
|
509
|
+
"No result was recorded for this tool call.";
|
|
510
|
+
|
|
425
511
|
function dynamicToolCallInput(
|
|
426
512
|
value: unknown,
|
|
427
513
|
): ClientDynamicToolCallInputV1 | undefined {
|
|
@@ -580,12 +666,23 @@ function projectionUnits(
|
|
|
580
666
|
}
|
|
581
667
|
}
|
|
582
668
|
if (isTerminalRunStatus(status)) {
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
669
|
+
for (const unit of units) {
|
|
670
|
+
if (unit.droppable) continue;
|
|
671
|
+
// A settled Turn owes every tool call a result, and `Session`'s
|
|
672
|
+
// interruption repairs now write one. Records already durable from
|
|
673
|
+
// before that do not have it, and a READ must never throw on them: one
|
|
674
|
+
// malformed row used to brick the whole transcript endpoint for ever.
|
|
675
|
+
// The row degrades instead, and says exactly what is missing — not
|
|
676
|
+
// through `projectClientRunOrDegradedV1`, which throws the whole Turn
|
|
677
|
+
// away for an unreadable record. Everything else here is readable.
|
|
678
|
+
const call = unit.events[0] as ClientToolCallV1;
|
|
679
|
+
unit.events.push({
|
|
680
|
+
type: "tool/result",
|
|
681
|
+
callId: call.call.id,
|
|
682
|
+
content: UNRECORDED_TOOL_RESULT_TEXT_V1,
|
|
683
|
+
isError: true,
|
|
684
|
+
});
|
|
685
|
+
unit.droppable = true;
|
|
589
686
|
}
|
|
590
687
|
}
|
|
591
688
|
return units;
|
|
@@ -1059,10 +1156,12 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
|
|
|
1059
1156
|
throw new Error("run event.type is invalid");
|
|
1060
1157
|
}
|
|
1061
1158
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1159
|
+
/**
|
|
1160
|
+
* The event walk a wire run is decoded through. It no longer takes the run's
|
|
1161
|
+
* status: a settled Turn's tool call with no result is a row the projection
|
|
1162
|
+
* has already degraded, not a message to refuse.
|
|
1163
|
+
*/
|
|
1164
|
+
function decodeEvents(values: unknown[]): ClientTurnEvent[] {
|
|
1066
1165
|
const events = values.map(decodeEvent);
|
|
1067
1166
|
let index = 0;
|
|
1068
1167
|
if (events[0]?.type === "run/events-truncated") index = 1;
|
|
@@ -1100,9 +1199,10 @@ function decodeEvents(
|
|
|
1100
1199
|
index += 2;
|
|
1101
1200
|
continue;
|
|
1102
1201
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1202
|
+
// A settled Turn whose call has no result is a degraded row, not a bad
|
|
1203
|
+
// wire message: the projection above already renders it as "no result
|
|
1204
|
+
// recorded", and refusing it here would put the whole transcript behind
|
|
1205
|
+
// one durable record nobody can now repair.
|
|
1106
1206
|
index += 1;
|
|
1107
1207
|
}
|
|
1108
1208
|
return events;
|
|
@@ -1252,7 +1352,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1252
1352
|
admittedAt,
|
|
1253
1353
|
input: wireString(run, "input", MAX_INPUT_BYTES, "run"),
|
|
1254
1354
|
status: runStatus,
|
|
1255
|
-
events: decodeEvents(run.events
|
|
1355
|
+
events: decodeEvents(run.events),
|
|
1256
1356
|
...(stopRequestedAt ? { stopRequestedAt } : {}),
|
|
1257
1357
|
...(run.queued === true ? { queued: true as const } : {}),
|
|
1258
1358
|
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
@@ -1376,7 +1476,7 @@ export function decodeClientTurnV1(input: unknown): ClientTurnResponse {
|
|
|
1376
1476
|
return {
|
|
1377
1477
|
runId,
|
|
1378
1478
|
text: wireString(turn, "text", MAX_OUTCOME_BYTES, "turn"),
|
|
1379
|
-
events: decodeEvents(turn.events
|
|
1479
|
+
events: decodeEvents(turn.events),
|
|
1380
1480
|
...(notification ? { notification } : {}),
|
|
1381
1481
|
};
|
|
1382
1482
|
}
|
|
@@ -1385,7 +1485,11 @@ export function decodeClientRunListQueryV1(
|
|
|
1385
1485
|
input: unknown,
|
|
1386
1486
|
): ClientRunListQueryV1 {
|
|
1387
1487
|
const query = record(input, "run list query");
|
|
1388
|
-
exactKeys(
|
|
1488
|
+
exactKeys(
|
|
1489
|
+
query,
|
|
1490
|
+
["schemaVersion", "before", "conversationId"],
|
|
1491
|
+
"run list query",
|
|
1492
|
+
);
|
|
1389
1493
|
if (query.schemaVersion !== 1) {
|
|
1390
1494
|
throw new Error("run list query.schemaVersion is invalid");
|
|
1391
1495
|
}
|
|
@@ -1400,7 +1504,20 @@ export function decodeClientRunListQueryV1(
|
|
|
1400
1504
|
throw new Error("run list query.before is invalid");
|
|
1401
1505
|
}
|
|
1402
1506
|
}
|
|
1403
|
-
|
|
1507
|
+
const conversationId =
|
|
1508
|
+
query.conversationId === undefined
|
|
1509
|
+
? undefined
|
|
1510
|
+
: string(
|
|
1511
|
+
query,
|
|
1512
|
+
"conversationId",
|
|
1513
|
+
MAX_SESSION_ID_LENGTH,
|
|
1514
|
+
"run list query",
|
|
1515
|
+
);
|
|
1516
|
+
return {
|
|
1517
|
+
schemaVersion: 1,
|
|
1518
|
+
...(before ? { before } : {}),
|
|
1519
|
+
...(conversationId ? { conversationId } : {}),
|
|
1520
|
+
};
|
|
1404
1521
|
}
|
|
1405
1522
|
|
|
1406
1523
|
export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
|
package/src/shared.ts
CHANGED
|
@@ -107,6 +107,13 @@ export interface WebChatMessage {
|
|
|
107
107
|
* Bot had already said, which it keeps (ADR 0024).
|
|
108
108
|
*/
|
|
109
109
|
notice?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The line offers to send the draft again. Set only where the client gave
|
|
112
|
+
* up on its own — it could not reach the backend — because that is the one
|
|
113
|
+
* ending the person cannot act on from the thread otherwise: their text is
|
|
114
|
+
* back in the composer, and this is the button that sends it.
|
|
115
|
+
*/
|
|
116
|
+
retry?: "resend";
|
|
110
117
|
tools: WebToolActivity[];
|
|
111
118
|
/** The typed payloads this Turn sent to the user, oldest first. */
|
|
112
119
|
sends: WebSendPayload[];
|
|
@@ -161,6 +168,60 @@ export interface PluginCatalogItem {
|
|
|
161
168
|
settings?: PackageSettingDefinition[];
|
|
162
169
|
}
|
|
163
170
|
|
|
171
|
+
/** What an external authorization redirect told the app on the way back. */
|
|
172
|
+
export interface ConnectionReturnV1 {
|
|
173
|
+
/** The Package that owns the Connection, e.g. `composio`. */
|
|
174
|
+
packageId: string;
|
|
175
|
+
status: "ready" | "pending" | "failed";
|
|
176
|
+
/** A provider- or callback-supplied explanation, when there is one. */
|
|
177
|
+
reason?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const CONNECTION_RETURN_PARAM = "connection";
|
|
181
|
+
const CONNECTION_RETURN_REASON_PARAM = "connection_reason";
|
|
182
|
+
const MAX_CONNECTION_RETURN_REASON = 300;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read an authorization return out of a URL query string.
|
|
186
|
+
*
|
|
187
|
+
* The callback redirects to `/?connection=<packageId>-<status>`. Left unread it
|
|
188
|
+
* is a stale query string and nothing else: the User is returned to the app
|
|
189
|
+
* with no confirmation, and a `failed` grant vanishes entirely.
|
|
190
|
+
*/
|
|
191
|
+
export function decodeConnectionReturnV1(
|
|
192
|
+
search: string,
|
|
193
|
+
): ConnectionReturnV1 | undefined {
|
|
194
|
+
let params: URLSearchParams;
|
|
195
|
+
try {
|
|
196
|
+
params = new URLSearchParams(search);
|
|
197
|
+
} catch {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
const raw = params.get(CONNECTION_RETURN_PARAM);
|
|
201
|
+
if (!raw) return undefined;
|
|
202
|
+
const separator = raw.lastIndexOf("-");
|
|
203
|
+
if (separator <= 0) return undefined;
|
|
204
|
+
const packageId = raw.slice(0, separator);
|
|
205
|
+
const status = raw.slice(separator + 1);
|
|
206
|
+
if (status !== "ready" && status !== "pending" && status !== "failed") {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(packageId)) return undefined;
|
|
210
|
+
const reason = params
|
|
211
|
+
.get(CONNECTION_RETURN_REASON_PARAM)
|
|
212
|
+
?.slice(0, MAX_CONNECTION_RETURN_REASON);
|
|
213
|
+
return { packageId, status, ...(reason ? { reason } : {}) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The same query string with the return parameters removed. */
|
|
217
|
+
export function withoutConnectionReturnV1(search: string): string {
|
|
218
|
+
const params = new URLSearchParams(search);
|
|
219
|
+
params.delete(CONNECTION_RETURN_PARAM);
|
|
220
|
+
params.delete(CONNECTION_RETURN_REASON_PARAM);
|
|
221
|
+
const rest = params.toString();
|
|
222
|
+
return rest ? `?${rest}` : "";
|
|
223
|
+
}
|
|
224
|
+
|
|
164
225
|
export interface FrockBotWebData {
|
|
165
226
|
connection: WebConnection;
|
|
166
227
|
modelLabel: string;
|
|
@@ -261,6 +322,12 @@ export interface FrockBotWebData {
|
|
|
261
322
|
*/
|
|
262
323
|
mcpServers?: McpServerStatusViewV1;
|
|
263
324
|
settingsError?: string;
|
|
325
|
+
/**
|
|
326
|
+
* What the browser came back from an external authorization with. Read once
|
|
327
|
+
* from the return URL at boot and cleared when the User has seen it, so a
|
|
328
|
+
* cancelled or failed grant is reported rather than silently discarded.
|
|
329
|
+
*/
|
|
330
|
+
connectionReturn?: ConnectionReturnV1;
|
|
264
331
|
selectBot(botId: string): Promise<void>;
|
|
265
332
|
loadBotSettings(): Promise<void>;
|
|
266
333
|
saveBotProfile(profile: BotProfile): Promise<void>;
|
|
@@ -365,6 +432,11 @@ export interface FrockBotWebData {
|
|
|
365
432
|
values?: Record<string, JsonValue>,
|
|
366
433
|
): Promise<void>;
|
|
367
434
|
uninstallPackage(packageId: string): Promise<void>;
|
|
435
|
+
/**
|
|
436
|
+
* Puts this conversation down and starts the next one. Memory is kept; only
|
|
437
|
+
* the history the next Turn carries is new (ADR 0027).
|
|
438
|
+
*/
|
|
439
|
+
startConversation(): Promise<void>;
|
|
368
440
|
startConnection(
|
|
369
441
|
packageId: string,
|
|
370
442
|
connectionTypeId: string,
|