switchroom 0.20.6 → 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.
Files changed (30) hide show
  1. package/dist/agent-scheduler/index.js +106 -12
  2. package/dist/auth-broker/index.js +71 -9
  3. package/dist/cli/notion-write-pretool.mjs +68 -7
  4. package/dist/cli/switchroom.js +387 -30
  5. package/dist/host-control/main.js +72 -10
  6. package/dist/vault/approvals/kernel-server.js +71 -9
  7. package/dist/vault/broker/server.js +71 -9
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  11. package/telegram-plugin/dist/gateway/gateway.js +342 -120
  12. package/telegram-plugin/dist/server.js +5 -2
  13. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  14. package/telegram-plugin/gateway/cron-session.ts +66 -0
  15. package/telegram-plugin/gateway/gateway.ts +50 -54
  16. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  17. package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
  18. package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
  19. package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
  20. package/telegram-plugin/gateway/represent-delivery-guard.ts +188 -0
  21. package/telegram-plugin/gateway/stream-render.ts +24 -2
  22. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  23. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  24. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  25. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  26. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +145 -9
  27. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  28. package/telegram-plugin/tests/represent-guard.test.ts +295 -0
  29. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  30. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -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