switchroom 0.13.52 → 0.13.54
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/agent-scheduler/index.js +399 -213
- package/dist/auth-broker/index.js +576 -237
- package/dist/cli/drive-write-pretool.mjs +28 -13
- package/dist/cli/ms-365-write-pretool.mjs +259 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3241 -1382
- package/dist/host-control/main.js +396 -276
- package/dist/vault/approvals/kernel-server.js +8266 -8142
- package/dist/vault/broker/server.js +2894 -2770
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -0
- package/profiles/_shared/telegram-style.md.hbs +2 -0
- package/skills/switchroom-status/SKILL.md +8 -6
- package/telegram-plugin/chat-lock.ts +87 -19
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1283 -343
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +485 -72
- package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
- package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
- package/telegram-plugin/gateway/ipc-server.ts +59 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
- package/telegram-plugin/stream-reply-handler.ts +10 -8
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
- package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
- package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
- package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
- package/telegram-plugin/typing-wrap.ts +43 -21
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
- package/vendor/hindsight-memory/scripts/recall.py +164 -4
- package/vendor/hindsight-memory/scripts/retain.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
- package/profiles/default/CLAUDE.md +0 -122
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft 365 write approval handler — RFC #1873 §8 PR 4.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `drive-write-approval.ts` shape but with the weak-metadata
|
|
5
|
+
* v1 preview (file path / item ID / size delta / deep link / agent
|
|
6
|
+
* rationale) instead of Google's full DiffPreviewInput.
|
|
7
|
+
*
|
|
8
|
+
* Flow (called by the gateway's IPC dispatcher when the
|
|
9
|
+
* `ms-365-write-pretool` hook sends `request_ms365_approval`):
|
|
10
|
+
* 1. Validate the inbound preview payload (`validateMs365Preview`).
|
|
11
|
+
* 2. Verify the request is for this gateway's agent (cross-agent
|
|
12
|
+
* requests rejected — defense in depth).
|
|
13
|
+
* 3. Register a kernel approval at scope `ms-365:write:<itemId>`,
|
|
14
|
+
* action `write`, approver_set = operator allowFrom.
|
|
15
|
+
* 4. Build a plain-text Telegram card showing: tool, target file/
|
|
16
|
+
* item, size delta, deep link, agent's rationale.
|
|
17
|
+
* 5. Post the card to operator chat.
|
|
18
|
+
* 6. Send `ms365_approval_posted { ok, requestId, expiresAtMs }` back
|
|
19
|
+
* over IPC so the hook can poll `approval_lookup` for verdict.
|
|
20
|
+
*
|
|
21
|
+
* Fail closed: on any failure (preview malformed, kernel down, card
|
|
22
|
+
* post fails) we send `ok: false` so the hook fails closed and blocks
|
|
23
|
+
* the tool call.
|
|
24
|
+
*
|
|
25
|
+
* **Why weak metadata v1** — softeria upload calls are full-file
|
|
26
|
+
* replacements; computing a structural diff would require downloading
|
|
27
|
+
* the prior version and running the docx/xlsx/pptx skill in diff mode.
|
|
28
|
+
* That's RFC §8 v1.5 work; v1 ships the simpler "operator must trust
|
|
29
|
+
* the agent's rationale + click through to OneDrive to verify" shape.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { IpcClient } from "./ipc-server.js";
|
|
33
|
+
import type { RequestMs365ApprovalMessage } from "./ipc-protocol.js";
|
|
34
|
+
|
|
35
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Wire shape — validates an inbound preview payload
|
|
37
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface Ms365WritePreview {
|
|
40
|
+
/** Agent slug — appears on the card title. */
|
|
41
|
+
agentName: string;
|
|
42
|
+
/** Tool name as Claude Code sees it: `mcp__ms-365__<fn>`. */
|
|
43
|
+
toolName: string;
|
|
44
|
+
/**
|
|
45
|
+
* Item identifier for the OneDrive item / mail message / calendar
|
|
46
|
+
* event the tool will mutate. Used as the kernel scope key:
|
|
47
|
+
* `ms-365:write:<itemId>`. May be "(new)" for create operations.
|
|
48
|
+
*/
|
|
49
|
+
itemId: string;
|
|
50
|
+
/** Display name (file name / mail subject / event title). */
|
|
51
|
+
itemDisplayName: string;
|
|
52
|
+
/** Microsoft account email the agent is authenticated as. */
|
|
53
|
+
accountEmail: string;
|
|
54
|
+
/** Deep link to OneDrive / Outlook web — best-effort, may be empty. */
|
|
55
|
+
deepLink?: string;
|
|
56
|
+
/** Byte delta — present only for OneDrive uploads with known sizes. */
|
|
57
|
+
sizeBytesBefore?: number;
|
|
58
|
+
sizeBytesAfter?: number;
|
|
59
|
+
/** 1-line agent rationale — advisory; operator should not over-trust. */
|
|
60
|
+
agentRationale?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a wire payload into a typed Ms365WritePreview. Returns null
|
|
65
|
+
* on malformed input (defense in depth — the hook is trusted but the
|
|
66
|
+
* payload may be corrupted in transit / by future-version drift).
|
|
67
|
+
*/
|
|
68
|
+
export function validateMs365Preview(input: unknown): Ms365WritePreview | null {
|
|
69
|
+
if (!input || typeof input !== "object") return null;
|
|
70
|
+
const o = input as Record<string, unknown>;
|
|
71
|
+
if (typeof o.agentName !== "string" || o.agentName.length === 0) return null;
|
|
72
|
+
if (typeof o.toolName !== "string" || o.toolName.length === 0) return null;
|
|
73
|
+
if (typeof o.itemId !== "string" || o.itemId.length === 0) return null;
|
|
74
|
+
if (typeof o.itemDisplayName !== "string") return null;
|
|
75
|
+
if (typeof o.accountEmail !== "string") return null;
|
|
76
|
+
const out: Ms365WritePreview = {
|
|
77
|
+
agentName: o.agentName,
|
|
78
|
+
toolName: o.toolName,
|
|
79
|
+
itemId: o.itemId,
|
|
80
|
+
itemDisplayName: o.itemDisplayName,
|
|
81
|
+
accountEmail: o.accountEmail,
|
|
82
|
+
};
|
|
83
|
+
if (typeof o.deepLink === "string") out.deepLink = o.deepLink;
|
|
84
|
+
if (typeof o.sizeBytesBefore === "number") out.sizeBytesBefore = o.sizeBytesBefore;
|
|
85
|
+
if (typeof o.sizeBytesAfter === "number") out.sizeBytesAfter = o.sizeBytesAfter;
|
|
86
|
+
if (typeof o.agentRationale === "string") out.agentRationale = o.agentRationale;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// Handler — DI shape mirrors DriveApprovalHandlerDeps
|
|
92
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface Ms365ApprovalHandlerDeps {
|
|
95
|
+
agentName: string;
|
|
96
|
+
loadAllowFrom: () => string[];
|
|
97
|
+
loadTargetChat: () => {
|
|
98
|
+
chatId: number | string;
|
|
99
|
+
threadId?: number;
|
|
100
|
+
} | null;
|
|
101
|
+
registerApproval: (args: {
|
|
102
|
+
agent_unit: string;
|
|
103
|
+
scope: string;
|
|
104
|
+
action: string;
|
|
105
|
+
approver_set: string[];
|
|
106
|
+
why: string;
|
|
107
|
+
ttl_ms: number;
|
|
108
|
+
}) => Promise<{ request_id: string; expires_at_ms: number } | null>;
|
|
109
|
+
postCard: (args: {
|
|
110
|
+
chatId: number | string;
|
|
111
|
+
threadId?: number;
|
|
112
|
+
text: string;
|
|
113
|
+
replyMarkup: unknown;
|
|
114
|
+
}) => Promise<{ messageId: number } | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Build the inline keyboard. Defaults to 2-button [✅ Approve]
|
|
117
|
+
* [🚫 Deny] using callback_data `apv:<requestId>:once|deny` — the
|
|
118
|
+
* SAME shape Drive uses. The existing gateway `apv:` handler at
|
|
119
|
+
* `gateway.ts:handleApprovalCallback` consumes the kernel state
|
|
120
|
+
* machine generically (no provider awareness). Reused on purpose:
|
|
121
|
+
* provider-namespaced callback_data would silently drop button
|
|
122
|
+
* taps because there's no `ms365:` branch in the dispatcher.
|
|
123
|
+
* Reviewer of PR 4 caught this — the original `ms365:` prefix was
|
|
124
|
+
* an unwired dead end.
|
|
125
|
+
*/
|
|
126
|
+
buildKeyboard?: (requestId: string) => unknown;
|
|
127
|
+
log?: (msg: string) => void;
|
|
128
|
+
defaultTtlMs?: number;
|
|
129
|
+
maxTtlMs?: number;
|
|
130
|
+
minTtlMs?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
134
|
+
const MAX_TTL_MS = 30 * 60 * 1000;
|
|
135
|
+
const MIN_TTL_MS = 30 * 1000;
|
|
136
|
+
|
|
137
|
+
function defaultKeyboard(requestId: string): unknown {
|
|
138
|
+
// `apv:<requestId>:once` is the kernel-generic approval shape used
|
|
139
|
+
// by Drive's diff-preview cards. The existing `apv:` dispatch
|
|
140
|
+
// branch at gateway.ts:14149 routes the kernel consume + record
|
|
141
|
+
// for any provider. Mirroring `apv:` keeps the M365 cards wired
|
|
142
|
+
// through the same well-tested approval state machine instead of a
|
|
143
|
+
// new provider-namespaced one with its own bug surface.
|
|
144
|
+
return {
|
|
145
|
+
inline_keyboard: [
|
|
146
|
+
[
|
|
147
|
+
{ text: "✅ Approve", callback_data: `apv:${requestId}:once` },
|
|
148
|
+
{ text: "🚫 Deny", callback_data: `apv:${requestId}:deny` },
|
|
149
|
+
],
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the card body. Plain text (no Markdown) to keep escaping
|
|
156
|
+
* trivial. Truncates the rationale + display name to fit Telegram's
|
|
157
|
+
* 4096-char message limit with safety margin.
|
|
158
|
+
*/
|
|
159
|
+
export function buildMs365CardText(p: Ms365WritePreview): string {
|
|
160
|
+
const lines: string[] = [];
|
|
161
|
+
lines.push(`📄 Microsoft 365 write approval`);
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push(`Agent: ${truncate(p.agentName, 64)}`);
|
|
164
|
+
lines.push(`Tool: ${truncate(p.toolName.replace(/^mcp__/, ""), 96)}`);
|
|
165
|
+
lines.push(`Item: ${truncate(p.itemDisplayName, 256)}`);
|
|
166
|
+
if (p.itemId !== "(new)") {
|
|
167
|
+
lines.push(`ID: ${truncate(p.itemId, 96)}`);
|
|
168
|
+
}
|
|
169
|
+
lines.push(`Account: ${truncate(p.accountEmail, 96)}`);
|
|
170
|
+
if (
|
|
171
|
+
typeof p.sizeBytesBefore === "number" ||
|
|
172
|
+
typeof p.sizeBytesAfter === "number"
|
|
173
|
+
) {
|
|
174
|
+
const before = p.sizeBytesBefore ?? 0;
|
|
175
|
+
const after = p.sizeBytesAfter ?? 0;
|
|
176
|
+
const delta = after - before;
|
|
177
|
+
const sign = delta >= 0 ? "+" : "";
|
|
178
|
+
lines.push(`Size: ${humanBytes(before)} → ${humanBytes(after)} (${sign}${humanBytes(delta)})`);
|
|
179
|
+
}
|
|
180
|
+
if (p.deepLink) {
|
|
181
|
+
lines.push(`Link: ${truncate(p.deepLink, 256)}`);
|
|
182
|
+
}
|
|
183
|
+
if (p.agentRationale) {
|
|
184
|
+
lines.push("");
|
|
185
|
+
lines.push(`💬 ${truncate(p.agentRationale, 512)}`);
|
|
186
|
+
}
|
|
187
|
+
lines.push("");
|
|
188
|
+
lines.push(
|
|
189
|
+
"⚠️ Weak attestation (RFC §8 v1): operator should click through to verify the actual change before approving. Structural diff coming v1.5.",
|
|
190
|
+
);
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function truncate(s: string, n: number): string {
|
|
195
|
+
if (s.length <= n) return s;
|
|
196
|
+
return s.slice(0, n - 1) + "…";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function humanBytes(bytes: number): string {
|
|
200
|
+
const abs = Math.abs(bytes);
|
|
201
|
+
if (abs < 1024) return `${bytes}B`;
|
|
202
|
+
if (abs < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
203
|
+
if (abs < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
|
|
204
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clampTtl(
|
|
208
|
+
requested: number | undefined,
|
|
209
|
+
def: number,
|
|
210
|
+
min: number,
|
|
211
|
+
max: number,
|
|
212
|
+
): number {
|
|
213
|
+
if (typeof requested !== "number" || !Number.isFinite(requested)) return def;
|
|
214
|
+
return Math.max(min, Math.min(max, requested));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Main handler — wire the inbound IPC message to kernel + card post.
|
|
219
|
+
*/
|
|
220
|
+
export async function handleRequestMs365Approval(
|
|
221
|
+
client: IpcClient,
|
|
222
|
+
msg: RequestMs365ApprovalMessage,
|
|
223
|
+
deps: Ms365ApprovalHandlerDeps,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const log = deps.log ?? (() => {});
|
|
226
|
+
const sendResponse = (
|
|
227
|
+
ok: boolean,
|
|
228
|
+
extra: {
|
|
229
|
+
requestId?: string;
|
|
230
|
+
expiresAtMs?: number;
|
|
231
|
+
reason?: string;
|
|
232
|
+
} = {},
|
|
233
|
+
): void => {
|
|
234
|
+
client.send({
|
|
235
|
+
type: "ms365_approval_posted",
|
|
236
|
+
correlationId: msg.correlationId,
|
|
237
|
+
ok,
|
|
238
|
+
...extra,
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Cross-agent guard
|
|
243
|
+
if (msg.agentName !== deps.agentName) {
|
|
244
|
+
log(
|
|
245
|
+
`ms365-approval: cross-agent request rejected (msg=${msg.agentName} vs gateway=${deps.agentName})`,
|
|
246
|
+
);
|
|
247
|
+
sendResponse(false, { reason: "cross-agent request rejected" });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const preview = validateMs365Preview(msg.preview);
|
|
252
|
+
if (!preview) {
|
|
253
|
+
log("ms365-approval: invalid preview payload");
|
|
254
|
+
sendResponse(false, { reason: "invalid preview payload" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const allowFrom = deps.loadAllowFrom();
|
|
259
|
+
if (allowFrom.length === 0) {
|
|
260
|
+
log("ms365-approval: no operator allowFrom configured");
|
|
261
|
+
sendResponse(false, { reason: "no operator allowFrom configured" });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const targetChat = deps.loadTargetChat();
|
|
266
|
+
if (!targetChat) {
|
|
267
|
+
log("ms365-approval: no target chat resolved");
|
|
268
|
+
sendResponse(false, { reason: "no target chat resolved" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const ttlMs = clampTtl(
|
|
273
|
+
msg.ttlMs,
|
|
274
|
+
deps.defaultTtlMs ?? DEFAULT_TTL_MS,
|
|
275
|
+
deps.minTtlMs ?? MIN_TTL_MS,
|
|
276
|
+
deps.maxTtlMs ?? MAX_TTL_MS,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const scope = `ms-365:write:${preview.itemId}`;
|
|
280
|
+
const why =
|
|
281
|
+
preview.agentRationale ??
|
|
282
|
+
`${preview.toolName} on ${preview.itemDisplayName}`;
|
|
283
|
+
|
|
284
|
+
let registered;
|
|
285
|
+
try {
|
|
286
|
+
registered = await deps.registerApproval({
|
|
287
|
+
agent_unit: preview.agentName,
|
|
288
|
+
scope,
|
|
289
|
+
action: "write",
|
|
290
|
+
approver_set: allowFrom,
|
|
291
|
+
why,
|
|
292
|
+
ttl_ms: ttlMs,
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const msg2 = err instanceof Error ? err.message : String(err);
|
|
296
|
+
log(`ms365-approval: kernel register failed — ${msg2}`);
|
|
297
|
+
sendResponse(false, { reason: `kernel register failed: ${msg2}` });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!registered) {
|
|
301
|
+
sendResponse(false, { reason: "kernel returned no request_id" });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const text = buildMs365CardText(preview);
|
|
306
|
+
const replyMarkup = (deps.buildKeyboard ?? defaultKeyboard)(registered.request_id);
|
|
307
|
+
|
|
308
|
+
let posted;
|
|
309
|
+
try {
|
|
310
|
+
posted = await deps.postCard({
|
|
311
|
+
chatId: targetChat.chatId,
|
|
312
|
+
threadId: targetChat.threadId,
|
|
313
|
+
text,
|
|
314
|
+
replyMarkup,
|
|
315
|
+
});
|
|
316
|
+
} catch (err) {
|
|
317
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
318
|
+
log(`ms365-approval: card post threw — ${m}`);
|
|
319
|
+
sendResponse(false, { reason: `card post failed: ${m}` });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (!posted) {
|
|
323
|
+
sendResponse(false, { reason: "card post returned null" });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
log(
|
|
328
|
+
`ms365-approval: posted card msg=${posted.messageId} request=${registered.request_id} expires=${new Date(registered.expires_at_ms).toISOString()}`,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
sendResponse(true, {
|
|
332
|
+
requestId: registered.request_id,
|
|
333
|
+
expiresAtMs: registered.expires_at_ms,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type RetryPolicy,
|
|
24
24
|
} from './stream-controller.js'
|
|
25
25
|
import { sanitizeTelegramHtml } from './html-sanitize.js'
|
|
26
|
+
import { chatKey, chatKeyWithSuffix } from './gateway/chat-key.js'
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Builds the inline status-accent header line for `reply` / `stream_reply`.
|
|
@@ -311,14 +312,15 @@ function streamKey(
|
|
|
311
312
|
lane?: string,
|
|
312
313
|
turnKey?: string,
|
|
313
314
|
): string {
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
const base =
|
|
320
|
-
|
|
321
|
-
|
|
315
|
+
// Adopt the canonical chatKey() / chatKeyWithSuffix() primitives from
|
|
316
|
+
// gateway/chat-key.ts (PR2 of supergroup mode — kills the previously
|
|
317
|
+
// inlined copy of the key expression). The brand erases to string at
|
|
318
|
+
// runtime, so callers using `streamKey` as a `Map<string, T>` key
|
|
319
|
+
// continue to work unchanged.
|
|
320
|
+
const base = lane != null && lane.length > 0
|
|
321
|
+
? chatKeyWithSuffix(chatId, threadId ?? null, lane)
|
|
322
|
+
: chatKey(chatId, threadId ?? null)
|
|
323
|
+
return turnKey != null && turnKey.length > 0 ? `${base}:${turnKey}` : base
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
export async function handleStreamReply(
|
|
@@ -50,6 +50,14 @@ function makeDeps(agentName: string | null) {
|
|
|
50
50
|
['chat1:thr1:msg1', 100],
|
|
51
51
|
['chat2:thr2:msg2', 200],
|
|
52
52
|
])
|
|
53
|
+
// PR3b: claudeBusyKeys tracks turns actually handed to claude. In a
|
|
54
|
+
// healthy registered-disconnect scenario both maps would carry the
|
|
55
|
+
// same keys (delivery succeeded); the dangling-sweep tests below
|
|
56
|
+
// override individual deps to exercise the orphaned-key path.
|
|
57
|
+
const claudeBusyKeys = new Set<string>([
|
|
58
|
+
'chat1:thr1:msg1',
|
|
59
|
+
'chat2:thr2:msg2',
|
|
60
|
+
])
|
|
53
61
|
const activeDraftStreams = new Map<string, FakeStream>([
|
|
54
62
|
['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
|
|
55
63
|
['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
|
|
@@ -66,6 +74,7 @@ function makeDeps(agentName: string | null) {
|
|
|
66
74
|
activeStatusReactions,
|
|
67
75
|
activeReactionMsgIds,
|
|
68
76
|
activeTurnStartedAt,
|
|
77
|
+
claudeBusyKeys,
|
|
69
78
|
activeDraftStreams,
|
|
70
79
|
activeDraftParseModes,
|
|
71
80
|
clearActiveReactions,
|
|
@@ -169,6 +178,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
169
178
|
['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
|
|
170
179
|
]),
|
|
171
180
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
181
|
+
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
172
182
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
173
183
|
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
174
184
|
clearActiveReactions,
|
|
@@ -179,6 +189,10 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
179
189
|
|
|
180
190
|
flushOnAgentDisconnect(deps)
|
|
181
191
|
|
|
192
|
+
// PR3b: claudeBusyKeys swept alongside the activeTurnStartedAt
|
|
193
|
+
// dangling entry — both maps mirror each other on registered
|
|
194
|
+
// disconnects, so a key in one is always a key in the other.
|
|
195
|
+
expect(deps.claudeBusyKeys.size).toBe(0)
|
|
182
196
|
// The sweep fired and cleared the dangling entry.
|
|
183
197
|
expect(deps.activeTurnStartedAt.size).toBe(0)
|
|
184
198
|
expect(deps.activeReactionMsgIds.size).toBe(0)
|
|
@@ -222,6 +236,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
222
236
|
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
223
237
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
224
238
|
activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
|
|
239
|
+
claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
|
|
225
240
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
226
241
|
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
227
242
|
clearActiveReactions: vi.fn(),
|
|
@@ -237,6 +252,106 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
237
252
|
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
238
253
|
})
|
|
239
254
|
|
|
255
|
+
// PR3b orphan-sweep regression: synthetic-inbound deliveries
|
|
256
|
+
// (cron, reactions, vault, button-callback) bypass handleInbound's
|
|
257
|
+
// fresh-turn branch and so never stamp activeTurnStartedAt. They
|
|
258
|
+
// DO mark claudeBusyKeys. If their turn dies without turn_end, the
|
|
259
|
+
// activeTurnStartedAt-keyed dangling sweep misses them — orphan
|
|
260
|
+
// persists in claudeBusyKeys → fleet gate wedges. This test pins
|
|
261
|
+
// the post-sweep claudeBusyKeys.clear() fix.
|
|
262
|
+
it('sweeps claudeBusyKeys orphans that have NO activeTurnStartedAt entry (PR3b follow-up)', () => {
|
|
263
|
+
const onDanglingTurnsSwept = vi.fn()
|
|
264
|
+
const log = vi.fn()
|
|
265
|
+
const deps = {
|
|
266
|
+
agentName: 'clerk',
|
|
267
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
268
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
269
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
270
|
+
// The orphan scenario: claude was handed a turn (e.g. cron
|
|
271
|
+
// synthetic delivered), so claudeBusyKeys has it, but
|
|
272
|
+
// activeTurnStartedAt was never set because cron bypasses
|
|
273
|
+
// handleInbound's fresh-turn branch.
|
|
274
|
+
claudeBusyKeys: new Set<string>(['cron-only-key:_']),
|
|
275
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
276
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
277
|
+
clearActiveReactions: vi.fn(),
|
|
278
|
+
disposeProgressDriver: vi.fn(),
|
|
279
|
+
onDanglingTurnsSwept,
|
|
280
|
+
log,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
flushOnAgentDisconnect(deps)
|
|
284
|
+
|
|
285
|
+
// The orphan is cleared even though it never had an
|
|
286
|
+
// activeTurnStartedAt entry.
|
|
287
|
+
expect(deps.claudeBusyKeys.size).toBe(0)
|
|
288
|
+
// The activeTurnStartedAt-keyed sweep wasn't fired (nothing in
|
|
289
|
+
// that map to sweep) — so onDanglingTurnsSwept shouldn't fire
|
|
290
|
+
// either. The orphan sweep is a separate observation.
|
|
291
|
+
expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
|
|
292
|
+
// But it logs the orphan-clear so operators can see it.
|
|
293
|
+
expect(
|
|
294
|
+
log.mock.calls.some((c: unknown[]) =>
|
|
295
|
+
typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
|
|
296
|
+
),
|
|
297
|
+
).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('orphan-sweep singular vs plural log message agrees with count', () => {
|
|
301
|
+
// Tiny grammar regression: "1 entry" vs "2 entries".
|
|
302
|
+
const log = vi.fn()
|
|
303
|
+
const baseDeps = {
|
|
304
|
+
agentName: 'clerk',
|
|
305
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
306
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
307
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
308
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
309
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
310
|
+
clearActiveReactions: vi.fn(),
|
|
311
|
+
disposeProgressDriver: vi.fn(),
|
|
312
|
+
}
|
|
313
|
+
// Singular form.
|
|
314
|
+
flushOnAgentDisconnect({
|
|
315
|
+
...baseDeps,
|
|
316
|
+
claudeBusyKeys: new Set<string>(['k1:_']),
|
|
317
|
+
log,
|
|
318
|
+
})
|
|
319
|
+
expect(log.mock.calls.some((c: unknown[]) =>
|
|
320
|
+
typeof c[0] === 'string' && / 1 orphan claudeBusyKeys entry /.test(c[0]),
|
|
321
|
+
)).toBe(true)
|
|
322
|
+
// Plural form.
|
|
323
|
+
log.mockClear()
|
|
324
|
+
flushOnAgentDisconnect({
|
|
325
|
+
...baseDeps,
|
|
326
|
+
claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
|
|
327
|
+
log,
|
|
328
|
+
})
|
|
329
|
+
expect(log.mock.calls.some((c: unknown[]) =>
|
|
330
|
+
typeof c[0] === 'string' && / 2 orphan claudeBusyKeys entries /.test(c[0]),
|
|
331
|
+
)).toBe(true)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('does NOT fire orphan-sweep log when claudeBusyKeys is empty', () => {
|
|
335
|
+
// Zero-noise discipline: every disconnect for a healthy idle
|
|
336
|
+
// agent shouldn't produce a "0 orphan claudeBusyKeys" line.
|
|
337
|
+
const log = vi.fn()
|
|
338
|
+
flushOnAgentDisconnect({
|
|
339
|
+
agentName: 'clerk',
|
|
340
|
+
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
341
|
+
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
342
|
+
activeTurnStartedAt: new Map<string, number>(),
|
|
343
|
+
claudeBusyKeys: new Set<string>(),
|
|
344
|
+
activeDraftStreams: new Map<string, FakeStream>(),
|
|
345
|
+
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
346
|
+
clearActiveReactions: vi.fn(),
|
|
347
|
+
disposeProgressDriver: vi.fn(),
|
|
348
|
+
log,
|
|
349
|
+
})
|
|
350
|
+
expect(log.mock.calls.some((c: unknown[]) =>
|
|
351
|
+
typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
|
|
352
|
+
)).toBe(false)
|
|
353
|
+
})
|
|
354
|
+
|
|
240
355
|
it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
|
|
241
356
|
// Backward-compat guard — existing callers that don't pass the new
|
|
242
357
|
// callback still work without runtime error.
|
|
@@ -245,6 +360,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
245
360
|
activeStatusReactions: new Map<string, FakeCtrl>(),
|
|
246
361
|
activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
|
|
247
362
|
activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
|
|
363
|
+
claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
|
|
248
364
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
249
365
|
activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
|
|
250
366
|
clearActiveReactions: vi.fn(),
|
|
@@ -21,10 +21,26 @@ beforeEach(() => { vi.useFakeTimers() })
|
|
|
21
21
|
afterEach(() => { vi.useRealTimers() })
|
|
22
22
|
|
|
23
23
|
describe('inboundCoalesceKey', () => {
|
|
24
|
-
it('combines chatId and userId so distinct senders never collide', () => {
|
|
25
|
-
expect(inboundCoalesceKey('c1', 'u1')).not.toBe(inboundCoalesceKey('c1', 'u2'))
|
|
26
|
-
expect(inboundCoalesceKey('c1', 'u1')).not.toBe(inboundCoalesceKey('c2', 'u1'))
|
|
27
|
-
expect(inboundCoalesceKey('c1', 'u1')).toBe(inboundCoalesceKey('c1', 'u1'))
|
|
24
|
+
it('combines chatId, threadId, and userId so distinct senders never collide', () => {
|
|
25
|
+
expect(inboundCoalesceKey('c1', null, 'u1')).not.toBe(inboundCoalesceKey('c1', null, 'u2'))
|
|
26
|
+
expect(inboundCoalesceKey('c1', null, 'u1')).not.toBe(inboundCoalesceKey('c2', null, 'u1'))
|
|
27
|
+
expect(inboundCoalesceKey('c1', null, 'u1')).toBe(inboundCoalesceKey('c1', null, 'u1'))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps the same user\'s messages in DIFFERENT topics in distinct buckets (supergroup-mode)', () => {
|
|
31
|
+
// CPO decision #9 ratified 2026-05-27: per-topic coalesce intent.
|
|
32
|
+
// The 1.5s window is "user sends 3 sentences as one thought" —
|
|
33
|
+
// applying it cross-topic merges genuinely separate conversations.
|
|
34
|
+
expect(inboundCoalesceKey('c1', 17, 'u1')).not.toBe(inboundCoalesceKey('c1', 23, 'u1'))
|
|
35
|
+
expect(inboundCoalesceKey('c1', 17, 'u1')).not.toBe(inboundCoalesceKey('c1', null, 'u1'))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('collapses null / undefined / 0 thread IDs to the same key (chatKey convention)', () => {
|
|
39
|
+
const k1 = inboundCoalesceKey('c1', null, 'u1')
|
|
40
|
+
const k2 = inboundCoalesceKey('c1', undefined, 'u1')
|
|
41
|
+
const k3 = inboundCoalesceKey('c1', 0, 'u1')
|
|
42
|
+
expect(k1).toBe(k2)
|
|
43
|
+
expect(k1).toBe(k3)
|
|
28
44
|
})
|
|
29
45
|
})
|
|
30
46
|
|
|
@@ -271,4 +271,65 @@ describe('validateClientMessage', () => {
|
|
|
271
271
|
expect(validateClientMessage({ type: 'heartbeat' })).toBe(false)
|
|
272
272
|
})
|
|
273
273
|
})
|
|
274
|
+
|
|
275
|
+
describe('request_ms365_approval (RFC #1873 §8 PR 4)', () => {
|
|
276
|
+
const valid = {
|
|
277
|
+
type: 'request_ms365_approval',
|
|
278
|
+
correlationId: 'abc123',
|
|
279
|
+
agentName: 'clerk',
|
|
280
|
+
preview: { agentName: 'clerk', toolName: 'mcp__ms-365__upload-file-content' },
|
|
281
|
+
ttlMs: 300000,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
it('accepts a valid request_ms365_approval', () => {
|
|
285
|
+
expect(validateClientMessage(valid)).toBe(true)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('accepts when ttlMs is omitted (handler uses default)', () => {
|
|
289
|
+
const { ttlMs: _, ...without } = valid
|
|
290
|
+
expect(validateClientMessage(without)).toBe(true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('rejects missing correlationId', () => {
|
|
294
|
+
const { correlationId: _, ...m } = valid
|
|
295
|
+
expect(validateClientMessage(m)).toBe(false)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('rejects empty correlationId', () => {
|
|
299
|
+
expect(validateClientMessage({ ...valid, correlationId: '' })).toBe(false)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('rejects oversized correlationId (>64 chars)', () => {
|
|
303
|
+
expect(validateClientMessage({ ...valid, correlationId: 'x'.repeat(65) })).toBe(false)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('rejects missing agentName', () => {
|
|
307
|
+
const { agentName: _, ...m } = valid
|
|
308
|
+
expect(validateClientMessage(m)).toBe(false)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('rejects malformed agentName (caps, spaces)', () => {
|
|
312
|
+
expect(validateClientMessage({ ...valid, agentName: 'NOT-LOWER' })).toBe(false)
|
|
313
|
+
expect(validateClientMessage({ ...valid, agentName: 'has space' })).toBe(false)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('rejects null / non-object preview', () => {
|
|
317
|
+
expect(validateClientMessage({ ...valid, preview: null })).toBe(false)
|
|
318
|
+
expect(validateClientMessage({ ...valid, preview: 'string' })).toBe(false)
|
|
319
|
+
expect(validateClientMessage({ ...valid, preview: 42 })).toBe(false)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('rejects negative ttlMs', () => {
|
|
323
|
+
expect(validateClientMessage({ ...valid, ttlMs: -1 })).toBe(false)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('rejects non-finite ttlMs', () => {
|
|
327
|
+
expect(validateClientMessage({ ...valid, ttlMs: Infinity })).toBe(false)
|
|
328
|
+
expect(validateClientMessage({ ...valid, ttlMs: NaN })).toBe(false)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('rejects non-number ttlMs', () => {
|
|
332
|
+
expect(validateClientMessage({ ...valid, ttlMs: '300000' })).toBe(false)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
274
335
|
})
|