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.
- package/dist/agent-scheduler/index.js +10 -1
- package/dist/auth-broker/index.js +12 -3
- package/dist/cli/notion-write-pretool.mjs +10 -1
- package/dist/cli/switchroom.js +562 -196
- package/dist/host-control/main.js +287 -20
- package/dist/vault/approvals/kernel-server.js +12 -3
- package/dist/vault/broker/server.js +12 -3
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +10 -0
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +10 -2
- package/telegram-plugin/dist/gateway/gateway.js +549 -232
- package/telegram-plugin/dist/server.js +10 -2
- package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/compaction-marker.ts +84 -0
- package/telegram-plugin/gateway/gateway.ts +72 -72
- package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
- package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +39 -1
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
- package/telegram-plugin/hooks/hooks.json +11 -0
- package/telegram-plugin/session-tail.ts +20 -0
- package/telegram-plugin/silence-poke.ts +28 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
- package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
- package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
- package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
- 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
|
|
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
|
|
@@ -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
|
-
|
|
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' })
|