switchroom 0.19.39 → 0.19.41

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 +10 -1
  2. package/dist/auth-broker/index.js +12 -3
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +562 -196
  5. package/dist/host-control/main.js +287 -20
  6. package/dist/vault/approvals/kernel-server.js +12 -3
  7. package/dist/vault/broker/server.js +12 -3
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/bridge/bridge.ts +2 -2
  11. package/telegram-plugin/dist/bridge/bridge.js +10 -2
  12. package/telegram-plugin/dist/gateway/gateway.js +549 -232
  13. package/telegram-plugin/dist/server.js +10 -2
  14. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  15. package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
  16. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  17. package/telegram-plugin/gateway/gateway.ts +72 -72
  18. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  19. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  20. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  21. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  22. package/telegram-plugin/gateway/stream-render.ts +39 -1
  23. package/telegram-plugin/gateway/turn-record-status.ts +80 -0
  24. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  25. package/telegram-plugin/hooks/hooks.json +11 -0
  26. package/telegram-plugin/session-tail.ts +20 -0
  27. package/telegram-plugin/silence-poke.ts +28 -0
  28. package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
  29. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  36. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  37. package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
@@ -27,6 +27,38 @@ export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
27
27
  /** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
28
28
  export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
29
29
 
30
+ /**
31
+ * How this turn's answer actually reached (or failed to reach) the user — the
32
+ * honest delivery route, recorded alongside `status` so the fleet-health
33
+ * detector can tell a flush-recovered turn apart from a genuine silent no-op.
34
+ *
35
+ * - `reply` — the reply tool delivered the answer (the normal path), OR the
36
+ * flush short-circuited because reply had already delivered.
37
+ * - `stream` — the answer landed via the streaming/draft path without a reply
38
+ * tool call (final answer delivered, `replyCalled` false).
39
+ * - `flush` — a turn-flush / outbox-sweep backstop delivered the answer after
40
+ * the reply tool was bypassed (terminal prose, `tools:0`). This is
41
+ * the case that used to masquerade as a silent no-op.
42
+ * - `none` — nothing reached the user (send failed, or a genuine no-reply).
43
+ */
44
+ export type TurnRoute = 'reply' | 'stream' | 'flush' | 'none'
45
+
46
+ /**
47
+ * Epoch of when the honest `route` field shipped on turns.jsonl rows — a FIXED
48
+ * literal cutoff the fleet-health detector uses to age out the pre-route legacy
49
+ * backlog (rows written before this field existed carry no `route`, so the
50
+ * detector cannot classify them and must not escalate them as silent no-ops).
51
+ *
52
+ * UNIX SECONDS, not milliseconds: turns.jsonl `ts` is written as
53
+ * `Math.floor(endedAt / 1000)` (see `buildTurnRecord`), and the detector
54
+ * compares this constant against that seconds-valued `ts`. It intentionally
55
+ * mirrors the units of `SILENT_NOOP_FLOOR_TS` in `src/fleet-health/detect.ts`.
56
+ *
57
+ * Value = 2026-07-31T00:00:00Z (`date -u -d @1785456000`). Fixed, not rolling:
58
+ * a rolling window would hide a genuine ongoing regression that drops the field.
59
+ */
60
+ export const ROUTE_FIELD_SHIP_TS = 1_785_456_000
61
+
30
62
  /**
31
63
  * Derive the recorded turn status from the turn's flags.
32
64
  *
@@ -58,6 +90,41 @@ export function computeTurnStatus(turn: {
58
90
  }
59
91
  }
60
92
 
93
+ /**
94
+ * Derive the honest delivery `route` from the SAME resolved delivery state
95
+ * `computeTurnStatus` reads — never speculative. `deliveryOutcome` (when
96
+ * present) is authoritative; when absent we fall back to the legacy
97
+ * `finalAnswerDelivered` / `replyCalled` reading, exactly as `computeTurnStatus`
98
+ * does for `status`.
99
+ *
100
+ * failed → none (nothing reached the user)
101
+ * delivered → flush (a backstop send delivered the answer)
102
+ * suppressed → reply (flush short-circuited; reply already delivered)
103
+ * undefined (legacy) → finalAnswerDelivered
104
+ * ? (replyCalled ? reply : stream)
105
+ * : none
106
+ */
107
+ export function computeTurnRoute(turn: {
108
+ finalAnswerDelivered: boolean
109
+ replyCalled: boolean
110
+ deliveryOutcome?: DeliveryOutcome
111
+ }): TurnRoute {
112
+ switch (turn.deliveryOutcome) {
113
+ case 'failed':
114
+ return 'none'
115
+ case 'delivered':
116
+ return 'flush'
117
+ case 'suppressed':
118
+ return 'reply'
119
+ default:
120
+ return turn.finalAnswerDelivered
121
+ ? turn.replyCalled
122
+ ? 'reply'
123
+ : 'stream'
124
+ : 'none'
125
+ }
126
+ }
127
+
61
128
  /**
62
129
  * Resolve a backstop send's outcome from what actually happened on the wire.
63
130
  * A throw is a failure; a no-throw send that delivered fewer chunks than it
@@ -148,6 +215,13 @@ export interface TurnRecordRow {
148
215
  tools: number
149
216
  status: TurnStatus
150
217
  turn_id: string
218
+ /**
219
+ * The honest delivery route for this turn (see `TurnRoute`). Recorded on every
220
+ * row so the fleet-health detector can distinguish a flush-recovered turn
221
+ * (`route: 'flush'`, answer delivered by a backstop after the reply tool was
222
+ * bypassed) from a genuine silent no-op (`route: 'none'`). Always emitted.
223
+ */
224
+ route: TurnRoute
151
225
  /**
152
226
  * How many landed message ids of this turn's backstop delivery the read-back
153
227
  * probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
@@ -178,6 +252,7 @@ export function buildTurnRecord(
178
252
  toolCallCount: number
179
253
  turnId: string
180
254
  finalAnswerDelivered: boolean
255
+ replyCalled?: boolean
181
256
  deliveryOutcome?: DeliveryOutcome
182
257
  landedUnconfirmed?: number
183
258
  },
@@ -190,6 +265,11 @@ export function buildTurnRecord(
190
265
  tools: turn.toolCallCount ?? 0,
191
266
  status: computeTurnStatus(turn),
192
267
  turn_id: turn.turnId,
268
+ route: computeTurnRoute({
269
+ finalAnswerDelivered: turn.finalAnswerDelivered,
270
+ replyCalled: turn.replyCalled ?? false,
271
+ deliveryOutcome: turn.deliveryOutcome,
272
+ }),
193
273
  // Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
194
274
  ...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
195
275
  ? { landed_unconfirmed: turn.landedUnconfirmed }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreCompact hook — marks "compaction in flight" for the gateway (#4058).
4
+ *
5
+ * Claude Code PreCompact protocol:
6
+ * Input: JSON on stdin — { session_id, transcript_path, cwd,
7
+ * hook_event_name: "PreCompact", trigger: "auto"|"manual",
8
+ * custom_instructions }
9
+ * Output: exit 0 + empty stdout → proceed. We NEVER block compaction and
10
+ * NEVER exit non-zero.
11
+ *
12
+ * Side effect: writes (overwrites) ONE small JSON file
13
+ * $TELEGRAM_STATE_DIR/compaction-in-flight.json
14
+ * with shape { ts, session_id, trigger }.
15
+ *
16
+ * Why: during mid-turn auto-compaction the model emits zero output and runs
17
+ * zero tools for minutes, so the gateway's silence-poke saw pure silence and
18
+ * fired its 300s "stalled turn" fallback on a healthy turn. The transcript
19
+ * only records compaction at its END (`system/compact_boundary` carries the
20
+ * compaction's own durationMs), so this hook is the one observable START
21
+ * signal. The gateway reads the marker's mtime (compaction-marker.ts) to
22
+ * DEFER the fallback — bounded by the same hard ceiling as the in-flight-tool
23
+ * defer — and removes it when the compact_boundary lands.
24
+ *
25
+ * If $TELEGRAM_STATE_DIR is unset → silent skip (dev / one-shot contexts;
26
+ * the fallback just keeps its pre-#4058 behaviour).
27
+ */
28
+
29
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ function readStdin() {
33
+ try {
34
+ return readFileSync(0, 'utf8')
35
+ } catch {
36
+ return ''
37
+ }
38
+ }
39
+
40
+ function main() {
41
+ const stateDir = process.env.TELEGRAM_STATE_DIR
42
+ if (!stateDir) return
43
+
44
+ let payload = {}
45
+ try {
46
+ payload = JSON.parse(readStdin() || '{}')
47
+ } catch {
48
+ // Unparseable stdin — still write the marker: the mtime alone carries
49
+ // the load-bearing signal; the body fields are diagnostics.
50
+ }
51
+
52
+ try {
53
+ mkdirSync(stateDir, { recursive: true })
54
+ writeFileSync(
55
+ join(stateDir, 'compaction-in-flight.json'),
56
+ JSON.stringify({
57
+ ts: Date.now(),
58
+ session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
59
+ trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
60
+ }) + '\n',
61
+ { mode: 0o600 },
62
+ )
63
+ } catch {
64
+ // Best-effort: a missed marker just reverts one fallback decision to
65
+ // the pre-#4058 behaviour. Never fail the hook (never block compaction).
66
+ }
67
+ }
68
+
69
+ main()
70
+ process.exit(0)
@@ -71,6 +71,17 @@
71
71
  ]
72
72
  }
73
73
  ],
74
+ "PreCompact": [
75
+ {
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
80
+ "timeout": 5
81
+ }
82
+ ]
83
+ }
84
+ ],
74
85
  "Stop": [
75
86
  {
76
87
  "hooks": [
@@ -158,6 +158,12 @@ export type SessionEvent =
158
158
  * ~300s wedge recovery once the launching bash is no longer running.
159
159
  */
160
160
  | { kind: 'task_notification'; taskId: string; status: string }
161
+ // #4058 — system compact_boundary: the CLI finished compacting the session
162
+ // mid-turn (the record is written at compaction END — it embeds the
163
+ // compaction's own durationMs). Consumed by silence-poke wiring to clear
164
+ // the PreCompact "compaction in flight" marker and count the boundary as
165
+ // production so the resumed turn gets a fresh silence window.
166
+ | { kind: 'compact_boundary'; trigger: string | null; compactDurationMs: number | null }
161
167
  // `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
162
168
  // the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
163
169
  // deterministic quiescence-flush signal, which — unlike the orphaned-reply
@@ -702,6 +708,20 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
702
708
  ]
703
709
  }
704
710
 
711
+ // #4058 — system compact_boundary: written when a mid-turn (auto or manual)
712
+ // compaction FINISHES. Real shape observed in live transcripts:
713
+ // { type:"system", subtype:"compact_boundary", content:"Conversation
714
+ // compacted", compactMetadata:{ trigger:"auto", preTokens, postTokens,
715
+ // durationMs, ... } }
716
+ if (type === 'system' && obj.subtype === 'compact_boundary') {
717
+ const meta = obj.compactMetadata as { trigger?: unknown; durationMs?: unknown } | undefined
718
+ return [{
719
+ kind: 'compact_boundary',
720
+ trigger: typeof meta?.trigger === 'string' ? meta.trigger : null,
721
+ compactDurationMs: typeof meta?.durationMs === 'number' ? meta.durationMs : null,
722
+ }]
723
+ }
724
+
705
725
  return []
706
726
  }
707
727
 
@@ -265,6 +265,25 @@ export interface SilencePokeDeps {
265
265
  * exactly as before.
266
266
  */
267
267
  isTurnLive?: (key: string) => boolean
268
+ /**
269
+ * #4058 — mid-turn auto-compaction defer predicate. Returns true while the
270
+ * Claude CLI is compacting the session for `key`'s turn (the PreCompact hook
271
+ * wrote the compaction marker and the `compact_boundary` transcript record
272
+ * hasn't landed / the marker isn't stale — see gateway/compaction-marker.ts).
273
+ *
274
+ * Why: during compaction the model emits ZERO output and runs ZERO tools
275
+ * for minutes, so every other work signal (`isLegitimatelyWorking`,
276
+ * in-flight tools, alive shells) is false and the 300s fallback fired on a
277
+ * healthy turn — a spurious "framework ended that stalled turn" card + a
278
+ * harmless re-ask, recurring on any session near the context ceiling.
279
+ *
280
+ * Semantics mirror the #1292/#3519 defers exactly: the silence CLOCK is
281
+ * never reset here; the terminal unwedge is DEFERRED (`continue` without
282
+ * setting fallbackFired) and remains bounded by `fallbackHardCeiling`, so
283
+ * a genuinely-wedged compaction still unwedges at the ceiling. Optional:
284
+ * absent (legacy fixtures) ⇒ behaviour unchanged.
285
+ */
286
+ isCompactionInFlight?: (key: string) => boolean
268
287
  }
269
288
 
270
289
  const state = new Map<string, SilencePokeState>()
@@ -773,6 +792,15 @@ function tick(now: number): void {
773
792
  const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
774
793
  const underCeiling = silence < ceiling
775
794
  if (underCeiling) {
795
+ // #4058 — mid-turn auto-compaction: the CLI is summarizing the
796
+ // session, so the model provably CANNOT produce output or tool
797
+ // events; the silence is healthy, not a wedge. Defer exactly like
798
+ // the in-flight-tool paths below (clock untouched, fallbackFired
799
+ // unset, re-checked next tick) and stay bounded by the same hard
800
+ // ceiling via the enclosing `underCeiling` guard. Deliberately NOT
801
+ // gated by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0 — that flag
802
+ // scopes the TOOL defers; compaction has no tool in flight.
803
+ if (activeDeps.isCompactionInFlight?.(key) === true) continue
776
804
  const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
777
805
  if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
778
806
  if (activeDeps.isLegitimatelyWorking(key)) continue
@@ -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
+ })
@@ -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
@@ -119,7 +119,8 @@ describe('sweepOutbox — end to end (exactly-once, siblings, dedup)', () => {
119
119
  sent,
120
120
  send: async (chatId: string, threadId: number | null, text: string) => {
121
121
  sent.push({ chatId, threadId, text })
122
- return sent.length
122
+ const id = sent.length
123
+ return { messageId: id, chunks: [{ messageId: id, text }] }
123
124
  },
124
125
  }
125
126
  }
@@ -136,6 +137,42 @@ describe('sweepOutbox — end to end (exactly-once, siblings, dedup)', () => {
136
137
  expect(listPendingRecords(dir)).toHaveLength(0)
137
138
  })
138
139
 
140
+ it('persist parity: recordOutbound fires ONCE with the delivered message_id(s) + text after a successful sweep', async () => {
141
+ // The safety-net delivery must land in history.db, exactly like the reply
142
+ // path and the turn-flush backstop. Pre-fix the sweep never called
143
+ // recordOutbound, so every net-delivered handback was silently absent from
144
+ // history — this asserts the recorder fires with the ACTUAL delivered ids +
145
+ // text, and exactly once across repeated (idempotent) sweeps.
146
+ writeOutboxRecordAtomic(rec({ turnNonce: 'p1', chatId: '444', text: 'the recovered final answer', createdAt: 0 }), dir)
147
+ const s = sink()
148
+ const recorded: Array<{ chatId: string; threadId: number | null; messageIds: number[]; texts: string[] }> = []
149
+ const deps = {
150
+ ...s,
151
+ recordOutbound: (chatId: string, threadId: number | null, messageIds: number[], texts: string[]) => {
152
+ recorded.push({ chatId, threadId, messageIds, texts })
153
+ },
154
+ textAlreadyDelivered: () => false,
155
+ stateDir: dir,
156
+ now: () => 10_000,
157
+ }
158
+ await sweepOutbox(deps)
159
+ await sweepOutbox(deps) // journal suppresses — recorder must NOT fire again
160
+ await sweepOutbox(deps)
161
+ expect(s.sent).toHaveLength(1)
162
+ expect(recorded).toHaveLength(1)
163
+ expect(recorded[0].chatId).toBe('444')
164
+ expect(recorded[0].messageIds).toEqual([1])
165
+ expect(recorded[0].texts).toEqual(['the recovered final answer'])
166
+ })
167
+
168
+ it('persist parity: a missing recordOutbound never throws (safety-net stays intact)', async () => {
169
+ // The recorder is optional; a sweep with no recorder wired must still deliver.
170
+ writeOutboxRecordAtomic(rec({ turnNonce: 'p2', chatId: '444', text: 'no recorder wired', createdAt: 0 }), dir)
171
+ const s = sink()
172
+ await sweepOutbox({ ...s, textAlreadyDelivered: () => false, stateDir: dir, now: () => 10_000 })
173
+ expect(s.sent).toHaveLength(1)
174
+ })
175
+
139
176
  it('two concurrent sibling handbacks with distinct nonces both deliver, no clobber', async () => {
140
177
  // Same content, distinct enqueue timestamps → distinct nonces (H2).
141
178
  const n1 = deriveTurnNonce({ chatId: null, threadId: null, messageId: null, anchorTimestampMs: 1000, anchorContent: 'sib' })