switchroom 0.19.14 → 0.19.16
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 +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -2
- package/telegram-plugin/dist/gateway/gateway.js +1690 -932
- package/telegram-plugin/dist/server.js +31 -2
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +10 -57
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
- package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/turn-flush-safety.ts +66 -53
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- package/telegram-plugin/voice-ondemand.ts +71 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-message inbound handler (forwarded-body drop fix).
|
|
3
|
+
*
|
|
4
|
+
* THE BUG THIS CLOSES (evidence: carrie history.db row message_id=944,
|
|
5
|
+
* 2026-07-24 06:03; gateway-supervisor.log update_id=417526125
|
|
6
|
+
* `content_keys=[forward_from,forward_date,rich_message]`): a message whose
|
|
7
|
+
* content is a Bot API 10.1 `rich_message` — typically a FORWARDED bot
|
|
8
|
+
* message, since the gateway itself sends everything via `sendRichMessage` —
|
|
9
|
+
* carries NO top-level `text` or `caption`. It therefore matched none of the
|
|
10
|
+
* registered `message:text` / `:photo` / `:caption` handlers, fell through to
|
|
11
|
+
* the terminal catch-all (`unhandled-message.ts`), and the agent received the
|
|
12
|
+
* placeholder `(unhandled message content: forward_from)` instead of the real
|
|
13
|
+
* body. The forwarded content was silently dropped.
|
|
14
|
+
*
|
|
15
|
+
* grammy ^1.44 DOES ship a `message:rich_message` filter (see
|
|
16
|
+
* `@grammyjs/types` `RichMessage` / `RichBlock` / `RichText`) — the gateway
|
|
17
|
+
* simply never registered it. This module renders the inbound block tree to
|
|
18
|
+
* plain markdown-ish text deterministically and routes it through the SAME
|
|
19
|
+
* inbound pipeline as `message:text` (access gating + forward-origin parsing
|
|
20
|
+
* happen downstream, unchanged — origin metadata stays on the trusted
|
|
21
|
+
* `<channel>`-attr lane per #3162; nothing here injects provenance into the
|
|
22
|
+
* body).
|
|
23
|
+
*
|
|
24
|
+
* Registration stays in gateway.ts (order pinned by
|
|
25
|
+
* gateway-handler-registration-wiring.test.ts): `message:rich_message` is
|
|
26
|
+
* registered BEFORE the terminal catch-all, so the catch-all no longer sees
|
|
27
|
+
* these messages at all. `planUnhandledMessage` also imports
|
|
28
|
+
* `extractRichMessageText` as belt-and-braces for any future shape that
|
|
29
|
+
* carries a `rich_message` alongside unknown content.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Context, Filter } from 'grammy'
|
|
33
|
+
import type { MediaEnvelopeDeps } from './media-message-handlers.js'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Delivered body when a rich message renders to nothing extractable (e.g. a
|
|
37
|
+
* pure media/divider composition). Distinct from the catch-all's
|
|
38
|
+
* `(unhandled message content: …)` placeholder — this one names the actual
|
|
39
|
+
* situation and still yields a turn (fail-toward-delivery).
|
|
40
|
+
*/
|
|
41
|
+
export const RICH_MESSAGE_EMPTY_TEXT = '(rich message with no extractable text)'
|
|
42
|
+
|
|
43
|
+
/** Recursion guard: the Bot API caps rich messages at 16 nesting levels; a
|
|
44
|
+
* hostile/malformed payload deeper than this is truncated, not stack-overflowed. */
|
|
45
|
+
const MAX_DEPTH = 32
|
|
46
|
+
|
|
47
|
+
interface RichTextNode {
|
|
48
|
+
type?: string
|
|
49
|
+
text?: unknown
|
|
50
|
+
expression?: string
|
|
51
|
+
alternative_text?: string
|
|
52
|
+
name?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Flatten a `RichText` union (string | RichText[] | typed node) to plain
|
|
57
|
+
* text. Inline formatting (bold/italic/…) is dropped — the agent needs the
|
|
58
|
+
* CONTENT; re-marking inline spans risks re-introducing accidental-formatting
|
|
59
|
+
* classes the render guards exist to kill. Leaf specials keep their readable
|
|
60
|
+
* payload: custom emoji → alternative text, math → LaTeX source, anchors →
|
|
61
|
+
* nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function renderRichText(rt: unknown, depth = 0): string {
|
|
64
|
+
if (depth > MAX_DEPTH || rt == null) return ''
|
|
65
|
+
if (typeof rt === 'string') return rt
|
|
66
|
+
if (Array.isArray(rt)) return rt.map(t => renderRichText(t, depth + 1)).join('')
|
|
67
|
+
if (typeof rt !== 'object') return ''
|
|
68
|
+
const node = rt as RichTextNode
|
|
69
|
+
if (node.type === 'custom_emoji') return node.alternative_text ?? ''
|
|
70
|
+
if (node.type === 'mathematical_expression') {
|
|
71
|
+
return typeof node.expression === 'string' ? node.expression : ''
|
|
72
|
+
}
|
|
73
|
+
if (node.type === 'anchor') return ''
|
|
74
|
+
// Every other RichText node (bold, italic, url, mention, code, …) wraps a
|
|
75
|
+
// `text: RichText` payload — recurse into it.
|
|
76
|
+
if ('text' in node) return renderRichText(node.text, depth + 1)
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface RichBlockNode {
|
|
81
|
+
type?: string
|
|
82
|
+
text?: unknown
|
|
83
|
+
credit?: unknown
|
|
84
|
+
language?: string
|
|
85
|
+
expression?: string
|
|
86
|
+
summary?: unknown
|
|
87
|
+
blocks?: unknown
|
|
88
|
+
items?: Array<{ label?: string; blocks?: unknown; has_checkbox?: boolean; is_checked?: boolean }>
|
|
89
|
+
cells?: Array<Array<{ text?: unknown }>>
|
|
90
|
+
caption?: { text?: unknown; credit?: unknown } | unknown
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderCaption(caption: unknown, depth: number): string {
|
|
94
|
+
if (caption == null || typeof caption !== 'object') return renderRichText(caption, depth)
|
|
95
|
+
const c = caption as { text?: unknown; credit?: unknown }
|
|
96
|
+
const text = renderRichText(c.text, depth)
|
|
97
|
+
const credit = renderRichText(c.credit, depth)
|
|
98
|
+
return [text, credit ? `— ${credit}` : ''].filter(Boolean).join(' ')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderBlocks(blocks: unknown, depth: number): string {
|
|
102
|
+
if (depth > MAX_DEPTH || !Array.isArray(blocks)) return ''
|
|
103
|
+
return blocks
|
|
104
|
+
.map(b => renderRichBlock(b, depth + 1))
|
|
105
|
+
.filter(s => s.length > 0)
|
|
106
|
+
.join('\n')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Render one `RichBlock` to a plain-text line/paragraph. Unknown block types
|
|
110
|
+
* degrade to their `text` / nested `blocks` when present (fail-toward-content:
|
|
111
|
+
* a future block type should surface its words, not vanish). */
|
|
112
|
+
export function renderRichBlock(block: unknown, depth = 0): string {
|
|
113
|
+
if (depth > MAX_DEPTH || block == null || typeof block !== 'object') return ''
|
|
114
|
+
const b = block as RichBlockNode
|
|
115
|
+
switch (b.type) {
|
|
116
|
+
case 'paragraph':
|
|
117
|
+
case 'heading':
|
|
118
|
+
case 'footer':
|
|
119
|
+
return renderRichText(b.text, depth)
|
|
120
|
+
case 'pre': {
|
|
121
|
+
const body = renderRichText(b.text, depth)
|
|
122
|
+
return body.length > 0 ? `\`\`\`${b.language ?? ''}\n${body}\n\`\`\`` : ''
|
|
123
|
+
}
|
|
124
|
+
case 'divider':
|
|
125
|
+
return '---'
|
|
126
|
+
case 'mathematical_expression':
|
|
127
|
+
return typeof b.expression === 'string' ? b.expression : ''
|
|
128
|
+
case 'anchor':
|
|
129
|
+
return ''
|
|
130
|
+
case 'list': {
|
|
131
|
+
if (!Array.isArray(b.items)) return ''
|
|
132
|
+
return b.items
|
|
133
|
+
.map(item => {
|
|
134
|
+
const body = renderBlocks(item?.blocks, depth)
|
|
135
|
+
const check = item?.has_checkbox ? (item.is_checked ? '[x] ' : '[ ] ') : ''
|
|
136
|
+
const label = typeof item?.label === 'string' && item.label.length > 0 ? item.label : '-'
|
|
137
|
+
return body.length > 0 ? `${label} ${check}${body}` : ''
|
|
138
|
+
})
|
|
139
|
+
.filter(s => s.length > 0)
|
|
140
|
+
.join('\n')
|
|
141
|
+
}
|
|
142
|
+
case 'blockquote': {
|
|
143
|
+
const body = renderBlocks(b.blocks, depth)
|
|
144
|
+
const credit = renderRichText(b.credit, depth)
|
|
145
|
+
const quoted = body
|
|
146
|
+
.split('\n')
|
|
147
|
+
.map(line => `> ${line}`)
|
|
148
|
+
.join('\n')
|
|
149
|
+
return [quoted, credit ? `> — ${credit}` : ''].filter(s => s.length > 0).join('\n')
|
|
150
|
+
}
|
|
151
|
+
case 'pullquote': {
|
|
152
|
+
const body = renderRichText(b.text, depth)
|
|
153
|
+
const credit = renderRichText(b.credit, depth)
|
|
154
|
+
return [body, credit ? `— ${credit}` : ''].filter(Boolean).join(' ')
|
|
155
|
+
}
|
|
156
|
+
case 'details': {
|
|
157
|
+
const summary = renderRichText(b.summary, depth)
|
|
158
|
+
const body = renderBlocks(b.blocks, depth)
|
|
159
|
+
return [summary, body].filter(s => s.length > 0).join('\n')
|
|
160
|
+
}
|
|
161
|
+
case 'collage':
|
|
162
|
+
case 'slideshow': {
|
|
163
|
+
const body = renderBlocks(b.blocks, depth)
|
|
164
|
+
const caption = renderCaption(b.caption, depth)
|
|
165
|
+
return [body, caption].filter(s => s.length > 0).join('\n')
|
|
166
|
+
}
|
|
167
|
+
case 'table': {
|
|
168
|
+
if (!Array.isArray(b.cells)) return ''
|
|
169
|
+
const rows = b.cells
|
|
170
|
+
.map(row =>
|
|
171
|
+
Array.isArray(row)
|
|
172
|
+
? row.map(cell => renderRichText(cell?.text, depth)).join(' | ')
|
|
173
|
+
: '',
|
|
174
|
+
)
|
|
175
|
+
.filter(s => s.length > 0)
|
|
176
|
+
.join('\n')
|
|
177
|
+
const caption = renderRichText((b as { caption?: unknown }).caption, depth)
|
|
178
|
+
return [caption, rows].filter(s => s.length > 0).join('\n')
|
|
179
|
+
}
|
|
180
|
+
case 'map':
|
|
181
|
+
case 'animation':
|
|
182
|
+
case 'audio':
|
|
183
|
+
case 'photo':
|
|
184
|
+
case 'video':
|
|
185
|
+
case 'voice_note': {
|
|
186
|
+
// Media blocks: the binary itself is not downloaded here (a forwarded
|
|
187
|
+
// rich message's media has no coalescing/attachment plumbing yet — see
|
|
188
|
+
// PR description follow-up); the caption text IS the readable content.
|
|
189
|
+
const caption = renderCaption(b.caption, depth)
|
|
190
|
+
const tag = `[${b.type}]`
|
|
191
|
+
return caption.length > 0 ? `${tag} ${caption}` : tag
|
|
192
|
+
}
|
|
193
|
+
case 'thinking':
|
|
194
|
+
return ''
|
|
195
|
+
default: {
|
|
196
|
+
// Unknown/future block type — surface any text-ish payload it carries.
|
|
197
|
+
const text = renderRichText(b.text, depth)
|
|
198
|
+
if (text.length > 0) return text
|
|
199
|
+
return renderBlocks(b.blocks, depth)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Extract the readable text of an inbound `message.rich_message`, or
|
|
206
|
+
* `undefined` when nothing extractable exists. Accepts `unknown` — the value
|
|
207
|
+
* arrives from the Telegram wire and must never throw the handler.
|
|
208
|
+
*/
|
|
209
|
+
export function extractRichMessageText(rich: unknown): string | undefined {
|
|
210
|
+
if (rich == null || typeof rich !== 'object') return undefined
|
|
211
|
+
const blocks = (rich as { blocks?: unknown }).blocks
|
|
212
|
+
const rendered = renderBlocks(blocks, 0)
|
|
213
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
214
|
+
.trim()
|
|
215
|
+
return rendered.length > 0 ? rendered : undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `message:rich_message` handler — renders the body and hands it to the
|
|
220
|
+
* normal COALESCING inbound pipeline, identical to `message:text`. It shares
|
|
221
|
+
* the cluster-A `MediaEnvelopeDeps` SHAPE, but gateway.ts binds this handler's
|
|
222
|
+
* `deps.handleInbound` to `handleInboundCoalesced` via an inline spread at the
|
|
223
|
+
* registration site — `{ ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }`
|
|
224
|
+
* — NOT the bare `handleInbound` the media-envelope handlers use: a rich
|
|
225
|
+
* message is pure forwarded body text with no attachment, so a forwarded bot
|
|
226
|
+
* message arriving in the same sliding window as another inbound folds into one
|
|
227
|
+
* turn (same coalescing contract as `message:text`). Access gating +
|
|
228
|
+
* forward-origin parsing happen downstream, unchanged.
|
|
229
|
+
*/
|
|
230
|
+
export async function handleRichMessageMessage(
|
|
231
|
+
ctx: Filter<Context, 'message:rich_message'>,
|
|
232
|
+
deps: MediaEnvelopeDeps,
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
try {
|
|
235
|
+
const text = extractRichMessageText(ctx.message.rich_message) ?? RICH_MESSAGE_EMPTY_TEXT
|
|
236
|
+
deps.log(`telegram gateway: inbound rich_message from chat=${ctx.chat?.id ?? '?'} chars=${text.length}\n`)
|
|
237
|
+
await deps.handleInbound(ctx, text, undefined)
|
|
238
|
+
} catch (err) {
|
|
239
|
+
deps.log(`telegram gateway: rich_message handler error: ${(err as Error).message}\n`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// #1122 silence-poke session-event wiring.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from gateway.ts (the file is under a hard line ratchet — see
|
|
4
|
+
// switchroom#2996). Maps a parsed session event onto the silence-poke activity
|
|
5
|
+
// registry so the 300s framework-fallback message wording is honest (thinking
|
|
6
|
+
// vs working, plus the longest-running in-flight tool), threads the #1445
|
|
7
|
+
// cross-turn pending-async ambient, and feeds the #3519 background-shell
|
|
8
|
+
// liveness signal. Called once per session event from the gateway's event loop
|
|
9
|
+
// while a turn is live (the caller owns the `currentTurn != null` guard and the
|
|
10
|
+
// `key` derivation).
|
|
11
|
+
import type { SessionEvent } from '../session-tail.js'
|
|
12
|
+
import { isTelegramSurfaceTool } from '../tool-names.js'
|
|
13
|
+
import { toolLabel } from '../tool-labels.js'
|
|
14
|
+
import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
|
|
15
|
+
|
|
16
|
+
/** The silence-poke module namespace (kept as `typeof` so no surface drift). */
|
|
17
|
+
type SilencePoke = typeof import('../silence-poke.js')
|
|
18
|
+
/** The pending-work-progress module namespace. */
|
|
19
|
+
type PendingProgress = typeof import('../pending-work-progress.js')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply the activity signals carried by `ev` to silence-poke / pending-progress
|
|
23
|
+
* for the live turn identified by `key`. Behaviour is identical to the inline
|
|
24
|
+
* block this replaced — a pure extract/move.
|
|
25
|
+
*/
|
|
26
|
+
export function applySilencePokeSessionEvent(
|
|
27
|
+
silencePoke: SilencePoke,
|
|
28
|
+
pendingProgress: PendingProgress,
|
|
29
|
+
key: string,
|
|
30
|
+
ev: SessionEvent,
|
|
31
|
+
): void {
|
|
32
|
+
if (ev.kind === 'thinking') {
|
|
33
|
+
silencePoke.noteThinking(key, Date.now())
|
|
34
|
+
} else if (ev.kind === 'tool_use') {
|
|
35
|
+
// #1292: track in-flight tool calls so the 300s framework
|
|
36
|
+
// fallback message can name the actual observable (e.g.
|
|
37
|
+
// "running Grep \"foo\" for 4m") instead of the dishonest
|
|
38
|
+
// generic "still working… no update in 5 min" when the agent
|
|
39
|
+
// is clearly busy on tool calls. Telegram-surface tools are
|
|
40
|
+
// excluded — their job IS the outbound message, the silence
|
|
41
|
+
// clock resets via noteOutbound when they fire. Sub-agent
|
|
42
|
+
// tool_use events (kind='sub_agent_tool_use') intentionally
|
|
43
|
+
// NOT tracked: the parent's Task tool_use is already on the
|
|
44
|
+
// map and represents the user-observable wait.
|
|
45
|
+
if (
|
|
46
|
+
ev.toolUseId != null
|
|
47
|
+
&& ev.toolUseId.length > 0
|
|
48
|
+
&& !isTelegramSurfaceTool(ev.toolName)
|
|
49
|
+
) {
|
|
50
|
+
const label = toolLabel(
|
|
51
|
+
ev.toolName,
|
|
52
|
+
ev.input,
|
|
53
|
+
/*preamble*/ undefined,
|
|
54
|
+
ev.precomputedLabel,
|
|
55
|
+
)
|
|
56
|
+
silencePoke.noteToolStart(
|
|
57
|
+
key,
|
|
58
|
+
ev.toolUseId,
|
|
59
|
+
ev.toolName,
|
|
60
|
+
label.length > 0 ? label : null,
|
|
61
|
+
Date.now(),
|
|
62
|
+
)
|
|
63
|
+
// #1445 cross-turn pending-async ambient. Mark the chat as
|
|
64
|
+
// having dispatched background work this turn so a turn_end
|
|
65
|
+
// that follows activates the edit-in-place ambient line.
|
|
66
|
+
// Covers `Agent` / `Task` (the harness-managed async path
|
|
67
|
+
// — handback channel turn clears it) and `Bash` with
|
|
68
|
+
// run_in_background:true (model is expected to poll
|
|
69
|
+
// BashOutput; the ambient ticks until next inbound or the
|
|
70
|
+
// 30-min budget cap).
|
|
71
|
+
const evInput = ev.input as { run_in_background?: boolean } | undefined
|
|
72
|
+
if (
|
|
73
|
+
ev.toolName === 'Agent'
|
|
74
|
+
|| ev.toolName === 'Task'
|
|
75
|
+
|| (ev.toolName === 'Bash' && evInput?.run_in_background === true)
|
|
76
|
+
) {
|
|
77
|
+
pendingProgress.noteAsyncDispatch(key)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} else if (ev.kind === 'tool_result') {
|
|
81
|
+
// #1292: drain the in-flight entry. Idempotent on unknown ids
|
|
82
|
+
// (covers Telegram-surface tools we skipped at start time).
|
|
83
|
+
if (ev.toolUseId != null && ev.toolUseId.length > 0) {
|
|
84
|
+
silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
|
|
88
|
+
applyBackgroundShellLiveness(silencePoke, key, ev)
|
|
89
|
+
}
|
|
@@ -59,11 +59,15 @@ import { normalizeOutboundBody } from './outbound-send-path.js'
|
|
|
59
59
|
import { resolveEnvTimezone } from '../shared/local-time.js'
|
|
60
60
|
import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
|
|
61
61
|
import { isReplyTool } from '../narrative-dedup.js'
|
|
62
|
+
import { isEphemeralTool } from '../hooks/narration-classify.mjs'
|
|
63
|
+
import { backstopAlreadyDelivered } from '../outbox.js'
|
|
64
|
+
import { journalExternalDelivery } from './outbox-sweep.js'
|
|
62
65
|
import { NarrativeFlushController } from '../narrative-flush.js'
|
|
63
66
|
import { recordTurnEnd, recordTurnStart } from '../registry/turns-schema.js'
|
|
64
67
|
import { retryWithThreadFallback } from '../retry-api-call.js'
|
|
65
68
|
import { richMessage } from '../rich-send.js'
|
|
66
69
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
70
|
+
import { isShownBlock } from '../shown-ledger.js'
|
|
67
71
|
import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
|
|
68
72
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
69
73
|
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
@@ -583,11 +587,21 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
583
587
|
case 'tool_use': {
|
|
584
588
|
const turn = getCurrentTurn()
|
|
585
589
|
if (turn == null) return
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
590
|
+
// #3513 follow-up (MF1 + MF4b) — a TURN-CONTINUING tool_use (any tool NOT
|
|
591
|
+
// in the ephemeral surface set) is the deterministic signal that every text
|
|
592
|
+
// block captured so far in this turn was intra-turn narration, not the
|
|
593
|
+
// terminal answer. Retro-mark ALL existing captured blocks
|
|
594
|
+
// (`capturedBlockMeta.fill(true)`) so the cross-message shape
|
|
595
|
+
// ([text-only message] → [tool_use in the NEXT message]) is actually seen
|
|
596
|
+
// by `selectBackstopDelivery` — the per-message `!ev.lastInMessage` push at
|
|
597
|
+
// the `text` case under-detects it. The SAME ephemeral gate protects the
|
|
598
|
+
// E2 answer-ready fast path: a trailing ephemeral tool (answer, then
|
|
599
|
+
// react / pin / typing / edit / delete) must neither mark the answer
|
|
600
|
+
// interim NOR disarm the quiescence flush.
|
|
601
|
+
if (!isEphemeralTool(ev.toolName)) {
|
|
602
|
+
turn.capturedBlockMeta.fill(true)
|
|
603
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
604
|
+
}
|
|
591
605
|
// Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
|
|
592
606
|
// narrative block was pending; this tool_use is the lookahead event
|
|
593
607
|
// that decides it. reply/stream_reply with near-identical text ⇒
|
|
@@ -678,8 +692,13 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
678
692
|
// PR A — a tool_label (real-time, ~250 ms) means the model is producing
|
|
679
693
|
// work right now: cancel any pending answer-ready quiescence flush (the
|
|
680
694
|
// turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
|
|
681
|
-
// the timer at the earliest deterministic point.
|
|
682
|
-
|
|
695
|
+
// the timer at the earliest deterministic point. MF4b: an EPHEMERAL surface
|
|
696
|
+
// tool (react / pin / typing / edit / delete fired after a terminal answer)
|
|
697
|
+
// must NOT disarm the quiescence flush — gate on the same ephemeral check as
|
|
698
|
+
// the tool_use reducer so answer-then-react keeps the E2 fast path.
|
|
699
|
+
if (!isEphemeralTool(ev.toolName)) {
|
|
700
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
701
|
+
}
|
|
683
702
|
// SECONDARY FIX: an active tool_label means the model is producing work
|
|
684
703
|
// right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
|
|
685
704
|
// (write → compile → test → fix) that regularly emits labels doesn't let
|
|
@@ -826,6 +845,20 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
826
845
|
// negation is the draft-then-send narration signal the turn-flush strip
|
|
827
846
|
// uses (`selectFlushDeliveryText`) to keep a real answer intact instead
|
|
828
847
|
// of truncating a paragraph that merely opens with "Let me explain…".
|
|
848
|
+
//
|
|
849
|
+
// NOTE (naming/approximation, #3515 review nit): downstream this value is
|
|
850
|
+
// consumed as `followedByToolUse`, but `!ev.lastInMessage` is a CONSERVATIVE
|
|
851
|
+
// APPROXIMATION of that predicate, not an exact match. It is true when the
|
|
852
|
+
// block is not the last block in its assistant message OR when a tool_use
|
|
853
|
+
// follows it in the same turn — i.e. it can over-flag: a block that is
|
|
854
|
+
// genuinely last-in-message may still be marked true. Over-approximation is
|
|
855
|
+
// SAFE here by construction: a `true` flag only ever makes a block a
|
|
856
|
+
// candidate for structural-narration suppression (selectFlushDeliveryText /
|
|
857
|
+
// isStructuralNarration), so the worst case is suppressing MORE narration —
|
|
858
|
+
// it can never promote an answer into the drop path. A real terminal answer
|
|
859
|
+
// is protected independently (it is never followed by a tool and survives
|
|
860
|
+
// the strip). Do not "tighten" this to the exact predicate expecting a
|
|
861
|
+
// behavioural change: the runtime value is deliberately conservative.
|
|
829
862
|
turn.capturedBlockMeta.push(!ev.lastInMessage)
|
|
830
863
|
// Narrative-dedup gate step 1 (JSONL-text-narrative primitive):
|
|
831
864
|
// stage this text block for one lookahead step. If a previous block
|
|
@@ -1739,6 +1772,24 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1739
1772
|
} catch {}
|
|
1740
1773
|
}
|
|
1741
1774
|
|
|
1775
|
+
// #3513 follow-up (MF2) — DURABLE exactly-once-among-backstops read.
|
|
1776
|
+
// The in-memory `backstopDeliveryLedger` (guard 5, below) only sees
|
|
1777
|
+
// fires within THIS process; it cannot see a prior backstop delivery
|
|
1778
|
+
// that landed in an earlier process (e.g. the Stop-hook captured-prose
|
|
1779
|
+
// bridge E3, or the outbox sweep E4, delivered this turn's answer, then
|
|
1780
|
+
// the gateway restarted and re-ran turn-flush). `backstopAlreadyDelivered`
|
|
1781
|
+
// scans the durable delivered-keys journal counting ONLY prior BACKSTOP
|
|
1782
|
+
// deliveries (sweep / flush / non-E0 reply-tool) for this nonce — it does
|
|
1783
|
+
// NOT count an explicit E0 reply (#3510 recap), so a legitimate later
|
|
1784
|
+
// explicit reply is never blocked by this guard. If a backstop already
|
|
1785
|
+
// delivered, this fire is a durable no-op.
|
|
1786
|
+
if (backstopAlreadyDelivered(turn.turnId, STATE_DIR)) {
|
|
1787
|
+
process.stderr.write(
|
|
1788
|
+
`telegram gateway: turn-flush skipped — turn ${turn.turnId} already delivered by a prior backstop (durable journal)\n`,
|
|
1789
|
+
)
|
|
1790
|
+
return
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1742
1793
|
// #3276 guard 5 — double-fire guard. If this turn already claimed the
|
|
1743
1794
|
// delivery latch (a prior backstop fire — e.g. answer-ready quiescence
|
|
1744
1795
|
// followed by the turn-end backstop for the same turn), do NOT deliver
|
|
@@ -1869,6 +1920,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
1869
1920
|
chunkCount,
|
|
1870
1921
|
cardMessageId: backstopCardMessageId,
|
|
1871
1922
|
})
|
|
1923
|
+
// #3513 follow-up (MF2) — DURABLE exactly-once-among-backstops
|
|
1924
|
+
// WRITE. Journal this backstop delivery to the delivered-keys
|
|
1925
|
+
// journal with `deliverySource:'flush'` so a later backstop in a
|
|
1926
|
+
// DIFFERENT process (the Stop-hook bridge E3 or the outbox sweep E4,
|
|
1927
|
+
// which read `backstopAlreadyDelivered`) recognises this turn's
|
|
1928
|
+
// answer as already delivered and skips a duplicate durably — not
|
|
1929
|
+
// only via the in-memory ledger, which does not survive a crash
|
|
1930
|
+
// between this send and the next process's backstop. Journal ONLY on
|
|
1931
|
+
// a receipt-gated `delivered` success. Best-effort: a journal-write
|
|
1932
|
+
// failure must never demote the successful delivery.
|
|
1933
|
+
if (delivered) {
|
|
1934
|
+
try {
|
|
1935
|
+
journalExternalDelivery(
|
|
1936
|
+
{
|
|
1937
|
+
turnNonce: turn.turnId,
|
|
1938
|
+
text: capturedText,
|
|
1939
|
+
tgMessageId: sentIds.length > 0 ? sentIds[0] : undefined,
|
|
1940
|
+
deliverySource: 'flush',
|
|
1941
|
+
},
|
|
1942
|
+
STATE_DIR,
|
|
1943
|
+
)
|
|
1944
|
+
} catch (err) {
|
|
1945
|
+
process.stderr.write(
|
|
1946
|
+
`telegram gateway: turn-flush delivered but journal write failed (non-fatal): ${(err as Error).message}\n`,
|
|
1947
|
+
)
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1872
1950
|
if (OBLIGATION_LEDGER_ENABLED) {
|
|
1873
1951
|
if (delivered) {
|
|
1874
1952
|
obligationLedger.close(turn.turnId)
|
|
@@ -2012,14 +2090,28 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
|
|
|
2012
2090
|
const proseMinChars =
|
|
2013
2091
|
!turn.replyCalled && gatewayCapturedEmpty ? 1 : CAPTURED_PROSE_MIN_CHARS
|
|
2014
2092
|
const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
|
|
2015
|
-
? decideCapturedProseDelivery(
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2093
|
+
? decideCapturedProseDelivery(
|
|
2094
|
+
{
|
|
2095
|
+
turnKey: tKey,
|
|
2096
|
+
// Per-turn nonce (#3228 Finding 3) — the persisted record must
|
|
2097
|
+
// belong to THIS turn, not a stale carryover from a prior turn
|
|
2098
|
+
// on the same chat/thread (tKey is not per-turn unique).
|
|
2099
|
+
turnId: turn.turnId,
|
|
2100
|
+
minChars: proseMinChars,
|
|
2101
|
+
},
|
|
2102
|
+
{
|
|
2103
|
+
// #3513 (correction 1): refuse to bridge a block already
|
|
2104
|
+
// surfaced on the ephemeral card for this turn (shown-ledger).
|
|
2105
|
+
isBlockShown: (nonce, text) => isShownBlock(nonce ?? null, text),
|
|
2106
|
+
// #3513 follow-up (MF2): refuse to bridge a turn a prior
|
|
2107
|
+
// backstop (turn-flush E1/E2 flush, or the outbox sweep E4)
|
|
2108
|
+
// already delivered — durable exactly-once-among-backstops,
|
|
2109
|
+
// scoped to backstop deliveries only (never an explicit E0
|
|
2110
|
+
// reply, so a genuine later reply is unaffected).
|
|
2111
|
+
backstopDeliveredNonceHit: (nonce) =>
|
|
2112
|
+
backstopAlreadyDelivered(nonce ?? '', STATE_DIR),
|
|
2113
|
+
},
|
|
2114
|
+
)
|
|
2023
2115
|
: { deliver: false as const, reason: 'no-state' as const }
|
|
2024
2116
|
if (proseDecision.deliver && proseDecision.text != null) {
|
|
2025
2117
|
// Deliver the recovered answer directly. This runs async and owns
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { Bot, Context } from 'grammy'
|
|
25
|
+
import { extractRichMessageText } from './rich-message-handler.js'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Top-level `message` envelope fields — identity/routing metadata, excluded
|
|
@@ -36,6 +37,14 @@ export const MESSAGE_ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
|
36
37
|
'business_connection_id', 'effect_id', 'has_protected_content',
|
|
37
38
|
'is_from_offline', 'link_preview_options', 'show_caption_above_media',
|
|
38
39
|
'entities', 'caption_entities', 'paid_star_count',
|
|
40
|
+
// Legacy (pre-Bot-API-7.0 spelling) forward metadata. Some wire payloads
|
|
41
|
+
// carry these ALONGSIDE `forward_origin` (observed live: carrie
|
|
42
|
+
// update_id=417526125, content_keys=[forward_from,forward_date,rich_message]).
|
|
43
|
+
// They are provenance metadata, not content — without this exclusion a
|
|
44
|
+
// forwarded unhandled message gets mislabeled `(unhandled message content:
|
|
45
|
+
// forward_from)` instead of naming its actual content type.
|
|
46
|
+
'forward_from', 'forward_from_chat', 'forward_from_message_id',
|
|
47
|
+
'forward_signature', 'forward_sender_name', 'forward_date',
|
|
39
48
|
])
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -88,6 +97,11 @@ export function planUnhandledMessage(msg: Record<string, unknown>): UnhandledMes
|
|
|
88
97
|
const text =
|
|
89
98
|
(typeof msg.text === 'string' ? msg.text : undefined) ??
|
|
90
99
|
(typeof msg.caption === 'string' ? msg.caption : undefined) ??
|
|
100
|
+
// Belt-and-braces: `message:rich_message` has its own registered handler
|
|
101
|
+
// (rich-message-handler.ts), so a rich message normally never reaches the
|
|
102
|
+
// catch-all — but if one arrives ALONGSIDE unknown future content, its
|
|
103
|
+
// real body still beats a placeholder.
|
|
104
|
+
extractRichMessageText(msg.rich_message) ??
|
|
91
105
|
`(unhandled message content: ${contentType})`
|
|
92
106
|
return { action: 'turn', text, contentKeys }
|
|
93
107
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the shared trailing-text narration classifier
|
|
3
|
+
* (switchroom#3513) so the bundled TypeScript surfaces — `turn-flush-safety.ts`,
|
|
4
|
+
* `shown-ledger.ts`, `narrative-flush.ts` — can import the ONE classifier from
|
|
5
|
+
* `narration-classify.mjs` without tsc resolution errors. The runtime module is
|
|
6
|
+
* plain ESM (it is also imported by the unbundled Stop-hook `.mjs`, which can
|
|
7
|
+
* only import sibling `.mjs` + node builtins), so it cannot be authored in TS.
|
|
8
|
+
*/
|
|
9
|
+
export const SUBSTANTIVE_MIN_CHARS: number
|
|
10
|
+
export const NARRATION_OPENER: RegExp
|
|
11
|
+
export const NARRATION_TRAILER: RegExp
|
|
12
|
+
export function isTrailingNarrationLine(block: string): boolean
|
|
13
|
+
export function isNarrationBlock(block: string): boolean
|
|
14
|
+
export function isStructuralNarration(
|
|
15
|
+
text: string,
|
|
16
|
+
followedByToolUse: boolean | undefined,
|
|
17
|
+
): boolean
|
|
18
|
+
export const EPHEMERAL_TOOLS: Set<string>
|
|
19
|
+
export function isEphemeralTool(name: string | null | undefined): boolean
|
|
20
|
+
export function selectBackstopDelivery(
|
|
21
|
+
blocks: ReadonlyArray<{ text: string; followedByToolUse?: boolean }>,
|
|
22
|
+
): { text: string } | null
|
|
23
|
+
export function ledgerHashHex(text: string): string
|