switchroom 0.14.49 → 0.14.51
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 +18 -10
- 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 +45 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +61 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +49 -6
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +90 -0
- package/telegram-plugin/uat/scenarios/jtbd-message-during-restart-channel.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/jtbd-message-during-restart-dm.test.ts +95 -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.51";
|
|
49466
|
+
var COMMIT_SHA = "53373533";
|
|
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.51";
|
|
52162
|
+
var COMMIT_SHA = "53373533";
|
|
52163
|
+
var COMMIT_DATE = "2026-06-03T11:19:38Z";
|
|
52164
|
+
var LATEST_PR = 2127;
|
|
52162
52165
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52163
52166
|
|
|
52164
52167
|
// gateway/boot-version.ts
|
|
@@ -52667,21 +52670,23 @@ function promptClause(turn) {
|
|
|
52667
52670
|
const p = turn.user_prompt_preview?.trim();
|
|
52668
52671
|
if (!p)
|
|
52669
52672
|
return "";
|
|
52670
|
-
|
|
52671
|
-
return ` The request was: "${snippet}".`;
|
|
52673
|
+
return ` The start of the request was: "${p}".`;
|
|
52672
52674
|
}
|
|
52673
52675
|
function buildResumeInterruptedInbound(ctx) {
|
|
52674
52676
|
const ts = ctx.nowMs ?? Date.now();
|
|
52675
52677
|
const elapsed = humanizeElapsed(ts - ctx.turn.started_at);
|
|
52678
|
+
const threadId = threadIdNum(ctx.turn);
|
|
52676
52679
|
const meta = {
|
|
52677
52680
|
source: "resume_interrupted",
|
|
52681
|
+
chat_id: ctx.turn.chat_id,
|
|
52682
|
+
...threadId != null ? { message_thread_id: String(threadId) } : {},
|
|
52683
|
+
message_id: String(ts),
|
|
52678
52684
|
resume_turn_key: ctx.turn.turn_key,
|
|
52679
52685
|
interrupted_via: ctx.turn.ended_via ?? "restart",
|
|
52680
52686
|
started_at: String(ctx.turn.started_at)
|
|
52681
52687
|
};
|
|
52682
52688
|
if (ctx.turn.user_prompt_preview)
|
|
52683
52689
|
meta.original_prompt = ctx.turn.user_prompt_preview;
|
|
52684
|
-
const threadId = threadIdNum(ctx.turn);
|
|
52685
52690
|
return {
|
|
52686
52691
|
type: "inbound",
|
|
52687
52692
|
chatId: ctx.turn.chat_id,
|
|
@@ -52690,7 +52695,7 @@ function buildResumeInterruptedInbound(ctx) {
|
|
|
52690
52695
|
user: "switchroom",
|
|
52691
52696
|
userId: 0,
|
|
52692
52697
|
ts,
|
|
52693
|
-
text: `You just restarted. Your previous turn was interrupted ${elapsed} ago, ` + `before it finished \u2014 it was cut off by a restart, not completed.` + promptClause(ctx.turn) + `
|
|
52698
|
+
text: `You just restarted. Your previous turn was interrupted ${elapsed} ago, ` + `before it finished \u2014 it was cut off by a restart, not completed.` + promptClause(ctx.turn) + ` That quoted text is only the first ~200 characters of the original ` + `request, and you've lost your in-memory context across the restart \u2014 so ` + `BEFORE you continue, call get_recent_messages for this chat to read your ` + `full original message and the surrounding conversation, so you resume the ` + `COMPLETE task (including any instructions in the tail of a long request), ` + `not just the truncated preview. Then pick that work back up and carry it ` + `through to completion. In your first message, briefly let the user know ` + `you're resuming what was interrupted (mention roughly how long ago in ` + `plain language) so they're not left wondering \u2014 then carry on with the ` + `actual task. Do not ask whether to resume; just resume. If even after ` + `reading the recent messages you genuinely can't tell what the work was, ` + `say so and ask.`,
|
|
52694
52699
|
meta
|
|
52695
52700
|
};
|
|
52696
52701
|
}
|
|
@@ -52699,8 +52704,11 @@ function buildResumeWatchdogReportInbound(ctx) {
|
|
|
52699
52704
|
const idle = humanizeElapsed(ctx.idleMs);
|
|
52700
52705
|
const since = humanizeElapsed(ts - ctx.turn.started_at);
|
|
52701
52706
|
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.` : "";
|
|
52707
|
+
const threadId = threadIdNum(ctx.turn);
|
|
52702
52708
|
const meta = {
|
|
52703
52709
|
source: "resume_watchdog_timeout",
|
|
52710
|
+
chat_id: ctx.turn.chat_id,
|
|
52711
|
+
...threadId != null ? { message_thread_id: String(threadId) } : {},
|
|
52704
52712
|
resume_turn_key: ctx.turn.turn_key,
|
|
52705
52713
|
interrupted_via: "timeout",
|
|
52706
52714
|
idle_ms: String(ctx.idleMs),
|
|
@@ -52710,7 +52718,6 @@ function buildResumeWatchdogReportInbound(ctx) {
|
|
|
52710
52718
|
meta.tool_call_count = String(ctx.turn.tool_call_count);
|
|
52711
52719
|
if (ctx.turn.user_prompt_preview)
|
|
52712
52720
|
meta.original_prompt = ctx.turn.user_prompt_preview;
|
|
52713
|
-
const threadId = threadIdNum(ctx.turn);
|
|
52714
52721
|
return {
|
|
52715
52722
|
type: "inbound",
|
|
52716
52723
|
chatId: ctx.turn.chat_id,
|
|
@@ -54501,7 +54508,8 @@ _deliveryMachineTick.unref?.();
|
|
|
54501
54508
|
function trackRedeliveredInbound(merged) {
|
|
54502
54509
|
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54503
54510
|
return;
|
|
54504
|
-
|
|
54511
|
+
const isTrackableResume = isTrackableResumeSynthetic(merged.meta);
|
|
54512
|
+
if (!isTrackableResume && !shouldTrackDelivery({
|
|
54505
54513
|
isSteering: false,
|
|
54506
54514
|
isInterrupt: false,
|
|
54507
54515
|
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
|
+
}
|
|
@@ -66,9 +66,12 @@ function threadIdNum(turn: Turn): number | undefined {
|
|
|
66
66
|
function promptClause(turn: Turn): string {
|
|
67
67
|
const p = turn.user_prompt_preview?.trim()
|
|
68
68
|
if (!p) return ''
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// The stored preview is already capped at the first ~200 chars of the user
|
|
70
|
+
// message (TURN_PREVIEW_MAX). Include it verbatim as a hint — do NOT
|
|
71
|
+
// re-truncate (the old 160-char slice dropped instructions that lived in the
|
|
72
|
+
// tail of a longer request). The FULL original is recovered via
|
|
73
|
+
// get_recent_messages; the resume body tells the model to do that.
|
|
74
|
+
return ` The start of the request was: "${p}".`
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/**
|
|
@@ -78,14 +81,33 @@ function promptClause(turn: Turn): string {
|
|
|
78
81
|
export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): InboundMessage {
|
|
79
82
|
const ts = ctx.nowMs ?? Date.now()
|
|
80
83
|
const elapsed = humanizeElapsed(ts - ctx.turn.started_at)
|
|
84
|
+
const threadId = threadIdNum(ctx.turn)
|
|
81
85
|
const meta: Record<string, string> = {
|
|
82
86
|
source: 'resume_interrupted',
|
|
87
|
+
// Carry the originating chat/topic as model-visible channel attributes
|
|
88
|
+
// (mirrors the real-inbound + subagent_handback shapes — see
|
|
89
|
+
// gateway.ts:10753 and subagent-handback-inbound-builder.ts:115-121).
|
|
90
|
+
// Without meta.chat_id the enqueue's channel XML has no chat_id, so the
|
|
91
|
+
// gateway's enqueue handler (gateway.ts `if (ev.chatId)`) never builds a
|
|
92
|
+
// currentTurn for the resume turn — meaning no progress card, no
|
|
93
|
+
// silence-poke protection, and the reply falling back to the agent's
|
|
94
|
+
// default chat instead of the topic the interrupted work lived in.
|
|
95
|
+
chat_id: ctx.turn.chat_id,
|
|
96
|
+
...(threadId != null ? { message_thread_id: String(threadId) } : {}),
|
|
97
|
+
// message_id rounds-trips the fabricated `ts` through the enqueue's
|
|
98
|
+
// channel XML so the deliver-until-acked queue can ack THIS synthetic by
|
|
99
|
+
// its own enqueue id (gateway trackRedeliveredInbound carve-in). It is
|
|
100
|
+
// never used as a Telegram reply_to: the model's reply tool quotes the
|
|
101
|
+
// real prior user message via getLatestInboundMessageId (role='user',
|
|
102
|
+
// synthetics aren't in history), and the activity feed anchors with
|
|
103
|
+
// allow_sending_without_reply. Required so tracking the resume can ack and
|
|
104
|
+
// never re-delivers forever.
|
|
105
|
+
message_id: String(ts),
|
|
83
106
|
resume_turn_key: ctx.turn.turn_key,
|
|
84
107
|
interrupted_via: ctx.turn.ended_via ?? 'restart',
|
|
85
108
|
started_at: String(ctx.turn.started_at),
|
|
86
109
|
}
|
|
87
110
|
if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
|
|
88
|
-
const threadId = threadIdNum(ctx.turn)
|
|
89
111
|
return {
|
|
90
112
|
type: 'inbound',
|
|
91
113
|
chatId: ctx.turn.chat_id,
|
|
@@ -98,12 +120,18 @@ export function buildResumeInterruptedInbound(ctx: ResumeInboundContext): Inboun
|
|
|
98
120
|
`You just restarted. Your previous turn was interrupted ${elapsed} ago, ` +
|
|
99
121
|
`before it finished — it was cut off by a restart, not completed.` +
|
|
100
122
|
promptClause(ctx.turn) +
|
|
101
|
-
`
|
|
102
|
-
`
|
|
103
|
-
`
|
|
104
|
-
`
|
|
105
|
-
`
|
|
106
|
-
`
|
|
123
|
+
` That quoted text is only the first ~200 characters of the original ` +
|
|
124
|
+
`request, and you've lost your in-memory context across the restart — so ` +
|
|
125
|
+
`BEFORE you continue, call get_recent_messages for this chat to read your ` +
|
|
126
|
+
`full original message and the surrounding conversation, so you resume the ` +
|
|
127
|
+
`COMPLETE task (including any instructions in the tail of a long request), ` +
|
|
128
|
+
`not just the truncated preview. Then pick that work back up and carry it ` +
|
|
129
|
+
`through to completion. In your first message, briefly let the user know ` +
|
|
130
|
+
`you're resuming what was interrupted (mention roughly how long ago in ` +
|
|
131
|
+
`plain language) so they're not left wondering — then carry on with the ` +
|
|
132
|
+
`actual task. Do not ask whether to resume; just resume. If even after ` +
|
|
133
|
+
`reading the recent messages you genuinely can't tell what the work was, ` +
|
|
134
|
+
`say so and ask.`,
|
|
107
135
|
meta,
|
|
108
136
|
}
|
|
109
137
|
}
|
|
@@ -127,8 +155,15 @@ export function buildResumeWatchdogReportInbound(
|
|
|
127
155
|
ctx.turn.tool_call_count != null && ctx.turn.tool_call_count > 0
|
|
128
156
|
? ` You'd run ${ctx.turn.tool_call_count} tool call${ctx.turn.tool_call_count === 1 ? '' : 's'} before it stalled.`
|
|
129
157
|
: ''
|
|
158
|
+
const threadId = threadIdNum(ctx.turn)
|
|
130
159
|
const meta: Record<string, string> = {
|
|
131
160
|
source: 'resume_watchdog_timeout',
|
|
161
|
+
// Origin chat/topic as channel attributes so the report turn gets a
|
|
162
|
+
// currentTurn (progress card + silence-poke) and the "your last turn was
|
|
163
|
+
// interrupted" notice lands in the topic the work lived in, not the
|
|
164
|
+
// agent's default chat. Same rationale as buildResumeInterruptedInbound.
|
|
165
|
+
chat_id: ctx.turn.chat_id,
|
|
166
|
+
...(threadId != null ? { message_thread_id: String(threadId) } : {}),
|
|
132
167
|
resume_turn_key: ctx.turn.turn_key,
|
|
133
168
|
interrupted_via: 'timeout',
|
|
134
169
|
idle_ms: String(ctx.idleMs),
|
|
@@ -136,7 +171,6 @@ export function buildResumeWatchdogReportInbound(
|
|
|
136
171
|
}
|
|
137
172
|
if (ctx.turn.tool_call_count != null) meta.tool_call_count = String(ctx.turn.tool_call_count)
|
|
138
173
|
if (ctx.turn.user_prompt_preview) meta.original_prompt = ctx.turn.user_prompt_preview
|
|
139
|
-
const threadId = threadIdNum(ctx.turn)
|
|
140
174
|
return {
|
|
141
175
|
type: 'inbound',
|
|
142
176
|
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
|
+
})
|
|
@@ -106,12 +106,22 @@ describe('buildResumeInterruptedInbound', () => {
|
|
|
106
106
|
expect(msg.meta.original_prompt).toBe('refactor the auth module')
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
-
it('
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect(
|
|
109
|
+
it('includes the FULL stored preview in the body (no double-truncation below the 200-char storage cap)', () => {
|
|
110
|
+
// The turns table already caps the preview at ~200 chars (TURN_PREVIEW_MAX);
|
|
111
|
+
// the builder must NOT slice it shorter (the old 160-char cut dropped detail
|
|
112
|
+
// from the tail of a longer request). A 180-char preview must appear in full.
|
|
113
|
+
const preview = "step 1 do X; step 2 do Y; step 3 do Z; " + "d".repeat(141) // 180 chars
|
|
114
|
+
expect(preview.length).toBe(180)
|
|
115
|
+
const msg = buildResumeInterruptedInbound({ turn: makeTurn({ user_prompt_preview: preview }) })
|
|
116
|
+
expect(msg.text).toContain(preview) // verbatim, not sliced to 160
|
|
117
|
+
expect(msg.meta.original_prompt).toBe(preview)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('tells the resumed turn to recover the FULL original via get_recent_messages', () => {
|
|
121
|
+
const msg = buildResumeInterruptedInbound({ turn: makeTurn({ user_prompt_preview: 'short task' }) })
|
|
122
|
+
expect(msg.text).toContain('get_recent_messages')
|
|
123
|
+
// Frames the quoted preview as partial so the model knows to fetch the rest.
|
|
124
|
+
expect(msg.text).toMatch(/first ~200 characters|start of the request/i)
|
|
115
125
|
})
|
|
116
126
|
|
|
117
127
|
it('routes to the forum thread when thread_id is numeric', () => {
|
|
@@ -124,6 +134,27 @@ describe('buildResumeInterruptedInbound', () => {
|
|
|
124
134
|
const msg = buildResumeInterruptedInbound({ turn: makeTurn({ thread_id: null }) })
|
|
125
135
|
expect(msg.threadId).toBeUndefined()
|
|
126
136
|
})
|
|
137
|
+
|
|
138
|
+
// Origin routing + ack-ability (the resume-deliver hardening). meta.chat_id
|
|
139
|
+
// makes the gateway build a currentTurn (progress card + silence-poke) and
|
|
140
|
+
// fire ackDelivery; meta.message_id round-trips so the deliver-until-acked
|
|
141
|
+
// queue can ack THIS synthetic by its own enqueue id (no re-deliver storm).
|
|
142
|
+
it('carries origin chat_id + message_id in meta (DM)', () => {
|
|
143
|
+
const turn = makeTurn({ chat_id: '12345', thread_id: null })
|
|
144
|
+
const msg = buildResumeInterruptedInbound({ turn, nowMs: 1_700_000_000_000 })
|
|
145
|
+
expect(msg.meta.chat_id).toBe('12345')
|
|
146
|
+
expect(msg.meta.message_id).toBe('1700000000000')
|
|
147
|
+
expect(msg.messageId).toBe(1_700_000_000_000)
|
|
148
|
+
expect(msg.meta.message_thread_id).toBeUndefined()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('carries message_thread_id in meta for a forum topic (supergroup routing)', () => {
|
|
152
|
+
const turn = makeTurn({ chat_id: '-1001234567890', thread_id: '42' })
|
|
153
|
+
const msg = buildResumeInterruptedInbound({ turn })
|
|
154
|
+
expect(msg.meta.chat_id).toBe('-1001234567890')
|
|
155
|
+
expect(msg.meta.message_thread_id).toBe('42')
|
|
156
|
+
expect(msg.threadId).toBe(42)
|
|
157
|
+
})
|
|
127
158
|
})
|
|
128
159
|
|
|
129
160
|
describe('buildResumeWatchdogReportInbound', () => {
|
|
@@ -163,6 +194,18 @@ describe('buildResumeWatchdogReportInbound', () => {
|
|
|
163
194
|
const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
|
|
164
195
|
expect(msg.text).not.toContain('tool call')
|
|
165
196
|
})
|
|
197
|
+
|
|
198
|
+
// The report turn ALSO gets origin routing (currentTurn + topic) — a "your
|
|
199
|
+
// last turn was interrupted" notice should land in the topic the work lived
|
|
200
|
+
// in — but it is NOT deliver-until-acked tracked, so it carries NO
|
|
201
|
+
// message_id (only the 'resume_interrupted' builder does, gating tracking).
|
|
202
|
+
it('carries origin chat_id + message_thread_id but NO message_id (report is untracked)', () => {
|
|
203
|
+
const turn = makeTurn({ ended_via: 'timeout', chat_id: '-1001234567890', thread_id: '42' })
|
|
204
|
+
const msg = buildResumeWatchdogReportInbound({ turn, idleMs: 300_000 })
|
|
205
|
+
expect(msg.meta.chat_id).toBe('-1001234567890')
|
|
206
|
+
expect(msg.meta.message_thread_id).toBe('42')
|
|
207
|
+
expect(msg.meta.message_id).toBeUndefined()
|
|
208
|
+
})
|
|
166
209
|
})
|
|
167
210
|
|
|
168
211
|
describe('selectResumeBuilder', () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD — always-on: a turn interrupted by a restart is RESUMED without
|
|
3
|
+
* re-prompting, and the resume turn runs to completion (it is not silently
|
|
4
|
+
* dropped into a not-ready session). Regression gate for v0.14.50 / #2122.
|
|
5
|
+
*
|
|
6
|
+
* Flow: send a long-running task, let it get in-flight, then restart the agent
|
|
7
|
+
* (--force, NOT --wait, so it interrupts rather than draining). On boot the
|
|
8
|
+
* gateway finds the orphaned turn and injects the `resume_interrupted`
|
|
9
|
+
* synthetic. With #2122 that synthetic carries meta.chat_id + message_id, so the
|
|
10
|
+
* resumed turn (a) builds a currentTurn (progress card + silence-poke), (b)
|
|
11
|
+
* routes its reply to the originating chat, and (c) re-enrols in
|
|
12
|
+
* deliver-until-acked so a drop into a still-booting session is rescued rather
|
|
13
|
+
* than lost.
|
|
14
|
+
*
|
|
15
|
+
* Assertion: a reply arrives after the interrupting restart whose FRAMING shows
|
|
16
|
+
* the agent is resuming ("picking this back up" / "interrupted" / "cut off by a
|
|
17
|
+
* restart"). We deliberately do NOT assert an end-token: the resume synthetic
|
|
18
|
+
* only carries the first ~160 chars of the original prompt (promptClause
|
|
19
|
+
* truncation), so instructions in the prompt's tail are not guaranteed to
|
|
20
|
+
* survive — the resume FRAMING is the stable, builder-guaranteed signal.
|
|
21
|
+
*
|
|
22
|
+
* Self-skips green without NOPASSWD sudo.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from "vitest";
|
|
26
|
+
import { execSync, spawn } from "node:child_process";
|
|
27
|
+
import { spinUp } from "../harness.js";
|
|
28
|
+
|
|
29
|
+
const AGENT = "test-harness";
|
|
30
|
+
const MID_TURN_MS = 10_000; // let the turn enqueue (become a recorded interrupted turn)
|
|
31
|
+
const RESUME_BUDGET_MS = 180_000; // boot + resume + reply
|
|
32
|
+
|
|
33
|
+
// The resume builder tells the model to "briefly let the user know you're
|
|
34
|
+
// resuming what was interrupted" — so the reply always opens with this framing.
|
|
35
|
+
const RESUME_FRAMING = /resum|picking .*back|interrupted|cut off|just restarted/i;
|
|
36
|
+
|
|
37
|
+
function canShellSudo(): boolean {
|
|
38
|
+
try {
|
|
39
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function kickRestartDetached(name: string): void {
|
|
47
|
+
// --force WITHOUT --wait → recreate now, interrupting the in-flight turn.
|
|
48
|
+
const child = spawn(
|
|
49
|
+
"sudo",
|
|
50
|
+
["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
|
|
51
|
+
"switchroom", "agent", "restart", name, "--force"],
|
|
52
|
+
{ detached: true, stdio: "ignore" },
|
|
53
|
+
);
|
|
54
|
+
child.unref();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sudoOk = canShellSudo();
|
|
58
|
+
|
|
59
|
+
(sudoOk ? describe : describe.skip)("uat: interrupted turn resumes after restart (DM)", () => {
|
|
60
|
+
it(
|
|
61
|
+
"a turn interrupted by a restart is resumed and the resume turn completes",
|
|
62
|
+
async () => {
|
|
63
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
64
|
+
try {
|
|
65
|
+
// A task long enough that it's still in-flight at MID_TURN_MS.
|
|
66
|
+
await sc.sendDM(
|
|
67
|
+
`Please write a thorough, detailed ~300-word explanation of how a ` +
|
|
68
|
+
`mechanical typewriter's typebar mechanism works, step by step. Take ` +
|
|
69
|
+
`your time and be complete.`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await new Promise((r) => setTimeout(r, MID_TURN_MS));
|
|
73
|
+
kickRestartDetached(AGENT);
|
|
74
|
+
|
|
75
|
+
// After boot, the resume synthetic should make the agent pick the work
|
|
76
|
+
// back up and say so. (The newest interrupted turn — this one — is the
|
|
77
|
+
// one findLatestTurnIfInterrupted resumes.)
|
|
78
|
+
const reply = await sc.expectMessage((m) => RESUME_FRAMING.test(m.text), {
|
|
79
|
+
from: "bot",
|
|
80
|
+
timeout: RESUME_BUDGET_MS,
|
|
81
|
+
});
|
|
82
|
+
expect(reply.text).toMatch(RESUME_FRAMING);
|
|
83
|
+
expect(reply.text.length).toBeGreaterThan(40); // a substantive resumed reply, not a stub
|
|
84
|
+
} finally {
|
|
85
|
+
await sc.tearDown();
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
RESUME_BUDGET_MS + 60_000,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD — always-on in a supergroup: a message sent into a forum topic WHILE the
|
|
3
|
+
* agent is restarting must still be answered IN the group (the channel twin of
|
|
4
|
+
* jtbd-message-during-restart-dm). Regression gate for v0.14.48 / #2117, proving
|
|
5
|
+
* the restart-redeliver rescue is keyed per (chat, thread) so DM and supergroup
|
|
6
|
+
* topics recover identically.
|
|
7
|
+
*
|
|
8
|
+
* Self-skips green when SWITCHROOM_UAT_CHAT_ID is unset or the chat isn't a
|
|
9
|
+
* resolvable forum supergroup the driver is in (uat/** is excluded from gating
|
|
10
|
+
* CI). mtcute caveat: no forum-topic create API, so this uses the supergroup's
|
|
11
|
+
* General topic — it proves DM-vs-channel routing, not "correct topic among
|
|
12
|
+
* many" (pinned by the gateway unit thread-assertions).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import { execSync, spawn } from "node:child_process";
|
|
17
|
+
import { spinUp } from "../harness.js";
|
|
18
|
+
import { expectMessage } from "../assertions.js";
|
|
19
|
+
|
|
20
|
+
const AGENT = "test-harness";
|
|
21
|
+
const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
|
|
22
|
+
const BOOT_SEND_DELAY_MS = Number.parseInt(
|
|
23
|
+
process.env.SWITCHROOM_UAT_BOOT_SEND_DELAY_MS ?? "12000",
|
|
24
|
+
10,
|
|
25
|
+
);
|
|
26
|
+
const REPLY_BUDGET_MS = 180_000;
|
|
27
|
+
|
|
28
|
+
function canShellSudo(): boolean {
|
|
29
|
+
try {
|
|
30
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function kickRestartDetached(name: string): void {
|
|
38
|
+
const child = spawn(
|
|
39
|
+
"sudo",
|
|
40
|
+
["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
|
|
41
|
+
"switchroom", "agent", "restart", name, "--force"],
|
|
42
|
+
{ detached: true, stdio: "ignore" },
|
|
43
|
+
);
|
|
44
|
+
child.unref();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sudoOk = canShellSudo();
|
|
48
|
+
|
|
49
|
+
(sudoOk ? describe : describe.skip)("uat: message sent during a restart (supergroup)", () => {
|
|
50
|
+
it(
|
|
51
|
+
"a supergroup-topic message sent DURING the restart boot window is still answered in the group",
|
|
52
|
+
async () => {
|
|
53
|
+
if (!Number.isFinite(SUPERGROUP_ID)) {
|
|
54
|
+
console.warn("[during-restart-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
58
|
+
try {
|
|
59
|
+
await sc.driver.primeDialogs();
|
|
60
|
+
if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[during-restart-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping ` +
|
|
63
|
+
`(ensure forum supergroup with Topics + driver is a member)`,
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const nonce = `bootsg-${Date.now().toString(36)}`;
|
|
69
|
+
kickRestartDetached(AGENT);
|
|
70
|
+
await new Promise((r) => setTimeout(r, BOOT_SEND_DELAY_MS));
|
|
71
|
+
|
|
72
|
+
const sendStart = Date.now();
|
|
73
|
+
await sc.driver.sendText(
|
|
74
|
+
SUPERGROUP_ID,
|
|
75
|
+
`You are being restart-tested in this group. Reply in this group with exactly this token and nothing else: ${nonce}`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const reply = await expectMessage(
|
|
79
|
+
sc.driver,
|
|
80
|
+
SUPERGROUP_ID,
|
|
81
|
+
(m) => m.text.includes(nonce),
|
|
82
|
+
{ timeout: REPLY_BUDGET_MS, senderFilter: { notUserId: sc.driverUserId } },
|
|
83
|
+
);
|
|
84
|
+
const ttfo = Date.now() - sendStart;
|
|
85
|
+
console.warn(`[during-restart-channel] answered in ${ttfo}ms (nonce ${nonce})`);
|
|
86
|
+
expect(reply.chatId).toBe(SUPERGROUP_ID);
|
|
87
|
+
expect(reply.fromBot).toBe(true);
|
|
88
|
+
expect(reply.text).toContain(nonce);
|
|
89
|
+
} finally {
|
|
90
|
+
await sc.tearDown();
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
REPLY_BUDGET_MS + 60_000,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JTBD — always-on: a message sent WHILE the agent is restarting must still be
|
|
3
|
+
* answered (DM). Regression gate for the v0.14.48 / #2117 lost-message incident
|
|
4
|
+
* (clerk/KenGPT, 2026-06-03).
|
|
5
|
+
*
|
|
6
|
+
* The wedge this guards: an inbound that arrives during a restart is buffered +
|
|
7
|
+
* spool-persisted, then redelivered on bridge-up — but `bridge registered` is
|
|
8
|
+
* not the same as `claude` session-ready. If claude is slow to boot (e.g. a
|
|
9
|
+
* Hindsight MCP timeout) the redelivered inject hit a still-booting session and
|
|
10
|
+
* was silently dropped; the 300s silence-poke then ended a phantom turn
|
|
11
|
+
* (`drained_buffered=0/0`) and the message was gone. The fix re-enrols the
|
|
12
|
+
* redelivered inbound in the deliver-until-acked queue so it re-delivers every
|
|
13
|
+
* 5s until claude actually consumes it.
|
|
14
|
+
*
|
|
15
|
+
* Unlike `jtbd-always-on-after-restart-dm` (which sends ~5s AFTER the restart
|
|
16
|
+
* returns, exercising the live path), this sends DURING the boot window so the
|
|
17
|
+
* message goes through the restart-redeliver path the fix patches. The mtcute
|
|
18
|
+
* driver sends straight to Telegram — independent of the agent's bridge — so the
|
|
19
|
+
* message queues server-side and is delivered the moment the new gateway
|
|
20
|
+
* reconnects.
|
|
21
|
+
*
|
|
22
|
+
* Self-skips green without NOPASSWD sudo (can't restart the agent).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from "vitest";
|
|
26
|
+
import { execSync, spawn } from "node:child_process";
|
|
27
|
+
import { spinUp } from "../harness.js";
|
|
28
|
+
|
|
29
|
+
const AGENT = "test-harness";
|
|
30
|
+
|
|
31
|
+
// After kicking the restart, wait this long before sending — long enough that
|
|
32
|
+
// the reconcile + docker recreate have dropped the bridge, so the message
|
|
33
|
+
// buffers and is drained on bridge-up (the restart-redeliver path). Override
|
|
34
|
+
// for forensics (e.g. 6000 to land it deeper in the not-ready window so the
|
|
35
|
+
// strand-rescue sweep fires).
|
|
36
|
+
const BOOT_SEND_DELAY_MS = Number.parseInt(
|
|
37
|
+
process.env.SWITCHROOM_UAT_BOOT_SEND_DELAY_MS ?? "12000",
|
|
38
|
+
10,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Generous: the fix re-delivers every 5s until claude is ready. The wedge
|
|
42
|
+
// symptom is ≥300s (silence-poke floor) or never — both fail this.
|
|
43
|
+
const REPLY_BUDGET_MS = 180_000;
|
|
44
|
+
|
|
45
|
+
function canShellSudo(): boolean {
|
|
46
|
+
try {
|
|
47
|
+
execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Kick a marker-safe restart and return immediately (detached). */
|
|
55
|
+
function kickRestartDetached(name: string): void {
|
|
56
|
+
const child = spawn(
|
|
57
|
+
"sudo",
|
|
58
|
+
["-n", "env", `PATH=${process.env.PATH}`, `HOME=${process.env.HOME}`,
|
|
59
|
+
"switchroom", "agent", "restart", name, "--force"],
|
|
60
|
+
{ detached: true, stdio: "ignore" },
|
|
61
|
+
);
|
|
62
|
+
child.unref();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sudoOk = canShellSudo();
|
|
66
|
+
|
|
67
|
+
(sudoOk ? describe : describe.skip)("uat: message sent during a restart (DM)", () => {
|
|
68
|
+
it(
|
|
69
|
+
"a DM sent DURING the restart boot window is still answered (not lost)",
|
|
70
|
+
async () => {
|
|
71
|
+
const sc = await spinUp({ agent: AGENT, settleMs: 0 });
|
|
72
|
+
try {
|
|
73
|
+
const nonce = `bootdm-${Date.now().toString(36)}`;
|
|
74
|
+
kickRestartDetached(AGENT);
|
|
75
|
+
await new Promise((r) => setTimeout(r, BOOT_SEND_DELAY_MS));
|
|
76
|
+
|
|
77
|
+
const sendStart = Date.now();
|
|
78
|
+
await sc.sendDM(
|
|
79
|
+
`You are being restart-tested. Reply with exactly this token and nothing else: ${nonce}`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const reply = await sc.expectMessage((m) => m.text.includes(nonce), {
|
|
83
|
+
from: "bot",
|
|
84
|
+
timeout: REPLY_BUDGET_MS,
|
|
85
|
+
});
|
|
86
|
+
const ttfo = Date.now() - sendStart;
|
|
87
|
+
console.warn(`[during-restart-dm] answered in ${ttfo}ms (nonce ${nonce})`);
|
|
88
|
+
expect(reply.text).toContain(nonce);
|
|
89
|
+
} finally {
|
|
90
|
+
await sc.tearDown();
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
REPLY_BUDGET_MS + 60_000,
|
|
94
|
+
);
|
|
95
|
+
});
|