pi-onlyne 0.9.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1079 @@
1
+ // Agent state-machine tests against a fake host on a real unix socket: the same
2
+ // framing, the same frames, no Rust process. The assign frame is the host's own
3
+ // wire vector, so the input side is byte-identical to what the client sends.
4
+
5
+ import assert from "node:assert/strict";
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
7
+ import { createServer } from "node:net";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { afterEach, test } from "node:test";
12
+
13
+ import { OnlyneAgent } from "./agent.mjs";
14
+ import { createFrameDecoder, encodeFrame } from "./frame.mjs";
15
+ import { SEQ_BASE, readyReport } from "./protocol.mjs";
16
+
17
+ const VECTOR_DIR = fileURLToPath(new URL("../../../crates/onlyne-proto/tests/wire_vectors/", import.meta.url));
18
+ const ASSIGN_FRAME = existsSync(VECTOR_DIR)
19
+ ? JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}adapter_host_assign.json`, "utf8")).frame)
20
+ : null;
21
+ const TASK_ID = "11111111-1111-4111-8111-111111111111";
22
+ const SESSION_ID = "8b1c";
23
+ const PANE_KEY = "45e603f7-0772-48aa-bcf6-832272747713:b6d067b6-9255-4f5c-a13f-24f194ea0560";
24
+ const PANE_TAB = "45e603f7-0772-48aa-bcf6-832272747713";
25
+ const PANE_LEAF = "b6d067b6-9255-4f5c-a13f-24f194ea0560";
26
+ /** Exactly the ORCA_* environment an Orca pane exports (measured on 1.4.198). */
27
+ const PANE_ENV = {
28
+ ORCA_PANE_KEY: PANE_KEY,
29
+ ORCA_TAB_ID: PANE_TAB,
30
+ ORCA_LEAF_ID: PANE_LEAF,
31
+ ORCA_TERMINAL_HANDLE: "term_1",
32
+ };
33
+
34
+ /** The `observed` bodies of every heartbeat the fake host received. */
35
+ function heartbeats(host) {
36
+ return host
37
+ .of("report")
38
+ .filter((args) => args.kind === "heartbeat")
39
+ .map((args) => args.data.observed);
40
+ }
41
+
42
+ /** Poll until `predicate` holds, so a test never races the event loop. */
43
+ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
44
+ const deadline = Date.now() + timeoutMs;
45
+ for (;;) {
46
+ const value = predicate();
47
+ if (value) return value;
48
+ if (Date.now() > deadline) throw new Error("timed out waiting for the expected state");
49
+ await new Promise((resolve) => setTimeout(resolve, stepMs));
50
+ }
51
+ }
52
+
53
+ /**
54
+ * A minimal host: records every frame it receives, answers every request, and
55
+ * can push notifications at any time. `coalesceAssign` puts an assignment in
56
+ * the same chunk as the hello reply, the way a client handing over a staged
57
+ * session does when both frames are ready at once. `holdCompletion` keeps
58
+ * `report` requests carrying a `complete` unanswered until `releaseReports`,
59
+ * which is the window a test needs to prove what the plugin does not do before
60
+ * the client has acknowledged the outcome. `failSend` refuses every `send` the
61
+ * way a client with no route to the target would. The ready and heartbeat
62
+ * reports are never held: the handshake waits on them.
63
+ */
64
+ class FakeHost {
65
+ constructor({ coalesceAssign = null, holdCompletion = false, failSend = false } = {}) {
66
+ this.server = createServer((socket) => this.onConnection(socket));
67
+ this.frames = [];
68
+ this.sockets = new Set();
69
+ this.connections = 0;
70
+ this.coalesceAssign = coalesceAssign;
71
+ this.holdCompletion = holdCompletion;
72
+ this.failSend = failSend;
73
+ this.held = [];
74
+ }
75
+
76
+ listen(path) {
77
+ return new Promise((resolve) => this.server.listen(path, resolve));
78
+ }
79
+
80
+ onConnection(socket) {
81
+ this.connections += 1;
82
+ this.sockets.add(socket);
83
+ socket.on("error", () => {});
84
+ socket.on("close", () => this.sockets.delete(socket));
85
+ const decoder = createFrameDecoder({
86
+ onFrame: (frame) => this.onFrame(socket, frame),
87
+ onError: () => socket.destroy(),
88
+ });
89
+ socket.on("data", (chunk) => decoder.push(chunk));
90
+ }
91
+
92
+ onFrame(socket, frame) {
93
+ this.frames.push({ frame, socket });
94
+ if (frame.id === undefined) return;
95
+ let body = { ok: true, data: null };
96
+ if (frame.op === "hello") {
97
+ const mount = frame.args.mount ?? {};
98
+ body = {
99
+ ok: true,
100
+ data: {
101
+ op: "welcome",
102
+ args: {
103
+ protocol: 1,
104
+ role: mount.role,
105
+ session_id: mount.session ?? SESSION_ID,
106
+ generation: 1,
107
+ prose: "Read the incoming task",
108
+ server: { connected: true, cluster: "local", name: "server" },
109
+ host_capabilities: ["probe", "recycle"],
110
+ },
111
+ },
112
+ };
113
+ }
114
+ if (this.holdCompletion && frame.op === "report" && frame.args?.kind === "complete") {
115
+ this.held.push({ socket, id: frame.id });
116
+ return;
117
+ }
118
+ if (this.failSend && frame.op === "send") {
119
+ socket.write(encodeFrame({
120
+ reply_to: frame.id,
121
+ ok: false,
122
+ error: { code: "no_route", message: "no route to that role" },
123
+ }));
124
+ return;
125
+ }
126
+ const reply = encodeFrame({ reply_to: frame.id, ...body });
127
+ const push = frame.op === "hello" && this.coalesceAssign
128
+ ? encodeFrame({ op: "assign", args: this.coalesceAssign })
129
+ : null;
130
+ socket.write(push ? Buffer.concat([reply, push]) : reply);
131
+ }
132
+
133
+ /** Answer the completion reports held so far, the way the client would. */
134
+ releaseReports() {
135
+ const held = this.held;
136
+ this.held = [];
137
+ for (const entry of held) {
138
+ entry.socket.write(encodeFrame({ reply_to: entry.id, ok: true, data: null }));
139
+ }
140
+ }
141
+
142
+ notify(op, args) {
143
+ for (const socket of this.sockets) socket.write(encodeFrame({ op, args }));
144
+ }
145
+
146
+ of(op) {
147
+ return this.frames.filter((entry) => entry.frame.op === op).map((entry) => entry.frame.args);
148
+ }
149
+
150
+ close() {
151
+ for (const socket of this.sockets) socket.destroy();
152
+ return new Promise((resolve) => this.server.close(resolve));
153
+ }
154
+ }
155
+
156
+ /** The effect surface the agent drives, recorded for assertions. */
157
+ function fakeSurface(options = {}) {
158
+ const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [] };
159
+ return {
160
+ calls,
161
+ available: {
162
+ wakeUser: true,
163
+ proseContext: true,
164
+ customEntry: true,
165
+ status: true,
166
+ exit: true,
167
+ isIdle: true,
168
+ registerTool: true,
169
+ registerCommand: true,
170
+ },
171
+ wakeUser: (text, parts = []) => {
172
+ calls.wakeUser.push({ text, parts });
173
+ return true;
174
+ },
175
+ proseContext: (text) => {
176
+ calls.prose.push(text);
177
+ return true;
178
+ },
179
+ customEntry: (type, data) => {
180
+ calls.entries.push({ type, data });
181
+ return true;
182
+ },
183
+ status: (text) => calls.status.push(text),
184
+ welcome: () => {},
185
+ isIdle: () => (options.idle === undefined ? true : options.idle()),
186
+ exit: (reason) => calls.exits.push(reason),
187
+ };
188
+ }
189
+
190
+ /** One agent on a fresh temp workspace socket. */
191
+ async function startAgent({
192
+ surface = fakeSurface(),
193
+ capabilities,
194
+ options = {},
195
+ coalesceAssign = null,
196
+ holdCompletion = false,
197
+ failSend = false,
198
+ relay = null,
199
+ } = {}) {
200
+ const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
201
+ const socketPath = join(dir, "s");
202
+ const host = new FakeHost({ coalesceAssign, holdCompletion, failSend });
203
+ await host.listen(socketPath);
204
+ const logs = [];
205
+ const agent = new OnlyneAgent({
206
+ socketPath,
207
+ cwd: dir,
208
+ role: "planner",
209
+ sessionId: SESSION_ID,
210
+ taskId: TASK_ID,
211
+ surface,
212
+ log: (line) => logs.push(line),
213
+ ladder: [20, 40, 80],
214
+ // The fallback is the agent_settled-less path; a test that wants it asks for
215
+ // a short window, and every other test drives the exit explicitly.
216
+ settleFallbackMs: options.settleFallbackMs ?? 30_000,
217
+ heartbeatMs: options.heartbeatMs ?? 60_000,
218
+ ...(capabilities ? { capabilities } : {}),
219
+ ...(relay ? { relay } : {}),
220
+ ...(options.host !== undefined ? { host: options.host } : {}),
221
+ });
222
+ cleanups.push(async () => {
223
+ agent.stop("test");
224
+ await host.close();
225
+ rmSync(dir, { recursive: true, force: true });
226
+ });
227
+ return { agent, host, surface, dir, socketPath, logs };
228
+ }
229
+
230
+ const cleanups = [];
231
+ afterEach(async () => {
232
+ while (cleanups.length > 0) await cleanups.pop()();
233
+ });
234
+
235
+ const assignArgs = () => (ASSIGN_FRAME ? ASSIGN_FRAME.args : {
236
+ envelope: {
237
+ protocol: 1,
238
+ id: "3f2a1c4e-5b6d-4e7f-8a90-1b2c3d4e5f60",
239
+ kind: "task",
240
+ from: { role: { role: "planner" } },
241
+ to: { role: { role: "builder" } },
242
+ causality: { task: TASK_ID, hop: 0, attempt: 0 },
243
+ body: { text: "build it" },
244
+ ts: "2026-09-10T12:00:00Z",
245
+ admin: false,
246
+ },
247
+ prose: "Read the incoming task",
248
+ task_id: TASK_ID,
249
+ generation: 1,
250
+ });
251
+
252
+ /**
253
+ * Run `body` with exactly the ORCA_* environment an Orca pane exports, so the
254
+ * result does not depend on the shell the tests happen to run in, then restore
255
+ * whatever was there.
256
+ */
257
+ async function inOrcaPane(env, body) {
258
+ const touched = new Set([...Object.keys(env), ...Object.keys(process.env).filter((key) => key.startsWith("ORCA_"))]);
259
+ const saved = new Map([...touched].map((key) => [key, process.env[key]]));
260
+ for (const key of touched) delete process.env[key];
261
+ Object.assign(process.env, env);
262
+ try {
263
+ return await body();
264
+ } finally {
265
+ for (const [key, value] of saved) {
266
+ if (value === undefined) delete process.env[key];
267
+ else process.env[key] = value;
268
+ }
269
+ }
270
+ }
271
+
272
+ /** One mounted agent: the socket is up and the ready report has landed, so a
273
+ * push from the test cannot race the dial. */
274
+ async function mountedAgent(options = {}) {
275
+ const started = await startAgent(options);
276
+ started.agent.start();
277
+ await waitFor(() => (started.host.of("report").length >= 1 ? true : null));
278
+ return started;
279
+ }
280
+
281
+ test("every heartbeat names the Orca pane this process was spawned in", async () => {
282
+ await inOrcaPane(PANE_ENV, async () => {
283
+ const { host } = await mountedAgent();
284
+ host.notify("assign", assignArgs());
285
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
286
+
287
+ host.notify("probe", {});
288
+ const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
289
+ assert.deepEqual(observed.host, {
290
+ orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF, handle: "term_1" },
291
+ });
292
+ // The binding rides beside the state dimensions instead of replacing them.
293
+ assert.equal(observed.agent, "running");
294
+ assert.equal(observed.public, "working");
295
+ assert.equal(observed.version.generation, 1);
296
+ });
297
+ });
298
+
299
+ test("a reconnect reports the same pane", async () => {
300
+ await inOrcaPane(PANE_ENV, async () => {
301
+ const { agent, host } = await mountedAgent();
302
+ host.notify("assign", assignArgs());
303
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
304
+ host.notify("probe", {});
305
+ await waitFor(() => (heartbeats(host).length ? true : null));
306
+
307
+ // The binding is process state: a client restarting under the plugin cannot
308
+ // move the pane the process runs in.
309
+ for (const socket of [...host.sockets]) socket.destroy();
310
+ await waitFor(() => (host.connections >= 2 ? true : null));
311
+ await waitFor(() => (agent.connected === true ? true : null));
312
+ const reports = await waitFor(() => (heartbeats(host).length >= 2 ? heartbeats(host) : null));
313
+ assert.deepEqual(reports.at(-1).host, reports[0].host);
314
+ });
315
+ });
316
+
317
+ test("a second task in the same pane reports the same pane", async () => {
318
+ await inOrcaPane(PANE_ENV, async () => {
319
+ const { host } = await mountedAgent();
320
+ host.notify("assign", assignArgs());
321
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
322
+ host.notify("probe", {});
323
+ await waitFor(() => (heartbeats(host).length ? true : null));
324
+
325
+ // The binding belongs to the process, not to the task: handing this pane
326
+ // another task cannot change where the process runs.
327
+ host.notify("assign", { ...assignArgs(), task_id: "22222222-2222-4222-8222-222222222222" });
328
+ await waitFor(() => (host.of("assign_ack").length === 2 ? true : null));
329
+ host.notify("probe", {});
330
+ const reports = await waitFor(() => (heartbeats(host).length >= 2 ? heartbeats(host) : null));
331
+ assert.deepEqual(reports.at(-1).host, reports[0].host);
332
+ });
333
+ });
334
+
335
+ test("a pi outside an Orca pane reports no host at all", async () => {
336
+ await inOrcaPane({}, async () => {
337
+ const { host } = await mountedAgent();
338
+ host.notify("assign", assignArgs());
339
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
340
+ host.notify("probe", {});
341
+
342
+ const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
343
+ assert.equal("host" in observed, false, "no pane, no binding");
344
+ assert.equal(observed.agent, "running", "the tuple is still a full observation");
345
+ });
346
+ });
347
+
348
+ test("a pane that exports no handle reports the pane without inventing one", async () => {
349
+ await inOrcaPane({ ORCA_PANE_KEY: PANE_KEY }, async () => {
350
+ const { host } = await mountedAgent();
351
+ host.notify("assign", assignArgs());
352
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
353
+ host.notify("probe", {});
354
+
355
+ const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
356
+ // The pane key carries both ids; the handle is a field the environment
357
+ // either has or has not, and a missing one is absent rather than empty.
358
+ assert.deepEqual(observed.host, {
359
+ orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF },
360
+ });
361
+ });
362
+ });
363
+
364
+ test("the binding is the environment the process was spawned with", async () => {
365
+ await inOrcaPane(PANE_ENV, async () => {
366
+ const { host } = await mountedAgent();
367
+ // A later change to the environment cannot move the process: what is
368
+ // reported is the pane it was started in.
369
+ process.env.ORCA_PANE_KEY = "aaaa1111-1111-4111-8111-111111111111:bbbb2222-2222-4222-8222-222222222222";
370
+ host.notify("assign", assignArgs());
371
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
372
+ host.notify("probe", {});
373
+
374
+ const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
375
+ assert.equal(observed.host.orca.pane_key, PANE_KEY);
376
+ });
377
+ });
378
+
379
+ test("a session writes nothing under the workspace: the binding is protocol data", async () => {
380
+ await inOrcaPane(PANE_ENV, async () => {
381
+ const { host, dir } = await mountedAgent();
382
+ host.notify("assign", assignArgs());
383
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
384
+ host.notify("probe", {});
385
+ await waitFor(() => (heartbeats(host).length ? true : null));
386
+
387
+ assert.equal(
388
+ existsSync(join(dir, ".onlyne")),
389
+ false,
390
+ "the plugin keeps no cache file; the pane travels in the observation"
391
+ );
392
+ });
393
+ });
394
+
395
+ test("a fresh agent opens with hello, registers and reports ready", async () => {
396
+ const { agent, host, surface } = await startAgent();
397
+ agent.start();
398
+ const [hello] = await waitFor(() => (host.of("hello").length === 1 ? host.of("hello") : null));
399
+ assert.deepEqual(hello, {
400
+ protocol: 1,
401
+ plugin: "pi-onlyne",
402
+ version: "1.0.0",
403
+ kind: "agent",
404
+ capabilities: ["register", "report", "inject", "recycle"],
405
+ mount: { role: "planner", session: SESSION_ID, task_id: TASK_ID, pid: process.pid },
406
+ });
407
+
408
+ const [register] = await waitFor(() => (host.of("session_register").length === 1 ? host.of("session_register") : null));
409
+ assert.equal(register.session_id, SESSION_ID);
410
+ assert.equal(register.task_id, TASK_ID);
411
+ assert.equal(register.generation, 1);
412
+
413
+ const [ready] = await waitFor(() => (host.of("report").length >= 1 ? host.of("report") : null));
414
+ assert.equal(ready.kind, "ready");
415
+ assert.equal(ready.data.task_id, TASK_ID);
416
+ assert.equal(ready.data.session_id, SESSION_ID);
417
+ assert.equal(ready.data.generation, 1);
418
+ // The report stream starts above the host's own dispatch sequence, or the
419
+ // reducer would drop it as stale (onlyne-session's watermark gate).
420
+ assert.ok(ready.data.seq > SEQ_BASE - 1, `seq ${ready.data.seq} must clear the host watermark`);
421
+ // The role prose arrived once, as context rather than as a turn.
422
+ assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
423
+ assert.equal(agent.status().connected, true);
424
+ });
425
+
426
+ test("an assign is injected once, acked, and a redelivery changes nothing", async () => {
427
+ const { agent, host, surface } = await startAgent();
428
+ agent.start();
429
+ await waitFor(() => host.of("report").length >= 1);
430
+
431
+ host.notify("assign", assignArgs());
432
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
433
+ const injected = surface.calls.wakeUser[0];
434
+ assert.match(injected.text, /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/);
435
+ assert.match(injected.text, /build it/);
436
+ assert.deepEqual(injected.parts, []);
437
+ // The prose came with welcome and is not repeated on every payload.
438
+ assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
439
+ assert.doesNotMatch(injected.text, /Read the incoming task/);
440
+ assert.deepEqual(surface.calls.entries[0].type, "onlyne-assign");
441
+ assert.equal(surface.calls.entries[0].data.proseInjected, false);
442
+
443
+ const acks = await waitFor(() => (host.of("assign_ack").length >= 1 ? host.of("assign_ack") : null));
444
+ assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
445
+ assert.deepEqual(agent.status().tasks, [TASK_ID]);
446
+
447
+ // The same delivery again: one injection, one more ack that says why.
448
+ host.notify("assign", assignArgs());
449
+ const second = await waitFor(() => (host.of("assign_ack").length >= 2 ? host.of("assign_ack") : null));
450
+ assert.equal(surface.calls.wakeUser.length, 1);
451
+ assert.deepEqual(second[1], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
452
+ });
453
+
454
+ // The live case in crates/onlyne-testkit/e2e/pi-live.sh found this: the client
455
+ // hands over a staged session by writing the hello reply and the first assign
456
+ // together, so both frames arrive in one read. The assignment must not be
457
+ // injected from inside the handshake, or the role prose loses its welcome-time
458
+ // delivery and gets folded into the task turn instead.
459
+ test("an assign sharing the hello reply's chunk waits for the welcome", async () => {
460
+ const events = [];
461
+ const surface = fakeSurface();
462
+ const proseContext = surface.proseContext;
463
+ const wakeUser = surface.wakeUser;
464
+ surface.proseContext = (text, welcome) => {
465
+ events.push("prose");
466
+ return proseContext(text, welcome);
467
+ };
468
+ surface.wakeUser = (text, parts) => {
469
+ events.push("assign");
470
+ return wakeUser(text, parts);
471
+ };
472
+ const { agent, host, logs } = await startAgent({ surface, coalesceAssign: assignArgs() });
473
+ agent.start();
474
+ try {
475
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
476
+ } catch (error) {
477
+ throw new Error(`${error.message}; agent log: ${logs.join(" | ")}`);
478
+ }
479
+ assert.deepEqual(events, ["prose", "assign"]);
480
+ assert.deepEqual(surface.calls.prose, ["Read the incoming task"]);
481
+ assert.doesNotMatch(surface.calls.wakeUser[0].text, /Read the incoming task/);
482
+ assert.equal(surface.calls.entries[0].data.proseInjected, false);
483
+ const acks = await waitFor(() => (host.of("assign_ack").length >= 1 ? host.of("assign_ack") : null));
484
+ assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
485
+ });
486
+
487
+ test("turn hooks report running then idle, and a settle completes done with the head", async () => {
488
+ const { agent, host, surface } = await startAgent();
489
+ agent.start();
490
+ await waitFor(() => host.of("report").length >= 1);
491
+ host.notify("assign", assignArgs());
492
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
493
+
494
+ agent.onTurnStart();
495
+ const running = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
496
+ assert.equal(running.kind, "heartbeat");
497
+ assert.equal(running.data.observed.public, "working");
498
+ assert.equal(running.data.observed.version.generation, 1);
499
+
500
+ agent.onTurnEnd();
501
+ const idle = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
502
+ assert.equal(idle.data.observed.public, "idle");
503
+
504
+ agent.noteAssistantText("OK");
505
+ agent.onSettled();
506
+ const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
507
+ assert.deepEqual(complete, { kind: "complete", data: { task_id: TASK_ID, outcome: "done", head: "OK" } });
508
+ assert.deepEqual(agent.status().tasks, []);
509
+ // The completion ends the session: the process that ran it is asked to leave.
510
+ assert.deepEqual(surface.calls.exits, ["done"]);
511
+ // Heartbeats stop with the last task, so a settled session stops writing.
512
+ const reports = host.of("report").length;
513
+ await new Promise((resolve) => setTimeout(resolve, 120));
514
+ assert.equal(host.of("report").length, reports);
515
+ });
516
+
517
+ // The live case found this ordering too: pi's turn-end hook fires in the same
518
+ // millisecond as the settle that reports the completion, so the turn-end
519
+ // heartbeat lands after the completion report. `observed` is a whole snapshot,
520
+ // and one carrying `delivery: none`/`outcome: pending` puts the session back to
521
+ // `idle` after the host has already recorded `exited`.
522
+ test("a turn-end heartbeat after the completion never leaves the plugin", async () => {
523
+ const { agent, host, surface } = await startAgent();
524
+ agent.start();
525
+ await waitFor(() => host.of("report").length >= 1);
526
+ host.notify("assign", assignArgs());
527
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
528
+ agent.onTurnStart();
529
+ await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
530
+
531
+ agent.noteAssistantText("OK");
532
+ await agent.complete(TASK_ID, "done", "OK");
533
+ const reports = host.of("report").length;
534
+ agent.onTurnEnd();
535
+ agent.onSettled();
536
+ await new Promise((resolve) => setTimeout(resolve, 60));
537
+ assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
538
+ });
539
+
540
+ test("a busy pi holds the completion until it is idle", async () => {
541
+ let idle = false;
542
+ const surface = fakeSurface({ idle: () => idle });
543
+ const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
544
+ agent.start();
545
+ await waitFor(() => host.of("report").length >= 1);
546
+ host.notify("assign", assignArgs());
547
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
548
+ agent.onTurnEnd();
549
+ agent.noteAssistantText("still working");
550
+ agent.onSettled();
551
+ await new Promise((resolve) => setTimeout(resolve, 80));
552
+ assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
553
+
554
+ idle = true;
555
+ const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
556
+ assert.equal(complete.data.head, "still working");
557
+ });
558
+
559
+ test("an assigned task that never ran is not completed", async () => {
560
+ const { agent, host, surface } = await startAgent();
561
+ agent.start();
562
+ await waitFor(() => host.of("report").length >= 1);
563
+ host.notify("assign", assignArgs());
564
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
565
+ // No turn ran: a settle now would claim work that never happened.
566
+ agent.onSettled();
567
+ await new Promise((resolve) => setTimeout(resolve, 100));
568
+ assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
569
+ assert.deepEqual(agent.status().tasks, [TASK_ID]);
570
+ });
571
+
572
+ test("a failed turn settles the task as failed with the error as head", async () => {
573
+ const { agent, host, surface } = await startAgent();
574
+ agent.start();
575
+ await waitFor(() => host.of("report").length >= 1);
576
+ host.notify("assign", assignArgs());
577
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
578
+ agent.onTurnStart();
579
+ agent.onTurnError("provider exploded");
580
+ const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
581
+ assert.deepEqual(complete.data, { task_id: TASK_ID, outcome: "failed", head: "provider exploded" });
582
+ });
583
+
584
+ test("an explicit tool outcome wins and a second completion is refused", async () => {
585
+ const { agent, host, surface } = await startAgent();
586
+ agent.start();
587
+ await waitFor(() => host.of("report").length >= 1);
588
+ host.notify("assign", assignArgs());
589
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
590
+ agent.onTurnStart();
591
+ agent.onTurnEnd();
592
+ agent.noteAssistantText("looks fine");
593
+
594
+ const result = await agent.completeFromTool({ outcome: "failed", text: "changed my mind" });
595
+ assert.deepEqual(result, { taskId: TASK_ID, outcome: "failed", head: "changed my mind" });
596
+ assert.deepEqual(await agent.completeFromTool({ outcome: "done" }), {
597
+ taskId: TASK_ID,
598
+ outcome: "done",
599
+ head: "",
600
+ duplicate: true,
601
+ });
602
+ agent.onSettled();
603
+ await new Promise((resolve) => setTimeout(resolve, 60));
604
+ const completes = host.of("report").filter((report) => report.kind === "complete");
605
+ assert.equal(completes.length, 1);
606
+ assert.equal(completes[0].data.outcome, "failed");
607
+ // One session asks for one exit, even when a second completion is refused.
608
+ assert.deepEqual(surface.calls.exits, ["failed"]);
609
+ });
610
+
611
+ // The completion body is what the tool call handed over. The sentence a turn
612
+ // ends on is only the fallback for a task whose argument carries nothing, and
613
+ // the auto rule reports exactly that fallback field — so an explicit argument
614
+ // can never be displaced, in either call path.
615
+ test("an explicit tool argument is the head over the last assistant text", async () => {
616
+ const { agent, host, surface } = await startAgent();
617
+ agent.start();
618
+ await waitFor(() => host.of("report").length >= 1);
619
+ host.notify("assign", assignArgs());
620
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
621
+ agent.onTurnStart();
622
+ agent.onTurnEnd();
623
+ agent.noteAssistantText("Handed off to `a` with K=6.");
624
+
625
+ const payload = "K=10: 1:a 2:b 3:c 4:d 5:e 6:a 7:b 8:c 9:d 10:e";
626
+ const result = await agent.completeFromTool({ outcome: "done", text: payload });
627
+ assert.equal(result.head, payload, "the argument is the head, byte for byte");
628
+ const completes = host.of("report").filter((report) => report.kind === "complete");
629
+ assert.deepEqual(completes[0].data, { task_id: TASK_ID, outcome: "done", head: payload });
630
+
631
+ // The model keeps talking until the exit, so the sentence that follows the
632
+ // call must not take the head's place: the task is settled, and it is
633
+ // reported once.
634
+ agent.noteAssistantText("done");
635
+ agent.onSettled();
636
+ await new Promise((resolve) => setTimeout(resolve, 60));
637
+ assert.equal(host.of("report").filter((report) => report.kind === "complete").length, 1);
638
+ });
639
+
640
+ // An argument that carries nothing is no argument: the completion summary is
641
+ // the last assistant text instead of an empty head. (An argument absent
642
+ // altogether is the queued-completion case above.)
643
+ test("an empty tool argument falls back to the last assistant text", async () => {
644
+ for (const text of ["", " "]) {
645
+ const { agent, host, surface } = await startAgent();
646
+ agent.start();
647
+ await waitFor(() => host.of("report").length >= 1);
648
+ host.notify("assign", assignArgs());
649
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
650
+ agent.onTurnStart();
651
+ agent.onTurnEnd();
652
+ agent.noteAssistantText("appended 10:e and handed the token back");
653
+
654
+ const result = await agent.completeFromTool({ outcome: "done", text });
655
+ assert.equal(result.head, "appended 10:e and handed the token back", `text=${JSON.stringify(text)}`);
656
+ const complete = host.of("report").find((report) => report.kind === "complete");
657
+ assert.equal(complete.data.head, "appended 10:e and handed the token back");
658
+ }
659
+ });
660
+
661
+ // The exit is a consequence of the client's acknowledgement, not of the
662
+ // completion call: the client answers a `report` only after it has settled the
663
+ // session row and written the `Completion` envelope, so a report still in
664
+ // flight must leave the process running (this is the frame the client
665
+ // duplicates into its intent queue when its own link is down).
666
+ test("the exit waits for the client's acknowledgement of the completion report", async () => {
667
+ const { agent, host, surface } = await startAgent({ holdCompletion: true });
668
+ agent.start();
669
+ await waitFor(() => host.of("report").length >= 1);
670
+ host.notify("assign", assignArgs());
671
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
672
+ agent.onTurnStart();
673
+ agent.onTurnEnd();
674
+ agent.noteAssistantText("OK");
675
+
676
+ const completion = agent.complete(TASK_ID, "done", "OK");
677
+ await waitFor(() =>
678
+ host.of("report").some((report) => report.kind === "complete") ? true : null,
679
+ );
680
+ await new Promise((resolve) => setTimeout(resolve, 40));
681
+ assert.deepEqual(
682
+ surface.calls.exits,
683
+ [],
684
+ "an unacknowledged completion must not take the process down",
685
+ );
686
+
687
+ host.releaseReports();
688
+ await completion;
689
+ assert.deepEqual(surface.calls.exits, ["done"]);
690
+ });
691
+
692
+ // A session that only ever reported `running` and then completed leaves the
693
+ // ledger saying `running` forever: the client settles the row from the
694
+ // completion report and hears nothing more. One final observation, sent after
695
+ // the completion is acknowledged and before the process leaves, is what makes
696
+ // an exited session read idle.
697
+ test("a completion after a running beat publishes the settled observation before the exit", async () => {
698
+ const { agent, host, surface } = await startAgent();
699
+ agent.start();
700
+ await waitFor(() => host.of("report").length >= 1);
701
+ host.notify("assign", assignArgs());
702
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
703
+ agent.onTurnStart();
704
+ await waitFor(() =>
705
+ host.of("report").find((report) => report.data?.observed?.agent === "running"),
706
+ );
707
+ agent.noteAssistantText("OK");
708
+
709
+ await agent.complete(TASK_ID, "done", "OK");
710
+ const reports = host.of("report").filter((report) => report.kind !== "ready");
711
+ const kinds = reports.map((report) => report.kind);
712
+ assert.deepEqual(
713
+ kinds.slice(-2),
714
+ ["complete", "heartbeat"],
715
+ "the settled observation follows the completion: " + JSON.stringify(kinds),
716
+ );
717
+ const settled = reports.at(-1).data.observed;
718
+ assert.equal(settled.agent, "idle");
719
+ assert.equal(settled.outcome, "done");
720
+ assert.equal(settled.delivery, "accepted");
721
+ assert.equal(settled.recovery, "draining");
722
+ assert.equal(settled.public, "exited");
723
+ assert.deepEqual(surface.calls.exits, ["done"]);
724
+ });
725
+
726
+ test("a session whose last beat was idle publishes no extra observation", async () => {
727
+ const { agent, host, surface } = await startAgent();
728
+ agent.start();
729
+ await waitFor(() => host.of("report").length >= 1);
730
+ host.notify("assign", assignArgs());
731
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
732
+ agent.onTurnStart();
733
+ agent.onTurnEnd();
734
+ await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
735
+ agent.noteAssistantText("OK");
736
+ const before = host.of("report").filter((report) => report.kind === "heartbeat").length;
737
+
738
+ await agent.complete(TASK_ID, "done", "OK");
739
+ assert.equal(
740
+ host.of("report").filter((report) => report.kind === "heartbeat").length,
741
+ before,
742
+ "an already-idle session needs no second idle observation",
743
+ );
744
+ assert.deepEqual(surface.calls.exits, ["done"]);
745
+ });
746
+
747
+ test("a queued completion and its settled observation flush in order after reconnect", async () => {
748
+ const { agent, host, surface } = await startAgent();
749
+ agent.start();
750
+ await waitFor(() => host.of("report").length >= 1);
751
+ host.notify("assign", assignArgs());
752
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
753
+ agent.onTurnStart();
754
+ agent.onTurnEnd();
755
+ agent.noteAssistantText("done offline");
756
+
757
+ for (const socket of [...host.sockets]) socket.destroy();
758
+ await waitFor(() => (agent.status().connected === false ? true : null));
759
+ await agent.completeFromTool({ outcome: "done" });
760
+ assert.deepEqual(surface.calls.exits, [], "nothing exits before the report lands");
761
+
762
+ await waitFor(() => (host.connections >= 2 ? true : null));
763
+ await waitFor(() => (surface.calls.exits.length === 1 ? true : null));
764
+ const reports = host.of("report").filter((report) => report.kind !== "ready");
765
+ assert.deepEqual(
766
+ reports.slice(-2).map((report) => [report.kind, report.data.observed?.agent ?? null]),
767
+ [
768
+ ["complete", null],
769
+ ["heartbeat", "idle"],
770
+ ],
771
+ "the settled observation rides after the flushed completion: " + JSON.stringify(reports),
772
+ );
773
+ assert.equal(reports.at(-1).data.observed.outcome, "done");
774
+ });
775
+
776
+ test("an inbound image is written under the workspace and handed to pi", async () => {
777
+ const { agent, host, surface, dir } = await startAgent();
778
+ agent.start();
779
+ await waitFor(() => host.of("report").length >= 1);
780
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
781
+ const args = assignArgs();
782
+ args.envelope.body = { text: "what is this?", image: { data_base64: bytes.toString("base64"), mime: "image/png", name: "shot.png" } };
783
+ host.notify("assign", args);
784
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
785
+
786
+ const injected = surface.calls.wakeUser[0];
787
+ assert.equal(injected.parts.length, 1);
788
+ assert.equal(injected.parts[0].mime, "image/png");
789
+ assert.equal(injected.parts[0].data, bytes.toString("base64"));
790
+ const path = join(dir, ".onlyne", "tmp", "attachments", `${TASK_ID}-3f2a1c4e-5b6d-4e7f-8a90-1b2c3d4e5f60-shot.png`);
791
+ assert.ok(existsSync(path), `expected ${path}`);
792
+ assert.deepEqual([...readFileSync(path)], [...bytes]);
793
+ assert.ok(injected.text.includes(path), "the injected message names the file it wrote");
794
+ assert.deepEqual(surface.calls.entries[0].data.attachments, [path]);
795
+ });
796
+
797
+ test("a probe is answered with a heartbeat for the live task", async () => {
798
+ const { agent, host, surface } = await startAgent();
799
+ agent.start();
800
+ await waitFor(() => host.of("report").length >= 1);
801
+ host.notify("assign", assignArgs());
802
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
803
+
804
+ host.notify("probe", { task_id: TASK_ID });
805
+ const heartbeat = await waitFor(() =>
806
+ host.of("report").find((report) => report.kind === "heartbeat" && report.data.task_id === TASK_ID),
807
+ );
808
+ assert.equal(heartbeat.data.observed.agent, "running");
809
+ assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
810
+ });
811
+
812
+ test("a task body handed over as config_get still reaches pi", async () => {
813
+ const { agent, host, surface } = await startAgent();
814
+ agent.start();
815
+ await waitFor(() => host.of("report").length >= 1);
816
+ host.notify("config_get", { key: "stdin:do the thing" });
817
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
818
+ assert.match(surface.calls.wakeUser[0].text, /do the thing/);
819
+ });
820
+
821
+ test("recycle settles the task, detaches and asks pi to exit", async () => {
822
+ const { agent, host, surface } = await startAgent();
823
+ agent.start();
824
+ await waitFor(() => host.of("report").length >= 1);
825
+ host.notify("assign", assignArgs());
826
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
827
+
828
+ host.notify("recycle", { task_id: TASK_ID, reason: "operator", outcome: "cancelled" });
829
+ const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
830
+ assert.equal(complete.data.outcome, "cancelled");
831
+ const detach = await waitFor(() => (host.of("detach").length >= 1 ? host.of("detach") : null));
832
+ assert.equal(detach[0].reason, "recycle:operator");
833
+ assert.deepEqual(surface.calls.exits, ["operator"]);
834
+ const connections = host.connections;
835
+ await new Promise((resolve) => setTimeout(resolve, 120));
836
+ assert.equal(host.connections, connections, "a recycled agent must not reconnect");
837
+ });
838
+
839
+ test("a completion reported while the socket is down is flushed after reconnect", async () => {
840
+ const { agent, host, surface } = await startAgent();
841
+ agent.start();
842
+ await waitFor(() => host.of("report").length >= 1);
843
+ host.notify("assign", assignArgs());
844
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
845
+ agent.onTurnStart();
846
+ agent.onTurnEnd();
847
+ agent.noteAssistantText("done offline");
848
+
849
+ for (const socket of [...host.sockets]) socket.destroy();
850
+ await waitFor(() => (agent.status().connected === false ? true : null));
851
+ const queued = await agent.completeFromTool({ outcome: "done" });
852
+ assert.equal(queued.queued, true);
853
+ assert.deepEqual(
854
+ surface.calls.exits,
855
+ [],
856
+ "a completion this process could not hand over keeps it alive",
857
+ );
858
+
859
+ await waitFor(() => (host.connections >= 2 ? true : null));
860
+ const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
861
+ assert.equal(complete.data.head, "done offline");
862
+ assert.equal(agent.status().connected, true, "the plugin re-hellos after a disconnect");
863
+ const hellos = host.of("hello");
864
+ // The flusher's acknowledgement is the handover the queued report was
865
+ // waiting for, so the process leaves once it lands.
866
+ await waitFor(() => (surface.calls.exits.length === 1 ? true : null));
867
+ assert.deepEqual(surface.calls.exits, ["done"]);
868
+ assert.equal(hellos.length, 2);
869
+ assert.deepEqual(hellos[1].mount, hellos[0].mount);
870
+ });
871
+
872
+ test("stop detaches and leaves no reconnect behind", async () => {
873
+ const { agent, host } = await startAgent();
874
+ agent.start();
875
+ await waitFor(() => host.of("report").length >= 1);
876
+ agent.stop("pi:quit");
877
+ await waitFor(() => (host.of("detach").length >= 1 ? host.of("detach") : null));
878
+ assert.equal(host.of("detach")[0].reason, "pi:quit");
879
+ const connections = host.connections;
880
+ await new Promise((resolve) => setTimeout(resolve, 120));
881
+ assert.equal(host.connections, connections);
882
+ assert.equal(agent.status().connected, false);
883
+ });
884
+
885
+ test("a reduced capability set reaches the hello unchanged", async () => {
886
+ const { agent, host } = await startAgent({ capabilities: ["register", "report", "recycle"] });
887
+ agent.start();
888
+ const [hello] = await waitFor(() => (host.of("hello").length === 1 ? host.of("hello") : null));
889
+ assert.deepEqual(hello.capabilities, ["register", "report", "recycle"]);
890
+ });
891
+
892
+ test("a host that never answers the hello is dropped and retried", async () => {
893
+ const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-mute-"));
894
+ const socketPath = join(dir, "s");
895
+ const accepted = new Set();
896
+ const server = createServer((socket) => {
897
+ accepted.add(socket);
898
+ socket.on("error", () => {});
899
+ });
900
+ await new Promise((resolve) => server.listen(socketPath, resolve));
901
+ const logs = [];
902
+ const agent = new OnlyneAgent({
903
+ socketPath,
904
+ cwd: dir,
905
+ role: "planner",
906
+ sessionId: SESSION_ID,
907
+ taskId: TASK_ID,
908
+ surface: fakeSurface(),
909
+ log: (line) => logs.push(line),
910
+ ladder: [20],
911
+ helloTimeoutMs: 40,
912
+ settleFallbackMs: 30,
913
+ });
914
+ cleanups.push(async () => {
915
+ agent.stop("test");
916
+ // The host holds the connection open, so `server.close` alone would wait
917
+ // forever: the sockets go first.
918
+ for (const socket of accepted) socket.destroy();
919
+ await new Promise((resolve) => server.close(resolve));
920
+ rmSync(dir, { recursive: true, force: true });
921
+ });
922
+ agent.start();
923
+ await waitFor(() => (logs.some((line) => line.includes("reconnecting")) ? true : null));
924
+ assert.equal(agent.status().connected, false);
925
+ assert.match(agent.status().lastError, /hello timed out/);
926
+ });
927
+
928
+ // ---------------------------------------------------------------- relay guard
929
+
930
+ // The relay guard is what stops a session from reporting a terminal outcome
931
+ // while it still owes a downstream handoff. Its evidence is delivery only:
932
+ // the roles this session's own successful `onlyne_send` calls reached.
933
+
934
+ /** The exact `report.complete` args this plugin sent before the guard existed. */
935
+ const COMPLETE_VECTOR =
936
+ '{"kind":"complete","data":{"task_id":"11111111-1111-4111-8111-111111111111","outcome":"done","head":"handed the token over"}}';
937
+
938
+ /** Every `report.complete` the fake host received, in arrival order. */
939
+ function completions(host) {
940
+ return host.of("report").filter((report) => report.kind === "complete");
941
+ }
942
+
943
+ /** The host's own assign frame, handed over by a role that is not this one. */
944
+ function assignmentFrom(role) {
945
+ const args = assignArgs();
946
+ return { ...args, envelope: { ...args.envelope, from: { role: { role } } } };
947
+ }
948
+
949
+ test("with no relay policy the completion frame is unchanged, force and reason included", async () => {
950
+ const inputs = [
951
+ { outcome: "done", text: "handed the token over" },
952
+ { outcome: "done", text: "handed the token over", force: true, reason: "no policy is in force" },
953
+ ];
954
+ for (const input of inputs) {
955
+ const { agent, host } = await startAgent();
956
+ agent.start();
957
+ await waitFor(() => host.of("report").length >= 1);
958
+ await agent.completeFromTool(input);
959
+ assert.equal(JSON.stringify(completions(host)[0]), COMPLETE_VECTOR, JSON.stringify(input));
960
+ }
961
+ });
962
+
963
+ test("a relay list refuses a completion until every named role has a handoff", async () => {
964
+ const { agent, host, surface } = await startAgent({ relay: { required: ["writer"] } });
965
+ agent.start();
966
+ await waitFor(() => host.of("report").length >= 1);
967
+
968
+ await assert.rejects(
969
+ () => agent.completeFromTool({ outcome: "done", text: "wrote the notes" }),
970
+ (error) => {
971
+ assert.match(
972
+ error.message,
973
+ /^onlyne: relay guard: missing handoff to: writer \(this session delivered to: none\)/,
974
+ );
975
+ assert.match(error.message, /force:true and a non-empty reason/);
976
+ return true;
977
+ },
978
+ );
979
+ assert.deepEqual(completions(host), []);
980
+ assert.deepEqual(surface.calls.exits, []);
981
+
982
+ await agent.sendFromTool({ to: "writer", text: "here is the outline" });
983
+ const result = await agent.completeFromTool({ outcome: "done", text: "wrote the notes" });
984
+ assert.deepEqual(result, { taskId: TASK_ID, outcome: "done", head: "wrote the notes" });
985
+ assert.equal(completions(host).length, 1);
986
+ assert.deepEqual(surface.calls.exits, ["done"]);
987
+ });
988
+
989
+ test("a relay count wants distinct downstream roles and ignores echoes", async () => {
990
+ const { agent, host } = await startAgent({ relay: { required: [], count: 2 } });
991
+ agent.start();
992
+ await waitFor(() => host.of("report").length >= 1);
993
+ host.notify("assign", assignmentFrom("supervisor"));
994
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
995
+
996
+ await agent.sendFromTool({ to: "supervisor", text: "status" }); // back upstream
997
+ await agent.sendFromTool({ to: "planner", text: "note to self" }); // this role
998
+ await agent.sendFromTool({ to: "builder", text: "build it" });
999
+ await assert.rejects(
1000
+ () => agent.completeFromTool({ outcome: "done" }),
1001
+ /missing handoff: 1 of 2 required distinct downstream roles/,
1002
+ );
1003
+
1004
+ await agent.sendFromTool({ to: "writer", text: "document it" });
1005
+ const result = await agent.completeFromTool({ outcome: "done" });
1006
+ assert.equal(result.outcome, "done");
1007
+ assert.equal(completions(host).length, 1);
1008
+ });
1009
+
1010
+ test("force needs a reason, and the reason it takes is stamped into the head", async () => {
1011
+ const { agent, host } = await startAgent({ relay: { required: ["writer"] } });
1012
+ agent.start();
1013
+ await waitFor(() => host.of("report").length >= 1);
1014
+
1015
+ await assert.rejects(
1016
+ () =>
1017
+ agent.completeFromTool({
1018
+ outcome: "done",
1019
+ text: "outline is done",
1020
+ force: true,
1021
+ reason: " ",
1022
+ }),
1023
+ /missing handoff to: writer/,
1024
+ );
1025
+ assert.deepEqual(completions(host), []);
1026
+
1027
+ const result = await agent.completeFromTool({
1028
+ outcome: "done",
1029
+ text: "outline is done",
1030
+ force: true,
1031
+ reason: "writer is offline for the day",
1032
+ });
1033
+ assert.equal(result.head, "relay-guard-forced: writer is offline for the day | outline is done");
1034
+ assert.equal(completions(host)[0].data.head, result.head);
1035
+ });
1036
+
1037
+ test("a refusal detaches nothing, and the same call lands once the handoff is out", async () => {
1038
+ const { agent, host, surface } = await startAgent({ relay: { required: ["writer"] } });
1039
+ agent.start();
1040
+ await waitFor(() => host.of("report").length >= 1);
1041
+
1042
+ await assert.rejects(() => agent.completeFromTool({ outcome: "done", text: "half done" }), /relay guard/);
1043
+ assert.equal(agent.status().connected, true);
1044
+ assert.equal(agent.status().stats.completions, 0);
1045
+ assert.deepEqual(surface.calls.exits, []);
1046
+ assert.deepEqual(host.of("detach"), []);
1047
+ assert.deepEqual(completions(host), []);
1048
+
1049
+ await agent.sendFromTool({ to: "writer", text: "the outline so far" });
1050
+ await agent.completeFromTool({ outcome: "done", text: "half done" });
1051
+ assert.equal(agent.status().stats.completions, 1);
1052
+ assert.deepEqual(surface.calls.exits, ["done"]);
1053
+ });
1054
+
1055
+ test("a send the client refused is not a handoff", async () => {
1056
+ const { agent, host } = await startAgent({ relay: { required: ["writer"] }, failSend: true });
1057
+ agent.start();
1058
+ await waitFor(() => host.of("report").length >= 1);
1059
+
1060
+ await assert.rejects(() => agent.sendFromTool({ to: "writer", text: "outline" }), /no_route/);
1061
+ await assert.rejects(() => agent.completeFromTool({ outcome: "done" }), /missing handoff to: writer/);
1062
+ });
1063
+
1064
+ test("the handoff ledger belongs to the session, not to one task", async () => {
1065
+ const { agent, host } = await startAgent({ relay: { required: ["writer"] } });
1066
+ agent.start();
1067
+ await waitFor(() => host.of("report").length >= 1);
1068
+
1069
+ // A note sent before the assignment arrived is still this session reaching
1070
+ // the role the policy names.
1071
+ await agent.sendFromTool({ to: "writer", text: "preamble" });
1072
+ host.notify("assign", assignArgs());
1073
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
1074
+
1075
+ const result = await agent.completeFromTool({ outcome: "done", text: "wrote the notes" });
1076
+ assert.equal(result.outcome, "done");
1077
+ assert.equal(completions(host).length, 1);
1078
+ });
1079
+