pi-onlyne 1.1.2 → 1.2.1

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.
@@ -3,7 +3,7 @@
3
3
  // wire vector, so the input side is byte-identical to what the client sends.
4
4
 
5
5
  import assert from "node:assert/strict";
6
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { createServer } from "node:net";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
@@ -12,6 +12,7 @@ import { afterEach, test } from "node:test";
12
12
 
13
13
  import { OnlyneAgent } from "./agent.mjs";
14
14
  import { MAX_LINES, MAX_WIDTH } from "./activity.mjs";
15
+ import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
15
16
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
16
17
  import { SEQ_BASE, readyReport } from "./protocol.mjs";
17
18
 
@@ -22,6 +23,15 @@ const ASSIGN_FRAME = existsSync(VECTOR_DIR)
22
23
  ? JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}adapter_host_assign.json`, "utf8")).frame)
23
24
  : null;
24
25
  const TASK_ID = "11111111-1111-4111-8111-111111111111";
26
+ /** A task the host hands this session, distinct from the id it was spawned with. */
27
+ const ASSIGNED_TASK_ID = "33333333-3333-4333-8333-333333333333";
28
+ /**
29
+ * The answer a real host gives one `handoff`: the child it minted, and the hop
30
+ * that child sits at. The hop is a figure no client can derive — it comes off
31
+ * the parent's causality — so a test asserting it reads the host's own number.
32
+ */
33
+ const CHILD_TASK_ID = "22222222-2222-4222-8222-222222222222";
34
+ const HANDOFF_ANSWER = { task_id: CHILD_TASK_ID, hop: 3, queued: true, op_id: `o-${CHILD_TASK_ID}` };
25
35
  const SESSION_ID = "8b1c";
26
36
  const PANE_KEY = "45e603f7-0772-48aa-bcf6-832272747713:b6d067b6-9255-4f5c-a13f-24f194ea0560";
27
37
  const PANE_TAB = "45e603f7-0772-48aa-bcf6-832272747713";
@@ -42,6 +52,34 @@ function heartbeats(host) {
42
52
  .map((args) => args.data.observed);
43
53
  }
44
54
 
55
+ /**
56
+ * The exact keys of a plugin observation outside a pane. `agent`, `resource` and
57
+ * the reconcile defaults are what the plugin states; `delivery` and `recovery`
58
+ * ride as placeholders the client overwrites with its own records, and no
59
+ * outcome or public view travels in a tuple at all.
60
+ */
61
+ const OBSERVED_KEYS = [
62
+ "version",
63
+ "generation_live",
64
+ "isolate_after",
65
+ "terminate_after",
66
+ "mismatch_count",
67
+ "agent",
68
+ "delivery",
69
+ "resource",
70
+ "recovery",
71
+ ];
72
+
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
+
45
83
  /** Poll until `predicate` holds, so a test never races the event loop. */
46
84
  async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
47
85
  const deadline = Date.now() + timeoutMs;
@@ -63,9 +101,12 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
63
101
  * the client has acknowledged the outcome. `failSend` refuses every `send` the
64
102
  * way a client with no route to the target would. The ready and heartbeat
65
103
  * reports are never held: the handshake waits on them.
104
+ * `handoffError` refuses every `handoff` with that error body, the way a client
105
+ * that cannot find the task the session names would; with none, the fake answers
106
+ * the child the proto documents.
66
107
  */
67
108
  class FakeHost {
68
- constructor({ coalesceAssign = null, holdCompletion = false, failSend = false } = {}) {
109
+ constructor({ coalesceAssign = null, holdCompletion = false, failSend = false, handoffError = null } = {}) {
69
110
  this.server = createServer((socket) => this.onConnection(socket));
70
111
  this.frames = [];
71
112
  this.sockets = new Set();
@@ -73,6 +114,7 @@ class FakeHost {
73
114
  this.coalesceAssign = coalesceAssign;
74
115
  this.holdCompletion = holdCompletion;
75
116
  this.failSend = failSend;
117
+ this.handoffError = handoffError;
76
118
  this.held = [];
77
119
  }
78
120
 
@@ -126,6 +168,11 @@ class FakeHost {
126
168
  }));
127
169
  return;
128
170
  }
171
+ if (frame.op === "handoff") {
172
+ body = this.handoffError
173
+ ? { ok: false, error: this.handoffError }
174
+ : { ok: true, data: HANDOFF_ANSWER };
175
+ }
129
176
  const reply = encodeFrame({ reply_to: frame.id, ...body });
130
177
  const push = frame.op === "hello" && this.coalesceAssign
131
178
  ? encodeFrame({ op: "assign", args: this.coalesceAssign })
@@ -159,6 +206,7 @@ class FakeHost {
159
206
  /** The effect surface the agent drives, recorded for assertions. */
160
207
  function fakeSurface(options = {}) {
161
208
  const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [], widget: [] };
209
+ const flag = (value, fallback) => (value === undefined ? fallback : typeof value === "function" ? value() : value);
162
210
  return {
163
211
  calls,
164
212
  available: {
@@ -190,6 +238,9 @@ function fakeSurface(options = {}) {
190
238
  widget: (lines) => calls.widget.push(lines),
191
239
  welcome: () => {},
192
240
  isIdle: () => (options.idle === undefined ? true : options.idle()),
241
+ async waitingForInput() {
242
+ return Boolean(flag(options.waiting, true) && !flag(options.backgroundWork, false));
243
+ },
193
244
  exit: (reason) => calls.exits.push(reason),
194
245
  };
195
246
  }
@@ -202,11 +253,12 @@ async function startAgent({
202
253
  coalesceAssign = null,
203
254
  holdCompletion = false,
204
255
  failSend = false,
256
+ handoffError = null,
205
257
  relay = null,
206
258
  } = {}) {
207
259
  const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
208
260
  const socketPath = join(dir, "s");
209
- const host = new FakeHost({ coalesceAssign, holdCompletion, failSend });
261
+ const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError });
210
262
  await host.listen(socketPath);
211
263
  const logs = [];
212
264
  const agent = new OnlyneAgent({
@@ -297,8 +349,9 @@ test("every heartbeat names the Orca pane this process was spawned in", async ()
297
349
  orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF, handle: "term_1" },
298
350
  });
299
351
  // The binding rides beside the state dimensions instead of replacing them.
300
- assert.equal(observed.agent, "running");
301
- assert.equal(observed.public, "working");
352
+ assert.equal(observed.agent, "idle", "the default fake surface is waiting for input");
353
+ assert.equal(observed.resource, "attached");
354
+ assert.deepEqual(Object.keys(observed), [...OBSERVED_KEYS, "host"]);
302
355
  assert.equal(observed.version.generation, 1);
303
356
  });
304
357
  });
@@ -348,7 +401,7 @@ test("a pi outside an Orca pane reports no host at all", async () => {
348
401
 
349
402
  const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
350
403
  assert.equal("host" in observed, false, "no pane, no binding");
351
- assert.equal(observed.agent, "running", "the tuple is still a full observation");
404
+ assert.equal(observed.agent, "idle", "the tuple is still a full observation");
352
405
  });
353
406
  });
354
407
 
@@ -535,7 +588,7 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
535
588
  assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
536
589
  });
537
590
 
538
- test("turn hooks report running then idle, and a settle completes done with the head", async () => {
591
+ test("a waiting turn end reports idle, and the settle after it re-sends the assignment", async () => {
539
592
  const { agent, host, surface } = await startAgent();
540
593
  agent.start();
541
594
  await waitFor(() => host.of("report").length >= 1);
@@ -545,31 +598,57 @@ test("turn hooks report running then idle, and a settle completes done with the
545
598
  agent.onTurnStart();
546
599
  const running = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
547
600
  assert.equal(running.kind, "heartbeat");
548
- assert.equal(running.data.observed.public, "working");
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");
549
603
  assert.equal(running.data.observed.version.generation, 1);
550
604
 
551
605
  agent.onTurnEnd();
552
606
  const idle = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
553
- assert.equal(idle.data.observed.public, "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");
554
609
 
555
610
  agent.noteAssistantText("OK");
556
611
  agent.onSettled();
557
- const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
558
- assert.deepEqual(complete, { kind: "complete", data: { task_id: TASK_ID, outcome: "done", head: "OK" } });
559
- assert.deepEqual(agent.status().tasks, []);
560
- // The completion ends the session: the process that ran it is asked to leave.
561
- assert.deepEqual(surface.calls.exits, ["done"]);
562
- // Heartbeats stop with the last task, so a settled session stops writing.
563
- const reports = host.of("report").length;
564
- await new Promise((resolve) => setTimeout(resolve, 120));
565
- assert.equal(host.of("report").length, reports);
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");
621
+ });
622
+
623
+ test("a busy turn end reports no idle beat and does not settle the ladder", async () => {
624
+ const surface = fakeSurface({ waiting: false });
625
+ const { agent, host } = await startAgent({ surface });
626
+ agent.start();
627
+ await waitFor(() => host.of("report").length >= 1);
628
+ host.notify("assign", assignArgs());
629
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
630
+
631
+ agent.onTurnStart();
632
+ await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
633
+ agent.onTurnEnd();
634
+ agent.onSettled();
635
+ await waitFor(() => heartbeats(host).length >= 3);
636
+
637
+ assert.equal(
638
+ heartbeats(host).some((observed) => observed.agent === "idle"),
639
+ false,
640
+ "the turn-end beat follows the busy surface rather than assuming the hook means idle",
641
+ );
642
+ assert.equal(surface.calls.wakeUser.length, 1, "the ladder waits while the turn is still running");
643
+ assert.deepEqual(surface.calls.exits, []);
566
644
  });
567
645
 
568
646
  // The live case found this ordering too: pi's turn-end hook fires in the same
569
647
  // millisecond as the settle that reports the completion, so the turn-end
570
- // heartbeat lands after the completion report. `observed` is a whole snapshot,
571
- // and one carrying `delivery: none`/`outcome: pending` puts the session back to
572
- // `idle` after the host has already recorded `exited`.
648
+ // heartbeat lands after the completion report. The beat no longer carries a
649
+ // delivery or recovery the host would believe — the client composes those from
650
+ // its own records — but a snapshot of an agent dimension nobody is watching any
651
+ // more is still a frame the protocol does not need, so the plugin stays quiet.
573
652
  test("a turn-end heartbeat after the completion never leaves the plugin", async () => {
574
653
  const { agent, host, surface } = await startAgent();
575
654
  agent.start();
@@ -588,23 +667,172 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
588
667
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
589
668
  });
590
669
 
591
- test("a busy pi holds the completion until it is idle", async () => {
592
- let idle = false;
593
- const surface = fakeSurface({ idle: () => idle });
670
+ test("a busy pi holds the settle decision until it is waiting for input", async () => {
671
+ let waiting = false;
672
+ const surface = fakeSurface({ waiting: () => waiting });
673
+ const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
674
+ agent.start();
675
+ await waitFor(() => host.of("report").length >= 1);
676
+ host.notify("assign", assignArgs());
677
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
678
+ agent.onTurnEnd();
679
+ agent.onSettled();
680
+ await new Promise((resolve) => setTimeout(resolve, 80));
681
+ assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
682
+
683
+ 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, []);
708
+ });
709
+
710
+ test("a live background task holds the ladder off until its task is terminal", async () => {
711
+ let backgroundWork = true;
712
+ const surface = fakeSurface({ backgroundWork: () => backgroundWork });
594
713
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
595
714
  agent.start();
596
715
  await waitFor(() => host.of("report").length >= 1);
597
716
  host.notify("assign", assignArgs());
598
717
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
718
+
719
+ agent.onTurnStart();
599
720
  agent.onTurnEnd();
600
- agent.noteAssistantText("still working");
601
721
  agent.onSettled();
722
+ await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
602
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");
725
+
726
+ backgroundWork = false;
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");
732
+ });
733
+
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");
743
+ agent.start();
744
+ 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);
754
+ 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");
603
785
  assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
604
786
 
605
- idle = true;
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();
792
+ agent.onTurnStart();
793
+ agent.onTurnEnd();
794
+ 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
+
800
+ agent.onTurnStart();
801
+ agent.onTurnEnd();
802
+ 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/);
807
+
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();
606
813
  const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
607
- assert.equal(complete.data.head, "still working");
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"]);
608
836
  });
609
837
 
610
838
  test("an assigned task that never ran is not completed", async () => {
@@ -660,9 +888,9 @@ test("an explicit tool outcome wins and a second completion is refused", async (
660
888
  });
661
889
 
662
890
  // The completion body is what the tool call handed over. The sentence a turn
663
- // ends on is only the fallback for a task whose argument carries nothing, and
664
- // the auto rule reports exactly that fallback field — so an explicit argument
665
- // can never be displaced, in either call path.
891
+ // ends on is only the fallback for a call whose argument carries nothing, and a
892
+ // call that carries a `text` reports it byte for byte — so the argument can
893
+ // never be displaced, before or after the call.
666
894
  test("an explicit tool argument is the head over the last assistant text", async () => {
667
895
  const { agent, host, surface } = await startAgent();
668
896
  agent.start();
@@ -740,12 +968,7 @@ test("the exit waits for the client's acknowledgement of the completion report",
740
968
  assert.deepEqual(surface.calls.exits, ["done"]);
741
969
  });
742
970
 
743
- // A session that only ever reported `running` and then completed leaves the
744
- // ledger saying `running` forever: the client settles the row from the
745
- // completion report and hears nothing more. One final observation, sent after
746
- // the completion is acknowledged and before the process leaves, is what makes
747
- // an exited session read idle.
748
- test("a completion after a running beat publishes the settled observation before the exit", async () => {
971
+ test("the completion report is the last frame the host receives before the exit", async () => {
749
972
  const { agent, host, surface } = await startAgent();
750
973
  agent.start();
751
974
  await waitFor(() => host.of("report").length >= 1);
@@ -759,23 +982,14 @@ test("a completion after a running beat publishes the settled observation before
759
982
 
760
983
  await agent.complete(TASK_ID, "done", "OK");
761
984
  const reports = host.of("report").filter((report) => report.kind !== "ready");
762
- const kinds = reports.map((report) => report.kind);
763
- assert.deepEqual(
764
- kinds.slice(-2),
765
- ["complete", "heartbeat"],
766
- "the settled observation follows the completion: " + JSON.stringify(kinds),
767
- );
768
- const settled = reports.at(-1).data.observed;
769
- assert.equal(settled.agent, "idle");
770
- assert.equal(settled.outcome, "done");
771
- assert.equal(settled.delivery, "accepted");
772
- assert.equal(settled.recovery, "draining");
773
- assert.equal(settled.public, "exited");
985
+ assert.equal(reports.at(-1).kind, "complete", "a running beat is not followed by a settled observation");
774
986
  assert.deepEqual(surface.calls.exits, ["done"]);
775
987
  });
776
988
 
777
- test("a session whose last beat was idle publishes no extra observation", async () => {
778
- const { agent, host, surface } = await startAgent();
989
+ test("a reconnect beat re-derives the phase from the surface", async () => {
990
+ let waiting = true;
991
+ const surface = fakeSurface({ waiting: () => waiting });
992
+ const { agent, host } = await startAgent({ surface });
779
993
  agent.start();
780
994
  await waitFor(() => host.of("report").length >= 1);
781
995
  host.notify("assign", assignArgs());
@@ -783,45 +997,21 @@ test("a session whose last beat was idle publishes no extra observation", async
783
997
  agent.onTurnStart();
784
998
  agent.onTurnEnd();
785
999
  await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
786
- agent.noteAssistantText("OK");
787
- const before = host.of("report").filter((report) => report.kind === "heartbeat").length;
788
-
789
- await agent.complete(TASK_ID, "done", "OK");
790
- assert.equal(
791
- host.of("report").filter((report) => report.kind === "heartbeat").length,
792
- before,
793
- "an already-idle session needs no second idle observation",
794
- );
795
- assert.deepEqual(surface.calls.exits, ["done"]);
796
- });
797
-
798
- test("a queued completion and its settled observation flush in order after reconnect", async () => {
799
- const { agent, host, surface } = await startAgent();
800
- agent.start();
801
- await waitFor(() => host.of("report").length >= 1);
802
- host.notify("assign", assignArgs());
803
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
804
- agent.onTurnStart();
805
- agent.onTurnEnd();
806
- agent.noteAssistantText("done offline");
807
1000
 
1001
+ const beforeReconnect = host.frames.length;
1002
+ waiting = false;
808
1003
  for (const socket of [...host.sockets]) socket.destroy();
809
1004
  await waitFor(() => (agent.status().connected === false ? true : null));
810
- await agent.completeFromTool({ outcome: "done" });
811
- assert.deepEqual(surface.calls.exits, [], "nothing exits before the report lands");
812
-
813
- await waitFor(() => (host.connections >= 2 ? true : null));
814
- await waitFor(() => (surface.calls.exits.length === 1 ? true : null));
815
- const reports = host.of("report").filter((report) => report.kind !== "ready");
816
- assert.deepEqual(
817
- reports.slice(-2).map((report) => [report.kind, report.data.observed?.agent ?? null]),
818
- [
819
- ["complete", null],
820
- ["heartbeat", "idle"],
821
- ],
822
- "the settled observation rides after the flushed completion: " + JSON.stringify(reports),
1005
+ const beat = await waitFor(() => host.frames
1006
+ .slice(beforeReconnect)
1007
+ .map((entry) => entry.frame)
1008
+ .find((frame) => frame.op === "report" && frame.args?.kind === "heartbeat"));
1009
+ assert.equal(beat.args.data.observed.agent, "running", "the reconnect asks the surface again");
1010
+ assert.equal(
1011
+ heartbeats(host).at(-1).agent,
1012
+ "running",
1013
+ "the old idle beat does not survive the reconnect",
823
1014
  );
824
- assert.equal(reports.at(-1).data.observed.outcome, "done");
825
1015
  });
826
1016
 
827
1017
  test("an inbound image is written under the workspace and handed to pi", async () => {
@@ -856,7 +1046,7 @@ test("a probe is answered with a heartbeat for the live task", async () => {
856
1046
  const heartbeat = await waitFor(() =>
857
1047
  host.of("report").find((report) => report.kind === "heartbeat" && report.data.task_id === TASK_ID),
858
1048
  );
859
- assert.equal(heartbeat.data.observed.agent, "running");
1049
+ assert.equal(heartbeat.data.observed.agent, "idle", "the probe re-derives a waiting session");
860
1050
  assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
861
1051
  });
862
1052
 
@@ -1128,6 +1318,90 @@ test("the handoff ledger belongs to the session, not to one task", async () => {
1128
1318
  assert.equal(completions(host).length, 1);
1129
1319
  });
1130
1320
 
1321
+ // ------------------------------------------------------------ the handoff tool
1322
+
1323
+ test("a handoff names the task this session holds and hands the answer back", async () => {
1324
+ const { agent, host } = await startAgent();
1325
+ agent.start();
1326
+ await waitFor(() => host.of("report").length >= 1);
1327
+
1328
+ // The task the host handed this session is the family's parent, so that id is
1329
+ // the one the request names: the spawn id stays behind it.
1330
+ const args = structuredClone(assignArgs());
1331
+ args.task_id = ASSIGNED_TASK_ID;
1332
+ args.envelope.causality.task = ASSIGNED_TASK_ID;
1333
+ host.notify("assign", args);
1334
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
1335
+
1336
+ const result = await agent.handoffFromTool({ to: "builder", text: "take it from here" });
1337
+ assert.deepEqual(host.of("handoff"), [
1338
+ { task_id: ASSIGNED_TASK_ID, to: "builder", text: "take it from here", image: null },
1339
+ ]);
1340
+ assert.deepEqual(result, {
1341
+ taskId: CHILD_TASK_ID,
1342
+ hop: HANDOFF_ANSWER.hop,
1343
+ queued: true,
1344
+ opId: `o-${CHILD_TASK_ID}`,
1345
+ to: "builder",
1346
+ });
1347
+ });
1348
+
1349
+ test("a handoff reads the image the model named and types it from its path", async () => {
1350
+ const { agent, host, dir } = await startAgent();
1351
+ agent.start();
1352
+ await waitFor(() => host.of("report").length >= 1);
1353
+
1354
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
1355
+ const shot = join(dir, "shot.png");
1356
+ writeFileSync(shot, bytes);
1357
+ await agent.handoffFromTool({ to: "builder", text: "the shot", imagePath: shot });
1358
+ assert.deepEqual(host.of("handoff")[0].image, {
1359
+ data_base64: bytes.toString("base64"),
1360
+ mime: "image/png",
1361
+ name: "shot.png",
1362
+ });
1363
+
1364
+ // A path this plugin cannot type is refused here, so no half-typed handoff
1365
+ // reaches the wire.
1366
+ const bad = join(dir, "shot.bmp");
1367
+ writeFileSync(bad, bytes);
1368
+ await assert.rejects(
1369
+ () => agent.handoffFromTool({ to: "builder", text: "the shot", imagePath: bad }),
1370
+ /unsupported image type/,
1371
+ );
1372
+ assert.equal(host.of("handoff").length, 1);
1373
+ });
1374
+
1375
+ test("a send types its image from the path too", async () => {
1376
+ const { agent, host, dir } = await startAgent();
1377
+ agent.start();
1378
+ await waitFor(() => host.of("report").length >= 1);
1379
+
1380
+ const path = join(dir, "diagram.webp");
1381
+ writeFileSync(path, Buffer.from([1, 2, 3]));
1382
+ await agent.sendFromTool({ to: "writer", text: "the diagram", imagePath: path });
1383
+
1384
+ assert.deepEqual(host.of("send")[0].body.image, {
1385
+ data_base64: "AQID",
1386
+ mime: "image/webp",
1387
+ name: "diagram.webp",
1388
+ });
1389
+ });
1390
+
1391
+ test("a handoff the client refuses throws the client's own reason", async () => {
1392
+ const { agent, host } = await startAgent({
1393
+ handoffError: { code: "no_family", message: "no task family for that id" },
1394
+ });
1395
+ agent.start();
1396
+ await waitFor(() => host.of("report").length >= 1);
1397
+
1398
+ await assert.rejects(
1399
+ () => agent.handoffFromTool({ to: "builder", text: "take it" }),
1400
+ /no_family: no task family for that id/,
1401
+ );
1402
+ assert.equal(host.of("handoff").length, 1, "a refusal is not retried");
1403
+ });
1404
+
1131
1405
  // ---------------------------------------------------------------- the panel
1132
1406
 
1133
1407
  test("a pi with the widget gets the panel and keeps a quiet scrollback", async () => {