switchroom 0.14.40 → 0.14.42
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 +107 -29
- package/telegram-plugin/gateway/gateway.ts +134 -24
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +69 -5
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +71 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +8 -1
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.42";
|
|
49466
|
+
var COMMIT_SHA = "6da4313d";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -44777,6 +44777,12 @@ function resolveOutboundTopic(config, event) {
|
|
|
44777
44777
|
return aliasToId(cfg, ADMIN_ALIAS) ?? cfg.default_topic_id;
|
|
44778
44778
|
}
|
|
44779
44779
|
}
|
|
44780
|
+
function topicForRecipient(args) {
|
|
44781
|
+
const { recipientChatId, resolvedTopic, supergroupChatId } = args;
|
|
44782
|
+
if (resolvedTopic == null || supergroupChatId == null)
|
|
44783
|
+
return;
|
|
44784
|
+
return String(recipientChatId) === String(supergroupChatId) ? resolvedTopic : undefined;
|
|
44785
|
+
}
|
|
44780
44786
|
|
|
44781
44787
|
// ../src/agents/perf.ts
|
|
44782
44788
|
import { existsSync as existsSync18, readFileSync as readFileSync14 } from "node:fs";
|
|
@@ -47263,11 +47269,17 @@ function decideInboundDelivery(input) {
|
|
|
47263
47269
|
function createDeliveryQueue() {
|
|
47264
47270
|
return { pending: new Map };
|
|
47265
47271
|
}
|
|
47266
|
-
function trackDelivery(q, key, inbound, now) {
|
|
47267
|
-
q.pending.set(key, { key, inbound, lastAttemptAt: now });
|
|
47272
|
+
function trackDelivery(q, key, inbound, now, messageId = null) {
|
|
47273
|
+
q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now });
|
|
47268
47274
|
}
|
|
47269
|
-
function ackDelivery(q, key) {
|
|
47270
|
-
|
|
47275
|
+
function ackDelivery(q, key, enqueueMessageId = null) {
|
|
47276
|
+
const entry = q.pending.get(key);
|
|
47277
|
+
if (!entry)
|
|
47278
|
+
return false;
|
|
47279
|
+
if (entry.messageId != null && entry.messageId !== enqueueMessageId)
|
|
47280
|
+
return false;
|
|
47281
|
+
q.pending.delete(key);
|
|
47282
|
+
return true;
|
|
47271
47283
|
}
|
|
47272
47284
|
function sweep(q, now, timeoutMs) {
|
|
47273
47285
|
const redeliver = [];
|
|
@@ -47282,6 +47294,15 @@ function sweep(q, now, timeoutMs) {
|
|
|
47282
47294
|
function forgetDelivery(q, key) {
|
|
47283
47295
|
q.pending.delete(key);
|
|
47284
47296
|
}
|
|
47297
|
+
function shouldTrackDelivery(input) {
|
|
47298
|
+
if (input.isSteering || input.isInterrupt)
|
|
47299
|
+
return false;
|
|
47300
|
+
if (input.hasSource)
|
|
47301
|
+
return false;
|
|
47302
|
+
if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0)
|
|
47303
|
+
return false;
|
|
47304
|
+
return true;
|
|
47305
|
+
}
|
|
47285
47306
|
|
|
47286
47307
|
// gateway/pending-permission-decisions.ts
|
|
47287
47308
|
var DEFAULT_PENDING_PERMISSION_CAP = 32;
|
|
@@ -51850,10 +51871,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51850
51871
|
}
|
|
51851
51872
|
|
|
51852
51873
|
// ../src/build-info.ts
|
|
51853
|
-
var VERSION = "0.14.
|
|
51854
|
-
var COMMIT_SHA = "
|
|
51855
|
-
var COMMIT_DATE = "2026-06-
|
|
51856
|
-
var LATEST_PR =
|
|
51874
|
+
var VERSION = "0.14.42";
|
|
51875
|
+
var COMMIT_SHA = "6da4313d";
|
|
51876
|
+
var COMMIT_DATE = "2026-06-02T22:14:58Z";
|
|
51877
|
+
var LATEST_PR = 2097;
|
|
51857
51878
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51858
51879
|
|
|
51859
51880
|
// gateway/boot-version.ts
|
|
@@ -53028,7 +53049,9 @@ function markClaudeBusyForInbound(m) {
|
|
|
53028
53049
|
return key;
|
|
53029
53050
|
}
|
|
53030
53051
|
var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
|
|
53031
|
-
var
|
|
53052
|
+
var _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS;
|
|
53053
|
+
var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== "" ? Number(_deliveryTimeoutRaw) : 15000;
|
|
53054
|
+
var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
|
|
53032
53055
|
var DELIVERY_CONFIRM_SWEEP_MS = 5000;
|
|
53033
53056
|
var deliveryQueue = createDeliveryQueue();
|
|
53034
53057
|
function turnInFlightForGate() {
|
|
@@ -53344,14 +53367,18 @@ function postPermissionResumeMessage(opts) {
|
|
|
53344
53367
|
timeoutMinutes: opts.timeoutMinutes
|
|
53345
53368
|
});
|
|
53346
53369
|
const turn = currentTurn;
|
|
53347
|
-
const targets = turn != null ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }] :
|
|
53348
|
-
|
|
53349
|
-
|
|
53370
|
+
const targets = turn != null ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }] : (() => {
|
|
53371
|
+
const sg = resolveAgentSupergroupChatId();
|
|
53372
|
+
const topic = resolveAgentOutboundTopic({
|
|
53350
53373
|
kind: "permission",
|
|
53351
53374
|
turnInitiated: false,
|
|
53352
53375
|
originThreadId: undefined
|
|
53353
|
-
})
|
|
53354
|
-
|
|
53376
|
+
});
|
|
53377
|
+
return loadAccess().allowFrom.map((chatId) => ({
|
|
53378
|
+
chatId,
|
|
53379
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
|
|
53380
|
+
}));
|
|
53381
|
+
})();
|
|
53355
53382
|
for (const { chatId, threadId } of targets) {
|
|
53356
53383
|
swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
|
|
53357
53384
|
parse_mode: "HTML",
|
|
@@ -54160,7 +54187,12 @@ async function redeliverStrandedInbound(p) {
|
|
|
54160
54187
|
clearAgentComposer2({ agentName: selfAgent });
|
|
54161
54188
|
} catch {}
|
|
54162
54189
|
const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
|
|
54163
|
-
if (
|
|
54190
|
+
if (ok) {
|
|
54191
|
+
markClaudeBusyForInbound(p.inbound);
|
|
54192
|
+
if (!deliveryQueue.pending.has(p.key)) {
|
|
54193
|
+
trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId);
|
|
54194
|
+
}
|
|
54195
|
+
} else {
|
|
54164
54196
|
pendingInboundBuffer.push(selfAgent, p.inbound);
|
|
54165
54197
|
forgetDelivery(deliveryQueue, p.key);
|
|
54166
54198
|
}
|
|
@@ -54168,6 +54200,10 @@ async function redeliverStrandedInbound(p) {
|
|
|
54168
54200
|
var _deliveryConfirmSweep = setInterval(() => {
|
|
54169
54201
|
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54170
54202
|
return;
|
|
54203
|
+
if (currentTurn != null)
|
|
54204
|
+
return;
|
|
54205
|
+
if (pendingPermissions.size > 0 || pendingAskUser.size > 0)
|
|
54206
|
+
return;
|
|
54171
54207
|
for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
54172
54208
|
redeliverStrandedInbound(p);
|
|
54173
54209
|
}
|
|
@@ -54453,11 +54489,13 @@ var ipcServer = createIpcServer({
|
|
|
54453
54489
|
turnInitiated: activeTurn != null,
|
|
54454
54490
|
originThreadId: activeTurn?.sessionThreadId
|
|
54455
54491
|
});
|
|
54492
|
+
const permSupergroup = resolveAgentSupergroupChatId();
|
|
54456
54493
|
for (const chat_id of access.allowFrom) {
|
|
54494
|
+
const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup });
|
|
54457
54495
|
bot.api.sendMessage(chat_id, text, {
|
|
54458
54496
|
parse_mode: "HTML",
|
|
54459
54497
|
reply_markup: keyboard,
|
|
54460
|
-
...
|
|
54498
|
+
...permThread != null ? { message_thread_id: permThread } : {}
|
|
54461
54499
|
}).catch((e) => {
|
|
54462
54500
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}
|
|
54463
54501
|
`);
|
|
@@ -54521,9 +54559,13 @@ var ipcServer = createIpcServer({
|
|
|
54521
54559
|
if (operator === undefined)
|
|
54522
54560
|
return null;
|
|
54523
54561
|
const activeTurn = currentTurn;
|
|
54524
|
-
const driveTopic =
|
|
54525
|
-
|
|
54526
|
-
|
|
54562
|
+
const driveTopic = topicForRecipient({
|
|
54563
|
+
recipientChatId: operator,
|
|
54564
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
54565
|
+
kind: "hostd-approval",
|
|
54566
|
+
originThreadId: activeTurn?.sessionThreadId
|
|
54567
|
+
}),
|
|
54568
|
+
supergroupChatId: resolveAgentSupergroupChatId()
|
|
54527
54569
|
});
|
|
54528
54570
|
return {
|
|
54529
54571
|
chatId: operator,
|
|
@@ -54579,9 +54621,13 @@ var ipcServer = createIpcServer({
|
|
|
54579
54621
|
if (operator === undefined)
|
|
54580
54622
|
return null;
|
|
54581
54623
|
const activeTurn = currentTurn;
|
|
54582
|
-
const ms365Topic =
|
|
54583
|
-
|
|
54584
|
-
|
|
54624
|
+
const ms365Topic = topicForRecipient({
|
|
54625
|
+
recipientChatId: operator,
|
|
54626
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
54627
|
+
kind: "hostd-approval",
|
|
54628
|
+
originThreadId: activeTurn?.sessionThreadId
|
|
54629
|
+
}),
|
|
54630
|
+
supergroupChatId: resolveAgentSupergroupChatId()
|
|
54585
54631
|
});
|
|
54586
54632
|
return {
|
|
54587
54633
|
chatId: operator,
|
|
@@ -54636,9 +54682,13 @@ var ipcServer = createIpcServer({
|
|
|
54636
54682
|
if (operator === undefined)
|
|
54637
54683
|
return null;
|
|
54638
54684
|
const activeTurn = currentTurn;
|
|
54639
|
-
const cfgTopic =
|
|
54640
|
-
|
|
54641
|
-
|
|
54685
|
+
const cfgTopic = topicForRecipient({
|
|
54686
|
+
recipientChatId: operator,
|
|
54687
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
54688
|
+
kind: "hostd-approval",
|
|
54689
|
+
originThreadId: activeTurn?.sessionThreadId
|
|
54690
|
+
}),
|
|
54691
|
+
supergroupChatId: resolveAgentSupergroupChatId()
|
|
54642
54692
|
});
|
|
54643
54693
|
return {
|
|
54644
54694
|
chatId: operator,
|
|
@@ -56564,7 +56614,7 @@ function handleSessionEvent(ev) {
|
|
|
56564
56614
|
};
|
|
56565
56615
|
currentTurn = next;
|
|
56566
56616
|
if (DELIVERY_CONFIRM_ENABLED) {
|
|
56567
|
-
ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
|
|
56617
|
+
ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
|
|
56568
56618
|
}
|
|
56569
56619
|
shadowEmit({
|
|
56570
56620
|
kind: "turnStart",
|
|
@@ -58000,12 +58050,24 @@ ${preBlock(write.output)}`;
|
|
|
58000
58050
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
|
|
58001
58051
|
if (delivered) {
|
|
58002
58052
|
const busyKey = markClaudeBusyForInbound(inboundMsg);
|
|
58003
|
-
if (DELIVERY_CONFIRM_ENABLED
|
|
58004
|
-
|
|
58053
|
+
if (DELIVERY_CONFIRM_ENABLED && shouldTrackDelivery({
|
|
58054
|
+
isSteering,
|
|
58055
|
+
isInterrupt: interrupt.isInterrupt,
|
|
58056
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
58057
|
+
effectiveText
|
|
58058
|
+
})) {
|
|
58059
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId));
|
|
58005
58060
|
}
|
|
58006
58061
|
}
|
|
58007
58062
|
if (!delivered) {
|
|
58008
|
-
|
|
58063
|
+
if (shouldTrackDelivery({
|
|
58064
|
+
isSteering,
|
|
58065
|
+
isInterrupt: interrupt.isInterrupt,
|
|
58066
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
58067
|
+
effectiveText
|
|
58068
|
+
})) {
|
|
58069
|
+
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
58070
|
+
}
|
|
58009
58071
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
|
|
58010
58072
|
swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u23F3 Agent is restarting \u2014 your message is queued and will be processed when it reconnects.", { ...threadOpts }), {
|
|
58011
58073
|
chat_id,
|
|
@@ -58191,6 +58253,22 @@ function resolveAgentOutboundTopic(event) {
|
|
|
58191
58253
|
return;
|
|
58192
58254
|
}
|
|
58193
58255
|
}
|
|
58256
|
+
function resolveAgentSupergroupChatId() {
|
|
58257
|
+
const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
|
|
58258
|
+
if (!agentName3)
|
|
58259
|
+
return;
|
|
58260
|
+
try {
|
|
58261
|
+
const cfg = loadConfig2();
|
|
58262
|
+
const rawAgent = cfg.agents?.[agentName3];
|
|
58263
|
+
if (!rawAgent)
|
|
58264
|
+
return;
|
|
58265
|
+
const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
|
|
58266
|
+
const tg = resolved.channels?.telegram;
|
|
58267
|
+
return tg?.chat_id != null ? String(tg.chat_id) : undefined;
|
|
58268
|
+
} catch {
|
|
58269
|
+
return;
|
|
58270
|
+
}
|
|
58271
|
+
}
|
|
58194
58272
|
function stampUserRestartReason(reason) {
|
|
58195
58273
|
try {
|
|
58196
58274
|
writeCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH, {
|
|
@@ -251,7 +251,7 @@ import { handleInjectCommand } from './inject-handler.js'
|
|
|
251
251
|
import { type BannerState } from '../slot-banner.js'
|
|
252
252
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
253
253
|
import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
254
|
-
import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
|
|
254
|
+
import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
|
|
255
255
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
256
256
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
257
257
|
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
@@ -286,6 +286,7 @@ import {
|
|
|
286
286
|
ackDelivery,
|
|
287
287
|
sweep as sweepDeliveryQueue,
|
|
288
288
|
forgetDelivery,
|
|
289
|
+
shouldTrackDelivery,
|
|
289
290
|
type PendingDelivery,
|
|
290
291
|
} from './inbound-delivery-confirm.js'
|
|
291
292
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
@@ -1342,7 +1343,15 @@ const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM
|
|
|
1342
1343
|
// a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
|
|
1343
1344
|
// (env) for tests/forensics; a too-low value re-delivers healthy slow turns
|
|
1344
1345
|
// (duplicate turn), which is why the default is comfortably above ack latency.
|
|
1345
|
-
const
|
|
1346
|
+
const _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS
|
|
1347
|
+
const _deliveryTimeoutParsed =
|
|
1348
|
+
_deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== '' ? Number(_deliveryTimeoutRaw) : 15_000
|
|
1349
|
+
// Clamp to a positive, finite value: a negative / zero / NaN env override would
|
|
1350
|
+
// make the sweep treat every tracked entry as stranded and re-deliver every
|
|
1351
|
+
// cycle forever (a self-inflicted re-delivery loop). To disable the feature,
|
|
1352
|
+
// use SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0, not a degenerate timeout.
|
|
1353
|
+
const DELIVERY_CONFIRM_TIMEOUT_MS =
|
|
1354
|
+
Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15_000
|
|
1346
1355
|
const DELIVERY_CONFIRM_SWEEP_MS = 5_000
|
|
1347
1356
|
const deliveryQueue = createDeliveryQueue<InboundMessage>()
|
|
1348
1357
|
|
|
@@ -2264,14 +2273,20 @@ function postPermissionResumeMessage(opts: {
|
|
|
2264
2273
|
const targets: Array<{ chatId: string; threadId: number | undefined }> =
|
|
2265
2274
|
turn != null
|
|
2266
2275
|
? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
|
|
2267
|
-
:
|
|
2268
|
-
|
|
2269
|
-
|
|
2276
|
+
: (() => {
|
|
2277
|
+
const sg = resolveAgentSupergroupChatId()
|
|
2278
|
+
const topic = resolveAgentOutboundTopic({
|
|
2270
2279
|
kind: 'permission',
|
|
2271
2280
|
turnInitiated: false,
|
|
2272
2281
|
originThreadId: undefined,
|
|
2273
|
-
})
|
|
2274
|
-
|
|
2282
|
+
})
|
|
2283
|
+
// allowFrom is normally operator DMs — attach the topic only to a
|
|
2284
|
+
// recipient that owns it (the supergroup), never a DM (marko wedge).
|
|
2285
|
+
return loadAccess().allowFrom.map(chatId => ({
|
|
2286
|
+
chatId,
|
|
2287
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
|
|
2288
|
+
}))
|
|
2289
|
+
})()
|
|
2275
2290
|
for (const { chatId, threadId } of targets) {
|
|
2276
2291
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
|
|
2277
2292
|
void swallowingApiCall(
|
|
@@ -4106,7 +4121,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
|
|
|
4106
4121
|
if (selfAgent) clearAgentComposer({ agentName: selfAgent })
|
|
4107
4122
|
} catch { /* best-effort; re-deliver regardless */ }
|
|
4108
4123
|
const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
|
|
4109
|
-
if (
|
|
4124
|
+
if (ok) {
|
|
4125
|
+
// Keep the #1556 gate coherent with the re-sent delivery, and survive an
|
|
4126
|
+
// ack that raced the `await import` above: only `enqueue` clears tracking,
|
|
4127
|
+
// so if a concurrent ack removed the entry, re-affirm it — never drop.
|
|
4128
|
+
// Both ops are idempotent.
|
|
4129
|
+
markClaudeBusyForInbound(p.inbound)
|
|
4130
|
+
if (!deliveryQueue.pending.has(p.key)) {
|
|
4131
|
+
trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
|
|
4132
|
+
}
|
|
4133
|
+
} else {
|
|
4110
4134
|
// Bridge offline between attempts — hand off to the offline buffer
|
|
4111
4135
|
// (bridgeUp drains it) and stop tracking here; the spool owns it now.
|
|
4112
4136
|
pendingInboundBuffer.push(selfAgent, p.inbound)
|
|
@@ -4115,6 +4139,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
|
|
|
4115
4139
|
}
|
|
4116
4140
|
const _deliveryConfirmSweep = setInterval(() => {
|
|
4117
4141
|
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
4142
|
+
// Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
|
|
4143
|
+
// by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
|
|
4144
|
+
// means a real turn is in flight — re-clearing the composer + re-sending now
|
|
4145
|
+
// would clobber it (the exact mid-turn wedge this queue exists to prevent). A
|
|
4146
|
+
// pending permission / ask_user prompt is likewise a live interaction. Defer:
|
|
4147
|
+
// leave the entry pending (it isn't acked) so the next idle sweep retries.
|
|
4148
|
+
// NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
|
|
4149
|
+
// stays set through a strand, so it is NOT a usable "idle" signal here.
|
|
4150
|
+
if (currentTurn != null) return
|
|
4151
|
+
if (pendingPermissions.size > 0 || pendingAskUser.size > 0) return
|
|
4118
4152
|
for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
4119
4153
|
void redeliverStrandedInbound(p)
|
|
4120
4154
|
}
|
|
@@ -4636,16 +4670,20 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4636
4670
|
turnInitiated: activeTurn != null,
|
|
4637
4671
|
originThreadId: activeTurn?.sessionThreadId,
|
|
4638
4672
|
})
|
|
4673
|
+
const permSupergroup = resolveAgentSupergroupChatId()
|
|
4639
4674
|
for (const chat_id of access.allowFrom) {
|
|
4640
4675
|
// parse_mode=HTML pairs with formatPermissionCardBody (#1790)
|
|
4641
4676
|
// so the <b>/<i> tags render as formatting.
|
|
4642
|
-
//
|
|
4643
|
-
//
|
|
4677
|
+
// The resolved topic is valid only in the agent's supergroup — attach
|
|
4678
|
+
// it ONLY when this recipient IS that supergroup. allowFrom DMs get the
|
|
4679
|
+
// card thread-less; attaching a topic to a DM yields 400 "message thread
|
|
4680
|
+
// not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
|
|
4681
|
+
const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
|
|
4644
4682
|
// allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
|
|
4645
4683
|
void bot.api.sendMessage(chat_id, text, {
|
|
4646
4684
|
parse_mode: 'HTML',
|
|
4647
4685
|
reply_markup: keyboard,
|
|
4648
|
-
...(
|
|
4686
|
+
...(permThread != null ? { message_thread_id: permThread } : {}),
|
|
4649
4687
|
}).catch(e => {
|
|
4650
4688
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
4651
4689
|
})
|
|
@@ -4783,9 +4821,15 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4783
4821
|
// topic. Drive approval cards follow the originating turn
|
|
4784
4822
|
// (operator-initiated tool call), admin alias fallback.
|
|
4785
4823
|
const activeTurn = currentTurn
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4824
|
+
// Attach the topic only when `operator` IS the agent's supergroup —
|
|
4825
|
+
// operator DMs have no topics (marko brevo wedge, 2026-06-02).
|
|
4826
|
+
const driveTopic = topicForRecipient({
|
|
4827
|
+
recipientChatId: operator,
|
|
4828
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
4829
|
+
kind: 'hostd-approval',
|
|
4830
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4831
|
+
}),
|
|
4832
|
+
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
4789
4833
|
})
|
|
4790
4834
|
return {
|
|
4791
4835
|
chatId: operator,
|
|
@@ -4856,9 +4900,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4856
4900
|
// alias fallback for background cases. Same shape as hostd /
|
|
4857
4901
|
// drive approvals below.
|
|
4858
4902
|
const activeTurn = currentTurn
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4903
|
+
// Topic valid only in the agent's supergroup — never on the operator DM.
|
|
4904
|
+
const ms365Topic = topicForRecipient({
|
|
4905
|
+
recipientChatId: operator,
|
|
4906
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
4907
|
+
kind: 'hostd-approval',
|
|
4908
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4909
|
+
}),
|
|
4910
|
+
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
4862
4911
|
})
|
|
4863
4912
|
return {
|
|
4864
4913
|
chatId: operator,
|
|
@@ -4935,9 +4984,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4935
4984
|
// returns undefined for non-supergroup agents → behavior
|
|
4936
4985
|
// unchanged.
|
|
4937
4986
|
const activeTurn = currentTurn
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4987
|
+
// Topic valid only in the agent's supergroup — never on the operator DM.
|
|
4988
|
+
const cfgTopic = topicForRecipient({
|
|
4989
|
+
recipientChatId: operator,
|
|
4990
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
4991
|
+
kind: 'hostd-approval',
|
|
4992
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4993
|
+
}),
|
|
4994
|
+
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
4941
4995
|
})
|
|
4942
4996
|
return {
|
|
4943
4997
|
chatId: operator,
|
|
@@ -7945,9 +7999,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7945
7999
|
// re-delivery. `enqueue` carries the same chat/thread the inbound was
|
|
7946
8000
|
// keyed on, so the key matches.
|
|
7947
8001
|
if (DELIVERY_CONFIRM_ENABLED) {
|
|
8002
|
+
// Match on the source message id: `enqueue` fires for EVERY turn
|
|
8003
|
+
// start (cron / subagent-handback / vault-resume / restart-marker
|
|
8004
|
+
// too — see comment below), so a key-only ack would let a synthetic
|
|
8005
|
+
// turn clear a real user message still waiting under the same key.
|
|
7948
8006
|
ackDelivery(
|
|
7949
8007
|
deliveryQueue,
|
|
7950
8008
|
chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
|
|
8009
|
+
ev.messageId,
|
|
7951
8010
|
)
|
|
7952
8011
|
}
|
|
7953
8012
|
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
@@ -10670,13 +10729,40 @@ async function handleInbound(
|
|
|
10670
10729
|
const busyKey = markClaudeBusyForInbound(inboundMsg)
|
|
10671
10730
|
// Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
|
|
10672
10731
|
// lands, the message stranded in the composer and the sweep re-delivers
|
|
10673
|
-
// it.
|
|
10674
|
-
|
|
10675
|
-
|
|
10732
|
+
// it. Track ONLY messages that produce an `enqueue` to ack against —
|
|
10733
|
+
// shouldTrackDelivery excludes steering / `!` interrupt (amend the running
|
|
10734
|
+
// turn), synthetic (meta.source) inbounds, and empty bodies, all of which
|
|
10735
|
+
// never enqueue and would re-deliver forever. The tracked messageId lets
|
|
10736
|
+
// the ack match only THIS message's enqueue (not a synthetic turn sharing
|
|
10737
|
+
// the key). See shouldTrackDelivery / ackDelivery (inbound-delivery-confirm.ts).
|
|
10738
|
+
if (
|
|
10739
|
+
DELIVERY_CONFIRM_ENABLED &&
|
|
10740
|
+
shouldTrackDelivery({
|
|
10741
|
+
isSteering,
|
|
10742
|
+
isInterrupt: interrupt.isInterrupt,
|
|
10743
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
10744
|
+
effectiveText,
|
|
10745
|
+
})
|
|
10746
|
+
) {
|
|
10747
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
|
|
10676
10748
|
}
|
|
10677
10749
|
}
|
|
10678
10750
|
if (!delivered) {
|
|
10679
|
-
|
|
10751
|
+
// Only persist fresh user turns to the durable spool. Steering / `!`
|
|
10752
|
+
// interrupt / empty bodies are mid-turn amendments or no-ops that would
|
|
10753
|
+
// arrive orphaned if replayed as a fresh turn after a restart — drop them
|
|
10754
|
+
// (the restart notice below tells the user to re-send). Mirrors the
|
|
10755
|
+
// tracking + #1556-gate carve-outs.
|
|
10756
|
+
if (
|
|
10757
|
+
shouldTrackDelivery({
|
|
10758
|
+
isSteering,
|
|
10759
|
+
isInterrupt: interrupt.isInterrupt,
|
|
10760
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
10761
|
+
effectiveText,
|
|
10762
|
+
})
|
|
10763
|
+
) {
|
|
10764
|
+
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
10765
|
+
}
|
|
10680
10766
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
|
|
10681
10767
|
// #1075: thread-id-bearing — swallow via robustApiCall so a deleted
|
|
10682
10768
|
// topic doesn't crash the gateway. Fire-and-forget; the inbound is
|
|
@@ -11007,6 +11093,30 @@ function resolveAgentOutboundTopic(
|
|
|
11007
11093
|
}
|
|
11008
11094
|
}
|
|
11009
11095
|
|
|
11096
|
+
/**
|
|
11097
|
+
* The agent's supergroup chat id (`channels.telegram.chat_id`) when it is in
|
|
11098
|
+
* supergroup-owned mode, else undefined. A forum topic id resolved by
|
|
11099
|
+
* {@link resolveAgentOutboundTopic} is valid ONLY in this chat — used by
|
|
11100
|
+
* {@link topicForRecipient} to decide whether an approval/permission card sent
|
|
11101
|
+
* to a given recipient (operator DMs vs the supergroup itself) may carry a
|
|
11102
|
+
* `message_thread_id`. Attaching a topic to a DM is the marko brevo wedge
|
|
11103
|
+
* (2026-06-02): the card fails with "message thread not found" and auto-denies.
|
|
11104
|
+
*/
|
|
11105
|
+
function resolveAgentSupergroupChatId(): string | undefined {
|
|
11106
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
11107
|
+
if (!agentName) return undefined
|
|
11108
|
+
try {
|
|
11109
|
+
const cfg = loadSwitchroomConfig()
|
|
11110
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
11111
|
+
if (!rawAgent) return undefined
|
|
11112
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
11113
|
+
const tg = resolved.channels?.telegram as { chat_id?: string | number } | undefined
|
|
11114
|
+
return tg?.chat_id != null ? String(tg.chat_id) : undefined
|
|
11115
|
+
} catch {
|
|
11116
|
+
return undefined
|
|
11117
|
+
}
|
|
11118
|
+
}
|
|
11119
|
+
|
|
11010
11120
|
/**
|
|
11011
11121
|
* Stamp a user-facing restart reason into the clean-shutdown marker
|
|
11012
11122
|
* (same file the SIGTERM handler writes to and the next session greeting
|
|
@@ -35,6 +35,14 @@ export interface PendingDelivery<M> {
|
|
|
35
35
|
readonly key: string
|
|
36
36
|
/** The exact inbound to re-send until claude acks it. */
|
|
37
37
|
readonly inbound: M
|
|
38
|
+
/**
|
|
39
|
+
* Source message id of the tracked inbound (stringified Telegram
|
|
40
|
+
* `message_id`), or null if unknown. The `enqueue` ack matches on THIS so a
|
|
41
|
+
* synthetic-source turn (cron / resume / vault / reaction) that shares the
|
|
42
|
+
* chatKey can't false-ack — and silently drop — a real user message still
|
|
43
|
+
* waiting to land. See ackDelivery.
|
|
44
|
+
*/
|
|
45
|
+
readonly messageId: string | null
|
|
38
46
|
/** When the latest delivery attempt was made (unix-ms). */
|
|
39
47
|
lastAttemptAt: number
|
|
40
48
|
}
|
|
@@ -51,22 +59,44 @@ export function createDeliveryQueue<M>(): DeliveryQueue<M> {
|
|
|
51
59
|
* Track a freshly-delivered inbound, awaiting claude's `enqueue` ack.
|
|
52
60
|
* Overwrites any prior pending for the key — the #1556 gate serialises per
|
|
53
61
|
* key, so a later inbound supersedes an earlier un-acked one for that key.
|
|
62
|
+
* `messageId` (stringified Telegram message_id) lets the ack match only the
|
|
63
|
+
* enqueue that belongs to THIS message; pass null when unknown.
|
|
54
64
|
*/
|
|
55
65
|
export function trackDelivery<M>(
|
|
56
66
|
q: DeliveryQueue<M>,
|
|
57
67
|
key: string,
|
|
58
68
|
inbound: M,
|
|
59
69
|
now: number,
|
|
70
|
+
messageId: string | null = null,
|
|
60
71
|
): void {
|
|
61
|
-
q.pending.set(key, { key, inbound, lastAttemptAt: now })
|
|
72
|
+
q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now })
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
65
|
-
* Ack a delivery — call from the `enqueue` session-event (claude started
|
|
66
|
-
* turn
|
|
76
|
+
* Ack a delivery — call from the `enqueue` session-event (claude started a
|
|
77
|
+
* turn). `enqueue` fires for EVERY turn start regardless of source (user
|
|
78
|
+
* inbound, cron, subagent-handback, vault-resume, restart-marker), so acking
|
|
79
|
+
* purely by chatKey would let a synthetic-source turn clear — and thus
|
|
80
|
+
* silently drop — a real user message still waiting under the same key. So
|
|
81
|
+
* ack ONLY when the enqueue's source message id matches the tracked one.
|
|
82
|
+
*
|
|
83
|
+
* Matching rule: if we recorded a messageId for the pending entry, require the
|
|
84
|
+
* enqueue's `enqueueMessageId` to equal it. If we never recorded one (legacy /
|
|
85
|
+
* defensive null), fall back to key-only ack. Returns true if an entry was
|
|
86
|
+
* cleared.
|
|
67
87
|
*/
|
|
68
|
-
export function ackDelivery<M>(
|
|
69
|
-
|
|
88
|
+
export function ackDelivery<M>(
|
|
89
|
+
q: DeliveryQueue<M>,
|
|
90
|
+
key: string,
|
|
91
|
+
enqueueMessageId: string | null = null,
|
|
92
|
+
): boolean {
|
|
93
|
+
const entry = q.pending.get(key)
|
|
94
|
+
if (!entry) return false
|
|
95
|
+
// A different message started this turn — don't ack ours (it may still be
|
|
96
|
+
// waiting to land; the sweep will re-deliver it if it stranded).
|
|
97
|
+
if (entry.messageId != null && entry.messageId !== enqueueMessageId) return false
|
|
98
|
+
q.pending.delete(key)
|
|
99
|
+
return true
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
/**
|
|
@@ -94,3 +124,37 @@ export function sweep<M>(
|
|
|
94
124
|
export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
|
|
95
125
|
q.pending.delete(key)
|
|
96
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Should this delivered inbound be tracked for ack/re-delivery?
|
|
130
|
+
*
|
|
131
|
+
* Track a delivery iff it is a fresh user turn that will produce exactly one
|
|
132
|
+
* `enqueue` to ack against. Everything that does NOT enqueue must be excluded,
|
|
133
|
+
* or the sweep re-delivers it forever (re-clearing the composer every cycle):
|
|
134
|
+
*
|
|
135
|
+
* - `isSteering` / `isInterrupt` — the #1556 gate's carve-outs: delivered
|
|
136
|
+
* mid-turn to AMEND the running turn, so they never start a fresh turn and
|
|
137
|
+
* never emit `enqueue`.
|
|
138
|
+
* - `hasSource` — synthetic inbounds (cron / vault-resume / subagent-handback
|
|
139
|
+
* / reaction) carry a `meta.source`; they enqueue under their own semantics
|
|
140
|
+
* and must never be tracked as if they were a queued user turn.
|
|
141
|
+
* - empty `effectiveText` — an empty body (e.g. `/queue` with no text) is
|
|
142
|
+
* silently dropped by claude's auto-submit and never enqueues, so tracking
|
|
143
|
+
* it is a pure re-delivery loop (a self-inflicted DoS on the queue).
|
|
144
|
+
*
|
|
145
|
+
* Mirror the gate's carve-outs here so tracking is exactly the set of messages
|
|
146
|
+
* that produce an `enqueue`.
|
|
147
|
+
*/
|
|
148
|
+
export function shouldTrackDelivery(input: {
|
|
149
|
+
isSteering: boolean
|
|
150
|
+
isInterrupt: boolean
|
|
151
|
+
hasSource?: boolean
|
|
152
|
+
effectiveText?: string
|
|
153
|
+
}): boolean {
|
|
154
|
+
if (input.isSteering || input.isInterrupt) return false
|
|
155
|
+
if (input.hasSource) return false
|
|
156
|
+
// Gate on empty text only when the caller actually provided it (undefined =
|
|
157
|
+
// "not supplied", left untracked-gated so existing callers keep their behaviour).
|
|
158
|
+
if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0) return false
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ackDelivery,
|
|
5
5
|
createDeliveryQueue,
|
|
6
6
|
forgetDelivery,
|
|
7
|
+
shouldTrackDelivery,
|
|
7
8
|
sweep,
|
|
8
9
|
trackDelivery,
|
|
9
10
|
type DeliveryQueue,
|
|
@@ -107,3 +108,73 @@ describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () =>
|
|
|
107
108
|
expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
|
|
108
109
|
})
|
|
109
110
|
})
|
|
111
|
+
|
|
112
|
+
// Regression for the cross-source ACK collision (silent drop): `enqueue` fires
|
|
113
|
+
// for EVERY turn start regardless of source. A synthetic-source turn (cron /
|
|
114
|
+
// resume / vault / reaction) that shares the chatKey of a real user message
|
|
115
|
+
// still waiting to land would, with a key-only ack, clear — and silently drop
|
|
116
|
+
// — that user message. The ack must match on the tracked message id.
|
|
117
|
+
describe('ackDelivery — message-id-matched (cross-source false-ack guard)', () => {
|
|
118
|
+
it('acks when the enqueue message id matches the tracked one', () => {
|
|
119
|
+
const q = fresh()
|
|
120
|
+
trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
|
|
121
|
+
expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
|
|
122
|
+
expect(q.pending.size).toBe(0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does NOT ack when a different (synthetic-source) turn enqueues under the same key', () => {
|
|
126
|
+
const q = fresh()
|
|
127
|
+
trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
|
|
128
|
+
// a cron / resume turn for the same chat enqueues first, with its own id
|
|
129
|
+
expect(ackDelivery(q, 'chat:_', '1716123456789')).toBe(false)
|
|
130
|
+
// the user message is still tracked — it strands → gets re-delivered, not dropped
|
|
131
|
+
expect(q.pending.size).toBe(1)
|
|
132
|
+
expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1)
|
|
133
|
+
// and its own enqueue (matching id) later acks it cleanly
|
|
134
|
+
expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
|
|
135
|
+
expect(q.pending.size).toBe(0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does NOT ack when the enqueue carries no message id but the tracked one has one', () => {
|
|
139
|
+
const q = fresh()
|
|
140
|
+
trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
|
|
141
|
+
expect(ackDelivery(q, 'chat:_', null)).toBe(false)
|
|
142
|
+
expect(q.pending.size).toBe(1)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('falls back to key-only ack when no message id was recorded (legacy/defensive)', () => {
|
|
146
|
+
const q = fresh()
|
|
147
|
+
trackDelivery(q, 'chat:_', { text: 'x' }, 0) // no messageId
|
|
148
|
+
expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
|
|
149
|
+
expect(q.pending.size).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Regression for the steer/interrupt re-delivery loop: steering and `!`
|
|
154
|
+
// interrupt inbounds amend the running turn and never emit `enqueue`, so they
|
|
155
|
+
// must NOT be tracked (else the sweep re-delivers them forever). Only
|
|
156
|
+
// fresh-turn messages — which DO enqueue — are tracked.
|
|
157
|
+
describe('shouldTrackDelivery — only fresh-turn messages are tracked', () => {
|
|
158
|
+
it('tracks a normal (non-steering, non-interrupt) message', () => {
|
|
159
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false })).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
it('does NOT track a /steer message (amends the turn — never acks)', () => {
|
|
162
|
+
expect(shouldTrackDelivery({ isSteering: true, isInterrupt: false })).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
it('does NOT track a ! interrupt message (amends the turn — never acks)', () => {
|
|
165
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: true })).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
it('does NOT track when both flags set (defensive)', () => {
|
|
168
|
+
expect(shouldTrackDelivery({ isSteering: true, isInterrupt: true })).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
it('does NOT track a synthetic (meta.source) inbound — cron/resume/vault/reaction enqueue under their own semantics', () => {
|
|
171
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, hasSource: true })).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
it('does NOT track an empty-body message (e.g. `/queue` with no text) — never enqueues, would re-deliver forever', () => {
|
|
174
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: '' })).toBe(false)
|
|
175
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: ' ' })).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
it('tracks a normal message when effectiveText is provided and non-empty', () => {
|
|
178
|
+
expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: 'draft the email' })).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -55,8 +55,15 @@ describe("uat: rapid follow-ups — steering vs queued classification", () => {
|
|
|
55
55
|
(m) => {
|
|
56
56
|
const txt = m.text;
|
|
57
57
|
const mentionsMd5 = /\bmd5\b/i.test(txt);
|
|
58
|
+
// Steer narration: the agent acknowledges amending the in-flight
|
|
59
|
+
// task. Accept the phrasings the model actually uses — including
|
|
60
|
+
// "Switched to MD5 per your update/follow-up" (the 2026-06-02
|
|
61
|
+
// canary reply that the old regex wrongly rejected). Anchored on
|
|
62
|
+
// "per your <qualifier>" / continuation language so it stays
|
|
63
|
+
// distinct from the QUEUED path (a fresh answer with no such
|
|
64
|
+
// course-correction narration).
|
|
58
65
|
const narratesSteer =
|
|
59
|
-
/↪️|\bsteer(ing)?\b|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
|
|
66
|
+
/↪️|\bsteer(ing)?\b|switch(?:ed|ing)? to \w+ per your (?:update|follow-?up|guidance|request|steer)|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
|
|
60
67
|
txt,
|
|
61
68
|
);
|
|
62
69
|
return mentionsMd5 && narratesSteer;
|