switchroom 0.13.42 → 0.13.44
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 +11 -4
- package/dist/vault/broker/server.js +9 -2
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +28 -2
- package/telegram-plugin/dist/gateway/gateway.js +43 -23
- package/telegram-plugin/gateway/gateway.ts +141 -39
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +2 -1
- package/telegram-plugin/silent-end.ts +11 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +6 -2
- package/telegram-plugin/tests/silent-end.test.ts +10 -7
package/dist/cli/switchroom.js
CHANGED
|
@@ -27303,8 +27303,15 @@ function checkAclByAgent(config, agentName, key) {
|
|
|
27303
27303
|
return { allow: true };
|
|
27304
27304
|
}
|
|
27305
27305
|
}
|
|
27306
|
-
const
|
|
27307
|
-
|
|
27306
|
+
const cfgWithProfiles = config;
|
|
27307
|
+
const profileName = agentConfig.extends;
|
|
27308
|
+
const profileMcp = profileName != null && profileName.length > 0 ? cfgWithProfiles.profiles?.[profileName]?.mcp_servers ?? {} : {};
|
|
27309
|
+
const effectiveMcp = {
|
|
27310
|
+
...cfgWithProfiles.defaults?.mcp_servers ?? {},
|
|
27311
|
+
...profileMcp,
|
|
27312
|
+
...agentConfig.mcp_servers ?? {}
|
|
27313
|
+
};
|
|
27314
|
+
for (const mcpEntry of Object.values(effectiveMcp)) {
|
|
27308
27315
|
if (!mcpEntry || typeof mcpEntry !== "object")
|
|
27309
27316
|
continue;
|
|
27310
27317
|
const declared = mcpEntry.secrets;
|
|
@@ -47756,8 +47763,8 @@ var {
|
|
|
47756
47763
|
} = import__.default;
|
|
47757
47764
|
|
|
47758
47765
|
// src/build-info.ts
|
|
47759
|
-
var VERSION = "0.13.
|
|
47760
|
-
var COMMIT_SHA = "
|
|
47766
|
+
var VERSION = "0.13.44";
|
|
47767
|
+
var COMMIT_SHA = "fa99d4de";
|
|
47761
47768
|
|
|
47762
47769
|
// src/cli/agent.ts
|
|
47763
47770
|
init_source();
|
|
@@ -13055,8 +13055,15 @@ function checkAclByAgent(config, agentName, key) {
|
|
|
13055
13055
|
return { allow: true };
|
|
13056
13056
|
}
|
|
13057
13057
|
}
|
|
13058
|
-
const
|
|
13059
|
-
|
|
13058
|
+
const cfgWithProfiles = config;
|
|
13059
|
+
const profileName = agentConfig.extends;
|
|
13060
|
+
const profileMcp = profileName != null && profileName.length > 0 ? cfgWithProfiles.profiles?.[profileName]?.mcp_servers ?? {} : {};
|
|
13061
|
+
const effectiveMcp = {
|
|
13062
|
+
...cfgWithProfiles.defaults?.mcp_servers ?? {},
|
|
13063
|
+
...profileMcp,
|
|
13064
|
+
...agentConfig.mcp_servers ?? {}
|
|
13065
|
+
};
|
|
13066
|
+
for (const mcpEntry of Object.values(effectiveMcp)) {
|
|
13060
13067
|
if (!mcpEntry || typeof mcpEntry !== "object")
|
|
13061
13068
|
continue;
|
|
13062
13069
|
const declared = mcpEntry.secrets;
|
package/package.json
CHANGED
|
@@ -92,6 +92,22 @@ export interface AnswerStreamConfig {
|
|
|
92
92
|
message_thread_id?: number
|
|
93
93
|
link_preview_options?: { is_disabled: boolean }
|
|
94
94
|
reply_parameters?: { message_id: number }
|
|
95
|
+
/**
|
|
96
|
+
* Distinguishes a streaming open (`'stream'` — silent edits-in-place
|
|
97
|
+
* preview, default) from the turn-end materialize (`'materialize'` —
|
|
98
|
+
* the user-facing final answer that should ping the device exactly
|
|
99
|
+
* once, matching beat-5 of the conversational-pacing contract).
|
|
100
|
+
*
|
|
101
|
+
* Used by the gateway wrapper in `gateway.ts` to set
|
|
102
|
+
* `disable_notification: true` for 'stream' purpose only —
|
|
103
|
+
* materialize lets the platform default (notify) through. Without
|
|
104
|
+
* this distinction, either every send pings (the original #1672
|
|
105
|
+
* bug, two device pings per multi-step turn — see
|
|
106
|
+
* `over-ping-safety-net.ts`) or none does (the over-correction
|
|
107
|
+
* where text-only short turns silently land and the user has no
|
|
108
|
+
* notification to know the answer arrived).
|
|
109
|
+
*/
|
|
110
|
+
purpose?: 'stream' | 'materialize'
|
|
95
111
|
},
|
|
96
112
|
) => Promise<{ message_id: number }>
|
|
97
113
|
editMessageText: (
|
|
@@ -344,10 +360,14 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
344
360
|
throw err
|
|
345
361
|
}
|
|
346
362
|
} else {
|
|
347
|
-
// First send — capture message_id; check generation for supersession
|
|
363
|
+
// First send — capture message_id; check generation for supersession.
|
|
364
|
+
// purpose: 'stream' tells the gateway wrapper this is the visible
|
|
365
|
+
// preview opener — silent (no device ping). The turn-end materialize
|
|
366
|
+
// path uses 'materialize' to allow the platform-default ping.
|
|
348
367
|
const sendParams: Parameters<typeof sendMessage>[2] = {
|
|
349
368
|
parse_mode: 'HTML',
|
|
350
369
|
link_preview_options: { is_disabled: true },
|
|
370
|
+
purpose: 'stream',
|
|
351
371
|
}
|
|
352
372
|
if (threadId != null) sendParams.message_thread_id = threadId
|
|
353
373
|
if (replyToMessageId != null) sendParams.reply_parameters = { message_id: replyToMessageId }
|
|
@@ -495,10 +515,16 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
495
515
|
return undefined
|
|
496
516
|
}
|
|
497
517
|
|
|
498
|
-
// Always send a fresh message for push notification
|
|
518
|
+
// Always send a fresh message for push notification.
|
|
519
|
+
// purpose: 'materialize' tells the gateway wrapper to allow the
|
|
520
|
+
// platform-default device ping through (vs the 'stream' opener
|
|
521
|
+
// which is forced silent). materialize() is the turn-end final-
|
|
522
|
+
// answer surface — beat 5 of the conversational-pacing contract
|
|
523
|
+
// — and MUST ping so the user knows the answer landed.
|
|
499
524
|
const sendParams: Parameters<typeof sendMessage>[2] = {
|
|
500
525
|
parse_mode: 'HTML',
|
|
501
526
|
link_preview_options: { is_disabled: true },
|
|
527
|
+
purpose: 'materialize',
|
|
502
528
|
}
|
|
503
529
|
if (threadId != null) sendParams.message_thread_id = threadId
|
|
504
530
|
// Don't quote-reply on materialize — the draft stream already established
|
|
@@ -37624,7 +37624,7 @@ function tick2(now) {
|
|
|
37624
37624
|
import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync5 } from "node:fs";
|
|
37625
37625
|
import { dirname as dirname6, join as join6 } from "node:path";
|
|
37626
37626
|
import { homedir as homedir2 } from "node:os";
|
|
37627
|
-
var SILENT_END_MAX_RETRIES =
|
|
37627
|
+
var SILENT_END_MAX_RETRIES = 2;
|
|
37628
37628
|
function resolveStateDir(deps) {
|
|
37629
37629
|
if (deps?.stateDir != null)
|
|
37630
37630
|
return deps.stateDir;
|
|
@@ -37862,7 +37862,8 @@ function createAnswerStream(config) {
|
|
|
37862
37862
|
} else {
|
|
37863
37863
|
const sendParams = {
|
|
37864
37864
|
parse_mode: "HTML",
|
|
37865
|
-
link_preview_options: { is_disabled: true }
|
|
37865
|
+
link_preview_options: { is_disabled: true },
|
|
37866
|
+
purpose: "stream"
|
|
37866
37867
|
};
|
|
37867
37868
|
if (threadId != null)
|
|
37868
37869
|
sendParams.message_thread_id = threadId;
|
|
@@ -37978,7 +37979,8 @@ function createAnswerStream(config) {
|
|
|
37978
37979
|
}
|
|
37979
37980
|
const sendParams = {
|
|
37980
37981
|
parse_mode: "HTML",
|
|
37981
|
-
link_preview_options: { is_disabled: true }
|
|
37982
|
+
link_preview_options: { is_disabled: true },
|
|
37983
|
+
purpose: "materialize"
|
|
37982
37984
|
};
|
|
37983
37985
|
if (threadId != null)
|
|
37984
37986
|
sendParams.message_thread_id = threadId;
|
|
@@ -48730,10 +48732,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48730
48732
|
}
|
|
48731
48733
|
|
|
48732
48734
|
// ../src/build-info.ts
|
|
48733
|
-
var VERSION = "0.13.
|
|
48734
|
-
var COMMIT_SHA = "
|
|
48735
|
-
var COMMIT_DATE = "2026-05-
|
|
48736
|
-
var LATEST_PR =
|
|
48735
|
+
var VERSION = "0.13.44";
|
|
48736
|
+
var COMMIT_SHA = "fa99d4de";
|
|
48737
|
+
var COMMIT_DATE = "2026-05-25T12:04:09Z";
|
|
48738
|
+
var LATEST_PR = 1816;
|
|
48737
48739
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48738
48740
|
|
|
48739
48741
|
// gateway/boot-version.ts
|
|
@@ -50457,7 +50459,15 @@ var STREAM_THROTTLE_MS_OVERRIDE = (() => {
|
|
|
50457
50459
|
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
50458
50460
|
})();
|
|
50459
50461
|
var TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled();
|
|
50460
|
-
var ANSWER_STREAM_VISIBLE_ENABLED =
|
|
50462
|
+
var ANSWER_STREAM_VISIBLE_ENABLED = (() => {
|
|
50463
|
+
const raw = process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM;
|
|
50464
|
+
if (raw == null)
|
|
50465
|
+
return true;
|
|
50466
|
+
const v = raw.trim().toLowerCase();
|
|
50467
|
+
if (v === "0" || v === "false" || v === "off" || v === "no")
|
|
50468
|
+
return false;
|
|
50469
|
+
return true;
|
|
50470
|
+
})();
|
|
50461
50471
|
var progressDriver = null;
|
|
50462
50472
|
var unpinProgressCardForChat = null;
|
|
50463
50473
|
var getPinnedProgressCardMessageId = null;
|
|
@@ -52662,14 +52672,16 @@ function handleSessionEvent(ev) {
|
|
|
52662
52672
|
...ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn },
|
|
52663
52673
|
sendMessage: async (chatId, text, params) => {
|
|
52664
52674
|
const tid = params?.message_thread_id;
|
|
52675
|
+
const silent = params?.purpose !== "materialize";
|
|
52665
52676
|
const msg = await robustApiCall(() => bot.api.sendMessage(chatId, text, {
|
|
52666
52677
|
parse_mode: params?.parse_mode,
|
|
52678
|
+
disable_notification: silent,
|
|
52667
52679
|
...tid != null ? { message_thread_id: tid } : {},
|
|
52668
52680
|
...params?.link_preview_options != null ? { link_preview_options: params.link_preview_options } : {},
|
|
52669
52681
|
...params?.reply_parameters != null ? { reply_parameters: params.reply_parameters } : {}
|
|
52670
52682
|
}), {
|
|
52671
52683
|
chat_id: chatId,
|
|
52672
|
-
verb:
|
|
52684
|
+
verb: `answer-stream.sendMessage(${params?.purpose ?? "stream"})`,
|
|
52673
52685
|
...tid != null ? { threadId: tid } : {}
|
|
52674
52686
|
});
|
|
52675
52687
|
return { message_id: msg.message_id };
|
|
@@ -52785,24 +52797,32 @@ function handleSessionEvent(ev) {
|
|
|
52785
52797
|
const streamedFinalText = turn.capturedText.join("").trim();
|
|
52786
52798
|
if (ANSWER_STREAM_VISIBLE_ENABLED && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
|
|
52787
52799
|
turn.answerStream = null;
|
|
52788
|
-
stream.stop();
|
|
52789
52800
|
streamFinalizedAsAnswer = true;
|
|
52790
52801
|
turn.finalAnswerDelivered = true;
|
|
52791
|
-
|
|
52792
|
-
|
|
52793
|
-
|
|
52794
|
-
if (HISTORY_ENABLED) {
|
|
52802
|
+
const oldStreamedMsgId = streamedMsgId;
|
|
52803
|
+
(async () => {
|
|
52804
|
+
let materializedId;
|
|
52795
52805
|
try {
|
|
52796
|
-
|
|
52797
|
-
|
|
52798
|
-
|
|
52799
|
-
|
|
52800
|
-
|
|
52801
|
-
|
|
52802
|
-
|
|
52803
|
-
|
|
52804
|
-
process.stderr.write(`telegram gateway: answer-stream finalized as answer chat=${turn.sessionChatId} msg=${streamedMsgId} chars=${streamedFinalText.length}
|
|
52806
|
+
materializedId = await stream.materialize();
|
|
52807
|
+
} catch (err) {
|
|
52808
|
+
process.stderr.write(`telegram gateway: answer-stream materialize failed: ${err instanceof Error ? err.message : String(err)}
|
|
52809
|
+
`);
|
|
52810
|
+
return;
|
|
52811
|
+
}
|
|
52812
|
+
if (typeof materializedId !== "number" || !Number.isFinite(materializedId)) {
|
|
52813
|
+
process.stderr.write(`telegram gateway: answer-stream materialize returned no msgId chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} \u2014 ` + `preserving silent preview as the user's only copy
|
|
52805
52814
|
`);
|
|
52815
|
+
return;
|
|
52816
|
+
}
|
|
52817
|
+
try {
|
|
52818
|
+
await bot.api.deleteMessage(turn.sessionChatId, oldStreamedMsgId);
|
|
52819
|
+
} catch (delErr) {
|
|
52820
|
+
process.stderr.write(`telegram gateway: answer-stream materialize-cleanup delete failed for msgId=${oldStreamedMsgId}: ${delErr instanceof Error ? delErr.message : String(delErr)}
|
|
52821
|
+
`);
|
|
52822
|
+
}
|
|
52823
|
+
process.stderr.write(`telegram gateway: answer-stream materialized as answer chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} newMsg=${materializedId} chars=${streamedFinalText.length}
|
|
52824
|
+
`);
|
|
52825
|
+
})();
|
|
52806
52826
|
} else {
|
|
52807
52827
|
turn.answerStream = null;
|
|
52808
52828
|
stream.retract().catch((err) => {
|
|
@@ -3024,10 +3024,24 @@ const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
|
|
|
3024
3024
|
// shape — they see the answer materialise live. For longer waits,
|
|
3025
3025
|
// the cross-turn pending-progress system (#1445/#1669) is the
|
|
3026
3026
|
// canonical surface and DOES ping at the appropriate boundaries.
|
|
3027
|
-
//
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3027
|
+
//
|
|
3028
|
+
// 2026-05-25: default flipped ON after fleet-log audit showed
|
|
3029
|
+
// framework-fallback rate at ~19% of inbounds (target per
|
|
3030
|
+
// `reference/conversational-pacing.md`: <0.5%). Streaming the
|
|
3031
|
+
// model's text events live into the chat closes the
|
|
3032
|
+
// catastrophic-UX failure mode at the lane-of-first-defense level.
|
|
3033
|
+
// Aligned with the "chat IS the artifact" sub-principle (the user
|
|
3034
|
+
// sees a normal chat message that grows — no chrome, no parallel
|
|
3035
|
+
// widget). Override with SWITCHROOM_VISIBLE_ANSWER_STREAM=0 to
|
|
3036
|
+
// disable, e.g. for an agent that needs the legacy draft-only
|
|
3037
|
+
// behaviour during debugging.
|
|
3038
|
+
const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
|
|
3039
|
+
const raw = process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM
|
|
3040
|
+
if (raw == null) return true
|
|
3041
|
+
const v = raw.trim().toLowerCase()
|
|
3042
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
|
|
3043
|
+
return true
|
|
3044
|
+
})()
|
|
3031
3045
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3032
3046
|
const progressDriver: any = null
|
|
3033
3047
|
const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
|
|
@@ -6665,12 +6679,39 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6665
6679
|
// instead of crashing the answer-stream loop on a deleted
|
|
6666
6680
|
// forum topic. answer-stream's own try/catch already
|
|
6667
6681
|
// tolerates undefined returns from editMessageText.
|
|
6682
|
+
//
|
|
6683
|
+
// disable_notification gating by purpose (2026-05-25):
|
|
6684
|
+
//
|
|
6685
|
+
// - purpose='stream' (the live edit-in-place preview): SILENT.
|
|
6686
|
+
// Without disable_notification, the first text chunk that
|
|
6687
|
+
// opens the visible message device-pings, and then when the
|
|
6688
|
+
// model later calls the reply MCP tool, that reply pings
|
|
6689
|
+
// AGAIN (the over-ping safety net at gateway.ts:~4452 only
|
|
6690
|
+
// sees executeReply paths, not this direct sendMessage). Two
|
|
6691
|
+
// device pings per multi-step turn — the original Bug A.
|
|
6692
|
+
// Edits in place don't notify regardless (Telegram semantics).
|
|
6693
|
+
//
|
|
6694
|
+
// - purpose='materialize' (turn-end final-answer fresh send,
|
|
6695
|
+
// only fires for text-only turns where the stream IS the
|
|
6696
|
+
// answer): PING. The user reached for the agent and the
|
|
6697
|
+
// model produced an answer; per beat 5 of
|
|
6698
|
+
// `reference/conversational-pacing.md` the final answer MUST
|
|
6699
|
+
// ping the device exactly once. Without this carve-out, a
|
|
6700
|
+
// short text-only turn ("on it" being the whole response)
|
|
6701
|
+
// lands silently and the user has no notification to know
|
|
6702
|
+
// the answer arrived — the original over-correction.
|
|
6703
|
+
//
|
|
6704
|
+
// - purpose unset (defensive default): SILENT. Treat as
|
|
6705
|
+
// stream-purpose so we never accidentally fire a stray ping
|
|
6706
|
+
// from an unrecognised sendMessage callsite.
|
|
6668
6707
|
sendMessage: async (chatId, text, params) => {
|
|
6669
6708
|
const tid = params?.message_thread_id
|
|
6709
|
+
const silent = params?.purpose !== 'materialize'
|
|
6670
6710
|
const msg = await robustApiCall(
|
|
6671
6711
|
() =>
|
|
6672
6712
|
bot.api.sendMessage(chatId, text, {
|
|
6673
6713
|
parse_mode: params?.parse_mode,
|
|
6714
|
+
disable_notification: silent,
|
|
6674
6715
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
6675
6716
|
...(params?.link_preview_options != null
|
|
6676
6717
|
? { link_preview_options: params.link_preview_options }
|
|
@@ -6681,7 +6722,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6681
6722
|
}),
|
|
6682
6723
|
{
|
|
6683
6724
|
chat_id: chatId,
|
|
6684
|
-
verb:
|
|
6725
|
+
verb: `answer-stream.sendMessage(${params?.purpose ?? 'stream'})`,
|
|
6685
6726
|
...(tid != null ? { threadId: tid } : {}),
|
|
6686
6727
|
},
|
|
6687
6728
|
)
|
|
@@ -6872,13 +6913,30 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6872
6913
|
//
|
|
6873
6914
|
// #869-Phase1 override: when `ANSWER_STREAM_VISIBLE_ENABLED` is
|
|
6874
6915
|
// on, the stream is rendering a USER-VISIBLE message in the
|
|
6875
|
-
// chat timeline.
|
|
6876
|
-
//
|
|
6877
|
-
//
|
|
6878
|
-
//
|
|
6879
|
-
//
|
|
6880
|
-
//
|
|
6881
|
-
//
|
|
6916
|
+
// chat timeline. When the stream is the de-facto final answer
|
|
6917
|
+
// (model never called reply, captured text is substantive), we
|
|
6918
|
+
// need to:
|
|
6919
|
+
// 1. Send a FRESH pinged message via `stream.materialize()`
|
|
6920
|
+
// so the user gets a device notification — beat 5 of the
|
|
6921
|
+
// conversational-pacing contract requires exactly one
|
|
6922
|
+
// ping per turn for the final answer. Without this,
|
|
6923
|
+
// text-only short turns ("on it" being the whole reply)
|
|
6924
|
+
// land silently and the user has no notification to know
|
|
6925
|
+
// the answer arrived (the failure caught by the
|
|
6926
|
+
// midturn-silent-dm UAT, 2026-05-25).
|
|
6927
|
+
// 2. Delete the silent streamed preview message so the user
|
|
6928
|
+
// doesn't see a duplicate (the streamed message in place
|
|
6929
|
+
// + the fresh materialized ping). materialize() handles
|
|
6930
|
+
// the fresh send but leaves the old streamed message_id
|
|
6931
|
+
// orphaned by design — we delete it explicitly here.
|
|
6932
|
+
//
|
|
6933
|
+
// The previous behavior (just `stream.stop()` to freeze the
|
|
6934
|
+
// streamed message in place) avoided the duplicate but also
|
|
6935
|
+
// skipped the ping. Materialize-and-delete trades a brief
|
|
6936
|
+
// visual "the streamed message is replaced by a fresh one"
|
|
6937
|
+
// (often imperceptible for short turns where the streaming
|
|
6938
|
+
// barely had time to register; mildly visible for longer
|
|
6939
|
+
// turns) in exchange for an always-correct turn-end ping.
|
|
6882
6940
|
let streamFinalizedAsAnswer = false
|
|
6883
6941
|
if (turn?.answerStream != null) {
|
|
6884
6942
|
const stream = turn.answerStream
|
|
@@ -6891,36 +6949,80 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6891
6949
|
&& streamedFinalText.length > 0
|
|
6892
6950
|
) {
|
|
6893
6951
|
turn.answerStream = null
|
|
6894
|
-
stream.stop()
|
|
6895
6952
|
streamFinalizedAsAnswer = true
|
|
6896
6953
|
turn.finalAnswerDelivered = true
|
|
6897
|
-
//
|
|
6898
|
-
//
|
|
6899
|
-
//
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6954
|
+
// Capture the old streamed message_id BEFORE materialize so
|
|
6955
|
+
// we can delete it after the fresh ping send. materialize()
|
|
6956
|
+
// overwrites `streamMsgId` internally with the new send's id;
|
|
6957
|
+
// without capturing here we'd lose the reference.
|
|
6958
|
+
const oldStreamedMsgId = streamedMsgId
|
|
6959
|
+
// Fire-and-forget materialize-and-delete sequence.
|
|
6960
|
+
//
|
|
6961
|
+
// Bookkeeping (dedup + history): handled inside
|
|
6962
|
+
// `materialize()` itself — see `answer-stream.ts:~548-549`
|
|
6963
|
+
// which calls the injected `recordDedup` + `recordOutbound`
|
|
6964
|
+
// callbacks with the NEW (fresh-send) message_id only after a
|
|
6965
|
+
// successful send. We deliberately do NOT pre-record here —
|
|
6966
|
+
// doing so populates the same `outboundDedup` store that
|
|
6967
|
+
// materialize's internal `checkDedup` consults at
|
|
6968
|
+
// `answer-stream.ts:~510`, causing materialize to dedup-
|
|
6969
|
+
// suppress its own send (return undefined, no ping fires) —
|
|
6970
|
+
// the exact failure mode this PR exists to fix. Let
|
|
6971
|
+
// materialize own the bookkeeping; gateway only sequences the
|
|
6972
|
+
// operations.
|
|
6973
|
+
//
|
|
6974
|
+
// Delete gating: only run the cleanup `deleteMessage` if
|
|
6975
|
+
// materialize actually sent (returned a numeric sentId). If
|
|
6976
|
+
// it dedup-suppressed or threw, the streamed preview is the
|
|
6977
|
+
// user's only copy of the answer and MUST be preserved.
|
|
6978
|
+
void (async () => {
|
|
6979
|
+
let materializedId: number | undefined
|
|
6910
6980
|
try {
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6981
|
+
materializedId = await stream.materialize()
|
|
6982
|
+
} catch (err) {
|
|
6983
|
+
process.stderr.write(
|
|
6984
|
+
`telegram gateway: answer-stream materialize failed: ${
|
|
6985
|
+
err instanceof Error ? err.message : String(err)
|
|
6986
|
+
}\n`,
|
|
6987
|
+
)
|
|
6988
|
+
return
|
|
6989
|
+
}
|
|
6990
|
+
if (typeof materializedId !== 'number' || !Number.isFinite(materializedId)) {
|
|
6991
|
+
// materialize() returned undefined — either pendingText
|
|
6992
|
+
// was empty, the body was a silent marker (NO_REPLY /
|
|
6993
|
+
// HEARTBEAT_OK), or `checkDedup` suppressed it. In every
|
|
6994
|
+
// such case the streamed preview is the user's only copy
|
|
6995
|
+
// of the content; don't delete it.
|
|
6996
|
+
process.stderr.write(
|
|
6997
|
+
`telegram gateway: answer-stream materialize returned no msgId ` +
|
|
6998
|
+
`chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} — ` +
|
|
6999
|
+
`preserving silent preview as the user's only copy\n`,
|
|
7000
|
+
)
|
|
7001
|
+
return
|
|
7002
|
+
}
|
|
7003
|
+
// Materialize sent a fresh pinged message at materializedId.
|
|
7004
|
+
// Delete the silent streamed preview so the chat shows one
|
|
7005
|
+
// canonical message (the fresh pinged one) and not two with
|
|
7006
|
+
// duplicate content. Best-effort; failures (already gone,
|
|
7007
|
+
// permission denied) leave a brief visible duplicate which
|
|
7008
|
+
// we accept rather than retry-storming.
|
|
7009
|
+
try {
|
|
7010
|
+
// allow-raw-bot-api: cleanup delete of silent streamed preview
|
|
7011
|
+
await bot.api.deleteMessage(turn.sessionChatId, oldStreamedMsgId)
|
|
7012
|
+
} catch (delErr) {
|
|
7013
|
+
process.stderr.write(
|
|
7014
|
+
`telegram gateway: answer-stream materialize-cleanup ` +
|
|
7015
|
+
`delete failed for msgId=${oldStreamedMsgId}: ${
|
|
7016
|
+
delErr instanceof Error ? delErr.message : String(delErr)
|
|
7017
|
+
}\n`,
|
|
7018
|
+
)
|
|
7019
|
+
}
|
|
7020
|
+
process.stderr.write(
|
|
7021
|
+
`telegram gateway: answer-stream materialized as answer ` +
|
|
7022
|
+
`chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} ` +
|
|
7023
|
+
`newMsg=${materializedId} chars=${streamedFinalText.length}\n`,
|
|
7024
|
+
)
|
|
7025
|
+
})()
|
|
6924
7026
|
} else {
|
|
6925
7027
|
turn.answerStream = null
|
|
6926
7028
|
void stream.retract().catch((err) => {
|
|
@@ -62,7 +62,8 @@ import { scanTurnForFinalReply } from './silent-end-scan.mjs'
|
|
|
62
62
|
|
|
63
63
|
// MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
|
|
64
64
|
// (this hook is a standalone .mjs and can't import the TS module).
|
|
65
|
-
|
|
65
|
+
// Bumped 1 → 2 on 2026-05-25 — see the matching doc-comment in silent-end.ts.
|
|
66
|
+
const MAX_RETRIES = 2
|
|
66
67
|
|
|
67
68
|
function readStdin() {
|
|
68
69
|
try {
|
|
@@ -56,8 +56,18 @@ export interface SilentEndDeps {
|
|
|
56
56
|
* gives up. MUST stay in sync with `MAX_RETRIES` in the Stop hook
|
|
57
57
|
* (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`) — the hook is a
|
|
58
58
|
* standalone `.mjs` and can't import this module.
|
|
59
|
+
*
|
|
60
|
+
* 2026-05-25 bump from 1 → 2. With the original budget of 1, a model
|
|
61
|
+
* that stubbornly emitted `type:"text"` + `stop_reason:"end_turn"` twice
|
|
62
|
+
* in a row (instead of calling the reply tool) fell through to the
|
|
63
|
+
* gateway's 5-minute silence-poke framework-fallback. Second-retry
|
|
64
|
+
* cases now get a chance before the user-visible nudge fires. Memory:
|
|
65
|
+
* the Stop hook prompt itself is explicit ("only text sent through the
|
|
66
|
+
* reply tool is delivered. Send your final answer now"), so a second
|
|
67
|
+
* nudge isn't redundant — it's giving a different sample from the
|
|
68
|
+
* model under the same prompt.
|
|
59
69
|
*/
|
|
60
|
-
export const SILENT_END_MAX_RETRIES =
|
|
70
|
+
export const SILENT_END_MAX_RETRIES = 2
|
|
61
71
|
|
|
62
72
|
function resolveStateDir(deps?: SilentEndDeps): string {
|
|
63
73
|
if (deps?.stateDir != null) return deps.stateDir
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
20
20
|
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { SILENT_END_MAX_RETRIES } from '../silent-end.js'
|
|
21
22
|
import {
|
|
22
23
|
mkdtempSync,
|
|
23
24
|
mkdirSync,
|
|
@@ -172,7 +173,10 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
172
173
|
reply('ack', { disable_notification: true }),
|
|
173
174
|
])
|
|
174
175
|
const statePath = join(stateDir, 'silent-end-pending.json')
|
|
175
|
-
|
|
176
|
+
// Use the canonical ceiling so this test stays accurate as MAX_RETRIES evolves.
|
|
177
|
+
writeFileSync(statePath, JSON.stringify({
|
|
178
|
+
retryCount: SILENT_END_MAX_RETRIES, chatId: '111',
|
|
179
|
+
}), 'utf8')
|
|
176
180
|
|
|
177
181
|
const r = runHook({
|
|
178
182
|
event: { session_id: 's1', transcript_path: transcript },
|
|
@@ -183,7 +187,7 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
183
187
|
expect(r.stderr).toMatch(/retry exhausted/)
|
|
184
188
|
// State unchanged.
|
|
185
189
|
const state = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
186
|
-
expect(state.retryCount).toBe(
|
|
190
|
+
expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
|
|
187
191
|
})
|
|
188
192
|
|
|
189
193
|
it('NO_REPLY in transcript → allow stop, no state file written', () => {
|
|
@@ -226,10 +226,11 @@ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
|
|
|
226
226
|
it('full lifecycle: silent → re-prompt → still silent → exhausted', () => {
|
|
227
227
|
// 1. Turn ends silent — first record.
|
|
228
228
|
expect(recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }).exhausted).toBe(false)
|
|
229
|
-
// 2. Stop hook blocks and
|
|
229
|
+
// 2. Stop hook blocks and bumps retryCount up to the ceiling
|
|
230
|
+
// (simulated; the hook does retryCount+1 each block until MAX).
|
|
230
231
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
231
232
|
const s = readSilentEndState()!
|
|
232
|
-
writeFileSync(path, JSON.stringify({ ...s, retryCount:
|
|
233
|
+
writeFileSync(path, JSON.stringify({ ...s, retryCount: SILENT_END_MAX_RETRIES }))
|
|
233
234
|
// 3. Re-prompted turn ends silent again — recovery exhausted.
|
|
234
235
|
expect(recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }).exhausted).toBe(true)
|
|
235
236
|
expect(readSilentEndState()).toBeNull()
|
|
@@ -346,10 +347,11 @@ describe('recordUndeliveredTurnEnd — #1664 extended trigger', () => {
|
|
|
346
347
|
[{ text: 'one sec', disableNotification: true }],
|
|
347
348
|
'c:exhaust',
|
|
348
349
|
).rePromptEngaged).toBe(true)
|
|
349
|
-
// Stop hook blocks
|
|
350
|
+
// Stop hook blocks until retryCount hits the ceiling (simulated;
|
|
351
|
+
// the hook does retryCount+1 each block until SILENT_END_MAX_RETRIES).
|
|
350
352
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
351
353
|
const s = readSilentEndState()!
|
|
352
|
-
writeFileSync(path, JSON.stringify({ ...s, retryCount:
|
|
354
|
+
writeFileSync(path, JSON.stringify({ ...s, retryCount: SILENT_END_MAX_RETRIES }))
|
|
353
355
|
// Re-prompted turn STILL ends with only an interim ack → exhausted.
|
|
354
356
|
const second = recordUndeliveredTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:exhaust' })
|
|
355
357
|
expect(second.exhausted).toBe(true)
|
|
@@ -538,13 +540,14 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
538
540
|
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
539
541
|
})
|
|
540
542
|
|
|
541
|
-
it('allows the stop when retryCount >= MAX_RETRIES
|
|
542
|
-
// Retry already spent — gateway will post the user-facing
|
|
543
|
+
it('allows the stop when retryCount >= MAX_RETRIES, even if transcript still shows no reply', () => {
|
|
544
|
+
// Retry budget already spent — gateway will post the user-facing
|
|
543
545
|
// fallback so the user isn't left silent.
|
|
544
546
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
545
547
|
mkdirSync(stateDir, { recursive: true })
|
|
546
548
|
writeFileSync(path, JSON.stringify({
|
|
547
|
-
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
549
|
+
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
550
|
+
retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
|
|
548
551
|
}))
|
|
549
552
|
const transcript = writeTranscript([
|
|
550
553
|
ENQUEUE,
|