switchroom 0.18.19 → 0.18.21
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/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR A — answer-ready quiescence flush (late-delivery fix).
|
|
3
|
+
*
|
|
4
|
+
* The gateway delivers a toolless terminal-text answer either on claude's
|
|
5
|
+
* (unreliable) `turn_duration`/turn_end, or — when that never lands — on the
|
|
6
|
+
* orphaned-reply backstop, which the answer text itself keeps re-arming across
|
|
7
|
+
* a ~150 s window. PR A adds a DETERMINISTIC ~1 s quiescence flush.
|
|
8
|
+
*
|
|
9
|
+
* Two layers of coverage:
|
|
10
|
+
* 1. The pure predicate `shouldArmAnswerReadyFlush` (which calls the REAL
|
|
11
|
+
* `decideTurnFlush` classifier + a REAL `ToolFlightTracker`).
|
|
12
|
+
* 2. An INTEGRATION harness that drives the REAL orchestration —
|
|
13
|
+
* `AnswerReadyFlushController` (the exact arm / debounce / rollover-guard /
|
|
14
|
+
* fire-time-re-verify / disarm code the gateway runs, extracted so it is
|
|
15
|
+
* testable, mirroring how `turn-end-gate-backstop.test.ts` drives the real
|
|
16
|
+
* `withTurnEndGateBackstop`). The harness supplies a faithful gateway world
|
|
17
|
+
* (a mutable `currentTurn`, a real `ToolFlightTracker`, and an
|
|
18
|
+
* `endCurrentTurnAtomic` that — like the real one — disarms via
|
|
19
|
+
* `controller.clear(turn)` and NULLS the atom) plus a send-sink that models
|
|
20
|
+
* `handleSessionEvent({turn_end})` short-circuiting on a null atom.
|
|
21
|
+
*
|
|
22
|
+
* The oracle asserts OUTCOMES on real code: the flush fires at the ~1 s debounce
|
|
23
|
+
* (not ~150 s), re-arms per text chunk, and delivers EXACTLY ONCE across both
|
|
24
|
+
* turn_end race orderings. It is designed to go RED if the controller's
|
|
25
|
+
* rollover guard, fire-time re-verify, or disarm were removed (see the
|
|
26
|
+
* red-when-removed proofs in the handback).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
30
|
+
import {
|
|
31
|
+
shouldArmAnswerReadyFlush,
|
|
32
|
+
resolveAnswerReadyFlushMs,
|
|
33
|
+
ANSWER_READY_FLUSH_MS,
|
|
34
|
+
AnswerReadyFlushController,
|
|
35
|
+
type AnswerReadyArmInput,
|
|
36
|
+
type FlushTimerHandle,
|
|
37
|
+
} from '../answer-ready-flush.js'
|
|
38
|
+
import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
|
|
39
|
+
|
|
40
|
+
const CHAT = '12345'
|
|
41
|
+
const WINDOW = 1000
|
|
42
|
+
// The orphaned-reply backstop's real dead-wait ceiling — the wall-clock the
|
|
43
|
+
// flush must beat. Used to prove the flush fires ~1 s, not ~150 s.
|
|
44
|
+
const BACKSTOP_MS = 150_000
|
|
45
|
+
|
|
46
|
+
function armInput(over: Partial<AnswerReadyArmInput> = {}): AnswerReadyArmInput {
|
|
47
|
+
return {
|
|
48
|
+
flush: {
|
|
49
|
+
chatId: CHAT,
|
|
50
|
+
replyCalled: false,
|
|
51
|
+
capturedText: ['Here is the composed final answer to your question.'],
|
|
52
|
+
flushEnabled: true,
|
|
53
|
+
},
|
|
54
|
+
inFlightToolCount: 0,
|
|
55
|
+
hasPendingAsyncDispatch: false,
|
|
56
|
+
flushWindowMs: WINDOW,
|
|
57
|
+
...over,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Pure predicate: shouldArmAnswerReadyFlush (real decideTurnFlush classifier)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('shouldArmAnswerReadyFlush (arm/fire predicate)', () => {
|
|
66
|
+
it('arms on a genuine composed terminal answer that is quiescent', () => {
|
|
67
|
+
expect(shouldArmAnswerReadyFlush(armInput())).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does NOT arm while a tool is in flight (not quiescent)', () => {
|
|
71
|
+
expect(shouldArmAnswerReadyFlush(armInput({ inFlightToolCount: 1 }))).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does NOT arm while a background/async dispatch is pending', () => {
|
|
75
|
+
expect(shouldArmAnswerReadyFlush(armInput({ hasPendingAsyncDispatch: true }))).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does NOT arm once the reply tool has served the turn (reply-called)', () => {
|
|
79
|
+
expect(
|
|
80
|
+
shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: true, capturedText: ['x'], flushEnabled: true } })),
|
|
81
|
+
).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does NOT arm on a genuine NO_REPLY turn (silent marker → no spurious flush)', () => {
|
|
85
|
+
expect(
|
|
86
|
+
shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: ['NO_REPLY'], flushEnabled: true } })),
|
|
87
|
+
).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does NOT arm on an empty terminal turn (nothing to send)', () => {
|
|
91
|
+
expect(
|
|
92
|
+
shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: [], flushEnabled: true } })),
|
|
93
|
+
).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does NOT arm on prose that ends with a trailing silent marker', () => {
|
|
97
|
+
expect(
|
|
98
|
+
shouldArmAnswerReadyFlush(
|
|
99
|
+
armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: ['Some prose the model wrote.', 'NO_REPLY'], flushEnabled: true } }),
|
|
100
|
+
),
|
|
101
|
+
).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does NOT arm for a system/sub-agent turn (no inbound chat)', () => {
|
|
105
|
+
expect(
|
|
106
|
+
shouldArmAnswerReadyFlush(armInput({ flush: { chatId: null, replyCalled: false, capturedText: ['answer'], flushEnabled: true } })),
|
|
107
|
+
).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does NOT arm when the kill-switch disables the flush (window <= 0)', () => {
|
|
111
|
+
expect(shouldArmAnswerReadyFlush(armInput({ flushWindowMs: 0 }))).toBe(false)
|
|
112
|
+
expect(shouldArmAnswerReadyFlush(armInput({ flushWindowMs: -1 }))).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does NOT arm when the turn-flush safety flag is off', () => {
|
|
116
|
+
expect(
|
|
117
|
+
shouldArmAnswerReadyFlush(armInput({ flush: { chatId: CHAT, replyCalled: false, capturedText: ['answer'], flushEnabled: false } })),
|
|
118
|
+
).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('resolveAnswerReadyFlushMs', () => {
|
|
123
|
+
it('defaults to ANSWER_READY_FLUSH_MS when unset', () => {
|
|
124
|
+
expect(resolveAnswerReadyFlushMs({})).toBe(ANSWER_READY_FLUSH_MS)
|
|
125
|
+
expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '' })).toBe(ANSWER_READY_FLUSH_MS)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('parses a positive override', () => {
|
|
129
|
+
expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '1500' })).toBe(1500)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('treats 0 (and negatives) as the kill-switch → disabled (0)', () => {
|
|
133
|
+
expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '0' })).toBe(0)
|
|
134
|
+
expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: '-5' })).toBe(0)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('fails safe to the default on an unparseable value', () => {
|
|
138
|
+
expect(resolveAnswerReadyFlushMs({ SWITCHROOM_ANSWER_READY_FLUSH_MS: 'nonsense' })).toBe(ANSWER_READY_FLUSH_MS)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('ANSWER_READY_FLUSH_MS is ~1 s ("immediate")', () => {
|
|
142
|
+
expect(ANSWER_READY_FLUSH_MS).toBe(1000)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Integration harness — drives the REAL AnswerReadyFlushController. Only the
|
|
148
|
+
// gateway world (currentTurn storage, the send sink, endCurrentTurnAtomic) is
|
|
149
|
+
// modeled; the arm / debounce / rollover-guard / fire-time-re-verify / disarm
|
|
150
|
+
// is the controller's REAL code. Mirrors turn-end-gate-backstop.test.ts driving
|
|
151
|
+
// the real withTurnEndGateBackstop.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
interface FakeTurn {
|
|
155
|
+
capturedText: string[]
|
|
156
|
+
replyCalled: boolean
|
|
157
|
+
answerReadyFlushTimeoutId: FlushTimerHandle | null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A faithful gateway world around the REAL controller. `sends` / `records`
|
|
162
|
+
* count what actually reached the user / turns.jsonl. `endCurrentTurnAtomic`
|
|
163
|
+
* mirrors the real one: it DISARMS via `controller.clear(turn)` and NULLS the
|
|
164
|
+
* atom. `dispatchTurnEnd` mirrors `handleSessionEvent({turn_end})`: it
|
|
165
|
+
* short-circuits when the atom is already gone (the exactly-once guarantee),
|
|
166
|
+
* else delivers once and runs `endCurrentTurnAtomic`.
|
|
167
|
+
*/
|
|
168
|
+
class GatewayWorld {
|
|
169
|
+
currentTurn: FakeTurn | null
|
|
170
|
+
flight = new ToolFlightTracker()
|
|
171
|
+
pendingAsync = false
|
|
172
|
+
sends = 0
|
|
173
|
+
records = 0
|
|
174
|
+
window = WINDOW
|
|
175
|
+
readonly controller: AnswerReadyFlushController<FakeTurn>
|
|
176
|
+
|
|
177
|
+
constructor() {
|
|
178
|
+
this.currentTurn = { capturedText: [], replyCalled: false, answerReadyFlushTimeoutId: null }
|
|
179
|
+
this.controller = new AnswerReadyFlushController<FakeTurn>({
|
|
180
|
+
getCurrentTurn: () => this.currentTurn,
|
|
181
|
+
getArmInput: (turn) => ({
|
|
182
|
+
flush: { chatId: CHAT, replyCalled: turn.replyCalled, capturedText: turn.capturedText, flushEnabled: true },
|
|
183
|
+
inFlightToolCount: this.flight.inFlightCount(),
|
|
184
|
+
hasPendingAsyncDispatch: this.pendingAsync,
|
|
185
|
+
flushWindowMs: this.window,
|
|
186
|
+
}),
|
|
187
|
+
getTimerHandle: (turn) => turn.answerReadyFlushTimeoutId,
|
|
188
|
+
setTimerHandle: (turn, handle) => {
|
|
189
|
+
turn.answerReadyFlushTimeoutId = handle
|
|
190
|
+
},
|
|
191
|
+
// The controller's single delivery action routes into the world's
|
|
192
|
+
// turn_end dispatch — exactly as the gateway's onFlush dispatches a
|
|
193
|
+
// synthetic turn_end into handleSessionEvent.
|
|
194
|
+
onFlush: () => this.dispatchTurnEnd(),
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** = endCurrentTurnAtomic: DISARM (real controller.clear) + NULL the atom. */
|
|
199
|
+
private endCurrentTurnAtomic(turn: FakeTurn): void {
|
|
200
|
+
this.controller.clear(turn) // ← the load-bearing disarm (gateway.ts:~4818)
|
|
201
|
+
this.records++
|
|
202
|
+
this.currentTurn = null // ← the load-bearing atom-null (#1067)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** = handleSessionEvent({turn_end}). A null atom short-circuits (no second
|
|
206
|
+
* send); otherwise deliver once and tear the turn down. */
|
|
207
|
+
dispatchTurnEnd(): void {
|
|
208
|
+
const turn = this.currentTurn
|
|
209
|
+
if (turn == null) return // atom already gone → exactly-once
|
|
210
|
+
this.sends++
|
|
211
|
+
this.endCurrentTurnAtomic(turn)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** = case 'text': push the chunk, then (re)arm via the REAL controller. */
|
|
215
|
+
onText(chunk: string): void {
|
|
216
|
+
if (this.currentTurn != null) this.currentTurn.capturedText.push(chunk)
|
|
217
|
+
this.controller.reset()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** = case 'tool_use' / 'tool_label': disarm (real controller.clear) then track. */
|
|
221
|
+
onToolUse(id: string): void {
|
|
222
|
+
this.controller.clear(this.currentTurn)
|
|
223
|
+
this.flight.onEvent({ kind: 'tool_use', toolUseId: id })
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
onToolResult(id: string): void {
|
|
227
|
+
this.flight.onEvent({ kind: 'tool_result', toolUseId: id })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
describe('answer-ready quiescence flush — real controller integration', () => {
|
|
232
|
+
beforeEach(() => vi.useFakeTimers())
|
|
233
|
+
afterEach(() => vi.useRealTimers())
|
|
234
|
+
|
|
235
|
+
it('flushes the composed answer at the ~1 s debounce, NOT at the ~150 s backstop', () => {
|
|
236
|
+
const w = new GatewayWorld()
|
|
237
|
+
w.onText('The composed final answer.')
|
|
238
|
+
// Nothing before the debounce elapses.
|
|
239
|
+
vi.advanceTimersByTime(WINDOW - 1)
|
|
240
|
+
expect(w.sends).toBe(0)
|
|
241
|
+
// Fires deterministically at the debounce.
|
|
242
|
+
vi.advanceTimersByTime(1)
|
|
243
|
+
expect(w.sends).toBe(1)
|
|
244
|
+
expect(w.records).toBe(1) // exactly one turns.jsonl record
|
|
245
|
+
// And it did NOT wait for the multi-minute backstop.
|
|
246
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
247
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
248
|
+
expect(w.sends).toBe(1) // still exactly one — proves it beat ~150 s
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('each text chunk re-arms the debounce (only fires 1 s after the LAST chunk)', () => {
|
|
252
|
+
const w = new GatewayWorld()
|
|
253
|
+
w.onText('Part one. ')
|
|
254
|
+
vi.advanceTimersByTime(500)
|
|
255
|
+
expect(w.sends).toBe(0)
|
|
256
|
+
w.onText('Part two — the rest of the answer.') // re-arms
|
|
257
|
+
vi.advanceTimersByTime(999)
|
|
258
|
+
expect(w.sends).toBe(0) // debounce reset by the 2nd chunk
|
|
259
|
+
vi.advanceTimersByTime(1)
|
|
260
|
+
expect(w.sends).toBe(1)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('EXACTLY-ONCE (a): quiescence flush, THEN a real turn_end → still one send', () => {
|
|
264
|
+
const w = new GatewayWorld()
|
|
265
|
+
w.onText('Answer text.')
|
|
266
|
+
vi.advanceTimersByTime(WINDOW)
|
|
267
|
+
expect(w.sends).toBe(1) // quiescence flushed + nulled the atom
|
|
268
|
+
// The real turn_end finally lands — it must short-circuit on the null atom.
|
|
269
|
+
// (Goes red if the atom-null in endCurrentTurnAtomic were removed.)
|
|
270
|
+
w.dispatchTurnEnd()
|
|
271
|
+
expect(w.sends).toBe(1)
|
|
272
|
+
expect(w.records).toBe(1)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('EXACTLY-ONCE (b): a real turn_end BEFORE the debounce disarms the pending flush', () => {
|
|
276
|
+
const w = new GatewayWorld()
|
|
277
|
+
w.onText('Answer text.')
|
|
278
|
+
vi.advanceTimersByTime(WINDOW - 100)
|
|
279
|
+
// turn_end arrives first → delivers once and (via controller.clear in
|
|
280
|
+
// endCurrentTurnAtomic) cancels the pending flush timer.
|
|
281
|
+
// (Goes red if controller.clear no longer cleared the timer.)
|
|
282
|
+
w.dispatchTurnEnd()
|
|
283
|
+
expect(w.sends).toBe(1)
|
|
284
|
+
expect(vi.getTimerCount()).toBe(0) // the pending quiescence timer was cleared
|
|
285
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
286
|
+
expect(w.sends).toBe(1) // the stale timer never fired a second send
|
|
287
|
+
expect(w.records).toBe(1)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('does NOT flush while a tool call is in flight (only on genuine quiescence)', () => {
|
|
291
|
+
const w = new GatewayWorld()
|
|
292
|
+
w.onText('Interim thought before a tool.')
|
|
293
|
+
// A tool starts within the window → disarm.
|
|
294
|
+
vi.advanceTimersByTime(400)
|
|
295
|
+
w.onToolUse('bash_1')
|
|
296
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
297
|
+
expect(w.sends).toBe(0) // never fired while (and after) the tool was open
|
|
298
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('re-arms and flushes once the tool completes and new answer text settles', () => {
|
|
302
|
+
const w = new GatewayWorld()
|
|
303
|
+
w.onText('Working on it.')
|
|
304
|
+
w.onToolUse('bash_1') // disarm
|
|
305
|
+
vi.advanceTimersByTime(2000)
|
|
306
|
+
expect(w.sends).toBe(0)
|
|
307
|
+
w.onToolResult('bash_1') // tool done → quiescent again
|
|
308
|
+
w.onText(' Here is the final composed answer.') // new text re-arms
|
|
309
|
+
vi.advanceTimersByTime(WINDOW)
|
|
310
|
+
expect(w.sends).toBe(1)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('fire-time re-verify: a tool in flight at expiry cancels the flush even if disarm was missed', () => {
|
|
314
|
+
const w = new GatewayWorld()
|
|
315
|
+
w.onText('Answer text.')
|
|
316
|
+
// Simulate a missed explicit disarm: a tool becomes in-flight directly,
|
|
317
|
+
// WITHOUT going through onToolUse (which would clear the timer).
|
|
318
|
+
w.flight.onEvent({ kind: 'tool_use', toolUseId: 'sneaky' })
|
|
319
|
+
vi.advanceTimersByTime(WINDOW)
|
|
320
|
+
// (Goes red if the controller's fire-time re-verification were removed.)
|
|
321
|
+
expect(w.sends).toBe(0)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('rollover guard: a superseded turn does not fire against a fresh atom', () => {
|
|
325
|
+
const w = new GatewayWorld()
|
|
326
|
+
const armed = w.currentTurn!
|
|
327
|
+
w.onText('Answer for turn A.') // arms A's timer
|
|
328
|
+
// A new turn swaps in before the debounce (turn rollover).
|
|
329
|
+
w.currentTurn = { capturedText: ['Turn B is still working'], replyCalled: false, answerReadyFlushTimeoutId: null }
|
|
330
|
+
expect(w.currentTurn).not.toBe(armed)
|
|
331
|
+
vi.advanceTimersByTime(WINDOW)
|
|
332
|
+
// (Goes red if the controller's rollover guard `live !== armedTurn` were removed.)
|
|
333
|
+
expect(w.sends).toBe(0)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('a NO_REPLY turn never arms, so it flushes nothing (records no_reply upstream)', () => {
|
|
337
|
+
const w = new GatewayWorld()
|
|
338
|
+
w.onText('NO_REPLY')
|
|
339
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
340
|
+
expect(w.sends).toBe(0)
|
|
341
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
342
|
+
})
|
|
343
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3114 — a cron fire must NOT warm the MAIN session's idle-clear clock.
|
|
3
|
+
*
|
|
4
|
+
* Root cause (confirmed live): `onInjectInbound` stamped the main idle clock
|
|
5
|
+
* UNCONDITIONALLY at inject time. A cron cadence shorter than
|
|
6
|
+
* `idle_clear_after` re-armed the timer on every fire, so idle-clear was
|
|
7
|
+
* permanently suppressed for any agent with a frequent scheduled task —
|
|
8
|
+
* including cheap-cron fires routed to the derived `<agent>-cron` bridge,
|
|
9
|
+
* whose session events never reach the main clock at all.
|
|
10
|
+
*
|
|
11
|
+
* This covers the pure identity predicate `isCronInjectFire` — which inject
|
|
12
|
+
* fires warm the main clock. It did not exist on main → import fails there.
|
|
13
|
+
*
|
|
14
|
+
* The gateway WIRING (that `onInjectInbound` gates the stamp on
|
|
15
|
+
* `!isCronInjectFire(...)`, that a cron cadence shorter than the window no
|
|
16
|
+
* longer re-arms the clock, and that a human inbound still warms it) is now
|
|
17
|
+
* covered BEHAVIOURALLY in `idle-clear.test.ts` — the
|
|
18
|
+
* `IdleTracker #3114` block drives the REAL predicate + the REAL `IdleTracker`
|
|
19
|
+
* through the exact gateway rule. #3115 extracted the idle bookkeeping into an
|
|
20
|
+
* importable object, so that block replaces the brittle source-text wiring pin
|
|
21
|
+
* this file used to carry (a regression in the real stamp logic now fails a
|
|
22
|
+
* behavioural assertion, not just a string-match over gateway.ts).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import { isCronInjectFire } from '../gateway/cron-session.js'
|
|
27
|
+
|
|
28
|
+
describe('#3114 isCronInjectFire — which inject fires warm the main idle clock', () => {
|
|
29
|
+
it('a cheap-cron fire (routed to the derived <agent>-cron bridge) does NOT warm the clock', () => {
|
|
30
|
+
// Tier-1: scheduler tags meta.session='cron' → resolveInjectTarget sends it
|
|
31
|
+
// to `<agent>-cron`; its session events are dropped for the cron identity.
|
|
32
|
+
expect(isCronInjectFire({ session: 'cron', source: 'cron' })).toBe(true)
|
|
33
|
+
expect(isCronInjectFire({ session: 'cron' })).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('a Tier-2 (main-session) cron fire does NOT warm the clock at inject time', () => {
|
|
37
|
+
// Tier-2 lands on the main bridge but is still a scheduled fire — the
|
|
38
|
+
// inject-time stamp is suppressed (its real turn stamps via session events;
|
|
39
|
+
// that is the documented residual, not the inject-time path).
|
|
40
|
+
expect(isCronInjectFire({ source: 'cron' })).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('a genuine operator/session inject (reaction, vault grant, resume) DOES warm the clock', () => {
|
|
44
|
+
expect(isCronInjectFire({ source: 'reaction' })).toBe(false)
|
|
45
|
+
expect(isCronInjectFire({ source: 'vault_grant_approved' })).toBe(false)
|
|
46
|
+
expect(isCronInjectFire({ source: 'resume' })).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a bare / manual inject with no cron marker DOES warm the clock', () => {
|
|
50
|
+
expect(isCronInjectFire(undefined)).toBe(false)
|
|
51
|
+
expect(isCronInjectFire({})).toBe(false)
|
|
52
|
+
expect(isCronInjectFire({ prompt_key: 'x' })).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -370,13 +370,15 @@ describe('the drain sites route through the façade with producers preserved ver
|
|
|
370
370
|
|
|
371
371
|
it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
|
|
372
372
|
const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
|
|
373
|
-
// narrative
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// feed-heartbeat-
|
|
379
|
-
|
|
373
|
+
// narrative SHOW + narrative RETRACT (timer-flush early-paint retract,
|
|
374
|
+
// narrative-flush.ts) + 2 liveness + tool + 2 sub-agent + 1 post-answer
|
|
375
|
+
// bg-liveness (Fix 2) = 8. The Phase-1 0-label climb
|
|
376
|
+
// (deterministic-turn-liveness.md) consults the SAME guard, but its
|
|
377
|
+
// `if (deps.mayDrain())` lives in the extracted tick body
|
|
378
|
+
// (feed-heartbeat-climb.ts) with `() => ea.mayDrain(turn)` injected at the
|
|
379
|
+
// gateway call site — pinned by feed-heartbeat-liveness-open.test.ts +
|
|
380
|
+
// silent-turn-climb-transport.test.ts.
|
|
381
|
+
expect(mayDrainGuards).toHaveLength(8)
|
|
380
382
|
expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
|
|
381
383
|
})
|
|
382
384
|
})
|
|
@@ -498,11 +500,12 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
|
|
|
498
500
|
})
|
|
499
501
|
|
|
500
502
|
it('the card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
|
|
501
|
-
// Option A: the
|
|
503
|
+
// Option A: the 8 `if (ea.mayDrain(turn))` guards + drainActivitySummary
|
|
502
504
|
// thunks stay byte-identical, wrapped by the centralized helper.
|
|
503
|
-
// 6 original + 1
|
|
505
|
+
// 6 original + 1 post-answer background-agent liveness drain (Fix 2) + 1
|
|
506
|
+
// narrative-retract drain (timer-flush early-paint retract, narrative-flush.ts).
|
|
504
507
|
const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
|
|
505
|
-
expect(wraps).toHaveLength(
|
|
508
|
+
expect(wraps).toHaveLength(8)
|
|
506
509
|
// The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
|
|
507
510
|
// the SAME helper via an injected thunk — its call lives in the extracted
|
|
508
511
|
// tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the fleet-wide consistent-formatting bundle:
|
|
3
3
|
*
|
|
4
|
-
* 1.
|
|
4
|
+
* 1. addParagraphSpacers — a visible U+00A0 spacer line at EVERY block
|
|
5
5
|
* transition (paragraph→list, list→paragraph, heading→anything,
|
|
6
|
-
* blockquote/table boundaries),
|
|
7
|
-
*
|
|
6
|
+
* blockquote/table boundaries), idempotent and double-gap-proof; list/
|
|
7
|
+
* table interiors stay tight. Restored after the #3208 F1 misfire — the
|
|
8
|
+
* rich GFM renderer renders a bare `\n\n` gap TIGHT, so the spacer is what
|
|
9
|
+
* produces the visible paragraph gap.
|
|
8
10
|
* 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
|
|
9
11
|
* list markers → `- `, on code-masked text, idempotent; link hrefs are
|
|
10
12
|
* protected from dash rewriting.
|
|
@@ -13,13 +15,17 @@
|
|
|
13
15
|
*/
|
|
14
16
|
import { describe, test, expect } from 'vitest'
|
|
15
17
|
import {
|
|
18
|
+
addParagraphSpacers,
|
|
16
19
|
normalizeParagraphBreaks,
|
|
17
20
|
normalizePunctuation,
|
|
18
21
|
stripExcessBold,
|
|
19
22
|
splitMarkdownChunks,
|
|
20
23
|
hardenCardBreaks,
|
|
24
|
+
PARAGRAPH_SPACER,
|
|
21
25
|
} from '../format.js'
|
|
22
26
|
|
|
27
|
+
const SP = PARAGRAPH_SPACER // U+00A0
|
|
28
|
+
|
|
23
29
|
describe('hardenCardBreaks — deterministic card line-break hardener', () => {
|
|
24
30
|
test('promotes lone field breaks to GFM hard breaks (the blob fix)', () => {
|
|
25
31
|
const out = hardenCardBreaks('Agent: assistant\nAuth: Max\nStatus: running')
|
|
@@ -98,60 +104,74 @@ describe('hardenCardBreaks — deterministic card line-break hardener', () => {
|
|
|
98
104
|
})
|
|
99
105
|
})
|
|
100
106
|
|
|
101
|
-
describe('
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const out = normalizeParagraphBreaks('Alpha.\n\nBravo.')
|
|
106
|
-
expect(out).toBe('Alpha.\n\nBravo.')
|
|
107
|
-
expect(out).not.toContain(NBSP)
|
|
107
|
+
describe('addParagraphSpacers — uniform block spacing', () => {
|
|
108
|
+
test('still spaces prose→prose (existing behaviour)', () => {
|
|
109
|
+
const out = addParagraphSpacers('Alpha.\n\nBravo.')
|
|
110
|
+
expect(out).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
|
|
108
111
|
})
|
|
109
112
|
|
|
110
|
-
test('paragraph→list transition
|
|
111
|
-
|
|
113
|
+
test('spaces paragraph→list transition', () => {
|
|
114
|
+
const out = addParagraphSpacers('Intro.\n\n- one\n- two')
|
|
115
|
+
expect(out).toBe(`Intro.\n\n${SP}\n\n- one\n- two`)
|
|
112
116
|
})
|
|
113
117
|
|
|
114
|
-
test('list→paragraph transition
|
|
115
|
-
|
|
118
|
+
test('spaces list→paragraph transition', () => {
|
|
119
|
+
const out = addParagraphSpacers('- one\n- two\n\nOutro.')
|
|
120
|
+
expect(out).toBe(`- one\n- two\n\n${SP}\n\nOutro.`)
|
|
116
121
|
})
|
|
117
122
|
|
|
118
|
-
test('heading→anything
|
|
119
|
-
expect(
|
|
120
|
-
expect(
|
|
123
|
+
test('spaces heading→anything', () => {
|
|
124
|
+
expect(addParagraphSpacers('# Title\n\nBody.')).toBe(`# Title\n\n${SP}\n\nBody.`)
|
|
125
|
+
expect(addParagraphSpacers('# Title\n\n- a\n- b')).toBe(`# Title\n\n${SP}\n\n- a\n- b`)
|
|
121
126
|
})
|
|
122
127
|
|
|
123
|
-
test('blockquote and table boundaries
|
|
124
|
-
expect(
|
|
128
|
+
test('spaces blockquote and table boundaries', () => {
|
|
129
|
+
expect(addParagraphSpacers('> quoted\n\nProse.')).toBe(`> quoted\n\n${SP}\n\nProse.`)
|
|
125
130
|
const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
|
126
|
-
expect(
|
|
127
|
-
expect(
|
|
131
|
+
expect(addParagraphSpacers(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${SP}\n\n${table}`)
|
|
132
|
+
expect(addParagraphSpacers(`${table}\n\nProse.`)).toBe(`${table}\n\n${SP}\n\nProse.`)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('does NOT space between items of the same loose list', () => {
|
|
136
|
+
const input = '- one\n\n- two\n\n- three'
|
|
137
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('does NOT space inside a tight list or table interior (single \\n)', () => {
|
|
141
|
+
const list = 'Intro.\n\n- a\n- b\n- c'
|
|
142
|
+
expect(addParagraphSpacers(list)).toBe(`Intro.\n\n${SP}\n\n- a\n- b\n- c`)
|
|
143
|
+
const table = '| a |\n| --- |\n| 1 |\n| 2 |'
|
|
144
|
+
expect(addParagraphSpacers(table)).toBe(table)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('idempotent across every transition kind', () => {
|
|
148
|
+
const input = '# H\n\nProse one.\n\n- a\n- b\n\nProse two.\n\n> quote'
|
|
149
|
+
const once = addParagraphSpacers(input)
|
|
150
|
+
expect(addParagraphSpacers(once)).toBe(once)
|
|
128
151
|
})
|
|
129
152
|
|
|
130
153
|
test('never touches code fences', () => {
|
|
131
154
|
const input = '```\nA\n\nB\n```\n\n```\nC\n```'
|
|
132
|
-
expect(
|
|
155
|
+
expect(addParagraphSpacers(input)).toBe(input)
|
|
133
156
|
})
|
|
134
157
|
|
|
135
|
-
test('full pipeline: mixed prose+list message gets
|
|
136
|
-
const raw = 'Summary line.\n
|
|
137
|
-
const out = normalizeParagraphBreaks(raw)
|
|
138
|
-
|
|
139
|
-
expect(out).
|
|
140
|
-
|
|
158
|
+
test('full pipeline: mixed prose+list+heading message gets uniform gaps', () => {
|
|
159
|
+
const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
|
|
160
|
+
const out = addParagraphSpacers(normalizeParagraphBreaks(raw))
|
|
161
|
+
// Every block transition carries exactly one visible spacer line.
|
|
162
|
+
expect(out).toBe(
|
|
163
|
+
`Summary line.\n\n${SP}\n\n- item one\n- item two\n\n${SP}\n\nClosing prose.`,
|
|
164
|
+
)
|
|
141
165
|
})
|
|
142
166
|
|
|
143
|
-
test('chunk-boundary interaction: a cut in a
|
|
167
|
+
test('chunk-boundary interaction: a cut in a spacer gap strips the spacer', () => {
|
|
144
168
|
const a = 'A'.repeat(60)
|
|
145
169
|
const b = 'B'.repeat(60)
|
|
146
|
-
const text = `${a}\n\n${b}`
|
|
170
|
+
const text = `${a}\n\n${SP}\n\n${b}`
|
|
147
171
|
const chunks = splitMarkdownChunks(text, 80)
|
|
148
172
|
expect(chunks.length).toBe(2)
|
|
149
173
|
expect(chunks[0]).toBe(a)
|
|
150
174
|
expect(chunks[1]).toBe(b)
|
|
151
|
-
// Neither chunk opens or ends with a stray blank line.
|
|
152
|
-
for (const c of chunks) {
|
|
153
|
-
expect(c).toBe(c.replace(/^\n+|\n+$/g, ''))
|
|
154
|
-
}
|
|
155
175
|
})
|
|
156
176
|
})
|
|
157
177
|
|
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
* fixture intended (bold/italic/code/pre/link at the right inner text).
|
|
18
18
|
*
|
|
19
19
|
* The pipeline mirrors `telegram-plugin/gateway/gateway.ts`:
|
|
20
|
-
* repairEscapedWhitespace -> normalizeParagraphBreaks ->
|
|
21
|
-
* (
|
|
22
|
-
*
|
|
23
|
-
* `normalizeDashes` first — that is where F1's voice scrub lives.)
|
|
20
|
+
* repairEscapedWhitespace -> normalizeParagraphBreaks -> addParagraphSpacers
|
|
21
|
+
* -> splitMarkdownChunks. (Card-surface fixtures also apply `normalizeDashes`
|
|
22
|
+
* first — that is where F1's voice scrub lives.)
|
|
24
23
|
*
|
|
25
24
|
* This suite is TEST-ONLY. It changes no production formatting behaviour. If a
|
|
26
25
|
* fixture ever fails signal (a), it means the formatter emits markdown Telegram
|
|
@@ -31,6 +30,7 @@ import { describe, test, expect } from 'vitest'
|
|
|
31
30
|
import {
|
|
32
31
|
repairEscapedWhitespace,
|
|
33
32
|
normalizeParagraphBreaks,
|
|
33
|
+
addParagraphSpacers,
|
|
34
34
|
splitMarkdownChunks,
|
|
35
35
|
RICH_MESSAGE_MAX_CHARS,
|
|
36
36
|
} from '../format.js'
|
|
@@ -57,7 +57,8 @@ function transform(input: string, opts: { cardSurfaceScrub?: boolean; cap?: numb
|
|
|
57
57
|
} {
|
|
58
58
|
let text = input
|
|
59
59
|
if (opts.cardSurfaceScrub) text = normalizeDashes(text)
|
|
60
|
-
|
|
60
|
+
text = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
61
|
+
const body = addParagraphSpacers(text)
|
|
61
62
|
const chunks = splitMarkdownChunks(body, opts.cap ?? RICH_MESSAGE_MAX_CHARS)
|
|
62
63
|
return { chunks, body }
|
|
63
64
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* The `input` is the raw text as a model/card surface would author it — the
|
|
8
8
|
* regression test runs it through the SAME outbound transform pipeline the
|
|
9
9
|
* gateway uses (repairEscapedWhitespace -> normalizeParagraphBreaks ->
|
|
10
|
-
* splitMarkdownChunks) and then asserts:
|
|
10
|
+
* addParagraphSpacers -> splitMarkdownChunks) and then asserts:
|
|
11
11
|
*
|
|
12
12
|
* (a) every emitted chunk is parse-accept valid (no rich-path 400), and
|
|
13
13
|
* (b) the concatenated output parses into `expect` (entity structure).
|