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.
Files changed (39) hide show
  1. package/dist/agent-scheduler/index.js +399 -213
  2. package/dist/auth-broker/index.js +576 -237
  3. package/dist/cli/drive-write-pretool.mjs +28 -13
  4. package/dist/cli/ms-365-write-pretool.mjs +259 -0
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +3241 -1382
  7. package/dist/host-control/main.js +396 -276
  8. package/dist/vault/approvals/kernel-server.js +8266 -8142
  9. package/dist/vault/broker/server.js +2894 -2770
  10. package/package.json +1 -1
  11. package/profiles/_base/start.sh.hbs +17 -0
  12. package/profiles/_shared/telegram-style.md.hbs +2 -0
  13. package/skills/switchroom-status/SKILL.md +8 -6
  14. package/telegram-plugin/chat-lock.ts +87 -19
  15. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  16. package/telegram-plugin/dist/gateway/gateway.js +1283 -343
  17. package/telegram-plugin/dist/server.js +160 -160
  18. package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
  19. package/telegram-plugin/gateway/gateway.ts +485 -72
  20. package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
  21. package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
  22. package/telegram-plugin/gateway/ipc-server.ts +59 -0
  23. package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
  24. package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
  25. package/telegram-plugin/stream-reply-handler.ts +10 -8
  26. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
  27. package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
  28. package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
  29. package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
  30. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
  31. package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
  32. package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
  33. package/telegram-plugin/typing-wrap.ts +43 -21
  34. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +164 -4
  36. package/vendor/hindsight-memory/scripts/retain.py +52 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
  39. 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
- // Canonical chat-key derivation lives in gateway/chat-key.ts keep this
315
- // expression in lockstep with that helper (treats 0/null/undefined the
316
- // same), but inline here so this file doesn't introduce a cross-package
317
- // import for one expression. See #1564 for the sibling-key bug class.
318
- const t = threadId == null || threadId === 0 ? '_' : String(threadId)
319
- const base = `${chatId}:${t}`
320
- const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base
321
- return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane
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
  })