switchroom 0.20.7 → 0.20.8
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 +105 -11
- package/dist/auth-broker/index.js +68 -7
- package/dist/cli/notion-write-pretool.mjs +67 -6
- package/dist/cli/switchroom.js +384 -28
- package/dist/host-control/main.js +69 -8
- package/dist/vault/approvals/kernel-server.js +68 -7
- package/dist/vault/broker/server.js +68 -7
- package/package.json +1 -1
- package/telegram-plugin/bridge/ipc-client.ts +17 -1
- package/telegram-plugin/dist/bridge/bridge.js +5 -2
- package/telegram-plugin/dist/gateway/gateway.js +237 -122
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/gateway/boot-reason.ts +61 -0
- package/telegram-plugin/gateway/cron-session.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +28 -30
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
- package/telegram-plugin/gateway/stream-render.ts +11 -2
- package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
- package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
- package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
- package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/represent-guard.test.ts +45 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- package/telegram-plugin/turn-flush-safety.ts +79 -0
|
@@ -260,3 +260,69 @@ describe('Part B — handback-while-busy: exactly one card, never a frozen "Queu
|
|
|
260
260
|
expect(rec.edits).toHaveLength(0)
|
|
261
261
|
})
|
|
262
262
|
})
|
|
263
|
+
|
|
264
|
+
describe('Part B — synthetic (fabricated) message ids never 400 the queued card', () => {
|
|
265
|
+
/** Recording bot that enforces the REAL Telegram Bot API contract on
|
|
266
|
+
* `reply_parameters.message_id`: anything non-integer or beyond signed int32
|
|
267
|
+
* is hard-rejected with the exact 400 the live gateway hit
|
|
268
|
+
* (gateway-supervisor.log 2026-08-04, msg=1785846295635) — BEFORE recording,
|
|
269
|
+
* exactly like the wire call. `allow_sending_without_reply` does not bypass
|
|
270
|
+
* the range check, only the message-not-found case. */
|
|
271
|
+
function withTelegramStrictBot(h: Harness) {
|
|
272
|
+
const sends: SendRec[] = []
|
|
273
|
+
let nextId = 9001
|
|
274
|
+
;(h.deps as unknown as { bot: unknown }).bot = {
|
|
275
|
+
api: {
|
|
276
|
+
sendRichMessage: async (chatId: string, msg: { markdown: string }, opts: Record<string, unknown>) => {
|
|
277
|
+
const rp = opts.reply_parameters as { message_id?: unknown } | undefined
|
|
278
|
+
if (rp != null) {
|
|
279
|
+
const mid = rp.message_id
|
|
280
|
+
if (typeof mid !== 'number' || !Number.isInteger(mid) || mid <= 0 || mid >= 2 ** 31) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Call to 'sendRichMessage' failed! (400: Bad Request: field "message_id" must be a valid Number)`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const id = nextId++
|
|
287
|
+
sends.push({ chatId, markdown: msg.markdown, opts, id })
|
|
288
|
+
return { message_id: id }
|
|
289
|
+
},
|
|
290
|
+
editMessageText: async () => true,
|
|
291
|
+
deleteMessage: async () => true,
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
return { sends }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
it('sends the queued card UNANCHORED (no 400) when the parked message id is a fabricated Date.now() timestamp', async () => {
|
|
298
|
+
const h = makeHarness()
|
|
299
|
+
const rec = withTelegramStrictBot(h)
|
|
300
|
+
|
|
301
|
+
// Turn A mints on an idle session.
|
|
302
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
303
|
+
expect(rec.sends).toHaveLength(0)
|
|
304
|
+
|
|
305
|
+
// A synthetic enqueue (subagent handback / boot resume) parks mid-turn with
|
|
306
|
+
// a fabricated ms-timestamp message id — finite, but NOT a Telegram id.
|
|
307
|
+
handleSessionEvent(h.deps, enqueue('1785846295635', 'handback: worker done'))
|
|
308
|
+
await settle()
|
|
309
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
310
|
+
|
|
311
|
+
// The card SENT (no 400 — RED on the pre-fix guard, which forwarded the
|
|
312
|
+
// 13-digit id into reply_parameters and lost the whole card)…
|
|
313
|
+
expect(rec.sends).toHaveLength(1)
|
|
314
|
+
// …and it sent WITHOUT reply-linkage: no reply_parameters at all.
|
|
315
|
+
expect(rec.sends[0]!.opts.reply_parameters).toBeUndefined()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('still reply-anchors when the parked message id is a real Telegram id', async () => {
|
|
319
|
+
const h = makeHarness()
|
|
320
|
+
const rec = withTelegramStrictBot(h)
|
|
321
|
+
|
|
322
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
323
|
+
handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
|
|
324
|
+
await settle()
|
|
325
|
+
expect(rec.sends).toHaveLength(1)
|
|
326
|
+
expect((rec.sends[0]!.opts.reply_parameters as { message_id: number }).message_id).toBe(502)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
@@ -336,6 +336,51 @@ describe("makeSessionBusyDrainDeferral — F2 bounded busy-defer for the idle dr
|
|
|
336
336
|
expect(off(true, 0)).toBe(false);
|
|
337
337
|
expect(off(true, 10_000)).toBe(false);
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
it("starts a FRESH bound for a new deferral episode after a call gap — a buffer emptied by bridge re-register (no busy=false call) must not pin a stale t0 (#4341 follow-up)", () => {
|
|
341
|
+
const BOUND = 20_000; // small bound so a realistic 5s poll cadence spans it
|
|
342
|
+
const defer = makeSessionBusyDrainDeferral(BOUND); // default staleGap = 15s
|
|
343
|
+
|
|
344
|
+
// Episode A: a represent is buffered while the session is busy. The idle
|
|
345
|
+
// drain gate polls it every ~5s (< staleGap) and defers each time.
|
|
346
|
+
expect(defer(true, 0)).toBe(true); // t0 = 0
|
|
347
|
+
expect(defer(true, 5_000)).toBe(true);
|
|
348
|
+
expect(defer(true, 10_000)).toBe(true);
|
|
349
|
+
|
|
350
|
+
// The buffer is now emptied by a bridge re-register (onClientRegistered)
|
|
351
|
+
// while the session is STILL busy. That drain path does NOT consult this
|
|
352
|
+
// predicate, so there is no busy=false call — deferringSince stays at 0 on
|
|
353
|
+
// the buggy code. Time then advances well past the bound with the buffer
|
|
354
|
+
// empty (predicate not called).
|
|
355
|
+
|
|
356
|
+
// Episode B: a brand-new represent is buffered while the session is busy,
|
|
357
|
+
// long after t0. This is a NEW mid-answer session — the represent MUST stay
|
|
358
|
+
// deferred (its own bound has not elapsed), NOT be drained immediately.
|
|
359
|
+
// Buggy code: now - stalePinnedT0 (2_000_000 - 0) >= BOUND → false → the
|
|
360
|
+
// represent is drained into the mid-answer session, reopening the duplicate
|
|
361
|
+
// window. Fixed code: the >15s call gap starts a fresh episode clock at
|
|
362
|
+
// t=2_000_000, so it defers.
|
|
363
|
+
const B0 = 2_000_000;
|
|
364
|
+
expect(defer(true, B0)).toBe(true);
|
|
365
|
+
// ...and the fresh episode is still bounded from ITS OWN start, polled at the
|
|
366
|
+
// real ~5s cadence (each gap < staleGap, so no further reset).
|
|
367
|
+
expect(defer(true, B0 + 5_000)).toBe(true);
|
|
368
|
+
expect(defer(true, B0 + 10_000)).toBe(true);
|
|
369
|
+
expect(defer(true, B0 + 15_000)).toBe(true);
|
|
370
|
+
expect(defer(true, B0 + BOUND)).toBe(false); // fresh bound elapsed → drains
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does NOT reset the clock on the normal poll cadence — a genuinely wedged busy session still drains at the bound (wedge budget preserved)", () => {
|
|
374
|
+
const BOUND = 20_000;
|
|
375
|
+
const defer = makeSessionBusyDrainDeferral(BOUND); // default staleGap = 15s
|
|
376
|
+
// Polls arrive every 5s (< staleGap) throughout one continuous episode, so
|
|
377
|
+
// the clock is never reset and the bound elapses on schedule.
|
|
378
|
+
expect(defer(true, 0)).toBe(true);
|
|
379
|
+
expect(defer(true, 5_000)).toBe(true);
|
|
380
|
+
expect(defer(true, 10_000)).toBe(true);
|
|
381
|
+
expect(defer(true, 15_000)).toBe(true);
|
|
382
|
+
expect(defer(true, 20_000)).toBe(false); // bound reached — drains, not silenced forever
|
|
383
|
+
});
|
|
339
384
|
});
|
|
340
385
|
|
|
341
386
|
describe("obligationSweep — F2 decision half: a poke-cleared-but-busy session defers, then re-asks (bounded)", () => {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isSilentFlushMarker,
|
|
21
21
|
isCompositeSilentNoise,
|
|
22
22
|
endsWithSilentMarker,
|
|
23
|
+
isSilentSentinelCardOutcome,
|
|
23
24
|
isTurnFlushSafetyEnabled,
|
|
24
25
|
selectFlushDeliveryText,
|
|
25
26
|
FLUSH_SUBSTANTIVE_MIN_CHARS,
|
|
@@ -893,3 +894,85 @@ describe('selectFlushDeliveryText — structural provenance (followedByToolUse)
|
|
|
893
894
|
expect(out).not.toContain('Here are the figures')
|
|
894
895
|
})
|
|
895
896
|
})
|
|
897
|
+
|
|
898
|
+
// #4348 — the pure gate that suppresses the per-turn activity/telemetry card
|
|
899
|
+
// when the turn's whole user-facing outcome was an intentional silent sentinel.
|
|
900
|
+
describe('isSilentSentinelCardOutcome (#4348)', () => {
|
|
901
|
+
const base = { replyCalled: false, lastReplyText: '', capturedText: [] as string[], finalAnswerEverDelivered: false }
|
|
902
|
+
|
|
903
|
+
it('reply("NO_REPLY") — the blocked sentinel-only reply is a silent outcome', () => {
|
|
904
|
+
expect(isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: 'NO_REPLY' })).toBe(true)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
it('trailing punctuation and case variants still count as silent', () => {
|
|
908
|
+
for (const t of ['NO_REPLY.', 'no_reply', 'HEARTBEAT_OK', 'heartbeat_ok!', ' NO_REPLY ']) {
|
|
909
|
+
expect(isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: t })).toBe(true)
|
|
910
|
+
}
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
it('composite silent noise ("Sent.\\nNO_REPLY\\nNO_REPLY") via the reply payload is silent', () => {
|
|
914
|
+
expect(
|
|
915
|
+
isSilentSentinelCardOutcome({ ...base, replyCalled: true, lastReplyText: 'Sent.\nNO_REPLY\nNO_REPLY' }),
|
|
916
|
+
).toBe(true)
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
it('flush path (no reply): prose + trailing NO_REPLY (H6/#2053) is silent', () => {
|
|
920
|
+
expect(
|
|
921
|
+
isSilentSentinelCardOutcome({
|
|
922
|
+
...base,
|
|
923
|
+
replyCalled: false,
|
|
924
|
+
capturedText: ["Nothing actionable in today's digest.", 'NO_REPLY'],
|
|
925
|
+
}),
|
|
926
|
+
).toBe(true)
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('a real reply is NOT silent — the card is a legitimate record', () => {
|
|
930
|
+
expect(
|
|
931
|
+
isSilentSentinelCardOutcome({
|
|
932
|
+
...base,
|
|
933
|
+
replyCalled: true,
|
|
934
|
+
lastReplyText: 'The three services are all green.',
|
|
935
|
+
finalAnswerEverDelivered: true,
|
|
936
|
+
}),
|
|
937
|
+
).toBe(false)
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
it('finalAnswerEverDelivered short-circuits even when a stray NO_REPLY is the last reply text', () => {
|
|
941
|
+
// An interim answer landed, then a trailing sentinel — the delivered answer wins.
|
|
942
|
+
expect(
|
|
943
|
+
isSilentSentinelCardOutcome({
|
|
944
|
+
...base,
|
|
945
|
+
replyCalled: true,
|
|
946
|
+
lastReplyText: 'NO_REPLY',
|
|
947
|
+
finalAnswerEverDelivered: true,
|
|
948
|
+
}),
|
|
949
|
+
).toBe(false)
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
it('a delivered reply that merely mentions the sentinel in prose is NOT silent', () => {
|
|
953
|
+
// Non-marker content ⇒ the sentinel-reply-guard would not drop it ⇒ delivered.
|
|
954
|
+
expect(
|
|
955
|
+
isSilentSentinelCardOutcome({
|
|
956
|
+
...base,
|
|
957
|
+
replyCalled: true,
|
|
958
|
+
lastReplyText: 'Reply with exactly NO_REPLY if there is nothing to add.',
|
|
959
|
+
}),
|
|
960
|
+
).toBe(false)
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('endsWithSilentMarker does NOT suppress a DELIVERED reply of "prose\\nNO_REPLY"', () => {
|
|
964
|
+
// The guard lets prose+trailing-NO_REPLY through the reply tool, so its
|
|
965
|
+
// prose reached chat; the card must stay even though it ends with a marker.
|
|
966
|
+
expect(
|
|
967
|
+
isSilentSentinelCardOutcome({
|
|
968
|
+
...base,
|
|
969
|
+
replyCalled: true,
|
|
970
|
+
lastReplyText: 'Here is the full answer.\nNO_REPLY',
|
|
971
|
+
}),
|
|
972
|
+
).toBe(false)
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
it('a genuinely empty dark turn (no reply, no text) is NOT a sentinel', () => {
|
|
976
|
+
expect(isSilentSentinelCardOutcome({ ...base })).toBe(false)
|
|
977
|
+
})
|
|
978
|
+
})
|
|
@@ -135,6 +135,85 @@ export function endsWithSilentMarker(text: string | undefined): boolean {
|
|
|
135
135
|
return isSilentFlushMarker(lines[lines.length - 1])
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Inputs for {@link isSilentSentinelCardOutcome} — the fields of the ending
|
|
140
|
+
* turn the card-suppression gate reads. All are already tracked on the
|
|
141
|
+
* gateway's `CurrentTurn`; passed as a plain struct so the decision is a pure,
|
|
142
|
+
* unit-testable core (`gateway.ts` / `narrative-lane.ts` are not importable in
|
|
143
|
+
* tests).
|
|
144
|
+
*/
|
|
145
|
+
export interface SilentSentinelCardInput {
|
|
146
|
+
/** True when the model called `reply` / `stream_reply` at least once. */
|
|
147
|
+
replyCalled: boolean
|
|
148
|
+
/**
|
|
149
|
+
* The most-recent `reply` / `stream_reply` `input.text` this turn — empty
|
|
150
|
+
* string when the reply tool was never called (`CurrentTurn.lastReplyText`).
|
|
151
|
+
*/
|
|
152
|
+
lastReplyText: string
|
|
153
|
+
/**
|
|
154
|
+
* Raw assistant text blocks accumulated across the turn — the same source
|
|
155
|
+
* `decideTurnFlush` classifies (`CurrentTurn.capturedText`). Only consulted
|
|
156
|
+
* on the reply-never-called (flush) path.
|
|
157
|
+
*/
|
|
158
|
+
capturedText: string[]
|
|
159
|
+
/**
|
|
160
|
+
* A SUBSTANTIVE final answer reached the user at some point this turn
|
|
161
|
+
* (`CurrentTurn.finalAnswerEverDelivered`). When true the card is a
|
|
162
|
+
* legitimate record beside a delivered answer and is NEVER suppressed.
|
|
163
|
+
*/
|
|
164
|
+
finalAnswerEverDelivered: boolean
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Decide whether an ending turn's user-facing outcome was an INTENTIONAL silent
|
|
169
|
+
* sentinel (NO_REPLY / HEARTBEAT_OK) — the deterministic gate that suppresses
|
|
170
|
+
* the per-turn activity/telemetry card for a turn that said nothing to the user.
|
|
171
|
+
*
|
|
172
|
+
* The symptom (#4348): a background sub-agent handback injects a forced-synthesis
|
|
173
|
+
* turn, the parent legitimately answers `NO_REPLY`, and the gateway still
|
|
174
|
+
* finalizes a `🤖 Agent · done · 0 tools · … · ✓ NO_REPLY` card in the chat.
|
|
175
|
+
* `sentinel-reply-guard-pretool.mjs` already drops the sentinel-only reply so
|
|
176
|
+
* nothing reaches chat, but the blocked `tool_use` still stamps `lastReplyText`,
|
|
177
|
+
* and the activity card finalizes as pure noise.
|
|
178
|
+
*
|
|
179
|
+
* This reuses the SAME silent-turn predicates the flush safety net and the Stop
|
|
180
|
+
* hook use — it invents no second notion of "silent":
|
|
181
|
+
* - Reply path (`replyCalled`): the model called `reply` with a sentinel-ONLY
|
|
182
|
+
* payload, which `sentinel-reply-guard-pretool.mjs` drops before chat, so the
|
|
183
|
+
* card's whole outcome is a silence signal that never became a message. Only
|
|
184
|
+
* `isSilentFlushMarker` / `isCompositeSilentNoise` count here — a
|
|
185
|
+
* `prose\nNO_REPLY` reply is NOT dropped by that guard (it has non-marker
|
|
186
|
+
* content), so its prose WAS delivered and its card must stay. `endsWithSilentMarker`
|
|
187
|
+
* is deliberately NOT applied to a delivered reply.
|
|
188
|
+
* - Flush path (reply never called): the turn's captured terminal text is its
|
|
189
|
+
* outcome, so match every shape `decideTurnFlush` treats as `silent-marker` —
|
|
190
|
+
* bare marker, composite noise, and prose+trailing-`NO_REPLY` (#2053 / H6).
|
|
191
|
+
*
|
|
192
|
+
* The `finalAnswerEverDelivered` short-circuit is the normal-case guarantee: a
|
|
193
|
+
* turn that actually delivered a substantive answer keeps its card, so a real
|
|
194
|
+
* reply (even one a later stray `NO_REPLY` follows) is never suppressed.
|
|
195
|
+
*/
|
|
196
|
+
export function isSilentSentinelCardOutcome(input: SilentSentinelCardInput): boolean {
|
|
197
|
+
// A substantive answer reached the user — the card is a legitimate record.
|
|
198
|
+
if (input.finalAnswerEverDelivered) return false
|
|
199
|
+
// Reply-tool outcome: a sentinel-ONLY payload the guard dropped before chat.
|
|
200
|
+
if (isSilentFlushMarker(input.lastReplyText) || isCompositeSilentNoise(input.lastReplyText)) {
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
// Flush outcome: reply never called; classify the captured terminal text with
|
|
204
|
+
// the exact predicates decideTurnFlush's `silent-marker` skip uses.
|
|
205
|
+
if (!input.replyCalled) {
|
|
206
|
+
const joined = input.capturedText.join('\n\n').trim()
|
|
207
|
+
if (
|
|
208
|
+
joined.length > 0 &&
|
|
209
|
+
(isSilentFlushMarker(joined) || isCompositeSilentNoise(joined) || endsWithSilentMarker(joined))
|
|
210
|
+
) {
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
|
|
138
217
|
/**
|
|
139
218
|
* Substantive-answer floor (chars, trimmed). Mirrors
|
|
140
219
|
* `final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS` and
|