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.
@@ -11,7 +11,6 @@ import { fileURLToPath } from "node:url";
11
11
  import { afterEach, test } from "node:test";
12
12
 
13
13
  import { OnlyneAgent } from "./agent.mjs";
14
- import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
15
14
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
16
15
  import { SEQ_BASE, readyReport } from "./protocol.mjs";
17
16
 
@@ -51,6 +50,28 @@ function heartbeats(host) {
51
50
  .map((args) => args.data.observed);
52
51
  }
53
52
 
53
+ /** Every `report.complete` the fake host received, in arrival order. */
54
+ function completions(host) {
55
+ return host.of("report").filter((report) => report.kind === "complete");
56
+ }
57
+
58
+ /**
59
+ * Every heartbeat the fake host received, in arrival order, with the two numbers
60
+ * the host's watermark gate reads — the seq on the frame and the seq inside the
61
+ * tuple it hands the reducer — beside the facts a test asserts on.
62
+ */
63
+ function beatFrames(host) {
64
+ return host
65
+ .of("report")
66
+ .filter((args) => args.kind === "heartbeat")
67
+ .map((args) => ({
68
+ taskId: args.data.task_id,
69
+ seq: args.data.seq,
70
+ versionSeq: args.data.observed.version.seq,
71
+ agent: args.data.observed.agent,
72
+ }));
73
+ }
74
+
54
75
  /**
55
76
  * The exact keys of a plugin observation outside a pane. `agent`, `resource` and
56
77
  * the reconcile defaults are what the plugin states; `delivery` and `recovery`
@@ -69,16 +90,6 @@ const OBSERVED_KEYS = [
69
90
  "recovery",
70
91
  ];
71
92
 
72
- /**
73
- * The observation's keys with the pane binding discounted: `host` rides along
74
- * when the process was spawned in an Orca pane, and the test host's own
75
- * environment decides whether it does.
76
- */
77
- function dimensionKeys(observed) {
78
- const { host, ...rest } = observed;
79
- return Object.keys(rest);
80
- }
81
-
82
93
  /** Poll until `predicate` holds, so a test never races the event loop. */
83
94
  async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
84
95
  const deadline = Date.now() + timeoutMs;
@@ -102,10 +113,19 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
102
113
  * reports are never held: the handshake waits on them.
103
114
  * `handoffError` refuses every `handoff` with that error body, the way a client
104
115
  * that cannot find the task the session names would; with none, the fake answers
105
- * the child the proto documents.
116
+ * the child the proto documents. `failCompletions` refuses that many `complete`
117
+ * reports before answering the rest, the way a client whose settle failed would.
118
+ * `request` sends a host frame carrying an id, which is how the plugin is asked
119
+ * for an answer (`nudge`); `answers` reads what came back.
106
120
  */
107
121
  class FakeHost {
108
- constructor({ coalesceAssign = null, holdCompletion = false, failSend = false, handoffError = null } = {}) {
122
+ constructor({
123
+ coalesceAssign = null,
124
+ holdCompletion = false,
125
+ failSend = false,
126
+ handoffError = null,
127
+ failCompletions = 0,
128
+ } = {}) {
109
129
  this.server = createServer((socket) => this.onConnection(socket));
110
130
  this.frames = [];
111
131
  this.sockets = new Set();
@@ -114,6 +134,7 @@ class FakeHost {
114
134
  this.holdCompletion = holdCompletion;
115
135
  this.failSend = failSend;
116
136
  this.handoffError = handoffError;
137
+ this.failCompletions = failCompletions;
117
138
  this.held = [];
118
139
  }
119
140
 
@@ -159,6 +180,15 @@ class FakeHost {
159
180
  this.held.push({ socket, id: frame.id });
160
181
  return;
161
182
  }
183
+ if (this.failCompletions > 0 && frame.op === "report" && frame.args?.kind === "complete") {
184
+ this.failCompletions -= 1;
185
+ socket.write(encodeFrame({
186
+ reply_to: frame.id,
187
+ ok: false,
188
+ error: { code: "invalid", message: "settle refused" },
189
+ }));
190
+ return;
191
+ }
162
192
  if (this.failSend && frame.op === "send") {
163
193
  socket.write(encodeFrame({
164
194
  reply_to: frame.id,
@@ -192,6 +222,16 @@ class FakeHost {
192
222
  for (const socket of this.sockets) socket.write(encodeFrame({ op, args }));
193
223
  }
194
224
 
225
+ /** One host request, with the id the plugin's answer is keyed by. */
226
+ request(op, args, id) {
227
+ for (const socket of this.sockets) socket.write(encodeFrame({ id, op, args }));
228
+ }
229
+
230
+ /** Every answer the plugin sent for one host request id. */
231
+ answers(id) {
232
+ return this.frames.filter((entry) => entry.frame.reply_to === id).map((entry) => entry.frame);
233
+ }
234
+
195
235
  of(op) {
196
236
  return this.frames.filter((entry) => entry.frame.op === op).map((entry) => entry.frame.args);
197
237
  }
@@ -210,7 +250,6 @@ function fakeSurface(options = {}) {
210
250
  calls,
211
251
  available: {
212
252
  wakeUser: true,
213
- proseContext: true,
214
253
  customEntry: true,
215
254
  // Off by default: tests that do not opt into a panel keep the footer and log path.
216
255
  widget: options.widget === true,
@@ -224,7 +263,7 @@ function fakeSurface(options = {}) {
224
263
  calls.wakeUser.push({ text, parts });
225
264
  return true;
226
265
  },
227
- proseContext: (text) => {
266
+ roleProse: (text) => {
228
267
  calls.prose.push(text);
229
268
  return true;
230
269
  },
@@ -253,11 +292,11 @@ async function startAgent({
253
292
  holdCompletion = false,
254
293
  failSend = false,
255
294
  handoffError = null,
256
- relay = null,
295
+ failCompletions = 0,
257
296
  } = {}) {
258
297
  const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
259
298
  const socketPath = join(dir, "s");
260
- const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError });
299
+ const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError, failCompletions });
261
300
  await host.listen(socketPath);
262
301
  const logs = [];
263
302
  const agent = new OnlyneAgent({
@@ -274,7 +313,6 @@ async function startAgent({
274
313
  settleFallbackMs: options.settleFallbackMs ?? 30_000,
275
314
  heartbeatMs: options.heartbeatMs ?? 60_000,
276
315
  ...(capabilities ? { capabilities } : {}),
277
- ...(relay ? { relay } : {}),
278
316
  ...(options.host !== undefined ? { host: options.host } : {}),
279
317
  });
280
318
  cleanups.push(async () => {
@@ -303,10 +341,30 @@ const assignArgs = () => (ASSIGN_FRAME ? ASSIGN_FRAME.args : {
303
341
  admin: false,
304
342
  },
305
343
  prose: "Read the incoming task",
344
+ // The delivery text the client's template renders for that envelope and body:
345
+ // `From <sender role>:` / blank / the body verbatim. The plugin injects these
346
+ // bytes and composes nothing of its own around a delivery.
347
+ text: "From planner:\n\nbuild it",
306
348
  task_id: TASK_ID,
307
349
  generation: 1,
308
350
  });
309
351
 
352
+ /**
353
+ * The same host delivery under its own task id and envelope: what a session
354
+ * holds when the work it is running and the next one handed over are both open.
355
+ * The envelope id has to move with the task id, or the delivery guard rightly
356
+ * reads the second assign as the first one re-offered.
357
+ */
358
+ const secondTaskArgs = () => {
359
+ const args = structuredClone(assignArgs());
360
+ args.task_id = ASSIGNED_TASK_ID;
361
+ args.envelope.id = "6c5d4e3f-8a9b-4c10-9d2e-4f5061728394";
362
+ args.envelope.causality.task = ASSIGNED_TASK_ID;
363
+ args.envelope.body.text = "take it from here";
364
+ args.text = "From planner:\n\ntake it from here";
365
+ return args;
366
+ };
367
+
310
368
  /**
311
369
  * Run `body` with exactly the ORCA_* environment an Orca pane exports, so the
312
370
  * result does not depend on the shell the tests happen to run in, then restore
@@ -443,7 +501,7 @@ test("a fresh agent opens with hello, registers and reports ready", async () =>
443
501
  // The report stream starts above the host's own dispatch sequence, or the
444
502
  // reducer would drop it as stale (onlyne-session's watermark gate).
445
503
  assert.ok(ready.data.seq > SEQ_BASE - 1, `seq ${ready.data.seq} must clear the host watermark`);
446
- // The role prose arrived once, as context rather than as a turn.
504
+ // The role prose was handed to the instruction layer once, not as a message.
447
505
  assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
448
506
  assert.equal(agent.status().connected, true);
449
507
  });
@@ -453,15 +511,15 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
453
511
  agent.start();
454
512
  await waitFor(() => host.of("report").length >= 1);
455
513
 
456
- host.notify("assign", assignArgs());
514
+ const args = assignArgs();
515
+ host.notify("assign", args);
457
516
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
458
517
  const injected = surface.calls.wakeUser[0];
459
- assert.match(injected.text, /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/);
460
- assert.match(injected.text, /build it/);
518
+ // What the client rendered, byte for byte: no header, no prose, no wrapper.
519
+ assert.equal(injected.text, args.text, "the frame's text is injected unchanged");
461
520
  assert.deepEqual(injected.parts, []);
462
521
  // The prose came with welcome and is not repeated on every payload.
463
522
  assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
464
- assert.doesNotMatch(injected.text, /Read the incoming task/);
465
523
  assert.deepEqual(surface.calls.entries[0].type, "onlyne-assign");
466
524
  assert.equal(surface.calls.entries[0].data.proseInjected, false);
467
525
 
@@ -488,10 +546,6 @@ test("a new envelope for a running task reaches the model and keeps its record",
488
546
  host.notify("assign", assignArgs());
489
547
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
490
548
  const record = agent.tasks.get(TASK_ID);
491
- // Turns already run under this task, without driving the turn hooks: a real
492
- // turn end also arms the settle fallback, which would complete the task.
493
- record.turns = 3;
494
- record.turnsSinceAssign = 2;
495
549
 
496
550
  const base = assignArgs();
497
551
  const follow = {
@@ -501,16 +555,15 @@ test("a new envelope for a running task reaches the model and keeps its record",
501
555
  id: "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172",
502
556
  body: { text: "actually, use the q8 variant" },
503
557
  },
558
+ text: "From planner:\n\nactually, use the q8 variant",
504
559
  };
505
560
  host.notify("assign", follow);
506
561
  await waitFor(() => (surface.calls.wakeUser.length === 2 ? true : null));
507
- assert.match(surface.calls.wakeUser[1].text, /q8 variant/);
562
+ assert.equal(surface.calls.wakeUser[1].text, follow.text, "the follow-up's own text is injected unchanged");
508
563
  const acks = await waitFor(() => (host.of("assign_ack").length === 2 ? host.of("assign_ack") : null));
509
564
  assert.deepEqual(acks[1], { task_id: TASK_ID, accepted: true });
510
565
 
511
566
  assert.equal(agent.tasks.get(TASK_ID), record, "the running work record survives");
512
- assert.equal(record.turns, 3, "what the session already did under this task is not erased");
513
- assert.equal(record.turnsSinceAssign, 0, "the watchdog counts from the newest instruction");
514
567
  assert.equal(record.envelopeId, "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172");
515
568
 
516
569
  // The same envelope again is the true duplicate, and it changes nothing.
@@ -523,16 +576,16 @@ test("a new envelope for a running task reaches the model and keeps its record",
523
576
  // The live case in crates/onlyne-testkit/e2e/pi-live.sh found this: the client
524
577
  // hands over a staged session by writing the hello reply and the first assign
525
578
  // together, so both frames arrive in one read. The assignment must not be
526
- // injected from inside the handshake, or the role prose loses its welcome-time
527
- // delivery and gets folded into the task turn instead.
579
+ // injected from inside the handshake, or the role prose is still unwritten when
580
+ // the delivery text opens the turn that should carry it.
528
581
  test("an assign sharing the hello reply's chunk waits for the welcome", async () => {
529
582
  const events = [];
530
583
  const surface = fakeSurface();
531
- const proseContext = surface.proseContext;
584
+ const roleProse = surface.roleProse;
532
585
  const wakeUser = surface.wakeUser;
533
- surface.proseContext = (text, welcome) => {
586
+ surface.roleProse = (text) => {
534
587
  events.push("prose");
535
- return proseContext(text, welcome);
588
+ return roleProse(text);
536
589
  };
537
590
  surface.wakeUser = (text, parts) => {
538
591
  events.push("assign");
@@ -547,45 +600,49 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
547
600
  }
548
601
  assert.deepEqual(events, ["prose", "assign"]);
549
602
  assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
550
- assert.doesNotMatch(surface.calls.wakeUser[0].text, /Read the incoming task/);
603
+ assert.equal(surface.calls.wakeUser[0].text, assignArgs().text, "the frame's text, byte for byte");
551
604
  assert.equal(surface.calls.entries[0].data.proseInjected, false);
552
605
  const acks = await waitFor(() => (host.of("assign_ack").length >= 1 ? host.of("assign_ack") : null));
553
606
  assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
554
607
  });
555
608
 
556
- test("a waiting turn end reports idle, and the settle after it re-sends the assignment", async () => {
557
- const { agent, host, surface } = await startAgent();
609
+ // The role prose reaches the instruction layer, never the delivery text. An
610
+ // assign that carries prose the welcome did not (a spec edited between the two
611
+ // frames) hands it over the same way, before the task message: the injected text
612
+ // stays exactly the bytes the client rendered.
613
+ test("prose the welcome did not deliver reaches the instruction layer before the task message", async () => {
614
+ const events = [];
615
+ const surface = fakeSurface();
616
+ const roleProse = surface.roleProse;
617
+ const wakeUser = surface.wakeUser;
618
+ surface.roleProse = (text) => {
619
+ events.push(`prose:${text}`);
620
+ return roleProse(text);
621
+ };
622
+ surface.wakeUser = (text, parts) => {
623
+ events.push(`assign:${text}`);
624
+ return wakeUser(text, parts);
625
+ };
626
+ const { agent, host } = await startAgent({ surface });
558
627
  agent.start();
559
628
  await waitFor(() => host.of("report").length >= 1);
560
- host.notify("assign", assignArgs());
561
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
562
-
563
- agent.onTurnStart();
564
- const running = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
565
- assert.equal(running.kind, "heartbeat");
566
- assert.equal(running.data.observed.resource, "attached");
567
- assert.deepEqual(dimensionKeys(running.data.observed), OBSERVED_KEYS, "a running beat carries exactly the dimensions the plugin can see");
568
- assert.equal(running.data.observed.version.generation, 1);
569
629
 
570
- agent.onTurnEnd();
571
- const idle = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
572
- assert.equal(idle.kind, "heartbeat");
573
- assert.deepEqual(dimensionKeys(idle.data.observed), OBSERVED_KEYS, "an idle beat claims no dimension the plugin does not own");
630
+ const args = assignArgs();
631
+ args.prose = "Write the changelog in the house style";
632
+ host.notify("assign", args);
633
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
574
634
 
575
- agent.noteAssistantText("OK");
576
- agent.onSettled();
577
- // A turn that ends without a completion leaves the task open: the settle sends
578
- // the assignment again rather than reporting an outcome the model never
579
- // claimed. `onlyne_complete` is the only path to `done`.
580
- const reminded = await waitFor(() =>
581
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
582
- );
583
- assert.match(reminded[1].text, /build it/, "the reminder carries the task text");
584
- assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
585
- assert.deepEqual(surface.calls.exits, [], "a task with a rung left is not failed");
635
+ assert.deepEqual(events, [
636
+ "prose:Read the incoming task",
637
+ "prose:Write the changelog in the house style",
638
+ `assign:${args.text}`,
639
+ ]);
640
+ assert.deepEqual(surface.calls.prose, ["Read the incoming task", "Write the changelog in the house style"]);
641
+ assert.equal(surface.calls.wakeUser[0].text, args.text, "the prose is not folded into the delivery");
642
+ assert.equal(surface.calls.entries[0].data.proseInjected, true);
586
643
  });
587
644
 
588
- test("a busy turn end reports no idle beat and does not settle the ladder", async () => {
645
+ test("a busy turn end reports no idle beat", async () => {
589
646
  const surface = fakeSurface({ waiting: false });
590
647
  const { agent, host } = await startAgent({ surface });
591
648
  agent.start();
@@ -604,7 +661,6 @@ test("a busy turn end reports no idle beat and does not settle the ladder", asyn
604
661
  false,
605
662
  "the turn-end beat follows the busy surface rather than assuming the hook means idle",
606
663
  );
607
- assert.equal(surface.calls.wakeUser.length, 1, "the ladder waits while the turn is still running");
608
664
  assert.deepEqual(surface.calls.exits, []);
609
665
  });
610
666
 
@@ -632,7 +688,10 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
632
688
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
633
689
  });
634
690
 
635
- test("a busy pi holds the settle decision until it is waiting for input", async () => {
691
+ // The plugin still owes one report at a turn end: the failure it witnessed
692
+ // itself. That report waits for the idle it is about, exactly as the phase-based
693
+ // signal always did.
694
+ test("a busy pi holds the failed-turn report until it is waiting for input", async () => {
636
695
  let waiting = false;
637
696
  const surface = fakeSurface({ waiting: () => waiting });
638
697
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
@@ -640,19 +699,16 @@ test("a busy pi holds the settle decision until it is waiting for input", async
640
699
  await waitFor(() => host.of("report").length >= 1);
641
700
  host.notify("assign", assignArgs());
642
701
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
643
- agent.onTurnEnd();
644
- agent.onSettled();
702
+ agent.onTurnError("provider exploded");
645
703
  await new Promise((resolve) => setTimeout(resolve, 80));
646
- assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
704
+ assert.deepEqual(completions(host), [], "a busy pi is not reported on yet");
647
705
 
648
706
  waiting = true;
649
- const reminded = await waitFor(() =>
650
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
651
- );
652
- assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
707
+ const complete = await waitFor(() => completions(host)[0] ?? null);
708
+ assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
653
709
  });
654
710
 
655
- test("a live background task holds the ladder off until its task is terminal", async () => {
711
+ test("a live background task holds the failed-turn report until its task is terminal", async () => {
656
712
  let backgroundWork = true;
657
713
  const surface = fakeSurface({ backgroundWork: () => backgroundWork });
658
714
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
@@ -662,122 +718,45 @@ test("a live background task holds the ladder off until its task is terminal", a
662
718
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
663
719
 
664
720
  agent.onTurnStart();
665
- agent.onTurnEnd();
666
- agent.onSettled();
721
+ agent.onTurnError("provider exploded");
667
722
  await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
668
723
  await new Promise((resolve) => setTimeout(resolve, 80));
669
- assert.equal(surface.calls.wakeUser.length, 1, "work off the agent loop keeps the ladder off");
724
+ assert.deepEqual(completions(host), [], "work off the agent loop keeps the report off");
670
725
 
671
726
  backgroundWork = false;
672
727
  agent.onSettled();
673
- const reminded = await waitFor(() =>
674
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
675
- );
676
- assert.match(reminded[1].text, /build it/, "the terminal task releases the idle ladder");
728
+ const complete = await waitFor(() => completions(host)[0] ?? null);
729
+ assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
677
730
  });
678
731
 
679
- // The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
680
- // without a completion exit re-sends the assignment, and the idle that finds the
681
- // bound spent fails the task instead of settling it `done`. The bound is the
682
- // workspace's (`config.mjs`, two by default) and the count lives on the record
683
- // `onAssign` wrote for the task, which is why a task can be reminded twice and
684
- // failed on the third idle without the tool ever being called.
685
- test("only settled idles spend ladder rungs, ordinary turns reset them, and the bound fails the task", async () => {
686
- const { agent, host, surface } = await startAgent();
687
- assert.equal(agent.idleReminders, DEFAULT_IDLE_REMINDERS, "the bound is the workspace's, two by default");
732
+ // The turn-end rule has one owner, the client (`docs/v2-CONTRACT.md` §3c): a
733
+ // turn that ends without a completion leaves the task open, and the client
734
+ // decides what that means and tells the session with its own sentence. The
735
+ // plugin's answer to a clean turn end is its beat and nothing else — no
736
+ // injection, no count, and no outcome nobody claimed.
737
+ test("a turn that ends without a completion settles nothing and says nothing", async () => {
738
+ const { agent, host, surface } = await startAgent({ options: { settleFallbackMs: 40 } });
688
739
  agent.start();
689
740
  await waitFor(() => host.of("report").length >= 1);
690
- // The assignment carries an image, so the reminder has an attachment path to
691
- // name and a part it must not hand over twice.
692
- const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
693
- const assignment = structuredClone(assignArgs());
694
- assignment.envelope.body = {
695
- text: "build it",
696
- image: { data_base64: bytes.toString("base64"), mime: "image/png", name: "shot.png" },
697
- };
698
- host.notify("assign", assignment);
741
+ host.notify("assign", assignArgs());
699
742
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
700
- assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
701
-
702
- // The first turn belongs to the session itself. Its settled idle spends rung
703
- // one. The turn that reminder wakes does not reset the episode, so its own
704
- // settled idle spends rung two rather than starting over.
705
- agent.onTurnStart();
706
- agent.onTurnEnd();
707
- agent.onSettled();
708
- const first = await waitFor(() =>
709
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
710
- );
711
- const firstReminder = first.at(-1);
712
- assert.match(firstReminder.text, /reminder 1 of 2/);
713
- assert.match(firstReminder.text, /build it/, "the reminder carries the task text");
714
- assert.match(
715
- firstReminder.text,
716
- /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
717
- "and the identity and source the injection named",
718
- );
719
- assert.match(firstReminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
720
- assert.deepEqual(firstReminder.parts, [], "and the image itself is not handed over twice");
721
- assert.doesNotMatch(firstReminder.text, /Read the incoming task/, "the role prose is already in the context");
722
-
723
- agent.onTurnStart();
724
- agent.onTurnEnd();
725
- agent.onSettled();
726
- const secondRung = await waitFor(() =>
727
- surface.calls.wakeUser.length === 3 ? surface.calls.wakeUser : null,
728
- );
729
- assert.match(secondRung.at(-1).text, /reminder 2 of 2/, "the reminder-woken turn keeps the episode's count");
730
- assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
743
+ agent.noteAssistantText("OK");
731
744
 
732
- // The next turn is the one the second reminder wakes, so it does not reset
733
- // the episode. Let that turn end without a settle, then start a turn of the
734
- // session's own: that ordinary turn begins a fresh episode.
735
- agent.onTurnStart();
736
- agent.onTurnEnd();
745
+ // Two clean turns and a settle after each: the fallback window and the
746
+ // settled signal both land, and neither has anything to decide.
737
747
  agent.onTurnStart();
738
748
  agent.onTurnEnd();
739
749
  agent.onSettled();
740
- const reset = await waitFor(() =>
741
- surface.calls.wakeUser.length === 4 ? surface.calls.wakeUser : null,
742
- );
743
- assert.match(reset.at(-1).text, /reminder 1 of 2/, "an ordinary turn clears the episode's rung count");
744
-
750
+ await new Promise((resolve) => setTimeout(resolve, 100));
745
751
  agent.onTurnStart();
746
752
  agent.onTurnEnd();
747
753
  agent.onSettled();
748
- const secondFresh = await waitFor(() =>
749
- surface.calls.wakeUser.length === 5 ? surface.calls.wakeUser : null,
750
- );
751
- assert.match(secondFresh.at(-1).text, /reminder 2 of 2/);
754
+ await new Promise((resolve) => setTimeout(resolve, 100));
752
755
 
753
- // The third idle in the fresh episode spends the bound, so the open task
754
- // fails and the session leaves — the tool was never called.
755
- agent.onTurnStart();
756
- agent.onTurnEnd();
757
- agent.onSettled();
758
- const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
759
- assert.deepEqual(complete.data, {
760
- task_id: TASK_ID,
761
- outcome: "failed",
762
- head: "no completion after 2 idle reminders",
763
- });
764
- assert.deepEqual(surface.calls.exits, ["failed"], "the ladder's failure leaves the session");
765
- assert.equal(surface.calls.wakeUser.length, 5, "the bound is spent: no third reminder");
766
-
767
- // A turn that hands the outcome over is never reminded: the tool call is the
768
- // exit the ladder exists to get.
769
- const completed = await startAgent();
770
- completed.agent.start();
771
- await waitFor(() => completed.host.of("report").length >= 1);
772
- completed.host.notify("assign", assignArgs());
773
- await waitFor(() => (completed.surface.calls.wakeUser.length === 1 ? true : null));
774
- completed.agent.onTurnStart();
775
- completed.agent.onTurnEnd();
776
- await completed.agent.completeFromTool({ outcome: "done", text: "built it" });
777
- completed.agent.onSettled();
778
- await new Promise((resolve) => setTimeout(resolve, 60));
779
- assert.equal(completed.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
780
- assert.deepEqual(completed.surface.calls.exits, ["done"]);
756
+ assert.equal(surface.calls.wakeUser.length, 1, "no turn end injects anything");
757
+ assert.deepEqual(completions(host), [], "and no turn end reports an outcome nobody claimed");
758
+ assert.deepEqual(surface.calls.exits, [], "the session stays mounted");
759
+ assert.deepEqual(agent.status().tasks, [TASK_ID], "the task is still open");
781
760
  });
782
761
 
783
762
  test("an assigned task that never ran is not completed", async () => {
@@ -815,7 +794,7 @@ test("an explicit tool outcome wins and a second completion is refused", async (
815
794
  agent.onTurnEnd();
816
795
  agent.noteAssistantText("looks fine");
817
796
 
818
- const result = await agent.completeFromTool({ outcome: "failed", text: "changed my mind" });
797
+ const result = await agent.completeFromTool({ outcome: "failed", summary: "changed my mind" });
819
798
  assert.deepEqual(result, { taskId: TASK_ID, outcome: "failed", head: "changed my mind" });
820
799
  assert.deepEqual(await agent.completeFromTool({ outcome: "done" }), {
821
800
  taskId: TASK_ID,
@@ -829,9 +808,36 @@ test("an explicit tool outcome wins and a second completion is refused", async (
829
808
  assert.equal(completes.length, 1);
830
809
  assert.equal(completes[0].data.outcome, "failed");
831
810
  // One session asks for one exit, even when a second completion is refused.
811
+ // The assignment carried no scope, which is read as `oneshot`, so this process
812
+ // is finished and leaves.
832
813
  assert.deepEqual(surface.calls.exits, ["failed"]);
833
814
  });
834
815
 
816
+ // A refused report handed nothing over: the task stays open, the tool caller
817
+ // sees the refusal, and the retry reports again and settles the task.
818
+ test("a refused completion report leaves the task open for the retry", async () => {
819
+ const { agent, host, surface } = await startAgent({ failCompletions: 1 });
820
+ agent.start();
821
+ await waitFor(() => host.of("report").length >= 1);
822
+ host.notify("assign", assignArgs());
823
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
824
+ agent.onTurnStart();
825
+ agent.onTurnEnd();
826
+
827
+ await assert.rejects(() => agent.completeFromTool({ outcome: "done", summary: "built it" }), /settle refused/);
828
+ assert.deepEqual(agent.status().tasks, [TASK_ID], "the refused task is still open");
829
+ assert.equal(agent.status().stats.completions, 0);
830
+ assert.deepEqual(surface.calls.exits, []);
831
+
832
+ const result = await agent.completeFromTool({ outcome: "done", summary: "built it" });
833
+ assert.deepEqual(result, { taskId: TASK_ID, outcome: "done", head: "built it" });
834
+ const completes = host.of("report").filter((report) => report.kind === "complete");
835
+ assert.equal(completes.length, 2, "the retry reported again");
836
+ assert.deepEqual(agent.status().tasks, []);
837
+ assert.equal(agent.status().stats.completions, 1);
838
+ assert.deepEqual(surface.calls.exits, ["done"]);
839
+ });
840
+
835
841
  // The completion body is what the tool call handed over. The sentence a turn
836
842
  // ends on is only the fallback for a call whose argument carries nothing, and a
837
843
  // call that carries a `text` reports it byte for byte — so the argument can
@@ -847,7 +853,7 @@ test("an explicit tool argument is the head over the last assistant text", async
847
853
  agent.noteAssistantText("Handed off to `a` with K=6.");
848
854
 
849
855
  const payload = "K=10: 1:a 2:b 3:c 4:d 5:e 6:a 7:b 8:c 9:d 10:e";
850
- const result = await agent.completeFromTool({ outcome: "done", text: payload });
856
+ const result = await agent.completeFromTool({ outcome: "done", summary: payload });
851
857
  assert.equal(result.head, payload, "the argument is the head, byte for byte");
852
858
  const completes = host.of("report").filter((report) => report.kind === "complete");
853
859
  assert.deepEqual(completes[0].data, { task_id: TASK_ID, outcome: "done", head: payload });
@@ -861,11 +867,11 @@ test("an explicit tool argument is the head over the last assistant text", async
861
867
  assert.equal(host.of("report").filter((report) => report.kind === "complete").length, 1);
862
868
  });
863
869
 
864
- // An argument that carries nothing is no argument: the completion summary is
865
- // the last assistant text instead of an empty head. (An argument absent
866
- // altogether is the queued-completion case above.)
870
+ // A summary that carries nothing is no summary: the head is the last assistant
871
+ // text instead of an empty display line. (A summary absent altogether is the
872
+ // queued-completion case above.)
867
873
  test("an empty tool argument falls back to the last assistant text", async () => {
868
- for (const text of ["", " "]) {
874
+ for (const summary of ["", " "]) {
869
875
  const { agent, host, surface } = await startAgent();
870
876
  agent.start();
871
877
  await waitFor(() => host.of("report").length >= 1);
@@ -875,8 +881,8 @@ test("an empty tool argument falls back to the last assistant text", async () =>
875
881
  agent.onTurnEnd();
876
882
  agent.noteAssistantText("appended 10:e and handed the token back");
877
883
 
878
- const result = await agent.completeFromTool({ outcome: "done", text });
879
- assert.equal(result.head, "appended 10:e and handed the token back", `text=${JSON.stringify(text)}`);
884
+ const result = await agent.completeFromTool({ outcome: "done", summary });
885
+ assert.equal(result.head, "appended 10:e and handed the token back", `summary=${JSON.stringify(summary)}`);
880
886
  const complete = host.of("report").find((report) => report.kind === "complete");
881
887
  assert.equal(complete.data.head, "appended 10:e and handed the token back");
882
888
  }
@@ -941,25 +947,32 @@ test("a reconnect beat re-derives the phase from the surface", async () => {
941
947
  );
942
948
  });
943
949
 
944
- test("an inbound image is written under the workspace and handed to pi", async () => {
950
+ // The client writes every file a delivery carries and names the absolute paths
951
+ // in the frame. The plugin's whole job on an inbound image is to turn the bytes
952
+ // beside it into a pi content part: it writes nothing, and the paths it records
953
+ // are the ones it was handed.
954
+ test("an inbound image reaches pi as a part while the client's own paths are recorded", async () => {
945
955
  const { agent, host, surface, dir } = await startAgent();
946
956
  agent.start();
947
957
  await waitFor(() => host.of("report").length >= 1);
948
958
  const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
959
+ const shot = "/ws/.onlyne/tmp/attachments/11111111-1111-4111-8111-111111111111-shot.png";
949
960
  const args = assignArgs();
950
961
  args.envelope.body = { text: "what is this?", image: { data_base64: bytes.toString("base64"), mime: "image/png", name: "shot.png" } };
962
+ args.text = `From planner:\n\nwhat is this?\n\nAttachments: ${shot}`;
963
+ args.attachments = [shot];
951
964
  host.notify("assign", args);
952
965
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
953
966
 
954
967
  const injected = surface.calls.wakeUser[0];
968
+ assert.equal(injected.text, args.text, "the frame's text, byte for byte");
955
969
  assert.equal(injected.parts.length, 1);
956
970
  assert.equal(injected.parts[0].mime, "image/png");
957
971
  assert.equal(injected.parts[0].data, bytes.toString("base64"));
958
- const path = join(dir, ".onlyne", "tmp", "attachments", `${TASK_ID}-3f2a1c4e-5b6d-4e7f-8a90-1b2c3d4e5f60-shot.png`);
959
- assert.ok(existsSync(path), `expected ${path}`);
960
- assert.deepEqual([...readFileSync(path)], [...bytes]);
961
- assert.ok(injected.text.includes(path), "the injected message names the file it wrote");
962
- assert.deepEqual(surface.calls.entries[0].data.attachments, [path]);
972
+ assert.equal(injected.parts[0].name, "shot.png");
973
+ // The client writes the files: the workspace stays untouched.
974
+ assert.equal(existsSync(join(dir, ".onlyne")), false, "the plugin writes no attachment file");
975
+ assert.deepEqual(surface.calls.entries[0].data.attachments, [shot]);
963
976
  });
964
977
 
965
978
  test("a probe is answered with a heartbeat for the live task", async () => {
@@ -977,13 +990,119 @@ test("a probe is answered with a heartbeat for the live task", async () => {
977
990
  assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
978
991
  });
979
992
 
980
- test("a task body handed over as config_get still reaches pi", async () => {
993
+ // One plugin, one counter, and the counter serves every task the session holds —
994
+ // while the watermark the host gates a report against belongs to one task's row
995
+ // (`onlyne-session`'s reducer: same generation, `seq <= row.seq` is dropped as a
996
+ // stale duplicate). So A@1001, B@1002, A@1003 is the shape that works, and what
997
+ // must never happen is a task's own seq going level or backwards: the dropped
998
+ // beat leaves the liveness stamp where it was, and the sweep retires a session as
999
+ // `heartbeat_stale` under an agent that is alive and working.
1000
+ test("two open tasks beat off one counter, each ahead of its own last beat", async () => {
1001
+ const { agent, host } = await mountedAgent();
1002
+ host.notify("assign", assignArgs());
1003
+ host.notify("assign", secondTaskArgs());
1004
+ await waitFor(() => (host.of("assign_ack").length === 2 ? true : null));
1005
+ assert.deepEqual(agent.status().tasks, [TASK_ID, ASSIGNED_TASK_ID]);
1006
+
1007
+ await agent.heartbeat();
1008
+ await agent.heartbeat();
1009
+ await agent.heartbeat();
1010
+
1011
+ const last = new Map();
1012
+ for (const beat of beatFrames(host)) {
1013
+ const prior = last.get(beat.taskId);
1014
+ assert.ok(
1015
+ prior === undefined || beat.seq > prior,
1016
+ `task ${beat.taskId.slice(0, 8)} beat at seq ${beat.seq} is not ahead of its own last beat (${prior})`,
1017
+ );
1018
+ assert.equal(beat.versionSeq, beat.seq, "the tuple the reducer gates carries the frame's seq");
1019
+ last.set(beat.taskId, beat.seq);
1020
+ }
1021
+ const beats = beatFrames(host);
1022
+ for (const taskId of [TASK_ID, ASSIGNED_TASK_ID]) {
1023
+ assert.equal(
1024
+ beats.filter((beat) => beat.taskId === taskId).length,
1025
+ 3,
1026
+ `every round beats every open task, not just the head of the list (task ${taskId.slice(0, 8)})`,
1027
+ );
1028
+ }
1029
+ const seqs = beats.map((beat) => beat.seq);
1030
+ assert.equal(new Set(seqs).size, seqs.length, "one writer issues one seq once");
1031
+ // The floor the plugin keeps is the one the wire shows: `/onlyne status` can be
1032
+ // read against a row's watermark without anyone reconstructing it from the log.
1033
+ assert.deepEqual(
1034
+ agent.status().taskSeqs,
1035
+ Object.fromEntries([...last].map(([taskId, seq]) => [taskId, seq])),
1036
+ );
1037
+ });
1038
+
1039
+ // The timer, a turn hook and a `probe` can each ask for a beat in the same
1040
+ // instant. Stacking those asks behind a round still writing splits one tick's
1041
+ // facts over two snapshots and doubles the frames every row has to clear, so a
1042
+ // request landing mid-round is folded into the round in flight: one more pass,
1043
+ // answering with the newest phase anyone asked for. And the fold must not cost
1044
+ // the next tick its round — the lock has to be released.
1045
+ test("a beat asked for mid-round folds into the running round instead of stacking", async () => {
1046
+ const waiting = true;
1047
+ const surface = fakeSurface({ waiting: () => waiting });
1048
+ const { agent, host } = await startAgent({ surface });
1049
+ agent.start();
1050
+ await waitFor(() => (host.of("report").length >= 1 ? true : null));
1051
+ host.notify("assign", assignArgs());
1052
+ host.notify("assign", secondTaskArgs());
1053
+ await waitFor(() => (host.of("assign_ack").length === 2 ? true : null));
1054
+
1055
+ const inFlight = agent.heartbeat();
1056
+ // Same tick, before the first round has written anything: this is the ask a
1057
+ // turn-start hook makes while the timer's round is still deriving its phase.
1058
+ const folded = agent.heartbeat("running");
1059
+ await Promise.all([inFlight, folded]);
1060
+
1061
+ let beats = beatFrames(host);
1062
+ // The order the frames arrive in is the property: a round beats every open task
1063
+ // before the next round writes anything. Two rounds interleaving instead —
1064
+ // `A idle, A running, B idle, B running` — is what let one task's older beat
1065
+ // trail its own newer one into the host, where the row's watermark gate reads it
1066
+ // stale and the liveness stamp never moves.
1067
+ assert.deepEqual(
1068
+ beats
1069
+ .slice(0, 4)
1070
+ .map((beat) => [beat.taskId === TASK_ID ? "head" : "relay", beat.agent]),
1071
+ [
1072
+ ["head", "idle"],
1073
+ ["relay", "idle"],
1074
+ ["head", "running"],
1075
+ ["relay", "running"],
1076
+ ],
1077
+ "one round beats both tasks before the folded round starts writing",
1078
+ );
1079
+ for (const taskId of [TASK_ID, ASSIGNED_TASK_ID]) {
1080
+ const own = beats.filter((beat) => beat.taskId === taskId);
1081
+ assert.equal(own.length, 2, "two passes, not three: the fold joined a round instead of queuing one");
1082
+ assert.equal(own[0].agent, "idle", "the pass already writing states the phase it derived");
1083
+ assert.equal(own[1].agent, "running", "the pass the fold bought answers the newest ask");
1084
+ assert.ok(own[1].seq > own[0].seq, "and it is still ahead of that task's own last beat");
1085
+ }
1086
+
1087
+ await agent.heartbeat();
1088
+ beats = beatFrames(host);
1089
+ for (const taskId of [TASK_ID, ASSIGNED_TASK_ID]) {
1090
+ assert.equal(
1091
+ beats.filter((beat) => beat.taskId === taskId).length,
1092
+ 3,
1093
+ "the finished round released the lock, so the next tick still beats",
1094
+ );
1095
+ }
1096
+ });
1097
+
1098
+ test("the delivery text handed over as config_get reaches pi unchanged", async () => {
981
1099
  const { agent, host, surface } = await startAgent();
982
1100
  agent.start();
983
1101
  await waitFor(() => host.of("report").length >= 1);
984
- host.notify("config_get", { key: "stdin:do the thing" });
1102
+ const text = "From planner:\n\ndo the thing";
1103
+ host.notify("config_get", { key: `stdin:${text}` });
985
1104
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
986
- assert.match(surface.calls.wakeUser[0].text, /do the thing/);
1105
+ assert.equal(surface.calls.wakeUser[0].text, text, "the stdin route composes no wording of its own");
987
1106
  });
988
1107
 
989
1108
  test("recycle settles the task, detaches and asks pi to exit", async () => {
@@ -1004,6 +1123,87 @@ test("recycle settles the task, detaches and asks pi to exit", async () => {
1004
1123
  assert.equal(host.connections, connections, "a recycled agent must not reconnect");
1005
1124
  });
1006
1125
 
1126
+ // The scope an assignment carries decides whether this process outlives the
1127
+ // delivery it just served, and both answers are load-bearing against a
1128
+ // different failure. `oneshot` leaving is the older half and every case above
1129
+ // covers it; what is new here is a runtime that stays, because a `role` pool in
1130
+ // front of a runtime that leaves is always empty.
1131
+ test("a scope that keeps the session holds this process open after the delivery settles", async () => {
1132
+ for (const scope of ["task", "role"]) {
1133
+ const { agent, host, surface } = await startAgent();
1134
+ agent.start();
1135
+ await waitFor(() => host.of("report").length >= 1);
1136
+ // `assignArgs()` hands back the shared frame's own args object when a frame
1137
+ // is installed, so a test that writes to it is writing to every case after
1138
+ // it. Clone before saying anything about the scope.
1139
+ const args = structuredClone(assignArgs());
1140
+ args.scope = scope;
1141
+ host.notify("assign", args);
1142
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1143
+ agent.onTurnStart();
1144
+ agent.onTurnEnd();
1145
+ agent.noteAssistantText("OK");
1146
+
1147
+ await agent.completeFromTool({ outcome: "done", summary: `kept by ${scope}` });
1148
+ const complete = host.of("report").find((report) => report.kind === "complete");
1149
+ assert.equal(complete.data.head, `kept by ${scope}`, `${scope}: the report landed`);
1150
+ assert.deepEqual(
1151
+ surface.calls.exits,
1152
+ [],
1153
+ `${scope}: the session outlives this delivery, so the process stays`,
1154
+ );
1155
+ // The heartbeat stops with the last task — that is the half the client
1156
+ // already expects, and it is why a task-free session is exempt from its
1157
+ // silence sweep. What must not happen is the process leaving.
1158
+ assert.equal(agent.heartbeatHandle, null, `${scope}: the heartbeat stopped with the task`);
1159
+ }
1160
+ });
1161
+
1162
+ // The other direction, and it is a leak rather than a lost pool: a runtime that
1163
+ // stays under a scope that finishes holds its pane open, because the client
1164
+ // retires a session while its agent is still reachable.
1165
+ test("a scope that does not keep the session takes this process with it", async () => {
1166
+ for (const scope of ["oneshot", undefined]) {
1167
+ const { agent, host, surface } = await startAgent();
1168
+ agent.start();
1169
+ await waitFor(() => host.of("report").length >= 1);
1170
+ const args = structuredClone(assignArgs());
1171
+ if (scope !== undefined) args.scope = scope;
1172
+ host.notify("assign", args);
1173
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1174
+ agent.onTurnStart();
1175
+ agent.onTurnEnd();
1176
+ agent.noteAssistantText("OK");
1177
+
1178
+ await agent.completeFromTool({ outcome: "done", summary: "finished" });
1179
+ const label = scope ?? "a frame with no scope";
1180
+ assert.deepEqual(
1181
+ surface.calls.exits,
1182
+ ["done"],
1183
+ `${label}: read as oneshot, so the process leaves and its store is flushed`,
1184
+ );
1185
+ }
1186
+ });
1187
+
1188
+ // The scope is the last assignment's word, so a re-offer of a delivery under a
1189
+ // different scope moves the decision rather than leaving a stale one behind.
1190
+ test("the scope the newest assignment named is the one the exit reads", async () => {
1191
+ const { agent, host, surface } = await startAgent();
1192
+ agent.start();
1193
+ await waitFor(() => host.of("report").length >= 1);
1194
+ const kept = structuredClone(assignArgs());
1195
+ kept.scope = "role";
1196
+ host.notify("assign", kept);
1197
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1198
+ assert.equal(agent.keepsSession(), true);
1199
+
1200
+ const second = secondTaskArgs();
1201
+ second.scope = "oneshot";
1202
+ host.notify("assign", second);
1203
+ await waitFor(() => (host.of("assign_ack").length >= 2 ? true : null));
1204
+ assert.equal(agent.keepsSession(), false, "the newest word wins");
1205
+ });
1206
+
1007
1207
  test("a completion reported while the socket is down is flushed after reconnect", async () => {
1008
1208
  const { agent, host, surface } = await startAgent();
1009
1209
  agent.start();
@@ -1093,156 +1293,121 @@ test("a host that never answers the hello is dropped and retried", async () => {
1093
1293
  assert.match(agent.status().lastError, /hello timed out/);
1094
1294
  });
1095
1295
 
1096
- // ---------------------------------------------------------------- relay guard
1296
+ // ---------------------------------------------------- the completion's shape
1097
1297
 
1098
- // The relay guard is what stops a session from reporting a terminal outcome
1099
- // while it still owes a downstream handoff. Its evidence is delivery only:
1100
- // the roles this session's own successful `onlyne_send` calls reached.
1298
+ // What the model hands over is the whole of the completion: `summary` becomes
1299
+ // the ledger's display head, and `details` and `files` ride the same report
1300
+ // unchanged. The shape and the details ceiling are the client's checks
1301
+ // (`docs/v2-CONTRACT.md` §3c), so the plugin keeps none of them of its own.
1101
1302
 
1102
- /** The exact `report.complete` args this plugin sent before the guard existed. */
1303
+ /** The exact `report.complete` args a completion that carries no body sends. */
1103
1304
  const COMPLETE_VECTOR =
1104
1305
  '{"kind":"complete","data":{"task_id":"11111111-1111-4111-8111-111111111111","outcome":"done","head":"handed the token over"}}';
1105
1306
 
1106
- /** Every `report.complete` the fake host received, in arrival order. */
1107
- function completions(host) {
1108
- return host.of("report").filter((report) => report.kind === "complete");
1109
- }
1307
+ test("a completion sends the summary as the head and adds nothing else", async () => {
1308
+ const { agent, host } = await startAgent();
1309
+ agent.start();
1310
+ await waitFor(() => host.of("report").length >= 1);
1110
1311
 
1111
- /** The host's own assign frame, handed over by a role that is not this one. */
1112
- function assignmentFrom(role) {
1113
- const args = assignArgs();
1114
- return { ...args, envelope: { ...args.envelope, from: { role: { role } } } };
1115
- }
1312
+ await agent.completeFromTool({ outcome: "done", summary: "handed the token over" });
1116
1313
 
1117
- test("with no relay policy the completion frame is unchanged, force and reason included", async () => {
1118
- const inputs = [
1119
- { outcome: "done", text: "handed the token over" },
1120
- { outcome: "done", text: "handed the token over", force: true, reason: "no policy is in force" },
1121
- ];
1122
- for (const input of inputs) {
1123
- const { agent, host } = await startAgent();
1124
- agent.start();
1125
- await waitFor(() => host.of("report").length >= 1);
1126
- await agent.completeFromTool(input);
1127
- assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR, JSON.stringify(input));
1128
- }
1314
+ assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR);
1129
1315
  });
1130
1316
 
1131
- test("a relay list refuses a completion until every named role has a handoff", async () => {
1132
- const { agent, host, surface } = await startAgent({ relay: { required: ["writer"] } });
1317
+ test("details and files ride the completion unchanged", async () => {
1318
+ const { agent, host } = await startAgent();
1133
1319
  agent.start();
1134
1320
  await waitFor(() => host.of("report").length >= 1);
1321
+ const details = "the whole result\nsecond line\n";
1322
+ const files = ["/ws/out/token.txt", "/ws/out/report.md"];
1135
1323
 
1136
- await assert.rejects(
1137
- () => agent.completeFromTool({ outcome: "done", text: "wrote the notes" }),
1138
- (error) => {
1139
- assert.match(
1140
- error.message,
1141
- /^onlyne: relay guard: missing handoff to: writer \(this session delivered to: none\)/,
1142
- );
1143
- assert.match(error.message, /force:true and a non-empty reason/);
1144
- return true;
1145
- },
1146
- );
1147
- assert.deepEqual(completions(host), []);
1148
- assert.deepEqual(surface.calls.exits, []);
1324
+ await agent.completeFromTool({ outcome: "done", summary: "appended 10:e", details, files });
1149
1325
 
1150
- await agent.sendFromTool({ to: "writer", text: "here is the outline" });
1151
- const result = await agent.completeFromTool({ outcome: "done", text: "wrote the notes" });
1152
- assert.deepEqual(result, { taskId: TASK_ID, outcome: "done", head: "wrote the notes" });
1153
- assert.equal(completions(host).length, 1);
1154
- assert.deepEqual(surface.calls.exits, ["done"]);
1326
+ const [complete] = completions(host);
1327
+ assert.equal(complete.data.details, details, "byte for byte, newlines and all");
1328
+ assert.deepEqual(complete.data.files, files);
1155
1329
  });
1156
1330
 
1157
- test("a relay count wants distinct downstream roles and ignores echoes", async () => {
1158
- const { agent, host } = await startAgent({ relay: { required: [], count: 2 } });
1159
- agent.start();
1160
- await waitFor(() => host.of("report").length >= 1);
1161
- host.notify("assign", assignmentFrom("supervisor"));
1162
- await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
1331
+ test("the outcome travels as the model's own word, the proto's four all through", async () => {
1332
+ for (const outcome of ["done", "failed", "cancelled", "blocked"]) {
1333
+ const { agent, host } = await startAgent();
1334
+ agent.start();
1335
+ await waitFor(() => host.of("report").length >= 1);
1163
1336
 
1164
- await agent.sendFromTool({ to: "supervisor", text: "status" }); // back upstream
1165
- await agent.sendFromTool({ to: "planner", text: "note to self" }); // this role
1166
- await agent.sendFromTool({ to: "builder", text: "build it" });
1167
- await assert.rejects(
1168
- () => agent.completeFromTool({ outcome: "done" }),
1169
- /missing handoff: 1 of 2 required distinct downstream roles/,
1170
- );
1337
+ await agent.completeFromTool({ outcome, summary: "the word is the outcome" });
1171
1338
 
1172
- await agent.sendFromTool({ to: "writer", text: "document it" });
1173
- const result = await agent.completeFromTool({ outcome: "done" });
1174
- assert.equal(result.outcome, "done");
1175
- assert.equal(completions(host).length, 1);
1339
+ assert.equal(completions(host)[0].data.outcome, outcome, `${outcome} must not be rewritten`);
1340
+ }
1176
1341
  });
1177
1342
 
1178
- test("force needs a reason, and the reason it takes is stamped into the head", async () => {
1179
- const { agent, host } = await startAgent({ relay: { required: ["writer"] } });
1180
- agent.start();
1181
- await waitFor(() => host.of("report").length >= 1);
1343
+ // ---------------------------------------------------------- the host's nudge
1182
1344
 
1183
- await assert.rejects(
1184
- () =>
1185
- agent.completeFromTool({
1186
- outcome: "done",
1187
- text: "outline is done",
1188
- force: true,
1189
- reason: " ",
1190
- }),
1191
- /missing handoff to: writer/,
1192
- );
1193
- assert.deepEqual(completions(host), []);
1345
+ // The turn-end rule has one owner, the client (`docs/v2-CONTRACT.md` §3c), so
1346
+ // the sentence a session reads when its turn ended without a completion is the
1347
+ // client's own: `nudge { task_id, text }`, handed over exactly as it arrived and
1348
+ // answered with the one claim the plugin can make — that the text was handed
1349
+ // over.
1194
1350
 
1195
- const result = await agent.completeFromTool({
1196
- outcome: "done",
1197
- text: "outline is done",
1198
- force: true,
1199
- reason: "writer is offline for the day",
1200
- });
1201
- assert.equal(result.head, "relay-guard-forced: writer is offline for the day | outline is done");
1202
- assert.equal(completions(host)[0].data.head, result.head);
1203
- });
1351
+ /** 3c's nudge sentence, verbatim. */
1352
+ const NUDGE_TEXT =
1353
+ "If this task is finished, report it with onlyne_complete; if something is missing, say what.";
1204
1354
 
1205
- test("a refusal detaches nothing, and the same call lands once the handoff is out", async () => {
1206
- const { agent, host, surface } = await startAgent({ relay: { required: ["writer"] } });
1355
+ test("a nudge hands the client's sentence over as it stands and answers ok", async () => {
1356
+ const { agent, host, surface } = await startAgent();
1207
1357
  agent.start();
1208
1358
  await waitFor(() => host.of("report").length >= 1);
1359
+ host.notify("assign", assignArgs());
1360
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1361
+ const record = agent.tasks.get(TASK_ID);
1362
+ const reportsBefore = host.of("report").length;
1209
1363
 
1210
- await assert.rejects(() => agent.completeFromTool({ outcome: "done", text: "half done" }), /relay guard/);
1211
- assert.equal(agent.status().connected, true);
1212
- assert.equal(agent.status().stats.completions, 0);
1213
- assert.deepEqual(surface.calls.exits, []);
1214
- assert.deepEqual(host.of("detach"), []);
1215
- assert.deepEqual(completions(host), []);
1364
+ host.request("nudge", { task_id: TASK_ID, text: NUDGE_TEXT }, 900);
1365
+ await waitFor(() => (host.answers(900).length > 0 ? true : null));
1216
1366
 
1217
- await agent.sendFromTool({ to: "writer", text: "the outline so far" });
1218
- await agent.completeFromTool({ outcome: "done", text: "half done" });
1219
- assert.equal(agent.status().stats.completions, 1);
1220
- assert.deepEqual(surface.calls.exits, ["done"]);
1367
+ assert.equal(surface.calls.wakeUser.length, 2);
1368
+ const handed = surface.calls.wakeUser[1];
1369
+ // No prefix, no count, no task id, no role, and nothing of the delivery text
1370
+ // repeated: the client's sentence is the whole of it.
1371
+ assert.equal(handed.text, NUDGE_TEXT, "the client's bytes, with nothing around them");
1372
+ assert.deepEqual(handed.parts, []);
1373
+ assert.deepEqual(host.answers(900), [{ reply_to: 900, ok: true }]);
1374
+ // A nudge is not a delivery and not a report: the task stays open, its record
1375
+ // is untouched, and no frame of the plugin's own follows.
1376
+ assert.equal(agent.tasks.get(TASK_ID), record);
1377
+ assert.equal(record.errored, false);
1378
+ assert.deepEqual(host.of("report").slice(reportsBefore), []);
1221
1379
  });
1222
1380
 
1223
- test("a send the client refused is not a handoff", async () => {
1224
- const { agent, host } = await startAgent({ relay: { required: ["writer"] }, failSend: true });
1381
+ test("a nudge carrying no text is refused rather than answered ok", async () => {
1382
+ const { agent, host, surface } = await startAgent();
1225
1383
  agent.start();
1226
1384
  await waitFor(() => host.of("report").length >= 1);
1227
1385
 
1228
- await assert.rejects(() => agent.sendFromTool({ to: "writer", text: "outline" }), /no_route/);
1229
- await assert.rejects(() => agent.completeFromTool({ outcome: "done" }), /missing handoff to: writer/);
1386
+ host.request("nudge", { task_id: TASK_ID, text: "" }, 901);
1387
+ await waitFor(() => (host.answers(901).length > 0 ? true : null));
1388
+
1389
+ assert.equal(surface.calls.wakeUser.length, 0, "there is nothing to hand over");
1390
+ const [answer] = host.answers(901);
1391
+ // The client settles a delivery on this answer, so it must not read as handed
1392
+ // over when pi took nothing.
1393
+ assert.equal(answer.ok, false);
1394
+ assert.equal(answer.error.code, "internal");
1230
1395
  });
1231
1396
 
1232
- test("the handoff ledger belongs to the session, not to one task", async () => {
1233
- const { agent, host } = await startAgent({ relay: { required: ["writer"] } });
1397
+ test("a nudge that carries no id is a notification and gets no answer", async () => {
1398
+ const { agent, host, surface } = await startAgent();
1234
1399
  agent.start();
1235
1400
  await waitFor(() => host.of("report").length >= 1);
1236
1401
 
1237
- // A note sent before the assignment arrived is still this session reaching
1238
- // the role the policy names.
1239
- await agent.sendFromTool({ to: "writer", text: "preamble" });
1240
- host.notify("assign", assignArgs());
1241
- await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
1402
+ host.notify("nudge", { task_id: TASK_ID, text: NUDGE_TEXT });
1403
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1242
1404
 
1243
- const result = await agent.completeFromTool({ outcome: "done", text: "wrote the notes" });
1244
- assert.equal(result.outcome, "done");
1245
- assert.equal(completions(host).length, 1);
1405
+ assert.equal(surface.calls.wakeUser[0].text, NUDGE_TEXT);
1406
+ assert.equal(
1407
+ host.frames.some((entry) => entry.frame.reply_to !== undefined),
1408
+ false,
1409
+ "a notification owes no answer",
1410
+ );
1246
1411
  });
1247
1412
 
1248
1413
  // ------------------------------------------------------------ the handoff tool