@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
|
@@ -25,7 +25,14 @@ import {
|
|
|
25
25
|
type IntercomOutboxResultCode,
|
|
26
26
|
type IntercomOutboxResultStatus,
|
|
27
27
|
type IntercomOutboxResultV1,
|
|
28
|
+
INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, INTERCOM_RPC_EVENT,
|
|
29
|
+
intercomRpcTimeout, type IntercomRpcRequest, type IntercomRpcResult,
|
|
30
|
+
type IntercomRpcEventData as IntercomRpcEvent, type IntercomRpcDelivery,
|
|
31
|
+
RELEASE_READINESS_EVENT, answerReleaseReadiness,
|
|
32
|
+
STOP_ALL_BACKGROUND_EVENT, answerStopAllBackground,
|
|
28
33
|
} from "./extension-api.ts";
|
|
34
|
+
import { intercomReleaseBlockers, stopIntercom } from "./release-readiness.ts";
|
|
35
|
+
import { isMessage } from "./broker/protocol.ts";
|
|
29
36
|
import { ReplyTracker } from "./reply-tracker.ts";
|
|
30
37
|
import { resolve as resolvePath } from "node:path";
|
|
31
38
|
import { sameCwd } from "./cwd.ts";
|
|
@@ -664,10 +671,11 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
664
671
|
let replyWaiter: {
|
|
665
672
|
from: string;
|
|
666
673
|
replyTo: string;
|
|
674
|
+
startedAt: number;
|
|
667
675
|
resolve: (message: Message) => void;
|
|
668
676
|
reject: (error: Error) => void;
|
|
669
677
|
} | null = null;
|
|
670
|
-
function waitForReply(from: string, replyTo: string, signal?: AbortSignal, cancelOnAbort?: () => void, getDeliveryState: () => string = () => "unknown"): Promise<Message> {
|
|
678
|
+
function waitForReply(from: string, replyTo: string, signal?: AbortSignal, cancelOnAbort?: () => void, getDeliveryState: () => string = () => "unknown", timeoutMs = askTimeoutMs): Promise<Message> {
|
|
671
679
|
if (replyWaiter) {
|
|
672
680
|
return Promise.reject(new Error("Already waiting for a reply"));
|
|
673
681
|
}
|
|
@@ -676,9 +684,9 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
676
684
|
}
|
|
677
685
|
return new Promise((resolve, reject) => {
|
|
678
686
|
const timeout = setTimeout(() => {
|
|
679
|
-
const timeoutDescription =
|
|
687
|
+
const timeoutDescription = timeoutMs % 60000 === 0 ? `${timeoutMs / 60000} minutes` : `${timeoutMs}ms`;
|
|
680
688
|
rejectReplyWaiter(new Error(`No reply from "${from}" for message ${replyTo} within ${timeoutDescription}. Last known delivery state: ${getDeliveryState()}. This waiter timeout is not cancellation; the delivered message may still be queued or actionable in the recipient session.`));
|
|
681
|
-
},
|
|
689
|
+
}, timeoutMs);
|
|
682
690
|
const cleanup = () => {
|
|
683
691
|
clearTimeout(timeout);
|
|
684
692
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -695,6 +703,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
695
703
|
replyWaiter = {
|
|
696
704
|
from,
|
|
697
705
|
replyTo,
|
|
706
|
+
startedAt: Date.now(),
|
|
698
707
|
resolve: (message) => {
|
|
699
708
|
cleanup();
|
|
700
709
|
resolve(message);
|
|
@@ -1162,6 +1171,126 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1162
1171
|
}
|
|
1163
1172
|
})();
|
|
1164
1173
|
}
|
|
1174
|
+
function emitRpcEvent(event: IntercomRpcEvent): void {
|
|
1175
|
+
pi.events.emit(INTERCOM_RPC_EVENT, { sessionId: currentSessionId, event });
|
|
1176
|
+
}
|
|
1177
|
+
function emitRpcConnection(connected: boolean, reason?: string): void {
|
|
1178
|
+
emitRpcEvent({ type: "intercom_connection", enabled: config.enabled, connected,
|
|
1179
|
+
sessionId: currentIntercomSessionId, ...(reason ? { reason } : {}) });
|
|
1180
|
+
}
|
|
1181
|
+
const rpcRequests = new Map<string, { controller: AbortController; settle(result: IntercomRpcResult): void }>();
|
|
1182
|
+
function endRpcRequests(): void {
|
|
1183
|
+
for (const [requestId, request] of [...rpcRequests]) {
|
|
1184
|
+
request.settle({ requestId, success: false, error: "Intercom session ended" });
|
|
1185
|
+
request.controller.abort();
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
function handleRpcRequest(payload: unknown): void {
|
|
1189
|
+
const request = payload as IntercomRpcRequest;
|
|
1190
|
+
if (!request || typeof request.accept !== "function" || !request.command || !request.signal) return;
|
|
1191
|
+
request.accept();
|
|
1192
|
+
const { requestId, command } = request;
|
|
1193
|
+
const controller = new AbortController();
|
|
1194
|
+
const signal = AbortSignal.any([request.signal, controller.signal]);
|
|
1195
|
+
const generation = runtimeGeneration;
|
|
1196
|
+
const ctx = getLiveContext();
|
|
1197
|
+
const settle = (result: IntercomRpcResult) => {
|
|
1198
|
+
signal.removeEventListener("abort", onAbort);
|
|
1199
|
+
if (!rpcRequests.delete(requestId)) return;
|
|
1200
|
+
pi.events.emit(INTERCOM_RPC_RESULT_EVENT, result);
|
|
1201
|
+
};
|
|
1202
|
+
const onAbort = () => settle({ requestId, success: false, error: "Intercom session ended or request timed out" });
|
|
1203
|
+
rpcRequests.set(requestId, { controller, settle });
|
|
1204
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1205
|
+
const checkLive = () => {
|
|
1206
|
+
if (signal.aborted || !getLiveContext(ctx, generation)) throw new Error("Intercom session ended or request timed out");
|
|
1207
|
+
};
|
|
1208
|
+
void (async () => {
|
|
1209
|
+
if (!["intercom_status", "intercom_list", "intercom_send", "intercom_ask", "intercom_reply", "intercom_pending"].includes(command.type)) throw new Error("Unknown Intercom RPC command");
|
|
1210
|
+
if (signal.aborted) throw new Error("Intercom request ended");
|
|
1211
|
+
const timeoutMs = intercomRpcTimeout(command);
|
|
1212
|
+
if (command.type === "intercom_status") {
|
|
1213
|
+
return { enabled: config.enabled, connected: Boolean(client?.isConnected()), sessionId: currentIntercomSessionId,
|
|
1214
|
+
...(currentSessionId ? { name: buildPresenceIdentity(pi, currentIntercomSessionId ?? currentSessionId).name } : {}) };
|
|
1215
|
+
}
|
|
1216
|
+
if (!config.enabled) throw new Error("Intercom disabled");
|
|
1217
|
+
checkLive();
|
|
1218
|
+
if (command.type === "intercom_pending") return { asks: replyTracker.listPending() };
|
|
1219
|
+
if (command.type !== "intercom_list") {
|
|
1220
|
+
if (typeof command.message !== "string" || !command.message.trim()) throw new Error("message must be a non-empty string");
|
|
1221
|
+
if (command.to !== undefined && (typeof command.to !== "string" || !command.to.trim())) throw new Error("to must be a non-empty string");
|
|
1222
|
+
if (command.type !== "intercom_reply" && !command.to) throw new Error("to is required");
|
|
1223
|
+
if (command.replyTo !== undefined && (typeof command.replyTo !== "string" || !command.replyTo.trim())) throw new Error("replyTo must be a non-empty string");
|
|
1224
|
+
if (!isMessage({ id: "validation", timestamp: Date.now(), replyTo: command.replyTo,
|
|
1225
|
+
content: { text: command.message, attachments: command.attachments } })) throw new Error("Invalid message or attachments");
|
|
1226
|
+
}
|
|
1227
|
+
const activeClient = await ensureConnected("tool");
|
|
1228
|
+
checkLive();
|
|
1229
|
+
if (command.type === "intercom_list") {
|
|
1230
|
+
const sessions = await activeClient.listSessions();
|
|
1231
|
+
checkLive();
|
|
1232
|
+
return { sessionId: activeClient.sessionId!, sessions };
|
|
1233
|
+
}
|
|
1234
|
+
let replyTo = command.replyTo;
|
|
1235
|
+
let to: string;
|
|
1236
|
+
if (command.type === "intercom_reply") {
|
|
1237
|
+
const target = replyTracker.resolveReplyTarget({ to: command.to, replyTo });
|
|
1238
|
+
to = target.from.id;
|
|
1239
|
+
replyTo = target.message.id;
|
|
1240
|
+
} else {
|
|
1241
|
+
to = await resolveSessionTarget(activeClient, command.to) ?? command.to;
|
|
1242
|
+
if (command.type === "intercom_ask" && !(await activeClient.listSessions()).some(peer => peer.id === to)) {
|
|
1243
|
+
throw new Error("Recipient is not currently connected; blocking asks are not queued");
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
checkLive();
|
|
1247
|
+
if (currentSessionTargetMatches(to, to, activeClient)) throw new Error("Cannot message the current session");
|
|
1248
|
+
if (command.type === "intercom_send" && !replyTo) {
|
|
1249
|
+
const mismatch = replyTracker.findActiveReplyTargetMismatch(to);
|
|
1250
|
+
if (mismatch) throw new Error(`Use reply for active ask ${mismatch.message.id}; refusing misdirected send`);
|
|
1251
|
+
replyTo = replyTracker.findUniquePendingAskFrom(to)?.message.id;
|
|
1252
|
+
}
|
|
1253
|
+
if (command.type === "intercom_send" && !command.replyTo && config.confirmSend) {
|
|
1254
|
+
if (!ctx!.hasUI) throw new Error("confirmation_unavailable: confirmSend requires UI");
|
|
1255
|
+
const confirmed = await ctx!.ui.confirm("Send message", `Send to "${command.to}":\n\n${command.message}${command.attachments?.length ? formatAttachments(command.attachments) : ""}`,
|
|
1256
|
+
{ signal, timeout: 30_000 });
|
|
1257
|
+
checkLive();
|
|
1258
|
+
if (!confirmed) throw new Error("Message cancelled by user");
|
|
1259
|
+
}
|
|
1260
|
+
const questionId = randomUUID();
|
|
1261
|
+
let replyPromise: Promise<Message> | undefined;
|
|
1262
|
+
let deliveryState = "created";
|
|
1263
|
+
// Reserve the shared waiter before sending, so a fast reply cannot be lost.
|
|
1264
|
+
if (command.type === "intercom_ask") {
|
|
1265
|
+
if (replyWaiter) throw new Error("Already waiting for a reply");
|
|
1266
|
+
replyPromise = waitForReply(to, questionId, signal, undefined,
|
|
1267
|
+
() => latestDeliveryState(questionId, deliveryState), timeoutMs);
|
|
1268
|
+
replyPromise.catch(() => undefined);
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
checkLive();
|
|
1272
|
+
const result = await activeClient.send(to, { text: command.message, attachments: command.attachments, replyTo,
|
|
1273
|
+
...(replyPromise ? { messageId: questionId, expectsReply: true } : {}) });
|
|
1274
|
+
deliveryState = result.delivery;
|
|
1275
|
+
checkLive();
|
|
1276
|
+
const data: IntercomRpcDelivery = { messageId: result.id, delivered: result.delivered, to, replyTo,
|
|
1277
|
+
delivery: result.delivery, retryable: result.retryable, outcomeKnown: result.outcomeKnown,
|
|
1278
|
+
...(result.code ? { code: result.code } : {}), ...(result.reason ? { reason: result.reason } : {}) };
|
|
1279
|
+
if (!result.delivered) throw new Error(`Message ${result.id} not delivered: ${result.reason ?? result.delivery}`);
|
|
1280
|
+
pi.appendEntry("intercom_sent", { to, message: { text: command.message, attachments: command.attachments, replyTo }, messageId: result.id, timestamp: Date.now() });
|
|
1281
|
+
if (replyTo) dismissIncomingAsk(replyTo);
|
|
1282
|
+
if (!replyPromise) return data;
|
|
1283
|
+
const reply = await replyPromise;
|
|
1284
|
+
checkLive();
|
|
1285
|
+
pi.appendEntry("intercom_received", { from: to, message: reply.content, messageId: reply.id, timestamp: reply.timestamp });
|
|
1286
|
+
return { ...data, reply };
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
// Only the owner of this waiter may reject it; concurrent asks fail without replacing it.
|
|
1289
|
+
if (replyPromise && replyWaiter?.replyTo === questionId) rejectReplyWaiter(toError(error));
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
})().then(data => settle({ requestId, success: true, data }), error => settle({ requestId, success: false, error: getErrorMessage(error) }));
|
|
1293
|
+
}
|
|
1165
1294
|
function shouldTriggerInboundMessage(entry: InboundMessageEntry, forceTrigger = false): boolean {
|
|
1166
1295
|
if (forceTrigger) {
|
|
1167
1296
|
return true;
|
|
@@ -1219,6 +1348,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1219
1348
|
return;
|
|
1220
1349
|
}
|
|
1221
1350
|
const receivedMessage = { ...message, receiverReceivedAt };
|
|
1351
|
+
emitRpcEvent({ type: "intercom_message", from, message: receivedMessage });
|
|
1222
1352
|
emitMessageReceipt(receivedMessage.id, "receiver_received");
|
|
1223
1353
|
if (replyWaiter) {
|
|
1224
1354
|
const senderTarget = from.name || from.id;
|
|
@@ -1277,6 +1407,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1277
1407
|
if (client !== nextClient) return;
|
|
1278
1408
|
switch (message.type) {
|
|
1279
1409
|
case "registered": {
|
|
1410
|
+
emitRpcConnection(true);
|
|
1280
1411
|
const supported = message.features?.includes(EXTENSION_BUS_FEATURE) ?? false;
|
|
1281
1412
|
if (supported && localExtensions.size > 0) {
|
|
1282
1413
|
nextClient.updateExtensionCapabilities(currentExtensionCapabilities());
|
|
@@ -1323,7 +1454,12 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1323
1454
|
...(message.reason ? { reason: message.reason } : {}),
|
|
1324
1455
|
});
|
|
1325
1456
|
break;
|
|
1457
|
+
case "delivered":
|
|
1458
|
+
case "delivery_failed":
|
|
1459
|
+
emitRpcEvent({ type: "intercom_delivery", result: message });
|
|
1460
|
+
break;
|
|
1326
1461
|
case "message_receipt":
|
|
1462
|
+
emitRpcEvent({ type: "intercom_receipt", from: message.from, receipt: message.receipt });
|
|
1327
1463
|
latestOutboundReceipts.set(message.receipt.messageId, {
|
|
1328
1464
|
status: message.receipt.status,
|
|
1329
1465
|
timestamp: message.receipt.timestamp,
|
|
@@ -1334,16 +1470,19 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1334
1470
|
handleMessageControl(message.control);
|
|
1335
1471
|
break;
|
|
1336
1472
|
case "session_joined":
|
|
1473
|
+
emitRpcEvent({ type: "intercom_session_joined", session: message.session });
|
|
1337
1474
|
for (const namespace of localExtensions.keys()) {
|
|
1338
1475
|
emitLocalExtensionEvent(namespace, { type: "session_joined", session: message.session });
|
|
1339
1476
|
}
|
|
1340
1477
|
break;
|
|
1341
1478
|
case "session_left":
|
|
1479
|
+
emitRpcEvent({ type: "intercom_session_left", sessionId: message.sessionId });
|
|
1342
1480
|
for (const namespace of localExtensions.keys()) {
|
|
1343
1481
|
emitLocalExtensionEvent(namespace, { type: "session_left", sessionId: message.sessionId });
|
|
1344
1482
|
}
|
|
1345
1483
|
break;
|
|
1346
1484
|
case "presence_update":
|
|
1485
|
+
emitRpcEvent({ type: "intercom_presence", session: message.session });
|
|
1347
1486
|
for (const namespace of localExtensions.keys()) {
|
|
1348
1487
|
emitLocalExtensionEvent(namespace, { type: "presence_update", session: message.session });
|
|
1349
1488
|
}
|
|
@@ -1361,6 +1500,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1361
1500
|
if (client !== nextClient) {
|
|
1362
1501
|
return;
|
|
1363
1502
|
}
|
|
1503
|
+
emitRpcConnection(false, error.message);
|
|
1504
|
+
endRpcRequests();
|
|
1364
1505
|
rejectReplyWaiter(new Error(`Disconnected while waiting for reply: ${error.message}`, { cause: error }));
|
|
1365
1506
|
for (const [namespace, extension] of localExtensions) {
|
|
1366
1507
|
extension.owner = undefined;
|
|
@@ -1433,6 +1574,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1433
1574
|
if (reason === "background" && getLiveContext(contextAtStart, generationAtStart)) {
|
|
1434
1575
|
scheduleReconnect();
|
|
1435
1576
|
}
|
|
1577
|
+
if (getLiveContext(contextAtStart, generationAtStart)) emitRpcConnection(false, getErrorMessage(error));
|
|
1436
1578
|
throw toError(error);
|
|
1437
1579
|
} finally {
|
|
1438
1580
|
if (reconnectPromise === nextReconnectPromise) {
|
|
@@ -1556,6 +1698,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1556
1698
|
}
|
|
1557
1699
|
function startSessionRuntime(ctx: ExtensionContext): void {
|
|
1558
1700
|
const previousClient = client;
|
|
1701
|
+
endRpcRequests();
|
|
1559
1702
|
failPendingOutboxRequests(runtimeGeneration, "session_ended", "Session replaced");
|
|
1560
1703
|
shuttingDown = false;
|
|
1561
1704
|
disposed = false;
|
|
@@ -1576,6 +1719,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1576
1719
|
currentSessionId = ctx.sessionManager.getSessionId();
|
|
1577
1720
|
currentIntercomSessionId = resolveConfiguredIntercomSessionId(currentSessionId, config);
|
|
1578
1721
|
publishIntercomSessionId(currentIntercomSessionId);
|
|
1722
|
+
emitRpcConnection(false, "Session started");
|
|
1579
1723
|
currentModel = ctx.model?.id ?? "unknown";
|
|
1580
1724
|
sessionStartedAt = Date.now();
|
|
1581
1725
|
const initialPresenceIdentity = buildPresenceIdentity(pi, currentIntercomSessionId);
|
|
@@ -1695,9 +1839,40 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1695
1839
|
acknowledge: true,
|
|
1696
1840
|
});
|
|
1697
1841
|
});
|
|
1842
|
+
const unsubscribeRpcRequest = pi.events.on(INTERCOM_RPC_REQUEST_EVENT, handleRpcRequest);
|
|
1843
|
+
// Release readiness (release:readiness:v1): answer only for the session this extension is bound to.
|
|
1844
|
+
const unsubscribeReleaseReadiness = pi.events.on(RELEASE_READINESS_EVENT, (payload) => {
|
|
1845
|
+
answerReleaseReadiness(payload, "intercom", currentSessionId, () => intercomReleaseBlockers({
|
|
1846
|
+
pendingAsks: replyTracker.listPending(),
|
|
1847
|
+
outboundAsk: replyWaiter,
|
|
1848
|
+
pendingOutboxRequests: pendingOutboxRequests.size,
|
|
1849
|
+
}));
|
|
1850
|
+
});
|
|
1851
|
+
// Kill switch (stop_all_background): cancel this session's blocking outbound ask; inbound asks cannot be cancelled by the receiver.
|
|
1852
|
+
const unsubscribeStopAll = pi.events.on(STOP_ALL_BACKGROUND_EVENT, (payload) => {
|
|
1853
|
+
answerStopAllBackground(payload, "intercom", currentSessionId, (request) => stopIntercom({
|
|
1854
|
+
pendingAsks: replyTracker.listPending(),
|
|
1855
|
+
outboundAsk: replyWaiter,
|
|
1856
|
+
cancelOutboundAsk: (ask) => {
|
|
1857
|
+
const activeClient = client;
|
|
1858
|
+
// Broker first (drops the ask edge + pending-ask record), then free the blocked tool call.
|
|
1859
|
+
activeClient?.cancelAsk(ask.replyTo);
|
|
1860
|
+
rejectReplyWaiter(new Error("Cancelled: session background work was stopped"));
|
|
1861
|
+
// Also ask the recipient to drop the ask. Best-effort: it may already be injected or processed.
|
|
1862
|
+
if (!activeClient?.isConnected()) return undefined;
|
|
1863
|
+
return activeClient.cancelMessage(ask.replyTo).then(
|
|
1864
|
+
(result) => result.delivered ? "recipient asked to drop it" : "recipient could not be asked to drop it (it may already be handled)",
|
|
1865
|
+
() => "recipient could not be asked to drop it (it may already be handled)",
|
|
1866
|
+
);
|
|
1867
|
+
},
|
|
1868
|
+
}, request));
|
|
1869
|
+
});
|
|
1698
1870
|
const unsubscribeOutboxRequest = pi.events.on(INTERCOM_OUTBOX_REQUEST_EVENT, handleOutboxRequest);
|
|
1699
1871
|
pi.on("session_start", (_event, ctx) => {
|
|
1700
1872
|
if (!config.enabled) {
|
|
1873
|
+
runtimeContext = ctx;
|
|
1874
|
+
currentSessionId = ctx.sessionManager.getSessionId();
|
|
1875
|
+
emitRpcConnection(false, "Intercom disabled");
|
|
1701
1876
|
return;
|
|
1702
1877
|
}
|
|
1703
1878
|
startSessionRuntime(ctx);
|
|
@@ -1708,6 +1883,11 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1708
1883
|
unsubscribeSubagentControlIntercom();
|
|
1709
1884
|
unsubscribeSubagentResultIntercom();
|
|
1710
1885
|
unsubscribeOutboxRequest();
|
|
1886
|
+
unsubscribeRpcRequest();
|
|
1887
|
+
unsubscribeReleaseReadiness();
|
|
1888
|
+
unsubscribeStopAll();
|
|
1889
|
+
endRpcRequests();
|
|
1890
|
+
emitRpcConnection(false, "Session shutting down");
|
|
1711
1891
|
shuttingDown = true;
|
|
1712
1892
|
disposed = true;
|
|
1713
1893
|
failPendingOutboxRequests(runtimeGeneration, "session_ended", "Session shutting down");
|
|
@@ -1976,7 +2156,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1976
2156
|
replyPromise = waitForReply(sendTo, questionId, signal, () => connectedClient.cancelAsk(questionId!), () => latestDeliveryState(questionId, deliveryState));
|
|
1977
2157
|
replyPromise.catch(() => undefined);
|
|
1978
2158
|
if (signal?.aborted) {
|
|
1979
|
-
rejectReplyWaiter(new Error("Cancelled"));
|
|
2159
|
+
if (replyWaiter?.replyTo === questionId) rejectReplyWaiter(new Error("Cancelled"));
|
|
1980
2160
|
try {
|
|
1981
2161
|
await replyPromise;
|
|
1982
2162
|
} catch {
|
|
@@ -1998,7 +2178,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
1998
2178
|
deliveryState = sendResult.delivered ? "socket_delivered" : "delivery_failed";
|
|
1999
2179
|
if (!sendResult.delivered) {
|
|
2000
2180
|
const errorText = sendResult.reason ?? "Session may not exist or has disconnected.";
|
|
2001
|
-
rejectReplyWaiter(new Error(`Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}`));
|
|
2181
|
+
if (replyWaiter?.replyTo === questionId) rejectReplyWaiter(new Error(`Message to "${metadata.orchestratorTarget}" was not delivered: ${errorText}`));
|
|
2002
2182
|
if (replyPromise) {
|
|
2003
2183
|
try {
|
|
2004
2184
|
await replyPromise;
|
|
@@ -2044,7 +2224,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
|
|
|
2044
2224
|
: {},
|
|
2045
2225
|
};
|
|
2046
2226
|
} catch (error) {
|
|
2047
|
-
rejectReplyWaiter(toError(error));
|
|
2227
|
+
if (questionId && replyWaiter?.replyTo === questionId) rejectReplyWaiter(toError(error));
|
|
2048
2228
|
if (replyPromise) {
|
|
2049
2229
|
try {
|
|
2050
2230
|
await replyPromise;
|
|
@@ -2478,7 +2658,7 @@ Usage:
|
|
|
2478
2658
|
deliveryState = sendResult.delivery;
|
|
2479
2659
|
if (!sendResult.delivered) {
|
|
2480
2660
|
const errorText = sendResult.reason ?? "Session may not exist or has disconnected.";
|
|
2481
|
-
rejectReplyWaiter(new Error(`Message to "${targetDisplay}" was not delivered: ${errorText}`));
|
|
2661
|
+
if (replyWaiter?.replyTo === questionId) rejectReplyWaiter(new Error(`Message to "${targetDisplay}" was not delivered: ${errorText}`));
|
|
2482
2662
|
if (replyPromise) {
|
|
2483
2663
|
try {
|
|
2484
2664
|
await replyPromise;
|
|
@@ -2513,7 +2693,7 @@ Usage:
|
|
|
2513
2693
|
details: target.projectPane ? { openedProjectPane: true, paneId: target.projectPane.paneId, projectRoot: target.projectPane.projectRoot } : {},
|
|
2514
2694
|
};
|
|
2515
2695
|
} catch (error) {
|
|
2516
|
-
rejectReplyWaiter(toError(error));
|
|
2696
|
+
if (questionId && replyWaiter?.replyTo === questionId) rejectReplyWaiter(toError(error));
|
|
2517
2697
|
if (replyPromise) {
|
|
2518
2698
|
try {
|
|
2519
2699
|
await replyPromise;
|
|
@@ -236,6 +236,7 @@ function createExtensionHarness(sessionName: string | (() => string) = "child-wo
|
|
|
236
236
|
entries,
|
|
237
237
|
sentMessages,
|
|
238
238
|
getActiveTools: () => pi.getActiveTools(),
|
|
239
|
+
clearLifecycleHandlers: () => lifecycleHandlers.clear(),
|
|
239
240
|
async emitLifecycle(event: string, payload: unknown = {}, eventContext: unknown = ctx) {
|
|
240
241
|
for (const handler of lifecycleHandlers.get(event) ?? []) {
|
|
241
242
|
await handler(payload, eventContext);
|
|
@@ -4349,3 +4350,282 @@ test("failed delivery from an inferred reply preserves the pending ask", { concu
|
|
|
4349
4350
|
await cleanup();
|
|
4350
4351
|
}
|
|
4351
4352
|
});
|
|
4353
|
+
|
|
4354
|
+
test("structured RPC uses the shared runtime for commands, fast replies, receipts, presence and idle/busy incoming messages", { concurrency: false }, async () => {
|
|
4355
|
+
const { RpcIntercomBridge } = await import("../../modes/rpc/intercom-bridge.ts");
|
|
4356
|
+
const { INTERCOM_RPC_EVENT } = await import("./extension-api.ts");
|
|
4357
|
+
const { default: piIntercomExtension } = await import("./index.ts");
|
|
4358
|
+
const { planner, orchestrator, cleanup } = await setupClients();
|
|
4359
|
+
let idle = true;
|
|
4360
|
+
const harness = createExtensionHarness("rpc-worker", { mode: "rpc", hasUI: true, isIdle: () => idle });
|
|
4361
|
+
const events: Array<{ type: string; [key: string]: unknown }> = [];
|
|
4362
|
+
const bridge = new RpcIntercomBridge(harness.pi.events, event => events.push(event as never), harness.ctx.sessionManager.getSessionId());
|
|
4363
|
+
try {
|
|
4364
|
+
await withIntercomConfig({ inboundTrigger: "always" }, async () => {
|
|
4365
|
+
piIntercomExtension(harness.pi as never);
|
|
4366
|
+
await harness.emitLifecycle("session_start");
|
|
4367
|
+
const self = await waitForSessionByName(planner, "rpc-worker");
|
|
4368
|
+
assert.equal((await bridge.request({ type: "intercom_status" })).success, true);
|
|
4369
|
+
const unknown = await bridge.request({ type: "intercom_arbitrary", to: "planner", message: "must not send" } as never);
|
|
4370
|
+
assert.ok(!unknown.success);
|
|
4371
|
+
assert.match(unknown.error, /Unknown/);
|
|
4372
|
+
const list = await bridge.request({ type: "intercom_list" });
|
|
4373
|
+
assert.ok(list.success && "data" in list && "sessions" in list.data);
|
|
4374
|
+
assert.equal(list.data.sessions.filter(peer => peer.name === "rpc-worker").length, 1);
|
|
4375
|
+
assert.ok(events.some(event => event.type === "intercom_connection" && event.connected === true));
|
|
4376
|
+
const sent = await bridge.request({ type: "intercom_send", to: planner.sessionId!, message: "Desktop update" });
|
|
4377
|
+
assert.ok(sent.success && "data" in sent && "messageId" in sent.data);
|
|
4378
|
+
planner.sendMessageReceipt({ messageId: sent.data.messageId, status: "receiver_received", timestamp: Date.now() });
|
|
4379
|
+
assert.equal((await bridge.request({ type: "intercom_send", to: self.id, message: "self" })).success, false);
|
|
4380
|
+
assert.equal((await bridge.request({ type: "intercom_ask", to: "missing-peer", message: "?", timeoutMs: 100 })).success, false);
|
|
4381
|
+
assert.equal((await bridge.request({ type: "intercom_send", to: planner.sessionId!, message: "", attachments: [] })).success, false);
|
|
4382
|
+
assert.equal((await bridge.request({ type: "intercom_send", to: planner.sessionId!, message: "bad", attachments: [{ type: "invalid", name: "x", content: "x" }] as never })).success, false);
|
|
4383
|
+
const answer = (from: SessionInfo, message: Message) => {
|
|
4384
|
+
if (message.expectsReply && message.content.text === "Fast question") void planner.send(from.id, { text: "Fast answer", replyTo: message.id });
|
|
4385
|
+
};
|
|
4386
|
+
planner.on("message", answer);
|
|
4387
|
+
const asked = await bridge.request({ id: "ask-correlation", type: "intercom_ask", to: planner.sessionId!, message: "Fast question", timeoutMs: 2000 });
|
|
4388
|
+
planner.off("message", answer);
|
|
4389
|
+
assert.ok(asked.success && "data" in asked && "reply" in asked.data);
|
|
4390
|
+
assert.equal(asked.id, "ask-correlation");
|
|
4391
|
+
assert.equal(asked.data.reply.replyTo, asked.data.messageId);
|
|
4392
|
+
assert.equal(asked.data.reply.content.text, "Fast answer");
|
|
4393
|
+
const askId = "rpc-inbound-ask";
|
|
4394
|
+
await planner.send(self.id, { messageId: askId, text: "Inbound", expectsReply: true });
|
|
4395
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
4396
|
+
assert.ok(harness.sentMessages.some(item => item.options?.triggerTurn));
|
|
4397
|
+
const pending = await bridge.request({ type: "intercom_pending" });
|
|
4398
|
+
assert.ok(pending.success && "data" in pending && "asks" in pending.data);
|
|
4399
|
+
assert.equal(pending.data.asks[0]?.message.id, askId);
|
|
4400
|
+
const replyReceived = waitForReply(planner, askId);
|
|
4401
|
+
assert.equal((await bridge.request({ type: "intercom_reply", message: "Desktop answer", replyTo: askId })).success, true);
|
|
4402
|
+
assert.equal((await replyReceived).message.content.text, "Desktop answer");
|
|
4403
|
+
idle = false;
|
|
4404
|
+
const messagesBefore = harness.sentMessages.length;
|
|
4405
|
+
await orchestrator.send(self.id, { text: "Busy message" });
|
|
4406
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
4407
|
+
assert.equal(harness.sentMessages.length, messagesBefore + 1);
|
|
4408
|
+
assert.equal(harness.sentMessages.at(-1)?.options?.deliverAs, "steer");
|
|
4409
|
+
orchestrator.updatePresence({ status: "thinking" });
|
|
4410
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
4411
|
+
assert.ok(events.some(event => event.type === "intercom_message"));
|
|
4412
|
+
assert.ok(events.some(event => event.type === "intercom_receipt"));
|
|
4413
|
+
assert.ok(events.some(event => event.type === "intercom_delivery"));
|
|
4414
|
+
assert.ok(events.some(event => event.type === "intercom_presence"));
|
|
4415
|
+
await orchestrator.disconnect();
|
|
4416
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
4417
|
+
assert.ok(events.some(event => event.type === "intercom_session_left"));
|
|
4418
|
+
// Duplicate broker messages must not create a second structured incoming event.
|
|
4419
|
+
const incomingCount = events.filter(event => event.type === "intercom_message").length;
|
|
4420
|
+
await planner.send(self.id, { messageId: askId, text: "Inbound", expectsReply: true });
|
|
4421
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
4422
|
+
assert.equal(events.filter(event => event.type === "intercom_message").length, incomingCount);
|
|
4423
|
+
const timedOut = await bridge.request({ type: "intercom_ask", to: planner.sessionId!, message: "Unanswered", timeoutMs: 30 });
|
|
4424
|
+
assert.ok(!timedOut.success);
|
|
4425
|
+
assert.match(timedOut.error, /not cancellation/);
|
|
4426
|
+
const blocked = bridge.request({ type: "intercom_ask", to: planner.sessionId!, message: "Pending at shutdown", timeoutMs: 1000 });
|
|
4427
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
4428
|
+
const competing = await bridge.request({ type: "intercom_ask", to: planner.sessionId!, message: "Competing ask", timeoutMs: 1000 });
|
|
4429
|
+
assert.ok(!competing.success);
|
|
4430
|
+
assert.match(competing.error, /Already waiting/);
|
|
4431
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4432
|
+
assert.equal((await blocked).success, false);
|
|
4433
|
+
assert.equal((await bridge.request({ type: "intercom_list" })).success, false);
|
|
4434
|
+
assert.ok(events.some(event => event.type === "intercom_connection" && event.reason === "Session shutting down"));
|
|
4435
|
+
// Reload installs exactly one listener on the same bus and stale results are ignored.
|
|
4436
|
+
harness.clearLifecycleHandlers();
|
|
4437
|
+
piIntercomExtension(harness.pi as never);
|
|
4438
|
+
await harness.emitLifecycle("session_start");
|
|
4439
|
+
assert.equal((await bridge.request({ type: "intercom_list" })).success, true);
|
|
4440
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4441
|
+
harness.pi.events.emit(INTERCOM_RPC_EVENT, { type: "intercom_connection", enabled: true, connected: false, sessionId: null });
|
|
4442
|
+
});
|
|
4443
|
+
} finally {
|
|
4444
|
+
bridge.dispose();
|
|
4445
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4446
|
+
await cleanup();
|
|
4447
|
+
}
|
|
4448
|
+
});
|
|
4449
|
+
|
|
4450
|
+
test("stop_all_background cancels the blocking outbound ask and reports inbound asks it cannot cancel", { concurrency: false }, async () => {
|
|
4451
|
+
const { RpcIntercomBridge } = await import("../../modes/rpc/intercom-bridge.ts");
|
|
4452
|
+
const { RpcReleaseBridge } = await import("../../modes/rpc/release-bridge.ts");
|
|
4453
|
+
const { default: piIntercomExtension } = await import("./index.ts");
|
|
4454
|
+
const { planner, orchestrator, cleanup } = await setupClients();
|
|
4455
|
+
const harness = createExtensionHarness("stop-all-worker", { mode: "rpc", hasUI: true, isIdle: () => true });
|
|
4456
|
+
const sessionId = harness.ctx.sessionManager.getSessionId();
|
|
4457
|
+
const intercomBridge = new RpcIntercomBridge(harness.pi.events, () => undefined, sessionId);
|
|
4458
|
+
const releaseBridge = new RpcReleaseBridge(harness.pi.events, { version: 1, sessionId, epoch: "e1" });
|
|
4459
|
+
const stopCommand = { type: "stop_all_background" as const, version: 1 as const };
|
|
4460
|
+
try {
|
|
4461
|
+
await withIntercomConfig({ inboundTrigger: "always" }, async () => {
|
|
4462
|
+
piIntercomExtension(harness.pi as never);
|
|
4463
|
+
await harness.emitLifecycle("session_start");
|
|
4464
|
+
const self = await waitForSessionByName(planner, "stop-all-worker");
|
|
4465
|
+
// Idle: nothing to stop, and the intercom contributor answered.
|
|
4466
|
+
const idle = await releaseBridge.stopAll(stopCommand, () => [], () => []);
|
|
4467
|
+
assert.ok(idle.success && idle.command === "stop_all_background");
|
|
4468
|
+
assert.deepEqual(idle.data.stopped, []);
|
|
4469
|
+
|
|
4470
|
+
await planner.send(self.id, { messageId: "stop-all-inbound", text: "Please answer", expectsReply: true });
|
|
4471
|
+
// A different peer than the inbound asker: the broker refuses mutual asks.
|
|
4472
|
+
const blocked = intercomBridge.request({ type: "intercom_ask", to: orchestrator.sessionId!, message: "Pending ask", timeoutMs: 20_000 });
|
|
4473
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
4474
|
+
const reply = await releaseBridge.stopAll(stopCommand, () => [], () => []);
|
|
4475
|
+
assert.ok(reply.success && reply.command === "stop_all_background");
|
|
4476
|
+
const intercomItems = reply.data.stopped.filter(item => item.source === "intercom");
|
|
4477
|
+
const outbound = intercomItems.find(item => item.kind === "outbound_ask");
|
|
4478
|
+
assert.ok(outbound, JSON.stringify(reply.data.stopped));
|
|
4479
|
+
assert.equal(outbound.ok, true);
|
|
4480
|
+
assert.match(outbound.detail, /^Cancelled blocking ask to /);
|
|
4481
|
+
assert.deepEqual(intercomItems.filter(item => item.kind === "inbound_ask").map(item => [item.id, item.ok, item.error]), [["stop-all-inbound", false, "cannot cancel inbound"]]);
|
|
4482
|
+
// The blocked tool call returned with the cancellation instead of waiting for its 20s timeout.
|
|
4483
|
+
const settled = await blocked;
|
|
4484
|
+
assert.equal(settled.success, false);
|
|
4485
|
+
assert.match((settled as { error: string }).error, /Cancelled: session background work was stopped/);
|
|
4486
|
+
// Readiness afterwards has no outbound ask; the inbound ask is still ours to answer.
|
|
4487
|
+
assert.deepEqual(reply.data.remaining.blockers.filter(b => b.source === "intercom").map(b => b.kind), ["inbound_ask"]);
|
|
4488
|
+
// Idempotent: only the inbound ask is reported the second time.
|
|
4489
|
+
const again = await releaseBridge.stopAll(stopCommand, () => [], () => []);
|
|
4490
|
+
assert.ok(again.success && again.command === "stop_all_background");
|
|
4491
|
+
assert.deepEqual(again.data.stopped.map(item => [item.kind, item.ok]), [["inbound_ask", false]]);
|
|
4492
|
+
});
|
|
4493
|
+
} finally {
|
|
4494
|
+
intercomBridge.dispose();
|
|
4495
|
+
releaseBridge.dispose();
|
|
4496
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4497
|
+
await cleanup();
|
|
4498
|
+
}
|
|
4499
|
+
});
|
|
4500
|
+
|
|
4501
|
+
test("structured RPC fails closed for disabled runtime, missing backend, and rejected/unavailable confirmation", { concurrency: false }, async () => {
|
|
4502
|
+
const { RpcIntercomBridge } = await import("../../modes/rpc/intercom-bridge.ts");
|
|
4503
|
+
const { default: piIntercomExtension } = await import("./index.ts");
|
|
4504
|
+
const { planner, cleanup } = await setupClients();
|
|
4505
|
+
try {
|
|
4506
|
+
for (const options of [
|
|
4507
|
+
{ config: { enabled: false }, hasUI: true },
|
|
4508
|
+
{ config: { confirmSend: true }, hasUI: false },
|
|
4509
|
+
{ config: { confirmSend: true }, hasUI: true },
|
|
4510
|
+
]) {
|
|
4511
|
+
const harness = createExtensionHarness("rpc-consent", { mode: "rpc", hasUI: options.hasUI, ui: { confirm: async () => false } });
|
|
4512
|
+
const bridge = new RpcIntercomBridge(harness.pi.events, () => {}, harness.ctx.sessionManager.getSessionId());
|
|
4513
|
+
await withIntercomConfig(options.config, async () => {
|
|
4514
|
+
piIntercomExtension(harness.pi as never);
|
|
4515
|
+
await harness.emitLifecycle("session_start");
|
|
4516
|
+
const sent = await bridge.request({ type: "intercom_send", to: planner.sessionId!, message: "Must not send" });
|
|
4517
|
+
assert.ok(!sent.success);
|
|
4518
|
+
assert.match(sent.error, /disabled|confirmation_unavailable|cancelled/);
|
|
4519
|
+
if (options.config.enabled === false) {
|
|
4520
|
+
const status = await bridge.request({ type: "intercom_status" });
|
|
4521
|
+
assert.ok(status.success && "data" in status && "enabled" in status.data);
|
|
4522
|
+
assert.equal(status.data.enabled, false);
|
|
4523
|
+
}
|
|
4524
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4525
|
+
const missing = await bridge.request({ type: "intercom_list" });
|
|
4526
|
+
assert.ok(!missing.success);
|
|
4527
|
+
assert.match(missing.error, /unavailable/);
|
|
4528
|
+
});
|
|
4529
|
+
bridge.dispose();
|
|
4530
|
+
}
|
|
4531
|
+
} finally { await cleanup(); }
|
|
4532
|
+
});
|
|
4533
|
+
|
|
4534
|
+
test("structured RPC aborts a pending confirmation on disconnect and can reconnect", { concurrency: false }, async () => {
|
|
4535
|
+
const { RpcIntercomBridge } = await import("../../modes/rpc/intercom-bridge.ts");
|
|
4536
|
+
const { default: piIntercomExtension } = await import("./index.ts");
|
|
4537
|
+
const { planner, cleanup } = await setupClients();
|
|
4538
|
+
let confirmSignal: AbortSignal | undefined;
|
|
4539
|
+
let signalAborted = false;
|
|
4540
|
+
let enteredConfirm!: () => void;
|
|
4541
|
+
const confirming = new Promise<void>(resolve => { enteredConfirm = resolve; });
|
|
4542
|
+
const harness = createExtensionHarness("rpc-disconnect-confirm", { mode: "rpc", hasUI: true,
|
|
4543
|
+
ui: { confirm: (_title: string, _message: string, options: { signal: AbortSignal }) => new Promise<boolean>(resolve => {
|
|
4544
|
+
confirmSignal = options.signal;
|
|
4545
|
+
options.signal.addEventListener("abort", () => { signalAborted = true; resolve(false); }, { once: true });
|
|
4546
|
+
enteredConfirm();
|
|
4547
|
+
}) } });
|
|
4548
|
+
const bridge = new RpcIntercomBridge(harness.pi.events, () => {}, harness.ctx.sessionManager.getSessionId());
|
|
4549
|
+
const originalList = IntercomClient.prototype.listSessions;
|
|
4550
|
+
let extensionClient: InstanceType<typeof IntercomClient> | undefined;
|
|
4551
|
+
try {
|
|
4552
|
+
await withIntercomConfig({ confirmSend: true }, async () => {
|
|
4553
|
+
piIntercomExtension(harness.pi as never);
|
|
4554
|
+
await harness.emitLifecycle("session_start");
|
|
4555
|
+
IntercomClient.prototype.listSessions = function (options) {
|
|
4556
|
+
extensionClient = this;
|
|
4557
|
+
return originalList.call(this, options);
|
|
4558
|
+
};
|
|
4559
|
+
assert.equal((await bridge.request({ type: "intercom_list" })).success, true);
|
|
4560
|
+
IntercomClient.prototype.listSessions = originalList;
|
|
4561
|
+
const sent = bridge.request({ type: "intercom_send", to: planner.sessionId!, message: "Do not send after disconnect" });
|
|
4562
|
+
await confirming;
|
|
4563
|
+
assert.ok(confirmSignal && !confirmSignal.aborted);
|
|
4564
|
+
// Destroy the live transport without graceful unregister so the real disconnect handler runs.
|
|
4565
|
+
(extensionClient as unknown as { socket: { destroy(): void } }).socket.destroy();
|
|
4566
|
+
const response = await Promise.race([sent, new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Disconnect did not settle confirmation promptly")), 1000).unref())]);
|
|
4567
|
+
assert.ok(!response.success);
|
|
4568
|
+
assert.match(response.error, /session ended/);
|
|
4569
|
+
assert.equal(signalAborted, true);
|
|
4570
|
+
assert.equal((await bridge.request({ type: "intercom_list" })).success, true);
|
|
4571
|
+
assert.equal(harness.entries.filter(entry => entry.type === "intercom_sent").length, 0);
|
|
4572
|
+
});
|
|
4573
|
+
} finally {
|
|
4574
|
+
IntercomClient.prototype.listSessions = originalList;
|
|
4575
|
+
bridge.dispose();
|
|
4576
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4577
|
+
await cleanup();
|
|
4578
|
+
}
|
|
4579
|
+
});
|
|
4580
|
+
|
|
4581
|
+
test("structured RPC ask survives a concurrent model ask target-resolution failure", { concurrency: false }, async () => {
|
|
4582
|
+
const { RpcIntercomBridge } = await import("../../modes/rpc/intercom-bridge.ts");
|
|
4583
|
+
const { default: piIntercomExtension } = await import("./index.ts");
|
|
4584
|
+
const { planner, cleanup } = await setupClients();
|
|
4585
|
+
const harness = createExtensionHarness("rpc-model-waiter-owner", { mode: "rpc", hasUI: true });
|
|
4586
|
+
const bridge = new RpcIntercomBridge(harness.pi.events, () => {}, harness.ctx.sessionManager.getSessionId());
|
|
4587
|
+
const originalList = IntercomClient.prototype.listSessions;
|
|
4588
|
+
let releaseResolution!: () => void;
|
|
4589
|
+
let startedResolution!: () => void;
|
|
4590
|
+
const resolving = new Promise<void>(resolve => { startedResolution = resolve; });
|
|
4591
|
+
const resolutionGate = new Promise<void>(resolve => { releaseResolution = resolve; });
|
|
4592
|
+
try {
|
|
4593
|
+
piIntercomExtension(harness.pi as never);
|
|
4594
|
+
await harness.emitLifecycle("session_start");
|
|
4595
|
+
assert.equal((await bridge.request({ type: "intercom_list" })).success, true);
|
|
4596
|
+
IntercomClient.prototype.listSessions = async function () {
|
|
4597
|
+
startedResolution();
|
|
4598
|
+
await resolutionGate;
|
|
4599
|
+
throw new Error("Injected model target-resolution failure");
|
|
4600
|
+
};
|
|
4601
|
+
const tool = harness.tools.find(tool => tool.name === "intercom")!;
|
|
4602
|
+
const modelAsk = tool.execute("model-resolution-race", { action: "ask", to: "planner", message: "Model question" }, new AbortController().signal, undefined, harness.ctx);
|
|
4603
|
+
await resolving;
|
|
4604
|
+
IntercomClient.prototype.listSessions = originalList;
|
|
4605
|
+
const rpcQuestion = new Promise<{ from: SessionInfo; message: Message }>(resolve => {
|
|
4606
|
+
const handler = (from: SessionInfo, message: Message) => {
|
|
4607
|
+
if (message.content.text !== "RPC question owns waiter") return;
|
|
4608
|
+
planner.off("message", handler);
|
|
4609
|
+
resolve({ from, message });
|
|
4610
|
+
};
|
|
4611
|
+
planner.on("message", handler);
|
|
4612
|
+
});
|
|
4613
|
+
const rpcAsk = bridge.request({ type: "intercom_ask", to: planner.sessionId!, message: "RPC question owns waiter", timeoutMs: 3000 });
|
|
4614
|
+
const question = await rpcQuestion;
|
|
4615
|
+
releaseResolution();
|
|
4616
|
+
const modelResult = await modelAsk;
|
|
4617
|
+
assert.equal(modelResult.details?.error, true);
|
|
4618
|
+
assert.match(modelResult.content[0].text, /target-resolution failure/);
|
|
4619
|
+
await planner.send(question.from.id, { text: "RPC waiter was preserved", replyTo: question.message.id });
|
|
4620
|
+
const rpcResult = await rpcAsk;
|
|
4621
|
+
assert.ok(rpcResult.success && "data" in rpcResult && "reply" in rpcResult.data);
|
|
4622
|
+
assert.equal(rpcResult.data.reply.content.text, "RPC waiter was preserved");
|
|
4623
|
+
assert.equal(rpcResult.data.reply.replyTo, rpcResult.data.messageId);
|
|
4624
|
+
} finally {
|
|
4625
|
+
releaseResolution?.();
|
|
4626
|
+
IntercomClient.prototype.listSessions = originalList;
|
|
4627
|
+
bridge.dispose();
|
|
4628
|
+
await harness.emitLifecycle("session_shutdown");
|
|
4629
|
+
await cleanup();
|
|
4630
|
+
}
|
|
4631
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"skills/**/*"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "tsx --test broker/client.test.ts broker/client-liveness.test.ts broker/extension.test.ts broker/framing.test.ts broker/paths.test.ts broker/runtime-claim.test.ts broker/spawn.test.ts config.test.ts cwd.test.ts format-context.test.ts project-agent.test.ts reply-tracker.test.ts intercom.integration.test.ts test/inline-message.test.ts test/overlay-width.test.ts"
|
|
21
|
+
"test": "tsx --test broker/client.test.ts broker/client-liveness.test.ts broker/extension.test.ts broker/framing.test.ts broker/mailbox-persistence.test.ts broker/paths.test.ts broker/runtime-claim.test.ts broker/spawn.test.ts config.test.ts cwd.test.ts format-context.test.ts project-agent.test.ts reply-tracker.test.ts intercom.integration.test.ts test/inline-message.test.ts test/overlay-width.test.ts"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
24
24
|
"pi-package"
|