switchroom 0.19.40 → 0.19.42

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.
Files changed (37) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +810 -241
  5. package/dist/host-control/main.js +94 -19
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +2 -2
  10. package/telegram-plugin/dist/bridge/bridge.js +2 -2
  11. package/telegram-plugin/dist/gateway/gateway.js +332 -124
  12. package/telegram-plugin/dist/server.js +2 -2
  13. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  14. package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
  15. package/telegram-plugin/gateway/gateway.ts +76 -76
  16. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  17. package/telegram-plugin/gateway/model-command.ts +21 -109
  18. package/telegram-plugin/gateway/stream-render.ts +57 -0
  19. package/telegram-plugin/gateway/turn-record-status.ts +80 -0
  20. package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
  21. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  22. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
  23. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  24. package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
  25. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  26. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  28. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  29. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  30. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  31. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Outcome pins for checklist graceful degradation
3
+ * (gateway/checklist-fallback.ts).
4
+ *
5
+ * The bug this guards: `send_checklist` built a FLAT
6
+ * `{ chat_id, title, tasks: [{ text, is_completed }] }` payload for
7
+ * Telegram's `sendChecklist`, which requires (a) `title`/`tasks` nested
8
+ * inside a single `checklist` object, (b) a REQUIRED integer `id` per task,
9
+ * (c) NO completion flag, and (d) a `business_connection_id` — native
10
+ * checklists are Business-account-only, so every ordinary bot chat got a raw
11
+ * `400: Bad Request: parameter "checklist" is required`.
12
+ *
13
+ * Pinned outcomes: the normal (no business connection) path sends a
14
+ * formatted ✅/⬜ text message and reports `degraded: "text"`; the native
15
+ * payload, when built, is CORRECT per Bot API 9.1; and no native failure
16
+ * escapes as a raw error — it falls back to text.
17
+ */
18
+
19
+ import { describe, it, expect, vi } from 'vitest'
20
+ import {
21
+ buildChecklistTasks,
22
+ renderChecklistText,
23
+ applyChecklistPatch,
24
+ buildNativeChecklistPayload,
25
+ buildNativeEditChecklistPayload,
26
+ createChecklistStore,
27
+ checklistStoreKey,
28
+ performSendChecklist,
29
+ performUpdateChecklist,
30
+ sendChecklistToolText,
31
+ updateChecklistToolText,
32
+ CHECKLIST_MAX_TASKS,
33
+ type ChecklistState,
34
+ type SendChecklistDeps,
35
+ type UpdateChecklistDeps,
36
+ } from '../gateway/checklist-fallback.js'
37
+
38
+ function sendDeps(over: Partial<SendChecklistDeps> = {}) {
39
+ const sendNative = vi.fn(async () => ({ message_id: 100 }))
40
+ const sendText = vi.fn(async () => ({ message_id: 200 }))
41
+ const log = vi.fn()
42
+ const deps: SendChecklistDeps = {
43
+ businessConnectionId: undefined,
44
+ nativeAvailable: true,
45
+ sendNative,
46
+ sendText,
47
+ literalText: false,
48
+ log,
49
+ ...over,
50
+ }
51
+ return { deps, sendNative, sendText, log }
52
+ }
53
+
54
+ function updateDeps(over: Partial<UpdateChecklistDeps> = {}) {
55
+ const editNative = vi.fn(async () => {})
56
+ const editText = vi.fn(async () => {})
57
+ const log = vi.fn()
58
+ const deps: UpdateChecklistDeps = {
59
+ state: undefined,
60
+ businessConnectionId: undefined,
61
+ nativeAvailable: true,
62
+ editNative,
63
+ editText,
64
+ literalText: false,
65
+ log,
66
+ chatId: 42,
67
+ messageId: 7,
68
+ ...over,
69
+ }
70
+ return { deps, editNative, editText, log }
71
+ }
72
+
73
+ const textState = (over: Partial<ChecklistState> = {}): ChecklistState => ({
74
+ title: 'Trip prep',
75
+ tasks: [
76
+ { id: 1, text: 'book flights', done: false },
77
+ { id: 2, text: 'renew passport', done: true },
78
+ ],
79
+ mode: 'text',
80
+ ...over,
81
+ })
82
+
83
+ describe('buildChecklistTasks', () => {
84
+ it('assigns sequential 1-based ids and normalizes done', () => {
85
+ expect(buildChecklistTasks([{ text: 'a' }, { text: 'b', done: true }])).toEqual([
86
+ { id: 1, text: 'a', done: false },
87
+ { id: 2, text: 'b', done: true },
88
+ ])
89
+ })
90
+
91
+ it('rejects more than the 30-task API limit', () => {
92
+ const tasks = Array.from({ length: CHECKLIST_MAX_TASKS + 1 }, (_, i) => ({ text: `t${i}` }))
93
+ expect(() => buildChecklistTasks(tasks)).toThrow(/30-task limit/)
94
+ })
95
+ })
96
+
97
+ describe('renderChecklistText', () => {
98
+ it('renders bold title + ✅/⬜ lines honoring done', () => {
99
+ expect(renderChecklistText(textState())).toBe('**Trip prep**\n⬜ book flights\n✅ renew passport')
100
+ })
101
+
102
+ it('literal mode drops the markdown bold (parseMode: text sends are not parsed)', () => {
103
+ expect(renderChecklistText(textState(), { literal: true })).toBe('Trip prep\n⬜ book flights\n✅ renew passport')
104
+ })
105
+ })
106
+
107
+ describe('applyChecklistPatch', () => {
108
+ it('updates by id, appends without id, replaces title; input untouched', () => {
109
+ const base = textState()
110
+ const next = applyChecklistPatch(base, {
111
+ title: 'Trip prep v2',
112
+ tasks: [{ id: '1', done: true }, { text: 'pack bags' }],
113
+ })
114
+ expect(next).toEqual({
115
+ title: 'Trip prep v2',
116
+ tasks: [
117
+ { id: 1, text: 'book flights', done: true },
118
+ { id: 2, text: 'renew passport', done: true },
119
+ { id: 3, text: 'pack bags', done: false },
120
+ ],
121
+ })
122
+ expect(base.tasks[0].done).toBe(false) // pure — no mutation
123
+ })
124
+
125
+ it('appends a task with an unknown id, preserving that id', () => {
126
+ const next = applyChecklistPatch(textState(), { tasks: [{ id: 9, text: 'buy sim', done: false }] })
127
+ expect(next.tasks[2]).toEqual({ id: 9, text: 'buy sim', done: false })
128
+ })
129
+ })
130
+
131
+ describe('native payloads — correct Bot API 9.1 shape', () => {
132
+ it('sendChecklist nests title/tasks inside `checklist`, carries ids, has NO is_completed', () => {
133
+ const payload = buildNativeChecklistPayload({
134
+ businessConnectionId: 'bc1',
135
+ chatId: 42,
136
+ title: 'T',
137
+ tasks: buildChecklistTasks([{ text: 'a', done: true }, { text: 'b' }]),
138
+ replyToMessageId: 5,
139
+ })
140
+ expect(payload).toEqual({
141
+ business_connection_id: 'bc1',
142
+ chat_id: 42,
143
+ checklist: { title: 'T', tasks: [{ id: 1, text: 'a' }, { id: 2, text: 'b' }] },
144
+ reply_parameters: { message_id: 5 },
145
+ })
146
+ expect(JSON.stringify(payload)).not.toContain('is_completed')
147
+ })
148
+
149
+ it('editMessageChecklist sends the FULL checklist (native edits replace it)', () => {
150
+ const payload = buildNativeEditChecklistPayload({
151
+ businessConnectionId: 'bc1',
152
+ chatId: 42,
153
+ messageId: 7,
154
+ title: 'T',
155
+ tasks: [{ id: 3, text: 'kept', done: false }],
156
+ })
157
+ expect(payload).toEqual({
158
+ business_connection_id: 'bc1',
159
+ chat_id: 42,
160
+ message_id: 7,
161
+ checklist: { title: 'T', tasks: [{ id: 3, text: 'kept' }] },
162
+ })
163
+ })
164
+ })
165
+
166
+ describe('performSendChecklist — degradation outcomes', () => {
167
+ it('no business connection (the fleet norm) → sends the ✅/⬜ text render, never native', async () => {
168
+ const { deps, sendNative, sendText } = sendDeps()
169
+ const r = await performSendChecklist(deps, {
170
+ title: 'Trip prep',
171
+ tasks: [{ text: 'book flights' }, { text: 'renew passport', done: true }],
172
+ chatId: 42,
173
+ })
174
+ expect(sendNative).not.toHaveBeenCalled()
175
+ expect(sendText).toHaveBeenCalledWith('**Trip prep**\n⬜ book flights\n✅ renew passport')
176
+ expect(r).toMatchObject({ message_id: 200, mode: 'text' })
177
+ expect(r.state).toEqual({
178
+ title: 'Trip prep',
179
+ tasks: [
180
+ { id: 1, text: 'book flights', done: false },
181
+ { id: 2, text: 'renew passport', done: true },
182
+ ],
183
+ mode: 'text',
184
+ })
185
+ })
186
+
187
+ it('business connection configured → sends the correct nested native payload', async () => {
188
+ const { deps, sendNative, sendText } = sendDeps({ businessConnectionId: 'bc1' })
189
+ const r = await performSendChecklist(deps, { title: 'T', tasks: [{ text: 'a' }], chatId: 42 })
190
+ expect(sendText).not.toHaveBeenCalled()
191
+ expect(sendNative).toHaveBeenCalledWith({
192
+ business_connection_id: 'bc1',
193
+ chat_id: 42,
194
+ checklist: { title: 'T', tasks: [{ id: 1, text: 'a' }] },
195
+ })
196
+ expect(r).toMatchObject({ message_id: 100, mode: 'native' })
197
+ })
198
+
199
+ it('native failure does NOT escape — falls back to the text render', async () => {
200
+ const { deps, sendText, log } = sendDeps({
201
+ businessConnectionId: 'bc1',
202
+ sendNative: vi.fn(async () => { throw new Error('400: Bad Request: parameter "checklist" is required') }),
203
+ })
204
+ const r = await performSendChecklist(deps, { title: 'T', tasks: [{ text: 'a' }], chatId: 42 })
205
+ expect(r).toMatchObject({ message_id: 200, mode: 'text' })
206
+ expect(sendText).toHaveBeenCalledWith('**T**\n⬜ a')
207
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('falling back to text'))
208
+ })
209
+
210
+ it('checklist API not available in this grammY version → text render, no native attempt', async () => {
211
+ const { deps, sendNative } = sendDeps({ businessConnectionId: 'bc1', nativeAvailable: false })
212
+ const r = await performSendChecklist(deps, { title: 'T', tasks: [{ text: 'a' }], chatId: 42 })
213
+ expect(sendNative).not.toHaveBeenCalled()
214
+ expect(r.mode).toBe('text')
215
+ })
216
+ })
217
+
218
+ describe('performUpdateChecklist — degradation outcomes', () => {
219
+ it('text-mode: patch applies to stored state and re-renders the ✅/⬜ text', async () => {
220
+ const { deps, editText, editNative } = updateDeps({ state: textState() })
221
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
222
+ expect(editNative).not.toHaveBeenCalled()
223
+ expect(editText).toHaveBeenCalledWith('**Trip prep**\n✅ book flights\n✅ renew passport')
224
+ expect(r).toMatchObject({ ok: true, mode: 'text' })
225
+ })
226
+
227
+ it('no stored state + partial patch → structured unknown_checklist, no edit attempted', async () => {
228
+ const { deps, editText, editNative } = updateDeps()
229
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
230
+ expect(r).toMatchObject({ ok: false, reason: 'unknown_checklist' })
231
+ expect(editText).not.toHaveBeenCalled()
232
+ expect(editNative).not.toHaveBeenCalled()
233
+ })
234
+
235
+ it('no stored state + full replacement (title + all task texts) → rebuilds and edits as text', async () => {
236
+ const { deps, editText } = updateDeps()
237
+ const r = await performUpdateChecklist(deps, {
238
+ title: 'Rebuilt',
239
+ tasks: [{ text: 'a', done: true }, { text: 'b' }],
240
+ })
241
+ expect(r).toMatchObject({ ok: true, mode: 'text' })
242
+ expect(editText).toHaveBeenCalledWith('**Rebuilt**\n✅ a\n⬜ b')
243
+ })
244
+
245
+ it('native-mode: edits with the FULL post-patch checklist payload', async () => {
246
+ const { deps, editNative } = updateDeps({
247
+ state: textState({ mode: 'native' }),
248
+ businessConnectionId: 'bc1',
249
+ })
250
+ const r = await performUpdateChecklist(deps, { tasks: [{ text: 'new task' }] })
251
+ expect(r).toMatchObject({ ok: true, mode: 'native' })
252
+ expect(editNative).toHaveBeenCalledWith({
253
+ business_connection_id: 'bc1',
254
+ chat_id: 42,
255
+ message_id: 7,
256
+ checklist: {
257
+ title: 'Trip prep',
258
+ tasks: [
259
+ { id: 1, text: 'book flights' },
260
+ { id: 2, text: 'renew passport' },
261
+ { id: 3, text: 'new task' },
262
+ ],
263
+ },
264
+ })
265
+ })
266
+
267
+ it('a failed edit never throws a raw Telegram error — structured edit_failed instead', async () => {
268
+ const { deps } = updateDeps({
269
+ state: textState(),
270
+ editText: vi.fn(async () => { throw new Error('400: Bad Request: message to edit not found') }),
271
+ })
272
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
273
+ expect(r).toMatchObject({ ok: false, reason: 'edit_failed' })
274
+ expect((r as { hint: string }).hint).toContain('message to edit not found')
275
+ })
276
+
277
+ it('native-mode checklist with the business connection gone → structured edit_failed', async () => {
278
+ const { deps, editNative } = updateDeps({ state: textState({ mode: 'native' }) })
279
+ const r = await performUpdateChecklist(deps, { tasks: [{ id: 1, done: true }] })
280
+ expect(r).toMatchObject({ ok: false, reason: 'edit_failed' })
281
+ expect(editNative).not.toHaveBeenCalled()
282
+ })
283
+ })
284
+
285
+ describe('tool-result text', () => {
286
+ it('send: degraded text result carries degraded:"text" so the agent knows', () => {
287
+ const parsed = JSON.parse(sendChecklistToolText({
288
+ message_id: 200, mode: 'text', state: textState(),
289
+ }))
290
+ expect(parsed).toMatchObject({ ok: true, message_id: 200, mode: 'text', degraded: 'text' })
291
+ expect(parsed.note).toContain('Business connection')
292
+ })
293
+
294
+ it('send: native result has no degraded marker', () => {
295
+ expect(JSON.parse(sendChecklistToolText({
296
+ message_id: 100, mode: 'native', state: textState({ mode: 'native' }),
297
+ }))).toEqual({ ok: true, message_id: 100, mode: 'native' })
298
+ })
299
+
300
+ it('update: structured failure round-trips reason + hint', () => {
301
+ expect(JSON.parse(updateChecklistToolText(
302
+ { ok: false, reason: 'unknown_checklist', hint: 'resend it' }, 7,
303
+ ))).toEqual({ ok: false, reason: 'unknown_checklist', hint: 'resend it' })
304
+ })
305
+ })
306
+
307
+ describe('createChecklistStore', () => {
308
+ it('evicts oldest entries past the cap', () => {
309
+ const store = createChecklistStore(2)
310
+ store.set(checklistStoreKey('1', 1), textState())
311
+ store.set(checklistStoreKey('1', 2), textState())
312
+ store.set(checklistStoreKey('1', 3), textState())
313
+ expect(store.get('1:1')).toBeUndefined()
314
+ expect(store.get('1:2')).toBeDefined()
315
+ expect(store.get('1:3')).toBeDefined()
316
+ })
317
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Two-state desync wedge — the silence-fallback must drain `parkedTurnStarts`.
3
+ *
4
+ * The gateway carries TWO independent "turn in flight" states. The park gate in
5
+ * `stream-render.ts` (`case 'enqueue'`) treats the session as busy if EITHER a
6
+ * live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
7
+ * framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
8
+ * hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
9
+ * before the fix it NEVER touched `parkedTurnStarts`. That store's only real
10
+ * drains are the CLI's own `dequeue` / `remove` stream events, so when the
11
+ * claude REPL hangs mid-turn those never arrive, a leftover parked envelope
12
+ * latches the busy gate permanently, and every later inbound parks unseen until
13
+ * a manual container restart (gymbro parked msgs 4502→4504 behind a dead lock
14
+ * until SIGTERM). #3927 introduced the parked store but never wired it into
15
+ * fallback recovery.
16
+ *
17
+ * This drives the REAL `handleSessionEvent` (the extracted-module golden
18
+ * harness, same standard as `turn-mint-defers-until-dequeue.test.ts`) and the
19
+ * REAL `onFrameworkFallback` (via `buildSilencePokeOptions`, same standard as
20
+ * `silence-poke-teardown-notice.test.ts`), sharing ONE world: the fallback's
21
+ * `drainParkedTurnStarts` dep routes to the real store + `beginTurn` over the
22
+ * harness deps. It asserts the OUTCOME, not a code path: after the fallback the
23
+ * parked store is emptied AND the parked message was handed back into turn
24
+ * processing (its turn begun, its card opened) rather than silently dropped.
25
+ */
26
+ import { describe, it, expect, beforeEach } from 'vitest'
27
+ import {
28
+ handleSessionEvent,
29
+ drainParkedTurnStartsForChat,
30
+ __resetParkedTurnStartsForTest,
31
+ __parkedTurnStartCountForTest,
32
+ } from '../gateway/stream-render.js'
33
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
34
+ import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
35
+ import { CHAT, enqueue, makeHarness } from './turn-mint-harness.js'
36
+
37
+ beforeEach(() => {
38
+ __resetParkedTurnStartsForTest()
39
+ })
40
+
41
+ describe('silence-fallback drains parkedTurnStarts so a hung REPL cannot wedge the park gate', () => {
42
+ it('empties the parked store AND redelivers the parked message into turn processing', async () => {
43
+ const h = makeHarness()
44
+
45
+ // ── turn A mints on the idle enqueue and its ms-later dequeue ─────────────
46
+ handleSessionEvent(h.deps, enqueue('501'))
47
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
48
+ const turnA = h.current()!
49
+ expect(turnA.sourceMessageId).toBe(501)
50
+
51
+ // ── the operator sends B mid-turn; the CLI queues it (enqueue) but the
52
+ // REPL hangs, so the paired `dequeue` never comes and B stays parked ──
53
+ handleSessionEvent(h.deps, enqueue('502', 'still there?'))
54
+ expect(__parkedTurnStartCountForTest()).toBe(1)
55
+
56
+ // ── the REAL 300 s framework-fallback fires, wired to the REAL parked
57
+ // drain over the harness deps so both states share one world ──────────
58
+ const KEY = statusKeyForTests(CHAT, null)
59
+ const fx = makeLivenessFixture({
60
+ drainParkedTurnStarts: (chatId: string, threadId: number | null) =>
61
+ drainParkedTurnStartsForChat(
62
+ h.deps,
63
+ chatId,
64
+ threadId != null ? String(threadId) : null,
65
+ ).length,
66
+ })
67
+ // The wedged turn the fallback tears down — same chat/thread as parked B.
68
+ fx.activeTurnStartedAt.set(KEY, 0)
69
+ fx.setCurrentTurn(makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }))
70
+
71
+ const opts = buildSilencePokeOptions(fx.deps)
72
+ await opts.onFrameworkFallback({
73
+ key: KEY,
74
+ chatId: CHAT,
75
+ threadId: null,
76
+ fallbackKind: 'working',
77
+ silenceMs: 302_000,
78
+ inFlightTools: [],
79
+ })
80
+
81
+ // The fallback nulled the wedged turn (its one load-bearing job).
82
+ expect(fx.endedKeys).toContain(KEY)
83
+
84
+ // OUTCOME 1 — the parked store is emptied, so the busy park gate unlatches
85
+ // and the NEXT inbound can mint its own turn instead of parking unseen.
86
+ expect(__parkedTurnStartCountForTest()).toBe(0)
87
+
88
+ // OUTCOME 2 — B was actually dispatched back into turn processing, not
89
+ // dropped: its turn was begun (it is now the live turn) and its progress
90
+ // card was opened. Pre-fix, B stayed parked and this never happened.
91
+ expect(h.current()!.sourceMessageId).toBe(502)
92
+ expect(h.cardsOpened.some((c) => c.sourceMessageId === 502)).toBe(true)
93
+ })
94
+
95
+ it('is scoped to the wedged chat — a fallback for chat A leaves chat B parked', () => {
96
+ const h = makeHarness()
97
+
98
+ // A live turn on CHAT, plus a parked entry on CHAT.
99
+ handleSessionEvent(h.deps, enqueue('601'))
100
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
101
+ handleSessionEvent(h.deps, enqueue('602'))
102
+ expect(__parkedTurnStartCountForTest()).toBe(1)
103
+
104
+ // A fallback for a DIFFERENT chat must not touch CHAT's parked entry.
105
+ const drainedOther = drainParkedTurnStartsForChat(h.deps, '9999', null)
106
+ expect(drainedOther).toHaveLength(0)
107
+ expect(__parkedTurnStartCountForTest()).toBe(1)
108
+
109
+ // The correctly-scoped drain releases it.
110
+ const drainedHere = drainParkedTurnStartsForChat(h.deps, CHAT, null)
111
+ expect(drainedHere).toHaveLength(1)
112
+ expect(drainedHere[0]!.messageId).toBe('602')
113
+ expect(__parkedTurnStartCountForTest()).toBe(0)
114
+ })
115
+
116
+ it('is idempotent w.r.t. a late CLI dequeue — no double-start', () => {
117
+ const h = makeHarness()
118
+ handleSessionEvent(h.deps, enqueue('701'))
119
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
120
+ handleSessionEvent(h.deps, enqueue('702'))
121
+ expect(__parkedTurnStartCountForTest()).toBe(1)
122
+
123
+ // Fallback drains + begins 702.
124
+ expect(drainParkedTurnStartsForChat(h.deps, CHAT, null)).toHaveLength(1)
125
+ expect(h.current()!.sourceMessageId).toBe(702)
126
+ const cardsAfterDrain = h.cardsOpened.length
127
+
128
+ // The REPL finally un-hangs and emits its late `dequeue`. The store is empty
129
+ // so `takeParkedTurnStart` returns null and no second turn/card is minted.
130
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
131
+ expect(h.cardsOpened).toHaveLength(cardsAfterDrain)
132
+ expect(h.current()!.sourceMessageId).toBe(702)
133
+ })
134
+ })
@@ -114,21 +114,25 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
114
114
  expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
115
115
  })
116
116
 
117
- it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
118
- // F2 — checklist title + task strings were sent unredacted.
117
+ it('send_checklist: redacts title + task text BEFORE the send orchestration', () => {
118
+ // F2 — checklist title + task strings were sent unredacted. The send now
119
+ // routes through performSendChecklist (native OR text fallback), so the
120
+ // scrub must land before that orchestration call — both paths receive
121
+ // only redacted strings.
119
122
  const start = src.indexOf('async function executeSendChecklist(')
120
123
  const redactIdx = src.indexOf('redactChecklistFields(', start)
121
- const sendIdx = src.indexOf('rawSendChecklist({', start)
124
+ const sendIdx = src.indexOf('performSendChecklist({', start)
122
125
  expect(start).toBeGreaterThan(0)
123
126
  expect(redactIdx).toBeGreaterThan(start)
124
127
  expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
125
128
  })
126
129
 
127
- it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
128
- // F2 sibling — update_checklist shares the identical leak class.
130
+ it('update_checklist: redacts title + task text BEFORE the edit orchestration', () => {
131
+ // F2 sibling — update_checklist shares the identical leak class; the edit
132
+ // routes through performUpdateChecklist (native OR text fallback).
129
133
  const start = src.indexOf('async function executeUpdateChecklist(')
130
134
  const redactIdx = src.indexOf('redactChecklistFields(', start)
131
- const editIdx = src.indexOf('rawEditMessageChecklist({', start)
135
+ const editIdx = src.indexOf('performUpdateChecklist({', start)
132
136
  expect(start).toBeGreaterThan(0)
133
137
  expect(redactIdx).toBeGreaterThan(start)
134
138
  expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
@@ -154,6 +154,9 @@ export function makeLivenessFixture(
154
154
  purgeReactionTracking: () => {},
155
155
  getPendingInboundBuffer: () => ({ drain: () => [] }),
156
156
  trackRedeliveredInbound: () => {},
157
+ // Default: no parked store wired (returns 0). Tests exercising the parked
158
+ // drain override this with the real `drainParkedTurnStartsForChat`.
159
+ drainParkedTurnStarts: (_chatId: string, _threadId: number | null) => 0,
157
160
  closeActivityLane: () => {},
158
161
  closeProgressLane: () => {},
159
162
  hangRestart: null,
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import {
4
4
  computeTurnStatus,
5
+ computeTurnRoute,
5
6
  backstopSendOutcome,
6
7
  finalizeBackstopSend,
7
8
  buildTurnRecord,
@@ -39,6 +40,67 @@ describe('computeTurnStatus — recorded turn status reflects real outcome', ()
39
40
  })
40
41
  })
41
42
 
43
+ /**
44
+ * Honest delivery ROUTE. Derived from the SAME resolved delivery state
45
+ * `computeTurnStatus` reads, so the fleet-health detector can tell a
46
+ * flush-recovered turn (answer delivered by a backstop after the reply tool was
47
+ * bypassed) apart from a genuine silent no-op. These assert the mapping OUTCOME,
48
+ * not merely that a branch ran.
49
+ */
50
+ describe('computeTurnRoute — honest delivery route from resolved state', () => {
51
+ it('deliveryOutcome delivered → flush (backstop delivered the answer)', () => {
52
+ expect(
53
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: false, deliveryOutcome: 'delivered' }),
54
+ ).toBe('flush')
55
+ })
56
+
57
+ it('deliveryOutcome suppressed → reply (flush short-circuited; reply delivered)', () => {
58
+ expect(
59
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: true, deliveryOutcome: 'suppressed' }),
60
+ ).toBe('reply')
61
+ })
62
+
63
+ it('deliveryOutcome failed → none (nothing reached the user)', () => {
64
+ expect(
65
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: true, deliveryOutcome: 'failed' }),
66
+ ).toBe('none')
67
+ })
68
+
69
+ it('undefined outcome + finalAnswer + replyCalled → reply (synchronous reply tail)', () => {
70
+ expect(
71
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: true, deliveryOutcome: undefined }),
72
+ ).toBe('reply')
73
+ })
74
+
75
+ it('undefined outcome + finalAnswer + NOT replyCalled → stream', () => {
76
+ expect(
77
+ computeTurnRoute({ finalAnswerDelivered: true, replyCalled: false, deliveryOutcome: undefined }),
78
+ ).toBe('stream')
79
+ })
80
+
81
+ it('undefined outcome + no final answer → none (genuine no-reply)', () => {
82
+ expect(
83
+ computeTurnRoute({ finalAnswerDelivered: false, replyCalled: false, deliveryOutcome: undefined }),
84
+ ).toBe('none')
85
+ })
86
+
87
+ it('buildTurnRecord stamps route on the row (flush for a delivered backstop send)', () => {
88
+ const rec = buildTurnRecord(
89
+ {
90
+ agent: 'test-agent',
91
+ startedAt: 1_700_000_495_000,
92
+ toolCallCount: 0,
93
+ turnId: 'turn-route',
94
+ finalAnswerDelivered: true,
95
+ replyCalled: false,
96
+ deliveryOutcome: 'delivered',
97
+ },
98
+ 1_700_000_500_000,
99
+ )
100
+ expect(rec.route).toBe('flush')
101
+ })
102
+ })
103
+
42
104
  describe('backstopSendOutcome — resolve outcome from what happened on the wire', () => {
43
105
  it('throw → failed', () => {
44
106
  expect(backstopSendOutcome({ threw: true, sentCount: 0, chunkCount: 1 })).toBe('failed')
@@ -54,6 +54,34 @@
54
54
 
55
55
  ### Changed (switchroom divergence)
56
56
 
57
+ - **Recall/retain hygiene guard-rail batch** (`scripts/recall.py`,
58
+ `scripts/subagent_retain.py`, `scripts/lib/content.py`, `scripts/tests/**`).
59
+ Closes guard-rail debt in the fork's hook scripts before extending them:
60
+ - **#3766** — `shape_recall_query` no longer drops a single-char SUBJECT
61
+ ('C', 'R', a single-digit version) before the stopword fallback runs. The
62
+ `len(t) > 1` guard moved into the fallback, so a single-char content word
63
+ survives while single-char stopwords are still removed.
64
+ - **#3578** — `_overlap_tokens` (the #3369 transcript-fallback tokenizer) now
65
+ accumulates alphanumeric runs (`isalnum`), so issue numbers, ports and
66
+ versions ('3993', ':9077', 'v0') carry signal and match symmetrically on
67
+ both the query and transcript sides. A lone digit is still dropped as noise.
68
+ - **#3994** — the sidechain volume gate counts ONLY the chars the text-only
69
+ retain path keeps (`retained_text_char_count` → `_extract_text_content`),
70
+ not the `tool_use` inputs the payload drops. Gate and payload now measure
71
+ the same char set, so a tool-heavy / prose-light fork that used to clear the
72
+ gate and retain a near-empty document is now correctly skipped. The
73
+ 2,000-char floor was re-measured against the corrected metric —
74
+ `docs/measurements/subagent-volume-gate-3994.md`, replay harness
75
+ `scripts/tests/data/replay_volume_gate_3994.py`.
76
+ - **#4001** — the "window formatted to empty despite clearing the gate" skip
77
+ now emits an unconditional stderr line, not a debug-only log that was silent
78
+ in shipped settings.
79
+ - **#3999 / #3777** — rewrote the vacuous sidechain config-mutation test to
80
+ assert on the config object the code actually receives (RED when the copy
81
+ fix is deleted), and restored direct characterisation of `_overlap_tokens`
82
+ (`scripts/tests/test_overlap_tokens.py`) that was deleted with the removed
83
+ lexical gate while the tokenizer stayed load-bearing.
84
+
57
85
  - **`MAX_DIRECTIVES` 15 → 30, and truncation is no longer SILENT**
58
86
  (`scripts/lib/directives.py`). Live fleet active-directive counts were 24
59
87
  (assistant), 17 (klanker), 15 (carrie) against a client-side cap of 15, so the