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.
@@ -11,8 +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 { MAX_LINES, MAX_WIDTH } from "./activity.mjs";
15
- import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
16
14
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
17
15
  import { SEQ_BASE, readyReport } from "./protocol.mjs";
18
16
 
@@ -52,6 +50,28 @@ function heartbeats(host) {
52
50
  .map((args) => args.data.observed);
53
51
  }
54
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
+
55
75
  /**
56
76
  * The exact keys of a plugin observation outside a pane. `agent`, `resource` and
57
77
  * the reconcile defaults are what the plugin states; `delivery` and `recovery`
@@ -70,16 +90,6 @@ const OBSERVED_KEYS = [
70
90
  "recovery",
71
91
  ];
72
92
 
73
- /**
74
- * The observation's keys with the pane binding discounted: `host` rides along
75
- * when the process was spawned in an Orca pane, and the test host's own
76
- * environment decides whether it does.
77
- */
78
- function dimensionKeys(observed) {
79
- const { host, ...rest } = observed;
80
- return Object.keys(rest);
81
- }
82
-
83
93
  /** Poll until `predicate` holds, so a test never races the event loop. */
84
94
  async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
85
95
  const deadline = Date.now() + timeoutMs;
@@ -103,10 +113,19 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
103
113
  * reports are never held: the handshake waits on them.
104
114
  * `handoffError` refuses every `handoff` with that error body, the way a client
105
115
  * that cannot find the task the session names would; with none, the fake answers
106
- * 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.
107
120
  */
108
121
  class FakeHost {
109
- 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
+ } = {}) {
110
129
  this.server = createServer((socket) => this.onConnection(socket));
111
130
  this.frames = [];
112
131
  this.sockets = new Set();
@@ -115,6 +134,7 @@ class FakeHost {
115
134
  this.holdCompletion = holdCompletion;
116
135
  this.failSend = failSend;
117
136
  this.handoffError = handoffError;
137
+ this.failCompletions = failCompletions;
118
138
  this.held = [];
119
139
  }
120
140
 
@@ -160,6 +180,15 @@ class FakeHost {
160
180
  this.held.push({ socket, id: frame.id });
161
181
  return;
162
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
+ }
163
192
  if (this.failSend && frame.op === "send") {
164
193
  socket.write(encodeFrame({
165
194
  reply_to: frame.id,
@@ -193,6 +222,16 @@ class FakeHost {
193
222
  for (const socket of this.sockets) socket.write(encodeFrame({ op, args }));
194
223
  }
195
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
+
196
235
  of(op) {
197
236
  return this.frames.filter((entry) => entry.frame.op === op).map((entry) => entry.frame.args);
198
237
  }
@@ -211,7 +250,6 @@ function fakeSurface(options = {}) {
211
250
  calls,
212
251
  available: {
213
252
  wakeUser: true,
214
- proseContext: true,
215
253
  customEntry: true,
216
254
  // Off by default: tests that do not opt into a panel keep the footer and log path.
217
255
  widget: options.widget === true,
@@ -225,7 +263,7 @@ function fakeSurface(options = {}) {
225
263
  calls.wakeUser.push({ text, parts });
226
264
  return true;
227
265
  },
228
- proseContext: (text) => {
266
+ roleProse: (text) => {
229
267
  calls.prose.push(text);
230
268
  return true;
231
269
  },
@@ -254,11 +292,11 @@ async function startAgent({
254
292
  holdCompletion = false,
255
293
  failSend = false,
256
294
  handoffError = null,
257
- relay = null,
295
+ failCompletions = 0,
258
296
  } = {}) {
259
297
  const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
260
298
  const socketPath = join(dir, "s");
261
- const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError });
299
+ const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError, failCompletions });
262
300
  await host.listen(socketPath);
263
301
  const logs = [];
264
302
  const agent = new OnlyneAgent({
@@ -275,7 +313,6 @@ async function startAgent({
275
313
  settleFallbackMs: options.settleFallbackMs ?? 30_000,
276
314
  heartbeatMs: options.heartbeatMs ?? 60_000,
277
315
  ...(capabilities ? { capabilities } : {}),
278
- ...(relay ? { relay } : {}),
279
316
  ...(options.host !== undefined ? { host: options.host } : {}),
280
317
  });
281
318
  cleanups.push(async () => {
@@ -304,10 +341,30 @@ const assignArgs = () => (ASSIGN_FRAME ? ASSIGN_FRAME.args : {
304
341
  admin: false,
305
342
  },
306
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",
307
348
  task_id: TASK_ID,
308
349
  generation: 1,
309
350
  });
310
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
+
311
368
  /**
312
369
  * Run `body` with exactly the ORCA_* environment an Orca pane exports, so the
313
370
  * result does not depend on the shell the tests happen to run in, then restore
@@ -374,24 +431,6 @@ test("a reconnect reports the same pane", async () => {
374
431
  });
375
432
  });
376
433
 
377
- test("a second task in the same pane reports the same pane", async () => {
378
- await inOrcaPane(PANE_ENV, async () => {
379
- const { host } = await mountedAgent();
380
- host.notify("assign", assignArgs());
381
- await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
382
- host.notify("probe", {});
383
- await waitFor(() => (heartbeats(host).length ? true : null));
384
-
385
- // The binding belongs to the process, not to the task: handing this pane
386
- // another task cannot change where the process runs.
387
- host.notify("assign", { ...assignArgs(), task_id: "22222222-2222-4222-8222-222222222222" });
388
- await waitFor(() => (host.of("assign_ack").length === 2 ? true : null));
389
- host.notify("probe", {});
390
- const reports = await waitFor(() => (heartbeats(host).length >= 2 ? heartbeats(host) : null));
391
- assert.deepEqual(reports.at(-1).host, reports[0].host);
392
- });
393
- });
394
-
395
434
  test("a pi outside an Orca pane reports no host at all", async () => {
396
435
  await inOrcaPane({}, async () => {
397
436
  const { host } = await mountedAgent();
@@ -405,22 +444,6 @@ test("a pi outside an Orca pane reports no host at all", async () => {
405
444
  });
406
445
  });
407
446
 
408
- test("a pane that exports no handle reports the pane without inventing one", async () => {
409
- await inOrcaPane({ ORCA_PANE_KEY: PANE_KEY }, async () => {
410
- const { host } = await mountedAgent();
411
- host.notify("assign", assignArgs());
412
- await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
413
- host.notify("probe", {});
414
-
415
- const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
416
- // The pane key carries both ids; the handle is a field the environment
417
- // either has or has not, and a missing one is absent rather than empty.
418
- assert.deepEqual(observed.host, {
419
- orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF },
420
- });
421
- });
422
- });
423
-
424
447
  test("the binding is the environment the process was spawned with", async () => {
425
448
  await inOrcaPane(PANE_ENV, async () => {
426
449
  const { host } = await mountedAgent();
@@ -478,7 +501,7 @@ test("a fresh agent opens with hello, registers and reports ready", async () =>
478
501
  // The report stream starts above the host's own dispatch sequence, or the
479
502
  // reducer would drop it as stale (onlyne-session's watermark gate).
480
503
  assert.ok(ready.data.seq > SEQ_BASE - 1, `seq ${ready.data.seq} must clear the host watermark`);
481
- // 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.
482
505
  assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
483
506
  assert.equal(agent.status().connected, true);
484
507
  });
@@ -488,15 +511,15 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
488
511
  agent.start();
489
512
  await waitFor(() => host.of("report").length >= 1);
490
513
 
491
- host.notify("assign", assignArgs());
514
+ const args = assignArgs();
515
+ host.notify("assign", args);
492
516
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
493
517
  const injected = surface.calls.wakeUser[0];
494
- assert.match(injected.text, /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/);
495
- 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");
496
520
  assert.deepEqual(injected.parts, []);
497
521
  // The prose came with welcome and is not repeated on every payload.
498
522
  assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
499
- assert.doesNotMatch(injected.text, /Read the incoming task/);
500
523
  assert.deepEqual(surface.calls.entries[0].type, "onlyne-assign");
501
524
  assert.equal(surface.calls.entries[0].data.proseInjected, false);
502
525
 
@@ -523,10 +546,6 @@ test("a new envelope for a running task reaches the model and keeps its record",
523
546
  host.notify("assign", assignArgs());
524
547
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
525
548
  const record = agent.tasks.get(TASK_ID);
526
- // Turns already run under this task, without driving the turn hooks: a real
527
- // turn end also arms the settle fallback, which would complete the task.
528
- record.turns = 3;
529
- record.turnsSinceAssign = 2;
530
549
 
531
550
  const base = assignArgs();
532
551
  const follow = {
@@ -536,16 +555,15 @@ test("a new envelope for a running task reaches the model and keeps its record",
536
555
  id: "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172",
537
556
  body: { text: "actually, use the q8 variant" },
538
557
  },
558
+ text: "From planner:\n\nactually, use the q8 variant",
539
559
  };
540
560
  host.notify("assign", follow);
541
561
  await waitFor(() => (surface.calls.wakeUser.length === 2 ? true : null));
542
- 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");
543
563
  const acks = await waitFor(() => (host.of("assign_ack").length === 2 ? host.of("assign_ack") : null));
544
564
  assert.deepEqual(acks[1], { task_id: TASK_ID, accepted: true });
545
565
 
546
566
  assert.equal(agent.tasks.get(TASK_ID), record, "the running work record survives");
547
- assert.equal(record.turns, 3, "what the session already did under this task is not erased");
548
- assert.equal(record.turnsSinceAssign, 0, "the watchdog counts from the newest instruction");
549
567
  assert.equal(record.envelopeId, "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172");
550
568
 
551
569
  // The same envelope again is the true duplicate, and it changes nothing.
@@ -558,16 +576,16 @@ test("a new envelope for a running task reaches the model and keeps its record",
558
576
  // The live case in crates/onlyne-testkit/e2e/pi-live.sh found this: the client
559
577
  // hands over a staged session by writing the hello reply and the first assign
560
578
  // together, so both frames arrive in one read. The assignment must not be
561
- // injected from inside the handshake, or the role prose loses its welcome-time
562
- // 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.
563
581
  test("an assign sharing the hello reply's chunk waits for the welcome", async () => {
564
582
  const events = [];
565
583
  const surface = fakeSurface();
566
- const proseContext = surface.proseContext;
584
+ const roleProse = surface.roleProse;
567
585
  const wakeUser = surface.wakeUser;
568
- surface.proseContext = (text, welcome) => {
586
+ surface.roleProse = (text) => {
569
587
  events.push("prose");
570
- return proseContext(text, welcome);
588
+ return roleProse(text);
571
589
  };
572
590
  surface.wakeUser = (text, parts) => {
573
591
  events.push("assign");
@@ -582,45 +600,49 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
582
600
  }
583
601
  assert.deepEqual(events, ["prose", "assign"]);
584
602
  assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
585
- 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");
586
604
  assert.equal(surface.calls.entries[0].data.proseInjected, false);
587
605
  const acks = await waitFor(() => (host.of("assign_ack").length >= 1 ? host.of("assign_ack") : null));
588
606
  assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
589
607
  });
590
608
 
591
- test("a waiting turn end reports idle, and the settle after it re-sends the assignment", async () => {
592
- 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 });
593
627
  agent.start();
594
628
  await waitFor(() => host.of("report").length >= 1);
595
- host.notify("assign", assignArgs());
596
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
597
-
598
- agent.onTurnStart();
599
- const running = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
600
- assert.equal(running.kind, "heartbeat");
601
- assert.equal(running.data.observed.resource, "attached");
602
- assert.deepEqual(dimensionKeys(running.data.observed), OBSERVED_KEYS, "a running beat carries exactly the dimensions the plugin can see");
603
- assert.equal(running.data.observed.version.generation, 1);
604
629
 
605
- agent.onTurnEnd();
606
- const idle = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
607
- assert.equal(idle.kind, "heartbeat");
608
- 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));
609
634
 
610
- agent.noteAssistantText("OK");
611
- agent.onSettled();
612
- // A turn that ends without a completion leaves the task open: the settle sends
613
- // the assignment again rather than reporting an outcome the model never
614
- // claimed. `onlyne_complete` is the only path to `done`.
615
- const reminded = await waitFor(() =>
616
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
617
- );
618
- assert.match(reminded[1].text, /build it/, "the reminder carries the task text");
619
- assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
620
- 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);
621
643
  });
622
644
 
623
- 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 () => {
624
646
  const surface = fakeSurface({ waiting: false });
625
647
  const { agent, host } = await startAgent({ surface });
626
648
  agent.start();
@@ -639,7 +661,6 @@ test("a busy turn end reports no idle beat and does not settle the ladder", asyn
639
661
  false,
640
662
  "the turn-end beat follows the busy surface rather than assuming the hook means idle",
641
663
  );
642
- assert.equal(surface.calls.wakeUser.length, 1, "the ladder waits while the turn is still running");
643
664
  assert.deepEqual(surface.calls.exits, []);
644
665
  });
645
666
 
@@ -667,7 +688,10 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
667
688
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
668
689
  });
669
690
 
670
- 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 () => {
671
695
  let waiting = false;
672
696
  const surface = fakeSurface({ waiting: () => waiting });
673
697
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
@@ -675,39 +699,16 @@ test("a busy pi holds the settle decision until it is waiting for input", async
675
699
  await waitFor(() => host.of("report").length >= 1);
676
700
  host.notify("assign", assignArgs());
677
701
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
678
- agent.onTurnEnd();
679
- agent.onSettled();
702
+ agent.onTurnError("provider exploded");
680
703
  await new Promise((resolve) => setTimeout(resolve, 80));
681
- 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");
682
705
 
683
706
  waiting = true;
684
- const reminded = await waitFor(() =>
685
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
686
- );
687
- assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
688
- });
689
-
690
- test("queued input keeps the session non-idle and holds the ladder off", async () => {
691
- let queued = false;
692
- const surface = fakeSurface({ waiting: () => !queued });
693
- const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
694
- agent.start();
695
- await waitFor(() => host.of("report").length >= 1);
696
- host.notify("assign", assignArgs());
697
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
698
-
699
- agent.onTurnStart();
700
- queued = true;
701
- agent.onTurnEnd();
702
- agent.onSettled();
703
- await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
704
- await new Promise((resolve) => setTimeout(resolve, 80));
705
-
706
- assert.equal(surface.calls.wakeUser.length, 1, "a queued continuation is not an idle episode");
707
- assert.deepEqual(surface.calls.exits, []);
707
+ const complete = await waitFor(() => completions(host)[0] ?? null);
708
+ assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
708
709
  });
709
710
 
710
- 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 () => {
711
712
  let backgroundWork = true;
712
713
  const surface = fakeSurface({ backgroundWork: () => backgroundWork });
713
714
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
@@ -717,122 +718,45 @@ test("a live background task holds the ladder off until its task is terminal", a
717
718
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
718
719
 
719
720
  agent.onTurnStart();
720
- agent.onTurnEnd();
721
- agent.onSettled();
721
+ agent.onTurnError("provider exploded");
722
722
  await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
723
723
  await new Promise((resolve) => setTimeout(resolve, 80));
724
- 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");
725
725
 
726
726
  backgroundWork = false;
727
727
  agent.onSettled();
728
- const reminded = await waitFor(() =>
729
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
730
- );
731
- 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" });
732
730
  });
733
731
 
734
- // The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
735
- // without a completion exit re-sends the assignment, and the idle that finds the
736
- // bound spent fails the task instead of settling it `done`. The bound is the
737
- // workspace's (`config.mjs`, two by default) and the count lives on the record
738
- // `onAssign` wrote for the task, which is why a task can be reminded twice and
739
- // failed on the third idle without the tool ever being called.
740
- test("only settled idles spend ladder rungs, ordinary turns reset them, and the bound fails the task", async () => {
741
- const { agent, host, surface } = await startAgent();
742
- 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 } });
743
739
  agent.start();
744
740
  await waitFor(() => host.of("report").length >= 1);
745
- // The assignment carries an image, so the reminder has an attachment path to
746
- // name and a part it must not hand over twice.
747
- const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
748
- const assignment = structuredClone(assignArgs());
749
- assignment.envelope.body = {
750
- text: "build it",
751
- image: { data_base64: bytes.toString("base64"), mime: "image/png", name: "shot.png" },
752
- };
753
- host.notify("assign", assignment);
741
+ host.notify("assign", assignArgs());
754
742
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
755
- assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
756
-
757
- // The first turn belongs to the session itself. Its settled idle spends rung
758
- // one. The turn that reminder wakes does not reset the episode, so its own
759
- // settled idle spends rung two rather than starting over.
760
- agent.onTurnStart();
761
- agent.onTurnEnd();
762
- agent.onSettled();
763
- const first = await waitFor(() =>
764
- surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
765
- );
766
- const firstReminder = first.at(-1);
767
- assert.match(firstReminder.text, /reminder 1 of 2/);
768
- assert.match(firstReminder.text, /build it/, "the reminder carries the task text");
769
- assert.match(
770
- firstReminder.text,
771
- /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
772
- "and the identity and source the injection named",
773
- );
774
- assert.match(firstReminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
775
- assert.deepEqual(firstReminder.parts, [], "and the image itself is not handed over twice");
776
- assert.doesNotMatch(firstReminder.text, /Read the incoming task/, "the role prose is already in the context");
777
-
778
- agent.onTurnStart();
779
- agent.onTurnEnd();
780
- agent.onSettled();
781
- const secondRung = await waitFor(() =>
782
- surface.calls.wakeUser.length === 3 ? surface.calls.wakeUser : null,
783
- );
784
- assert.match(secondRung.at(-1).text, /reminder 2 of 2/, "the reminder-woken turn keeps the episode's count");
785
- assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
743
+ agent.noteAssistantText("OK");
786
744
 
787
- // The next turn is the one the second reminder wakes, so it does not reset
788
- // the episode. Let that turn end without a settle, then start a turn of the
789
- // session's own: that ordinary turn begins a fresh episode.
790
- agent.onTurnStart();
791
- 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.
792
747
  agent.onTurnStart();
793
748
  agent.onTurnEnd();
794
749
  agent.onSettled();
795
- const reset = await waitFor(() =>
796
- surface.calls.wakeUser.length === 4 ? surface.calls.wakeUser : null,
797
- );
798
- assert.match(reset.at(-1).text, /reminder 1 of 2/, "an ordinary turn clears the episode's rung count");
799
-
750
+ await new Promise((resolve) => setTimeout(resolve, 100));
800
751
  agent.onTurnStart();
801
752
  agent.onTurnEnd();
802
753
  agent.onSettled();
803
- const secondFresh = await waitFor(() =>
804
- surface.calls.wakeUser.length === 5 ? surface.calls.wakeUser : null,
805
- );
806
- assert.match(secondFresh.at(-1).text, /reminder 2 of 2/);
754
+ await new Promise((resolve) => setTimeout(resolve, 100));
807
755
 
808
- // The third idle in the fresh episode spends the bound, so the open task
809
- // fails and the session leaves — the tool was never called.
810
- agent.onTurnStart();
811
- agent.onTurnEnd();
812
- agent.onSettled();
813
- const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
814
- assert.deepEqual(complete.data, {
815
- task_id: TASK_ID,
816
- outcome: "failed",
817
- head: "no completion after 2 idle reminders",
818
- });
819
- assert.deepEqual(surface.calls.exits, ["failed"], "the ladder's failure leaves the session");
820
- assert.equal(surface.calls.wakeUser.length, 5, "the bound is spent: no third reminder");
821
-
822
- // A turn that hands the outcome over is never reminded: the tool call is the
823
- // exit the ladder exists to get.
824
- const completed = await startAgent();
825
- completed.agent.start();
826
- await waitFor(() => completed.host.of("report").length >= 1);
827
- completed.host.notify("assign", assignArgs());
828
- await waitFor(() => (completed.surface.calls.wakeUser.length === 1 ? true : null));
829
- completed.agent.onTurnStart();
830
- completed.agent.onTurnEnd();
831
- await completed.agent.completeFromTool({ outcome: "done", text: "built it" });
832
- completed.agent.onSettled();
833
- await new Promise((resolve) => setTimeout(resolve, 60));
834
- assert.equal(completed.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
835
- 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");
836
760
  });
837
761
 
838
762
  test("an assigned task that never ran is not completed", async () => {
@@ -870,7 +794,7 @@ test("an explicit tool outcome wins and a second completion is refused", async (
870
794
  agent.onTurnEnd();
871
795
  agent.noteAssistantText("looks fine");
872
796
 
873
- const result = await agent.completeFromTool({ outcome: "failed", text: "changed my mind" });
797
+ const result = await agent.completeFromTool({ outcome: "failed", summary: "changed my mind" });
874
798
  assert.deepEqual(result, { taskId: TASK_ID, outcome: "failed", head: "changed my mind" });
875
799
  assert.deepEqual(await agent.completeFromTool({ outcome: "done" }), {
876
800
  taskId: TASK_ID,
@@ -884,9 +808,36 @@ test("an explicit tool outcome wins and a second completion is refused", async (
884
808
  assert.equal(completes.length, 1);
885
809
  assert.equal(completes[0].data.outcome, "failed");
886
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.
887
813
  assert.deepEqual(surface.calls.exits, ["failed"]);
888
814
  });
889
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
+
890
841
  // The completion body is what the tool call handed over. The sentence a turn
891
842
  // ends on is only the fallback for a call whose argument carries nothing, and a
892
843
  // call that carries a `text` reports it byte for byte — so the argument can
@@ -902,7 +853,7 @@ test("an explicit tool argument is the head over the last assistant text", async
902
853
  agent.noteAssistantText("Handed off to `a` with K=6.");
903
854
 
904
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";
905
- const result = await agent.completeFromTool({ outcome: "done", text: payload });
856
+ const result = await agent.completeFromTool({ outcome: "done", summary: payload });
906
857
  assert.equal(result.head, payload, "the argument is the head, byte for byte");
907
858
  const completes = host.of("report").filter((report) => report.kind === "complete");
908
859
  assert.deepEqual(completes[0].data, { task_id: TASK_ID, outcome: "done", head: payload });
@@ -916,11 +867,11 @@ test("an explicit tool argument is the head over the last assistant text", async
916
867
  assert.equal(host.of("report").filter((report) => report.kind === "complete").length, 1);
917
868
  });
918
869
 
919
- // An argument that carries nothing is no argument: the completion summary is
920
- // the last assistant text instead of an empty head. (An argument absent
921
- // 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.)
922
873
  test("an empty tool argument falls back to the last assistant text", async () => {
923
- for (const text of ["", " "]) {
874
+ for (const summary of ["", " "]) {
924
875
  const { agent, host, surface } = await startAgent();
925
876
  agent.start();
926
877
  await waitFor(() => host.of("report").length >= 1);
@@ -930,8 +881,8 @@ test("an empty tool argument falls back to the last assistant text", async () =>
930
881
  agent.onTurnEnd();
931
882
  agent.noteAssistantText("appended 10:e and handed the token back");
932
883
 
933
- const result = await agent.completeFromTool({ outcome: "done", text });
934
- 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)}`);
935
886
  const complete = host.of("report").find((report) => report.kind === "complete");
936
887
  assert.equal(complete.data.head, "appended 10:e and handed the token back");
937
888
  }
@@ -968,24 +919,6 @@ test("the exit waits for the client's acknowledgement of the completion report",
968
919
  assert.deepEqual(surface.calls.exits, ["done"]);
969
920
  });
970
921
 
971
- test("the completion report is the last frame the host receives before the exit", async () => {
972
- const { agent, host, surface } = await startAgent();
973
- agent.start();
974
- await waitFor(() => host.of("report").length >= 1);
975
- host.notify("assign", assignArgs());
976
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
977
- agent.onTurnStart();
978
- await waitFor(() =>
979
- host.of("report").find((report) => report.data?.observed?.agent === "running"),
980
- );
981
- agent.noteAssistantText("OK");
982
-
983
- await agent.complete(TASK_ID, "done", "OK");
984
- const reports = host.of("report").filter((report) => report.kind !== "ready");
985
- assert.equal(reports.at(-1).kind, "complete", "a running beat is not followed by a settled observation");
986
- assert.deepEqual(surface.calls.exits, ["done"]);
987
- });
988
-
989
922
  test("a reconnect beat re-derives the phase from the surface", async () => {
990
923
  let waiting = true;
991
924
  const surface = fakeSurface({ waiting: () => waiting });
@@ -1014,25 +947,32 @@ test("a reconnect beat re-derives the phase from the surface", async () => {
1014
947
  );
1015
948
  });
1016
949
 
1017
- 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 () => {
1018
955
  const { agent, host, surface, dir } = await startAgent();
1019
956
  agent.start();
1020
957
  await waitFor(() => host.of("report").length >= 1);
1021
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";
1022
960
  const args = assignArgs();
1023
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];
1024
964
  host.notify("assign", args);
1025
965
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1026
966
 
1027
967
  const injected = surface.calls.wakeUser[0];
968
+ assert.equal(injected.text, args.text, "the frame's text, byte for byte");
1028
969
  assert.equal(injected.parts.length, 1);
1029
970
  assert.equal(injected.parts[0].mime, "image/png");
1030
971
  assert.equal(injected.parts[0].data, bytes.toString("base64"));
1031
- const path = join(dir, ".onlyne", "tmp", "attachments", `${TASK_ID}-3f2a1c4e-5b6d-4e7f-8a90-1b2c3d4e5f60-shot.png`);
1032
- assert.ok(existsSync(path), `expected ${path}`);
1033
- assert.deepEqual([...readFileSync(path)], [...bytes]);
1034
- assert.ok(injected.text.includes(path), "the injected message names the file it wrote");
1035
- 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]);
1036
976
  });
1037
977
 
1038
978
  test("a probe is answered with a heartbeat for the live task", async () => {
@@ -1050,13 +990,119 @@ test("a probe is answered with a heartbeat for the live task", async () => {
1050
990
  assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
1051
991
  });
1052
992
 
1053
- 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 () => {
1054
1099
  const { agent, host, surface } = await startAgent();
1055
1100
  agent.start();
1056
1101
  await waitFor(() => host.of("report").length >= 1);
1057
- 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}` });
1058
1104
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
1059
- 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");
1060
1106
  });
1061
1107
 
1062
1108
  test("recycle settles the task, detaches and asks pi to exit", async () => {
@@ -1077,6 +1123,87 @@ test("recycle settles the task, detaches and asks pi to exit", async () => {
1077
1123
  assert.equal(host.connections, connections, "a recycled agent must not reconnect");
1078
1124
  });
1079
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
+
1080
1207
  test("a completion reported while the socket is down is flushed after reconnect", async () => {
1081
1208
  const { agent, host, surface } = await startAgent();
1082
1209
  agent.start();
@@ -1166,156 +1293,121 @@ test("a host that never answers the hello is dropped and retried", async () => {
1166
1293
  assert.match(agent.status().lastError, /hello timed out/);
1167
1294
  });
1168
1295
 
1169
- // ---------------------------------------------------------------- relay guard
1296
+ // ---------------------------------------------------- the completion's shape
1170
1297
 
1171
- // The relay guard is what stops a session from reporting a terminal outcome
1172
- // while it still owes a downstream handoff. Its evidence is delivery only:
1173
- // 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.
1174
1302
 
1175
- /** 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. */
1176
1304
  const COMPLETE_VECTOR =
1177
1305
  '{"kind":"complete","data":{"task_id":"11111111-1111-4111-8111-111111111111","outcome":"done","head":"handed the token over"}}';
1178
1306
 
1179
- /** Every `report.complete` the fake host received, in arrival order. */
1180
- function completions(host) {
1181
- return host.of("report").filter((report) => report.kind === "complete");
1182
- }
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);
1183
1311
 
1184
- /** The host's own assign frame, handed over by a role that is not this one. */
1185
- function assignmentFrom(role) {
1186
- const args = assignArgs();
1187
- return { ...args, envelope: { ...args.envelope, from: { role: { role } } } };
1188
- }
1312
+ await agent.completeFromTool({ outcome: "done", summary: "handed the token over" });
1189
1313
 
1190
- test("with no relay policy the completion frame is unchanged, force and reason included", async () => {
1191
- const inputs = [
1192
- { outcome: "done", text: "handed the token over" },
1193
- { outcome: "done", text: "handed the token over", force: true, reason: "no policy is in force" },
1194
- ];
1195
- for (const input of inputs) {
1196
- const { agent, host } = await startAgent();
1197
- agent.start();
1198
- await waitFor(() => host.of("report").length >= 1);
1199
- await agent.completeFromTool(input);
1200
- assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR, JSON.stringify(input));
1201
- }
1314
+ assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR);
1202
1315
  });
1203
1316
 
1204
- test("a relay list refuses a completion until every named role has a handoff", async () => {
1205
- 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();
1206
1319
  agent.start();
1207
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"];
1208
1323
 
1209
- await assert.rejects(
1210
- () => agent.completeFromTool({ outcome: "done", text: "wrote the notes" }),
1211
- (error) => {
1212
- assert.match(
1213
- error.message,
1214
- /^onlyne: relay guard: missing handoff to: writer \(this session delivered to: none\)/,
1215
- );
1216
- assert.match(error.message, /force:true and a non-empty reason/);
1217
- return true;
1218
- },
1219
- );
1220
- assert.deepEqual(completions(host), []);
1221
- assert.deepEqual(surface.calls.exits, []);
1324
+ await agent.completeFromTool({ outcome: "done", summary: "appended 10:e", details, files });
1222
1325
 
1223
- await agent.sendFromTool({ to: "writer", text: "here is the outline" });
1224
- const result = await agent.completeFromTool({ outcome: "done", text: "wrote the notes" });
1225
- assert.deepEqual(result, { taskId: TASK_ID, outcome: "done", head: "wrote the notes" });
1226
- assert.equal(completions(host).length, 1);
1227
- 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);
1228
1329
  });
1229
1330
 
1230
- test("a relay count wants distinct downstream roles and ignores echoes", async () => {
1231
- const { agent, host } = await startAgent({ relay: { required: [], count: 2 } });
1232
- agent.start();
1233
- await waitFor(() => host.of("report").length >= 1);
1234
- host.notify("assign", assignmentFrom("supervisor"));
1235
- 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);
1236
1336
 
1237
- await agent.sendFromTool({ to: "supervisor", text: "status" }); // back upstream
1238
- await agent.sendFromTool({ to: "planner", text: "note to self" }); // this role
1239
- await agent.sendFromTool({ to: "builder", text: "build it" });
1240
- await assert.rejects(
1241
- () => agent.completeFromTool({ outcome: "done" }),
1242
- /missing handoff: 1 of 2 required distinct downstream roles/,
1243
- );
1337
+ await agent.completeFromTool({ outcome, summary: "the word is the outcome" });
1244
1338
 
1245
- await agent.sendFromTool({ to: "writer", text: "document it" });
1246
- const result = await agent.completeFromTool({ outcome: "done" });
1247
- assert.equal(result.outcome, "done");
1248
- assert.equal(completions(host).length, 1);
1339
+ assert.equal(completions(host)[0].data.outcome, outcome, `${outcome} must not be rewritten`);
1340
+ }
1249
1341
  });
1250
1342
 
1251
- test("force needs a reason, and the reason it takes is stamped into the head", async () => {
1252
- const { agent, host } = await startAgent({ relay: { required: ["writer"] } });
1253
- agent.start();
1254
- await waitFor(() => host.of("report").length >= 1);
1343
+ // ---------------------------------------------------------- the host's nudge
1255
1344
 
1256
- await assert.rejects(
1257
- () =>
1258
- agent.completeFromTool({
1259
- outcome: "done",
1260
- text: "outline is done",
1261
- force: true,
1262
- reason: " ",
1263
- }),
1264
- /missing handoff to: writer/,
1265
- );
1266
- 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.
1267
1350
 
1268
- const result = await agent.completeFromTool({
1269
- outcome: "done",
1270
- text: "outline is done",
1271
- force: true,
1272
- reason: "writer is offline for the day",
1273
- });
1274
- assert.equal(result.head, "relay-guard-forced: writer is offline for the day | outline is done");
1275
- assert.equal(completions(host)[0].data.head, result.head);
1276
- });
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.";
1277
1354
 
1278
- test("a refusal detaches nothing, and the same call lands once the handoff is out", async () => {
1279
- 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();
1280
1357
  agent.start();
1281
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;
1282
1363
 
1283
- await assert.rejects(() => agent.completeFromTool({ outcome: "done", text: "half done" }), /relay guard/);
1284
- assert.equal(agent.status().connected, true);
1285
- assert.equal(agent.status().stats.completions, 0);
1286
- assert.deepEqual(surface.calls.exits, []);
1287
- assert.deepEqual(host.of("detach"), []);
1288
- 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));
1289
1366
 
1290
- await agent.sendFromTool({ to: "writer", text: "the outline so far" });
1291
- await agent.completeFromTool({ outcome: "done", text: "half done" });
1292
- assert.equal(agent.status().stats.completions, 1);
1293
- 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), []);
1294
1379
  });
1295
1380
 
1296
- test("a send the client refused is not a handoff", async () => {
1297
- 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();
1298
1383
  agent.start();
1299
1384
  await waitFor(() => host.of("report").length >= 1);
1300
1385
 
1301
- await assert.rejects(() => agent.sendFromTool({ to: "writer", text: "outline" }), /no_route/);
1302
- 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");
1303
1395
  });
1304
1396
 
1305
- test("the handoff ledger belongs to the session, not to one task", async () => {
1306
- 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();
1307
1399
  agent.start();
1308
1400
  await waitFor(() => host.of("report").length >= 1);
1309
1401
 
1310
- // A note sent before the assignment arrived is still this session reaching
1311
- // the role the policy names.
1312
- await agent.sendFromTool({ to: "writer", text: "preamble" });
1313
- host.notify("assign", assignArgs());
1314
- 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));
1315
1404
 
1316
- const result = await agent.completeFromTool({ outcome: "done", text: "wrote the notes" });
1317
- assert.equal(result.outcome, "done");
1318
- 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
+ );
1319
1411
  });
1320
1412
 
1321
1413
  // ------------------------------------------------------------ the handoff tool
@@ -1442,27 +1534,6 @@ test("a repeating fault folds into one panel line with a count", () => {
1442
1534
  assert.equal(surface.calls.widget.length, 3, "every event redraws the same panel");
1443
1535
  });
1444
1536
 
1445
- test("the panel stays inside its line and width budget", () => {
1446
- const surface = fakeSurface({ widget: true });
1447
- const agent = new OnlyneAgent({
1448
- socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
1449
- cwd: tmpdir(),
1450
- role: "planner",
1451
- sessionId: SESSION_ID,
1452
- taskId: TASK_ID,
1453
- surface,
1454
- log: () => {},
1455
- });
1456
- for (let index = 0; index < 40; index += 1) {
1457
- agent.notice("in", `task ${index} from gateway:wechat:room ${"长".repeat(400)}`);
1458
- }
1459
- const [render] = surface.calls.widget.slice(-1);
1460
- assert.ok(render.length <= MAX_LINES, `panel grew to ${render.length} lines`);
1461
- for (const line of render) {
1462
- assert.ok(Array.from(line).length <= MAX_WIDTH, `line overruns its width: ${line}`);
1463
- }
1464
- });
1465
-
1466
1537
  test("a host without the widget keeps the footer line and the stderr line", async () => {
1467
1538
  const surface = fakeSurface();
1468
1539
  const { agent, host, logs } = await startAgent({ surface });