switchroom 0.19.4 → 0.19.5
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/auth-broker/index.js +7 -3
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -184,13 +184,20 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
|
|
|
184
184
|
expect(r.pendingText).toBe(trailing)
|
|
185
185
|
})
|
|
186
186
|
|
|
187
|
-
it('Option A: a SHORT trailing
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
187
|
+
it('Option A: a SHORT single trailing fragment (zero-reply) IS persisted for the lowered-floor corner', () => {
|
|
188
|
+
// Post duplicate-message fix: a single short plain-text block in the
|
|
189
|
+
// ZERO-reply case is the real short-answer shape, so the scan persists it
|
|
190
|
+
// as pendingText for the capture-divergence corner (gateway captured empty →
|
|
191
|
+
// the bridge delivers with a lowered floor). Under the gateway's DEFAULT
|
|
192
|
+
// 200-char floor it is still NOT delivered, so the substance guard holds at
|
|
193
|
+
// the delivery layer.
|
|
194
|
+
const short = 'ok done, let me know if you need anything else'
|
|
195
|
+
const text = jsonl(ENQUEUE, assistantText(short))
|
|
191
196
|
const r = scanTurnForFinalReply(text)
|
|
192
197
|
expect(r.decided).toBe('block')
|
|
193
|
-
expect(r.
|
|
198
|
+
expect(r.reason).toBe('no-final-reply')
|
|
199
|
+
expect(r.pendingText).toBe(short)
|
|
200
|
+
expect(r.hasTrailingProse).toBe(true)
|
|
194
201
|
})
|
|
195
202
|
|
|
196
203
|
it('notification-bearing reply → allow', () => {
|
|
@@ -388,14 +395,19 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
388
395
|
// "Let me check…" / "Still querying…" narration crossed 200 and was delivered
|
|
389
396
|
// as if it were the answer. Post-fix only a single block that clears the floor
|
|
390
397
|
// ON ITS OWN becomes pendingText.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
398
|
+
// Genuinely-narration blocks: each matches the opener/trailer heuristic
|
|
399
|
+
// (`isNarrationBlock`) the flush's `selectFlushDeliveryText` uses, so a pure
|
|
400
|
+
// run of them is delivered as NOTHING by the flush — and the scan must agree
|
|
401
|
+
// (no pendingText) so the capture-divergence bridge never masquerades a
|
|
402
|
+
// narration run as an answer (#3228 Finding 2).
|
|
403
|
+
const NARRATION_A = 'Let me check the first data source now, pulling the records and scanning the rows…' // opener + …
|
|
404
|
+
const NARRATION_B = "Now let me query the second source; it is slower than expected, hang tight…" // opener + …
|
|
405
|
+
const NARRATION_C = "I'll cross-reference the last set of figures against the ledger before I summarise…" // opener + …
|
|
406
|
+
|
|
407
|
+
it('zero-delivery turn of only NARRATION blocks → block WITHOUT pendingText (#3228 Finding 2 / flush parity)', () => {
|
|
408
|
+
// Combined length of the three blocks is ≥ 200, so the OLD joined-floor code
|
|
409
|
+
// would join them and set pendingText (masquerade). The NEW code mirrors the
|
|
410
|
+
// flush's narration strip: every block is narration → nothing to deliver.
|
|
399
411
|
expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
|
|
400
412
|
.toBeGreaterThanOrEqual(200)
|
|
401
413
|
const text = jsonl(
|
|
@@ -413,6 +425,35 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
413
425
|
expect(r.pendingText).toBeUndefined()
|
|
414
426
|
})
|
|
415
427
|
|
|
428
|
+
it('zero-delivery turn of MULTIPLE sub-200 REAL-CONTENT blocks → JOINED pendingText (review item 3 drop fix)', () => {
|
|
429
|
+
// A real answer split as two ~150-char paragraphs, each individually under
|
|
430
|
+
// the 200-char substance floor. Pre-fix the scan set NO pendingText — and in
|
|
431
|
+
// the capture-divergence-empty corner (gateway captured nothing → flush
|
|
432
|
+
// skips 'empty-text') the bridge then had nothing to deliver and the hook
|
|
433
|
+
// had already allowed the stop → DROPPED ANSWER. Post-fix the scan mirrors
|
|
434
|
+
// the flush's join so the bridge delivers the joined prose.
|
|
435
|
+
const PARA_1 = 'The root cause is a stale cache entry that survives the reload because the invalidation key is derived from the wrong field.' // ~150, real content
|
|
436
|
+
const PARA_2 = 'The fix is to key invalidation off the canonical id so the entry is dropped on every write; I have verified it against the repro.' // ~150, real content
|
|
437
|
+
expect(PARA_1.length).toBeLessThan(200)
|
|
438
|
+
expect(PARA_2.length).toBeLessThan(200)
|
|
439
|
+
const text = jsonl(ENQUEUE, assistantText(PARA_1), assistantText(PARA_2))
|
|
440
|
+
const r = scanTurnForFinalReply(text)
|
|
441
|
+
expect(r.decided).toBe('block')
|
|
442
|
+
expect(r.reason).toBe('no-final-reply')
|
|
443
|
+
// Joined prose, mirroring the flush's selectFlushDeliveryText.
|
|
444
|
+
expect(r.pendingText).toBe(`${PARA_1}\n\n${PARA_2}`)
|
|
445
|
+
expect(r.hasTrailingProse).toBe(true)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('leading narration + a real sub-200 answer block → only the answer is delivered (narration stripped)', () => {
|
|
449
|
+
const NARR = 'Let me pull the numbers first…' // narration opener + …
|
|
450
|
+
const ANSWER = 'Revenue was up 12% quarter-over-quarter, driven mostly by the new enterprise tier.' // ~85, real
|
|
451
|
+
const text = jsonl(ENQUEUE, assistantText(NARR), assistantText(ANSWER))
|
|
452
|
+
const r = scanTurnForFinalReply(text)
|
|
453
|
+
expect(r.decided).toBe('block')
|
|
454
|
+
expect(r.pendingText).toBe(ANSWER)
|
|
455
|
+
})
|
|
456
|
+
|
|
416
457
|
it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
|
|
417
458
|
// Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
|
|
418
459
|
// false → allow. (The old joined-floor logic never affected the block
|
|
@@ -447,9 +488,12 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
|
|
|
447
488
|
const text = jsonl(ENQUEUE, assistantText('X'.repeat(n)))
|
|
448
489
|
return scanTurnForFinalReply(text)
|
|
449
490
|
}
|
|
450
|
-
// 199 → under floor,
|
|
451
|
-
//
|
|
452
|
-
|
|
491
|
+
// 199 → under the 200-char SUBSTANCE floor, but a SINGLE trailing block is
|
|
492
|
+
// the real short-answer shape: post duplicate-message fix the scan persists
|
|
493
|
+
// it as pendingText so the capture-divergence corner can deliver it with a
|
|
494
|
+
// lowered floor. The gateway's default-floor decide still won't deliver it,
|
|
495
|
+
// so the #3228 masquerade guard is unchanged at the delivery layer.
|
|
496
|
+
expect(at(199).pendingText).toBe('X'.repeat(199))
|
|
453
497
|
// 200 → exactly the floor, delivered.
|
|
454
498
|
expect(at(200).pendingText).toBe('X'.repeat(200))
|
|
455
499
|
// 201 → over floor, delivered.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the single-writer election (duplicate-message fix).
|
|
3
|
+
*
|
|
4
|
+
* RCA: when a turn ends with its final answer as PLAIN TRANSCRIPT TEXT (no
|
|
5
|
+
* `reply` tool call), two uncoordinated recovery paths both fired — (A) the
|
|
6
|
+
* gateway turn-end flush (`decideTurnFlush`) delivered the captured prose, and
|
|
7
|
+
* (B) this Stop hook blocked and re-prompted, regenerating a REWORDED reply
|
|
8
|
+
* that defeated the exact-match dedup. The user got two near-identical
|
|
9
|
+
* messages.
|
|
10
|
+
*
|
|
11
|
+
* Fix: the Stop hook is the single elector (`decideStopHookDisposition`). On a
|
|
12
|
+
* would-BLOCK scan it ALLOWS the stop — handing delivery to the gateway's
|
|
13
|
+
* flush / captured-prose bridge — IFF four never-drop gates all hold; otherwise
|
|
14
|
+
* it BLOCKS exactly as today. These tests pin the whole verdict matrix and
|
|
15
|
+
* prove every allow path has a delivery machine and every guard forces BLOCK.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import {
|
|
20
|
+
decideStopHookDisposition,
|
|
21
|
+
isTurnFlushSafetyEnabledEnv,
|
|
22
|
+
isCapturedProseDeliveryEnabledEnv,
|
|
23
|
+
isGatewayHeartbeatFresh,
|
|
24
|
+
GATEWAY_HEARTBEAT_FRESH_MS,
|
|
25
|
+
} from '../hooks/silent-end-scan.mjs'
|
|
26
|
+
import {
|
|
27
|
+
mkdtempSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
utimesSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
} from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
|
|
35
|
+
// Scan fixtures — the shapes `scanTurnForFinalReply` returns on a would-block.
|
|
36
|
+
const ZERO_REPLY_LONG = {
|
|
37
|
+
decided: 'block' as const,
|
|
38
|
+
reason: 'no-final-reply',
|
|
39
|
+
turnKey: 'c:_',
|
|
40
|
+
hasTrailingProse: true,
|
|
41
|
+
pendingText: 'A'.repeat(300),
|
|
42
|
+
}
|
|
43
|
+
const ZERO_REPLY_SHORT = {
|
|
44
|
+
decided: 'block' as const,
|
|
45
|
+
reason: 'no-final-reply',
|
|
46
|
+
turnKey: 'c:_',
|
|
47
|
+
hasTrailingProse: true,
|
|
48
|
+
pendingText: 'ok done',
|
|
49
|
+
}
|
|
50
|
+
const ZERO_REPLY_NO_PROSE = {
|
|
51
|
+
decided: 'block' as const,
|
|
52
|
+
reason: 'no-final-reply',
|
|
53
|
+
turnKey: 'c:_',
|
|
54
|
+
// hasTrailingProse absent — tool-calls-only turn, nothing for a machine to send.
|
|
55
|
+
}
|
|
56
|
+
const INTERIM_ACK_LONG = {
|
|
57
|
+
decided: 'block' as const,
|
|
58
|
+
reason: 'trailing-text-after-reply',
|
|
59
|
+
turnKey: 'c:_',
|
|
60
|
+
hasTrailingProse: true,
|
|
61
|
+
pendingText: 'B'.repeat(300),
|
|
62
|
+
}
|
|
63
|
+
const INTERIM_ACK_SHORT = {
|
|
64
|
+
decided: 'block' as const,
|
|
65
|
+
reason: 'trailing-text-after-reply',
|
|
66
|
+
turnKey: 'c:_',
|
|
67
|
+
hasTrailingProse: true,
|
|
68
|
+
pendingText: 'thanks!',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ALL_ON = {
|
|
72
|
+
retryCount: 0,
|
|
73
|
+
turnFlushSafetyEnabled: true,
|
|
74
|
+
capturedProseDeliveryEnabled: true,
|
|
75
|
+
gatewayLive: true,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('decideStopHookDisposition — duplicate repro (#1: zero-reply ≥200)', () => {
|
|
79
|
+
it('zero-reply long answer, flags on, retry 0, live → ALLOW-elected (today it blocks → duplicate)', () => {
|
|
80
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON })
|
|
81
|
+
expect(d.action).toBe('allow-elected')
|
|
82
|
+
expect(d.reason).toBe('flush-will-deliver')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('decideStopHookDisposition — short-answer repro (#2: zero-reply <200)', () => {
|
|
87
|
+
it('zero-reply SHORT answer, flags on, retry 0, live → ALLOW-elected (flush has no length floor)', () => {
|
|
88
|
+
// Today this BLOCKS *and* the flush fires → duplicate. Exactly one machine
|
|
89
|
+
// must win: the flush. The election allows the stop so the hook does not
|
|
90
|
+
// also re-prompt.
|
|
91
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON })
|
|
92
|
+
expect(d.action).toBe('allow-elected')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('decideStopHookDisposition — never-drop matrix (#3)', () => {
|
|
97
|
+
it('every ALLOW verdict is backed by a delivery machine', () => {
|
|
98
|
+
// Zero-reply → the turn-flush delivers (no length floor); interim-ack ≥200 →
|
|
99
|
+
// the captured-prose bridge delivers. Both are allow.
|
|
100
|
+
expect(decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON }).action).toBe('allow-elected')
|
|
101
|
+
expect(decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON }).action).toBe('allow-elected')
|
|
102
|
+
expect(decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON }).action).toBe('allow-elected')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('turn-flush flag OFF (zero-reply) → BLOCK (the flush will not fire)', () => {
|
|
106
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, turnFlushSafetyEnabled: false })
|
|
107
|
+
expect(d.action).toBe('block')
|
|
108
|
+
expect(d.reason).toBe('turn-flush-flag-disabled')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('captured-prose flag OFF (interim-ack) → BLOCK (the bridge will not fire)', () => {
|
|
112
|
+
const d = decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
|
|
113
|
+
expect(d.action).toBe('block')
|
|
114
|
+
expect(d.reason).toBe('captured-prose-flag-disabled')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('captured-prose flag OFF (zero-reply) → BLOCK (bridge is the capture-divergence backstop)', () => {
|
|
118
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
|
|
119
|
+
expect(d.action).toBe('block')
|
|
120
|
+
expect(d.reason).toBe('captured-prose-flag-disabled')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('retryCount > 0 → BLOCK (a prior failed delivery keeps the #3228 recovery net)', () => {
|
|
124
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, retryCount: 1 })
|
|
125
|
+
expect(d.action).toBe('block')
|
|
126
|
+
expect(d.reason).toBe('retry-ladder-in-flight')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('gateway NOT live → BLOCK (never allow into a possibly-dead gateway)', () => {
|
|
130
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false })
|
|
131
|
+
expect(d.action).toBe('block')
|
|
132
|
+
expect(d.reason).toBe('gateway-liveness-not-fresh')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('interim-ack SHORT (<200) → BLOCK (bridge floor is 200; no duplicate exists today)', () => {
|
|
136
|
+
const d = decideStopHookDisposition({ scan: INTERIM_ACK_SHORT, ...ALL_ON })
|
|
137
|
+
expect(d.action).toBe('block')
|
|
138
|
+
expect(d.reason).toBe('short-trailing-after-reply')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('zero-reply with NO trailing prose (tool-calls only) → BLOCK (nothing to deliver)', () => {
|
|
142
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_NO_PROSE, ...ALL_ON })
|
|
143
|
+
expect(d.action).toBe('block')
|
|
144
|
+
expect(d.reason).toBe('no-trailing-prose')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a non-block scan (allow/unknown) is passed straight through as allow-scan', () => {
|
|
148
|
+
expect(decideStopHookDisposition({ scan: { decided: 'allow', reason: 'final-reply' }, ...ALL_ON }).action).toBe('allow-scan')
|
|
149
|
+
expect(decideStopHookDisposition({ scan: { decided: 'unknown', reason: 'no-turn-start' }, ...ALL_ON }).action).toBe('allow-scan')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('gateway-liveness gate is checked BEFORE the flag/prose gates (dead gateway always blocks)', () => {
|
|
153
|
+
// Even with an otherwise-perfect zero-reply allow, a dead gateway forces block.
|
|
154
|
+
const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false, retryCount: 0 })
|
|
155
|
+
expect(d.action).toBe('block')
|
|
156
|
+
expect(d.reason).toBe('gateway-liveness-not-fresh')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('env-flag mirrors stay in sync with the TS source', () => {
|
|
161
|
+
it('turn-flush-safety: default ON; 0/false/off/no disable', () => {
|
|
162
|
+
expect(isTurnFlushSafetyEnabledEnv({})).toBe(true)
|
|
163
|
+
for (const v of ['0', 'false', 'off', 'no', 'FALSE', 'Off']) {
|
|
164
|
+
expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: v })).toBe(false)
|
|
165
|
+
}
|
|
166
|
+
expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: '1' })).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
it('captured-prose: default ON; only exact "0" disables', () => {
|
|
169
|
+
expect(isCapturedProseDeliveryEnabledEnv({})).toBe(true)
|
|
170
|
+
expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '0' })).toBe(false)
|
|
171
|
+
expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '1' })).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('isGatewayHeartbeatFresh — liveness gate IO', () => {
|
|
176
|
+
it('fresh heartbeat → true; stale → false; missing → false', () => {
|
|
177
|
+
const dir = mkdtempSync(join(tmpdir(), 'gw-hb-'))
|
|
178
|
+
try {
|
|
179
|
+
// Missing file → not fresh.
|
|
180
|
+
expect(isGatewayHeartbeatFresh(dir)).toBe(false)
|
|
181
|
+
const path = join(dir, 'gateway-heartbeat')
|
|
182
|
+
writeFileSync(path, 'x')
|
|
183
|
+
// Just-written → fresh.
|
|
184
|
+
expect(isGatewayHeartbeatFresh(dir)).toBe(true)
|
|
185
|
+
// Age it past the freshness bound → stale.
|
|
186
|
+
const old = new Date(Date.now() - GATEWAY_HEARTBEAT_FRESH_MS - 10_000)
|
|
187
|
+
utimesSync(path, old, old)
|
|
188
|
+
expect(isGatewayHeartbeatFresh(dir)).toBe(false)
|
|
189
|
+
} finally {
|
|
190
|
+
rmSync(dir, { recursive: true, force: true })
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -736,10 +736,15 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
736
736
|
expect(decision.reason).toBe('no-state')
|
|
737
737
|
})
|
|
738
738
|
|
|
739
|
-
it('a
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
739
|
+
it('a short single-block answer → hook persists pendingText but default-floor decide falls through', () => {
|
|
740
|
+
// Single short trailing block under the 200-char substance floor. Post
|
|
741
|
+
// duplicate-message fix the hook persists it as pendingText (so the
|
|
742
|
+
// capture-divergence corner can deliver it with a lowered floor when the
|
|
743
|
+
// gateway captured nothing), but under the DEFAULT floor the gateway's
|
|
744
|
+
// decide returns no-substantive-prose — the normal recordUndeliveredTurnEnd
|
|
745
|
+
// / represent path is unchanged. (No gateway heartbeat file exists in this
|
|
746
|
+
// test's state dir, so the single-writer election also BLOCKS on the
|
|
747
|
+
// liveness gate rather than allowing the stop.)
|
|
743
748
|
const transcript = writeTranscript([
|
|
744
749
|
ENQUEUE,
|
|
745
750
|
{ type: 'assistant', message: { content: [{ type: 'text', text: 'ok done' }] } },
|
|
@@ -747,10 +752,60 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
747
752
|
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
748
753
|
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
749
754
|
const state = readSilentEndState()!
|
|
750
|
-
expect(state.pendingText).
|
|
755
|
+
expect(state.pendingText).toBe('ok done')
|
|
756
|
+
// Default floor (200) → short prose is not delivered on the normal path.
|
|
751
757
|
const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
752
758
|
expect(decision.deliver).toBe(false)
|
|
753
759
|
expect(decision.reason).toBe('no-substantive-prose')
|
|
760
|
+
// Lowered floor (capture-divergence corner) → the short answer IS delivered.
|
|
761
|
+
const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
|
|
762
|
+
expect(lowered.deliver).toBe(true)
|
|
763
|
+
expect(lowered.text).toBe('ok done')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('review item 3: multi sub-200 REAL blocks + capture-empty corner → bridge delivers JOINED prose (no drop)', () => {
|
|
767
|
+
// A real answer split as two ~150-char paragraphs, each under the 200-char
|
|
768
|
+
// floor. Pre-fix: scan set NO pendingText → in the capture-divergence-empty
|
|
769
|
+
// corner (gateway captured nothing → flush skips 'empty-text') the bridge
|
|
770
|
+
// had nothing → hook already allowed the stop → DROPPED ANSWER. Post-fix
|
|
771
|
+
// the hook persists the JOINED prose so the lowered-floor bridge delivers
|
|
772
|
+
// exactly one message.
|
|
773
|
+
// Each block short enough that the JOINED prose is still under the 200-char
|
|
774
|
+
// default floor — so this also exercises the lowered-floor plumbing.
|
|
775
|
+
const PARA_1 = 'The root cause is a stale cache entry surviving reload.'
|
|
776
|
+
const PARA_2 = 'The fix keys invalidation off the canonical id; verified.'
|
|
777
|
+
const joined = `${PARA_1}\n\n${PARA_2}`
|
|
778
|
+
expect(joined.length).toBeLessThan(200)
|
|
779
|
+
const transcript = writeTranscript([
|
|
780
|
+
ENQUEUE,
|
|
781
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: PARA_1 }] } },
|
|
782
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: PARA_2 }] } },
|
|
783
|
+
])
|
|
784
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
785
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
786
|
+
const state = readSilentEndState()!
|
|
787
|
+
expect(state.pendingText).toBe(joined)
|
|
788
|
+
// Default floor → not delivered on the normal path (no duplicate risk).
|
|
789
|
+
expect(decideCapturedProseDelivery({ turnKey: 'c:_' }).deliver).toBe(false)
|
|
790
|
+
// Capture-divergence corner (gateway captured empty → lowered floor) →
|
|
791
|
+
// exactly one delivery of the joined answer, instead of a silent drop.
|
|
792
|
+
const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
|
|
793
|
+
expect(lowered.deliver).toBe(true)
|
|
794
|
+
expect(lowered.text).toBe(joined)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('review item 3: NARRATION-only multi-block → still NO pendingText (no masquerade, #3228 Finding 2)', () => {
|
|
798
|
+
const transcript = writeTranscript([
|
|
799
|
+
ENQUEUE,
|
|
800
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check the first source now, scanning the rows…' }] } },
|
|
801
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: "Now let me query the second source; hang tight…" }] } },
|
|
802
|
+
])
|
|
803
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
804
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
805
|
+
const state = readSilentEndState()!
|
|
806
|
+
expect(state.pendingText).toBeUndefined()
|
|
807
|
+
// Even at the lowered floor there is nothing to deliver.
|
|
808
|
+
expect(decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 }).deliver).toBe(false)
|
|
754
809
|
})
|
|
755
810
|
|
|
756
811
|
it('a stale pendingText for a DIFFERENT turn is never delivered (turnKey guard)', () => {
|