pi-onlyne 1.2.2 → 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/agent.live.test.mjs +15 -5
- package/src/agent.mjs +413 -291
- package/src/agent.test.mjs +466 -301
- package/src/background-subagents.mjs +192 -0
- package/src/background-subagents.test.mjs +166 -0
- package/src/config.mjs +4 -23
- package/src/index.ts +67 -54
- package/src/pi-surface.mjs +90 -26
- package/src/pi-surface.test.mjs +76 -4
- package/src/protocol.mjs +28 -47
- package/src/protocol.test.mjs +23 -59
- 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
package/src/pi-surface.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Probed members (measured against pi 0.87.1):
|
|
6
6
|
// wakeUser pi.sendUserMessage(content, { deliverAs: "followUp" })
|
|
7
|
-
//
|
|
7
|
+
// roleProse pi.on("before_agent_start") -> event.systemPromptOptions.sections
|
|
8
|
+
// (index.ts owns the subscription; this module holds the prose and
|
|
9
|
+
// writes the section it becomes)
|
|
8
10
|
// customEntry pi.appendEntry(customType, data)
|
|
9
11
|
// widget ctx.ui.setWidget("onlyne", lines) / ctx.ui.setWidget("onlyne", undefined)
|
|
10
12
|
// status ctx.ui.setStatus("onlyne", text)
|
|
@@ -13,15 +15,25 @@
|
|
|
13
15
|
// pending ctx.hasPendingMessages()
|
|
14
16
|
// toolNames pi.getAllTools() (background-work.mjs)
|
|
15
17
|
// eventBus pi.events (background-work.mjs)
|
|
18
|
+
// subagentRegistry ~/.pi/subagents/missions (background-subagents.mjs)
|
|
16
19
|
// registerTool / registerCommand are probed by index.ts itself.
|
|
17
20
|
|
|
18
21
|
import { WIDGET_KEY } from "./activity.mjs";
|
|
22
|
+
import { createSubagentProbe } from "./background-subagents.mjs";
|
|
19
23
|
import { createBackgroundProbe } from "./background-work.mjs";
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
26
|
+
* The system-prompt section the role prose occupies. pi wraps a section in a tag
|
|
27
|
+
* of the same name and records it under that name in the transcript, which is
|
|
28
|
+
* also where the live case reads the prose back from
|
|
29
|
+
* (`crates/onlyne-testkit/e2e/pi-live.sh`), so the name is stable.
|
|
23
30
|
*/
|
|
24
|
-
export
|
|
31
|
+
export const PROSE_SECTION = "onlyne-role-prose";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ pi: any, log: (line: string) => void, context: () => any, sessionId?: string | null }} options
|
|
35
|
+
*/
|
|
36
|
+
export function createSurface({ pi, log, context, sessionId = null }) {
|
|
25
37
|
const has = (value) => typeof value === "function";
|
|
26
38
|
const ctx = () => {
|
|
27
39
|
try {
|
|
@@ -55,9 +67,33 @@ export function createSurface({ pi, log, context }) {
|
|
|
55
67
|
log,
|
|
56
68
|
});
|
|
57
69
|
|
|
70
|
+
// The second family of off-loop work: `Agent` calls, which return a handle and
|
|
71
|
+
// leave a subagent running with no tool event to bracket it. Scoped to this
|
|
72
|
+
// session's own missions when the plugin knows its id.
|
|
73
|
+
const subagents = createSubagentProbe({
|
|
74
|
+
getSessionId: () => sessionId,
|
|
75
|
+
log,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Live work in either extension holds the turn open. Both probes fail safe on
|
|
80
|
+
* their own, and a throw from either is caught here, so a broken probe still
|
|
81
|
+
* resolves to "not running" and the session proceeds.
|
|
82
|
+
* @returns {Promise<boolean>}
|
|
83
|
+
*/
|
|
84
|
+
const backgroundRunning = async () => {
|
|
85
|
+
try {
|
|
86
|
+
const answers = await Promise.all([background.running(), subagents.running()]);
|
|
87
|
+
return answers.some(Boolean);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
log(`background work probe failed: ${message}`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
58
95
|
const available = {
|
|
59
96
|
wakeUser: has(pi.sendUserMessage),
|
|
60
|
-
proseContext: has(pi.sendMessage),
|
|
61
97
|
customEntry: has(pi.appendEntry),
|
|
62
98
|
widget: has(ctx()?.ui?.setWidget),
|
|
63
99
|
status: true,
|
|
@@ -91,7 +127,7 @@ export function createSurface({ pi, log, context }) {
|
|
|
91
127
|
* built, so a part missing either string stops the whole delivery inside pi.
|
|
92
128
|
* pi reports that failure in its own pane and hands nothing back to this
|
|
93
129
|
* plugin, so an unusable part is dropped here and the assignment still
|
|
94
|
-
* travels: the
|
|
130
|
+
* travels: the delivery text already names the path the client wrote.
|
|
95
131
|
*/
|
|
96
132
|
const wakeUser = (text, parts = []) => {
|
|
97
133
|
if (!available.wakeUser) {
|
|
@@ -130,26 +166,53 @@ export function createSurface({ pi, log, context }) {
|
|
|
130
166
|
};
|
|
131
167
|
|
|
132
168
|
/**
|
|
133
|
-
* The role prose
|
|
134
|
-
*
|
|
169
|
+
* The role prose this session was handed, held for the instruction layer.
|
|
170
|
+
*
|
|
171
|
+
* The client rendered it and this module adds nothing to it: no prefix, no
|
|
172
|
+
* label, no formatting. It reaches the model as a system-prompt section and
|
|
173
|
+
* never as a conversation message: a message would file the spec's prose in
|
|
174
|
+
* the transcript's message stream beside the delivery the model was asked to
|
|
175
|
+
* act on, and the model would read both as the same kind of thing.
|
|
135
176
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
177
|
+
let roleProseText = "";
|
|
178
|
+
|
|
179
|
+
/** One line for a pi without sectioned prompts, not one per run. */
|
|
180
|
+
let warnedNoSections = false;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Hand the role prose over once.
|
|
184
|
+
* @param {string} text
|
|
185
|
+
* @returns {boolean} whether there is prose to carry
|
|
186
|
+
*/
|
|
187
|
+
const roleProse = (text) => {
|
|
188
|
+
roleProseText = typeof text === "string" ? text : "";
|
|
189
|
+
return roleProseText.length > 0;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Put that prose into the run that is starting: one section of pi's system
|
|
194
|
+
* prompt, whose value is the client's bytes exactly.
|
|
195
|
+
*
|
|
196
|
+
* `before_agent_start` is pi's only instruction-layer point, and it hands the
|
|
197
|
+
* handler the prompt options it is about to render (`prompt-customizer.ts` in
|
|
198
|
+
* pi's own examples does this). pi rebuilds those options for every run, so the
|
|
199
|
+
* section is written again rather than once — the write is idempotent, and the
|
|
200
|
+
* first run records it in the transcript's system message.
|
|
201
|
+
* @param {{ systemPromptOptions?: { sections?: Record<string, string> } } | null} event
|
|
202
|
+
* @returns {boolean} whether a section was written
|
|
203
|
+
*/
|
|
204
|
+
const applyRoleProse = (event) => {
|
|
205
|
+
if (roleProseText.length === 0) return false;
|
|
206
|
+
const sections = event?.systemPromptOptions?.sections;
|
|
207
|
+
if (!sections) {
|
|
208
|
+
if (!warnedNoSections) {
|
|
209
|
+
warnedNoSections = true;
|
|
210
|
+
log("before_agent_start carries no prompt sections; the role prose reached no instruction layer");
|
|
211
|
+
}
|
|
151
212
|
return false;
|
|
152
213
|
}
|
|
214
|
+
sections[PROSE_SECTION] = roleProseText;
|
|
215
|
+
return true;
|
|
153
216
|
};
|
|
154
217
|
|
|
155
218
|
const customEntry = (customType, data) => {
|
|
@@ -167,7 +230,8 @@ export function createSurface({ pi, log, context }) {
|
|
|
167
230
|
return {
|
|
168
231
|
available,
|
|
169
232
|
wakeUser,
|
|
170
|
-
|
|
233
|
+
roleProse,
|
|
234
|
+
applyRoleProse,
|
|
171
235
|
customEntry,
|
|
172
236
|
widget,
|
|
173
237
|
status,
|
|
@@ -185,12 +249,12 @@ export function createSurface({ pi, log, context }) {
|
|
|
185
249
|
},
|
|
186
250
|
/**
|
|
187
251
|
* The phase rule in one place: idle means waiting for user input, and a
|
|
188
|
-
* background
|
|
189
|
-
* even while pi itself waits.
|
|
252
|
+
* background extension holding live work — a `bg_*` task or a subagent
|
|
253
|
+
* mission — keeps the session running even while pi itself waits.
|
|
190
254
|
*/
|
|
191
255
|
async waitingForInput() {
|
|
192
256
|
if (!piWaitsForInput()) return false;
|
|
193
|
-
return !(await
|
|
257
|
+
return !(await backgroundRunning());
|
|
194
258
|
},
|
|
195
259
|
closeBackground() {
|
|
196
260
|
background.close();
|
package/src/pi-surface.test.mjs
CHANGED
|
@@ -7,14 +7,22 @@
|
|
|
7
7
|
// Anthropic `source: { type, media_type, data }` this plugin once sent — stops
|
|
8
8
|
// the whole delivery inside pi, and pi reports that in its own pane without
|
|
9
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.
|
|
10
15
|
|
|
11
16
|
import assert from "node:assert/strict";
|
|
12
17
|
import { test } from "node:test";
|
|
13
18
|
|
|
14
|
-
import { createSurface } from "./pi-surface.mjs";
|
|
19
|
+
import { PROSE_SECTION, createSurface } from "./pi-surface.mjs";
|
|
15
20
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
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";
|
|
18
26
|
|
|
19
27
|
/** One inline image part in the shape the agent hands the surface. */
|
|
20
28
|
const PNG_PART = { type: "image", mime: "image/png", data: "iVBORw0KGgoAAAANSUhEUg==", name: "shot.png" };
|
|
@@ -34,6 +42,7 @@ const PNG_CONTENT = [
|
|
|
34
42
|
*/
|
|
35
43
|
function fakeSurface({ refuse = null } = {}) {
|
|
36
44
|
const calls = [];
|
|
45
|
+
const messages = [];
|
|
37
46
|
const lines = [];
|
|
38
47
|
const surface = createSurface({
|
|
39
48
|
pi: {
|
|
@@ -41,11 +50,16 @@ function fakeSurface({ refuse = null } = {}) {
|
|
|
41
50
|
calls.push({ content, options });
|
|
42
51
|
if (refuse?.(content, options)) throw new Error("this pi does not take that option");
|
|
43
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
|
+
},
|
|
44
58
|
},
|
|
45
59
|
log: (line) => lines.push(line),
|
|
46
60
|
context: () => null,
|
|
47
61
|
});
|
|
48
|
-
return { calls, lines, surface };
|
|
62
|
+
return { calls, messages, lines, surface };
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
test("an image attachment reaches pi as one flat image content part beside the task text", () => {
|
|
@@ -114,6 +128,64 @@ test("delivery asks for followUp and a refusal of that option retries with the s
|
|
|
114
128
|
assert.deepEqual(refusing.lines, [], "a delivery that landed on the retry is not a failure");
|
|
115
129
|
});
|
|
116
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
|
+
|
|
117
189
|
/**
|
|
118
190
|
* A surface over a fake pi that answers the two questions the phase is read
|
|
119
191
|
* from, plus the cases where pi answers neither.
|
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 }, () => {
|
|
@@ -229,9 +235,24 @@ test("a completion names an outcome and a single-line head", () => {
|
|
|
229
235
|
kind: "complete",
|
|
230
236
|
data: { task_id: "t1", outcome: "done" },
|
|
231
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
|
+
});
|
|
232
251
|
assert.equal(completeReport({ taskId: "t1", outcome: "weird" }).data.outcome, "done");
|
|
233
252
|
assert.equal(normalizeOutcome(undefined), "done");
|
|
234
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");
|
|
235
256
|
});
|
|
236
257
|
|
|
237
258
|
test("the ledger head is capped at the plan's 200 characters", () => {
|
|
@@ -241,63 +262,6 @@ test("the ledger head is capped at the plan's 200 characters", () => {
|
|
|
241
262
|
assert.equal(headOf(undefined), "");
|
|
242
263
|
});
|
|
243
264
|
|
|
244
|
-
test("an assignment becomes one message naming its origin and payload", { skip: !hasVectors }, () => {
|
|
245
|
-
const assign = ASSIGN().args;
|
|
246
|
-
const text = injectionText({ assign, proseIsNew: true });
|
|
247
|
-
// The vector's causality names no hop budget, and the header is byte for byte
|
|
248
|
-
// the line this plugin has always injected.
|
|
249
|
-
assert.equal(
|
|
250
|
-
text.split("\n")[0],
|
|
251
|
-
"[onlyne] task 11111111-1111-4111-8111-111111111111 from role:planner (kind task)",
|
|
252
|
-
);
|
|
253
|
-
assert.match(text, /\[onlyne\] role prose from the spec:\nRead the incoming task/);
|
|
254
|
-
assert.match(text, /\nbuild it\n?$/);
|
|
255
|
-
|
|
256
|
-
const repeat = injectionText({ assign, proseIsNew: false });
|
|
257
|
-
assert.doesNotMatch(repeat, /role prose from the spec/);
|
|
258
|
-
assert.match(repeat, /build it/);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test("the header names the hop and the budget once the family names a budget", () => {
|
|
262
|
-
const header = (causality) =>
|
|
263
|
-
injectionText({
|
|
264
|
-
assign: {
|
|
265
|
-
task_id: "t9",
|
|
266
|
-
envelope: {
|
|
267
|
-
id: "e9",
|
|
268
|
-
kind: "task",
|
|
269
|
-
from: { role: { role: "planner" } },
|
|
270
|
-
causality,
|
|
271
|
-
body: { text: "pass it on" },
|
|
272
|
-
},
|
|
273
|
-
},
|
|
274
|
-
proseIsNew: false,
|
|
275
|
-
}).split("\n")[0];
|
|
276
|
-
|
|
277
|
-
assert.equal(
|
|
278
|
-
header({ task: "t9", hop: 2, attempt: 0, family: "t1", hop_budget: 5 }),
|
|
279
|
-
"[onlyne] task t9 from role:planner (kind task, hop 2, hop budget 5)",
|
|
280
|
-
);
|
|
281
|
-
// A family may spend no further hop: zero is a figure the header carries.
|
|
282
|
-
assert.equal(
|
|
283
|
-
header({ task: "t9", hop: 0, attempt: 0, family: "t1", hop_budget: 0 }),
|
|
284
|
-
"[onlyne] task t9 from role:planner (kind task, hop 0, hop budget 0)",
|
|
285
|
-
);
|
|
286
|
-
// No budget named: the compat rule holds, hop or no hop.
|
|
287
|
-
assert.equal(header({ task: "t9", hop: 2, attempt: 0 }), "[onlyne] task t9 from role:planner (kind task)");
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("an empty-bodied assignment still produces an instruction", () => {
|
|
291
|
-
const text = injectionText({
|
|
292
|
-
assign: { task_id: "t9", envelope: { id: "e9", kind: "note", from: { gateway: { gateway: "fg1", channel: "fake", conversation: "c1" } }, body: {} } },
|
|
293
|
-
proseIsNew: false,
|
|
294
|
-
attachmentPaths: ["/ws/.onlyne/tmp/attachments/a.png"],
|
|
295
|
-
});
|
|
296
|
-
assert.match(text, /from gateway:fg1:fake:c1/);
|
|
297
|
-
assert.match(text, /the task carried no text/);
|
|
298
|
-
assert.match(text, /\/ws\/\.onlyne\/tmp\/attachments\/a\.png/);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
265
|
test("a note carries no idempotency key and a task carries both", { skip: !hasVectors }, () => {
|
|
302
266
|
const note = sendEnvelope({ from: "planner", to: "builder", kind: "note", text: "ping" });
|
|
303
267
|
assert.deepEqual(Object.keys(note).sort(), ["admin", "body", "from", "id", "kind", "protocol", "to", "ts"].sort());
|