agent-yes 1.267.3 → 1.269.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 +73 -0
- package/dist/{SUPPORTED_CLIS-Cg0BDCiA.js → SUPPORTED_CLIS-Bne04-66.js} +2 -2
- package/dist/SUPPORTED_CLIS-CdyjhpZP.js +12 -0
- package/dist/{agent-yes.config-DzNini0E.js → agent-yes.config-2xKNJM5Y.js} +2 -2
- package/dist/{agentShare-UMpLmVuM.js → agentShare-BI126Ej9.js} +8 -8
- package/dist/askCli-Bbxt9Umz.js +272 -0
- package/dist/{browser-DSduVZeZ.js → browser-CorYp8Yu.js} +1 -1
- package/dist/{browser-B7eYrBy7.js → browser-_2ndf80G.js} +5 -5
- package/dist/callback-BnUbjH7p.js +13 -0
- package/dist/{callback-CnXpN0lG.js → callback-DI2r3NrV.js} +5 -5
- package/dist/{callbackCore-K99x3C75.js → callbackCore-Bbe7rpN2.js} +1 -1
- package/dist/{callbackCore-DcptaZwH.js → callbackCore-C0Cg9RbS.js} +1 -1
- package/dist/{channels-DQaZbare.js → channels-3gz9gHP7.js} +6 -6
- package/dist/{channels-yE1YUHCl.js → channels-CkaCyvEp.js} +2 -2
- package/dist/channels.js +5 -5
- package/dist/cli.js +11 -11
- package/dist/{cmdPs-wuMnTKQ2.js → cmdPs-CVnlflxP.js} +7 -7
- package/dist/{configShared-DtHMBCl7.js → configShared-DYxlvrH0.js} +1 -1
- package/dist/{cwdConflictWarn-BpweoAiy.js → cwdConflictWarn-Buy3r_qc.js} +1 -1
- package/dist/{cwdPassthroughHint-BlRuOGo_.js → cwdPassthroughHint-BlWO-h5w.js} +1 -1
- package/dist/{e2e-CUMZC53I.js → e2e-OLfcNxiv.js} +1 -1
- package/dist/{expose-DyiZ5AU0.js → expose-Bfo-dZdy.js} +1 -1
- package/dist/{forkNested-B_4c-TQk.js → forkNested-jc2ER9xn.js} +1 -1
- package/dist/{hist-BoZPJ2ah.js → hist-DiOYRXuD.js} +1 -1
- package/dist/{html2canvas-pro.esm-cq4l_ZIw.js → html2canvas-pro.esm-D19oTQeV.js} +1 -1
- package/dist/identity-BmDYOuZE.js +3 -0
- package/dist/identity-CLaJ9-uE.js +109 -0
- package/dist/index.js +4 -3
- package/dist/{initMsg-D8cmdY76.js → initMsg-Bat5PUDf.js} +1 -1
- package/dist/{initMsg-BKqWDYFk.js → initMsg-CTAIqBWF.js} +8 -4
- package/dist/{needsInput-103zhZ90.js → needsInput-DhfIwxse.js} +1 -1
- package/dist/{nodeRuntime-QihGFQbI.js → nodeRuntime-CKc1BN5J.js} +1 -1
- package/dist/{notifyDaemon-CtAtYGcX.js → notifyDaemon-Cf4KHDOr.js} +7 -7
- package/dist/{notifyStore-CEDiVyis.js → notifyStore-0hlFPa6z.js} +1 -1
- package/dist/{notifyStore-Cbw2ZJ0X.js → notifyStore-Dus2D1XA.js} +1 -1
- package/dist/{openBrowser-Cs46NiH6.js → openBrowser-Dmg2Oi4q.js} +1 -1
- package/dist/{parentLink-CTgZZCgw.js → parentLink-BwX4jaGD.js} +1 -1
- package/dist/{parentLink-CQrn998L.js → parentLink-DS-XflaJ.js} +1 -1
- package/dist/{parentPingLoop-Cv-VVEEd.js → parentPingLoop-Bxd3dXWO.js} +6 -6
- package/dist/{peer-DIl5xtx8.js → peer-Dr4zvtcd.js} +3 -3
- package/dist/peer-u1AUAZeA.js +4 -0
- package/dist/{remotes-M8FbM92D.js → remotes-BiftUp09.js} +3 -3
- package/dist/{remotes-DdAfueOu.js → remotes-IzQHupeo.js} +3 -3
- package/dist/{rustBinary-BInJa-Nl.js → rustBinary-B2lNFJSe.js} +2 -2
- package/dist/{schedule-DFa8Z-bC.js → schedule-D1CNvuKT.js} +8 -7
- package/dist/{serve-BYETLNkD.js → serve-BaHCO0bU.js} +49 -42
- package/dist/{setup-ng9mUcXb.js → setup-B8EL0SWL.js} +4 -4
- package/dist/{share-Db_zb6gH.js → share-DTPZgbfH.js} +2 -2
- package/dist/{share-NUhi-Wdd.js → share-RgJUhzqG.js} +2 -2
- package/dist/{spawnGate-CZlMs6uV.js → spawnGate-C6YKlnmb.js} +1 -1
- package/dist/{spawnGate-DN4wNXHq.js → spawnGate-CS3oFdJ9.js} +2 -2
- package/dist/{store-BW5NBouz.js → store-C7I2CAu9.js} +1 -1
- package/dist/{subcommands-Dk2xyL6v.js → subcommands-03ghe-aZ.js} +192 -157
- package/dist/{subcommands-DBs9UQ-3.js → subcommands-BS4lnTrb.js} +6 -6
- package/dist/{systemPathLink-BHv1I6wh.js → systemPathLink-DGVcx0fg.js} +1 -1
- package/dist/{terminal-fqDRPIu8.js → terminal-DrCP-200.js} +2 -2
- package/dist/terminal.js +1 -1
- package/dist/todoAutomation-BETGOik9.js +302 -0
- package/dist/{todoCli-BYGZ7_C5.js → todoCli-DUoxA1dh.js} +164 -37
- package/dist/todoIdentity-BffBxRHA.js +138 -0
- package/dist/{todoAutomation-BQRW614S.js → todoStore-Wk8U4zJ8.js} +63 -280
- package/dist/{tray-CzGs9-ch.js → tray-Bxq_sfvU.js} +1 -1
- package/dist/trayApp-BDMzlxRo.js +5 -0
- package/dist/{trayApp-mB2pPeCD.js → trayApp-DC4qjut7.js} +2 -2
- package/dist/{ts-CORLA6m-.js → ts-D0Pk-WJg.js} +14 -8
- package/dist/{versionChecker-N8QScM_b.js → versionChecker-V6ceVsu9.js} +2 -2
- package/dist/{webrtcLink-DyCj380i.js → webrtcLink-B72AQ2eb.js} +2 -2
- package/dist/{webrtcRemote-Bl8eowma.js → webrtcRemote-D68dkqFp.js} +3 -3
- package/dist/{widget-CszqlDS7.js → widget-BM03OkC1.js} +3 -3
- package/dist/widget.js +1 -1
- package/dist/widgets.js +5 -5
- package/dist/{workspaceConfig-CBKlgA_q.js → workspaceConfig-EK7XNsgR.js} +1 -1
- package/dist/{ws-50USZcB2.js → ws-6l7Wb_0r.js} +7 -7
- package/dist/{ws-CTk97AIX.js → ws-pRFrqms9.js} +3 -3
- package/lab/ui/console-logic.js +64 -0
- package/lab/ui/index.html +10 -1
- package/package.json +1 -1
- package/ts/askCli.spec.ts +304 -0
- package/ts/askCli.ts +378 -0
- package/ts/identity.spec.ts +80 -1
- package/ts/identity.ts +16 -9
- package/ts/index.ts +6 -1
- package/ts/initMsg.spec.ts +22 -14
- package/ts/initMsg.ts +7 -2
- package/ts/ipcLock.spec.ts +107 -0
- package/ts/ipcLock.ts +130 -0
- package/ts/serve.ts +68 -17
- package/ts/subcommandMirror.spec.ts +76 -0
- package/ts/subcommands.spec.ts +115 -10
- package/ts/subcommands.ts +108 -36
- package/ts/todoAutomation.spec.ts +64 -0
- package/ts/todoAutomation.ts +25 -2
- package/ts/todoBlock.spec.ts +31 -1
- package/ts/todoBlock.ts +33 -1
- package/ts/todoCli.spec.ts +199 -4
- package/ts/todoCli.ts +266 -42
- package/ts/todoIdentity.spec.ts +85 -0
- package/ts/todoIdentity.ts +76 -0
- package/ts/todoLifecycle.spec.ts +13 -1
- package/ts/todoLifecycle.ts +35 -1
- package/ts/todoRoot.spec.ts +117 -0
- package/ts/todoRoot.ts +90 -0
- package/ts/todoStore.spec.ts +25 -0
- package/ts/todoStore.ts +36 -2
- package/dist/SUPPORTED_CLIS-C48G8vGB.js +0 -11
- package/dist/callback-KldFWtzP.js +0 -13
- package/dist/peer-Cvntlw1i.js +0 -4
- package/dist/trayApp-C96YVMTQ.js +0 -5
package/lab/ui/console-logic.js
CHANGED
|
@@ -773,3 +773,67 @@ export function omniScore(e, query) {
|
|
|
773
773
|
if ((e.prompt || "").toLowerCase().includes(q)) return 20;
|
|
774
774
|
return 0;
|
|
775
775
|
}
|
|
776
|
+
|
|
777
|
+
// ---- ordered keystroke delivery ------------------------------------------
|
|
778
|
+
//
|
|
779
|
+
// Every keystroke the terminal emits used to become its own fire-and-forget
|
|
780
|
+
// POST /api/send. Nothing tied those requests together, so a fast typist had
|
|
781
|
+
// several in flight at once — and independent requests have no ordering
|
|
782
|
+
// guarantee on ANY of the three transports the console uses (plain fetch, the
|
|
783
|
+
// /api/mux socket's request/response pairs, the WebRTC room's). Worse, the
|
|
784
|
+
// host resolves the target agent (`resolveOne`, disk I/O) before writing to
|
|
785
|
+
// its FIFO, so two requests that arrive in order can still reach the write in
|
|
786
|
+
// the other order. Nothing is dropped and nothing errors — the characters just
|
|
787
|
+
// land transposed, which is exactly how it was reported: typing "which is also
|
|
788
|
+
// the r" produced " which il sao sthe r".
|
|
789
|
+
//
|
|
790
|
+
// The fix is to never have two writes in flight for one agent. `push` queues,
|
|
791
|
+
// and a single drain loop sends one request at a time. While that request is
|
|
792
|
+
// out, the burst of typing behind it accumulates and goes as ONE payload, so
|
|
793
|
+
// the common case (typing faster than the round trip) gets fewer requests than
|
|
794
|
+
// before rather than more.
|
|
795
|
+
//
|
|
796
|
+
// Deliberately NOT a debounce: delaying input would hide the race behind added
|
|
797
|
+
// latency while leaving two concurrent writes possible whenever the delay was
|
|
798
|
+
// beaten. Serializing removes it.
|
|
799
|
+
export function createInputSender(send) {
|
|
800
|
+
const queue = [];
|
|
801
|
+
let draining = false;
|
|
802
|
+
|
|
803
|
+
async function drain() {
|
|
804
|
+
if (draining) return;
|
|
805
|
+
draining = true;
|
|
806
|
+
try {
|
|
807
|
+
while (queue.length) {
|
|
808
|
+
let msg = queue.shift();
|
|
809
|
+
// Coalesce only an adjacent RUN of strings. A binary chunk (xterm's
|
|
810
|
+
// onBinary, e.g. UTF-8 mouse reports) is passed through on its own so
|
|
811
|
+
// it is never concatenated into a text payload — but it still holds
|
|
812
|
+
// its place in the queue, so ordering is preserved across a mixed run.
|
|
813
|
+
if (typeof msg === "string") {
|
|
814
|
+
while (queue.length && typeof queue[0] === "string") msg += queue.shift();
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
await send(msg);
|
|
818
|
+
} catch {
|
|
819
|
+
// Same as the old fire-and-forget `.catch(() => {})`: a failed write
|
|
820
|
+
// must not wedge the queue, or one dropped request would silently
|
|
821
|
+
// stop the agent from receiving anything typed after it.
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
} finally {
|
|
825
|
+
draining = false;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
push(chunk) {
|
|
831
|
+
queue.push(chunk);
|
|
832
|
+
void drain();
|
|
833
|
+
},
|
|
834
|
+
/** Queue depth, for tests and the perf overlay. */
|
|
835
|
+
pending() {
|
|
836
|
+
return queue.length;
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
}
|
package/lab/ui/index.html
CHANGED
|
@@ -2317,6 +2317,7 @@
|
|
|
2317
2317
|
centerOffset,
|
|
2318
2318
|
docTitle,
|
|
2319
2319
|
omniScore,
|
|
2320
|
+
createInputSender,
|
|
2320
2321
|
} from "./console-logic.js";
|
|
2321
2322
|
// The ay-share (rtc) remote-room transport lives in a shared module so the
|
|
2322
2323
|
// /r/ rgui viewer reuses the exact same WebRTC + e2e wire (rtc.js imports
|
|
@@ -6327,12 +6328,20 @@ my.translate = async (text, lang) => {
|
|
|
6327
6328
|
// keyword MUST be a string — ay serve's POST /api/send rejects a numeric
|
|
6328
6329
|
// keyword with 400 (pid arrives as a number from /api/ls JSON).
|
|
6329
6330
|
const kw = String(pid);
|
|
6331
|
+
// One /api/send in flight at a time for this agent. A request per
|
|
6332
|
+
// keystroke let a fast typist have several racing at once, and nothing
|
|
6333
|
+
// in any transport (fetch / mux / RTC) — nor in the host's
|
|
6334
|
+
// resolve-then-write handler — kept them in order, so characters
|
|
6335
|
+
// arrived transposed. See createInputSender in console-logic.js.
|
|
6336
|
+
const input = createInputSender((msg) =>
|
|
6337
|
+
tx.post("/api/send", { keyword: kw, msg, code: "none" }),
|
|
6338
|
+
);
|
|
6330
6339
|
const fwd = (d) => {
|
|
6331
6340
|
if (sel !== e._key) return; // ignore a stale terminal mid-switch
|
|
6332
6341
|
// a soft-keyboard character may be decorated by an armed sticky Ctrl/Alt
|
|
6333
6342
|
if (typeof d === "string") d = applyStickyMods(d);
|
|
6334
6343
|
perfNote("in", typeof d === "string" ? d.length : (d?.byteLength ?? 0));
|
|
6335
|
-
|
|
6344
|
+
input.push(d);
|
|
6336
6345
|
};
|
|
6337
6346
|
term.onData(fwd);
|
|
6338
6347
|
term.onBinary(fwd);
|
package/package.json
CHANGED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { rm, mkdir } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { runAskSubcommand, runAnswerSubcommand, buildAskEnvelope, type AskDeps } from "./askCli.ts";
|
|
5
|
+
import { openStore } from "./todoStore.ts";
|
|
6
|
+
import type { GlobalPidRecord } from "./globalPidIndex.ts";
|
|
7
|
+
import type { SelfIdentity } from "./todoIdentity.ts";
|
|
8
|
+
|
|
9
|
+
const isWindows = process.platform === "win32";
|
|
10
|
+
const TEST_ROOT = isWindows
|
|
11
|
+
? path.join(process.env.TEMP || "C:\\Temp", "askcli-test-" + process.pid)
|
|
12
|
+
: "/tmp/askcli-test-" + process.pid;
|
|
13
|
+
|
|
14
|
+
function captureStdout(): { text: () => string; restore: () => void } {
|
|
15
|
+
let buf = "";
|
|
16
|
+
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
|
|
17
|
+
buf += String(chunk);
|
|
18
|
+
return true;
|
|
19
|
+
});
|
|
20
|
+
return { text: () => buf, restore: () => spy.mockRestore() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const agentRec = (over: Partial<GlobalPidRecord>): GlobalPidRecord =>
|
|
24
|
+
({
|
|
25
|
+
pid: 100,
|
|
26
|
+
cli: "claude",
|
|
27
|
+
prompt: null,
|
|
28
|
+
cwd: "/repo",
|
|
29
|
+
log_file: null,
|
|
30
|
+
status: "active",
|
|
31
|
+
exit_code: null,
|
|
32
|
+
exit_reason: null,
|
|
33
|
+
started_at: 0,
|
|
34
|
+
...over,
|
|
35
|
+
}) as GlobalPidRecord;
|
|
36
|
+
|
|
37
|
+
const ASKER: SelfIdentity = { agentId: "lane-a", pid: 11, cwd: "/repo/lane-a", cli: "codex" };
|
|
38
|
+
const ANSWERER: SelfIdentity = { agentId: "lane-b", pid: 21, cwd: "/repo/lane-b", cli: "claude" };
|
|
39
|
+
|
|
40
|
+
/** Deps with a recording `send` and a registry of two agents. */
|
|
41
|
+
function mkDeps(self: SelfIdentity | null, over: Partial<AskDeps> = {}) {
|
|
42
|
+
const sent: string[][] = [];
|
|
43
|
+
const deps: AskDeps = {
|
|
44
|
+
resolveAgent: async (keyword) => {
|
|
45
|
+
const known: Record<string, GlobalPidRecord> = {
|
|
46
|
+
"lane-a": agentRec({ pid: 11, agent_id: "lane-a", cwd: "/repo/lane-a" }),
|
|
47
|
+
"lane-b": agentRec({ pid: 21, agent_id: "lane-b", cwd: "/repo/lane-b" }),
|
|
48
|
+
"11": agentRec({ pid: 11, agent_id: "lane-a", cwd: "/repo/lane-a" }),
|
|
49
|
+
"21": agentRec({ pid: 21, agent_id: "lane-b", cwd: "/repo/lane-b" }),
|
|
50
|
+
legacy: agentRec({ pid: 31, agent_id: null }),
|
|
51
|
+
};
|
|
52
|
+
const rec = known[keyword];
|
|
53
|
+
if (!rec) throw new Error(`no agent matched "${keyword}"`);
|
|
54
|
+
return rec;
|
|
55
|
+
},
|
|
56
|
+
send: async (argv) => {
|
|
57
|
+
sent.push(argv);
|
|
58
|
+
return 0;
|
|
59
|
+
},
|
|
60
|
+
self: async () => self,
|
|
61
|
+
...over,
|
|
62
|
+
};
|
|
63
|
+
return { deps, sent };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ask(deps: AskDeps, ...args: string[]) {
|
|
67
|
+
const cap = captureStdout();
|
|
68
|
+
try {
|
|
69
|
+
const code = await runAskSubcommand([...args, "--root", TEST_ROOT], deps);
|
|
70
|
+
return { code, out: cap.text() };
|
|
71
|
+
} finally {
|
|
72
|
+
cap.restore();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function answer(deps: AskDeps, ...args: string[]) {
|
|
77
|
+
const cap = captureStdout();
|
|
78
|
+
try {
|
|
79
|
+
const code = await runAnswerSubcommand([...args, "--root", TEST_ROOT], deps);
|
|
80
|
+
return { code, out: cap.text() };
|
|
81
|
+
} finally {
|
|
82
|
+
cap.restore();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("ay ask", () => {
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
89
|
+
await mkdir(TEST_ROOT, { recursive: true });
|
|
90
|
+
});
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("records a task carrying BOTH parties: owner = asker, block = the answerer", async () => {
|
|
96
|
+
const { deps } = mkDeps(ASKER);
|
|
97
|
+
await ask(deps, "lane-b", "does", "the", "cache", "need", "invalidating?");
|
|
98
|
+
|
|
99
|
+
const store = await openStore(TEST_ROOT);
|
|
100
|
+
const t = store.get("T1")!;
|
|
101
|
+
expect(t.kind).toBe("question");
|
|
102
|
+
expect(t.state).toBe("pending");
|
|
103
|
+
expect(t.owner).toBe("lane-a"); // asker — reconcile orphans this if lane-a dies
|
|
104
|
+
expect(t.block).toMatchObject({ type: "waiting-on-answer", agentId: "lane-b" });
|
|
105
|
+
// The whole question is kept, not just the one-line summary.
|
|
106
|
+
expect(t.description).toBe("does the cache need invalidating?");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("delivers an <ay-ask-msg> envelope naming the task and the exact reply command", async () => {
|
|
110
|
+
const { deps, sent } = mkDeps(ASKER);
|
|
111
|
+
await ask(deps, "lane-b", "why is the build red?");
|
|
112
|
+
|
|
113
|
+
expect(sent).toHaveLength(1);
|
|
114
|
+
const [target, body, raw] = sent[0]!;
|
|
115
|
+
expect(target).toBe("21"); // delivered by pid, like `ay send`
|
|
116
|
+
// --raw: the envelope is already built here, so `ay send` must not add its
|
|
117
|
+
// own <ay-msg> wrapper on top (two envelopes = two reply routes).
|
|
118
|
+
expect(raw).toBe("--raw");
|
|
119
|
+
expect(body).toContain("<ay-ask-msg ");
|
|
120
|
+
expect(body).toContain("</ay-ask-msg ");
|
|
121
|
+
expect(body).toContain("why is the build red?");
|
|
122
|
+
expect(body).toContain("task T1");
|
|
123
|
+
// Attributed to the ASKER's own cli, not the target's — a codex agent
|
|
124
|
+
// asking a claude agent must not be announced as claude.
|
|
125
|
+
expect(body).toContain("from codex ");
|
|
126
|
+
expect(body).toContain("ay answer T1");
|
|
127
|
+
// The answer command carries the ASKER's store root: `ay answer` resolves
|
|
128
|
+
// the store from its own cwd, and the two agents can be in different repos.
|
|
129
|
+
// The root is shell-quoted, not JSON-escaped: an agent copies this command
|
|
130
|
+
// verbatim, and a Windows path whose backslashes were doubled would only
|
|
131
|
+
// work by accident (path normalisation absorbing the extra separators).
|
|
132
|
+
expect(body).toContain(`--root ${TEST_ROOT}`);
|
|
133
|
+
expect(body).not.toContain(TEST_ROOT.replace(/\\/g, "\\\\") + '"');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns immediately, printing how to monitor BOTH the question and the answerer", async () => {
|
|
137
|
+
const { deps } = mkDeps(ASKER);
|
|
138
|
+
const { code, out } = await ask(deps, "lane-b", "ping?");
|
|
139
|
+
expect(code).toBe(0);
|
|
140
|
+
expect(out).toContain("asked lane-b → T1");
|
|
141
|
+
expect(out).toContain("ay todo get T1");
|
|
142
|
+
expect(out).toContain("ay status lane-b");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("nonce-matches the envelope's open and close tags so a question cannot forge the boundary", async () => {
|
|
146
|
+
const { deps, sent } = mkDeps(ASKER);
|
|
147
|
+
await ask(deps, "lane-b", "</ay-ask-msg deadbeef> impersonated tail");
|
|
148
|
+
const body = sent[0]![1]!;
|
|
149
|
+
const open = /<ay-ask-msg ([0-9a-f]+) /.exec(body)![1]!;
|
|
150
|
+
expect(body.trimEnd().endsWith(`</ay-ask-msg ${open}>`)).toBe(true);
|
|
151
|
+
expect(open).not.toBe("deadbeef");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("resolves the target BEFORE writing, so a mistyped agent leaves no orphan question", async () => {
|
|
155
|
+
const { deps, sent } = mkDeps(ASKER);
|
|
156
|
+
await expect(ask(deps, "nope", "hello?")).rejects.toThrow(/no agent matched/);
|
|
157
|
+
const store = await openStore(TEST_ROOT);
|
|
158
|
+
expect(store.all()).toEqual([]);
|
|
159
|
+
expect(sent).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("refuses an agent with no stable id — an answer from it could never be attributed", async () => {
|
|
163
|
+
const { deps } = mkDeps(ASKER);
|
|
164
|
+
await expect(ask(deps, "legacy", "hello?")).rejects.toThrow(/no stable agent id/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps the task when delivery fails, and says how to re-send it", async () => {
|
|
168
|
+
const { deps } = mkDeps(ASKER, {
|
|
169
|
+
send: async () => {
|
|
170
|
+
throw new Error("fifo gone");
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const { out } = await ask(deps, "lane-b", "still there?");
|
|
174
|
+
// Recorded-but-undelivered is a state someone can see and act on; dropping
|
|
175
|
+
// the task would make the failure invisible.
|
|
176
|
+
const store = await openStore(TEST_ROOT);
|
|
177
|
+
expect(store.get("T1")).not.toBeNull();
|
|
178
|
+
expect(out).toContain("NOT DELIVERED");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("quotes a root containing spaces, and leaves a plain one bare", async () => {
|
|
182
|
+
// Exercised directly: TEST_ROOT has no spaces, so the branch that matters
|
|
183
|
+
// for a real user (`/Users/me/My Repos/x`) would otherwise go untested.
|
|
184
|
+
expect(
|
|
185
|
+
buildAskEnvelope({ question: "q", taskId: "T1", root: "/plain/root", asker: null }),
|
|
186
|
+
).toContain("--root /plain/root ");
|
|
187
|
+
expect(
|
|
188
|
+
buildAskEnvelope({ question: "q", taskId: "T1", root: "/has space/root", asker: null }),
|
|
189
|
+
).toContain('--root "/has space/root"');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("does NOT bypass `ay send`'s recency guard by default, but --force passes through", async () => {
|
|
193
|
+
// The target is a keyword the caller typed, so the guard's own rationale
|
|
194
|
+
// (a fuzzy keyword resolving to an agent you never looked at) applies
|
|
195
|
+
// unchanged — asking the wrong agent is that same mistake.
|
|
196
|
+
const { deps, sent } = mkDeps(ASKER);
|
|
197
|
+
await ask(deps, "lane-b", "plain");
|
|
198
|
+
expect(sent[0]).not.toContain("--force");
|
|
199
|
+
|
|
200
|
+
await ask(deps, "lane-b", "urgent", "--force");
|
|
201
|
+
expect(sent[1]).toContain("--force");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("a human shell can ask too — the task is simply unowned", async () => {
|
|
205
|
+
const { deps } = mkDeps(null);
|
|
206
|
+
await ask(deps, "lane-b", "anyone home?");
|
|
207
|
+
const store = await openStore(TEST_ROOT);
|
|
208
|
+
expect(store.get("T1")?.owner).toBeUndefined();
|
|
209
|
+
expect(store.get("T1")?.block).toMatchObject({ agentId: "lane-b" });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("ay answer", () => {
|
|
214
|
+
beforeEach(async () => {
|
|
215
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
216
|
+
await mkdir(TEST_ROOT, { recursive: true });
|
|
217
|
+
});
|
|
218
|
+
afterEach(async () => {
|
|
219
|
+
await rm(TEST_ROOT, { recursive: true, force: true });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("closes the loop: satisfies the gate, moves to answered, and tells the asker", async () => {
|
|
223
|
+
const { deps: askDeps } = mkDeps(ASKER);
|
|
224
|
+
await ask(askDeps, "lane-b", "why is the build red?");
|
|
225
|
+
|
|
226
|
+
const { deps: ansDeps, sent } = mkDeps(ANSWERER);
|
|
227
|
+
const { out } = await answer(ansDeps, "T1", "a", "stale", "lockfile");
|
|
228
|
+
|
|
229
|
+
const store = await openStore(TEST_ROOT);
|
|
230
|
+
const t = store.get("T1")!;
|
|
231
|
+
expect(t.state).toBe("answered");
|
|
232
|
+
// The block is cleared — an answered question must stop advertising that
|
|
233
|
+
// an answer is owed, or `ls` shows it as still waiting and reconcile
|
|
234
|
+
// reports the (finished) answerer as having died owing one.
|
|
235
|
+
expect(t.block).toBeNull();
|
|
236
|
+
// ...but the answerer is still on the record, as the gate's validator.
|
|
237
|
+
expect(t.verifyEvidence[0]).toMatchObject({
|
|
238
|
+
gate: "answer-received",
|
|
239
|
+
validator: "lane-b",
|
|
240
|
+
note: "a stale lockfile",
|
|
241
|
+
});
|
|
242
|
+
// The answer goes back to the ASKER, wrapped so the recipient can tell it
|
|
243
|
+
// apart from an ordinary message.
|
|
244
|
+
const body = sent[0]![1]!;
|
|
245
|
+
expect(sent[0]![0]).toBe("11");
|
|
246
|
+
// Forced, unlike `ask`: the recipient came off the task record rather than
|
|
247
|
+
// from a typed keyword, and an answerer has no reason to have tailed
|
|
248
|
+
// whoever asked it something.
|
|
249
|
+
expect(sent[0]).toContain("--force");
|
|
250
|
+
expect(body).toContain("<ay-answer-msg ");
|
|
251
|
+
expect(body).toContain("a stale lockfile");
|
|
252
|
+
expect(out).toContain("answered T1");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// The store's independence rule does this, so it holds no matter how the
|
|
256
|
+
// command is invoked — an asker cannot quietly close its own question.
|
|
257
|
+
it("refuses an answer from the asker itself", async () => {
|
|
258
|
+
const { deps: askDeps } = mkDeps(ASKER);
|
|
259
|
+
await ask(askDeps, "lane-b", "am I right?");
|
|
260
|
+
const { deps: selfDeps } = mkDeps(ASKER);
|
|
261
|
+
await expect(answer(selfDeps, "T1", "yes obviously")).rejects.toThrow(
|
|
262
|
+
/independent verification required/,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("still records the answer when the asker can no longer be reached", async () => {
|
|
267
|
+
const { deps: askDeps } = mkDeps(ASKER);
|
|
268
|
+
await ask(askDeps, "lane-b", "are you there?");
|
|
269
|
+
|
|
270
|
+
const { deps: ansDeps } = mkDeps(ANSWERER, {
|
|
271
|
+
resolveAgent: async (keyword) => {
|
|
272
|
+
if (keyword === "lane-a") throw new Error("no agent matched");
|
|
273
|
+
return agentRec({ pid: 21, agent_id: "lane-b" });
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const { out } = await answer(ansDeps, "T1", "yes");
|
|
277
|
+
|
|
278
|
+
const store = await openStore(TEST_ROOT);
|
|
279
|
+
expect(store.get("T1")?.state).toBe("answered");
|
|
280
|
+
expect(store.get("T1")?.verifyEvidence[0]?.note).toBe("yes");
|
|
281
|
+
expect(out).toContain("could NOT reach the asker");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("refuses a task that is not an `ay ask` question", async () => {
|
|
285
|
+
const store = await openStore(TEST_ROOT);
|
|
286
|
+
await store.create({ summary: "ordinary work", kind: "code" });
|
|
287
|
+
const { deps } = mkDeps(ANSWERER);
|
|
288
|
+
await expect(answer(deps, "T1", "hi")).rejects.toThrow(/not a question/);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("names the store it looked in when the task id is unknown there", async () => {
|
|
292
|
+
const { deps } = mkDeps(ANSWERER);
|
|
293
|
+
await expect(answer(deps, "T9", "hi")).rejects.toThrow(/no such task in .*T9/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("a human at a shell can answer — validated as `human`, never colliding with an agent id", async () => {
|
|
297
|
+
const { deps: askDeps } = mkDeps(ASKER);
|
|
298
|
+
await ask(askDeps, "lane-b", "ok?");
|
|
299
|
+
const { deps } = mkDeps(null);
|
|
300
|
+
await answer(deps, "T1", "fine");
|
|
301
|
+
const store = await openStore(TEST_ROOT);
|
|
302
|
+
expect(store.get("T1")?.verifyEvidence[0]?.validator).toBe("human");
|
|
303
|
+
});
|
|
304
|
+
});
|