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