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.
- package/dist/agent-scheduler/index.js +106 -12
- package/dist/auth-broker/index.js +71 -9
- package/dist/cli/notion-write-pretool.mjs +68 -7
- package/dist/cli/switchroom.js +387 -30
- package/dist/host-control/main.js +72 -10
- package/dist/vault/approvals/kernel-server.js +71 -9
- package/dist/vault/broker/server.js +71 -9
- 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 +342 -120
- 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 +50 -54
- package/telegram-plugin/gateway/narrative-lane.ts +21 -1
- package/telegram-plugin/gateway/obligation-ledger.ts +9 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +19 -4
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +43 -3
- package/telegram-plugin/gateway/represent-delivery-guard.ts +188 -0
- package/telegram-plugin/gateway/stream-render.ts +24 -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/pending-inbound-buffer.test.ts +145 -9
- package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
- package/telegram-plugin/tests/represent-guard.test.ts +295 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
- 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
|