switchroom 0.13.33 → 0.13.36
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/bin/timezone-hook.sh +1 -1
- package/dist/agent-scheduler/index.js +8 -1
- package/dist/auth-broker/index.js +8 -1
- package/dist/cli/switchroom.js +176 -26
- package/dist/host-control/main.js +5222 -203
- package/dist/vault/approvals/kernel-server.js +9 -2
- package/dist/vault/broker/server.js +9 -2
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +234 -31
- package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
- package/telegram-plugin/gateway/gateway.ts +112 -15
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +227 -38
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for #1775 — the deterministic transcript-scan
|
|
3
|
+
* replacement of the silent-end Stop hook's signal source.
|
|
4
|
+
*
|
|
5
|
+
* Pre-fix the hook depended on a gateway-written state file as the
|
|
6
|
+
* block/allow signal. The state file was always written ~175ms AFTER
|
|
7
|
+
* the hook fired (live evidence on clerk 2026-05-25, 12 correlated
|
|
8
|
+
* samples), so the hook never saw its own turn's signal.
|
|
9
|
+
*
|
|
10
|
+
* Post-fix the hook reads `transcript_path` directly and scans the
|
|
11
|
+
* just-finished turn's tool_use entries for a qualifying reply. This
|
|
12
|
+
* test suite pins every branch of the new scan logic — the helper is
|
|
13
|
+
* a pure function (`scanTurnForFinalReply`), so we exercise it with
|
|
14
|
+
* synthetic JSONL fixtures rather than spawning the .mjs subprocess.
|
|
15
|
+
*
|
|
16
|
+
* Each fixture mimics the shapes the live Claude Code transcripts
|
|
17
|
+
* use (verified against clerk's
|
|
18
|
+
* `/state/agent/.claude/projects/.../{session}.jsonl` 2026-05-25).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import {
|
|
23
|
+
scanTurnForFinalReply,
|
|
24
|
+
isFinalAnswerReply,
|
|
25
|
+
} from '../hooks/silent-end-scan.mjs'
|
|
26
|
+
|
|
27
|
+
// ── Fixture builders ────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const ENQUEUE = JSON.stringify({
|
|
30
|
+
type: 'queue-operation',
|
|
31
|
+
operation: 'enqueue',
|
|
32
|
+
content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">hi</channel>',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function assistantToolUse(name: string, input: Record<string, unknown>, opts: { isSidechain?: boolean } = {}) {
|
|
36
|
+
const base = {
|
|
37
|
+
type: 'assistant',
|
|
38
|
+
message: { content: [{ type: 'tool_use', name, input }] },
|
|
39
|
+
}
|
|
40
|
+
if (opts.isSidechain) (base as Record<string, unknown>).isSidechain = true
|
|
41
|
+
return JSON.stringify(base)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assistantText(text: string) {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
type: 'assistant',
|
|
47
|
+
message: { content: [{ type: 'text', text }] },
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function jsonl(...lines: string[]) {
|
|
52
|
+
return lines.join('\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── isFinalAnswerReply parity with TS ───────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('isFinalAnswerReply (parity with final-answer-detect.ts)', () => {
|
|
58
|
+
it('done:true → final answer regardless of length/notification', () => {
|
|
59
|
+
expect(isFinalAnswerReply({ text: '', disableNotification: true, done: true })).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('disable_notification:false → final answer (the notification-bearing case)', () => {
|
|
63
|
+
expect(isFinalAnswerReply({ text: 'ok', disableNotification: false })).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('length ≥ 200 + disable_notification:true → final answer (substantive backstop)', () => {
|
|
67
|
+
expect(isFinalAnswerReply({ text: 'a'.repeat(200), disableNotification: true })).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('length 199 + disable_notification:true → interim ack', () => {
|
|
71
|
+
expect(isFinalAnswerReply({ text: 'a'.repeat(199), disableNotification: true })).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ── scanTurnForFinalReply branches ──────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('scanTurnForFinalReply — turn-start anchor', () => {
|
|
78
|
+
it('empty transcript → unknown (caller must fail-open)', () => {
|
|
79
|
+
const r = scanTurnForFinalReply('')
|
|
80
|
+
expect(r.decided).toBe('unknown')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('no enqueue line in transcript → unknown', () => {
|
|
84
|
+
const text = jsonl(
|
|
85
|
+
assistantText('hello'),
|
|
86
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
87
|
+
)
|
|
88
|
+
const r = scanTurnForFinalReply(text)
|
|
89
|
+
expect(r.decided).toBe('unknown')
|
|
90
|
+
expect(r.reason).toBe('no-turn-start')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('multiple enqueues → anchors on the LAST one (queued mid-turn semantics)', () => {
|
|
94
|
+
// First inbound got a long reply BEFORE the second inbound was
|
|
95
|
+
// queued. Scanning anchors on the last enqueue, so the early
|
|
96
|
+
// long reply does NOT count.
|
|
97
|
+
const text = jsonl(
|
|
98
|
+
ENQUEUE,
|
|
99
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
100
|
+
text: 'a'.repeat(500),
|
|
101
|
+
disable_notification: false,
|
|
102
|
+
}),
|
|
103
|
+
ENQUEUE, // second queued inbound
|
|
104
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
105
|
+
text: 'ack',
|
|
106
|
+
disable_notification: true,
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
const r = scanTurnForFinalReply(text)
|
|
110
|
+
expect(r.decided).toBe('block')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('scanTurnForFinalReply — final-reply detection', () => {
|
|
115
|
+
it('Ken-2026-05-25 repro: ack + plain text answer → block', () => {
|
|
116
|
+
// The exact shape from clerk's msg 12227 slip.
|
|
117
|
+
const text = jsonl(
|
|
118
|
+
ENQUEUE,
|
|
119
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
120
|
+
text: "On it — checking the Bloomfield statement, then I'll lay out…",
|
|
121
|
+
disable_notification: true,
|
|
122
|
+
}),
|
|
123
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
124
|
+
assistantToolUse('Read', { file_path: '/tmp/x' }),
|
|
125
|
+
assistantText('That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)),
|
|
126
|
+
)
|
|
127
|
+
const r = scanTurnForFinalReply(text)
|
|
128
|
+
expect(r.decided).toBe('block')
|
|
129
|
+
expect(r.reason).toBe('no-final-reply')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('notification-bearing reply → allow', () => {
|
|
133
|
+
const text = jsonl(
|
|
134
|
+
ENQUEUE,
|
|
135
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
136
|
+
)
|
|
137
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('stream_reply done:true → allow even with empty text', () => {
|
|
141
|
+
const text = jsonl(
|
|
142
|
+
ENQUEUE,
|
|
143
|
+
assistantToolUse('mcp__switchroom-telegram__stream_reply', {
|
|
144
|
+
text: '',
|
|
145
|
+
done: true,
|
|
146
|
+
disable_notification: true,
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
const r = scanTurnForFinalReply(text)
|
|
150
|
+
expect(r.decided).toBe('allow')
|
|
151
|
+
expect(r.reason).toBe('final-reply')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('long reply mis-marked disable_notification:true → still allow (≥200 chars backstop)', () => {
|
|
155
|
+
const text = jsonl(
|
|
156
|
+
ENQUEUE,
|
|
157
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
158
|
+
text: 'B'.repeat(500),
|
|
159
|
+
disable_notification: true,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('short ack followed by long reply → allow (later qualifies)', () => {
|
|
166
|
+
const text = jsonl(
|
|
167
|
+
ENQUEUE,
|
|
168
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'on it', disable_notification: true }),
|
|
169
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
170
|
+
assistantToolUse('mcp__switchroom-telegram__reply', {
|
|
171
|
+
text: 'Here is the full answer with notification ' + 'C'.repeat(500),
|
|
172
|
+
disable_notification: false,
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('scanTurnForFinalReply — silent-marker carve-out', () => {
|
|
180
|
+
it('NO_REPLY → allow', () => {
|
|
181
|
+
const text = jsonl(
|
|
182
|
+
ENQUEUE,
|
|
183
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'NO_REPLY' }),
|
|
184
|
+
)
|
|
185
|
+
const r = scanTurnForFinalReply(text)
|
|
186
|
+
expect(r.decided).toBe('allow')
|
|
187
|
+
expect(r.reason).toBe('silent-marker')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('NO_REPLY with trailing punctuation → allow (matches gateway tolerance)', () => {
|
|
191
|
+
const text = jsonl(
|
|
192
|
+
ENQUEUE,
|
|
193
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'NO_REPLY.' }),
|
|
194
|
+
)
|
|
195
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('lowercase no_reply → allow (case-insensitive)', () => {
|
|
199
|
+
const text = jsonl(
|
|
200
|
+
ENQUEUE,
|
|
201
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'no_reply' }),
|
|
202
|
+
)
|
|
203
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('HEARTBEAT_OK → allow (cron-silence carve-out)', () => {
|
|
207
|
+
const text = jsonl(
|
|
208
|
+
ENQUEUE,
|
|
209
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'HEARTBEAT_OK' }),
|
|
210
|
+
)
|
|
211
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('scanTurnForFinalReply — non-reply tool_use does NOT satisfy', () => {
|
|
216
|
+
it('Bash + Read + Agent(sub-agent dispatch) without reply → block', () => {
|
|
217
|
+
const text = jsonl(
|
|
218
|
+
ENQUEUE,
|
|
219
|
+
assistantToolUse('Bash', { command: 'ls' }),
|
|
220
|
+
assistantToolUse('Read', { file_path: '/tmp/x' }),
|
|
221
|
+
assistantToolUse('Agent', { description: 'sub-agent' }),
|
|
222
|
+
assistantText('done thinking, but never called reply'),
|
|
223
|
+
)
|
|
224
|
+
const r = scanTurnForFinalReply(text)
|
|
225
|
+
expect(r.decided).toBe('block')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('isSidechain:true sub-agent reply does NOT count for parent', () => {
|
|
229
|
+
const text = jsonl(
|
|
230
|
+
ENQUEUE,
|
|
231
|
+
assistantToolUse(
|
|
232
|
+
'mcp__switchroom-telegram__reply',
|
|
233
|
+
{ text: 'sub-agent answer', disable_notification: false },
|
|
234
|
+
{ isSidechain: true },
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
const r = scanTurnForFinalReply(text)
|
|
238
|
+
expect(r.decided).toBe('block')
|
|
239
|
+
expect(r.reason).toBe('no-final-reply')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('scanTurnForFinalReply — envelope-derived turnKey (block result)', () => {
|
|
244
|
+
it('block carries turnKey/chatId/threadId parsed from the enqueue envelope', () => {
|
|
245
|
+
const enq = JSON.stringify({
|
|
246
|
+
type: 'queue-operation',
|
|
247
|
+
operation: 'enqueue',
|
|
248
|
+
content: '<channel source="switchroom-telegram" chat_id="abc" message_thread_id="42" message_id="9">hi</channel>',
|
|
249
|
+
})
|
|
250
|
+
const text = jsonl(
|
|
251
|
+
enq,
|
|
252
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }),
|
|
253
|
+
)
|
|
254
|
+
const r = scanTurnForFinalReply(text)
|
|
255
|
+
expect(r.decided).toBe('block')
|
|
256
|
+
expect(r.chatId).toBe('abc')
|
|
257
|
+
expect(r.threadId).toBe(42)
|
|
258
|
+
expect(r.turnKey).toBe('abc:42')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it("DM (no message_thread_id) → turnKey uses '_' sentinel matching chatKey()", () => {
|
|
262
|
+
// chatKey() at telegram-plugin/gateway/chat-key.ts:46 returns
|
|
263
|
+
// `${chatId}:_` when threadId is missing/0. This must match.
|
|
264
|
+
const r = scanTurnForFinalReply(
|
|
265
|
+
jsonl(ENQUEUE, assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true })),
|
|
266
|
+
)
|
|
267
|
+
expect(r.decided).toBe('block')
|
|
268
|
+
expect(r.turnKey).toBe('111:_')
|
|
269
|
+
expect(r.chatId).toBe('111')
|
|
270
|
+
expect(r.threadId).toBeNull()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('allow result does NOT need turnKey (only block path writes the state file)', () => {
|
|
274
|
+
const text = jsonl(
|
|
275
|
+
ENQUEUE,
|
|
276
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
277
|
+
)
|
|
278
|
+
const r = scanTurnForFinalReply(text)
|
|
279
|
+
expect(r.decided).toBe('allow')
|
|
280
|
+
expect(r.turnKey).toBeUndefined()
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('scanTurnForFinalReply — malformed input tolerance', () => {
|
|
285
|
+
it('malformed JSON lines interleaved → skipped, decision matches the well-formed ones', () => {
|
|
286
|
+
const text = jsonl(
|
|
287
|
+
'this is not json',
|
|
288
|
+
'{partial',
|
|
289
|
+
ENQUEUE,
|
|
290
|
+
'another bad line',
|
|
291
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
|
|
292
|
+
)
|
|
293
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('lines starting with non-`{` → skipped quickly (perf guard)', () => {
|
|
297
|
+
const text = jsonl(
|
|
298
|
+
'# this is a comment',
|
|
299
|
+
'random plaintext',
|
|
300
|
+
ENQUEUE,
|
|
301
|
+
assistantToolUse('mcp__switchroom-telegram__reply', { text: 'NO_REPLY' }),
|
|
302
|
+
)
|
|
303
|
+
expect(scanTurnForFinalReply(text).decided).toBe('allow')
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('assistant line with non-array content is tolerated → no crash', () => {
|
|
307
|
+
const text = jsonl(
|
|
308
|
+
ENQUEUE,
|
|
309
|
+
JSON.stringify({ type: 'assistant', message: { content: null } }),
|
|
310
|
+
JSON.stringify({ type: 'assistant', message: { content: 'a string somehow' } }),
|
|
311
|
+
)
|
|
312
|
+
expect(scanTurnForFinalReply(text).decided).toBe('block')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
@@ -301,7 +301,123 @@ describe('recordUndeliveredTurnEnd — #1664 extended trigger', () => {
|
|
|
301
301
|
})
|
|
302
302
|
})
|
|
303
303
|
|
|
304
|
-
describe('silent-end
|
|
304
|
+
describe('#1741 — ack reply must not clear silent-end state', () => {
|
|
305
|
+
// The gateway gates `clearSilentEndState` at the reply send-site on
|
|
306
|
+
// `isFinalAnswerReply`. These tests reproduce that gate as a unit:
|
|
307
|
+
// simulate a turn's reply sequence by calling the same predicate the
|
|
308
|
+
// gateway uses, and assert state-file persistence matches the contract.
|
|
309
|
+
//
|
|
310
|
+
// Why this matters: if `turn_end` never lands (Claude Code's
|
|
311
|
+
// `turn_duration` system event is unreliable for trivial-prompt
|
|
312
|
+
// turns), the only line of defence between an undelivered turn and
|
|
313
|
+
// the Stop hook is the persistence of `silent-end-pending.json`.
|
|
314
|
+
// Pre-fix, an ack reply cleared the file unconditionally — so the
|
|
315
|
+
// Stop hook found no state and allowed the stop on every ack-then-
|
|
316
|
+
// tool-then-silent shape. Post-fix, only a plausibly-final reply
|
|
317
|
+
// clears it.
|
|
318
|
+
|
|
319
|
+
function simulateReplyAtGateway(
|
|
320
|
+
reply: { text: string; disableNotification: boolean; done?: boolean },
|
|
321
|
+
turnKey: string,
|
|
322
|
+
): void {
|
|
323
|
+
// The gateway calls clearSilentEndState ONLY when isFinalAnswerReply
|
|
324
|
+
// is true. Mirror that gate exactly.
|
|
325
|
+
if (isFinalAnswerReply(reply)) {
|
|
326
|
+
clearSilentEndState(turnKey)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
it('ack reply (disable_notification, short, no done) does NOT clear pending state', () => {
|
|
331
|
+
// A prior turn-end already wrote state (or a re-prompt round wrote it).
|
|
332
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
333
|
+
// Agent sends an interim ack.
|
|
334
|
+
simulateReplyAtGateway(
|
|
335
|
+
{ text: 'On it', disableNotification: true },
|
|
336
|
+
'c:_',
|
|
337
|
+
)
|
|
338
|
+
// State must persist — the Stop hook still needs to be able to
|
|
339
|
+
// catch a subsequent silent end.
|
|
340
|
+
expect(readSilentEndState()).not.toBeNull()
|
|
341
|
+
expect(readSilentEndState()!.turnKey).toBe('c:_')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('final-answer reply (disable_notification=false) clears the state', () => {
|
|
345
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
346
|
+
simulateReplyAtGateway(
|
|
347
|
+
{ text: "Done — here's the result.", disableNotification: false },
|
|
348
|
+
'c:_',
|
|
349
|
+
)
|
|
350
|
+
expect(readSilentEndState()).toBeNull()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('stream_reply done=true clears the state even with disable_notification=true', () => {
|
|
354
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
355
|
+
simulateReplyAtGateway(
|
|
356
|
+
{ text: 'ok', disableNotification: true, done: true },
|
|
357
|
+
'c:_',
|
|
358
|
+
)
|
|
359
|
+
expect(readSilentEndState()).toBeNull()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('long ack-shaped reply (>=200 chars) is treated as final and clears the state', () => {
|
|
363
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
364
|
+
simulateReplyAtGateway(
|
|
365
|
+
{ text: 'x'.repeat(250), disableNotification: true },
|
|
366
|
+
'c:_',
|
|
367
|
+
)
|
|
368
|
+
expect(readSilentEndState()).toBeNull()
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('ack-then-silent end-to-end: ack does not clear, Stop hook still blocks', () => {
|
|
372
|
+
// 1. The previous undelivered turn-end wrote state, OR the turn
|
|
373
|
+
// starts fresh and only the Stop hook will see this state file
|
|
374
|
+
// once the gateway re-writes it. Simulate the gateway's writer
|
|
375
|
+
// firing at turn-end with finalAnswerDelivered=false (no
|
|
376
|
+
// qualifying reply happened this turn).
|
|
377
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
378
|
+
// 2. Mid-turn ack reply lands. Pre-fix this would unlink the
|
|
379
|
+
// state file; post-fix it must persist.
|
|
380
|
+
simulateReplyAtGateway(
|
|
381
|
+
{ text: 'On it, working on it…', disableNotification: true },
|
|
382
|
+
'c:_',
|
|
383
|
+
)
|
|
384
|
+
// 3. Stop hook fires (separately tested below): it must still
|
|
385
|
+
// find the state file and decide to block. Verify the file is
|
|
386
|
+
// intact at the path the hook reads.
|
|
387
|
+
const state = readSilentEndState()
|
|
388
|
+
expect(state).not.toBeNull()
|
|
389
|
+
expect(state!.turnKey).toBe('c:_')
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('ack-then-final: ack does not clear, final clears', () => {
|
|
393
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
394
|
+
// Interim ack — state persists.
|
|
395
|
+
simulateReplyAtGateway(
|
|
396
|
+
{ text: 'On it', disableNotification: true },
|
|
397
|
+
'c:_',
|
|
398
|
+
)
|
|
399
|
+
expect(readSilentEndState()).not.toBeNull()
|
|
400
|
+
// Final answer — state cleared.
|
|
401
|
+
simulateReplyAtGateway(
|
|
402
|
+
{ text: 'Done — the answer is 42.', disableNotification: false },
|
|
403
|
+
'c:_',
|
|
404
|
+
)
|
|
405
|
+
expect(readSilentEndState()).toBeNull()
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan signal)', () => {
|
|
410
|
+
// Post-#1775 the hook's BLOCK/ALLOW signal is derived from the
|
|
411
|
+
// transcript file, not the state file. The state file remains for
|
|
412
|
+
// retry-count bookkeeping only. These tests pin the new contract.
|
|
413
|
+
//
|
|
414
|
+
// The race the new contract closes: pre-fix the hook read the
|
|
415
|
+
// state file as its signal, but the gateway wrote that file
|
|
416
|
+
// ~175ms AFTER the hook fired (race lost every time). Now the
|
|
417
|
+
// hook reads `transcript_path` directly — Claude Code flushes
|
|
418
|
+
// assistant content before firing Stop hooks, so the read is
|
|
419
|
+
// race-free.
|
|
420
|
+
|
|
305
421
|
const hookPath = join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
306
422
|
|
|
307
423
|
function runHook(input: object): { exit: number; stdout: string; stderr: string } {
|
|
@@ -315,75 +431,148 @@ describe('silent-end-interrupt-stop hook — integration', () => {
|
|
|
315
431
|
return { exit: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
316
432
|
}
|
|
317
433
|
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
434
|
+
function writeTranscript(lines: object[]): string {
|
|
435
|
+
const path = join(stateDir, 'transcript.jsonl')
|
|
436
|
+
mkdirSync(stateDir, { recursive: true })
|
|
437
|
+
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n'), 'utf8')
|
|
438
|
+
return path
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const ENQUEUE = {
|
|
442
|
+
type: 'queue-operation',
|
|
443
|
+
operation: 'enqueue',
|
|
444
|
+
content: '<channel source="switchroom-telegram" chat_id="c">hi</channel>',
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function replyToolUse(text: string, opts: { disable_notification?: boolean; done?: boolean } = {}) {
|
|
448
|
+
return {
|
|
449
|
+
type: 'assistant',
|
|
450
|
+
message: {
|
|
451
|
+
content: [
|
|
452
|
+
{ type: 'tool_use', name: 'mcp__switchroom-telegram__reply', input: { text, ...opts } },
|
|
453
|
+
],
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
it('allows the stop when transcript_path is missing from the event (fail-open)', () => {
|
|
459
|
+
// Pre-#1775 this branch was "no state file → allow". Post-fix
|
|
460
|
+
// the same outcome holds via the fail-open transcript guard.
|
|
461
|
+
const r = runHook({ session_id: 's', hook_event_name: 'Stop' })
|
|
324
462
|
expect(r.exit).toBe(0)
|
|
325
463
|
expect(r.stdout.trim()).toBe('')
|
|
326
464
|
})
|
|
327
465
|
|
|
328
|
-
it('blocks the stop
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
466
|
+
it('blocks the stop when transcript shows ack-only (no final reply) — Ken-2026-05-25 repro', () => {
|
|
467
|
+
const transcript = writeTranscript([
|
|
468
|
+
ENQUEUE,
|
|
469
|
+
replyToolUse('on it — checking now', { disable_notification: true }),
|
|
470
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] } },
|
|
471
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(2237) }] } },
|
|
472
|
+
])
|
|
473
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
335
474
|
expect(r.exit).toBe(0)
|
|
336
475
|
const out = JSON.parse(r.stdout.trim())
|
|
337
476
|
expect(out.decision).toBe('block')
|
|
338
477
|
expect(out.reason).toContain('reply')
|
|
339
|
-
// #1664 — the re-prompt must offer the NO_REPLY escape hatch
|
|
340
|
-
// model that already delivered (or intentionally has nothing to add)
|
|
341
|
-
// can end the turn cleanly instead of being forced to re-send.
|
|
478
|
+
// #1664 — the re-prompt must offer the NO_REPLY escape hatch.
|
|
342
479
|
expect(out.reason).toContain('NO_REPLY')
|
|
343
|
-
// retryCount
|
|
480
|
+
// retryCount incremented to 1 (the budget bookkeeping still
|
|
481
|
+
// uses the state file).
|
|
344
482
|
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
345
483
|
})
|
|
346
484
|
|
|
347
|
-
it('allows the stop when retryCount >= MAX_RETRIES (1)', () => {
|
|
485
|
+
it('allows the stop when retryCount >= MAX_RETRIES (1), even if transcript still shows no reply', () => {
|
|
486
|
+
// Retry already spent — gateway will post the user-facing
|
|
487
|
+
// fallback so the user isn't left silent.
|
|
348
488
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
489
|
+
mkdirSync(stateDir, { recursive: true })
|
|
349
490
|
writeFileSync(path, JSON.stringify({
|
|
350
491
|
chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: 0,
|
|
351
492
|
}))
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
})
|
|
493
|
+
const transcript = writeTranscript([
|
|
494
|
+
ENQUEUE,
|
|
495
|
+
replyToolUse('still just an ack', { disable_notification: true }),
|
|
496
|
+
])
|
|
497
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
357
498
|
expect(r.exit).toBe(0)
|
|
358
499
|
expect(r.stdout.trim()).toBe('')
|
|
359
500
|
expect(r.stderr).toContain('retry exhausted')
|
|
360
501
|
})
|
|
361
502
|
|
|
362
|
-
it('
|
|
363
|
-
//
|
|
364
|
-
|
|
503
|
+
it('allows the stop when transcript shows a notification-bearing reply (no state needed)', () => {
|
|
504
|
+
// Pre-#1775 this scenario depended on the gateway having
|
|
505
|
+
// already cleared the state file (race-prone). Post-fix the
|
|
506
|
+
// transcript scan finds the qualifying reply directly.
|
|
507
|
+
const transcript = writeTranscript([
|
|
508
|
+
ENQUEUE,
|
|
509
|
+
replyToolUse('here is the answer', { disable_notification: false }),
|
|
510
|
+
])
|
|
511
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
512
|
+
expect(r.exit).toBe(0)
|
|
513
|
+
expect(r.stdout.trim()).toBe('')
|
|
514
|
+
// No state file written (nothing to bookkeep — no block).
|
|
515
|
+
expect(readSilentEndState()).toBeNull()
|
|
516
|
+
})
|
|
365
517
|
|
|
366
|
-
|
|
367
|
-
|
|
518
|
+
it('end-to-end: silent turn → hook blocks → re-prompt delivers → next stop allows', () => {
|
|
519
|
+
// The contract for the HOOK's stdout decision — independent of
|
|
520
|
+
// gateway state-file lifecycle. Gateway's clear happens via
|
|
521
|
+
// recordSilentTurnEnd / clearSilentEndState; this test only
|
|
522
|
+
// exercises the hook's decision based on the transcript that's
|
|
523
|
+
// on disk at hook-time.
|
|
524
|
+
|
|
525
|
+
// 1. Turn first-stop with ack-only transcript → block + retryCount→1.
|
|
526
|
+
const transcriptAck = writeTranscript([
|
|
527
|
+
ENQUEUE,
|
|
528
|
+
replyToolUse('ack', { disable_notification: true }),
|
|
529
|
+
])
|
|
530
|
+
const r1 = runHook({ session_id: 's', transcript_path: transcriptAck, hook_event_name: 'Stop' })
|
|
368
531
|
expect(JSON.parse(r1.stdout).decision).toBe('block')
|
|
369
532
|
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
370
533
|
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
534
|
+
// 2. Model retried within the same turn — transcript now has the
|
|
535
|
+
// final reply appended. The hook scans transcript at its next
|
|
536
|
+
// fire and finds the qualifying reply.
|
|
537
|
+
const transcriptFinal = writeTranscript([
|
|
538
|
+
ENQUEUE,
|
|
539
|
+
replyToolUse('ack', { disable_notification: true }),
|
|
540
|
+
replyToolUse('here is the actual answer', { disable_notification: false }),
|
|
541
|
+
])
|
|
542
|
+
const r2 = runHook({ session_id: 's', transcript_path: transcriptFinal, hook_event_name: 'Stop' })
|
|
377
543
|
expect(r2.stdout.trim()).toBe('')
|
|
544
|
+
// State file still present (retryCount=1) — gateway's
|
|
545
|
+
// clearSilentEndState path (post-finalAnswerDelivered) is what
|
|
546
|
+
// clears it; the hook doesn't manage that.
|
|
378
547
|
})
|
|
379
548
|
|
|
380
|
-
it('
|
|
549
|
+
it('NO_REPLY silent-marker in transcript → allow stop even without final reply', () => {
|
|
550
|
+
const transcript = writeTranscript([
|
|
551
|
+
ENQUEUE,
|
|
552
|
+
replyToolUse('NO_REPLY'),
|
|
553
|
+
])
|
|
554
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
555
|
+
expect(r.exit).toBe(0)
|
|
556
|
+
expect(r.stdout.trim()).toBe('')
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it('fails open on a corrupt state file (when block would otherwise fire)', () => {
|
|
560
|
+
// Transcript shows ack-only (would block), but state file is
|
|
561
|
+
// corrupt. The hook treats the state file as fresh (retryCount=0)
|
|
562
|
+
// and proceeds to block + write a fresh state file. This is the
|
|
563
|
+
// safe behavior — corrupt state shouldn't cause perpetual stop.
|
|
564
|
+
const transcript = writeTranscript([
|
|
565
|
+
ENQUEUE,
|
|
566
|
+
replyToolUse('ack', { disable_notification: true }),
|
|
567
|
+
])
|
|
381
568
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
382
569
|
mkdirSync(stateDir, { recursive: true })
|
|
383
570
|
writeFileSync(path, 'corrupt {{{', 'utf8')
|
|
384
|
-
const r = runHook({ session_id: 's', transcript_path:
|
|
571
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
385
572
|
expect(r.exit).toBe(0)
|
|
386
|
-
|
|
573
|
+
// Decision is block (transcript says block, state is treated as fresh).
|
|
574
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
575
|
+
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
387
576
|
})
|
|
388
577
|
|
|
389
578
|
it('fails open on empty stdin', () => {
|