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