@selesai/code 0.13.39 → 0.13.40
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/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +4 -2
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { createMessageReader, writeMessage } from "./framing.ts";
|
|
11
|
+
import { getTsxCliPath } from "./spawn.ts";
|
|
12
|
+
|
|
13
|
+
const brokerPath = fileURLToPath(new URL("./broker.ts", import.meta.url));
|
|
14
|
+
const brokerCwd = path.dirname(brokerPath);
|
|
15
|
+
const clientCwd = "/tmp/pi-intercom-mailbox-test-cwd";
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
type Frame = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
// Unix socket paths are limited to ~104 bytes on macOS; a long $TMPDIR would
|
|
21
|
+
// make the broker fail to listen, so fall back to /tmp when needed.
|
|
22
|
+
function makeAgentDir(): string {
|
|
23
|
+
const suffix = "/intercom/broker.sock";
|
|
24
|
+
const preferred = mkdtempSync(path.join(tmpdir(), "pi-ic-mbx-"));
|
|
25
|
+
if (preferred.length + suffix.length < 100) {
|
|
26
|
+
return preferred;
|
|
27
|
+
}
|
|
28
|
+
rmSync(preferred, { recursive: true, force: true });
|
|
29
|
+
return mkdtempSync(path.join("/tmp", "pi-ic-mbx-"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function intercomDir(agentDir: string): string {
|
|
33
|
+
return path.join(agentDir, "intercom");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mailboxDir(agentDir: string): string {
|
|
37
|
+
return path.join(intercomDir(agentDir), "mailbox");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function disconnectedDir(agentDir: string): string {
|
|
41
|
+
return path.join(intercomDir(agentDir), "disconnected-sessions");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listFiles(dir: string): string[] {
|
|
45
|
+
return existsSync(dir) ? readdirSync(dir).sort() : [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pendingAskRecordExists(agentDir: string, messageId: string): boolean {
|
|
49
|
+
return existsSync(path.join(intercomDir(agentDir), "pending-asks", `${encodeURIComponent(messageId)}.json`));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function sleep(ms: number): Promise<void> {
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TestBroker {
|
|
57
|
+
proc: ChildProcess;
|
|
58
|
+
output: string[];
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function startBroker(agentDir: string, extraEnv: Record<string, string> = {}): Promise<TestBroker> {
|
|
63
|
+
const proc = spawn(process.execPath, [getTsxCliPath(), brokerPath], {
|
|
64
|
+
cwd: brokerCwd,
|
|
65
|
+
env: { ...process.env, HOME: agentDir, USERPROFILE: agentDir, SELESAI_CODING_AGENT_DIR: agentDir, ...extraEnv },
|
|
66
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
const output: string[] = [];
|
|
69
|
+
proc.stderr?.on("data", (chunk: Buffer) => output.push(chunk.toString()));
|
|
70
|
+
await new Promise<void>((resolve, reject) => {
|
|
71
|
+
const timeout = setTimeout(() => reject(new Error(`Broker startup timed out: ${output.join("")}`)), 15000);
|
|
72
|
+
proc.stdout?.on("data", (chunk: Buffer) => {
|
|
73
|
+
output.push(chunk.toString());
|
|
74
|
+
if (chunk.toString().includes("Intercom broker started")) {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
resolve();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
proc.once("exit", (code, signal) => {
|
|
80
|
+
clearTimeout(timeout);
|
|
81
|
+
reject(new Error(`Broker exited before startup (code=${code}, signal=${signal}): ${output.join("")}`));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
proc,
|
|
86
|
+
output,
|
|
87
|
+
async stop() {
|
|
88
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
89
|
+
proc.kill("SIGTERM");
|
|
90
|
+
await once(proc, "exit").catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class RawClient {
|
|
97
|
+
readonly frames: Frame[] = [];
|
|
98
|
+
readonly socket: net.Socket;
|
|
99
|
+
readonly sessionId: string;
|
|
100
|
+
|
|
101
|
+
private constructor(socket: net.Socket, sessionId: string) {
|
|
102
|
+
this.socket = socket;
|
|
103
|
+
this.sessionId = sessionId;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static async connect(agentDir: string, sessionId: string, name: string, cwd = clientCwd): Promise<RawClient> {
|
|
107
|
+
const socket = net.connect(path.join(intercomDir(agentDir), "broker.sock"));
|
|
108
|
+
await once(socket, "connect");
|
|
109
|
+
const client = new RawClient(socket, sessionId);
|
|
110
|
+
socket.on("data", createMessageReader((msg) => {
|
|
111
|
+
client.frames.push(msg as Frame);
|
|
112
|
+
}, (error) => {
|
|
113
|
+
socket.destroy(error);
|
|
114
|
+
}));
|
|
115
|
+
socket.on("error", () => undefined);
|
|
116
|
+
writeMessage(socket, {
|
|
117
|
+
type: "register",
|
|
118
|
+
sessionId,
|
|
119
|
+
session: { name, cwd, model: "test-model", pid: process.pid, startedAt: Date.now(), lastActivity: Date.now() },
|
|
120
|
+
});
|
|
121
|
+
await client.waitFor((frame) => frame.type === "registered");
|
|
122
|
+
return client;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
send(msg: unknown): void {
|
|
126
|
+
writeMessage(this.socket, msg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async waitFor(predicate: (frame: Frame) => boolean, timeoutMs = 4000): Promise<Frame> {
|
|
130
|
+
const deadline = Date.now() + timeoutMs;
|
|
131
|
+
while (Date.now() < deadline) {
|
|
132
|
+
const found = this.frames.find(predicate);
|
|
133
|
+
if (found) return found;
|
|
134
|
+
await sleep(10);
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Timed out waiting for frame; saw ${JSON.stringify(this.frames)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
messagesWithId(id: string): Frame[] {
|
|
140
|
+
return this.frames.filter((frame) => frame.type === "message" && (frame.message as { id?: string }).id === id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async sendMessage(to: string, id: string, extra: Record<string, unknown> = {}): Promise<Frame> {
|
|
144
|
+
this.send({
|
|
145
|
+
type: "send",
|
|
146
|
+
to,
|
|
147
|
+
message: { id, timestamp: Date.now(), content: { text: `body of ${id}` }, ...extra },
|
|
148
|
+
});
|
|
149
|
+
return this.waitFor((frame) => (frame.type === "delivered" || frame.type === "delivery_failed") && frame.messageId === id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async waitForSessionLeft(sessionId: string): Promise<void> {
|
|
153
|
+
await this.waitFor((frame) => frame.type === "session_left" && frame.sessionId === sessionId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async leave(mode: "destroy" | "unregister" = "destroy"): Promise<void> {
|
|
157
|
+
const closed = once(this.socket, "close");
|
|
158
|
+
if (mode === "unregister") {
|
|
159
|
+
this.send({ type: "unregister" });
|
|
160
|
+
this.socket.end();
|
|
161
|
+
} else {
|
|
162
|
+
this.socket.destroy();
|
|
163
|
+
}
|
|
164
|
+
await closed;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sessionInfo(id: string, name: string): Record<string, unknown> {
|
|
169
|
+
return { id, name, cwd: clientCwd, model: "test-model", pid: process.pid, startedAt: 1, lastActivity: 1 };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sessionKey(id: string): string {
|
|
173
|
+
return JSON.stringify([null, id]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function seedMailboxRecord(agentDir: string, fileName: string, options: { id: string; queuedAt: number; targetId?: string; targetName?: string }): void {
|
|
177
|
+
const targetId = options.targetId ?? "seed-target";
|
|
178
|
+
const record = {
|
|
179
|
+
version: 1,
|
|
180
|
+
from: sessionInfo("seed-sender", "seed-sender"),
|
|
181
|
+
fromKey: sessionKey("seed-sender"),
|
|
182
|
+
target: sessionInfo(targetId, options.targetName ?? "seed-target"),
|
|
183
|
+
targetKey: sessionKey(targetId),
|
|
184
|
+
message: { id: options.id, timestamp: options.queuedAt, brokerReceivedAt: options.queuedAt, content: { text: `seeded ${options.id}` } },
|
|
185
|
+
queuedAt: options.queuedAt,
|
|
186
|
+
};
|
|
187
|
+
mkdirSync(mailboxDir(agentDir), { recursive: true });
|
|
188
|
+
writeFileSync(path.join(mailboxDir(agentDir), fileName), JSON.stringify(record));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function withAgentDir(fn: (agentDir: string, brokers: TestBroker[]) => Promise<void>): Promise<void> {
|
|
192
|
+
const agentDir = makeAgentDir();
|
|
193
|
+
const brokers: TestBroker[] = [];
|
|
194
|
+
try {
|
|
195
|
+
await fn(agentDir, brokers);
|
|
196
|
+
} finally {
|
|
197
|
+
for (const broker of brokers) await broker.stop();
|
|
198
|
+
try {
|
|
199
|
+
chmodSync(mailboxDir(agentDir), 0o700);
|
|
200
|
+
chmodSync(disconnectedDir(agentDir), 0o700);
|
|
201
|
+
} catch {
|
|
202
|
+
// Directories may not exist for tests that never created them.
|
|
203
|
+
}
|
|
204
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const reconnectAs of ["same-id", "new-id-same-name"] as const) {
|
|
209
|
+
test(`queued mail survives a broker restart and is delivered exactly once (${reconnectAs})`, { concurrency: false }, async () => {
|
|
210
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
211
|
+
const first = await startBroker(agentDir);
|
|
212
|
+
brokers.push(first);
|
|
213
|
+
const target = await RawClient.connect(agentDir, "target-1", "restart-target");
|
|
214
|
+
const sender = await RawClient.connect(agentDir, "sender-1", "restart-sender");
|
|
215
|
+
await target.leave();
|
|
216
|
+
await sender.waitForSessionLeft("target-1");
|
|
217
|
+
|
|
218
|
+
const result = await sender.sendMessage("restart-target", "mail-1");
|
|
219
|
+
assert.equal(result.type, "delivered");
|
|
220
|
+
assert.equal(result.delivery, "queued");
|
|
221
|
+
assert.equal(listFiles(mailboxDir(agentDir)).filter((name) => name.endsWith(".json")).length, 1);
|
|
222
|
+
assert.equal(listFiles(mailboxDir(agentDir)).filter((name) => name.endsWith(".tmp")).length, 0);
|
|
223
|
+
|
|
224
|
+
await sender.leave();
|
|
225
|
+
await first.stop();
|
|
226
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 1, "shutdown must keep the persisted mailbox");
|
|
227
|
+
|
|
228
|
+
const second = await startBroker(agentDir);
|
|
229
|
+
brokers.push(second);
|
|
230
|
+
const resumed = await RawClient.connect(agentDir, reconnectAs === "same-id" ? "target-1" : "target-2", "restart-target");
|
|
231
|
+
await resumed.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "mail-1");
|
|
232
|
+
await sleep(200);
|
|
233
|
+
assert.equal(resumed.messagesWithId("mail-1").length, 1);
|
|
234
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
235
|
+
|
|
236
|
+
// Reconnecting again must not redeliver.
|
|
237
|
+
await resumed.leave();
|
|
238
|
+
const again = await RawClient.connect(agentDir, "target-1", "restart-target");
|
|
239
|
+
await sleep(200);
|
|
240
|
+
assert.equal(again.messagesWithId("mail-1").length, 0);
|
|
241
|
+
assert.equal(second.proc.exitCode, null);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
test("expired mailbox and disconnected-session files are dropped on load and deleted", { concurrency: false }, async () => {
|
|
247
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
seedMailboxRecord(agentDir, "expired.json", { id: "expired", queuedAt: now - DAY_MS - 60_000 });
|
|
250
|
+
seedMailboxRecord(agentDir, "fresh.json", { id: "fresh", queuedAt: now - 60_000 });
|
|
251
|
+
mkdirSync(disconnectedDir(agentDir), { recursive: true });
|
|
252
|
+
writeFileSync(path.join(disconnectedDir(agentDir), "old.json"), JSON.stringify({
|
|
253
|
+
version: 1,
|
|
254
|
+
key: sessionKey("old-session"),
|
|
255
|
+
info: sessionInfo("old-session", "old-session"),
|
|
256
|
+
disconnectedAt: now - DAY_MS - 60_000,
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
brokers.push(await startBroker(agentDir));
|
|
260
|
+
assert.equal(existsSync(path.join(mailboxDir(agentDir), "expired.json")), false);
|
|
261
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 1, "only the fresh record remains");
|
|
262
|
+
assert.deepEqual(listFiles(disconnectedDir(agentDir)), []);
|
|
263
|
+
|
|
264
|
+
const sender = await RawClient.connect(agentDir, "sender-x", "sender-x");
|
|
265
|
+
const result = await sender.sendMessage("old-session", "to-old");
|
|
266
|
+
assert.equal(result.type, "delivery_failed");
|
|
267
|
+
assert.equal(result.code, "E_TARGET_NOT_FOUND");
|
|
268
|
+
|
|
269
|
+
const target = await RawClient.connect(agentDir, "seed-target", "seed-target");
|
|
270
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "fresh");
|
|
271
|
+
assert.equal(target.messagesWithId("expired").length, 0);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("corrupt, invalid and temp mailbox files are deleted on load without stopping the broker", { concurrency: false }, async () => {
|
|
276
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
mkdirSync(mailboxDir(agentDir), { recursive: true });
|
|
279
|
+
writeFileSync(path.join(mailboxDir(agentDir), "corrupt.json"), "{not json");
|
|
280
|
+
writeFileSync(path.join(mailboxDir(agentDir), "empty.json"), "");
|
|
281
|
+
writeFileSync(path.join(mailboxDir(agentDir), "invalid-shape.json"), JSON.stringify({ version: 1, queuedAt: now }));
|
|
282
|
+
writeFileSync(path.join(mailboxDir(agentDir), "wrong-version.json"), JSON.stringify({ version: 2 }));
|
|
283
|
+
writeFileSync(path.join(mailboxDir(agentDir), "half-written.json.abc123.tmp"), "{\"version\":1");
|
|
284
|
+
seedMailboxRecord(agentDir, "good.json", { id: "good", queuedAt: now - 1000 });
|
|
285
|
+
|
|
286
|
+
const broker = await startBroker(agentDir);
|
|
287
|
+
brokers.push(broker);
|
|
288
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), ["good.json"]);
|
|
289
|
+
|
|
290
|
+
const target = await RawClient.connect(agentDir, "seed-target", "seed-target");
|
|
291
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "good");
|
|
292
|
+
assert.equal(broker.proc.exitCode, null);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("mailbox load keeps only the newest MAX_MAILBOX_MESSAGES and deletes the rest", { concurrency: false }, async () => {
|
|
297
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
const total = 260;
|
|
300
|
+
for (let index = 0; index < total; index += 1) {
|
|
301
|
+
seedMailboxRecord(agentDir, `m-${index}.json`, { id: `cap-${index}`, queuedAt: now - 100_000 + index * 10 });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
brokers.push(await startBroker(agentDir));
|
|
305
|
+
const remaining = listFiles(mailboxDir(agentDir));
|
|
306
|
+
assert.equal(remaining.length, 256);
|
|
307
|
+
for (let index = 0; index < 4; index += 1) {
|
|
308
|
+
assert.equal(remaining.includes(`m-${index}.json`), false, `m-${index}.json is among the oldest and must be deleted`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const target = await RawClient.connect(agentDir, "seed-target", "seed-target");
|
|
312
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "cap-259");
|
|
313
|
+
await sleep(200);
|
|
314
|
+
const delivered = target.frames.filter((frame) => frame.type === "message").map((frame) => (frame.message as { id: string }).id);
|
|
315
|
+
assert.equal(delivered.length, 256);
|
|
316
|
+
assert.equal(delivered[0], "cap-4");
|
|
317
|
+
assert.equal(delivered[255], "cap-259");
|
|
318
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("queue-time capacity eviction deletes the evicted message's file", { concurrency: false }, async () => {
|
|
323
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
for (let index = 0; index < 256; index += 1) {
|
|
326
|
+
seedMailboxRecord(agentDir, `m-${index}.json`, { id: `cap-${index}`, queuedAt: now - 100_000 + index * 10, targetId: "ghost", targetName: "ghost" });
|
|
327
|
+
}
|
|
328
|
+
mkdirSync(disconnectedDir(agentDir), { recursive: true });
|
|
329
|
+
writeFileSync(path.join(disconnectedDir(agentDir), "ghost.json"), JSON.stringify({
|
|
330
|
+
version: 1,
|
|
331
|
+
key: sessionKey("ghost"),
|
|
332
|
+
info: sessionInfo("ghost", "ghost"),
|
|
333
|
+
disconnectedAt: now - 1000,
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
brokers.push(await startBroker(agentDir));
|
|
337
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 256);
|
|
338
|
+
const sender = await RawClient.connect(agentDir, "sender-e", "sender-e");
|
|
339
|
+
const result = await sender.sendMessage("ghost", "overflow");
|
|
340
|
+
assert.equal(result.delivery, "queued");
|
|
341
|
+
const names = listFiles(mailboxDir(agentDir));
|
|
342
|
+
assert.equal(names.length, 256, "still bounded after eviction");
|
|
343
|
+
assert.equal(names.includes("m-0.json"), false, "oldest entry's file is evicted");
|
|
344
|
+
assert.equal(names.includes("m-1.json"), true);
|
|
345
|
+
|
|
346
|
+
const ghost = await RawClient.connect(agentDir, "ghost", "ghost");
|
|
347
|
+
await ghost.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "overflow");
|
|
348
|
+
const ids = ghost.frames.filter((frame) => frame.type === "message").map((frame) => (frame.message as { id: string }).id);
|
|
349
|
+
assert.equal(ids.length, 256);
|
|
350
|
+
assert.equal(ids[0], "cap-1");
|
|
351
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("cancelling a queued message deletes its mailbox file", { concurrency: false }, async () => {
|
|
356
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
357
|
+
brokers.push(await startBroker(agentDir));
|
|
358
|
+
const target = await RawClient.connect(agentDir, "target-c", "cancel-target");
|
|
359
|
+
const sender = await RawClient.connect(agentDir, "sender-c", "cancel-sender");
|
|
360
|
+
await target.leave();
|
|
361
|
+
await sender.waitForSessionLeft("target-c");
|
|
362
|
+
|
|
363
|
+
assert.equal((await sender.sendMessage("cancel-target", "cancel-me")).delivery, "queued");
|
|
364
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 1);
|
|
365
|
+
|
|
366
|
+
sender.send({ type: "cancel_message", messageId: "cancel-me" });
|
|
367
|
+
await sender.waitFor((frame) => frame.type === "delivered" && frame.messageId === "cancel-me" && frame.delivery === undefined);
|
|
368
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
369
|
+
|
|
370
|
+
const resumed = await RawClient.connect(agentDir, "target-c", "cancel-target");
|
|
371
|
+
await sleep(200);
|
|
372
|
+
assert.equal(resumed.messagesWithId("cancel-me").length, 0);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("a sender can queue to a session that disconnected before a broker restart", { concurrency: false }, async () => {
|
|
377
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
378
|
+
const first = await startBroker(agentDir);
|
|
379
|
+
brokers.push(first);
|
|
380
|
+
const target = await RawClient.connect(agentDir, "target-r", "released-chat");
|
|
381
|
+
const witness = await RawClient.connect(agentDir, "witness-r", "witness-r");
|
|
382
|
+
await target.leave("unregister");
|
|
383
|
+
await witness.waitForSessionLeft("target-r");
|
|
384
|
+
assert.equal(listFiles(disconnectedDir(agentDir)).length, 1);
|
|
385
|
+
await witness.leave();
|
|
386
|
+
await first.stop();
|
|
387
|
+
// Target and witness were both remembered, and shutdown keeps both.
|
|
388
|
+
assert.equal(listFiles(disconnectedDir(agentDir)).length, 2, "shutdown must keep disconnected-session memory");
|
|
389
|
+
|
|
390
|
+
const second = await startBroker(agentDir);
|
|
391
|
+
brokers.push(second);
|
|
392
|
+
const sender = await RawClient.connect(agentDir, "sender-r", "sender-r");
|
|
393
|
+
const byName = await sender.sendMessage("released-chat", "after-restart-1");
|
|
394
|
+
assert.equal(byName.type, "delivered");
|
|
395
|
+
assert.equal(byName.delivery, "queued");
|
|
396
|
+
const byId = await sender.sendMessage("target-r", "after-restart-2");
|
|
397
|
+
assert.equal(byId.delivery, "queued");
|
|
398
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 2);
|
|
399
|
+
|
|
400
|
+
const resumed = await RawClient.connect(agentDir, "target-r", "released-chat");
|
|
401
|
+
await resumed.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "after-restart-2");
|
|
402
|
+
await resumed.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "after-restart-1");
|
|
403
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
404
|
+
assert.equal(listFiles(disconnectedDir(agentDir)).length, 1, "re-registering forgets the target's record (the witness's remains)");
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
for (const mode of ["destroy", "unregister"] as const) {
|
|
409
|
+
test(`asker disconnect (${mode}) keeps its ask edge: a late reply is queued, survives a broker restart, and reaches the resumed asker once`, { concurrency: false }, async () => {
|
|
410
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
411
|
+
const first = await startBroker(agentDir);
|
|
412
|
+
brokers.push(first);
|
|
413
|
+
const target = await RawClient.connect(agentDir, "target-a", "ask-target");
|
|
414
|
+
const asker = await RawClient.connect(agentDir, "asker-a", "ask-asker");
|
|
415
|
+
|
|
416
|
+
const ask = await asker.sendMessage("ask-target", "ask-1", { expectsReply: true });
|
|
417
|
+
assert.equal(ask.delivery, "socket_delivered");
|
|
418
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "ask-1");
|
|
419
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-1"), true);
|
|
420
|
+
|
|
421
|
+
await asker.leave(mode);
|
|
422
|
+
await target.waitForSessionLeft("asker-a");
|
|
423
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-1"), true, "asker-side record stays until the ask timeout");
|
|
424
|
+
|
|
425
|
+
// The edge is still there, so the reply is accepted and mailboxed for the asker.
|
|
426
|
+
const reply = await target.sendMessage("asker-a", "reply-1", { replyTo: "ask-1" });
|
|
427
|
+
assert.equal(reply.type, "delivered");
|
|
428
|
+
assert.equal(reply.delivery, "queued");
|
|
429
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-1"), false, "answered ask is retired");
|
|
430
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 1);
|
|
431
|
+
|
|
432
|
+
await target.leave();
|
|
433
|
+
await first.stop();
|
|
434
|
+
assert.equal(listFiles(mailboxDir(agentDir)).length, 1, "queued reply survives the broker exiting");
|
|
435
|
+
|
|
436
|
+
const second = await startBroker(agentDir);
|
|
437
|
+
brokers.push(second);
|
|
438
|
+
const resumed = await RawClient.connect(agentDir, "asker-a", "ask-asker");
|
|
439
|
+
const received = await resumed.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "reply-1");
|
|
440
|
+
assert.equal((received.message as { replyTo?: string }).replyTo, "ask-1");
|
|
441
|
+
await sleep(200);
|
|
442
|
+
assert.equal(resumed.messagesWithId("reply-1").length, 1);
|
|
443
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
test("an ask whose asker never returns still expires through the ask timeout", { concurrency: false }, async () => {
|
|
449
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
450
|
+
brokers.push(await startBroker(agentDir, { PI_INTERCOM_ASK_TIMEOUT_MS: "300" }));
|
|
451
|
+
const target = await RawClient.connect(agentDir, "target-t", "timeout-target");
|
|
452
|
+
const asker = await RawClient.connect(agentDir, "asker-t", "timeout-asker");
|
|
453
|
+
|
|
454
|
+
await asker.sendMessage("timeout-target", "ask-t", { expectsReply: true });
|
|
455
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "ask-t");
|
|
456
|
+
await asker.leave();
|
|
457
|
+
await target.waitForSessionLeft("asker-t");
|
|
458
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-t"), true);
|
|
459
|
+
|
|
460
|
+
await sleep(500);
|
|
461
|
+
const reply = await target.sendMessage("asker-t", "late-reply", { replyTo: "ask-t" });
|
|
462
|
+
assert.equal(reply.type, "delivery_failed");
|
|
463
|
+
assert.equal(reply.code, "E_REPLY_TARGET", "timed-out edge is pruned");
|
|
464
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-t"), false, "pending-ask record is pruned with the edge");
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("target disconnect keeps the ask edge so a resumed target can still answer", { concurrency: false }, async () => {
|
|
469
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
470
|
+
brokers.push(await startBroker(agentDir));
|
|
471
|
+
const target = await RawClient.connect(agentDir, "target-b", "parent-chat");
|
|
472
|
+
const asker = await RawClient.connect(agentDir, "asker-b", "child-chat");
|
|
473
|
+
|
|
474
|
+
await asker.sendMessage("parent-chat", "ask-2", { expectsReply: true });
|
|
475
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "ask-2");
|
|
476
|
+
|
|
477
|
+
await target.leave();
|
|
478
|
+
await asker.waitForSessionLeft("target-b");
|
|
479
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-2"), true, "target-side record stays");
|
|
480
|
+
|
|
481
|
+
const resumed = await RawClient.connect(agentDir, "target-b", "parent-chat");
|
|
482
|
+
const reply = await resumed.sendMessage("child-chat", "reply-2", { replyTo: "ask-2" });
|
|
483
|
+
assert.equal(reply.type, "delivered");
|
|
484
|
+
assert.equal(reply.delivery, "socket_delivered");
|
|
485
|
+
const received = await asker.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "reply-2");
|
|
486
|
+
assert.equal((received.message as { replyTo?: string }).replyTo, "ask-2");
|
|
487
|
+
assert.equal(pendingAskRecordExists(agentDir, "ask-2"), false);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("persistence write failures do not break live delivery or queueing", { concurrency: false }, async (t) => {
|
|
492
|
+
if (process.platform === "win32" || process.getuid?.() === 0) {
|
|
493
|
+
t.skip("read-only directories are not enforced for this platform/user");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
await withAgentDir(async (agentDir, brokers) => {
|
|
497
|
+
const broker = await startBroker(agentDir);
|
|
498
|
+
brokers.push(broker);
|
|
499
|
+
// Broker creates the dirs on start; make them unwritable.
|
|
500
|
+
chmodSync(mailboxDir(agentDir), 0o500);
|
|
501
|
+
chmodSync(disconnectedDir(agentDir), 0o500);
|
|
502
|
+
|
|
503
|
+
const target = await RawClient.connect(agentDir, "target-w", "write-target");
|
|
504
|
+
const sender = await RawClient.connect(agentDir, "sender-w", "write-sender");
|
|
505
|
+
|
|
506
|
+
const live = await sender.sendMessage("write-target", "live-1");
|
|
507
|
+
assert.equal(live.delivery, "socket_delivered");
|
|
508
|
+
await target.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "live-1");
|
|
509
|
+
|
|
510
|
+
await target.leave(); // disconnected-session write fails here
|
|
511
|
+
await sender.waitForSessionLeft("target-w");
|
|
512
|
+
const queued = await sender.sendMessage("write-target", "queued-1"); // mailbox write fails here
|
|
513
|
+
assert.equal(queued.type, "delivered");
|
|
514
|
+
assert.equal(queued.delivery, "queued");
|
|
515
|
+
assert.deepEqual(listFiles(mailboxDir(agentDir)), []);
|
|
516
|
+
|
|
517
|
+
// The in-memory copy still delivers.
|
|
518
|
+
const resumed = await RawClient.connect(agentDir, "target-w", "write-target");
|
|
519
|
+
await resumed.waitFor((frame) => frame.type === "message" && (frame.message as { id: string }).id === "queued-1");
|
|
520
|
+
assert.equal(broker.proc.exitCode, null);
|
|
521
|
+
assert.match(broker.output.join(""), /Failed to persist intercom mailbox message/);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
ensureIntercomRuntimeDir,
|
|
@@ -18,9 +18,9 @@ import {
|
|
|
18
18
|
shouldUseWindowsTcpTransport,
|
|
19
19
|
} from "./paths.ts";
|
|
20
20
|
|
|
21
|
-
test("getAgentDirPath defaults to Selesai
|
|
22
|
-
|
|
23
|
-
assert.equal(getAgentDirPath({}),
|
|
21
|
+
test("getAgentDirPath defaults to the canonical Selesai directory without a host import", () => {
|
|
22
|
+
assert.equal(getAgentDirPath({}), join(homedir(), ".selesai", "agent"));
|
|
23
|
+
assert.equal(getAgentDirPath({ SELESAI_CODING_AGENT_DIR: " " }), join(homedir(), ".selesai", "agent"));
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
test("getAgentDirPath honors SELESAI_CODING_AGENT_DIR", () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync } from "fs";
|
|
2
2
|
import { isAbsolute, join, resolve } from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
4
|
|
|
5
5
|
export const INTERCOM_DIR_MODE = 0o700;
|
|
6
6
|
export const INTERCOM_RUNTIME_FILE_MODE = 0o600;
|
|
@@ -30,7 +30,9 @@ export function getAgentDirPath(
|
|
|
30
30
|
): string {
|
|
31
31
|
const configured = env.SELESAI_CODING_AGENT_DIR?.trim();
|
|
32
32
|
if (!configured) {
|
|
33
|
-
|
|
33
|
+
// Selesai's canonical default. The host launcher supplies its resolved agent
|
|
34
|
+
// directory via env, so the standalone broker never imports the host package.
|
|
35
|
+
return join(homedir(), ".selesai", "agent");
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
return isAbsolute(configured) ? configured : resolve(cwd, configured);
|
|
@@ -230,10 +230,8 @@ test("spawnBrokerIfNeeded includes stderr from default broker startup failures",
|
|
|
230
230
|
try {
|
|
231
231
|
mkdirSync(brokerDir, { recursive: true });
|
|
232
232
|
mkdirSync(path.dirname(fakeTsxCli), { recursive: true });
|
|
233
|
-
|
|
233
|
+
// No @selesai/code shim: standalone launcher/path loading must be host-independent.
|
|
234
234
|
writeFileSync(path.join(extensionDir, "package.json"), JSON.stringify({ type: "module" }));
|
|
235
|
-
writeFileSync(path.join(extensionDir, "node_modules", "@selesai", "code", "package.json"), JSON.stringify({ type: "module" }));
|
|
236
|
-
writeFileSync(path.join(extensionDir, "node_modules", "@selesai", "code", "index.js"), "export const getAgentDir = () => process.env.SELESAI_CODING_AGENT_DIR;\n");
|
|
237
235
|
writeFileSync(fakeTsxCli, "process.stderr.write('fake tsx failed\\n'); process.exit(1);\n");
|
|
238
236
|
|
|
239
237
|
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -82,3 +82,13 @@ export interface IntercomExtensionRegistration {
|
|
|
82
82
|
onEvent(event: IntercomExtensionEvent): void;
|
|
83
83
|
onReady(channel: IntercomExtensionChannel): void;
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, INTERCOM_RPC_EVENT,
|
|
89
|
+
INTERCOM_RPC_ASK_TIMEOUT_MS, intercomRpcTimeout,
|
|
90
|
+
RELEASE_READINESS_EVENT, answerReleaseReadiness, type ReleaseBlocker,
|
|
91
|
+
STOP_ALL_BACKGROUND_EVENT, answerStopAllBackground,
|
|
92
|
+
type IntercomRpcCommand, type IntercomRpcEventData as IntercomRpcEvent, type IntercomRpcRequest,
|
|
93
|
+
type IntercomRpcResult, type IntercomRpcDelivery,
|
|
94
|
+
} from "@selesai/code";
|