switchroom 0.14.49 → 0.14.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +16 -7
- package/telegram-plugin/gateway/gateway.ts +8 -5
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +26 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +27 -2
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +61 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +33 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49462,8 +49462,8 @@ var {
|
|
|
49462
49462
|
} = import__.default;
|
|
49463
49463
|
|
|
49464
49464
|
// src/build-info.ts
|
|
49465
|
-
var VERSION = "0.14.
|
|
49466
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.50";
|
|
49466
|
+
var COMMIT_SHA = "07e8b692";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -47376,6 +47376,9 @@ function shouldTrackDelivery(input) {
|
|
|
47376
47376
|
return false;
|
|
47377
47377
|
return true;
|
|
47378
47378
|
}
|
|
47379
|
+
function isTrackableResumeSynthetic(meta) {
|
|
47380
|
+
return meta?.source === "resume_interrupted" && meta.message_id != null && meta.message_id !== "";
|
|
47381
|
+
}
|
|
47379
47382
|
|
|
47380
47383
|
// gateway/pending-permission-decisions.ts
|
|
47381
47384
|
var DEFAULT_PENDING_PERMISSION_CAP = 32;
|
|
@@ -52155,10 +52158,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52155
52158
|
}
|
|
52156
52159
|
|
|
52157
52160
|
// ../src/build-info.ts
|
|
52158
|
-
var VERSION = "0.14.
|
|
52159
|
-
var COMMIT_SHA = "
|
|
52160
|
-
var COMMIT_DATE = "2026-06-
|
|
52161
|
-
var LATEST_PR =
|
|
52161
|
+
var VERSION = "0.14.50";
|
|
52162
|
+
var COMMIT_SHA = "07e8b692";
|
|
52163
|
+
var COMMIT_DATE = "2026-06-03T10:08:18Z";
|
|
52164
|
+
var LATEST_PR = 2124;
|
|
52162
52165
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52163
52166
|
|
|
52164
52167
|
// gateway/boot-version.ts
|
|
@@ -52673,15 +52676,18 @@ function promptClause(turn) {
|
|
|
52673
52676
|
function buildResumeInterruptedInbound(ctx) {
|
|
52674
52677
|
const ts = ctx.nowMs ?? Date.now();
|
|
52675
52678
|
const elapsed = humanizeElapsed(ts - ctx.turn.started_at);
|
|
52679
|
+
const threadId = threadIdNum(ctx.turn);
|
|
52676
52680
|
const meta = {
|
|
52677
52681
|
source: "resume_interrupted",
|
|
52682
|
+
chat_id: ctx.turn.chat_id,
|
|
52683
|
+
...threadId != null ? { message_thread_id: String(threadId) } : {},
|
|
52684
|
+
message_id: String(ts),
|
|
52678
52685
|
resume_turn_key: ctx.turn.turn_key,
|
|
52679
52686
|
interrupted_via: ctx.turn.ended_via ?? "restart",
|
|
52680
52687
|
started_at: String(ctx.turn.started_at)
|
|
52681
52688
|
};
|
|
52682
52689
|
if (ctx.turn.user_prompt_preview)
|
|
52683
52690
|
meta.original_prompt = ctx.turn.user_prompt_preview;
|
|
52684
|
-
const threadId = threadIdNum(ctx.turn);
|
|
52685
52691
|
return {
|
|
52686
52692
|
type: "inbound",
|
|
52687
52693
|
chatId: ctx.turn.chat_id,
|
|
@@ -52699,8 +52705,11 @@ function buildResumeWatchdogReportInbound(ctx) {
|
|
|
52699
52705
|
const idle = humanizeElapsed(ctx.idleMs);
|
|
52700
52706
|
const since = humanizeElapsed(ts - ctx.turn.started_at);
|
|
52701
52707
|
const toolClause = ctx.turn.tool_call_count != null && ctx.turn.tool_call_count > 0 ? ` You'd run ${ctx.turn.tool_call_count} tool call${ctx.turn.tool_call_count === 1 ? "" : "s"} before it stalled.` : "";
|
|
52708
|
+
const threadId = threadIdNum(ctx.turn);
|
|
52702
52709
|
const meta = {
|
|
52703
52710
|
source: "resume_watchdog_timeout",
|
|
52711
|
+
chat_id: ctx.turn.chat_id,
|
|
52712
|
+
...threadId != null ? { message_thread_id: String(threadId) } : {},
|
|
52704
52713
|
resume_turn_key: ctx.turn.turn_key,
|
|
52705
52714
|
interrupted_via: "timeout",
|
|
52706
52715
|
idle_ms: String(ctx.idleMs),
|
|
@@ -52710,7 +52719,6 @@ function buildResumeWatchdogReportInbound(ctx) {
|
|
|
52710
52719
|
meta.tool_call_count = String(ctx.turn.tool_call_count);
|
|
52711
52720
|
if (ctx.turn.user_prompt_preview)
|
|
52712
52721
|
meta.original_prompt = ctx.turn.user_prompt_preview;
|
|
52713
|
-
const threadId = threadIdNum(ctx.turn);
|
|
52714
52722
|
return {
|
|
52715
52723
|
type: "inbound",
|
|
52716
52724
|
chatId: ctx.turn.chat_id,
|
|
@@ -54501,7 +54509,8 @@ _deliveryMachineTick.unref?.();
|
|
|
54501
54509
|
function trackRedeliveredInbound(merged) {
|
|
54502
54510
|
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54503
54511
|
return;
|
|
54504
|
-
|
|
54512
|
+
const isTrackableResume = isTrackableResumeSynthetic(merged.meta);
|
|
54513
|
+
if (!isTrackableResume && !shouldTrackDelivery({
|
|
54505
54514
|
isSteering: false,
|
|
54506
54515
|
isInterrupt: false,
|
|
54507
54516
|
hasSource: merged.meta?.source != null,
|
|
@@ -287,6 +287,7 @@ import {
|
|
|
287
287
|
sweep as sweepDeliveryQueue,
|
|
288
288
|
forgetDelivery,
|
|
289
289
|
shouldTrackDelivery,
|
|
290
|
+
isTrackableResumeSynthetic,
|
|
290
291
|
type PendingDelivery,
|
|
291
292
|
} from './inbound-delivery-confirm.js'
|
|
292
293
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
@@ -4212,15 +4213,17 @@ _deliveryMachineTick.unref?.()
|
|
|
4212
4213
|
// re-deliver forever.
|
|
4213
4214
|
function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
4214
4215
|
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
4216
|
+
// The boot-resume synthetic ('resume_interrupted') is the ONE synthetic we DO
|
|
4217
|
+
// enrol: a restart can drop it into a not-ready session exactly like a user
|
|
4218
|
+
// inbound, leaving the interrupted work silently un-resumed. Safe iff it
|
|
4219
|
+
// carries the chat_id + message_id that make its enqueue ack-able — see
|
|
4220
|
+
// isTrackableResumeSynthetic. Every other synthetic stays excluded below.
|
|
4221
|
+
const isTrackableResume = isTrackableResumeSynthetic(merged.meta)
|
|
4215
4222
|
if (
|
|
4223
|
+
!isTrackableResume &&
|
|
4216
4224
|
!shouldTrackDelivery({
|
|
4217
4225
|
isSteering: false,
|
|
4218
4226
|
isInterrupt: false,
|
|
4219
|
-
// Synthetic inbounds (cron / vault / handback / resume) carry a source
|
|
4220
|
-
// and are NOT tracked here — they enqueue under their own semantics, and
|
|
4221
|
-
// (for the resume synthetics) tracking them safely first needs the
|
|
4222
|
-
// resume builder to emit meta.message_id so the deliver-until-acked ack
|
|
4223
|
-
// matches its enqueue. Tracked separately as a follow-up (see PR notes).
|
|
4224
4227
|
hasSource: merged.meta?.source != null,
|
|
4225
4228
|
effectiveText: merged.text,
|
|
4226
4229
|
})
|
|
@@ -158,3 +158,29 @@ export function shouldTrackDelivery(input: {
|
|
|
158
158
|
if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0) return false
|
|
159
159
|
return true
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The ONE synthetic-source inbound that IS safe to enrol in the
|
|
164
|
+
* deliver-until-acked queue: the boot-resume synthetic
|
|
165
|
+
* (`meta.source === 'resume_interrupted'`). A restart can drop it into a
|
|
166
|
+
* not-ready (slow MCP boot) session exactly like a user inbound, leaving the
|
|
167
|
+
* interrupted work silently un-resumed — so it needs the same re-delivery
|
|
168
|
+
* backstop. It is safe to track ONLY when it carries BOTH:
|
|
169
|
+
* - `meta.chat_id` — so the gateway's enqueue handler (gated on `ev.chatId`)
|
|
170
|
+
* builds a currentTurn AND fires `ackDelivery` for it; and
|
|
171
|
+
* - `meta.message_id` — so the enqueue's id round-trips and `ackDelivery`
|
|
172
|
+
* matches THIS synthetic, stopping the sweep (no re-deliver-forever storm).
|
|
173
|
+
* Every other synthetic (cron / vault / handback / the watchdog `report`) omits
|
|
174
|
+
* `meta.message_id` and stays excluded by `shouldTrackDelivery`'s `hasSource`
|
|
175
|
+
* gate. We require message_id here (chat_id is implied by the ack mechanics);
|
|
176
|
+
* without a round-trippable id, tracking would storm.
|
|
177
|
+
*/
|
|
178
|
+
export function isTrackableResumeSynthetic(
|
|
179
|
+
meta: Record<string, string> | undefined,
|
|
180
|
+
): boolean {
|
|
181
|
+
return (
|
|
182
|
+
meta?.source === 'resume_interrupted' &&
|
|
183
|
+
meta.message_id != null &&
|
|
184
|
+
meta.message_id !== ''
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -78,14 +78,33 @@ function promptClause(turn: Turn): string {
|
|
|
78
78
|
export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): InboundMessage {
|
|
79
79
|
const ts = ctx.nowMs ?? Date.now()
|
|
80
80
|
const elapsed = humanizeElapsed(ts - ctx.turn.started_at)
|
|
81
|
+
const threadId = threadIdNum(ctx.turn)
|
|
81
82
|
const meta: Record<string, string> = {
|
|
82
83
|
source: 'resume_interrupted',
|
|
84
|
+
// Carry the originating chat/topic as model-visible channel attributes
|
|
85
|
+
// (mirrors the real-inbound + subagent_handback shapes — see
|
|
86
|
+
// gateway.ts:10753 and subagent-handback-inbound-builder.ts:115-121).
|
|
87
|
+
// Without meta.chat_id the enqueue's channel XML has no chat_id, so the
|
|
88
|
+
// gateway's enqueue handler (gateway.ts `if (ev.chatId)`) never builds a
|
|
89
|
+
// currentTurn for the resume turn — meaning no progress card, no
|
|
90
|
+
// silence-poke protection, and the reply falling back to the agent's
|
|
91
|
+
// default chat instead of the topic the interrupted work lived in.
|
|
92
|
+
chat_id: ctx.turn.chat_id,
|
|
93
|
+
...(threadId != null ? { message_thread_id: String(threadId) } : {}),
|
|
94
|
+
// message_id rounds-trips the fabricated `ts` through the enqueue's
|
|
95
|
+
// channel XML so the deliver-until-acked queue can ack THIS synthetic by
|
|
96
|
+
// its own enqueue id (gateway trackRedeliveredInbound carve-in). It is
|
|
97
|
+
// never used as a Telegram reply_to: the model's reply tool quotes the
|
|
98
|
+
// real prior user message via getLatestInboundMessageId (role='user',
|
|
99
|
+
// synthetics aren't in history), and the activity feed anchors with
|
|
100
|
+
// allow_sending_without_reply. Required so tracking the resume can ack and
|
|
101
|
+
// never re-delivers forever.
|
|
102
|
+
message_id: String(ts),
|
|
83
103
|
resume_turn_key: ctx.turn.turn_key,
|
|
84
104
|
interrupted_via: ctx.turn.ended_via ?? 'restart',
|
|
85
105
|
started_at: String(ctx.turn.started_at),
|
|
86
106
|
}
|
|
87
107
|
if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
|
|
88
|
-
const threadId = threadIdNum(ctx.turn)
|
|
89
108
|
return {
|
|
90
109
|
type: 'inbound',
|
|
91
110
|
chatId: ctx.turn.chat_id,
|
|
@@ -127,8 +146,15 @@ export function buildResumeWatchdogReportInbound(
|
|
|
127
146
|
ctx.turn.tool_call_count != null && ctx.turn.tool_call_count > 0
|
|
128
147
|
? ` You'd run ${ctx.turn.tool_call_count} tool call${ctx.turn.tool_call_count === 1 ? '' : 's'} before it stalled.`
|
|
129
148
|
: ''
|
|
149
|
+
const threadId = threadIdNum(ctx.turn)
|
|
130
150
|
const meta: Record<string, string> = {
|
|
131
151
|
source: 'resume_watchdog_timeout',
|
|
152
|
+
// Origin chat/topic as channel attributes so the report turn gets a
|
|
153
|
+
// currentTurn (progress card + silence-poke) and the "your last turn was
|
|
154
|
+
// interrupted" notice lands in the topic the work lived in, not the
|
|
155
|
+
// agent's default chat. Same rationale as buildResumeInterruptedInbound.
|
|
156
|
+
chat_id: ctx.turn.chat_id,
|
|
157
|
+
...(threadId != null ? { message_thread_id: String(threadId) } : {}),
|
|
132
158
|
resume_turn_key: ctx.turn.turn_key,
|
|
133
159
|
interrupted_via: 'timeout',
|
|
134
160
|
idle_ms: String(ctx.idleMs),
|
|
@@ -136,7 +162,6 @@ export function buildResumeWatchdogReportInbound(
|
|
|
136
162
|
}
|
|
137
163
|
if (ctx.turn.tool_call_count != null) meta.tool_call_count = String(ctx.turn.tool_call_count)
|
|
138
164
|
if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
|
|
139
|
-
const threadId = threadIdNum(ctx.turn)
|
|
140
165
|
return {
|
|
141
166
|
type: 'inbound',
|
|
142
167
|
chatId: ctx.turn.chat_id,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ackDelivery,
|
|
5
5
|
createDeliveryQueue,
|
|
6
6
|
forgetDelivery,
|
|
7
|
+
isTrackableResumeSynthetic,
|
|
7
8
|
shouldTrackDelivery,
|
|
8
9
|
sweep,
|
|
9
10
|
trackDelivery,
|
|
@@ -178,3 +179,63 @@ describe('shouldTrackDelivery — only fresh-turn messages are tracked', () => {
|
|
|
178
179
|
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: 'draft the email' })).toBe(true)
|
|
179
180
|
})
|
|
180
181
|
})
|
|
182
|
+
|
|
183
|
+
// The boot-resume synthetic is the one synthetic ENROLLED for re-delivery (so a
|
|
184
|
+
// restart can't silently drop the "pick up your interrupted work" wake into a
|
|
185
|
+
// not-ready session). It is safe ONLY because it carries a round-trippable
|
|
186
|
+
// message_id that its own enqueue acks — without that it would re-deliver
|
|
187
|
+
// forever (the storm the hasSource exclusion otherwise prevents).
|
|
188
|
+
describe('isTrackableResumeSynthetic — resume carve-in to the deliver-until-acked queue', () => {
|
|
189
|
+
it('tracks a resume_interrupted synthetic that carries a message_id', () => {
|
|
190
|
+
expect(isTrackableResumeSynthetic({ source: 'resume_interrupted', message_id: '1700000000000' })).toBe(true)
|
|
191
|
+
})
|
|
192
|
+
it('does NOT track a resume_interrupted WITHOUT a message_id (would never ack → storm)', () => {
|
|
193
|
+
expect(isTrackableResumeSynthetic({ source: 'resume_interrupted' })).toBe(false)
|
|
194
|
+
expect(isTrackableResumeSynthetic({ source: 'resume_interrupted', message_id: '' })).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
it('does NOT track the watchdog report (untracked by design) even though it carries chat_id', () => {
|
|
197
|
+
expect(isTrackableResumeSynthetic({ source: 'resume_watchdog_timeout', chat_id: '123' })).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
it('does NOT track other synthetics (cron / vault / handback) even if they somehow had a message_id', () => {
|
|
200
|
+
expect(isTrackableResumeSynthetic({ source: 'cron', message_id: '1' })).toBe(false)
|
|
201
|
+
expect(isTrackableResumeSynthetic({ source: 'subagent_handback', message_id: '1' })).toBe(false)
|
|
202
|
+
expect(isTrackableResumeSynthetic({ source: 'vault_grant_approved', message_id: '1' })).toBe(false)
|
|
203
|
+
})
|
|
204
|
+
it('does NOT track a real (no source) inbound here — that path is shouldTrackDelivery', () => {
|
|
205
|
+
expect(isTrackableResumeSynthetic({ chat_id: '1', message_id: '2' })).toBe(false)
|
|
206
|
+
expect(isTrackableResumeSynthetic(undefined)).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// End-to-end queue contract for the resume synthetic: tracked by its fabricated
|
|
211
|
+
// id, acked by its OWN enqueue (which round-trips the same id), and — crucially
|
|
212
|
+
// — it can never re-deliver forever (the storm the original deferral feared).
|
|
213
|
+
describe('resume synthetic: tracked, acked by own enqueue, never storms', () => {
|
|
214
|
+
const RESUME_ID = '1700000000000' // String(ts), the fabricated message_id
|
|
215
|
+
it('acks when its own enqueue arrives with the matching id (no storm)', () => {
|
|
216
|
+
const q = fresh()
|
|
217
|
+
trackDelivery(q, 'chat:_', { text: 'resume…' }, 1_000, RESUME_ID)
|
|
218
|
+
// The resume turn starts → enqueue fires carrying the round-tripped id.
|
|
219
|
+
expect(ackDelivery(q, 'chat:_', RESUME_ID)).toBe(true)
|
|
220
|
+
expect(q.pending.size).toBe(0)
|
|
221
|
+
// Never re-delivered after the ack — bounded, not a forever loop.
|
|
222
|
+
expect(sweep(q, 1_000 + 999_999, TIMEOUT)).toHaveLength(0)
|
|
223
|
+
})
|
|
224
|
+
it('strands then re-delivers until acked (rescues a drop into a not-ready session)', () => {
|
|
225
|
+
const q = fresh()
|
|
226
|
+
trackDelivery(q, 'chat:_', { text: 'resume…' }, 1_000, RESUME_ID)
|
|
227
|
+
// Dropped into a not-ready session: no enqueue within the timeout → re-deliver.
|
|
228
|
+
const stranded = sweep(q, 1_000 + TIMEOUT + 1, TIMEOUT)
|
|
229
|
+
expect(stranded).toHaveLength(1)
|
|
230
|
+
expect(stranded[0]!.messageId).toBe(RESUME_ID)
|
|
231
|
+
// Once claude is ready, its enqueue acks it and the loop stops.
|
|
232
|
+
expect(ackDelivery(q, 'chat:_', RESUME_ID)).toBe(true)
|
|
233
|
+
expect(sweep(q, 1_000 + TIMEOUT * 10, TIMEOUT)).toHaveLength(0)
|
|
234
|
+
})
|
|
235
|
+
it('a DIFFERENT enqueue id (e.g. a racing real user msg) does NOT false-ack the resume', () => {
|
|
236
|
+
const q = fresh()
|
|
237
|
+
trackDelivery(q, 'chat:_', { text: 'resume…' }, 1_000, RESUME_ID)
|
|
238
|
+
expect(ackDelivery(q, 'chat:_', '999')).toBe(false) // not our id
|
|
239
|
+
expect(q.pending.size).toBe(1) // still pending, will re-deliver
|
|
240
|
+
})
|
|
241
|
+
})
|
|
@@ -124,6 +124,27 @@ describe('buildResumeInterruptedInbound', () => {
|
|
|
124
124
|
const msg = buildResumeInterruptedInbound({ turn: makeTurn({ thread_id: null }) })
|
|
125
125
|
expect(msg.threadId).toBeUndefined()
|
|
126
126
|
})
|
|
127
|
+
|
|
128
|
+
// Origin routing + ack-ability (the resume-deliver hardening). meta.chat_id
|
|
129
|
+
// makes the gateway build a currentTurn (progress card + silence-poke) and
|
|
130
|
+
// fire ackDelivery; meta.message_id round-trips so the deliver-until-acked
|
|
131
|
+
// queue can ack THIS synthetic by its own enqueue id (no re-deliver storm).
|
|
132
|
+
it('carries origin chat_id + message_id in meta (DM)', () => {
|
|
133
|
+
const turn = makeTurn({ chat_id: '12345', thread_id: null })
|
|
134
|
+
const msg = buildResumeInterruptedInbound({ turn, nowMs: 1_700_000_000_000 })
|
|
135
|
+
expect(msg.meta.chat_id).toBe('12345')
|
|
136
|
+
expect(msg.meta.message_id).toBe('1700000000000')
|
|
137
|
+
expect(msg.messageId).toBe(1_700_000_000_000)
|
|
138
|
+
expect(msg.meta.message_thread_id).toBeUndefined()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('carries message_thread_id in meta for a forum topic (supergroup routing)', () => {
|
|
142
|
+
const turn = makeTurn({ chat_id: '-1001234567890', thread_id: '42' })
|
|
143
|
+
const msg = buildResumeInterruptedInbound({ turn })
|
|
144
|
+
expect(msg.meta.chat_id).toBe('-1001234567890')
|
|
145
|
+
expect(msg.meta.message_thread_id).toBe('42')
|
|
146
|
+
expect(msg.threadId).toBe(42)
|
|
147
|
+
})
|
|
127
148
|
})
|
|
128
149
|
|
|
129
150
|
describe('buildResumeWatchdogReportInbound', () => {
|
|
@@ -163,6 +184,18 @@ describe('buildResumeWatchdogReportInbound', () => {
|
|
|
163
184
|
const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
|
|
164
185
|
expect(msg.text).not.toContain('tool call')
|
|
165
186
|
})
|
|
187
|
+
|
|
188
|
+
// The report turn ALSO gets origin routing (currentTurn + topic) — a "your
|
|
189
|
+
// last turn was interrupted" notice should land in the topic the work lived
|
|
190
|
+
// in — but it is NOT deliver-until-acked tracked, so it carries NO
|
|
191
|
+
// message_id (only the 'resume_interrupted' builder does, gating tracking).
|
|
192
|
+
it('carries origin chat_id + message_thread_id but NO message_id (report is untracked)', () => {
|
|
193
|
+
const turn = makeTurn({ ended_via: 'timeout', chat_id: '-1001234567890', thread_id: '42' })
|
|
194
|
+
const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
|
|
195
|
+
expect(msg.meta.chat_id).toBe('-1001234567890')
|
|
196
|
+
expect(msg.meta.message_thread_id).toBe('42')
|
|
197
|
+
expect(msg.meta.message_id).toBeUndefined()
|
|
198
|
+
})
|
|
166
199
|
})
|
|
167
200
|
|
|
168
201
|
describe('selectResumeBuilder', () => {
|