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.
@@ -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
- /** 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 }, () => {
@@ -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());