switchroom 0.14.66 → 0.14.68
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 +556 -325
- package/dist/cli/ui/index.html +103 -38
- package/package.json +1 -1
- package/telegram-plugin/answer-stream-flag.ts +19 -0
- package/telegram-plugin/dist/gateway/gateway.js +35 -11
- package/telegram-plugin/gateway/gateway.ts +71 -7
- package/telegram-plugin/silence-poke.ts +25 -0
- package/telegram-plugin/tests/answer-stream-flag.test.ts +19 -1
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +52 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +67 -0
- package/telegram-plugin/tests/silence-poke.test.ts +42 -0
- package/telegram-plugin/uat/real-work-prompts.ts +332 -0
- package/telegram-plugin/uat/scenarios/fuzz-real-work-channel.test.ts +82 -0
- package/telegram-plugin/uat/scenarios/fuzz-real-work-dm.test.ts +64 -0
package/dist/cli/ui/index.html
CHANGED
|
@@ -409,7 +409,7 @@
|
|
|
409
409
|
<button id="tab-agents" onclick="switchTab('agents')">Agents</button>
|
|
410
410
|
<button id="tab-accounts" onclick="switchTab('accounts')">Accounts</button>
|
|
411
411
|
<button id="tab-system" onclick="switchTab('system')">System</button>
|
|
412
|
-
<button id="tab-
|
|
412
|
+
<button id="tab-connections" onclick="switchTab('connections')">Connections</button>
|
|
413
413
|
<button id="tab-schedule" onclick="switchTab('schedule')">Schedule</button>
|
|
414
414
|
<button id="tab-approvals" onclick="switchTab('approvals')">Approvals</button>
|
|
415
415
|
</nav>
|
|
@@ -419,7 +419,7 @@
|
|
|
419
419
|
<div id="agents" style="display:none" class="loading">Loading agents...</div>
|
|
420
420
|
<div id="accounts" style="display:none"></div>
|
|
421
421
|
<div id="system" style="display:none"></div>
|
|
422
|
-
<div id="
|
|
422
|
+
<div id="connections" style="display:none"></div>
|
|
423
423
|
<div id="schedule" style="display:none"></div>
|
|
424
424
|
<div id="approvals" style="display:none"></div>
|
|
425
425
|
</main>
|
|
@@ -493,14 +493,17 @@
|
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
-
async function
|
|
496
|
+
async function fetchConnections() {
|
|
497
497
|
try {
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
498
|
+
const [google, microsoft, notion] = await Promise.all([
|
|
499
|
+
fetch(`${API}/api/google-accounts`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
|
|
500
|
+
fetch(`${API}/api/microsoft-accounts`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
|
|
501
|
+
fetch(`${API}/api/notion-workspace`, { headers: authHeaders() }).then(r => r.ok ? r.json() : { configured: false, databases: [] }),
|
|
502
|
+
]);
|
|
503
|
+
renderConnections({ google, microsoft, notion });
|
|
501
504
|
clearError();
|
|
502
505
|
} catch (err) {
|
|
503
|
-
showError(`Failed to fetch
|
|
506
|
+
showError(`Failed to fetch connections: ${err.message}`);
|
|
504
507
|
}
|
|
505
508
|
}
|
|
506
509
|
|
|
@@ -527,7 +530,7 @@
|
|
|
527
530
|
}
|
|
528
531
|
|
|
529
532
|
function switchTab(tab) {
|
|
530
|
-
const tabs = ['summary', 'agents', 'accounts', 'system', '
|
|
533
|
+
const tabs = ['summary', 'agents', 'accounts', 'system', 'connections', 'schedule', 'approvals'];
|
|
531
534
|
for (const t of tabs) {
|
|
532
535
|
document.getElementById(`tab-${t}`).classList.toggle('active', tab === t);
|
|
533
536
|
document.getElementById(t).style.display = tab === t ? '' : 'none';
|
|
@@ -535,7 +538,7 @@
|
|
|
535
538
|
if (tab === 'summary') fetchSummary();
|
|
536
539
|
if (tab === 'accounts') fetchAccounts();
|
|
537
540
|
if (tab === 'system') fetchSystemHealth();
|
|
538
|
-
if (tab === '
|
|
541
|
+
if (tab === 'connections') fetchConnections();
|
|
539
542
|
if (tab === 'schedule') fetchSchedule();
|
|
540
543
|
if (tab === 'approvals') fetchApprovals();
|
|
541
544
|
}
|
|
@@ -988,37 +991,99 @@
|
|
|
988
991
|
return String(ts).replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '');
|
|
989
992
|
}
|
|
990
993
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
994
|
+
const _dimC = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
|
|
995
|
+
|
|
996
|
+
// One OAuth-account card (Google or Microsoft — same shape; Microsoft
|
|
997
|
+
// adds an account-type pill).
|
|
998
|
+
function renderOAuthAccountCard(a, opts) {
|
|
999
|
+
const expires = a.expiresAt ? formatTimestamp(a.expiresAt) : _dimC('—');
|
|
1000
|
+
const known = a.brokerKnown
|
|
1001
|
+
? '<span class="usage-pill primary">slot present</span>'
|
|
1002
|
+
: '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
|
|
1003
|
+
const acl = (a.enabledFor && a.enabledFor.length)
|
|
1004
|
+
? a.enabledFor.map(escapeHtml).join(', ')
|
|
1005
|
+
: _dimC('no agents enabled');
|
|
1006
|
+
const typePill = (opts && opts.showType && a.accountType)
|
|
1007
|
+
? `<span class="usage-pill" style="margin-left:.4rem">${escapeHtml(a.accountType)}</span>`
|
|
1008
|
+
: '';
|
|
1009
|
+
return `
|
|
1010
|
+
<div class="account-card">
|
|
1011
|
+
<div class="account-card-header">
|
|
1012
|
+
<div class="account-label">${escapeHtml(a.account)}${typePill}</div>
|
|
1013
|
+
<span style="margin-left:auto">${known}</span>
|
|
1014
|
+
</div>
|
|
1015
|
+
<div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
|
|
1016
|
+
<div class="card-meta" style="padding:0">
|
|
1017
|
+
<div class="meta-item"><label>Expires </label><span>${expires}</span></div>
|
|
1018
|
+
<div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : _dimC('—')}</span></div>
|
|
1019
|
+
<div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : _dimC('—')}</span></div>
|
|
1020
|
+
</div>
|
|
1021
|
+
</div>`;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function _connectionSection(title, emptyHtml, cardsHtml) {
|
|
1025
|
+
return `
|
|
1026
|
+
<div style="margin-bottom:1.5rem">
|
|
1027
|
+
<h3 style="margin:0 0 .6rem;font-size:.95rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em">${title}</h3>
|
|
1028
|
+
${cardsHtml ? `<div class="accounts-grid">${cardsHtml}</div>` : `<div class="loading" style="padding:.8rem">${emptyHtml}</div>`}
|
|
1029
|
+
</div>`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Unified external-connections view: Google + Microsoft accounts and
|
|
1033
|
+
// the Notion workspace, each showing which agents have access.
|
|
1034
|
+
function renderConnections(data) {
|
|
1035
|
+
const container = document.getElementById('connections');
|
|
1036
|
+
const google = data.google || [];
|
|
1037
|
+
const microsoft = data.microsoft || [];
|
|
1038
|
+
const notion = data.notion || { configured: false, databases: [] };
|
|
1039
|
+
|
|
1040
|
+
const googleSection = _connectionSection(
|
|
1041
|
+
'Google',
|
|
1042
|
+
'No Google accounts. Add one under <code>google_accounts:</code> and run <code>switchroom auth google account add</code>.',
|
|
1043
|
+
google.map(a => renderOAuthAccountCard(a, { showType: false })).join(''),
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
const microsoftSection = _connectionSection(
|
|
1047
|
+
'Microsoft 365',
|
|
1048
|
+
'No Microsoft accounts. Connect one from Telegram with <code>/connect microsoft</code> (admin DM), or <code>switchroom auth microsoft account add</code>.',
|
|
1049
|
+
microsoft.map(a => renderOAuthAccountCard(a, { showType: true })).join(''),
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
let notionCards = '';
|
|
1053
|
+
if (notion.configured) {
|
|
1054
|
+
notionCards = (notion.databases || []).map(db => {
|
|
1055
|
+
const acl = (db.enabledFor && db.enabledFor.length)
|
|
1056
|
+
? db.enabledFor.map(escapeHtml).join(', ')
|
|
1057
|
+
: _dimC('no agents enabled');
|
|
1058
|
+
return `
|
|
1059
|
+
<div class="account-card">
|
|
1060
|
+
<div class="account-card-header">
|
|
1061
|
+
<div class="account-label">${escapeHtml(db.name)}</div>
|
|
1062
|
+
<span style="margin-left:auto" class="usage-pill">database</span>
|
|
1063
|
+
</div>
|
|
1064
|
+
<div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
|
|
1065
|
+
<div class="card-meta" style="padding:0">
|
|
1066
|
+
<div class="meta-item"><label>DB id </label><span>${escapeHtml(db.id.slice(0, 12))}…</span></div>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>`;
|
|
1069
|
+
}).join('');
|
|
996
1070
|
}
|
|
997
|
-
const
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
<div class="account-label">${escapeHtml(a.account)}</div>
|
|
1011
|
-
<span style="margin-left:auto">${known}</span>
|
|
1012
|
-
</div>
|
|
1013
|
-
<div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
|
|
1014
|
-
<div class="card-meta" style="padding:0">
|
|
1015
|
-
<div class="meta-item"><label>Expires </label><span>${expires}</span></div>
|
|
1016
|
-
<div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : dim('—')}</span></div>
|
|
1017
|
-
<div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : dim('—')}</span></div>
|
|
1018
|
-
</div>
|
|
1071
|
+
const notionTitle = 'Notion' + (notion.configured && notion.vaultKey ? ` <span style="text-transform:none;font-weight:400">· token <code>${escapeHtml(notion.vaultKey)}</code></span>` : '');
|
|
1072
|
+
const fullAccessBanner = (notion.fullAccessAgents && notion.fullAccessAgents.length)
|
|
1073
|
+
? `<div style="margin:0 0 .6rem;font-size:.85rem;color:var(--text-dim)">Full access (all databases): ${notion.fullAccessAgents.map(escapeHtml).join(', ')}</div>`
|
|
1074
|
+
: '';
|
|
1075
|
+
const notionBody = notionCards
|
|
1076
|
+
? `<div class="accounts-grid">${notionCards}</div>`
|
|
1077
|
+
: `<div class="loading" style="padding:.8rem">${notion.configured
|
|
1078
|
+
? 'Notion configured but no databases declared. Add them under <code>notion_workspace.databases:</code>.'
|
|
1079
|
+
: 'Notion not configured. See <code>docs/notion-integration.md</code>.'}</div>`;
|
|
1080
|
+
const notionSection = `
|
|
1081
|
+
<div style="margin-bottom:1.5rem">
|
|
1082
|
+
<h3 style="margin:0 0 .6rem;font-size:.95rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em">${notionTitle}</h3>
|
|
1083
|
+
${fullAccessBanner}${notionBody}
|
|
1019
1084
|
</div>`;
|
|
1020
|
-
|
|
1021
|
-
container.innerHTML =
|
|
1085
|
+
|
|
1086
|
+
container.innerHTML = googleSection + microsoftSection + notionSection;
|
|
1022
1087
|
}
|
|
1023
1088
|
|
|
1024
1089
|
function renderSchedule(data) {
|
package/package.json
CHANGED
|
@@ -16,3 +16,22 @@ export function parseVisibleAnswerStreamEnabled(raw: string | undefined): boolea
|
|
|
16
16
|
const v = raw.trim().toLowerCase()
|
|
17
17
|
return v === '1' || v === 'true' || v === 'on' || v === 'yes'
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Draft-answer-lane retirement (2026-06-05). The compose-box draft transport
|
|
22
|
+
* (`sendMessageDraft`) is invisible to the mtcute UAT harness, so the live
|
|
23
|
+
* answer-stream surface couldn't be tested. Retired by DEFAULT: the answer lane
|
|
24
|
+
* now opens a real, observable edit-in-place message instead of the compose-box
|
|
25
|
+
* draft (and the onMetric silence-liveness reset from #2169 now fires on visible
|
|
26
|
+
* sends in BOTH DMs and supergroups, not just DM drafts). Kill switch
|
|
27
|
+
* `SWITCHROOM_DRAFT_ANSWER_LANE=0` (also false/off/no) restores the legacy
|
|
28
|
+
* invisible draft.
|
|
29
|
+
*
|
|
30
|
+
* Returns true when the draft lane is RETIRED (the default — env unset or any
|
|
31
|
+
* truthy value); false only for an explicit disable of the retirement.
|
|
32
|
+
*/
|
|
33
|
+
export function parseDraftLaneRetiredEnabled(raw: string | undefined): boolean {
|
|
34
|
+
if (raw == null) return true
|
|
35
|
+
const v = raw.trim().toLowerCase()
|
|
36
|
+
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
37
|
+
}
|
|
@@ -39034,6 +39034,13 @@ function noteOutbound2(key, now) {
|
|
|
39034
39034
|
s.lastOutboundAt = now;
|
|
39035
39035
|
s.fallbackFired = false;
|
|
39036
39036
|
}
|
|
39037
|
+
function noteProduction(key, now) {
|
|
39038
|
+
const s = state2.get(key);
|
|
39039
|
+
if (s == null)
|
|
39040
|
+
return;
|
|
39041
|
+
s.lastOutboundAt = now;
|
|
39042
|
+
s.fallbackFired = false;
|
|
39043
|
+
}
|
|
39037
39044
|
function noteThinking(key, now) {
|
|
39038
39045
|
const s = state2.get(key);
|
|
39039
39046
|
if (s == null)
|
|
@@ -39754,6 +39761,12 @@ function parseVisibleAnswerStreamEnabled(raw) {
|
|
|
39754
39761
|
const v = raw.trim().toLowerCase();
|
|
39755
39762
|
return v === "1" || v === "true" || v === "on" || v === "yes";
|
|
39756
39763
|
}
|
|
39764
|
+
function parseDraftLaneRetiredEnabled(raw) {
|
|
39765
|
+
if (raw == null)
|
|
39766
|
+
return true;
|
|
39767
|
+
const v = raw.trim().toLowerCase();
|
|
39768
|
+
return !(v === "0" || v === "false" || v === "off" || v === "no");
|
|
39769
|
+
}
|
|
39757
39770
|
|
|
39758
39771
|
// pty-tail.ts
|
|
39759
39772
|
var import_headless = __toESM(require_xterm_headless(), 1);
|
|
@@ -52763,11 +52776,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52763
52776
|
}
|
|
52764
52777
|
|
|
52765
52778
|
// ../src/build-info.ts
|
|
52766
|
-
var VERSION = "0.14.
|
|
52767
|
-
var COMMIT_SHA = "
|
|
52768
|
-
var COMMIT_DATE = "2026-06-
|
|
52769
|
-
var LATEST_PR =
|
|
52770
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52779
|
+
var VERSION = "0.14.68";
|
|
52780
|
+
var COMMIT_SHA = "4f371b79";
|
|
52781
|
+
var COMMIT_DATE = "2026-06-05T10:24:42Z";
|
|
52782
|
+
var LATEST_PR = 2174;
|
|
52783
|
+
var COMMITS_AHEAD_OF_TAG = 3;
|
|
52771
52784
|
|
|
52772
52785
|
// gateway/boot-version.ts
|
|
52773
52786
|
function formatRelativeAgo(iso) {
|
|
@@ -53567,6 +53580,7 @@ var TOPIC_ID = process.env.TELEGRAM_TOPIC_ID ? Number(process.env.TELEGRAM_TOPIC
|
|
|
53567
53580
|
var AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === "true";
|
|
53568
53581
|
var bot = new import_grammy9.Bot(TOKEN);
|
|
53569
53582
|
installTgPostLogger(bot);
|
|
53583
|
+
var DRAFT_ANSWER_LANE_RETIRED = parseDraftLaneRetiredEnabled(process.env.SWITCHROOM_DRAFT_ANSWER_LANE);
|
|
53570
53584
|
var _rawSendMessageDraft = bot.api.raw.sendMessageDraft;
|
|
53571
53585
|
var GRAMMY_VERSION = (() => {
|
|
53572
53586
|
try {
|
|
@@ -53576,7 +53590,7 @@ var GRAMMY_VERSION = (() => {
|
|
|
53576
53590
|
return "unknown";
|
|
53577
53591
|
}
|
|
53578
53592
|
})();
|
|
53579
|
-
var sendMessageDraftFn = typeof _rawSendMessageDraft === "function" ? (chatId, draftId, text, params) => _rawSendMessageDraft({
|
|
53593
|
+
var sendMessageDraftFn = !DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === "function" ? (chatId, draftId, text, params) => _rawSendMessageDraft({
|
|
53580
53594
|
chat_id: Number(chatId),
|
|
53581
53595
|
draft_id: draftId,
|
|
53582
53596
|
text,
|
|
@@ -54075,7 +54089,7 @@ function findLatestEndedTurnForChat(chatId) {
|
|
|
54075
54089
|
return latest;
|
|
54076
54090
|
}
|
|
54077
54091
|
function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTurn, surface) {
|
|
54078
|
-
const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn
|
|
54092
|
+
const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
|
|
54079
54093
|
const threadId = resolveAnswerThreadId({
|
|
54080
54094
|
explicitThreadId,
|
|
54081
54095
|
originResolved: originTurn != null,
|
|
@@ -55260,6 +55274,7 @@ function parsePositiveMsEnv(name, fallbackMs) {
|
|
|
55260
55274
|
var SILENCE_FALLBACK_MS = parsePositiveMsEnv("SWITCHROOM_SILENCE_FALLBACK_MS", 300000);
|
|
55261
55275
|
var SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv("SWITCHROOM_SILENCE_FALLBACK_HARD_MS", 900000);
|
|
55262
55276
|
var SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === "1";
|
|
55277
|
+
var SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== "0";
|
|
55263
55278
|
startTimer({
|
|
55264
55279
|
thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS },
|
|
55265
55280
|
deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
|
|
@@ -55351,8 +55366,11 @@ startTimer({
|
|
|
55351
55366
|
const sib = silenceMsForKey(siblingKey, fbNow);
|
|
55352
55367
|
return sib == null || sib >= DEFAULT_THRESHOLDS.fallback;
|
|
55353
55368
|
});
|
|
55354
|
-
if (turnMatchesFallback && currentTurn === wedgedTurn)
|
|
55369
|
+
if (turnMatchesFallback && currentTurn === wedgedTurn && wedgedTurn != null) {
|
|
55370
|
+
process.stderr.write(`telegram gateway: ${formatTurnLifecycle("clear", "silence_fallback", wedgedTurn, Date.now())}
|
|
55371
|
+
`);
|
|
55355
55372
|
currentTurn = null;
|
|
55373
|
+
}
|
|
55356
55374
|
try {
|
|
55357
55375
|
clearSilentEndState(fbKey);
|
|
55358
55376
|
} catch {}
|
|
@@ -58076,6 +58094,9 @@ function handleSessionEvent(ev) {
|
|
|
58076
58094
|
const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
|
|
58077
58095
|
if (rendered != null) {
|
|
58078
58096
|
turn.lastToolLabelAt = Date.now();
|
|
58097
|
+
if (SILENCE_LIVENESS_PRODUCTION && currentTurn === turn) {
|
|
58098
|
+
noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now());
|
|
58099
|
+
}
|
|
58079
58100
|
turn.activityPendingRender = composeTurnActivity(turn) ?? rendered;
|
|
58080
58101
|
if (turn.activityInFlight == null) {
|
|
58081
58102
|
turn.activityInFlight = drainActivitySummary(turn);
|
|
@@ -58092,7 +58113,7 @@ function handleSessionEvent(ev) {
|
|
|
58092
58113
|
chatId: turn.sessionChatId,
|
|
58093
58114
|
isPrivateChat: turn.isDm,
|
|
58094
58115
|
threadId: turn.sessionThreadId,
|
|
58095
|
-
...ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER },
|
|
58116
|
+
...ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER },
|
|
58096
58117
|
sendMessage: async (chatId, text, params) => {
|
|
58097
58118
|
const tid = params?.message_thread_id;
|
|
58098
58119
|
const silent = params?.purpose !== "materialize";
|
|
@@ -58130,6 +58151,9 @@ function handleSessionEvent(ev) {
|
|
|
58130
58151
|
logStreamingEvent(metricEv);
|
|
58131
58152
|
if (currentTurn === turn) {
|
|
58132
58153
|
noteSignal(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now());
|
|
58154
|
+
if (SILENCE_LIVENESS_PRODUCTION) {
|
|
58155
|
+
noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now());
|
|
58156
|
+
}
|
|
58133
58157
|
}
|
|
58134
58158
|
},
|
|
58135
58159
|
checkDedup: (text) => {
|
|
@@ -58221,7 +58245,7 @@ function handleSessionEvent(ev) {
|
|
|
58221
58245
|
const stream = turn.answerStream;
|
|
58222
58246
|
const streamedMsgId = stream.messageId();
|
|
58223
58247
|
const streamedFinalText = turn.capturedText.join("").trim();
|
|
58224
|
-
if (ANSWER_STREAM_VISIBLE_ENABLED && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
|
|
58248
|
+
if ((ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED) && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
|
|
58225
58249
|
turn.answerStream = null;
|
|
58226
58250
|
streamFinalizedAsAnswer = true;
|
|
58227
58251
|
turn.finalAnswerDelivered = true;
|
|
@@ -64557,7 +64581,7 @@ var didOneTimeSetup = false;
|
|
|
64557
64581
|
}
|
|
64558
64582
|
}
|
|
64559
64583
|
}
|
|
64560
|
-
process.stderr.write(`telegram gateway: answer-stream draft
|
|
64584
|
+
process.stderr.write(`telegram gateway: answer-stream lane=${DRAFT_ANSWER_LANE_RETIRED ? "visible(draft-retired)" : ANSWER_STREAM_VISIBLE_ENABLED ? "visible" : "draft"} draftFn=${sendMessageDraftFn != null ? "available" : "off"} grammy=${GRAMMY_VERSION}
|
|
64561
64585
|
`);
|
|
64562
64586
|
process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? "-"} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? "checklist"}
|
|
64563
64587
|
`);
|
|
@@ -98,7 +98,7 @@ import * as pendingProgress from '../pending-work-progress.js'
|
|
|
98
98
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
99
99
|
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
100
100
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
101
|
-
import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
|
|
101
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
102
102
|
import { type SessionEvent } from '../session-tail.js'
|
|
103
103
|
import {
|
|
104
104
|
shouldSuppressToolActivity,
|
|
@@ -678,6 +678,14 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
|
|
|
678
678
|
const bot = new Bot(TOKEN)
|
|
679
679
|
installTgPostLogger(bot)
|
|
680
680
|
|
|
681
|
+
// Draft-answer-lane retirement (2026-06-05): default RETIRED so the live answer
|
|
682
|
+
// lane uses a real, mtcute-observable message instead of the invisible
|
|
683
|
+
// compose-box draft. Declared HERE (above the boot-probe block) because
|
|
684
|
+
// `sendMessageDraftFn` below reads it — keep it above its first use to avoid a
|
|
685
|
+
// temporal-dead-zone ReferenceError at boot. Kill switch
|
|
686
|
+
// SWITCHROOM_DRAFT_ANSWER_LANE=0 restores the legacy draft.
|
|
687
|
+
const DRAFT_ANSWER_LANE_RETIRED = parseDraftLaneRetiredEnabled(process.env.SWITCHROOM_DRAFT_ANSWER_LANE)
|
|
688
|
+
|
|
681
689
|
// ─── sendMessageDraft boot probe ──────────────────────────────────────────
|
|
682
690
|
// grammY 1.x exposes all Telegram Bot API methods through bot.api.raw.
|
|
683
691
|
// bot.api.sendMessageDraft (the typed wrapper) takes chat_id as number, but
|
|
@@ -695,7 +703,11 @@ const GRAMMY_VERSION: string = (() => {
|
|
|
695
703
|
const sendMessageDraftFn: (
|
|
696
704
|
(chatId: string, draftId: number, text: string, params?: { message_thread_id?: number; parse_mode?: 'HTML' }) => Promise<unknown>
|
|
697
705
|
) | undefined =
|
|
698
|
-
|
|
706
|
+
// When the draft lane is retired (default), force this undefined so BOTH
|
|
707
|
+
// consumers (the answer-stream config + the stream_reply handler) drop the
|
|
708
|
+
// draft transport and fall back to visible message transport — the single
|
|
709
|
+
// chokepoint for the retirement.
|
|
710
|
+
!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'
|
|
699
711
|
? (chatId, draftId, text, params) =>
|
|
700
712
|
(_rawSendMessageDraft as (args: Record<string, unknown>) => Promise<unknown>)({
|
|
701
713
|
chat_id: Number(chatId),
|
|
@@ -1930,11 +1942,17 @@ function resolveAnswerThreadWithLog(
|
|
|
1930
1942
|
liveTurn: CurrentTurn | null,
|
|
1931
1943
|
surface: 'reply' | 'stream_reply',
|
|
1932
1944
|
): number | undefined {
|
|
1945
|
+
// Recover ONLY for a genuinely LATE reply — no live turn at all. Gating on
|
|
1946
|
+
// `liveTurn?.sessionThreadId == null` (the original) also fired for a
|
|
1947
|
+
// threadless DM that still had a live turn, marking every DM reply
|
|
1948
|
+
// `via=recovered`/RECOVERED in the telemetry (routing result unchanged —
|
|
1949
|
+
// DM → undefined — but it drowned the real supergroup recoveries the marker
|
|
1950
|
+
// exists to surface). `liveTurn == null` is the precise late-reply condition.
|
|
1933
1951
|
const recovered =
|
|
1934
1952
|
LATE_REPLY_TOPIC_RECOVERY_ENABLED &&
|
|
1935
1953
|
explicitThreadId == null &&
|
|
1936
1954
|
originTurn == null &&
|
|
1937
|
-
liveTurn
|
|
1955
|
+
liveTurn == null
|
|
1938
1956
|
? findLatestEndedTurnForChat(chatId)
|
|
1939
1957
|
: null
|
|
1940
1958
|
const threadId = resolveAnswerThreadId({
|
|
@@ -4673,6 +4691,12 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
|
|
|
4673
4691
|
const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
|
|
4674
4692
|
const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
|
|
4675
4693
|
const SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '1'
|
|
4694
|
+
// Production-liveness (2026-06-05 UAT finding). Count an activity-feed render or
|
|
4695
|
+
// an answer-stream draft update as liveness for the silence clock, so a long
|
|
4696
|
+
// tool/composition turn that's visibly producing doesn't trip the 300s fallback
|
|
4697
|
+
// and null currentTurn mid-work. Default ON; SWITCHROOM_SILENCE_LIVENESS_PRODUCTION=0
|
|
4698
|
+
// restores the legacy "only a real reply resets the clock" behaviour.
|
|
4699
|
+
const SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== '0'
|
|
4676
4700
|
|
|
4677
4701
|
silencePoke.startTimer({
|
|
4678
4702
|
thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS },
|
|
@@ -4889,7 +4913,16 @@ silencePoke.startTimer({
|
|
|
4889
4913
|
// returns null and the regular teardown short-circuits. Without
|
|
4890
4914
|
// this, the late event would re-emit `turn_ended` AND clobber
|
|
4891
4915
|
// whatever fresh turn the next inbound started.
|
|
4892
|
-
if (turnMatchesFallback && currentTurn === wedgedTurn
|
|
4916
|
+
if (turnMatchesFallback && currentTurn === wedgedTurn && wedgedTurn != null) {
|
|
4917
|
+
// Status-surface observability: emit the lifecycle CLEAR for the
|
|
4918
|
+
// silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
|
|
4919
|
+
// line like every other clear path (the framework-fallback line below is
|
|
4920
|
+
// its own format — this makes the dark-out greppable in the same shape).
|
|
4921
|
+
process.stderr.write(
|
|
4922
|
+
`telegram gateway: ${formatTurnLifecycle('clear', 'silence_fallback', wedgedTurn, Date.now())}\n`,
|
|
4923
|
+
)
|
|
4924
|
+
currentTurn = null
|
|
4925
|
+
}
|
|
4893
4926
|
// Best-effort: clear any pending silent-end marker so the Stop hook
|
|
4894
4927
|
// doesn't double-block when claude eventually exits the wedged turn.
|
|
4895
4928
|
try {
|
|
@@ -9452,6 +9485,16 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9452
9485
|
// the " · Ns" elapsed restarts from this step (and the feed itself just
|
|
9453
9486
|
// advanced, so it isn't stale).
|
|
9454
9487
|
turn.lastToolLabelAt = Date.now()
|
|
9488
|
+
// Production-liveness: a NEW model-driven activity label is genuine
|
|
9489
|
+
// liveness (the model emitted a new step), so reset the silence-poke
|
|
9490
|
+
// clock — this is the safe site, NOT drainActivitySummary, because the
|
|
9491
|
+
// framework feedHeartbeatTick also drains (climbing-elapsed re-renders)
|
|
9492
|
+
// and would falsely reset the clock forever on a hung-mid-tool turn,
|
|
9493
|
+
// reintroducing the #1556 dangling-turn wedge. Only the model emitting a
|
|
9494
|
+
// fresh label reaches here.
|
|
9495
|
+
if (SILENCE_LIVENESS_PRODUCTION && currentTurn === turn) {
|
|
9496
|
+
silencePoke.noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now())
|
|
9497
|
+
}
|
|
9455
9498
|
// Recompose so any active foreground sub-agent's nested block (Model A)
|
|
9456
9499
|
// is preserved when the parent appends its own step. composeTurnActivity
|
|
9457
9500
|
// == the flat render when no foreground sub-agent is active.
|
|
@@ -9514,7 +9557,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9514
9557
|
// General). With the gate unreachable the only posted message is
|
|
9515
9558
|
// the canonical reply. (The gate is bypassed for DM draft
|
|
9516
9559
|
// transport, so DM draft streaming is unaffected.)
|
|
9517
|
-
|
|
9560
|
+
// Draft retired (default) OR visible explicitly on → a real
|
|
9561
|
+
// edit-in-place message (minInitialChars:1, no draft): observable by
|
|
9562
|
+
// the UAT and the onMetric silence-liveness reset fires on visible
|
|
9563
|
+
// sends in DMs AND supergroups. Legacy draft only when the kill
|
|
9564
|
+
// switch re-enables it (DRAFT_ANSWER_LANE_RETIRED=false), which also
|
|
9565
|
+
// restores sendMessageDraftFn above.
|
|
9566
|
+
...(ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED
|
|
9518
9567
|
? { minInitialChars: 1 }
|
|
9519
9568
|
: { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER }),
|
|
9520
9569
|
// #1075: route through robustApiCall so flood-wait,
|
|
@@ -9612,6 +9661,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9612
9661
|
statusKey(turn.sessionChatId, turn.sessionThreadId),
|
|
9613
9662
|
Date.now(),
|
|
9614
9663
|
)
|
|
9664
|
+
// Production-liveness: a draft update is the agent visibly
|
|
9665
|
+
// composing — reset the silence-poke clock so a long
|
|
9666
|
+
// compose-only turn (no tools, no reply yet) isn't torn down.
|
|
9667
|
+
if (SILENCE_LIVENESS_PRODUCTION) {
|
|
9668
|
+
silencePoke.noteProduction(
|
|
9669
|
+
statusKey(turn.sessionChatId, turn.sessionThreadId),
|
|
9670
|
+
Date.now(),
|
|
9671
|
+
)
|
|
9672
|
+
}
|
|
9615
9673
|
}
|
|
9616
9674
|
},
|
|
9617
9675
|
// #646 — wire the shared outboundDedup into the answer-stream
|
|
@@ -9795,7 +9853,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9795
9853
|
const streamedMsgId = stream.messageId()
|
|
9796
9854
|
const streamedFinalText = turn.capturedText.join('').trim()
|
|
9797
9855
|
if (
|
|
9798
|
-
|
|
9856
|
+
// Broadened for draft retirement: a text-only no-reply turn that
|
|
9857
|
+
// streamed a VISIBLE preview must materialize a pinged final answer +
|
|
9858
|
+
// delete the preview. Without this, the retired-default path would
|
|
9859
|
+
// fall into the else-branch retract() and delete the user's only copy
|
|
9860
|
+
// of the answer (a lost-answer bug). The reply-tool branch still hits
|
|
9861
|
+
// retract() → single canonical formatted reply, no flash.
|
|
9862
|
+
(ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED)
|
|
9799
9863
|
&& !turn.replyCalled
|
|
9800
9864
|
&& streamedMsgId != null
|
|
9801
9865
|
&& streamedFinalText.length > 0
|
|
@@ -20525,7 +20589,7 @@ void (async () => {
|
|
|
20525
20589
|
}
|
|
20526
20590
|
}
|
|
20527
20591
|
|
|
20528
|
-
process.stderr.write(`telegram gateway: answer-stream draft
|
|
20592
|
+
process.stderr.write(`telegram gateway: answer-stream lane=${DRAFT_ANSWER_LANE_RETIRED ? 'visible(draft-retired)' : (ANSWER_STREAM_VISIBLE_ENABLED ? 'visible' : 'draft')} draftFn=${sendMessageDraftFn != null ? 'available' : 'off'} grammy=${GRAMMY_VERSION}\n`)
|
|
20529
20593
|
process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? 'checklist'}\n`)
|
|
20530
20594
|
runnerHandle = run(bot, {
|
|
20531
20595
|
runner: {
|
|
@@ -196,6 +196,31 @@ export function noteOutbound(key: string, now: number): void {
|
|
|
196
196
|
s.fallbackFired = false
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Record observable PRODUCTION that isn't a final reply — an activity-feed
|
|
201
|
+
* render (`→/✓` edit-in-place message) or an answer-stream draft update. Resets
|
|
202
|
+
* the silence clock exactly like a reply.
|
|
203
|
+
*
|
|
204
|
+
* Why this exists (2026-06-05): the header's "only a real reply counts; tool
|
|
205
|
+
* churn / the model ripping through 20 tool calls is still SILENT to the user"
|
|
206
|
+
* rule predates the live activity feed (#2162) and the compose draft. Those
|
|
207
|
+
* surfaces ARE user-visible now, so a turn actively rendering them is NOT
|
|
208
|
+
* silent — yet the 300s fallback (which nulls `currentTurn` and kills the very
|
|
209
|
+
* feed/draft the user is watching) still fired on a long tool/composition turn,
|
|
210
|
+
* darkening the live status mid-work. Counting production as liveness makes the
|
|
211
|
+
* fallback fire only on GENUINE silence (no reply, no feed, no draft, no tool
|
|
212
|
+
* events for the window) — a real wedge. A wedged agent produces nothing
|
|
213
|
+
* observable, so its clock is never reset and it still recovers.
|
|
214
|
+
*
|
|
215
|
+
* No-op when the kill switch is on or the key has no turn.
|
|
216
|
+
*/
|
|
217
|
+
export function noteProduction(key: string, now: number): void {
|
|
218
|
+
const s = state.get(key)
|
|
219
|
+
if (s == null) return
|
|
220
|
+
s.lastOutboundAt = now
|
|
221
|
+
s.fallbackFired = false
|
|
222
|
+
}
|
|
223
|
+
|
|
199
224
|
/**
|
|
200
225
|
* Record a `thinking` session event. Used to pick "still thinking…" vs
|
|
201
226
|
* "still working…" wording for the 300s framework fallback.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect } from 'vitest'
|
|
9
|
-
import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
|
|
9
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
10
10
|
|
|
11
11
|
describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
12
12
|
it('defaults OFF when unset', () => {
|
|
@@ -25,3 +25,21 @@ describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
|
25
25
|
}
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-switch off', () => {
|
|
30
|
+
it('defaults to RETIRED (true) when unset — the draft lane is gone by default', () => {
|
|
31
|
+
expect(parseDraftLaneRetiredEnabled(undefined)).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('stays RETIRED for any non-disable value (including unrecognized)', () => {
|
|
35
|
+
for (const v of ['1', 'true', 'on', 'yes', '', ' ', 'whatever', 'retired']) {
|
|
36
|
+
expect(parseDraftLaneRetiredEnabled(v)).toBe(true)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('restores the legacy draft (false) ONLY on an explicit disable (case/space-insensitive)', () => {
|
|
41
|
+
for (const v of ['0', 'false', 'off', 'no', ' FALSE ', 'Off', 'NO']) {
|
|
42
|
+
expect(parseDraftLaneRetiredEnabled(v)).toBe(false)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draft-answer-lane retirement — gateway wiring guards (2026-06-05).
|
|
3
|
+
*
|
|
4
|
+
* The retirement switches the live answer lane from the invisible compose-box
|
|
5
|
+
* draft to a real, mtcute-observable edit-in-place message, default-on. The
|
|
6
|
+
* design review flagged two ways this silently breaks (gateway IIFE can't be
|
|
7
|
+
* instantiated in-process, so these are source-level assertions, same pattern as
|
|
8
|
+
* silence-liveness-wiring.test):
|
|
9
|
+
*
|
|
10
|
+
* PRIMARY: drop sendMessageDraftFn but FORGET to flip minInitialChars to 1 →
|
|
11
|
+
* the lane becomes a total no-op (the MAX gate never opens it), losing ALL
|
|
12
|
+
* answer-lane status AND the #2169 onMetric silence-liveness reset.
|
|
13
|
+
* SECONDARY: flip the lane to visible but FORGET to broaden the
|
|
14
|
+
* materialize-as-answer guard → a text-only no-reply turn falls into retract()
|
|
15
|
+
* and deletes the user's only copy of the answer (a lost-answer bug).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
22
|
+
|
|
23
|
+
describe('draft-retirement wiring', () => {
|
|
24
|
+
it('sendMessageDraftFn is gated on the retirement (the single chokepoint)', () => {
|
|
25
|
+
expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('DRAFT_ANSWER_LANE_RETIRED is declared before its first use (no TDZ at boot)', () => {
|
|
29
|
+
const declIdx = gatewaySrc.indexOf('const DRAFT_ANSWER_LANE_RETIRED =')
|
|
30
|
+
const firstUseIdx = gatewaySrc.indexOf('!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft')
|
|
31
|
+
expect(declIdx).toBeGreaterThan(0)
|
|
32
|
+
expect(firstUseIdx).toBeGreaterThan(declIdx)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('PRIMARY GUARD: retired lane uses minInitialChars:1 (visible), never the MAX no-op gate', () => {
|
|
36
|
+
// The config must pick the {minInitialChars:1} branch when retired, so the
|
|
37
|
+
// lane actually opens a real message. The MAX branch is draft-only (legacy).
|
|
38
|
+
expect(gatewaySrc).toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\s*\n?\s*\?\s*\{ minInitialChars: 1 \}/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('SECONDARY GUARD: the materialize-as-answer guard is broadened in lockstep', () => {
|
|
42
|
+
// A text-only no-reply turn must materialize (ping + delete preview), not
|
|
43
|
+
// retract() the answer away.
|
|
44
|
+
expect(gatewaySrc).toMatch(/\(ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\)\s*\n?\s*&& !turn\.replyCalled/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('the #2169 onMetric silence-liveness reset is preserved (fires on visible sends now)', () => {
|
|
48
|
+
const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
|
|
49
|
+
expect(onMetric).toMatch(/silencePoke\.noteProduction/)
|
|
50
|
+
expect(onMetric).toMatch(/currentTurn === turn/)
|
|
51
|
+
})
|
|
52
|
+
})
|