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.
@@ -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
- // proseContext pi.sendMessage({customType,...}, { deliverAs:"followUp", triggerTurn:false })
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
- * @param {{ pi: any, log: (line: string) => void, context: () => any }} options
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 function createSurface({ pi, log, context }) {
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 injection text already names the file that was written.
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, once, as a custom message that joins the LLM context
134
- * without starting a turn of its own.
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
- const proseContext = (text, welcome) => {
137
- if (!available.proseContext) return false;
138
- try {
139
- pi.sendMessage(
140
- {
141
- customType: "onlyne-role-prose",
142
- content: `[onlyne] role prose for ${welcome.role} (from the cluster spec, delivered with welcome):\n\n${text}`,
143
- display: true,
144
- },
145
- { deliverAs: "followUp", triggerTurn: false },
146
- );
147
- return true;
148
- } catch (error) {
149
- const message = error instanceof Error ? error.message : String(error);
150
- log(`role prose injection refused: ${message}`);
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
- proseContext,
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-task extension holding live work keeps the session running
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 background.running());
257
+ return !(await backgroundRunning());
194
258
  },
195
259
  closeBackground() {
196
260
  background.close();
@@ -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
- /** The injection text `agent.mjs` builds for one assignment. */
17
- const TASK_TEXT = "[onlyne] task 11111111-1111-4111-8111-111111111111 from role:planner";
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
- /** Outcomes the wire accepts for one completion. */
40
- export const OUTCOMES = ["done", "failed", "cancelled"];
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
- /** `report.complete` — the terminal fact the ledger keeps. */
135
- export function completeReport({ taskId, outcome, head }) {
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 injection headers. */
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
- * The user message one `assign` becomes.
309
- *
310
- * The task text is quoted verbatim under a header naming its origin, so a
311
- * session transcript shows where the instruction came from; the role prose
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) {
@@ -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());