switchroom 0.14.55 → 0.14.57
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/bridge/bridge.ts +3 -1
- package/telegram-plugin/dist/bridge/bridge.js +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +227 -47
- package/telegram-plugin/dist/server.js +3 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +532 -98
- package/telegram-plugin/gateway/serialize-drain-gate.ts +111 -0
- package/telegram-plugin/tests/answer-thread-resolve.test.ts +111 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -7
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +131 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +156 -0
- package/telegram-plugin/tests/serialize-drain-gate.test.ts +112 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +26 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49463,8 +49463,8 @@ var {
|
|
|
49463
49463
|
} = import__.default;
|
|
49464
49464
|
|
|
49465
49465
|
// src/build-info.ts
|
|
49466
|
-
var VERSION = "0.14.
|
|
49467
|
-
var COMMIT_SHA = "
|
|
49466
|
+
var VERSION = "0.14.57";
|
|
49467
|
+
var COMMIT_SHA = "ddb0b353";
|
|
49468
49468
|
|
|
49469
49469
|
// src/cli/agent.ts
|
|
49470
49470
|
init_source();
|
package/package.json
CHANGED
|
@@ -78,7 +78,7 @@ const mcp = new Server(
|
|
|
78
78
|
'',
|
|
79
79
|
'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
|
|
80
80
|
'',
|
|
81
|
-
'If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic — no need to pass message_thread_id manually unless you want to override.',
|
|
81
|
+
'If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic — no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
|
|
82
82
|
'',
|
|
83
83
|
'The default format is "html" — write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
84
84
|
'',
|
|
@@ -104,6 +104,7 @@ const TOOL_SCHEMAS = [
|
|
|
104
104
|
reply_to: { type: 'string', description: 'Message ID to thread under. Overrides the default (latest inbound).' },
|
|
105
105
|
quote: { type: 'boolean', description: 'Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set.' },
|
|
106
106
|
message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified.' },
|
|
107
|
+
origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
|
|
107
108
|
files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each.' },
|
|
108
109
|
format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
109
110
|
disable_web_page_preview: { type: 'boolean', description: 'Disable link preview thumbnails. Default: true.' },
|
|
@@ -143,6 +144,7 @@ const TOOL_SCHEMAS = [
|
|
|
143
144
|
text: { type: 'string', description: 'Full text snapshot. NOT a delta — pass the complete current content each call.' },
|
|
144
145
|
done: { type: 'boolean', description: 'Must be true. Posts this text as the final answer for the turn and locks the message.' },
|
|
145
146
|
message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
|
|
147
|
+
origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
|
|
146
148
|
format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
147
149
|
reply_to: { type: 'string', description: 'Message ID to quote-reply to. Overrides the default (latest inbound).' },
|
|
148
150
|
quote: { type: 'boolean', description: 'Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set.' },
|
|
@@ -24571,7 +24571,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
|
|
|
24571
24571
|
"",
|
|
24572
24572
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24573
24573
|
"",
|
|
24574
|
-
"If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override.",
|
|
24574
|
+
"If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
24575
24575
|
"",
|
|
24576
24576
|
'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
24577
24577
|
"",
|
|
@@ -24593,6 +24593,7 @@ var TOOL_SCHEMAS = [
|
|
|
24593
24593
|
reply_to: { type: "string", description: "Message ID to thread under. Overrides the default (latest inbound)." },
|
|
24594
24594
|
quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set." },
|
|
24595
24595
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
|
|
24596
|
+
origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
|
|
24596
24597
|
files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each." },
|
|
24597
24598
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
24598
24599
|
disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
|
|
@@ -24631,6 +24632,7 @@ var TOOL_SCHEMAS = [
|
|
|
24631
24632
|
text: { type: "string", description: "Full text snapshot. NOT a delta \u2014 pass the complete current content each call." },
|
|
24632
24633
|
done: { type: "boolean", description: "Must be true. Posts this text as the final answer for the turn and locks the message." },
|
|
24633
24634
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." },
|
|
24635
|
+
origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
|
|
24634
24636
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
24635
24637
|
reply_to: { type: "string", description: "Message ID to quote-reply to. Overrides the default (latest inbound)." },
|
|
24636
24638
|
quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set." },
|
|
@@ -47347,6 +47347,34 @@ function decideInboundDelivery(input) {
|
|
|
47347
47347
|
return "deliver";
|
|
47348
47348
|
}
|
|
47349
47349
|
|
|
47350
|
+
// gateway/serialize-drain-gate.ts
|
|
47351
|
+
function mayDrainBufferedInbound(input) {
|
|
47352
|
+
if (input.turnInFlight)
|
|
47353
|
+
return false;
|
|
47354
|
+
if (!input.enabled)
|
|
47355
|
+
return true;
|
|
47356
|
+
const delivered = input.endingTurnFinalAnswerDelivered;
|
|
47357
|
+
if (delivered == null)
|
|
47358
|
+
return true;
|
|
47359
|
+
return delivered === true;
|
|
47360
|
+
}
|
|
47361
|
+
function shouldArmNoReplyDrain(input) {
|
|
47362
|
+
if (!input.enabled)
|
|
47363
|
+
return false;
|
|
47364
|
+
if (input.finalAnswerDelivered === true)
|
|
47365
|
+
return false;
|
|
47366
|
+
return input.bufferedDepth > 0;
|
|
47367
|
+
}
|
|
47368
|
+
|
|
47369
|
+
// gateway/answer-thread-resolve.ts
|
|
47370
|
+
function resolveAnswerThreadId(input) {
|
|
47371
|
+
if (input.explicitThreadId != null)
|
|
47372
|
+
return input.explicitThreadId;
|
|
47373
|
+
if (input.originResolved)
|
|
47374
|
+
return input.originThreadId;
|
|
47375
|
+
return input.liveThreadId;
|
|
47376
|
+
}
|
|
47377
|
+
|
|
47350
47378
|
// gateway/inbound-delivery-confirm.ts
|
|
47351
47379
|
function createDeliveryQueue() {
|
|
47352
47380
|
return { pending: new Map };
|
|
@@ -52167,10 +52195,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52167
52195
|
}
|
|
52168
52196
|
|
|
52169
52197
|
// ../src/build-info.ts
|
|
52170
|
-
var VERSION = "0.14.
|
|
52171
|
-
var COMMIT_SHA = "
|
|
52172
|
-
var COMMIT_DATE = "2026-06-
|
|
52173
|
-
var LATEST_PR =
|
|
52198
|
+
var VERSION = "0.14.57";
|
|
52199
|
+
var COMMIT_SHA = "ddb0b353";
|
|
52200
|
+
var COMMIT_DATE = "2026-06-03T22:37:37Z";
|
|
52201
|
+
var LATEST_PR = 2140;
|
|
52174
52202
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52175
52203
|
|
|
52176
52204
|
// gateway/boot-version.ts
|
|
@@ -53340,6 +53368,7 @@ if (!STATIC)
|
|
|
53340
53368
|
var chatThreadMap = new Map;
|
|
53341
53369
|
var activeStatusReactions = new Map;
|
|
53342
53370
|
var activeReactionMsgIds = new Map;
|
|
53371
|
+
var queuedStatusMsgIds = new Map;
|
|
53343
53372
|
var deferredDoneReactions = new DeferredDoneReactions({
|
|
53344
53373
|
countRunningWorkers: () => countRunningWorkers(),
|
|
53345
53374
|
getActive: (key) => activeStatusReactions.get(key),
|
|
@@ -53367,6 +53396,13 @@ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw
|
|
|
53367
53396
|
var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
|
|
53368
53397
|
var DELIVERY_CONFIRM_SWEEP_MS = 5000;
|
|
53369
53398
|
var deliveryQueue = createDeliveryQueue();
|
|
53399
|
+
var SERIALIZE_UNTIL_REPLIED_ENABLED = process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== "0";
|
|
53400
|
+
var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
|
|
53401
|
+
var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
|
|
53402
|
+
var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
|
|
53403
|
+
var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
|
|
53404
|
+
var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
|
|
53405
|
+
var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
|
|
53370
53406
|
function turnInFlightForGate() {
|
|
53371
53407
|
return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
|
|
53372
53408
|
}
|
|
@@ -53384,6 +53420,68 @@ var lastPtyPreviewByChat = new Map;
|
|
|
53384
53420
|
var progressUpdateLastSent = new Map;
|
|
53385
53421
|
var progressUpdateTurnCount = new Map;
|
|
53386
53422
|
var currentTurn = null;
|
|
53423
|
+
var RECENT_TURNS_MAX = 32;
|
|
53424
|
+
var recentTurnsById = new Map;
|
|
53425
|
+
function rememberRecentTurn(turn) {
|
|
53426
|
+
recentTurnsById.set(turn.turnId, turn);
|
|
53427
|
+
while (recentTurnsById.size > RECENT_TURNS_MAX) {
|
|
53428
|
+
const oldest = recentTurnsById.keys().next().value;
|
|
53429
|
+
if (oldest === undefined)
|
|
53430
|
+
break;
|
|
53431
|
+
recentTurnsById.delete(oldest);
|
|
53432
|
+
}
|
|
53433
|
+
}
|
|
53434
|
+
function deriveTurnId(chatId, threadId, messageId) {
|
|
53435
|
+
if (messageId == null || messageId === "" || String(messageId) === "0")
|
|
53436
|
+
return null;
|
|
53437
|
+
return `${chatKey2(chatId, threadId ?? null)}#${messageId}`;
|
|
53438
|
+
}
|
|
53439
|
+
function findTurnByOriginId(originTurnId) {
|
|
53440
|
+
if (originTurnId == null || originTurnId === "")
|
|
53441
|
+
return null;
|
|
53442
|
+
if (currentTurn != null && currentTurn.turnId === originTurnId)
|
|
53443
|
+
return currentTurn;
|
|
53444
|
+
return recentTurnsById.get(originTurnId) ?? null;
|
|
53445
|
+
}
|
|
53446
|
+
function postQueuedStatus(chatId, bufferedThread, inFlightThread) {
|
|
53447
|
+
if (!QUEUED_STATUS_UX_ENABLED)
|
|
53448
|
+
return;
|
|
53449
|
+
const key = statusKey(chatId, bufferedThread);
|
|
53450
|
+
if (queuedStatusMsgIds.has(key))
|
|
53451
|
+
return;
|
|
53452
|
+
const otherTopic = inFlightThread != null ? `another topic` : `another conversation`;
|
|
53453
|
+
const text = `\u23F3 Queued \u2014 replying in ${otherTopic} first, then I'll get to this.`;
|
|
53454
|
+
(async () => {
|
|
53455
|
+
const sent = await swallowingApiCall(() => bot.api.sendMessage(chatId, text, { message_thread_id: bufferedThread }), { chat_id: chatId, verb: "queued-status.post", threadId: bufferedThread });
|
|
53456
|
+
const messageId = sent?.message_id;
|
|
53457
|
+
if (typeof messageId !== "number")
|
|
53458
|
+
return;
|
|
53459
|
+
if (queuedStatusMsgIds.has(key)) {
|
|
53460
|
+
swallowingApiCall(() => bot.api.deleteMessage(chatId, messageId), { chat_id: chatId, verb: "queued-status.post-race-cleanup", threadId: bufferedThread });
|
|
53461
|
+
return;
|
|
53462
|
+
}
|
|
53463
|
+
queuedStatusMsgIds.set(key, { chatId, threadId: bufferedThread, messageId });
|
|
53464
|
+
})();
|
|
53465
|
+
}
|
|
53466
|
+
function promoteQueuedStatus(chatId, thread) {
|
|
53467
|
+
if (!QUEUED_STATUS_UX_ENABLED)
|
|
53468
|
+
return;
|
|
53469
|
+
if (thread == null)
|
|
53470
|
+
return;
|
|
53471
|
+
const key = statusKey(chatId, thread);
|
|
53472
|
+
const entry = queuedStatusMsgIds.get(key);
|
|
53473
|
+
if (entry == null)
|
|
53474
|
+
return;
|
|
53475
|
+
swallowingApiCall(() => bot.api.editMessageText(chatId, entry.messageId, "\u270D\uFE0F On it \u2014 replying now.", {}), { chat_id: chatId, verb: "queued-status.promote", threadId: thread });
|
|
53476
|
+
}
|
|
53477
|
+
function reapQueuedStatus(chatId, thread) {
|
|
53478
|
+
const key = statusKey(chatId, thread ?? null);
|
|
53479
|
+
const entry = queuedStatusMsgIds.get(key);
|
|
53480
|
+
if (entry == null)
|
|
53481
|
+
return;
|
|
53482
|
+
queuedStatusMsgIds.delete(key);
|
|
53483
|
+
swallowingApiCall(() => bot.api.deleteMessage(chatId, entry.messageId), { chat_id: chatId, verb: "queued-status.reap", ...entry.threadId != null ? { threadId: entry.threadId } : {} });
|
|
53484
|
+
}
|
|
53387
53485
|
var toolFlightTracker = new ToolFlightTracker;
|
|
53388
53486
|
var pendingDeferredInterrupt = null;
|
|
53389
53487
|
async function fireDeferredInterrupt(reason) {
|
|
@@ -53448,6 +53546,52 @@ function statusKey(chatId, threadId) {
|
|
|
53448
53546
|
function streamKey3(chatId, threadId) {
|
|
53449
53547
|
return chatKey2(chatId, threadId);
|
|
53450
53548
|
}
|
|
53549
|
+
function performBufferDrain(reason) {
|
|
53550
|
+
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53551
|
+
if (pendingInboundBuffer.depth(selfAgentForFlush) <= 0)
|
|
53552
|
+
return;
|
|
53553
|
+
const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => {
|
|
53554
|
+
const d = ipcServer.sendToAgent(selfAgentForFlush, m);
|
|
53555
|
+
if (d)
|
|
53556
|
+
markClaudeBusyForInbound(m);
|
|
53557
|
+
return d;
|
|
53558
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
53559
|
+
if (fr.redelivered > 0) {
|
|
53560
|
+
process.stderr.write(`telegram gateway: ${reason} flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53561
|
+
`);
|
|
53562
|
+
}
|
|
53563
|
+
}
|
|
53564
|
+
function drainBufferedIfAllowed(endingTurn, reason) {
|
|
53565
|
+
if (!mayDrainBufferedInbound({
|
|
53566
|
+
turnInFlight: turnInFlightForGate(),
|
|
53567
|
+
endingTurnFinalAnswerDelivered: endingTurn?.finalAnswerDelivered ?? null,
|
|
53568
|
+
enabled: SERIALIZE_UNTIL_REPLIED_ENABLED
|
|
53569
|
+
})) {
|
|
53570
|
+
return;
|
|
53571
|
+
}
|
|
53572
|
+
performBufferDrain(reason);
|
|
53573
|
+
}
|
|
53574
|
+
function armNoReplyDrainTimer(turn) {
|
|
53575
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53576
|
+
if (!shouldArmNoReplyDrain({
|
|
53577
|
+
enabled: SERIALIZE_UNTIL_REPLIED_ENABLED,
|
|
53578
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
53579
|
+
bufferedDepth: pendingInboundBuffer.depth(selfAgent)
|
|
53580
|
+
})) {
|
|
53581
|
+
return;
|
|
53582
|
+
}
|
|
53583
|
+
if (turn.noReplyDrainTimer != null) {
|
|
53584
|
+
clearTimeout(turn.noReplyDrainTimer);
|
|
53585
|
+
turn.noReplyDrainTimer = null;
|
|
53586
|
+
}
|
|
53587
|
+
turn.noReplyDrainTimer = setTimeout(() => {
|
|
53588
|
+
turn.noReplyDrainTimer = null;
|
|
53589
|
+
process.stderr.write(`telegram gateway: no-reply bounded drain (${SERIALIZE_NOREPLY_DRAIN_MS}ms) \u2014 ` + `turn ${turn.turnId} ended without a reply; force-draining buffered inbound
|
|
53590
|
+
`);
|
|
53591
|
+
performBufferDrain("no-reply-bounded-drain");
|
|
53592
|
+
}, SERIALIZE_NOREPLY_DRAIN_MS);
|
|
53593
|
+
turn.noReplyDrainTimer.unref?.();
|
|
53594
|
+
}
|
|
53451
53595
|
function purgeReactionTracking(key, endingTurn) {
|
|
53452
53596
|
const outboundEmitted = endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
|
|
53453
53597
|
shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
|
|
@@ -53455,6 +53599,14 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
53455
53599
|
activeStatusReactions.delete(key);
|
|
53456
53600
|
activeReactionMsgIds.delete(key);
|
|
53457
53601
|
activeTurnStartedAt.delete(key);
|
|
53602
|
+
if (endingTurn != null) {
|
|
53603
|
+
reapQueuedStatus(endingTurn.sessionChatId, endingTurn.sessionThreadId);
|
|
53604
|
+
} else {
|
|
53605
|
+
const pqChatId = chatIdOfChatKey(key);
|
|
53606
|
+
const pqThreadPart = key.slice(pqChatId.length + 1);
|
|
53607
|
+
const pqThread = pqThreadPart === "_" || pqThreadPart === "" ? null : Number(pqThreadPart);
|
|
53608
|
+
reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? pqThread : undefined);
|
|
53609
|
+
}
|
|
53458
53610
|
claudeBusyKeys.delete(key);
|
|
53459
53611
|
if (endingTurn != null) {
|
|
53460
53612
|
stopTurnTypingLoop(endingTurn.sessionChatId, endingTurn.sessionThreadId ?? null);
|
|
@@ -53469,20 +53621,8 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
53469
53621
|
if (agentDir != null)
|
|
53470
53622
|
removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId);
|
|
53471
53623
|
}
|
|
53624
|
+
drainBufferedIfAllowed(endingTurn, "turn-complete");
|
|
53472
53625
|
if (!turnInFlightForGate()) {
|
|
53473
|
-
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53474
|
-
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
53475
|
-
const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => {
|
|
53476
|
-
const d = ipcServer.sendToAgent(selfAgentForFlush, m);
|
|
53477
|
-
if (d)
|
|
53478
|
-
markClaudeBusyForInbound(m);
|
|
53479
|
-
return d;
|
|
53480
|
-
}, inboundSpool, trackRedeliveredInbound);
|
|
53481
|
-
if (fr.redelivered > 0) {
|
|
53482
|
-
process.stderr.write(`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53483
|
-
`);
|
|
53484
|
-
}
|
|
53485
|
-
}
|
|
53486
53626
|
if (pendingRestarts.size > 0) {
|
|
53487
53627
|
for (const [agentName3, _timestamp] of pendingRestarts.entries()) {
|
|
53488
53628
|
triggerSelfRestart(agentName3, "turn-complete-pending-restart");
|
|
@@ -53493,33 +53633,24 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
53493
53633
|
}
|
|
53494
53634
|
}
|
|
53495
53635
|
}
|
|
53496
|
-
function releaseTurnBufferGate(key) {
|
|
53636
|
+
function releaseTurnBufferGate(key, endingTurn) {
|
|
53497
53637
|
if (!activeTurnStartedAt.has(key))
|
|
53498
53638
|
return;
|
|
53499
53639
|
activeTurnStartedAt.delete(key);
|
|
53500
53640
|
claudeBusyKeys.delete(key);
|
|
53501
53641
|
shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
|
|
53502
|
-
|
|
53503
|
-
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53504
|
-
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
53505
|
-
const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => {
|
|
53506
|
-
const d = ipcServer.sendToAgent(selfAgentForFlush, m);
|
|
53507
|
-
if (d)
|
|
53508
|
-
markClaudeBusyForInbound(m);
|
|
53509
|
-
return d;
|
|
53510
|
-
}, inboundSpool, trackRedeliveredInbound);
|
|
53511
|
-
if (fr.redelivered > 0) {
|
|
53512
|
-
process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53513
|
-
`);
|
|
53514
|
-
}
|
|
53515
|
-
}
|
|
53516
|
-
}
|
|
53642
|
+
drainBufferedIfAllowed(endingTurn, "reply-released-gate");
|
|
53517
53643
|
}
|
|
53518
53644
|
function endCurrentTurnAtomic(turn) {
|
|
53519
53645
|
if (currentTurn !== turn)
|
|
53520
53646
|
return;
|
|
53521
53647
|
currentTurn = null;
|
|
53648
|
+
if (turn.noReplyDrainTimer != null) {
|
|
53649
|
+
clearTimeout(turn.noReplyDrainTimer);
|
|
53650
|
+
turn.noReplyDrainTimer = null;
|
|
53651
|
+
}
|
|
53522
53652
|
purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn);
|
|
53653
|
+
armNoReplyDrainTimer(turn);
|
|
53523
53654
|
}
|
|
53524
53655
|
function maybeProactiveCompact() {
|
|
53525
53656
|
if (compactDispatching)
|
|
@@ -55207,7 +55338,9 @@ if (!STATIC) {
|
|
|
55207
55338
|
}
|
|
55208
55339
|
inboundSpool?.sweepEscalations((e) => {
|
|
55209
55340
|
const chat = e.msg.chatId;
|
|
55210
|
-
const
|
|
55341
|
+
const escThread = typeof e.msg.meta?.threadId === "string" && e.msg.meta.threadId ? Number(e.msg.meta.threadId) : undefined;
|
|
55342
|
+
const threadOpts = escThread != null ? { message_thread_id: escThread } : {};
|
|
55343
|
+
reapQueuedStatus(chat, escThread);
|
|
55211
55344
|
swallowingApiCall(() => bot.api.sendMessage(chat, "\u26A0\uFE0F I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-spool-escalation" });
|
|
55212
55345
|
});
|
|
55213
55346
|
}, IDLE_DRAIN_INTERVAL_MS).unref();
|
|
@@ -55422,7 +55555,19 @@ ${url}`;
|
|
|
55422
55555
|
effectiveText = text;
|
|
55423
55556
|
}
|
|
55424
55557
|
assertAllowedChat(chat_id);
|
|
55425
|
-
let threadId
|
|
55558
|
+
let threadId;
|
|
55559
|
+
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
55560
|
+
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
55561
|
+
const originTurn = findTurnByOriginId(args.origin_turn_id);
|
|
55562
|
+
threadId = resolveAnswerThreadId({
|
|
55563
|
+
explicitThreadId: Number.isFinite(explicit) ? explicit : undefined,
|
|
55564
|
+
originResolved: originTurn != null,
|
|
55565
|
+
originThreadId: originTurn?.sessionThreadId,
|
|
55566
|
+
liveThreadId: turn?.sessionThreadId
|
|
55567
|
+
});
|
|
55568
|
+
} else {
|
|
55569
|
+
threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
|
|
55570
|
+
}
|
|
55426
55571
|
if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
|
|
55427
55572
|
try {
|
|
55428
55573
|
const latest = getLatestInboundMessageId(chat_id, threadId ?? null);
|
|
@@ -55746,7 +55891,10 @@ ${url}`;
|
|
|
55746
55891
|
turn.finalAnswerDelivered = true;
|
|
55747
55892
|
finalizeStatusReaction(chat_id, threadId, "done");
|
|
55748
55893
|
}
|
|
55749
|
-
releaseTurnBufferGate(statusKey(chat_id, threadId));
|
|
55894
|
+
releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
|
|
55895
|
+
if (turn?.finalAnswerDelivered === true) {
|
|
55896
|
+
reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId);
|
|
55897
|
+
}
|
|
55750
55898
|
}
|
|
55751
55899
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
|
|
55752
55900
|
`);
|
|
@@ -55761,8 +55909,21 @@ async function executeStreamReply(args) {
|
|
|
55761
55909
|
throw new Error("stream_reply: chat_id is required");
|
|
55762
55910
|
if (args.text == null || args.text === "")
|
|
55763
55911
|
throw new Error("stream_reply: text is required and cannot be empty");
|
|
55764
|
-
if (args.message_thread_id == null
|
|
55765
|
-
|
|
55912
|
+
if (args.message_thread_id == null) {
|
|
55913
|
+
let injected;
|
|
55914
|
+
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
55915
|
+
const originTurn = findTurnByOriginId(args.origin_turn_id);
|
|
55916
|
+
injected = resolveAnswerThreadId({
|
|
55917
|
+
explicitThreadId: undefined,
|
|
55918
|
+
originResolved: originTurn != null,
|
|
55919
|
+
originThreadId: originTurn?.sessionThreadId,
|
|
55920
|
+
liveThreadId: turn?.sessionThreadId
|
|
55921
|
+
});
|
|
55922
|
+
} else {
|
|
55923
|
+
injected = turn?.sessionThreadId;
|
|
55924
|
+
}
|
|
55925
|
+
if (injected != null)
|
|
55926
|
+
args.message_thread_id = String(injected);
|
|
55766
55927
|
}
|
|
55767
55928
|
args.text = redactOutboundText(args.text, "stream_reply");
|
|
55768
55929
|
{
|
|
@@ -55900,7 +56061,10 @@ async function executeStreamReply(args) {
|
|
|
55900
56061
|
{
|
|
55901
56062
|
const sChat = args.chat_id;
|
|
55902
56063
|
const sThread = resolveThreadId(sChat, args.message_thread_id);
|
|
55903
|
-
releaseTurnBufferGate(statusKey(sChat, sThread));
|
|
56064
|
+
releaseTurnBufferGate(statusKey(sChat, sThread), turn ?? undefined);
|
|
56065
|
+
if (turn?.finalAnswerDelivered === true) {
|
|
56066
|
+
reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId);
|
|
56067
|
+
}
|
|
55904
56068
|
}
|
|
55905
56069
|
return { content: [{ type: "text", text: `${result.status} (id: ${result.messageId ?? "pending"})` }] };
|
|
55906
56070
|
}
|
|
@@ -56926,7 +57090,7 @@ async function drainActivitySummary(turn) {
|
|
|
56926
57090
|
turn.activityInFlight = null;
|
|
56927
57091
|
}
|
|
56928
57092
|
}
|
|
56929
|
-
function clearActivitySummary(turn) {
|
|
57093
|
+
function clearActivitySummary(turn, finalHtmlOverride) {
|
|
56930
57094
|
const chat = turn.sessionChatId;
|
|
56931
57095
|
const thread = turn.sessionThreadId;
|
|
56932
57096
|
const inFlight = turn.activityInFlight ?? Promise.resolve();
|
|
@@ -56944,7 +57108,7 @@ function clearActivitySummary(turn) {
|
|
|
56944
57108
|
}
|
|
56945
57109
|
return;
|
|
56946
57110
|
}
|
|
56947
|
-
const finalHtml = composeTurnActivity(turn, true);
|
|
57111
|
+
const finalHtml = finalHtmlOverride !== undefined ? finalHtmlOverride : composeTurnActivity(turn, true);
|
|
56948
57112
|
if (finalHtml == null)
|
|
56949
57113
|
return;
|
|
56950
57114
|
try {
|
|
@@ -56974,9 +57138,11 @@ function handleSessionEvent(ev) {
|
|
|
56974
57138
|
prior.answerStream = null;
|
|
56975
57139
|
}
|
|
56976
57140
|
const startedAt = Date.now();
|
|
57141
|
+
const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined;
|
|
57142
|
+
const turnId = deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId) ?? `${chatKey2(ev.chatId, enqThreadIdNum ?? null)}#synthetic-${startedAt}`;
|
|
56977
57143
|
const next = {
|
|
56978
57144
|
sessionChatId: ev.chatId,
|
|
56979
|
-
sessionThreadId:
|
|
57145
|
+
sessionThreadId: enqThreadIdNum,
|
|
56980
57146
|
sourceMessageId: ev.messageId != null && /^\d+$/.test(ev.messageId) ? Number(ev.messageId) : null,
|
|
56981
57147
|
startedAt,
|
|
56982
57148
|
gatewayReceiveAt: startedAt,
|
|
@@ -56987,7 +57153,9 @@ function handleSessionEvent(ev) {
|
|
|
56987
57153
|
silentAnchorText: "",
|
|
56988
57154
|
capturedText: [],
|
|
56989
57155
|
orphanedReplyTimeoutId: null,
|
|
57156
|
+
turnId,
|
|
56990
57157
|
registryKey: null,
|
|
57158
|
+
noReplyDrainTimer: null,
|
|
56991
57159
|
lastAssistantMsgId: null,
|
|
56992
57160
|
lastAssistantDone: false,
|
|
56993
57161
|
toolCallCount: 0,
|
|
@@ -57001,6 +57169,8 @@ function handleSessionEvent(ev) {
|
|
|
57001
57169
|
isDm: isDmChatId(ev.chatId)
|
|
57002
57170
|
};
|
|
57003
57171
|
currentTurn = next;
|
|
57172
|
+
rememberRecentTurn(next);
|
|
57173
|
+
promoteQueuedStatus(ev.chatId, enqThreadIdNum);
|
|
57004
57174
|
if (DELIVERY_CONFIRM_ENABLED) {
|
|
57005
57175
|
ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
|
|
57006
57176
|
}
|
|
@@ -58342,6 +58512,8 @@ ${preBlock(write.output)}`;
|
|
|
58342
58512
|
} catch {}
|
|
58343
58513
|
}
|
|
58344
58514
|
}
|
|
58515
|
+
const originTurnId = deriveTurnId(chat_id, messageThreadId ?? null, msgId);
|
|
58516
|
+
const topicScope = TOPIC_FRAMING_ENABLED && messageThreadId != null ? "This message belongs to the current topic only \u2014 answer ONLY this question, in this topic. Do not also answer a pending message from another topic." : undefined;
|
|
58345
58517
|
const inboundMsg = {
|
|
58346
58518
|
type: "inbound",
|
|
58347
58519
|
chatId: chat_id,
|
|
@@ -58366,6 +58538,8 @@ ${preBlock(write.output)}`;
|
|
|
58366
58538
|
user_id: String(from.id),
|
|
58367
58539
|
ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
|
|
58368
58540
|
...messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {},
|
|
58541
|
+
...originTurnId != null ? { origin_turn_id: originTurnId } : {},
|
|
58542
|
+
...topicScope != null ? { topic_scope: topicScope } : {},
|
|
58369
58543
|
...imagePath ? { image_path: imagePath } : {},
|
|
58370
58544
|
...replyToMessageId != null ? { reply_to_message_id: String(replyToMessageId) } : {},
|
|
58371
58545
|
...replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {},
|
|
@@ -58419,6 +58593,10 @@ ${preBlock(write.output)}`;
|
|
|
58419
58593
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
58420
58594
|
process.stderr.write(`telegram gateway: inbound held mid-turn agent=${selfAgent} chat=${chat_id} msg=${msgId ?? "-"} \u2014 will flush on turn-complete
|
|
58421
58595
|
`);
|
|
58596
|
+
const inFlightThread = currentTurn?.sessionThreadId;
|
|
58597
|
+
if (QUEUED_STATUS_UX_ENABLED && !isDmChatId(chat_id) && messageThreadId != null && messageThreadId !== inFlightThread) {
|
|
58598
|
+
postQueuedStatus(chat_id, messageThreadId, inFlightThread);
|
|
58599
|
+
}
|
|
58422
58600
|
return;
|
|
58423
58601
|
}
|
|
58424
58602
|
if (selfAgent) {
|
|
@@ -63238,16 +63416,18 @@ var didOneTimeSetup = false;
|
|
|
63238
63416
|
const isBackground = dispatch.isBackground;
|
|
63239
63417
|
if (!isBackground) {
|
|
63240
63418
|
const turn = currentTurn;
|
|
63241
|
-
|
|
63242
|
-
if (turn != null && removed) {
|
|
63419
|
+
if (turn != null && turn.foregroundSubAgents.has(agentId)) {
|
|
63243
63420
|
const action = foregroundFinishAction({
|
|
63244
|
-
removed,
|
|
63421
|
+
removed: true,
|
|
63245
63422
|
replyCalled: turn.replyCalled,
|
|
63246
|
-
remainingForeground: turn.foregroundSubAgents.size
|
|
63423
|
+
remainingForeground: turn.foregroundSubAgents.size - 1
|
|
63247
63424
|
});
|
|
63248
63425
|
if (action === "handoff-clear") {
|
|
63249
|
-
|
|
63426
|
+
const finalHtml = composeTurnActivity(turn, true);
|
|
63427
|
+
turn.foregroundSubAgents.delete(agentId);
|
|
63428
|
+
clearActivitySummary(turn, finalHtml);
|
|
63250
63429
|
} else if (action === "recompose") {
|
|
63430
|
+
turn.foregroundSubAgents.delete(agentId);
|
|
63251
63431
|
const rendered = composeTurnActivity(turn);
|
|
63252
63432
|
if (rendered != null) {
|
|
63253
63433
|
turn.activityPendingRender = rendered;
|
|
@@ -24268,7 +24268,7 @@ var init_bridge = __esm(async () => {
|
|
|
24268
24268
|
"",
|
|
24269
24269
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24270
24270
|
"",
|
|
24271
|
-
"If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override.",
|
|
24271
|
+
"If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
24272
24272
|
"",
|
|
24273
24273
|
'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
|
|
24274
24274
|
"",
|
|
@@ -24290,6 +24290,7 @@ var init_bridge = __esm(async () => {
|
|
|
24290
24290
|
reply_to: { type: "string", description: "Message ID to thread under. Overrides the default (latest inbound)." },
|
|
24291
24291
|
quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set." },
|
|
24292
24292
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
|
|
24293
|
+
origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
|
|
24293
24294
|
files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each." },
|
|
24294
24295
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
24295
24296
|
disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
|
|
@@ -24328,6 +24329,7 @@ var init_bridge = __esm(async () => {
|
|
|
24328
24329
|
text: { type: "string", description: "Full text snapshot. NOT a delta \u2014 pass the complete current content each call." },
|
|
24329
24330
|
done: { type: "boolean", description: "Must be true. Posts this text as the final answer for the turn and locks the message." },
|
|
24330
24331
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." },
|
|
24332
|
+
origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
|
|
24331
24333
|
format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
24332
24334
|
reply_to: { type: "string", description: "Message ID to quote-reply to. Overrides the default (latest inbound)." },
|
|
24333
24335
|
quote: { type: "boolean", description: "Opt out of the default quote-reply behavior. Default: true. Ignored when reply_to is explicitly set." },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-origin answer-thread resolution (multitopic reply-routing,
|
|
3
|
+
* component 3).
|
|
4
|
+
*
|
|
5
|
+
* Pure decision: which forum-topic thread should an ANSWER reply
|
|
6
|
+
* (`reply` / `stream_reply`) land in?
|
|
7
|
+
*
|
|
8
|
+
* ## The bug this closes
|
|
9
|
+
*
|
|
10
|
+
* In a forum supergroup one sequential `claude` CLI owns every topic with
|
|
11
|
+
* a singleton `currentTurn`. The Brevo turn's reply landed ~42s after its
|
|
12
|
+
* turn-end event; by then `currentTurn` had flipped to the Meta turn.
|
|
13
|
+
* `executeReply` captured `const turn = currentTurn` at execution and, when
|
|
14
|
+
* the model omitted `message_thread_id`, resolved the thread from
|
|
15
|
+
* `turn.sessionThreadId` (Meta's thread) — so Brevo's answer landed in
|
|
16
|
+
* Meta. A successor turn stole a predecessor's late reply.
|
|
17
|
+
*
|
|
18
|
+
* ## The precedence (answer paths)
|
|
19
|
+
*
|
|
20
|
+
* 1. An explicit `message_thread_id` the MODEL passed wins outright —
|
|
21
|
+
* the model is asserting the destination topic.
|
|
22
|
+
* 2. Else the ORIGIN turn's thread: the turn matched by the reply's
|
|
23
|
+
* `origin_turn_id` (the meta field the model echoes back). This is
|
|
24
|
+
* authoritative even when `currentTurn` has flipped, because the
|
|
25
|
+
* origin turn is looked up in a recently-ended registry.
|
|
26
|
+
* 3. Else the LIVE turn's thread — but ONLY when the live turn IS the
|
|
27
|
+
* origin turn (no flip happened) OR no origin turn could be resolved
|
|
28
|
+
* at all (origin id absent/unknown; legacy / pre-stamp path).
|
|
29
|
+
* 4. Else (origin resolved AND it differs from the live turn) we pin to
|
|
30
|
+
* the origin thread and explicitly DO NOT fall through to the chat's
|
|
31
|
+
* last-seen `chatThreadMap` thread. For answer surfaces the chat
|
|
32
|
+
* last-seen heuristic is exactly what produced the wrong-topic bug.
|
|
33
|
+
*
|
|
34
|
+
* The `chatThreadMap` last-seen fallback is preserved for NON-answer
|
|
35
|
+
* surfaces (`send_typing`, `forward_message`, `progress_update`) by NOT
|
|
36
|
+
* routing them through this function — they keep calling `resolveThreadId`
|
|
37
|
+
* directly.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export interface AnswerThreadInput {
|
|
41
|
+
/** Explicit `message_thread_id` the model passed (already coerced to a
|
|
42
|
+
* number), or undefined when omitted. */
|
|
43
|
+
explicitThreadId?: number | undefined
|
|
44
|
+
/** Thread of the turn matched by `origin_turn_id`, or undefined when the
|
|
45
|
+
* origin turn is a DM (no thread). Only meaningful when
|
|
46
|
+
* `originResolved` is true. */
|
|
47
|
+
originThreadId?: number | undefined
|
|
48
|
+
/** Whether an origin turn was resolved at all. Distinguishes
|
|
49
|
+
* "origin turn exists and its thread is undefined (a DM origin)" from
|
|
50
|
+
* "no origin turn" — both surface as `originThreadId === undefined`. */
|
|
51
|
+
originResolved: boolean
|
|
52
|
+
/** Thread of the LIVE `currentTurn` at execution time, or undefined
|
|
53
|
+
* (no live turn, or a DM live turn). The legacy (#1664) fallback when
|
|
54
|
+
* no origin turn is resolvable. */
|
|
55
|
+
liveThreadId?: number | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pure. Returns the thread id to send the answer to, or undefined for the
|
|
60
|
+
* main chat (DM / no thread).
|
|
61
|
+
*
|
|
62
|
+
* Precedence: explicit model thread → origin turn's thread (authoritative
|
|
63
|
+
* across a currentTurn flip; this is the wrong-topic fix) → live turn's
|
|
64
|
+
* thread (legacy #1664 fallback when origin can't be resolved). Crucially
|
|
65
|
+
* the chat last-seen `chatThreadMap` heuristic is NOT in this chain — that
|
|
66
|
+
* heuristic is what produced the Brevo→Meta wrong-topic bug, so answer
|
|
67
|
+
* paths never reach it.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefined {
|
|
70
|
+
// (1) explicit model thread wins.
|
|
71
|
+
if (input.explicitThreadId != null) return input.explicitThreadId
|
|
72
|
+
// (2) origin turn resolved → pin to its thread (authoritative even when
|
|
73
|
+
// currentTurn has flipped to a successor). A DM origin yields
|
|
74
|
+
// undefined, which is correct.
|
|
75
|
+
if (input.originResolved) return input.originThreadId
|
|
76
|
+
// (3) no origin resolved (legacy / pre-stamp / evicted) → fall back to
|
|
77
|
+
// the live turn's thread, the existing turn-pinned behaviour (#1664).
|
|
78
|
+
return input.liveThreadId
|
|
79
|
+
}
|