pi-onlyne 1.2.1 → 2.0.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/README.md +147 -240
- package/README.zh.md +79 -125
- package/package.json +2 -3
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -395
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/config.test.mjs +0 -8
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +110 -30
- package/src/pi-surface.test.mjs +232 -0
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -69
- package/src/socket.mjs +270 -39
- package/src/socket.test.mjs +205 -47
- package/relay.toml.example +0 -18
- package/src/relay.mjs +0 -299
- package/src/relay.test.mjs +0 -210
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The pi-facing half of the surface: exactly what `wakeUser` hands
|
|
2
|
+
// `pi.sendUserMessage`. Every shape asserted here is pi's own declaration —
|
|
3
|
+
// `sendUserMessage(content: string | (TextContent | ImageContent)[])`, with
|
|
4
|
+
// `ImageContent` the flat `{ type: "image", data, mimeType }` of
|
|
5
|
+
// `@earendil-works/pi-ai` (measured against pi 0.87.1). pi normalizes each part
|
|
6
|
+
// before it builds the message, so a part in any other shape — the nested
|
|
7
|
+
// Anthropic `source: { type, media_type, data }` this plugin once sent — stops
|
|
8
|
+
// the whole delivery inside pi, and pi reports that in its own pane without
|
|
9
|
+
// handing anything back to the plugin.
|
|
10
|
+
//
|
|
11
|
+
// The role prose is the other half: it becomes one section of pi's system prompt
|
|
12
|
+
// at `before_agent_start` (`event.systemPromptOptions.sections`, the mutable
|
|
13
|
+
// prompt options pi hands the handler) and never a message. The section's value
|
|
14
|
+
// is the client's own text, byte for byte.
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
|
|
19
|
+
import { PROSE_SECTION, createSurface } from "./pi-surface.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One delivery text in the shape the client renders and `agent.mjs` injects.
|
|
23
|
+
* The surface carries it verbatim; it composes no part of it.
|
|
24
|
+
*/
|
|
25
|
+
const TASK_TEXT = "From planner:\n\nbuild it";
|
|
26
|
+
|
|
27
|
+
/** One inline image part in the shape the agent hands the surface. */
|
|
28
|
+
const PNG_PART = { type: "image", mime: "image/png", data: "iVBORw0KGgoAAAANSUhEUg==", name: "shot.png" };
|
|
29
|
+
|
|
30
|
+
/** pi's own `UserMessage.content` for that text and that part, key for key. */
|
|
31
|
+
const PNG_CONTENT = [
|
|
32
|
+
{ type: "text", text: TASK_TEXT },
|
|
33
|
+
{ type: "image", data: "iVBORw0KGgoAAAANSUhEUg==", mimeType: "image/png" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A surface over a hand-written fake pi: every `sendUserMessage` arrives with
|
|
38
|
+
* the arguments it was given, recorded whole, and every surface log line with
|
|
39
|
+
* it. `refuse` makes one call throw synchronously, the way a pi that does not
|
|
40
|
+
* know an option does.
|
|
41
|
+
* @param {{ refuse?: (content: unknown, options: unknown) => boolean }} [options]
|
|
42
|
+
*/
|
|
43
|
+
function fakeSurface({ refuse = null } = {}) {
|
|
44
|
+
const calls = [];
|
|
45
|
+
const messages = [];
|
|
46
|
+
const lines = [];
|
|
47
|
+
const surface = createSurface({
|
|
48
|
+
pi: {
|
|
49
|
+
sendUserMessage(content, options) {
|
|
50
|
+
calls.push({ content, options });
|
|
51
|
+
if (refuse?.(content, options)) throw new Error("this pi does not take that option");
|
|
52
|
+
},
|
|
53
|
+
// Recorded, never expected: no part of this plugin is a conversation
|
|
54
|
+
// message any more, and the prose is the one that could creep back.
|
|
55
|
+
sendMessage(message, options) {
|
|
56
|
+
messages.push({ message, options });
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
log: (line) => lines.push(line),
|
|
60
|
+
context: () => null,
|
|
61
|
+
});
|
|
62
|
+
return { calls, messages, lines, surface };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("an image attachment reaches pi as one flat image content part beside the task text", () => {
|
|
66
|
+
const { calls, lines, surface } = fakeSurface();
|
|
67
|
+
|
|
68
|
+
assert.equal(surface.wakeUser(TASK_TEXT, [PNG_PART]), true);
|
|
69
|
+
assert.equal(calls.length, 1, "one message, not one call per part");
|
|
70
|
+
// The whole array, key for key: a nested `source` / `mediaType` part carries
|
|
71
|
+
// keys pi does not declare and lacks the two it does, so it cannot pass this.
|
|
72
|
+
assert.deepEqual(calls[0].content, PNG_CONTENT);
|
|
73
|
+
assert.deepEqual(lines, [], "a readable attachment is delivered, not reported");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("task text with no attachments reaches pi as a bare string", () => {
|
|
77
|
+
const { calls, surface } = fakeSurface();
|
|
78
|
+
|
|
79
|
+
assert.equal(surface.wakeUser(TASK_TEXT, []), true);
|
|
80
|
+
assert.equal(calls.length, 1);
|
|
81
|
+
// pi takes the string path of `content`; a one-element array of text parts is
|
|
82
|
+
// a different message and a needless normalization round trip.
|
|
83
|
+
assert.equal(calls[0].content, TASK_TEXT);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("an attachment pi cannot read is dropped, logged, and the task text still travels", () => {
|
|
87
|
+
const { calls, lines, surface } = fakeSurface();
|
|
88
|
+
|
|
89
|
+
// Each part below fails one clause of what pi needs: no base64 data, no media
|
|
90
|
+
// type, an empty media type. Sending any of them stops the whole delivery
|
|
91
|
+
// inside pi, so all three go and the text goes alone.
|
|
92
|
+
assert.equal(
|
|
93
|
+
surface.wakeUser(TASK_TEXT, [
|
|
94
|
+
{ type: "image", mime: "image/png", name: "no-data.png" },
|
|
95
|
+
{ type: "image", data: "iVBORw0KGgoAAAANSUhEUg==", name: "no-mime.png" },
|
|
96
|
+
{ type: "image", data: "iVBORw0KGgoAAAANSUhEUg==", mime: "" },
|
|
97
|
+
]),
|
|
98
|
+
true,
|
|
99
|
+
);
|
|
100
|
+
assert.equal(calls.length, 1);
|
|
101
|
+
assert.equal(calls[0].content, TASK_TEXT);
|
|
102
|
+
assert.equal(lines.length, 1);
|
|
103
|
+
assert.match(lines[0], /3 dropped/);
|
|
104
|
+
assert.match(lines[0], /the task text still went/);
|
|
105
|
+
|
|
106
|
+
// Dropping is per part: a usable image beside an unusable one still rides.
|
|
107
|
+
assert.equal(surface.wakeUser(TASK_TEXT, [{ type: "image", mime: "image/png" }, PNG_PART]), true);
|
|
108
|
+
assert.deepEqual(calls[1].content, PNG_CONTENT);
|
|
109
|
+
assert.equal(lines.length, 2);
|
|
110
|
+
assert.match(lines[1], /1 dropped/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("delivery asks for followUp and a refusal of that option retries with the same content", () => {
|
|
114
|
+
const asked = fakeSurface();
|
|
115
|
+
|
|
116
|
+
assert.equal(asked.surface.wakeUser(TASK_TEXT, [PNG_PART]), true);
|
|
117
|
+
assert.deepEqual(asked.calls, [{ content: PNG_CONTENT, options: { deliverAs: "followUp" } }]);
|
|
118
|
+
|
|
119
|
+
// An older pi throws on the option it does not know. The message itself is
|
|
120
|
+
// fine, so the retry drops the option and keeps the content.
|
|
121
|
+
const refusing = fakeSurface({ refuse: (_content, options) => Boolean(options) });
|
|
122
|
+
|
|
123
|
+
assert.equal(refusing.surface.wakeUser(TASK_TEXT, [PNG_PART]), true);
|
|
124
|
+
assert.equal(refusing.calls.length, 2);
|
|
125
|
+
assert.deepEqual(refusing.calls[0].options, { deliverAs: "followUp" });
|
|
126
|
+
assert.equal(refusing.calls[1].options, undefined);
|
|
127
|
+
assert.deepEqual(refusing.calls[1].content, PNG_CONTENT);
|
|
128
|
+
assert.deepEqual(refusing.lines, [], "a delivery that landed on the retry is not a failure");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** One role prose, in the shape the client renders and `agent.mjs` hands over. */
|
|
132
|
+
const PROSE_TEXT = "Read the incoming task.\n\nAnswer in the house style.\n";
|
|
133
|
+
|
|
134
|
+
/** The shift `before_agent_start` hands a handler: the prompt options it is about to render. */
|
|
135
|
+
const promptEvent = () => ({ systemPromptOptions: { sections: {} } });
|
|
136
|
+
|
|
137
|
+
test("the role prose becomes one system-prompt section, byte for byte", () => {
|
|
138
|
+
const { surface, calls, messages } = fakeSurface();
|
|
139
|
+
assert.equal(surface.roleProse(PROSE_TEXT), true);
|
|
140
|
+
|
|
141
|
+
const event = promptEvent();
|
|
142
|
+
assert.equal(surface.applyRoleProse(event), true);
|
|
143
|
+
assert.deepEqual(event.systemPromptOptions.sections, { [PROSE_SECTION]: PROSE_TEXT });
|
|
144
|
+
// The client's bytes exactly: no prefix naming the role, no label, no newline
|
|
145
|
+
// of this plugin's own.
|
|
146
|
+
assert.equal(event.systemPromptOptions.sections[PROSE_SECTION], PROSE_TEXT);
|
|
147
|
+
// And it travelled as no message at all: the injection that used to put the
|
|
148
|
+
// prose in the transcript's message stream is gone.
|
|
149
|
+
assert.deepEqual(messages, [], "the role prose is not a conversation message");
|
|
150
|
+
assert.deepEqual(calls, [], "and not a user message either");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("no prose arrived is no section written", () => {
|
|
154
|
+
const { surface } = fakeSurface();
|
|
155
|
+
const event = promptEvent();
|
|
156
|
+
|
|
157
|
+
assert.equal(surface.applyRoleProse(event), false);
|
|
158
|
+
assert.deepEqual(event.systemPromptOptions.sections, {});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("every run writes the section again, because pi rebuilds the prompt options per run", () => {
|
|
162
|
+
const { surface } = fakeSurface();
|
|
163
|
+
surface.roleProse(PROSE_TEXT);
|
|
164
|
+
|
|
165
|
+
const first = promptEvent();
|
|
166
|
+
const second = promptEvent();
|
|
167
|
+
assert.equal(surface.applyRoleProse(first), true);
|
|
168
|
+
assert.equal(surface.applyRoleProse(second), true);
|
|
169
|
+
assert.deepEqual(second.systemPromptOptions.sections, first.systemPromptOptions.sections);
|
|
170
|
+
|
|
171
|
+
// A later prose — the spec edited between two runs of one session — takes the
|
|
172
|
+
// section over rather than being masked by the text that was written first.
|
|
173
|
+
surface.roleProse("Answer in the log style.");
|
|
174
|
+
const third = promptEvent();
|
|
175
|
+
surface.applyRoleProse(third);
|
|
176
|
+
assert.deepEqual(third.systemPromptOptions.sections, { [PROSE_SECTION]: "Answer in the log style." });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a pi whose prompt options carry no sections reports it once and carries no prose", () => {
|
|
180
|
+
const { surface, lines } = fakeSurface();
|
|
181
|
+
surface.roleProse(PROSE_TEXT);
|
|
182
|
+
|
|
183
|
+
assert.equal(surface.applyRoleProse({ systemPromptOptions: {} }), false);
|
|
184
|
+
assert.equal(surface.applyRoleProse({}), false);
|
|
185
|
+
assert.equal(lines.length, 1, "one line, not one per run");
|
|
186
|
+
assert.match(lines[0], /no instruction layer/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A surface over a fake pi that answers the two questions the phase is read
|
|
191
|
+
* from, plus the cases where pi answers neither.
|
|
192
|
+
* @param {{ isIdle?: () => boolean, hasPendingMessages?: () => boolean, throwOn?: "idle" | "pending" }} [pi]
|
|
193
|
+
*/
|
|
194
|
+
function idleSurface(pi = {}) {
|
|
195
|
+
const lines = [];
|
|
196
|
+
return {
|
|
197
|
+
lines,
|
|
198
|
+
surface: createSurface({ pi, log: (line) => lines.push(line), context: () => pi }),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test("only a session waiting on its next message reads as idle", async () => {
|
|
203
|
+
const waiting = idleSurface({ isIdle: () => true, hasPendingMessages: () => false });
|
|
204
|
+
assert.equal(await waiting.surface.waitingForInput(), true);
|
|
205
|
+
|
|
206
|
+
// A queued continuation is input already on its way, so the session is not
|
|
207
|
+
// waiting for the user even though no turn is running.
|
|
208
|
+
const queued = idleSurface({ isIdle: () => true, hasPendingMessages: () => true });
|
|
209
|
+
assert.equal(await queued.surface.waitingForInput(), false);
|
|
210
|
+
|
|
211
|
+
// A turn in flight, a retry, and a compaction all read as not idle here.
|
|
212
|
+
const busy = idleSurface({ isIdle: () => false, hasPendingMessages: () => false });
|
|
213
|
+
assert.equal(await busy.surface.waitingForInput(), false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a phase pi was never asked about reads as running, not idle", async () => {
|
|
217
|
+
// The probes are optional members of an older pi, and a session with no
|
|
218
|
+
// context yet has witnessed nothing: both answer `false` rather than guess.
|
|
219
|
+
const noProbes = idleSurface({});
|
|
220
|
+
assert.equal(await noProbes.surface.waitingForInput(), false);
|
|
221
|
+
|
|
222
|
+
const noContext = createSurface({ pi: { isIdle: () => true }, log: () => {}, context: () => null });
|
|
223
|
+
assert.equal(await noContext.waitingForInput(), false);
|
|
224
|
+
|
|
225
|
+
const throwing = idleSurface({
|
|
226
|
+
isIdle: () => true,
|
|
227
|
+
hasPendingMessages: () => {
|
|
228
|
+
throw new Error("pi is mid-replay");
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
assert.equal(await throwing.surface.waitingForInput(), false);
|
|
232
|
+
});
|
package/src/protocol.mjs
CHANGED
|
@@ -36,8 +36,12 @@ export const SEQ_BASE = 1000;
|
|
|
36
36
|
/** Heartbeat cadence while a task is active (`heartbeat_timeout_ms` is 30s). */
|
|
37
37
|
export const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Outcomes the wire accepts for one completion. The proto's own `Outcome`
|
|
41
|
+
* (`crates/onlyne-proto/src/envelope.rs`), so the two drives that mount these
|
|
42
|
+
* tools refuse the same spellings rather than each keeping a list.
|
|
43
|
+
*/
|
|
44
|
+
export const OUTCOMES = ["done", "failed", "cancelled", "blocked"];
|
|
41
45
|
|
|
42
46
|
/** Mime types `ImagePart` accepts, in the core's stable order. */
|
|
43
47
|
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
@@ -102,6 +106,11 @@ export function welcomeFrom(data) {
|
|
|
102
106
|
prose: typeof args.prose === "string" ? args.prose : "",
|
|
103
107
|
server: args.server ?? null,
|
|
104
108
|
hostCapabilities: Array.isArray(args.host_capabilities) ? args.host_capabilities : [],
|
|
109
|
+
// The tasks whose delivery the host already handed this session: a plugin
|
|
110
|
+
// that restarts under a live client seeds its injection guard from it, so
|
|
111
|
+
// the work its predecessor read is not injected a second time. Null when the
|
|
112
|
+
// host says nothing.
|
|
113
|
+
deliveredTasks: Array.isArray(args.delivered_tasks) ? args.delivered_tasks : null,
|
|
105
114
|
};
|
|
106
115
|
}
|
|
107
116
|
|
|
@@ -131,11 +140,21 @@ export function heartbeatReport({ taskId, generation, seq, agent, host = null })
|
|
|
131
140
|
};
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
143
|
+
/**
|
|
144
|
+
* `report.complete` — the terminal fact the ledger keeps.
|
|
145
|
+
*
|
|
146
|
+
* `head` is the display line the ledger holds; `details` is the full result,
|
|
147
|
+
* carried verbatim for the next hop and the originator, and `files` are the
|
|
148
|
+
* absolute paths that result names. The client owns the `details` ceiling and
|
|
149
|
+
* refuses an oversize body, so this builds the frame and adds no rule of its
|
|
150
|
+
* own (`docs/v2-CONTRACT.md` §3c).
|
|
151
|
+
*/
|
|
152
|
+
export function completeReport({ taskId, outcome, head, details = null, files = [] }) {
|
|
136
153
|
const report = { kind: "complete", data: { task_id: taskId, outcome: normalizeOutcome(outcome) } };
|
|
137
154
|
const text = headOf(head);
|
|
138
155
|
if (text) report.data.head = text;
|
|
156
|
+
if (typeof details === "string" && details.length > 0) report.data.details = details;
|
|
157
|
+
if (Array.isArray(files) && files.length > 0) report.data.files = files;
|
|
139
158
|
return report;
|
|
140
159
|
}
|
|
141
160
|
|
|
@@ -277,7 +296,7 @@ export function sendEnvelope({ from, to, kind = "note", text = "", image = null,
|
|
|
277
296
|
return envelope;
|
|
278
297
|
}
|
|
279
298
|
|
|
280
|
-
/** A human-readable principal, for
|
|
299
|
+
/** A human-readable principal, for the plugin's own operator log line. */
|
|
281
300
|
export function describePrincipal(principal) {
|
|
282
301
|
if (!principal || typeof principal !== "object") return "unknown";
|
|
283
302
|
if (principal.role) {
|
|
@@ -305,48 +324,10 @@ export function normalizeOutcome(value) {
|
|
|
305
324
|
}
|
|
306
325
|
|
|
307
326
|
/**
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* (identical in `welcome` and `assign`) is folded in only when it has not
|
|
313
|
-
* already been delivered.
|
|
314
|
-
*
|
|
315
|
-
* The header also carries the family's own figures — the hop this assignment
|
|
316
|
-
* sits at and the hops the family may spend — so a role reads its position off
|
|
317
|
-
* the instruction. Both appear only when the causality names a hop budget: the
|
|
318
|
-
* budget is what marks a payload as a member of a bounded family, and a payload
|
|
319
|
-
* that names none injects exactly the bytes it produced before this header
|
|
320
|
-
* carried them.
|
|
321
|
-
* @param {{ assign: any, proseIsNew: boolean, attachmentPaths?: string[] }} options
|
|
322
|
-
*/
|
|
323
|
-
export function injectionText({ assign, proseIsNew, attachmentPaths = [] }) {
|
|
324
|
-
const envelope = assign.envelope ?? {};
|
|
325
|
-
const causality = envelope.causality ?? {};
|
|
326
|
-
const taskId = assign.task_id ?? envelope.causality?.task ?? "unknown";
|
|
327
|
-
const position =
|
|
328
|
-
typeof causality.hop_budget === "number"
|
|
329
|
-
? `, hop ${causality.hop ?? 0}, hop budget ${causality.hop_budget}`
|
|
330
|
-
: "";
|
|
331
|
-
const lines = [
|
|
332
|
-
`[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"}${position})`,
|
|
333
|
-
];
|
|
334
|
-
const prose = typeof assign.prose === "string" ? assign.prose.trim() : "";
|
|
335
|
-
if (prose && proseIsNew) {
|
|
336
|
-
lines.push("", "[onlyne] role prose from the spec:", prose);
|
|
337
|
-
}
|
|
338
|
-
const text = typeof envelope.body?.text === "string" ? envelope.body.text.trim() : "";
|
|
339
|
-
lines.push("", text || "(the task carried no text; the image attachment is the payload)");
|
|
340
|
-
if (attachmentPaths.length > 0) {
|
|
341
|
-
lines.push("", `[onlyne] attachment saved to: ${attachmentPaths.join(", ")}`);
|
|
342
|
-
}
|
|
343
|
-
return lines.join("\n");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Task body for the `config_get` route: when a plugin does not declare `inject`
|
|
348
|
-
* the host hands the payload over as `config_get{key:"stdin:<task text>"}`
|
|
349
|
-
* (PROTOCOL.md, "Mounts and capabilities").
|
|
327
|
+
* Delivery text for the `config_get` route: when a plugin does not declare
|
|
328
|
+
* `inject` the host hands the payload over as
|
|
329
|
+
* `config_get{key:"stdin:<delivery text>"}`, the same bytes an `assign` carries
|
|
330
|
+
* in its `text` (PROTOCOL.md, "Mounts and capabilities").
|
|
350
331
|
* @returns {{ text: string } | null}
|
|
351
332
|
*/
|
|
352
333
|
export function stdinTaskText(args) {
|
package/src/protocol.test.mjs
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
helloArgs,
|
|
19
19
|
hostBinding,
|
|
20
20
|
imagePart,
|
|
21
|
-
injectionText,
|
|
22
21
|
normalizeOutcome,
|
|
23
22
|
observationFor,
|
|
24
23
|
readPluginVersion,
|
|
@@ -31,7 +30,6 @@ import {
|
|
|
31
30
|
const VECTOR_DIR = fileURLToPath(new URL("../../../crates/onlyne-proto/tests/wire_vectors/", import.meta.url));
|
|
32
31
|
const hasVectors = existsSync(VECTOR_DIR);
|
|
33
32
|
const vectorFrame = (name) => JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}${name}`, "utf8")).frame);
|
|
34
|
-
const ASSIGN = () => vectorFrame("adapter_host_assign.json");
|
|
35
33
|
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
36
34
|
|
|
37
35
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
@@ -89,6 +87,7 @@ test("welcome parses the host's response body and rejects junk", () => {
|
|
|
89
87
|
prose: "Read the incoming task",
|
|
90
88
|
server: { connected: true, cluster: "local", name: "server" },
|
|
91
89
|
hostCapabilities: ["probe", "recycle"],
|
|
90
|
+
deliveredTasks: null,
|
|
92
91
|
});
|
|
93
92
|
assert.deepEqual(welcomeFrom(args)?.role, "planner");
|
|
94
93
|
assert.equal(welcomeFrom(null), null);
|
|
@@ -101,7 +100,14 @@ test("welcome parses the host's response body and rejects junk", () => {
|
|
|
101
100
|
prose: "",
|
|
102
101
|
server: null,
|
|
103
102
|
hostCapabilities: [],
|
|
103
|
+
deliveredTasks: null,
|
|
104
104
|
});
|
|
105
|
+
// The host's restart-recovery list travels under its own wire name.
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
welcomeFrom({ op: "welcome", args: { ...args, delivered_tasks: ["task-1"] } }).deliveredTasks,
|
|
108
|
+
["task-1"],
|
|
109
|
+
);
|
|
110
|
+
assert.equal(welcomeFrom({ op: "welcome", args: { ...args, delivered_tasks: "task-1" } }).deliveredTasks, null);
|
|
105
111
|
});
|
|
106
112
|
|
|
107
113
|
test("ready matches the host's ready vector, which relays a cluster this plugin never speaks for", { skip: !hasVectors }, () => {
|
|
@@ -219,16 +225,6 @@ test("observationFor attaches the host only when there is one", () => {
|
|
|
219
225
|
assert.deepEqual(dimensions, bare);
|
|
220
226
|
});
|
|
221
227
|
|
|
222
|
-
test("heartbeatReport carries the host inside observed", () => {
|
|
223
|
-
const report = heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 1, agent: "idle", host: hostBinding(PANE_ENV) });
|
|
224
|
-
assert.equal(report.data.observed.host.orca.pane_key, PANE_KEY);
|
|
225
|
-
assert.equal(report.data.observed.agent, "idle");
|
|
226
|
-
assert.equal(
|
|
227
|
-
"host" in heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 2, agent: "idle" }).data.observed,
|
|
228
|
-
false
|
|
229
|
-
);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
228
|
test("a completion names an outcome and a single-line head", () => {
|
|
233
229
|
assert.deepEqual(completeReport({ taskId: "t1", outcome: "failed", head: "broke\non line two" }), {
|
|
234
230
|
kind: "complete",
|
|
@@ -239,9 +235,24 @@ test("a completion names an outcome and a single-line head", () => {
|
|
|
239
235
|
kind: "complete",
|
|
240
236
|
data: { task_id: "t1", outcome: "done" },
|
|
241
237
|
});
|
|
238
|
+
// The full result and the paths it names ride the same report, and only when
|
|
239
|
+
// there is something to carry: an empty body adds no key at all.
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
completeReport({ taskId: "t1", outcome: "done", head: "done", details: "all of it", files: ["/ws/a.md"] }),
|
|
242
|
+
{
|
|
243
|
+
kind: "complete",
|
|
244
|
+
data: { task_id: "t1", outcome: "done", head: "done", details: "all of it", files: ["/ws/a.md"] },
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
assert.deepEqual(completeReport({ taskId: "t1", outcome: "done", details: "", files: [] }), {
|
|
248
|
+
kind: "complete",
|
|
249
|
+
data: { task_id: "t1", outcome: "done" },
|
|
250
|
+
});
|
|
242
251
|
assert.equal(completeReport({ taskId: "t1", outcome: "weird" }).data.outcome, "done");
|
|
243
252
|
assert.equal(normalizeOutcome(undefined), "done");
|
|
244
253
|
assert.equal(normalizeOutcome("cancelled"), "cancelled");
|
|
254
|
+
// `blocked` is the proto's own outcome (slice 1), so it travels as itself.
|
|
255
|
+
assert.equal(normalizeOutcome("blocked"), "blocked");
|
|
245
256
|
});
|
|
246
257
|
|
|
247
258
|
test("the ledger head is capped at the plan's 200 characters", () => {
|
|
@@ -251,63 +262,6 @@ test("the ledger head is capped at the plan's 200 characters", () => {
|
|
|
251
262
|
assert.equal(headOf(undefined), "");
|
|
252
263
|
});
|
|
253
264
|
|
|
254
|
-
test("an assignment becomes one message naming its origin and payload", { skip: !hasVectors }, () => {
|
|
255
|
-
const assign = ASSIGN().args;
|
|
256
|
-
const text = injectionText({ assign, proseIsNew: true });
|
|
257
|
-
// The vector's causality names no hop budget, and the header is byte for byte
|
|
258
|
-
// the line this plugin has always injected.
|
|
259
|
-
assert.equal(
|
|
260
|
-
text.split("\n")[0],
|
|
261
|
-
"[onlyne] task 11111111-1111-4111-8111-111111111111 from role:planner (kind task)",
|
|
262
|
-
);
|
|
263
|
-
assert.match(text, /\[onlyne\] role prose from the spec:\nRead the incoming task/);
|
|
264
|
-
assert.match(text, /\nbuild it\n?$/);
|
|
265
|
-
|
|
266
|
-
const repeat = injectionText({ assign, proseIsNew: false });
|
|
267
|
-
assert.doesNotMatch(repeat, /role prose from the spec/);
|
|
268
|
-
assert.match(repeat, /build it/);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test("the header names the hop and the budget once the family names a budget", () => {
|
|
272
|
-
const header = (causality) =>
|
|
273
|
-
injectionText({
|
|
274
|
-
assign: {
|
|
275
|
-
task_id: "t9",
|
|
276
|
-
envelope: {
|
|
277
|
-
id: "e9",
|
|
278
|
-
kind: "task",
|
|
279
|
-
from: { role: { role: "planner" } },
|
|
280
|
-
causality,
|
|
281
|
-
body: { text: "pass it on" },
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
|
-
proseIsNew: false,
|
|
285
|
-
}).split("\n")[0];
|
|
286
|
-
|
|
287
|
-
assert.equal(
|
|
288
|
-
header({ task: "t9", hop: 2, attempt: 0, family: "t1", hop_budget: 5 }),
|
|
289
|
-
"[onlyne] task t9 from role:planner (kind task, hop 2, hop budget 5)",
|
|
290
|
-
);
|
|
291
|
-
// A family may spend no further hop: zero is a figure the header carries.
|
|
292
|
-
assert.equal(
|
|
293
|
-
header({ task: "t9", hop: 0, attempt: 0, family: "t1", hop_budget: 0 }),
|
|
294
|
-
"[onlyne] task t9 from role:planner (kind task, hop 0, hop budget 0)",
|
|
295
|
-
);
|
|
296
|
-
// No budget named: the compat rule holds, hop or no hop.
|
|
297
|
-
assert.equal(header({ task: "t9", hop: 2, attempt: 0 }), "[onlyne] task t9 from role:planner (kind task)");
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
test("an empty-bodied assignment still produces an instruction", () => {
|
|
301
|
-
const text = injectionText({
|
|
302
|
-
assign: { task_id: "t9", envelope: { id: "e9", kind: "note", from: { gateway: { gateway: "fg1", channel: "fake", conversation: "c1" } }, body: {} } },
|
|
303
|
-
proseIsNew: false,
|
|
304
|
-
attachmentPaths: ["/ws/.onlyne/tmp/attachments/a.png"],
|
|
305
|
-
});
|
|
306
|
-
assert.match(text, /from gateway:fg1:fake:c1/);
|
|
307
|
-
assert.match(text, /the task carried no text/);
|
|
308
|
-
assert.match(text, /\/ws\/\.onlyne\/tmp\/attachments\/a\.png/);
|
|
309
|
-
});
|
|
310
|
-
|
|
311
265
|
test("a note carries no idempotency key and a task carries both", { skip: !hasVectors }, () => {
|
|
312
266
|
const note = sendEnvelope({ from: "planner", to: "builder", kind: "note", text: "ping" });
|
|
313
267
|
assert.deepEqual(Object.keys(note).sort(), ["admin", "body", "from", "id", "kind", "protocol", "to", "ts"].sort());
|