switchroom 0.20.11 → 0.20.13
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 +5 -2
- package/dist/auth-broker/index.js +32 -25
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/self-improve-stop.mjs +13 -1
- package/dist/cli/switchroom.js +3069 -1146
- package/dist/host-control/main.js +33 -26
- package/dist/vault/approvals/kernel-server.js +32 -25
- package/dist/vault/broker/server.js +32 -25
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +7 -4
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/skills/switchroom-release/SKILL.md +3 -2
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/bunfig.toml +9 -5
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +219 -127
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
- package/telegram-plugin/gateway/gateway.ts +25 -59
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
- package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +8 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
- package/telegram-plugin/hooks/audience-classify.mjs +193 -0
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
- package/telegram-plugin/outbox.ts +69 -3
- package/telegram-plugin/silent-end.ts +48 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
- package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
- package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-live-path-review-4490.test.ts — the self-improvement card gate on
|
|
3
|
+
* the LIVE (elected) send path, issue #4490 (follow-up to PR #4485 / #4141).
|
|
4
|
+
*
|
|
5
|
+
* THE GAP THIS CLOSES
|
|
6
|
+
* --------------------
|
|
7
|
+
* #4485 wired the audience gate + title framing into the OUTBOX SWEEP only
|
|
8
|
+
* (`decideOutboxSweep` in `outbox.ts`, exercised end-to-end in
|
|
9
|
+
* `outbox-self-improve-review.test.ts`). But the outbox sweep only ever
|
|
10
|
+
* fires when the Stop hook writes a durable outbox RECORD, and — exactly as
|
|
11
|
+
* #4141 already found for the reply-throw case — the DOMINANT live shape
|
|
12
|
+
* never does that:
|
|
13
|
+
*
|
|
14
|
+
* A self-improvement review turn calls the reply tool (itself a prompt
|
|
15
|
+
* violation — the review's contract is to act silently), and it throws.
|
|
16
|
+
* `scanForOutboxCapture` classifies the reply-tool call from its INPUT,
|
|
17
|
+
* sees a QUALIFYING final answer, and marks
|
|
18
|
+
* `replyAlreadyDeliveredThisTurn = true` even though the call errored. The
|
|
19
|
+
* Stop hook's single-writer election therefore takes the #3510 branch,
|
|
20
|
+
* writes NO outbox record, and hands the trailing prose to the gateway's
|
|
21
|
+
* captured-prose bridge (`deliverCapturedProse`,
|
|
22
|
+
* `gateway/outbound-send-path.ts`) — a machine the outbox sweep's card
|
|
23
|
+
* gate can never reach.
|
|
24
|
+
*
|
|
25
|
+
* Before this fix that bridge had NO audience gate and NO self-improvement
|
|
26
|
+
* framing at all: a non-card review turn's raw, unlabelled reasoning would
|
|
27
|
+
* be sent to the operator exactly like any other captured prose — the same
|
|
28
|
+
* leak #4485 closed on the sweep, still open on the bridge.
|
|
29
|
+
*
|
|
30
|
+
* WHAT THIS FILE PINS
|
|
31
|
+
* --------------------
|
|
32
|
+
* (0) `parseChannelEnvelope` recovers `source="self_improve_review"` from
|
|
33
|
+
* the REAL double-sourced envelope shape the gateway actually emits
|
|
34
|
+
* (`<channel source="switchroom-telegram" … source="self_improve_review"
|
|
35
|
+
* …>`), not just the single-sourced shape other fixtures use.
|
|
36
|
+
* (a) SURFACING: a review turn ending in a CARD is delivered, self-labelled.
|
|
37
|
+
* (b) NO-OP: a review turn ending in RAW REASONING is suppressed on the
|
|
38
|
+
* bridge — zero chat-visible calls.
|
|
39
|
+
* (b') REVERT CHECK: with the audience gate off, the same raw reasoning
|
|
40
|
+
* still reaches the operator (proving (b) is load-bearing) but titled
|
|
41
|
+
* (the residual belt-and-braces framing, so it can never be raw).
|
|
42
|
+
* (c) ANTI-SILENCE / SEVERITY-3: a genuine waiting operator's answer is
|
|
43
|
+
* NEVER swallowed by the review branch — both for a normal foreground
|
|
44
|
+
* turn, and for the specific shape the reviewer flagged: a user
|
|
45
|
+
* message arriving mid-review-turn becomes the LAST enqueue, so
|
|
46
|
+
* `resolveCaptureAnchor` anchors on it instead of the review's own
|
|
47
|
+
* enqueue, and the review branch must not fire at all.
|
|
48
|
+
* (d) WIRED: the four hops between the transcript scan and the bridge
|
|
49
|
+
* (`silent-end-scan.mjs` → `silent-end-interrupt-stop.mjs` →
|
|
50
|
+
* `silent-end.ts` → `stream-render.ts`) actually carry the signal in
|
|
51
|
+
* production, not just in a hand-built `deliverCapturedProse` call.
|
|
52
|
+
*
|
|
53
|
+
* Every assertion drives REAL machinery: the REAL Stop hook spawned as a
|
|
54
|
+
* subprocess against a REAL transcript, the REAL `decideCapturedProseDelivery`,
|
|
55
|
+
* and the REAL `deliverCapturedProse` against a RECORDING fake Bot API.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
59
|
+
import { spawnSync } from 'node:child_process'
|
|
60
|
+
import { mkdtempSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs'
|
|
61
|
+
import { tmpdir } from 'node:os'
|
|
62
|
+
import { join, resolve } from 'node:path'
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
SELF_IMPROVEMENT_TITLE,
|
|
66
|
+
REVIEW_SOURCE,
|
|
67
|
+
} from '../hooks/audience-classify.mjs'
|
|
68
|
+
import { scanTurnForFinalReply } from '../hooks/silent-end-scan.mjs'
|
|
69
|
+
import { deliverCapturedProse } from '../gateway/outbound-send-path.js'
|
|
70
|
+
import { decideCapturedProseDelivery } from '../silent-end.js'
|
|
71
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
72
|
+
|
|
73
|
+
const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
74
|
+
|
|
75
|
+
// Synthetic ids only (check-no-pii-secrets forbids real chat/user ids).
|
|
76
|
+
const DM_CHAT = '5550002'
|
|
77
|
+
const INBOUND_MSG_ID = 9130
|
|
78
|
+
const USER_MSG_ID = 9131
|
|
79
|
+
|
|
80
|
+
/** Raw review reasoning — must never reach the operator unlabelled. */
|
|
81
|
+
const REVIEW_REASONING = [
|
|
82
|
+
'The failing assertion traced back to a stale fixture, not the code path I',
|
|
83
|
+
'was reviewing, so I am not proposing a change here — logging this as a',
|
|
84
|
+
'non-finding rather than forcing a suggestion just to have said something.',
|
|
85
|
+
].join(' ')
|
|
86
|
+
|
|
87
|
+
/** A well-formed card, exactly as `buildReviewPrompt` instructs the model. */
|
|
88
|
+
const REVIEW_CARD = [
|
|
89
|
+
`${SELF_IMPROVEMENT_TITLE} — pending suggestion logged`,
|
|
90
|
+
'- **Signal:** repeated manual retry of the same flaky assertion',
|
|
91
|
+
'- **Suggestion:** add a fixture reset before the retry loop',
|
|
92
|
+
'- **Status:** T2, logged for your review, nothing auto-applied',
|
|
93
|
+
].join('\n')
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A normal operator answer on a normal (non-review) turn. Must clear the
|
|
97
|
+
* scanner's 200-char `FINAL_ANSWER_MIN_CHARS` substance floor for the
|
|
98
|
+
* `trailing-text-after-reply` path (silent-end-scan.mjs:578) — a qualifying
|
|
99
|
+
* reply-tool call already happened in these fixtures (it just threw), so
|
|
100
|
+
* anything shorter is amnestied as a mere "interim ack closer" and the scan
|
|
101
|
+
* returns `{decided:'allow'}` instead of electing the bridge, which starved
|
|
102
|
+
* these fixtures of any elected state to test against.
|
|
103
|
+
*/
|
|
104
|
+
const NORMAL_ANSWER =
|
|
105
|
+
'The deploy went out cleanly and the health check came back green a minute ' +
|
|
106
|
+
'later, all three services reporting healthy and no errors in the logs ' +
|
|
107
|
+
'since the rollout finished. Latency held steady through the rollout window ' +
|
|
108
|
+
'and no alerts fired on any of the dashboards I checked afterward.'
|
|
109
|
+
|
|
110
|
+
function makeStateDir(): string {
|
|
111
|
+
// NEVER ~/.switchroom — a test that writes there corrupts production state.
|
|
112
|
+
return mkdtempSync(join(tmpdir(), 'w4490-live-review-'))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeTranscript(dir: string, lines: object[]): string {
|
|
116
|
+
const p = join(dir, 'transcript.jsonl')
|
|
117
|
+
writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
|
|
118
|
+
return p
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runHook(transcriptPath: string, stateDir: string, extraEnv: Record<string, string> = {}) {
|
|
122
|
+
return spawnSync('node', [HOOK], {
|
|
123
|
+
input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
timeout: 10_000,
|
|
126
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: stateDir, ...extraEnv },
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeFreshHeartbeat(dir: string): void {
|
|
131
|
+
writeFileSync(join(dir, 'gateway-heartbeat'), 'up', 'utf8')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readElectedState(dir: string): Record<string, unknown> | null {
|
|
135
|
+
const p = join(dir, 'silent-end-pending.json')
|
|
136
|
+
if (!existsSync(p)) return null
|
|
137
|
+
return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function journalLines(dir: string): Array<Record<string, unknown>> {
|
|
141
|
+
const p = join(dir, 'outbox', 'delivered.jsonl')
|
|
142
|
+
if (!existsSync(p)) return []
|
|
143
|
+
return readFileSync(p, 'utf8')
|
|
144
|
+
.split('\n')
|
|
145
|
+
.filter((l) => l.trim().length > 0)
|
|
146
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function outboxRecordCount(dir: string): number {
|
|
150
|
+
const outbox = join(dir, 'outbox')
|
|
151
|
+
if (!existsSync(outbox)) return 0
|
|
152
|
+
return readdirSync(outbox).filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl').length
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A RECORDING fake Bot API — every chat-visible method is counted, so a
|
|
157
|
+
* send-count assertion covers all of them, not just the one the test
|
|
158
|
+
* remembered to stub.
|
|
159
|
+
*/
|
|
160
|
+
function recordingBot() {
|
|
161
|
+
const calls: Array<{ method: string; chatId: string; text: string }> = []
|
|
162
|
+
let nextId = 900
|
|
163
|
+
return {
|
|
164
|
+
calls,
|
|
165
|
+
chatCalls: () => calls,
|
|
166
|
+
api: {
|
|
167
|
+
sendRichMessage: async (chatId: string, body: { markdown: string }) => {
|
|
168
|
+
calls.push({ method: 'sendRichMessage', chatId, text: body.markdown })
|
|
169
|
+
return { message_id: nextId++ }
|
|
170
|
+
},
|
|
171
|
+
sendMessage: async (chatId: string, text: string) => {
|
|
172
|
+
calls.push({ method: 'sendMessage', chatId, text })
|
|
173
|
+
return { message_id: nextId++ }
|
|
174
|
+
},
|
|
175
|
+
editMessageText: async (chatId: string, _mid: number, text: string) => {
|
|
176
|
+
calls.push({ method: 'editMessageText', chatId, text })
|
|
177
|
+
return { message_id: nextId++ }
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The REAL review-turn ELECTED shape, using the REAL double-sourced envelope
|
|
187
|
+
* (`source="switchroom-telegram"` from the outer wrapper, THEN
|
|
188
|
+
* `source="self_improve_review"` from the synthesized-inbound meta the
|
|
189
|
+
* gateway hoists onto the same tag — `review-prompt.ts:60-61`), a QUALIFYING
|
|
190
|
+
* reply-tool call (no `disable_notification`, so `isFinalAnswerReply` is true
|
|
191
|
+
* on the input alone — the review turn violating its own silent-turn
|
|
192
|
+
* contract), which THROWS, followed by trailing prose over the bridge's
|
|
193
|
+
* floor.
|
|
194
|
+
*/
|
|
195
|
+
function electedReviewThrowTranscript(dir: string, prose: string): string {
|
|
196
|
+
return writeTranscript(dir, [
|
|
197
|
+
{
|
|
198
|
+
type: 'queue-operation',
|
|
199
|
+
operation: 'enqueue',
|
|
200
|
+
content:
|
|
201
|
+
`<channel source="switchroom-telegram" chat_id="${DM_CHAT}" ` +
|
|
202
|
+
`message_id="${INBOUND_MSG_ID}" source="${REVIEW_SOURCE}">` +
|
|
203
|
+
`[self-improvement review] The turn-end gate detected a learning signal. ` +
|
|
204
|
+
`Run a focused, forked review.</channel>`,
|
|
205
|
+
timestamp: 1000,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
type: 'assistant',
|
|
209
|
+
message: {
|
|
210
|
+
content: [
|
|
211
|
+
{
|
|
212
|
+
type: 'tool_use',
|
|
213
|
+
id: 'toolu_review_elected',
|
|
214
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
215
|
+
input: { text: 'Here is what I found.' },
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
type: 'user',
|
|
222
|
+
message: {
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: 'tool_result',
|
|
226
|
+
tool_use_id: 'toolu_review_elected',
|
|
227
|
+
is_error: true,
|
|
228
|
+
content: 'Error: request timed out after 30000ms',
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
|
|
234
|
+
])
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The SEVERITY-3 shape: the review's own enqueue, THEN a genuine user
|
|
239
|
+
* message enqueued mid-turn (the LAST enqueue in the file), THEN the
|
|
240
|
+
* review's reply-tool throw and trailing prose. `resolveCaptureAnchor`
|
|
241
|
+
* anchors on the LAST enqueue, so this transcript's anchor is the user
|
|
242
|
+
* message, not the review — the review's `source` must never leak through.
|
|
243
|
+
*/
|
|
244
|
+
function userMessageDuringReviewTranscript(dir: string, prose: string): string {
|
|
245
|
+
return writeTranscript(dir, [
|
|
246
|
+
{
|
|
247
|
+
type: 'queue-operation',
|
|
248
|
+
operation: 'enqueue',
|
|
249
|
+
content:
|
|
250
|
+
`<channel source="switchroom-telegram" chat_id="${DM_CHAT}" ` +
|
|
251
|
+
`message_id="${INBOUND_MSG_ID}" source="${REVIEW_SOURCE}">` +
|
|
252
|
+
`[self-improvement review] The turn-end gate detected a learning signal.</channel>`,
|
|
253
|
+
timestamp: 1000,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
type: 'queue-operation',
|
|
257
|
+
operation: 'enqueue',
|
|
258
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${USER_MSG_ID}">are you still there?</channel>`,
|
|
259
|
+
timestamp: 2000,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
type: 'assistant',
|
|
263
|
+
message: {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: 'tool_use',
|
|
267
|
+
id: 'toolu_review_mid',
|
|
268
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
269
|
+
input: { text: 'One sec.' },
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
type: 'user',
|
|
276
|
+
message: {
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: 'tool_result',
|
|
280
|
+
tool_use_id: 'toolu_review_mid',
|
|
281
|
+
is_error: true,
|
|
282
|
+
content: 'Error: request timed out after 30000ms',
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
|
|
288
|
+
])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Drive the REAL captured-prose bridge against the recording bot. */
|
|
292
|
+
async function realBridge(
|
|
293
|
+
stateDir: string,
|
|
294
|
+
bot: ReturnType<typeof recordingBot>,
|
|
295
|
+
decision: { text?: string; replyToolThrewThisTurn?: boolean; reviewOriginated?: boolean },
|
|
296
|
+
) {
|
|
297
|
+
const stderr: string[] = []
|
|
298
|
+
const prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
299
|
+
process.env.TELEGRAM_STATE_DIR = stateDir
|
|
300
|
+
const origWrite = process.stderr.write.bind(process.stderr)
|
|
301
|
+
;(process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
|
|
302
|
+
stderr.push(String(s))
|
|
303
|
+
return true
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await deliverCapturedProse(
|
|
307
|
+
{
|
|
308
|
+
outboundDedup: new OutboundDedupCache({}),
|
|
309
|
+
bot: bot as never,
|
|
310
|
+
robustApiCall: passthroughRetry,
|
|
311
|
+
redactOutboundText: (t: string) => t,
|
|
312
|
+
recordOutbound: () => {},
|
|
313
|
+
HISTORY_ENABLED: false,
|
|
314
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
315
|
+
obligationLedger: { close: () => {} },
|
|
316
|
+
clearSilentEndState: () => {},
|
|
317
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
318
|
+
hasOutboundDeliveredSince: () => false,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
chatId: DM_CHAT,
|
|
322
|
+
threadId: undefined,
|
|
323
|
+
statusKeyStr: `${DM_CHAT}:_`,
|
|
324
|
+
registryKey: null,
|
|
325
|
+
originTurnId: NONCE,
|
|
326
|
+
text: decision.text!,
|
|
327
|
+
replyToolThrewThisTurn: decision.replyToolThrewThisTurn === true,
|
|
328
|
+
reviewOriginated: decision.reviewOriginated === true,
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
} finally {
|
|
332
|
+
;(process.stderr as unknown as { write: typeof origWrite }).write = origWrite
|
|
333
|
+
if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
|
|
334
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
335
|
+
}
|
|
336
|
+
return { stderr }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const NONCE = `${DM_CHAT}:_#${INBOUND_MSG_ID}`
|
|
340
|
+
|
|
341
|
+
describe('#4490 (0) — the envelope parser recovers the review source from the REAL double-sourced tag', () => {
|
|
342
|
+
it('scanTurnForFinalReply carries the double-sourced envelope\'s source through to the block result', () => {
|
|
343
|
+
const dir = makeStateDir()
|
|
344
|
+
try {
|
|
345
|
+
const t = electedReviewThrowTranscript(dir, REVIEW_REASONING)
|
|
346
|
+
const result = scanTurnForFinalReply(readFileSync(t, 'utf8')) as {
|
|
347
|
+
decided: string
|
|
348
|
+
source?: string
|
|
349
|
+
replyToolThrewThisTurn?: boolean
|
|
350
|
+
}
|
|
351
|
+
expect(result.decided).toBe('block')
|
|
352
|
+
expect(result.source).toBe(REVIEW_SOURCE)
|
|
353
|
+
expect(result.replyToolThrewThisTurn).toBe(true)
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(dir, { recursive: true, force: true })
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe('#4490 (d) — the signal is WIRED end to end through the real Stop hook', () => {
|
|
361
|
+
let dir: string
|
|
362
|
+
beforeEach(() => {
|
|
363
|
+
dir = makeStateDir()
|
|
364
|
+
writeFreshHeartbeat(dir)
|
|
365
|
+
})
|
|
366
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
367
|
+
|
|
368
|
+
it('PREMISE: a thrown QUALIFYING reply on a review turn writes no outbox record and elects the bridge', () => {
|
|
369
|
+
const t = electedReviewThrowTranscript(dir, REVIEW_REASONING)
|
|
370
|
+
const res = runHook(t, dir)
|
|
371
|
+
expect(res.status).toBe(0)
|
|
372
|
+
expect(outboxRecordCount(dir)).toBe(0)
|
|
373
|
+
expect(res.stderr).toContain('deferring to single-writer election')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('the elected state file carries reviewOriginated to the bridge', () => {
|
|
377
|
+
runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
|
|
378
|
+
const state = readElectedState(dir)
|
|
379
|
+
expect(state).not.toBeNull()
|
|
380
|
+
expect(state!.reviewOriginated).toBe(true)
|
|
381
|
+
expect(state!.pendingText).toBe(REVIEW_REASONING)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('a NON-review turn carries no reviewOriginated field — no stale carryover', () => {
|
|
385
|
+
// Seed a state file that already claims review-origin, then run a CLEAN
|
|
386
|
+
// (non-review) turn. `buildNextState` must DELETE the field, not spread
|
|
387
|
+
// it forward.
|
|
388
|
+
writeFileSync(
|
|
389
|
+
join(dir, 'silent-end-pending.json'),
|
|
390
|
+
JSON.stringify({ retryCount: 0, reviewOriginated: true, turnKey: 'stale' }),
|
|
391
|
+
'utf8',
|
|
392
|
+
)
|
|
393
|
+
const t = writeTranscript(dir, [
|
|
394
|
+
{
|
|
395
|
+
type: 'queue-operation',
|
|
396
|
+
operation: 'enqueue',
|
|
397
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">status?</channel>`,
|
|
398
|
+
timestamp: 1000,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
type: 'assistant',
|
|
402
|
+
message: {
|
|
403
|
+
content: [
|
|
404
|
+
{
|
|
405
|
+
type: 'tool_use',
|
|
406
|
+
id: 'toolu_normal',
|
|
407
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
408
|
+
input: { text: 'checking' },
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
type: 'user',
|
|
415
|
+
message: {
|
|
416
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_normal', is_error: true, content: 'Error: timeout' }],
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: NORMAL_ANSWER }] } },
|
|
420
|
+
])
|
|
421
|
+
runHook(t, dir)
|
|
422
|
+
const state = readElectedState(dir)
|
|
423
|
+
expect(state).not.toBeNull()
|
|
424
|
+
expect(state!.reviewOriginated).toBeUndefined()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('decideCapturedProseDelivery surfaces reviewOriginated from the state file', () => {
|
|
428
|
+
runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
|
|
429
|
+
const decision = decideCapturedProseDelivery(
|
|
430
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
431
|
+
{ stateDir: dir },
|
|
432
|
+
)
|
|
433
|
+
expect(decision.deliver).toBe(true)
|
|
434
|
+
expect(decision.reviewOriginated).toBe(true)
|
|
435
|
+
expect(decision.text).toBe(REVIEW_REASONING)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('stream-render forwards reviewOriginated from the decision into the bridge call (structural pin)', () => {
|
|
439
|
+
// Mirrors the #4141 structural pin for `replyToolThrewThisTurn`: the ONE
|
|
440
|
+
// link between the decision and the bridge lives inside a ~2000-line
|
|
441
|
+
// event handler that cannot be driven in isolation, so pin it by source
|
|
442
|
+
// inspection — without this argument the feature is inert in production
|
|
443
|
+
// while every decision-side / bridge-side test in this file still passes.
|
|
444
|
+
const src = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf8')
|
|
445
|
+
const call = src.slice(src.indexOf('void deliverCapturedProse({'))
|
|
446
|
+
const body = call.slice(0, call.indexOf('\n })'))
|
|
447
|
+
expect(body).toContain('reviewOriginated: proseDecision.reviewOriginated === true')
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
describe('#4490 (a)/(b)/(b\') — the card gate on the LIVE bridge, end to end', () => {
|
|
452
|
+
let dir: string
|
|
453
|
+
beforeEach(() => {
|
|
454
|
+
dir = makeStateDir()
|
|
455
|
+
writeFreshHeartbeat(dir)
|
|
456
|
+
})
|
|
457
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
458
|
+
|
|
459
|
+
it('(a) SURFACING: a review turn ending in a CARD is delivered on the bridge, self-labelled', async () => {
|
|
460
|
+
runHook(electedReviewThrowTranscript(dir, REVIEW_CARD), dir)
|
|
461
|
+
const decision = decideCapturedProseDelivery(
|
|
462
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
463
|
+
{ stateDir: dir },
|
|
464
|
+
)
|
|
465
|
+
expect(decision.reviewOriginated).toBe(true)
|
|
466
|
+
|
|
467
|
+
const bot = recordingBot()
|
|
468
|
+
await realBridge(dir, bot, decision)
|
|
469
|
+
|
|
470
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
471
|
+
const sent = bot.chatCalls()[0]!.text
|
|
472
|
+
// This fixture's qualifying reply also THREW (#4141 provenance banner),
|
|
473
|
+
// so the composed body is [banner][card] — the banner is prepended
|
|
474
|
+
// before the card, not the title. Assert exactly one title occurrence
|
|
475
|
+
// (no double-framing of an already-titled card, per #4489) rather than
|
|
476
|
+
// `startsWith`, which would be a false requirement here.
|
|
477
|
+
const titleOccurrences = sent.split(SELF_IMPROVEMENT_TITLE).length - 1
|
|
478
|
+
expect(titleOccurrences).toBe(1)
|
|
479
|
+
expect(sent).toContain('add a fixture reset before the retry loop')
|
|
480
|
+
expect(journalLines(dir)[0]!.suppressedAudience).toBeUndefined()
|
|
481
|
+
expect(journalLines(dir)[0]!.audience).toBe('user')
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('(b) NO-OP — THE GAP THIS CLOSES: a review turn ending in RAW REASONING is suppressed on the bridge, zero sends', async () => {
|
|
485
|
+
runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
|
|
486
|
+
const decision = decideCapturedProseDelivery(
|
|
487
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
488
|
+
{ stateDir: dir },
|
|
489
|
+
)
|
|
490
|
+
expect(decision.reviewOriginated).toBe(true)
|
|
491
|
+
|
|
492
|
+
const bot = recordingBot()
|
|
493
|
+
await realBridge(dir, bot, decision)
|
|
494
|
+
|
|
495
|
+
// THE assertion this whole file exists to make: before #4490's fix this
|
|
496
|
+
// path had no audience gate at all, so the raw reasoning was sent here
|
|
497
|
+
// exactly like any other captured prose.
|
|
498
|
+
expect(bot.chatCalls()).toEqual([])
|
|
499
|
+
const journal = journalLines(dir)
|
|
500
|
+
expect(journal).toHaveLength(1)
|
|
501
|
+
expect(journal[0]!.suppressedAudience).toBe('internal')
|
|
502
|
+
expect(journal[0]!.audience).toBe('internal')
|
|
503
|
+
expect(journal[0]!.tgMessageId).toBeUndefined()
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('(b\') REVERT CHECK: with the audience gate OFF the same reasoning still reaches the operator — but TITLED, never raw', async () => {
|
|
507
|
+
const prev = process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE
|
|
508
|
+
process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE = '0'
|
|
509
|
+
try {
|
|
510
|
+
runHook(electedReviewThrowTranscript(dir, REVIEW_REASONING), dir)
|
|
511
|
+
const decision = decideCapturedProseDelivery(
|
|
512
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
513
|
+
{ stateDir: dir },
|
|
514
|
+
)
|
|
515
|
+
const bot = recordingBot()
|
|
516
|
+
await realBridge(dir, bot, decision)
|
|
517
|
+
|
|
518
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
519
|
+
const sent = bot.chatCalls()[0]!.text
|
|
520
|
+
// The raw reasoning is present (degraded-config leak surface) BUT
|
|
521
|
+
// titled — the belt-and-braces residual guarantee.
|
|
522
|
+
expect(sent).toContain(REVIEW_REASONING)
|
|
523
|
+
expect(sent.startsWith(SELF_IMPROVEMENT_TITLE)).toBe(true)
|
|
524
|
+
expect(journalLines(dir)[0]!.framedSelfImprovement).toBe('self-improve')
|
|
525
|
+
} finally {
|
|
526
|
+
if (prev === undefined) delete process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE
|
|
527
|
+
else process.env.SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE = prev
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
describe('#4490 (c) — ANTI-SILENCE / SEVERITY-3: a genuine waiting operator is never swallowed', () => {
|
|
533
|
+
let dir: string
|
|
534
|
+
beforeEach(() => {
|
|
535
|
+
dir = makeStateDir()
|
|
536
|
+
writeFreshHeartbeat(dir)
|
|
537
|
+
})
|
|
538
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
539
|
+
|
|
540
|
+
it('a normal (non-review) foreground turn is delivered in full, unsuppressed', async () => {
|
|
541
|
+
const t = writeTranscript(dir, [
|
|
542
|
+
{
|
|
543
|
+
type: 'queue-operation',
|
|
544
|
+
operation: 'enqueue',
|
|
545
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">how did the deploy go?</channel>`,
|
|
546
|
+
timestamp: 1000,
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
type: 'assistant',
|
|
550
|
+
message: {
|
|
551
|
+
content: [
|
|
552
|
+
{
|
|
553
|
+
type: 'tool_use',
|
|
554
|
+
id: 'toolu_normal_2',
|
|
555
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
556
|
+
input: { text: 'checking' },
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
type: 'user',
|
|
563
|
+
message: {
|
|
564
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_normal_2', is_error: true, content: 'Error: timeout' }],
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: NORMAL_ANSWER }] } },
|
|
568
|
+
])
|
|
569
|
+
runHook(t, dir)
|
|
570
|
+
const decision = decideCapturedProseDelivery(
|
|
571
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: `${DM_CHAT}:_#${INBOUND_MSG_ID}` },
|
|
572
|
+
{ stateDir: dir },
|
|
573
|
+
)
|
|
574
|
+
expect(decision.reviewOriginated).toBeUndefined()
|
|
575
|
+
|
|
576
|
+
const bot = recordingBot()
|
|
577
|
+
await realBridge(dir, bot, decision)
|
|
578
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
579
|
+
expect(bot.chatCalls()[0]!.text).toContain(NORMAL_ANSWER)
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('SEVERITY-3: a user message arriving mid-review-turn becomes the anchor — the review source never leaks through, and the answer is delivered, not swallowed', async () => {
|
|
583
|
+
// `resolveCaptureAnchor` (silent-end-scan.mjs) anchors on the LAST
|
|
584
|
+
// enqueue in the transcript. Here the review's own enqueue is FIRST and
|
|
585
|
+
// a genuine user message is enqueued SECOND (mid-turn), so the anchor —
|
|
586
|
+
// and therefore `envelope.source` — must be the user's `"telegram"`
|
|
587
|
+
// source, not the review's. If this regressed (e.g. anchoring on the
|
|
588
|
+
// FIRST enqueue instead), a real waiting operator's answer would be
|
|
589
|
+
// misclassified as review-internal and suppressed — strictly worse than
|
|
590
|
+
// the pre-#4490 behaviour on a turn that was never a review leak at all.
|
|
591
|
+
const t = userMessageDuringReviewTranscript(dir, NORMAL_ANSWER)
|
|
592
|
+
const scan = scanTurnForFinalReply(readFileSync(t, 'utf8')) as { source?: string }
|
|
593
|
+
expect(scan.source).toBe('telegram')
|
|
594
|
+
expect(scan.source).not.toBe(REVIEW_SOURCE)
|
|
595
|
+
|
|
596
|
+
runHook(t, dir)
|
|
597
|
+
const state = readElectedState(dir)
|
|
598
|
+
expect(state).not.toBeNull()
|
|
599
|
+
expect(state!.reviewOriginated).toBeUndefined()
|
|
600
|
+
|
|
601
|
+
const decision = decideCapturedProseDelivery(
|
|
602
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: `${DM_CHAT}:_#${USER_MSG_ID}` },
|
|
603
|
+
{ stateDir: dir },
|
|
604
|
+
)
|
|
605
|
+
expect(decision.reviewOriginated).toBeUndefined()
|
|
606
|
+
|
|
607
|
+
const bot = recordingBot()
|
|
608
|
+
await realBridge(dir, bot, decision)
|
|
609
|
+
// The answer reaches the operator — not suppressed as review-internal.
|
|
610
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
611
|
+
expect(bot.chatCalls()[0]!.text).toContain(NORMAL_ANSWER)
|
|
612
|
+
})
|
|
613
|
+
})
|