@frockbot/protocol 0.2.5 → 0.3.1
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 +1 -1
- package/src/bot-state-channel.test.ts +60 -0
- package/src/index.ts +125 -0
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeBotStateChannelFrameV1,
|
|
4
|
+
decodeBotStateCursorV1,
|
|
5
|
+
} from "./index.js";
|
|
6
|
+
|
|
7
|
+
describe("Bot-state channel protocol", () => {
|
|
8
|
+
test("decodes each exact version 1 frame", () => {
|
|
9
|
+
expect(
|
|
10
|
+
decodeBotStateChannelFrameV1(
|
|
11
|
+
JSON.stringify({
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
type: "state/event",
|
|
14
|
+
cursor: "12",
|
|
15
|
+
topic: "computer",
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
).toEqual({
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
type: "state/event",
|
|
21
|
+
cursor: "12",
|
|
22
|
+
topic: "computer",
|
|
23
|
+
});
|
|
24
|
+
expect(
|
|
25
|
+
decodeBotStateChannelFrameV1(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
type: "state/reset",
|
|
29
|
+
cursor: "4",
|
|
30
|
+
reason: "gap",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
).toMatchObject({ type: "state/reset", reason: "gap" });
|
|
34
|
+
expect(
|
|
35
|
+
decodeBotStateChannelFrameV1(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
type: "state/ready",
|
|
39
|
+
cursor: "0",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
).toMatchObject({ type: "state/ready", cursor: "0" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects non-canonical cursors and protocol extensions", () => {
|
|
46
|
+
for (const cursor of ["", "01", "-1", "1.5", "9007199254740992"]) {
|
|
47
|
+
expect(() => decodeBotStateCursorV1(cursor)).toThrow();
|
|
48
|
+
}
|
|
49
|
+
expect(() =>
|
|
50
|
+
decodeBotStateChannelFrameV1(
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
type: "state/ready",
|
|
54
|
+
cursor: "0",
|
|
55
|
+
extra: true,
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
).toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,131 @@ export interface PromptRequest {
|
|
|
3
3
|
text: string;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Version 1 of the Bot-state observer protocol. Frames are invalidations, not
|
|
8
|
+
* authority: a client that receives one re-reads the owning HTTP projection.
|
|
9
|
+
*/
|
|
10
|
+
export const BOT_STATE_CHANNEL_VERSION = 1 as const;
|
|
11
|
+
|
|
12
|
+
export type BotStateTopicV1 = "computer";
|
|
13
|
+
|
|
14
|
+
export type BotStateChannelFrameV1 =
|
|
15
|
+
| {
|
|
16
|
+
schemaVersion: 1;
|
|
17
|
+
type: "state/event";
|
|
18
|
+
cursor: string;
|
|
19
|
+
topic: BotStateTopicV1;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
schemaVersion: 1;
|
|
23
|
+
type: "state/reset";
|
|
24
|
+
cursor: string;
|
|
25
|
+
reason: "initial" | "gap" | "cursor-ahead";
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
schemaVersion: 1;
|
|
29
|
+
type: "state/ready";
|
|
30
|
+
cursor: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const BOT_STATE_CURSOR_PATTERN = /^(?:0|[1-9][0-9]{0,15})$/u;
|
|
34
|
+
|
|
35
|
+
export function decodeBotStateCursorV1(value: unknown): string {
|
|
36
|
+
if (
|
|
37
|
+
typeof value !== "string" ||
|
|
38
|
+
!BOT_STATE_CURSOR_PATTERN.test(value) ||
|
|
39
|
+
!Number.isSafeInteger(Number(value))
|
|
40
|
+
) {
|
|
41
|
+
throw new Error("invalid Bot-state cursor");
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exactObject(
|
|
47
|
+
value: unknown,
|
|
48
|
+
required: readonly string[],
|
|
49
|
+
): Record<string, unknown> {
|
|
50
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
51
|
+
throw new Error("invalid Bot-state frame");
|
|
52
|
+
}
|
|
53
|
+
const record = value as Record<string, unknown>;
|
|
54
|
+
const allowed = new Set(required);
|
|
55
|
+
if (
|
|
56
|
+
!required.every((key) => Object.hasOwn(record, key)) ||
|
|
57
|
+
Object.keys(record).some((key) => !allowed.has(key))
|
|
58
|
+
) {
|
|
59
|
+
throw new Error("invalid Bot-state frame");
|
|
60
|
+
}
|
|
61
|
+
return record;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function decodeBotStateChannelFrameV1(
|
|
65
|
+
value: unknown,
|
|
66
|
+
): BotStateChannelFrameV1 {
|
|
67
|
+
if (typeof value !== "string" || value.length > 4_096) {
|
|
68
|
+
throw new Error("invalid Bot-state frame");
|
|
69
|
+
}
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(value);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error("invalid Bot-state frame");
|
|
75
|
+
}
|
|
76
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
77
|
+
throw new Error("invalid Bot-state frame");
|
|
78
|
+
}
|
|
79
|
+
const type = (parsed as Record<string, unknown>).type;
|
|
80
|
+
if (type === "state/event") {
|
|
81
|
+
const frame = exactObject(parsed, [
|
|
82
|
+
"schemaVersion",
|
|
83
|
+
"type",
|
|
84
|
+
"cursor",
|
|
85
|
+
"topic",
|
|
86
|
+
]);
|
|
87
|
+
if (frame.schemaVersion !== 1 || frame.topic !== "computer") {
|
|
88
|
+
throw new Error("invalid Bot-state frame");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
type,
|
|
93
|
+
cursor: decodeBotStateCursorV1(frame.cursor),
|
|
94
|
+
topic: frame.topic,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (type === "state/reset") {
|
|
98
|
+
const frame = exactObject(parsed, [
|
|
99
|
+
"schemaVersion",
|
|
100
|
+
"type",
|
|
101
|
+
"cursor",
|
|
102
|
+
"reason",
|
|
103
|
+
]);
|
|
104
|
+
if (
|
|
105
|
+
frame.schemaVersion !== 1 ||
|
|
106
|
+
(frame.reason !== "initial" &&
|
|
107
|
+
frame.reason !== "gap" &&
|
|
108
|
+
frame.reason !== "cursor-ahead")
|
|
109
|
+
) {
|
|
110
|
+
throw new Error("invalid Bot-state frame");
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
schemaVersion: 1,
|
|
114
|
+
type,
|
|
115
|
+
cursor: decodeBotStateCursorV1(frame.cursor),
|
|
116
|
+
reason: frame.reason,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (type === "state/ready") {
|
|
120
|
+
const frame = exactObject(parsed, ["schemaVersion", "type", "cursor"]);
|
|
121
|
+
if (frame.schemaVersion !== 1) throw new Error("invalid Bot-state frame");
|
|
122
|
+
return {
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
type,
|
|
125
|
+
cursor: decodeBotStateCursorV1(frame.cursor),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
throw new Error("invalid Bot-state frame");
|
|
129
|
+
}
|
|
130
|
+
|
|
6
131
|
export type AgentCommand =
|
|
7
132
|
| { type: "prompt"; runId: string; text: string }
|
|
8
133
|
| { type: "abort"; runId: string }
|