@frockbot/plugin-subagents 0.0.0 → 0.1.0
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/frockbot.json +33 -0
- package/package.json +38 -6
- package/src/agent.test.ts +480 -0
- package/src/agent.ts +1078 -0
- package/src/backend.ts +193 -0
- package/src/index.ts +8 -0
- package/src/manifest.ts +3 -0
- package/src/models.test.ts +175 -0
- package/src/models.ts +185 -0
- package/src/quota.test.ts +82 -0
- package/src/quota.ts +222 -0
- package/src/records.test.ts +245 -0
- package/src/records.ts +649 -0
- package/src/roles.test.ts +60 -0
- package/src/roles.ts +76 -0
- package/src/shared.ts +232 -0
- package/src/storage-keys.ts +148 -0
- package/src/store.test.ts +500 -0
- package/src/store.ts +695 -0
- package/src/testing.ts +58 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/quota.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The per-User concurrent-subagent bound, as durable User Durable Object state.
|
|
2
|
+
//
|
|
3
|
+
// A Bot's own bound (four) is countable in the Bot Durable Object, because the
|
|
4
|
+
// keys are there. A User's (eight) is not: a User's Bots are separate objects
|
|
5
|
+
// and no one of them can see the others. So the counter lives where the
|
|
6
|
+
// authority for User-scoped state already is, and the Bot Durable Object
|
|
7
|
+
// *reserves* a slot over a narrow RPC before it dispatches — the
|
|
8
|
+
// `AUTHORING_QUOTA_RESERVATION_PREFIX` pattern in `plugin-authoring/src/quota.ts`.
|
|
9
|
+
//
|
|
10
|
+
// The one difference from the authoring quota is what a unit *is*. An authored
|
|
11
|
+
// generation is spent; a running subagent is *held*, and comes back. So the
|
|
12
|
+
// reservation is a live key that a settle releases, not a day counter that only
|
|
13
|
+
// ever rises — and both halves are idempotent on `(botId, taskId)`, so a
|
|
14
|
+
// resumed Turn neither takes a second slot nor releases someone else's.
|
|
15
|
+
|
|
16
|
+
import { isTaskIdV1, TASK_CONCURRENCY_PER_USER_V1 } from "./records.js";
|
|
17
|
+
|
|
18
|
+
/** `PUBLIC_IDENTIFIER_PATTERN`: the shape every Bot id already has. */
|
|
19
|
+
const SLOT_BOT_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
20
|
+
|
|
21
|
+
export const SUBAGENT_SLOT_PREFIX = "subagent:slot:";
|
|
22
|
+
|
|
23
|
+
/** The durable per-User bound, overridable by nothing today: it is the plan's. */
|
|
24
|
+
export const SUBAGENT_SLOT_LIMIT_V1 = TASK_CONCURRENCY_PER_USER_V1;
|
|
25
|
+
|
|
26
|
+
export function subagentSlotKeyV1(botId: string, taskId: string): string {
|
|
27
|
+
// Two ids, one key. Neither may carry the separator, or a key would be
|
|
28
|
+
// ambiguous about where the Bot ends and the task begins — and both patterns
|
|
29
|
+
// already exclude it, so this is the check that says so out loud.
|
|
30
|
+
if (!isTaskIdV1(taskId) || !SLOT_BOT_ID.test(botId)) {
|
|
31
|
+
throw new Error("subagent slot key is invalid");
|
|
32
|
+
}
|
|
33
|
+
return `${SUBAGENT_SLOT_PREFIX}${botId}:${taskId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SubagentSlotRequestV1 {
|
|
37
|
+
schemaVersion: 1;
|
|
38
|
+
userId: string;
|
|
39
|
+
botId: string;
|
|
40
|
+
taskId: string;
|
|
41
|
+
reservedAt: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SubagentSlotReceiptV1 =
|
|
45
|
+
| {
|
|
46
|
+
schemaVersion: 1;
|
|
47
|
+
status: "reserved";
|
|
48
|
+
botId: string;
|
|
49
|
+
taskId: string;
|
|
50
|
+
held: number;
|
|
51
|
+
limit: number;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
schemaVersion: 1;
|
|
55
|
+
status: "refused";
|
|
56
|
+
botId: string;
|
|
57
|
+
taskId: string;
|
|
58
|
+
reason: string;
|
|
59
|
+
held: number;
|
|
60
|
+
limit: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
interface StoredSubagentSlotV1 {
|
|
64
|
+
schemaVersion: 1;
|
|
65
|
+
botId: string;
|
|
66
|
+
taskId: string;
|
|
67
|
+
reservedAt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The narrow storage surface this module needs from the User Durable Object.
|
|
72
|
+
* Deliberately the same shape as {@link TaskStorageWritesV1}, so one in-memory
|
|
73
|
+
* fake — and one Durable Object storage — satisfies both sides of the bound.
|
|
74
|
+
*/
|
|
75
|
+
export interface SubagentSlotTransaction {
|
|
76
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
77
|
+
list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
|
|
78
|
+
put(key: string, value: unknown): Promise<void>;
|
|
79
|
+
delete(key: string): Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SubagentSlotStorage extends SubagentSlotTransaction {
|
|
83
|
+
transaction<T>(
|
|
84
|
+
callback: (storage: SubagentSlotTransaction) => Promise<T>,
|
|
85
|
+
): Promise<T>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Takes one slot for a task, or refuses. Never throws for a breach: a bound is
|
|
90
|
+
* an observable outcome the Bot's tool result reports.
|
|
91
|
+
*/
|
|
92
|
+
export async function reserveSubagentSlotV1(
|
|
93
|
+
storage: SubagentSlotStorage,
|
|
94
|
+
request: SubagentSlotRequestV1,
|
|
95
|
+
): Promise<SubagentSlotReceiptV1> {
|
|
96
|
+
const key = subagentSlotKeyV1(request.botId, request.taskId);
|
|
97
|
+
// One transaction from the count to the write: the read-modify-write spans
|
|
98
|
+
// awaits, so two dispatches racing at the bound must not both be admitted.
|
|
99
|
+
return storage.transaction(async (transaction) => {
|
|
100
|
+
const held = await transaction.list<StoredSubagentSlotV1>({
|
|
101
|
+
prefix: SUBAGENT_SLOT_PREFIX,
|
|
102
|
+
});
|
|
103
|
+
if (held.has(key)) {
|
|
104
|
+
// Already ours. A resumed Turn re-executing the same dispatch reads its
|
|
105
|
+
// own reservation back rather than taking a second one.
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
status: "reserved",
|
|
109
|
+
botId: request.botId,
|
|
110
|
+
taskId: request.taskId,
|
|
111
|
+
held: held.size,
|
|
112
|
+
limit: SUBAGENT_SLOT_LIMIT_V1,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (held.size >= SUBAGENT_SLOT_LIMIT_V1) {
|
|
116
|
+
return {
|
|
117
|
+
schemaVersion: 1,
|
|
118
|
+
status: "refused",
|
|
119
|
+
botId: request.botId,
|
|
120
|
+
taskId: request.taskId,
|
|
121
|
+
reason: `this User already has ${held.size} subagents running; the bound is ${SUBAGENT_SLOT_LIMIT_V1}`,
|
|
122
|
+
held: held.size,
|
|
123
|
+
limit: SUBAGENT_SLOT_LIMIT_V1,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
await transaction.put(key, {
|
|
127
|
+
schemaVersion: 1,
|
|
128
|
+
botId: request.botId,
|
|
129
|
+
taskId: request.taskId,
|
|
130
|
+
reservedAt: request.reservedAt,
|
|
131
|
+
} satisfies StoredSubagentSlotV1);
|
|
132
|
+
return {
|
|
133
|
+
schemaVersion: 1,
|
|
134
|
+
status: "reserved",
|
|
135
|
+
botId: request.botId,
|
|
136
|
+
taskId: request.taskId,
|
|
137
|
+
held: held.size + 1,
|
|
138
|
+
limit: SUBAGENT_SLOT_LIMIT_V1,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Gives one slot back. Idempotent: releasing a slot nobody holds is a no-op. */
|
|
144
|
+
export async function releaseSubagentSlotV1(
|
|
145
|
+
storage: SubagentSlotStorage,
|
|
146
|
+
request: { botId: string; taskId: string },
|
|
147
|
+
): Promise<{ schemaVersion: 1; status: "released"; held: number }> {
|
|
148
|
+
const key = subagentSlotKeyV1(request.botId, request.taskId);
|
|
149
|
+
return storage.transaction(async (transaction) => {
|
|
150
|
+
await transaction.delete(key);
|
|
151
|
+
const held = await transaction.list<StoredSubagentSlotV1>({
|
|
152
|
+
prefix: SUBAGENT_SLOT_PREFIX,
|
|
153
|
+
});
|
|
154
|
+
return { schemaVersion: 1, status: "released", held: held.size };
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function decodeSubagentSlotReceiptV1(
|
|
159
|
+
input: unknown,
|
|
160
|
+
label = "subagent slot receipt",
|
|
161
|
+
): SubagentSlotReceiptV1 {
|
|
162
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
163
|
+
throw new Error(`${label} must be an object`);
|
|
164
|
+
}
|
|
165
|
+
const value = input as Record<string, unknown>;
|
|
166
|
+
if (value.schemaVersion !== 1) {
|
|
167
|
+
throw new Error(`${label}.schemaVersion is unsupported`);
|
|
168
|
+
}
|
|
169
|
+
const text = (name: string, maximum: number): string => {
|
|
170
|
+
const candidate = value[name];
|
|
171
|
+
if (
|
|
172
|
+
typeof candidate !== "string" ||
|
|
173
|
+
candidate.length === 0 ||
|
|
174
|
+
candidate.length > maximum
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(`${label}.${name} is invalid`);
|
|
177
|
+
}
|
|
178
|
+
return candidate;
|
|
179
|
+
};
|
|
180
|
+
const integer = (name: string): number => {
|
|
181
|
+
const candidate = value[name];
|
|
182
|
+
if (!Number.isSafeInteger(candidate) || (candidate as number) < 0) {
|
|
183
|
+
throw new Error(`${label}.${name} is invalid`);
|
|
184
|
+
}
|
|
185
|
+
return candidate as number;
|
|
186
|
+
};
|
|
187
|
+
const botId = text("botId", 128);
|
|
188
|
+
const taskId = text("taskId", 128);
|
|
189
|
+
if (value.status === "reserved") {
|
|
190
|
+
return {
|
|
191
|
+
schemaVersion: 1,
|
|
192
|
+
status: "reserved",
|
|
193
|
+
botId,
|
|
194
|
+
taskId,
|
|
195
|
+
held: integer("held"),
|
|
196
|
+
limit: integer("limit"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (value.status === "refused") {
|
|
200
|
+
return {
|
|
201
|
+
schemaVersion: 1,
|
|
202
|
+
status: "refused",
|
|
203
|
+
botId,
|
|
204
|
+
taskId,
|
|
205
|
+
reason: text("reason", 1_024),
|
|
206
|
+
held: integer("held"),
|
|
207
|
+
limit: integer("limit"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`${label}.status is invalid`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The narrow RPC the Bot Durable Object calls on the User Durable Object. */
|
|
214
|
+
export interface SubagentSlotBinding {
|
|
215
|
+
reserve(request: SubagentSlotRequestV1): Promise<SubagentSlotReceiptV1>;
|
|
216
|
+
release(request: {
|
|
217
|
+
schemaVersion: 1;
|
|
218
|
+
userId: string;
|
|
219
|
+
botId: string;
|
|
220
|
+
taskId: string;
|
|
221
|
+
}): Promise<void>;
|
|
222
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeTaskDesktopLeaseIntentV1,
|
|
4
|
+
decodeTaskMessageRecordV1,
|
|
5
|
+
decodeTaskModelBindingV1,
|
|
6
|
+
decodeTaskOutcomeV1,
|
|
7
|
+
decodeTaskRecordV1,
|
|
8
|
+
taskPromptDigestV1,
|
|
9
|
+
utf8ByteLengthV1,
|
|
10
|
+
TASK_ATTACHMENT_LIMIT_V1,
|
|
11
|
+
TASK_CONCURRENCY_PER_BOT_V1,
|
|
12
|
+
TASK_CONCURRENCY_PER_USER_V1,
|
|
13
|
+
TASK_DEADLINE_MS_V1,
|
|
14
|
+
TASK_MAX_DEPTH_V1,
|
|
15
|
+
TASK_MESSAGE_QUEUE_LIMIT_V1,
|
|
16
|
+
TASK_PROMPT_MAX_BYTES_V1,
|
|
17
|
+
type TaskRecordV1,
|
|
18
|
+
} from "./records.js";
|
|
19
|
+
|
|
20
|
+
const BINDING = {
|
|
21
|
+
assignmentId: "asg-1",
|
|
22
|
+
packageId: "provider-ollama-cloud",
|
|
23
|
+
capabilityId: "ollama-cloud-models",
|
|
24
|
+
connectionId: "conn-1",
|
|
25
|
+
provider: "ollama-cloud",
|
|
26
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function taskRecord(overrides: Record<string, unknown> = {}): unknown {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
taskId: "tk-1",
|
|
33
|
+
type: "executor",
|
|
34
|
+
description: "Summarise the changelog",
|
|
35
|
+
promptDigest: "a".repeat(64),
|
|
36
|
+
model: { binding: BINDING, slug: "provider-ollama-cloud/glm-5.3-flash" },
|
|
37
|
+
compositionGenerationId: "gen-1",
|
|
38
|
+
background: true,
|
|
39
|
+
depth: 1,
|
|
40
|
+
status: "queued",
|
|
41
|
+
dispatch: { runId: "run-1", turnId: "run-1", sessionId: "user:bot" },
|
|
42
|
+
childSessionId: "task:tk-1",
|
|
43
|
+
attachments: [],
|
|
44
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
45
|
+
deadlineAt: "2026-09-01T00:30:00.000Z",
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("the bounds the plan states", () => {
|
|
51
|
+
test("are stated once, here, and are the values the plan names", () => {
|
|
52
|
+
expect({
|
|
53
|
+
perBot: TASK_CONCURRENCY_PER_BOT_V1,
|
|
54
|
+
perUser: TASK_CONCURRENCY_PER_USER_V1,
|
|
55
|
+
depth: TASK_MAX_DEPTH_V1,
|
|
56
|
+
promptBytes: TASK_PROMPT_MAX_BYTES_V1,
|
|
57
|
+
attachments: TASK_ATTACHMENT_LIMIT_V1,
|
|
58
|
+
queuedMessages: TASK_MESSAGE_QUEUE_LIMIT_V1,
|
|
59
|
+
lifetimeMs: TASK_DEADLINE_MS_V1,
|
|
60
|
+
}).toEqual({
|
|
61
|
+
perBot: 4,
|
|
62
|
+
perUser: 8,
|
|
63
|
+
depth: 1,
|
|
64
|
+
promptBytes: 32_768,
|
|
65
|
+
attachments: 4,
|
|
66
|
+
queuedMessages: 16,
|
|
67
|
+
lifetimeMs: 30 * 60_000,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the prompt bound is bytes, not characters", () => {
|
|
72
|
+
expect(utf8ByteLengthV1("é")).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("TaskRecordV1 decodes exactly", () => {
|
|
77
|
+
test("accepts a queued record and returns it field for field", () => {
|
|
78
|
+
const decoded = decodeTaskRecordV1(taskRecord());
|
|
79
|
+
expect(decoded).toEqual(taskRecord() as TaskRecordV1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("accepts a settled record whose outcome agrees with its status", () => {
|
|
83
|
+
const decoded = decodeTaskRecordV1(
|
|
84
|
+
taskRecord({
|
|
85
|
+
status: "completed",
|
|
86
|
+
outcome: {
|
|
87
|
+
status: "completed",
|
|
88
|
+
settledAt: "2026-09-01T00:10:00.000Z",
|
|
89
|
+
summary: "Done.",
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
expect(decoded.outcome).toMatchObject({ status: "completed" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("refuses an unknown field rather than dropping it", () => {
|
|
97
|
+
expect(() => decodeTaskRecordV1(taskRecord({ prompt: "leaked" }))).toThrow(
|
|
98
|
+
/unknown field "prompt"/,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("refuses a missing required field", () => {
|
|
103
|
+
const { model: _model, ...rest } = taskRecord() as Record<string, unknown>;
|
|
104
|
+
expect(() => decodeTaskRecordV1(rest)).toThrow(/is missing "model"/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("refuses a non-enumerable own property", () => {
|
|
108
|
+
const candidate = taskRecord() as Record<string, unknown>;
|
|
109
|
+
Object.defineProperty(candidate, "smuggled", {
|
|
110
|
+
value: 1,
|
|
111
|
+
enumerable: false,
|
|
112
|
+
});
|
|
113
|
+
expect(() => decodeTaskRecordV1(candidate)).toThrow(
|
|
114
|
+
/has a non-enumerable field/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("refuses an unknown status and an unknown type", () => {
|
|
119
|
+
expect(() => decodeTaskRecordV1(taskRecord({ status: "paused" }))).toThrow(
|
|
120
|
+
/status is invalid/,
|
|
121
|
+
);
|
|
122
|
+
expect(() =>
|
|
123
|
+
decodeTaskRecordV1(taskRecord({ type: "researcher" })),
|
|
124
|
+
).toThrow(/type is invalid/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("refuses a depth past one: a subagent never dispatches a subagent", () => {
|
|
128
|
+
expect(() => decodeTaskRecordV1(taskRecord({ depth: 2 }))).toThrow(
|
|
129
|
+
/depth is invalid/,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("refuses a terminal status with no outcome, and an outcome with none", () => {
|
|
134
|
+
expect(() =>
|
|
135
|
+
decodeTaskRecordV1(taskRecord({ status: "completed" })),
|
|
136
|
+
).toThrow(/inconsistent terminal state/);
|
|
137
|
+
expect(() =>
|
|
138
|
+
decodeTaskRecordV1(
|
|
139
|
+
taskRecord({
|
|
140
|
+
outcome: {
|
|
141
|
+
status: "completed",
|
|
142
|
+
settledAt: "2026-09-01T00:10:00.000Z",
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
).toThrow(/inconsistent terminal state/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("refuses an outcome that disagrees with the record's status", () => {
|
|
150
|
+
expect(() =>
|
|
151
|
+
decodeTaskRecordV1(
|
|
152
|
+
taskRecord({
|
|
153
|
+
status: "failed",
|
|
154
|
+
outcome: {
|
|
155
|
+
status: "completed",
|
|
156
|
+
settledAt: "2026-09-01T00:10:00.000Z",
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
),
|
|
160
|
+
).toThrow(/outcome disagrees with its status/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("refuses more attachments than the bound allows", () => {
|
|
164
|
+
expect(() =>
|
|
165
|
+
decodeTaskRecordV1(
|
|
166
|
+
taskRecord({ attachments: ["a", "b", "c", "d", "e"] }),
|
|
167
|
+
),
|
|
168
|
+
).toThrow(/at most 4 entries/);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("the model binding a task pins", () => {
|
|
173
|
+
test("accepts exactly the fields the Shell resolves", () => {
|
|
174
|
+
expect(decodeTaskModelBindingV1(BINDING)).toEqual(BINDING);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("refuses a binding carrying anything else", () => {
|
|
178
|
+
expect(() =>
|
|
179
|
+
decodeTaskModelBindingV1({ ...BINDING, apiKey: "secret" }),
|
|
180
|
+
).toThrow(/unknown field "apiKey"/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("the terminal outcome", () => {
|
|
185
|
+
test("refuses a non-terminal status", () => {
|
|
186
|
+
expect(() =>
|
|
187
|
+
decodeTaskOutcomeV1({
|
|
188
|
+
status: "running",
|
|
189
|
+
settledAt: "2026-09-01T00:10:00.000Z",
|
|
190
|
+
}),
|
|
191
|
+
).toThrow(/status is invalid/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("refuses a completion that also carries a failure", () => {
|
|
195
|
+
expect(() =>
|
|
196
|
+
decodeTaskOutcomeV1({
|
|
197
|
+
status: "completed",
|
|
198
|
+
settledAt: "2026-09-01T00:10:00.000Z",
|
|
199
|
+
summary: "ok",
|
|
200
|
+
failure: "not ok",
|
|
201
|
+
}),
|
|
202
|
+
).toThrow(/completed and carries a failure at once/);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("the other records this Package writes", () => {
|
|
207
|
+
test("a queued message decodes exactly", () => {
|
|
208
|
+
const message = {
|
|
209
|
+
schemaVersion: 1 as const,
|
|
210
|
+
taskId: "tk-1",
|
|
211
|
+
seq: 0,
|
|
212
|
+
message: "one more thing",
|
|
213
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
214
|
+
};
|
|
215
|
+
expect(decodeTaskMessageRecordV1(message)).toEqual(message);
|
|
216
|
+
expect(() =>
|
|
217
|
+
decodeTaskMessageRecordV1({ ...message, priority: "high" }),
|
|
218
|
+
).toThrow(/unknown field "priority"/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("the desktop lease intent names one scope and nothing else", () => {
|
|
222
|
+
const intent = {
|
|
223
|
+
schemaVersion: 1 as const,
|
|
224
|
+
taskId: "tk-1",
|
|
225
|
+
scope: "desktop-gui" as const,
|
|
226
|
+
recordedAt: "2026-09-01T00:00:00.000Z",
|
|
227
|
+
};
|
|
228
|
+
expect(decodeTaskDesktopLeaseIntentV1(intent)).toEqual({
|
|
229
|
+
...intent,
|
|
230
|
+
scope: "desktop-gui" as const,
|
|
231
|
+
});
|
|
232
|
+
expect(() =>
|
|
233
|
+
decodeTaskDesktopLeaseIntentV1({ ...intent, scope: "browser" }),
|
|
234
|
+
).toThrow(/scope is invalid/);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("the prompt digest", () => {
|
|
239
|
+
test("is stable, and is what the record carries instead of the prompt", async () => {
|
|
240
|
+
const digest = await taskPromptDigestV1("do the thing");
|
|
241
|
+
expect(digest).toMatch(/^[0-9a-f]{64}$/);
|
|
242
|
+
expect(await taskPromptDigestV1("do the thing")).toBe(digest);
|
|
243
|
+
expect(await taskPromptDigestV1("do the other thing")).not.toBe(digest);
|
|
244
|
+
});
|
|
245
|
+
});
|