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