switchroom 0.18.31 → 0.18.33
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/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand voice ('🔊 Listen') callback family (`voice:<token>`) extracted
|
|
3
|
+
* verbatim from the gateway's `callback_query:data` dispatcher
|
|
4
|
+
* (switchroom#2996 P6 dispatcher drain).
|
|
5
|
+
*
|
|
6
|
+
* Handles the tap INTERNALLY — synthesizes the cached reply text and sends it
|
|
7
|
+
* as a native voice note. MUST run before the agent-callback routing in the
|
|
8
|
+
* dispatcher so a tap never reaches the agent as an inbound message.
|
|
9
|
+
*
|
|
10
|
+
* Returns true when the callback was a voice-on-demand tap (handled —
|
|
11
|
+
* dispatcher must `return`), false otherwise (dispatcher falls through).
|
|
12
|
+
*
|
|
13
|
+
* DI note (check-bot-api-wrapping, cluster-F pattern): the raw
|
|
14
|
+
* `bot.api.sendVoice` / `bot.api.editMessageReplyMarkup` calls are NOT made
|
|
15
|
+
* here — they are injected as `sendVoiceByFileId` / `sendVoiceByUpload` /
|
|
16
|
+
* `stripListenKeyboard`, bound in gateway.ts inside `robustApiCall(...)` with
|
|
17
|
+
* their existing allow-raw-bot-api markers. This module has no raw Bot API
|
|
18
|
+
* site.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import type { Context } from 'grammy'
|
|
23
|
+
import {
|
|
24
|
+
isVoiceOnDemandCallback,
|
|
25
|
+
parseVoiceOnDemandToken,
|
|
26
|
+
type VoiceOnDemandPayload,
|
|
27
|
+
} from '../voice-ondemand.js'
|
|
28
|
+
import { sendVoiceReusingFileId, type SentVoiceMessage } from '../voice-send.js'
|
|
29
|
+
import { synthesizeViaSidecar } from '../voice-synthesize-sidecar.js'
|
|
30
|
+
import { normalizeForTts } from '../tts-normalize.js'
|
|
31
|
+
import { materializeSidecarToken } from '../../src/telegram/materialize-sidecar-token.js'
|
|
32
|
+
|
|
33
|
+
export interface VoiceSendOpts {
|
|
34
|
+
reply_parameters?: { message_id: number }
|
|
35
|
+
message_thread_id?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface VoiceOnDemandCallbackDeps {
|
|
39
|
+
loadAccess: () => { allowFrom: string[] }
|
|
40
|
+
voiceOnDemandCache: {
|
|
41
|
+
get: (token: string) => VoiceOnDemandPayload | null
|
|
42
|
+
setTelegramFileId: (token: string, fileId: string) => void
|
|
43
|
+
}
|
|
44
|
+
/** robustApiCall(() => bot.api.sendVoice(chatId, fileId, opts), verbOpts) — bound in gateway.ts. */
|
|
45
|
+
sendVoiceByFileId: (
|
|
46
|
+
chatId: string,
|
|
47
|
+
fileId: string,
|
|
48
|
+
sendOpts: VoiceSendOpts,
|
|
49
|
+
threadId: number | undefined,
|
|
50
|
+
) => Promise<SentVoiceMessage>
|
|
51
|
+
/** robustApiCall(() => bot.api.sendVoice(chatId, new InputFile(audio), opts), verbOpts) — bound in gateway.ts. */
|
|
52
|
+
sendVoiceByUpload: (
|
|
53
|
+
chatId: string,
|
|
54
|
+
audio: Uint8Array,
|
|
55
|
+
sendOpts: VoiceSendOpts,
|
|
56
|
+
threadId: number | undefined,
|
|
57
|
+
) => Promise<SentVoiceMessage>
|
|
58
|
+
/** robustApiCall(() => bot.api.editMessageReplyMarkup(...)) — bound in gateway.ts; swallows failure. */
|
|
59
|
+
stripListenKeyboard: (
|
|
60
|
+
chatId: string,
|
|
61
|
+
messageId: number,
|
|
62
|
+
threadId: number | undefined,
|
|
63
|
+
) => Promise<void>
|
|
64
|
+
/** stderr log sink. */
|
|
65
|
+
log: (line: string) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Handle a `voice:` callback. Returns false if `data` is not one. */
|
|
69
|
+
export async function handleVoiceOnDemandCallback(
|
|
70
|
+
ctx: Context,
|
|
71
|
+
data: string,
|
|
72
|
+
deps: VoiceOnDemandCallbackDeps,
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
if (!isVoiceOnDemandCallback(data)) return false
|
|
75
|
+
const access = deps.loadAccess()
|
|
76
|
+
const senderId = String(ctx.from!.id)
|
|
77
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
78
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
const token = parseVoiceOnDemandToken(data)
|
|
82
|
+
const entry = token != null ? deps.voiceOnDemandCache.get(token) : null
|
|
83
|
+
if (entry == null) {
|
|
84
|
+
await ctx
|
|
85
|
+
.answerCallbackQuery({ text: 'Voice expired — send again to hear it.' })
|
|
86
|
+
.catch(() => {})
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from!.id)
|
|
90
|
+
const cbMessageId = ctx.callbackQuery?.message?.message_id
|
|
91
|
+
const cbThreadId = (() => {
|
|
92
|
+
const msg = ctx.callbackQuery?.message
|
|
93
|
+
if (msg && 'is_topic_message' in msg && msg.is_topic_message && 'message_thread_id' in msg) {
|
|
94
|
+
const tid = (msg as { message_thread_id?: number }).message_thread_id
|
|
95
|
+
return typeof tid === 'number' ? tid : undefined
|
|
96
|
+
}
|
|
97
|
+
return undefined
|
|
98
|
+
})()
|
|
99
|
+
const tok = token as string
|
|
100
|
+
const sendOpts: VoiceSendOpts = {
|
|
101
|
+
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
102
|
+
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written by
|
|
106
|
+
// the pre-synth queue at reply time). If it's on disk, attach it
|
|
107
|
+
// immediately — no GPU wait. Missing/unreadable file (expired + swept,
|
|
108
|
+
// crash, pre-feature entry, kill-switched gateway) falls back transparently
|
|
109
|
+
// to the lazy synth path. Only invoked when there's no reusable file_id (or
|
|
110
|
+
// a stored id was rejected as stale) — see sendVoiceReusingFileId.
|
|
111
|
+
const loadAudio = async (): Promise<Uint8Array | null> => {
|
|
112
|
+
let audio: Uint8Array | null = null
|
|
113
|
+
if (entry.filePath != null) {
|
|
114
|
+
try {
|
|
115
|
+
audio = readFileSync(entry.filePath)
|
|
116
|
+
} catch {
|
|
117
|
+
audio = null // swept/missing — lazy fallback
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (audio != null) {
|
|
121
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
122
|
+
return audio
|
|
123
|
+
}
|
|
124
|
+
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
125
|
+
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
126
|
+
// path uses. On-demand is a local-engine feature; the cache is only
|
|
127
|
+
// populated when resolveVoiceOutPlan gated the local verdict.
|
|
128
|
+
const sidecarToken = await materializeSidecarToken()
|
|
129
|
+
if (!sidecarToken) {
|
|
130
|
+
await ctx
|
|
131
|
+
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
132
|
+
.catch(() => {})
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
const result = await synthesizeViaSidecar({
|
|
136
|
+
token: sidecarToken,
|
|
137
|
+
// #2760 Phase 1: same deterministic normalization as the immediate
|
|
138
|
+
// voice-out path — the Listen lazy path builds its own /tts body from
|
|
139
|
+
// the persisted cache, so it must normalize independently (cache
|
|
140
|
+
// entries may predate the flag flip).
|
|
141
|
+
text: normalizeForTts(entry.text),
|
|
142
|
+
voice: entry.voice,
|
|
143
|
+
speed: entry.speed,
|
|
144
|
+
})
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
deps.log(
|
|
147
|
+
`telegram gateway: voice-out on-demand: synthesis failed reason=${result.reason}\n`,
|
|
148
|
+
)
|
|
149
|
+
await ctx
|
|
150
|
+
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
151
|
+
.catch(() => {})
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
return result.audio
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Fast path: if we already captured a reusable file_id, ack instantly and
|
|
158
|
+
// send BY the id — no disk read, no re-upload. First tap (no id yet) and a
|
|
159
|
+
// stale-id fallback both go through loadAudio + InputFile below and refresh
|
|
160
|
+
// the stored id from the returned message.
|
|
161
|
+
if (entry.telegramFileId != null) {
|
|
162
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
163
|
+
}
|
|
164
|
+
const sendResult = await sendVoiceReusingFileId({
|
|
165
|
+
fileId: entry.telegramFileId ?? null,
|
|
166
|
+
sendByFileId: (fid) => deps.sendVoiceByFileId(cbChatId, fid, sendOpts, cbThreadId),
|
|
167
|
+
loadAudio,
|
|
168
|
+
sendByUpload: (audioOut) => deps.sendVoiceByUpload(cbChatId, audioOut, sendOpts, cbThreadId),
|
|
169
|
+
onFileId: (fid) => deps.voiceOnDemandCache.setTelegramFileId(tok, fid),
|
|
170
|
+
log: (line) => deps.log(line),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!sendResult.ok) {
|
|
174
|
+
// no-audio already surfaced a toast inside loadAudio; send-failed is
|
|
175
|
+
// logged non-fatally — the '🔊 Listen' keyboard is left intact so the
|
|
176
|
+
// user can retry (only a successful send strips it below).
|
|
177
|
+
if (sendResult.reason === 'send-failed') {
|
|
178
|
+
const err = sendResult.error
|
|
179
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
180
|
+
deps.log(
|
|
181
|
+
`telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
// Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
|
|
188
|
+
// can't be re-tapped now that the audio has been delivered. Mirrors the
|
|
189
|
+
// agent-button single_use strip (keyboardIsSingleUse) house style.
|
|
190
|
+
// Best-effort + non-fatal — a failed strip only leaves a replayable
|
|
191
|
+
// button, never drops the delivered audio. Only reached on a successful
|
|
192
|
+
// sendVoice; expiry / synth-failure / sidecar-unavailable all return
|
|
193
|
+
// earlier WITHOUT stripping, so the user can retry those.
|
|
194
|
+
if (cbMessageId != null) {
|
|
195
|
+
await deps.stripListenKeyboard(cbChatId, cbMessageId, cbThreadId).catch(() => {})
|
|
196
|
+
}
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
199
|
+
deps.log(
|
|
200
|
+
`telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
import type { Subagent } from '../registry/subagents-schema.js'
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The narrow slice of the worker-activity feed `handleWorkerResume` needs —
|
|
5
|
+
* structurally satisfied by `WorkerActivityFeed` (worker-activity-feed.ts)
|
|
6
|
+
* without importing it (keeps this module dependency-light and the seam
|
|
7
|
+
* trivially mockable in tests).
|
|
8
|
+
*/
|
|
9
|
+
export interface WorkerFeedForResume {
|
|
10
|
+
resurrect(agentId: string): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Issue #3373 (SendMessage-resume feed re-surface) — the gateway's `onResume`
|
|
15
|
+
* handler body, extracted so the seam is unit-testable and gateway.ts keeps
|
|
16
|
+
* only a thin delegation (the line-ratchet drains inline bodies).
|
|
17
|
+
*
|
|
18
|
+
* A worker with a GENUINE terminal completion that is resumed via SendMessage
|
|
19
|
+
* grows its jsonl past the terminal boundary and is re-registered live by the
|
|
20
|
+
* watcher (#3315). This clears the feed's terminal `finalized` latch
|
|
21
|
+
* (`feed.resurrect`) so the resumed worker's next onProgress cue repaints a
|
|
22
|
+
* fresh live row — without it the latch swallows every resumed cue and the
|
|
23
|
+
* worker reads as silence. Distinct from the #3023 `onResurrect` path: no
|
|
24
|
+
* bounded-chain budget (a worker may be stopped/resumed any number of times).
|
|
25
|
+
*
|
|
26
|
+
* Best-effort: a resurrect failure is logged, never thrown back into the
|
|
27
|
+
* watcher's poll loop. `feed` may be null/undefined (feed disabled) — the
|
|
28
|
+
* re-surface log line is still emitted for the audit trail.
|
|
29
|
+
*/
|
|
30
|
+
export function handleWorkerResume(
|
|
31
|
+
feed: WorkerFeedForResume | null | undefined,
|
|
32
|
+
agentId: string,
|
|
33
|
+
log: (msg: string) => void,
|
|
34
|
+
): void {
|
|
35
|
+
try {
|
|
36
|
+
feed?.resurrect(agentId)
|
|
37
|
+
} catch (err) {
|
|
38
|
+
log(`telegram gateway: worker resume feed re-surface error agent=${agentId}: ${(err as Error).message}`)
|
|
39
|
+
}
|
|
40
|
+
log(`telegram gateway: worker ${agentId} card RE-SURFACED — resumed via SendMessage after a genuine terminal (issue #3373)`)
|
|
41
|
+
}
|
|
42
|
+
|
|
3
43
|
export interface WorkerFeedDispatch {
|
|
4
44
|
/** True when the sub-agent was dispatched with `run_in_background: true`. */
|
|
5
45
|
isBackground: boolean
|
|
@@ -25,9 +25,32 @@
|
|
|
25
25
|
* message.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/** Tool suffixes whose `input.text` IS the canonical answer surface. */
|
|
29
29
|
export const REPLY_TOOLS = new Set(['reply', 'stream_reply'])
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Strip this plugin's `mcp__<server-key>__telegram__` prefix, matched by the
|
|
33
|
+
* same key-agnostic regex `computeLabel` / `isTelegramReplyTool` use so renames
|
|
34
|
+
* and forks (`clerk-telegram`, `switchroom-telegram`, …) all resolve. A bare
|
|
35
|
+
* name (no prefix) is returned unchanged.
|
|
36
|
+
*/
|
|
37
|
+
const TELEGRAM_TOOL_PREFIX_RE = /^mcp__[^_].*?telegram__/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Prefix-aware membership test for {@link REPLY_TOOLS}.
|
|
41
|
+
*
|
|
42
|
+
* Production jsonl carries the fully-qualified MCP tool name
|
|
43
|
+
* (`mcp__switchroom-telegram__stream_reply`), so a bare `REPLY_TOOLS.has(name)`
|
|
44
|
+
* is always false in prod and the draft-then-send suppression path silently
|
|
45
|
+
* goes inert (#3231). Bare names ('reply' / 'stream_reply') can still appear
|
|
46
|
+
* from non-MCP sources, so both wire shapes must match: strip the telegram MCP
|
|
47
|
+
* prefix (a no-op for bare names) then test the suffix against REPLY_TOOLS.
|
|
48
|
+
*/
|
|
49
|
+
export function isReplyTool(toolName: string | null | undefined): boolean {
|
|
50
|
+
if (typeof toolName !== 'string') return false
|
|
51
|
+
return REPLY_TOOLS.has(toolName.replace(TELEGRAM_TOOL_PREFIX_RE, ''))
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
/**
|
|
32
55
|
* Normalize for prefix comparison: strip markdown/HTML-ish emphasis,
|
|
33
56
|
* heading and quote marks, collapse whitespace, lowercase. Mirrors the
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* effects and the per-turn lifetime.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
import {
|
|
42
|
+
import { isReplyTool, isDraftOfReply } from './narrative-dedup.js'
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Time-box for the parked-narrative early-paint (the kernel's home for the
|
|
@@ -139,7 +139,7 @@ export class NarrativeFlushController {
|
|
|
139
139
|
resolveOnTool(toolName: string, input: Record<string, unknown> | undefined): void {
|
|
140
140
|
this.scheduler.disarm()
|
|
141
141
|
const replyText =
|
|
142
|
-
|
|
142
|
+
isReplyTool(toolName) && typeof input?.text === 'string' ? (input.text as string) : null
|
|
143
143
|
if (replyText != null) this.maybeRetract(replyText)
|
|
144
144
|
const pending = this.pending
|
|
145
145
|
if (pending == null) return
|
|
@@ -36,6 +36,25 @@ export interface PendingUserNotice {
|
|
|
36
36
|
kind: string
|
|
37
37
|
/** When the notice was scheduled (ms epoch). */
|
|
38
38
|
atMs: number
|
|
39
|
+
/**
|
|
40
|
+
* TOPIC KEY (#3294) — `statusKey(chatId, threadId)` of the turn that was live
|
|
41
|
+
* when the error surfaced, or `undefined` when no turn was attributable (the
|
|
42
|
+
* error arrived between turns, or after a silence poke nulled the live turn).
|
|
43
|
+
*
|
|
44
|
+
* Why it exists: the api_error operator event is agent-level (its wire
|
|
45
|
+
* `chatId` is always empty), so the gate USED to collapse per-agent and let
|
|
46
|
+
* ANY turn end resolve the notice. Under the PR-4e keyed-liveness flag
|
|
47
|
+
* (concurrent per-topic turns) that is a bug: turn B's reply-less end would
|
|
48
|
+
* flush turn A's pending notice while A may still recover — a FALSE "couldn't
|
|
49
|
+
* complete" claim. Keying the notice by the live turn's topic means only a
|
|
50
|
+
* turn end on the SAME topic resolves it (see {@link
|
|
51
|
+
* PendingUserNoticeGate.resolveTurnEnd}).
|
|
52
|
+
*
|
|
53
|
+
* A notice with an UNDEFINED key keeps the legacy agent-wide resolution (any
|
|
54
|
+
* turn end resolves it), so single-turn gateways — the current norm, where
|
|
55
|
+
* there is only ever one topic — are byte-identical to the pre-#3294 gate.
|
|
56
|
+
*/
|
|
57
|
+
key?: string
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
/** How long a scheduled notice may wait for a resolving turn end. */
|
|
@@ -45,28 +64,55 @@ export class PendingUserNoticeGate {
|
|
|
45
64
|
private pending: PendingUserNotice[] = []
|
|
46
65
|
|
|
47
66
|
/**
|
|
48
|
-
* Schedule a notice for turn-end resolution. Collapses per agent
|
|
49
|
-
* of error lines within one turn holds ONE pending notice
|
|
67
|
+
* Schedule a notice for turn-end resolution. Collapses per (agent, topic key)
|
|
68
|
+
* — a burst of error lines within one turn holds ONE pending notice for that
|
|
69
|
+
* topic, not a stack. Distinct concurrent topics (keyed liveness) each keep
|
|
70
|
+
* their own pending notice, so one topic's burst never overwrites another's.
|
|
71
|
+
* (For a single-turn gateway every notice shares the one topic — or an
|
|
72
|
+
* undefined key — so the collapse is identical to the pre-#3294 per-agent one.)
|
|
50
73
|
*/
|
|
51
74
|
schedule(notice: PendingUserNotice): void {
|
|
52
75
|
this.prune(notice.atMs)
|
|
53
|
-
this.pending = this.pending.filter(
|
|
76
|
+
this.pending = this.pending.filter(
|
|
77
|
+
(p) => !(p.agent === notice.agent && p.key === notice.key),
|
|
78
|
+
)
|
|
54
79
|
this.pending.push(notice)
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
/**
|
|
58
|
-
* Resolve at
|
|
59
|
-
* (the gateway passes `finalAnswerDelivered ||
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
83
|
+
* Resolve at the end of the turn whose topic is `turnKey`. `turnDeliveredReply`
|
|
84
|
+
* is the turn's outcome signal (the gateway passes `finalAnswerDelivered ||
|
|
85
|
+
* replyCalled`).
|
|
86
|
+
*
|
|
87
|
+
* A pending notice is RESOLVED by this turn end iff it belongs to the ending
|
|
88
|
+
* turn's topic (`p.key === turnKey`) OR it is unattributed (`p.key ===
|
|
89
|
+
* undefined` — legacy agent-wide resolution, kept so single-turn gateways are
|
|
90
|
+
* unchanged). Notices scheduled for a DIFFERENT live topic stay pending until
|
|
91
|
+
* their own topic's turn ends (or the TTL expires) — this is the #3294 fix:
|
|
92
|
+
* turn B's end no longer flushes turn A's pending notice.
|
|
93
|
+
*
|
|
94
|
+
* Of the resolved notices:
|
|
95
|
+
* - `turnDeliveredReply === true` → the turn recovered; the resolved
|
|
96
|
+
* notices are dropped and `[]` is returned.
|
|
97
|
+
* - `turnDeliveredReply === false` → the turn died reply-less; the resolved,
|
|
98
|
+
* un-expired notices are returned EXACTLY ONCE for the caller to send.
|
|
99
|
+
* Either way the resolved notices leave the ledger (a notice never survives
|
|
100
|
+
* the end of the turn it is keyed to).
|
|
64
101
|
*/
|
|
65
|
-
resolveTurnEnd(
|
|
102
|
+
resolveTurnEnd(
|
|
103
|
+
turnKey: string | undefined,
|
|
104
|
+
turnDeliveredReply: boolean,
|
|
105
|
+
now: number = Date.now(),
|
|
106
|
+
): PendingUserNotice[] {
|
|
66
107
|
this.prune(now)
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
108
|
+
const resolved: PendingUserNotice[] = []
|
|
109
|
+
const remaining: PendingUserNotice[] = []
|
|
110
|
+
for (const p of this.pending) {
|
|
111
|
+
if (p.key === turnKey || p.key === undefined) resolved.push(p)
|
|
112
|
+
else remaining.push(p)
|
|
113
|
+
}
|
|
114
|
+
this.pending = remaining
|
|
115
|
+
return turnDeliveredReply ? [] : resolved
|
|
70
116
|
}
|
|
71
117
|
|
|
72
118
|
/** True when at least one un-expired notice is pending (does not mutate). */
|
|
@@ -203,10 +203,34 @@ function renderTable(node: TableNode): string {
|
|
|
203
203
|
return [headerLine, sepLine, ...bodyLines].join("\n");
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Escape a line-leading `#` in rendered PROSE so Telegram cannot promote the
|
|
208
|
+
* line to a heading (#3304 regression).
|
|
209
|
+
*
|
|
210
|
+
* Per CommonMark/GFM, `#` opens an ATX heading only when followed by a space,
|
|
211
|
+
* another `#`, or end-of-line — so micromark correctly parses `#3304 is
|
|
212
|
+
* merged.` as a plain paragraph, and this renderer re-emits it verbatim
|
|
213
|
+
* (`escapeMarkdown` deliberately leaves `#` alone). But Telegram's Bot API
|
|
214
|
+
* 10.1 server-side rich-markdown parser is NON-spec here: any line-leading
|
|
215
|
+
* `#` run is read as a heading, so an issue reference opening a line renders
|
|
216
|
+
* the whole paragraph as huge heading text. Backslash-escaping the first `#`
|
|
217
|
+
* of each such line keeps it literal on Telegram while remaining a no-op
|
|
218
|
+
* visually (Telegram honours backslash escapes, same mechanism the link-href
|
|
219
|
+
* `)` escape relies on).
|
|
220
|
+
*
|
|
221
|
+
* Applied ONLY to paragraph output (including paragraphs nested in
|
|
222
|
+
* blockquotes / list items via their renderBlock recursion), never to
|
|
223
|
+
* heading, code-block, or table nodes — so a genuine `# Title` heading node
|
|
224
|
+
* still renders as `# Title`.
|
|
225
|
+
*/
|
|
226
|
+
function escapeLineLeadingHash(text: string): string {
|
|
227
|
+
return text.replace(/^([ \t]{0,3})#/gm, "$1\\#");
|
|
228
|
+
}
|
|
229
|
+
|
|
206
230
|
function renderBlock(node: Block): string {
|
|
207
231
|
switch (node.type) {
|
|
208
232
|
case "paragraph":
|
|
209
|
-
return renderInlineChildren(node.children);
|
|
233
|
+
return escapeLineLeadingHash(renderInlineChildren(node.children));
|
|
210
234
|
case "heading":
|
|
211
235
|
return `${"#".repeat(node.level)} ${renderInlineChildren(node.children)}`;
|
|
212
236
|
case "blockquote":
|
|
@@ -28,6 +28,19 @@ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
|
|
|
28
28
|
*/
|
|
29
29
|
export const STATUS_ROLLING_LINES = 5
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Ceiling on the per-worker recent-step history RETAINED in `row.narrative`
|
|
33
|
+
* (worker-activity-feed.ts) and the deepest trail the worker cards will render.
|
|
34
|
+
*
|
|
35
|
+
* Distinct from `STATUS_ROLLING_LINES` (which governs the 🤖 agent card's
|
|
36
|
+
* window and stays at 5) so raising the worker trail never widens the agent
|
|
37
|
+
* card. Set to 6 because the deterministic per-worker depth curve
|
|
38
|
+
* (`workerHistoryDepth`, tool-activity-summary.ts) peaks at 6 for a lone
|
|
39
|
+
* worker — `max(3, 7 − workerCount)` at `w = 1`. The buffer must retain at
|
|
40
|
+
* least this many lines or the renderer would ask for 6 and only find 5.
|
|
41
|
+
*/
|
|
42
|
+
export const WORKER_HISTORY_MAX = 6
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Per-line character cap, applied to every step + child step on BOTH
|
|
33
46
|
* surfaces before HTML-escaping (clip raw → escape last). A line longer
|