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.
- package/dist/agent-scheduler/index.js +22 -2
- package/dist/auth-broker/index.js +22 -2
- package/dist/cli/notion-write-pretool.mjs +22 -2
- package/dist/cli/switchroom.js +810 -241
- package/dist/host-control/main.js +94 -19
- package/dist/vault/approvals/kernel-server.js +22 -2
- package/dist/vault/broker/server.js +22 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +332 -124
- package/telegram-plugin/dist/server.js +2 -2
- package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/gateway.ts +76 -76
- package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- package/telegram-plugin/gateway/stream-render.ts +57 -0
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
- package/vendor/hindsight-memory/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- 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
|
|
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('
|
|
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
|
|
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('
|
|
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
|