switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- 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/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/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/gateway.ts +1347 -6758
- 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-wiring.ts +333 -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/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- 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/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 +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -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 +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- 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-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- 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 +27 -9
- 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/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- 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/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 +28 -18
- 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 +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -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 +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- 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 +10 -4
|
@@ -0,0 +1,2166 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Stream / render dispatcher — the `handleSessionEvent` switch, relocated
|
|
3
|
+
// VERBATIM from gateway.ts (switchroom#2996 P4-A, plan Amendments 1/5/9/10).
|
|
4
|
+
//
|
|
5
|
+
// WHAT THIS IS
|
|
6
|
+
// -----------
|
|
7
|
+
// `handleSessionEvent` is the giant per-session-event switch that drives every
|
|
8
|
+
// live surface (progress card, answer stream, activity/liveness lanes, turn
|
|
9
|
+
// lifecycle, silent-end recovery). It was ~1,970 inline lines in gateway.ts;
|
|
10
|
+
// this module holds the body so the file drains toward its ratchet ceiling.
|
|
11
|
+
//
|
|
12
|
+
// DI CONTRACT (plan Amendment 1/5/9 — read before editing)
|
|
13
|
+
// ---------------------------------------------------------
|
|
14
|
+
// - The body is BYTE-IDENTICAL to the pre-move gateway.ts inline body except
|
|
15
|
+
// for the enumerated turn/mutable-state spellings below and the deps
|
|
16
|
+
// destructure preamble. Do NOT "clean up" while moving — behavior-changing
|
|
17
|
+
// work ships as SEPARATE PRs (plan §1).
|
|
18
|
+
// - SHARED SINGLETONS (Amendment 1, BLOCKING): `outboundDedup` and
|
|
19
|
+
// `backstopDeliveryLedger` are injected — THE one live instance each. The
|
|
20
|
+
// answer-stream dedup sites in this module record into the SAME
|
|
21
|
+
// `OutboundDedupCache` that P2's `sendReply` checks; a re-`new` here would
|
|
22
|
+
// reinstate the cross-surface duplicate-reply class. This file NEVER
|
|
23
|
+
// re-constructs the dedup cache or the delivery ledger — the golden
|
|
24
|
+
// harness pins that (send-reply-golden + stream-render-golden).
|
|
25
|
+
// - TURN HANDLE (Amendment 9 / #1664): the module NEVER reads the
|
|
26
|
+
// `currentTurn` module global directly. Every live re-read is routed
|
|
27
|
+
// through the injected `getCurrentTurn()` accessor, preserving each call
|
|
28
|
+
// site's pin-vs-live choice VERBATIM (a `const turn = currentTurn` pin
|
|
29
|
+
// becomes `const turn = getCurrentTurn()`; a late `currentTurn === turn`
|
|
30
|
+
// liveness check stays live via `getCurrentTurn() === turn`). Writes to the
|
|
31
|
+
// turn go through the injected `setCurrentTurn` closure, exactly as before.
|
|
32
|
+
// - Two other reassigned module-globals are routed through get/set accessors
|
|
33
|
+
// so the extracted body cannot fork gateway state: `pendingPtyPartial`
|
|
34
|
+
// (`getPendingPtyPartial()`/`setPendingPtyPartial()`) and
|
|
35
|
+
// `lastContextExhaustionWarningAt`
|
|
36
|
+
// (`getLastContextExhaustionWarningAt()`/`setLastContextExhaustionWarningAt()`).
|
|
37
|
+
// One PTY-partial guard is spelled "capture-once-then-guard" (a call
|
|
38
|
+
// expression can't be narrowed like the pre-move variable was) — provably
|
|
39
|
+
// equivalent (no await/mutation between the two reads), flagged inline.
|
|
40
|
+
// - Pure / leaf-module helpers are IMPORTED (same modules gateway.ts imports
|
|
41
|
+
// them from — ES modules are singletons, so the ambient trackers
|
|
42
|
+
// `pendingProgress` / `signalTracker` / `silencePoke` are the SAME
|
|
43
|
+
// instances). Everything gateway-scoped (state singletons, config values,
|
|
44
|
+
// local closures, `bot`, `turnsDb`) is INJECTED via `StreamRenderDeps`.
|
|
45
|
+
//
|
|
46
|
+
// ORACLE (Amendment 10): gateway.ts cannot be driven in-place from a test
|
|
47
|
+
// runner (`handleSessionEvent` was unexported; `bot` is assigned only inside
|
|
48
|
+
// the isGatewayMain boot). Behavior preservation rests on (a) the verbatim
|
|
49
|
+
// relocation, (b) the deps type being `ReturnType<typeof gatewayStreamRenderDeps>`
|
|
50
|
+
// (exact-by-construction — the gateway wiring IS the type), and (c) the
|
|
51
|
+
// extracted-module golden harness (tests/stream-render-golden.test.ts) driving
|
|
52
|
+
// this function against a fake bot recorder + the REAL OutboundDedupCache,
|
|
53
|
+
// including the cross-surface stream-then-reply dedup proof spanning P2+P4.
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
import { createAnswerStream } from '../answer-stream.js'
|
|
57
|
+
import { LivenessTracker, isContextExhaustionText } from '../context-exhaustion.js'
|
|
58
|
+
import { normalizeParagraphBreaks, normalizePunctuation, repairEscapedWhitespace, stripExcessBold } from '../format.js'
|
|
59
|
+
import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
|
|
60
|
+
import { isReplyTool } from '../narrative-dedup.js'
|
|
61
|
+
import { NarrativeFlushController } from '../narrative-flush.js'
|
|
62
|
+
import { recordTurnEnd, recordTurnStart } from '../registry/turns-schema.js'
|
|
63
|
+
import { retryWithThreadFallback } from '../retry-api-call.js'
|
|
64
|
+
import { richMessage } from '../rich-send.js'
|
|
65
|
+
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
66
|
+
import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
|
|
67
|
+
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
68
|
+
import { scrubVoice } from '../text-voice-scrub.js'
|
|
69
|
+
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
70
|
+
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
71
|
+
import { decideTurnFlush } from '../turn-flush-safety.js'
|
|
72
|
+
import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
|
|
73
|
+
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
74
|
+
import { deriveTurnId } from './derive-turn-id.js'
|
|
75
|
+
import { EMISSION_AUTHORITY_ENABLED, EmissionAuthority } from './emission-authority.js'
|
|
76
|
+
import { decideFeedReopen } from './feed-reopen-gate.js'
|
|
77
|
+
import { ackDelivery } from './inbound-delivery-confirm.js'
|
|
78
|
+
import { shadowEmit } from './inbound-delivery-machine-shadow.js'
|
|
79
|
+
import { parseSourceMessageId } from './source-message-id.js'
|
|
80
|
+
import { formatTurnLifecycle } from './status-surface-log.js'
|
|
81
|
+
import { removeTurnActiveMarker, touchTurnActiveMarker, writeTurnActiveMarker } from './turn-active-marker.js'
|
|
82
|
+
import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
|
|
83
|
+
import { decideTurnEndGate } from './turn-end-gate.js'
|
|
84
|
+
import { finalizeBackstopSendGated } from './turn-record-status.js'
|
|
85
|
+
import type { SilentEndDeps } from '../silent-end.js'
|
|
86
|
+
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
87
|
+
import type { SessionEvent } from '../session-tail.js'
|
|
88
|
+
import type { CurrentTurn, StreamRenderDeps } from './gateway.js'
|
|
89
|
+
import * as pendingProgress from '../pending-work-progress.js'
|
|
90
|
+
import * as signalTracker from '../turn-signal-tracker.js'
|
|
91
|
+
import * as silencePoke from '../silence-poke.js'
|
|
92
|
+
|
|
93
|
+
export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): void {
|
|
94
|
+
const {
|
|
95
|
+
ANSWER_LANE,
|
|
96
|
+
CAPTURED_PROSE_DELIVERY_ENABLED,
|
|
97
|
+
CONTEXT_EXHAUSTION_COOLDOWN_MS,
|
|
98
|
+
DELIVERY_CONFIRM_ENABLED,
|
|
99
|
+
FEED_REOPEN_AFTER_ACK_ENABLED,
|
|
100
|
+
HANDBACK_PRETURN_ENABLED,
|
|
101
|
+
HISTORY_ENABLED,
|
|
102
|
+
LIVENESS_TERMINAL_HONESTY,
|
|
103
|
+
OBLIGATION_LEDGER_ENABLED,
|
|
104
|
+
ORPHANED_REPLY_STREAM_WINDOW_MS,
|
|
105
|
+
SILENCE_LIVENESS_PRODUCTION,
|
|
106
|
+
STATE_DIR,
|
|
107
|
+
TURN_FLUSH_SAFETY_ENABLED,
|
|
108
|
+
TURN_PREVIEW_MAX,
|
|
109
|
+
activeDraftStreams,
|
|
110
|
+
activeStatusReactions,
|
|
111
|
+
activeTurnStartedAt,
|
|
112
|
+
backstopDeliveryLedger,
|
|
113
|
+
bot,
|
|
114
|
+
cardDrainGate,
|
|
115
|
+
clearActivitySummary,
|
|
116
|
+
clearAnswerReadyFlushTimeout,
|
|
117
|
+
closeActivityLane,
|
|
118
|
+
closeProgressLane,
|
|
119
|
+
completeProgressCardTurn,
|
|
120
|
+
composeTurnActivity,
|
|
121
|
+
confirmMemoryLegibility,
|
|
122
|
+
deliverAnswer,
|
|
123
|
+
deliverCapturedProse,
|
|
124
|
+
deliveryQueue,
|
|
125
|
+
drainActivitySummary,
|
|
126
|
+
emissionAuthorityFor,
|
|
127
|
+
emitTurnRecord,
|
|
128
|
+
endCurrentTurnAtomic,
|
|
129
|
+
extractUserPromptPreview,
|
|
130
|
+
finalizeStatusReaction,
|
|
131
|
+
flushPendingNarrativeAtTurnEnd,
|
|
132
|
+
flushedTurnSupersede,
|
|
133
|
+
getCurrentTurn,
|
|
134
|
+
getLastContextExhaustionWarningAt,
|
|
135
|
+
getPendingPtyPartial,
|
|
136
|
+
getPinnedProgressCardMessageId,
|
|
137
|
+
handbackPreturnSignal,
|
|
138
|
+
handlePtyPartial,
|
|
139
|
+
idleTracker,
|
|
140
|
+
isDmChatId,
|
|
141
|
+
isLegitimatelyWorking,
|
|
142
|
+
lastPtyPreviewByChat,
|
|
143
|
+
makeNarrativeGate,
|
|
144
|
+
obligationLedger,
|
|
145
|
+
outboundDedup,
|
|
146
|
+
pendingCrossTurnGate,
|
|
147
|
+
preambleSuppressor,
|
|
148
|
+
progressDriver,
|
|
149
|
+
promoteQueuedStatus,
|
|
150
|
+
purgeReactionTracking,
|
|
151
|
+
reactionTransitionCounts,
|
|
152
|
+
redactOutboundText,
|
|
153
|
+
rememberRecentTurn,
|
|
154
|
+
resetAnswerReadyFlushTimeout,
|
|
155
|
+
resetOrphanedReplyTimeout,
|
|
156
|
+
resolvePendingNarrativeOnTool,
|
|
157
|
+
robustApiCall,
|
|
158
|
+
scheduleEarlyLivenessOpen,
|
|
159
|
+
sessionModelSource,
|
|
160
|
+
setCurrentTurn,
|
|
161
|
+
setLastContextExhaustionWarningAt,
|
|
162
|
+
setPendingPtyPartial,
|
|
163
|
+
stagePendingNarrative,
|
|
164
|
+
startTurnTypingLoop,
|
|
165
|
+
statusKey,
|
|
166
|
+
streamKey,
|
|
167
|
+
suppressPtyPreview,
|
|
168
|
+
surfaceMemoryLegibility,
|
|
169
|
+
swallowingApiCall,
|
|
170
|
+
toolFlightTracker,
|
|
171
|
+
turnLiveForItsTopic,
|
|
172
|
+
turnsDb,
|
|
173
|
+
typingWrapper,
|
|
174
|
+
unpinProgressCardForChat,
|
|
175
|
+
} = deps
|
|
176
|
+
|
|
177
|
+
// Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
|
|
178
|
+
// lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
|
|
179
|
+
// under ONE shared predicate: a live turn is present and this is not the
|
|
180
|
+
// synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
|
|
181
|
+
// The counter reset MUST live here at the dispatcher — NOT inside
|
|
182
|
+
// resetOrphanedReplyTimeout() (which is called from the fire callback one
|
|
183
|
+
// line after the counter increments) and NOT tied to a single case (e.g.
|
|
184
|
+
// tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
|
|
185
|
+
// the `!(turn_end && -1)` half of the predicate internally.
|
|
186
|
+
{
|
|
187
|
+
const liveTurn = getCurrentTurn()
|
|
188
|
+
if (liveTurn != null) {
|
|
189
|
+
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
190
|
+
liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Idle-clear clocks (#3084 follow-up). EVERY genuine session event is
|
|
194
|
+
// activity — an agent that is thinking, calling a tool, streaming text or
|
|
195
|
+
// driving a sub-agent is NOT idle, whether or not the gateway currently has a
|
|
196
|
+
// turn open. Stamping only at turn START (the old behaviour) is what let a
|
|
197
|
+
// 3-hour working stretch be scored as zero activity and `/clear`ed the moment
|
|
198
|
+
// the window elapsed. A turn ending stamps the turn-end clock too, so a turn
|
|
199
|
+
// that outran the window is not wiped the instant `turnInFlight` goes false.
|
|
200
|
+
// This runs for the whole event stream, including every `sub_agent_*` kind —
|
|
201
|
+
// background workers keep the timer warm exactly as long as they are working.
|
|
202
|
+
{
|
|
203
|
+
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
204
|
+
idleTracker.noteEvent(ev.kind, Date.now(), durationMs)
|
|
205
|
+
}
|
|
206
|
+
switch (ev.kind) {
|
|
207
|
+
case 'enqueue': {
|
|
208
|
+
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
209
|
+
// prior turn before resetting focus.
|
|
210
|
+
typingWrapper.drainAll()
|
|
211
|
+
if (ev.chatId) {
|
|
212
|
+
// #1445 cross-turn pending-async ambient — backstop for the
|
|
213
|
+
// `handleInbound` path's `clearPending('inbound')`. The
|
|
214
|
+
// inbound path covers real user messages, but synthesised
|
|
215
|
+
// wakes (subagent-handback channel turn, cron fires, vault
|
|
216
|
+
// grant resumes, restart markers) push directly to
|
|
217
|
+
// `pendingInboundBuffer` and bypass `handleInbound`. The
|
|
218
|
+
// `enqueue` session-event fires for EVERY fresh turn atom
|
|
219
|
+
// regardless of source — clearing here drops any prior turn's
|
|
220
|
+
// ambient before the new turn's `noteOutbound` lands. The
|
|
221
|
+
// call is idempotent so it's safe to fire in addition to the
|
|
222
|
+
// inbound-path clear (for the real-inbound case, this is a
|
|
223
|
+
// no-op because state was already deleted by then).
|
|
224
|
+
const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
225
|
+
pendingProgress.clearPending(
|
|
226
|
+
statusKey(ev.chatId, enqThreadId),
|
|
227
|
+
'handback',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
if (ev.chatId) {
|
|
231
|
+
// Issue #195: if a previous turn left an answer-lane stream open
|
|
232
|
+
// (rapid steer/queue), force it to a new generation so its in-flight
|
|
233
|
+
// edits don't mutate the new turn's message. Materialize is best-effort
|
|
234
|
+
// — we don't await here because turn_end on the prior turn should
|
|
235
|
+
// have already done it; this is a defensive supersession guard.
|
|
236
|
+
const prior = getCurrentTurn()
|
|
237
|
+
if (prior?.answerStream != null) {
|
|
238
|
+
prior.answerStream.forceNewMessage()
|
|
239
|
+
prior.answerStream.stop()
|
|
240
|
+
prior.answerStream = null
|
|
241
|
+
}
|
|
242
|
+
// Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
|
|
243
|
+
// fuse before it is superseded. The fire callback re-reads currentTurn
|
|
244
|
+
// and no-ops on a stale turn, but proactively clearing the timer avoids
|
|
245
|
+
// a bounded pile-up of dangling timers across rapid steer/queue turns.
|
|
246
|
+
if (prior?.orphanedReplyTimeoutId != null) {
|
|
247
|
+
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
248
|
+
prior.orphanedReplyTimeoutId = null
|
|
249
|
+
}
|
|
250
|
+
// Same bounded-leak class (early-paint 250ms setTimeout): the prior
|
|
251
|
+
// turn may have armed its narrative gate's early-paint timer before
|
|
252
|
+
// being superseded. Left untorn, ~250ms later it fires showNarrativeStep
|
|
253
|
+
// on the dead turn and can paint a stale narration card below the new
|
|
254
|
+
// turn's surface. Teardown is guard-safe and idempotent (no-op when never
|
|
255
|
+
// armed / already fired / already disarmed by the prior turn's turn_end).
|
|
256
|
+
prior?.narrativeGate?.teardown()
|
|
257
|
+
// #1067: swap the entire turn atom in one assignment. Every
|
|
258
|
+
// handler captures `const turn = currentTurn` at entry, so a
|
|
259
|
+
// captured-then-awaited read can't reattribute to the new turn.
|
|
260
|
+
const startedAt = Date.now()
|
|
261
|
+
// Component 3 — stable per-turn identity. For a real inbound this
|
|
262
|
+
// matches the `origin_turn_id` stamped into the inbound meta at
|
|
263
|
+
// build time (same chat/thread/messageId). Synthetic turns (cron /
|
|
264
|
+
// handback — no messageId) get a unique startedAt-based fallback id
|
|
265
|
+
// that no reply will ever echo, so they correctly fall through to
|
|
266
|
+
// the live-turn routing in resolveAnswerThreadId.
|
|
267
|
+
const enqThreadIdNum = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
268
|
+
const turnId =
|
|
269
|
+
deriveTurnId(ev.chatId, enqThreadIdNum ?? null, ev.messageId)
|
|
270
|
+
?? `${chatKey(ev.chatId, enqThreadIdNum ?? null)}#synthetic-${startedAt}`
|
|
271
|
+
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Consume any
|
|
272
|
+
// pending cross-turn gate `obligationSweep` armed for THIS exact turn
|
|
273
|
+
// when it pushed an `obligation_represent` inbound. The gate is keyed on
|
|
274
|
+
// the obligation's `originTurnId`, and the represent inbound reuses the
|
|
275
|
+
// original chat/thread/messageId, so this turn's `turnId` (derived just
|
|
276
|
+
// above) equals that key iff this turn IS the represent surface armed for.
|
|
277
|
+
// An unrelated foreground turn on the same chat/thread derives a
|
|
278
|
+
// different `turnId` → finds no entry → no gate → its card opens normally
|
|
279
|
+
// (correct). Consume-once: delete on read so the matched gate can't leak
|
|
280
|
+
// forward, and a never-matched stale gate can never suppress another turn.
|
|
281
|
+
const xTurnGateKey = turnId
|
|
282
|
+
const consumedCrossTurnGate = pendingCrossTurnGate.get(xTurnGateKey)
|
|
283
|
+
if (consumedCrossTurnGate != null) pendingCrossTurnGate.delete(xTurnGateKey)
|
|
284
|
+
const next: CurrentTurn = {
|
|
285
|
+
sessionChatId: ev.chatId,
|
|
286
|
+
sessionThreadId: enqThreadIdNum,
|
|
287
|
+
// Accept the inbound id as a reply anchor only when it is a plausible
|
|
288
|
+
// Telegram message id. Synthetic boot-resume inbounds fabricate a
|
|
289
|
+
// 13-digit Date.now() message_id (for ack-tracking); if that reached
|
|
290
|
+
// the activity-feed reply anchor it 400'd every feed send and darkened
|
|
291
|
+
// the live feed for the whole resume turn (2026-06-05). The ack-queue
|
|
292
|
+
// still keys on ev.messageId independently — only the anchor is gated.
|
|
293
|
+
sourceMessageId: parseSourceMessageId(ev.messageId),
|
|
294
|
+
startedAt,
|
|
295
|
+
gatewayReceiveAt: startedAt,
|
|
296
|
+
// #2527 — stamp the loop role once, from the enqueue envelope.
|
|
297
|
+
role: deriveTurnRole(ev.rawContent),
|
|
298
|
+
// PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Only a
|
|
299
|
+
// synthetic represent/owed-reply turn carries this; a foreground turn
|
|
300
|
+
// leaves it undefined and the cross-turn card-OPEN gate is inert.
|
|
301
|
+
...(consumedCrossTurnGate != null ? { crossTurnGate: consumedCrossTurnGate } : {}),
|
|
302
|
+
replyCalled: false,
|
|
303
|
+
finalAnswerDelivered: false,
|
|
304
|
+
finalAnswerSubstantive: false,
|
|
305
|
+
// Sticky latch — reset ONLY here (turn start), never by reopen.
|
|
306
|
+
finalAnswerEverDelivered: false,
|
|
307
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
|
|
308
|
+
// latch, reset at turn start alongside the other answer flags.
|
|
309
|
+
answerDelivered: false,
|
|
310
|
+
// 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
|
|
311
|
+
endedAt: null,
|
|
312
|
+
firstPingAt: null,
|
|
313
|
+
// Notification ownership (R8 / PR-2): no slot claimed yet, so the
|
|
314
|
+
// "claimer was substantive" flag starts false. Set atomically with
|
|
315
|
+
// firstPingAt at the over-ping decision site.
|
|
316
|
+
firstPingWasSubstantive: false,
|
|
317
|
+
silentAnchorMessageId: null,
|
|
318
|
+
silentAnchorText: '',
|
|
319
|
+
capturedText: [],
|
|
320
|
+
capturedBlockMeta: [],
|
|
321
|
+
orphanedReplyTimeoutId: null,
|
|
322
|
+
answerReadyFlushTimeoutId: null,
|
|
323
|
+
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
324
|
+
// so a turn that never streams still trips the fuse after windowMs.
|
|
325
|
+
liveness: new LivenessTracker(startedAt),
|
|
326
|
+
turnId,
|
|
327
|
+
registryKey: null,
|
|
328
|
+
noReplyDrainTimer: null,
|
|
329
|
+
lastAssistantMsgId: null,
|
|
330
|
+
lastAssistantDone: false,
|
|
331
|
+
toolCallCount: 0,
|
|
332
|
+
labeledToolCount: 0,
|
|
333
|
+
totalTokens: 0,
|
|
334
|
+
seenUsageMessageIds: new Set<string>(),
|
|
335
|
+
activityMessageId: null,
|
|
336
|
+
activityInFlight: null,
|
|
337
|
+
activityPendingRender: null,
|
|
338
|
+
activityLastSentRender: null,
|
|
339
|
+
activityEverOpened: false,
|
|
340
|
+
activityDrainFailures: 0,
|
|
341
|
+
mirrorLines: [],
|
|
342
|
+
// Assigned immediately after this literal via makeNarrativeGate(next) —
|
|
343
|
+
// the controller's SHOW/RETRACT effects close over the turn object, which
|
|
344
|
+
// can't reference itself inside its own initializer.
|
|
345
|
+
narrativeGate: undefined as unknown as NarrativeFlushController,
|
|
346
|
+
lastReplyText: '',
|
|
347
|
+
foregroundSubAgents: new Map(),
|
|
348
|
+
answerStream: null,
|
|
349
|
+
isDm: isDmChatId(ev.chatId),
|
|
350
|
+
// PR-4a — construct ONE emission-authority façade per turn, passing
|
|
351
|
+
// the chat/thread key in EXPLICITLY (the PR-4e seam; today equal to
|
|
352
|
+
// the singleton-sourced key). Per-turn: born with this turn literal,
|
|
353
|
+
// discarded with it — never persists across turns.
|
|
354
|
+
emissionAuthority: new EmissionAuthority(
|
|
355
|
+
statusKey(ev.chatId, enqThreadIdNum),
|
|
356
|
+
),
|
|
357
|
+
}
|
|
358
|
+
// Wire the per-turn narrative gate now that `next` exists (its SHOW/RETRACT
|
|
359
|
+
// effects close over the turn). Born with this turn, torn down at turn end.
|
|
360
|
+
next.narrativeGate = makeNarrativeGate(next)
|
|
361
|
+
// Dead-air pre-turn signal — ADOPT by inbound identity (design lever 2).
|
|
362
|
+
// If a subagent-handback pre-turn signal was emitted for THIS exact turn
|
|
363
|
+
// (matched on `turnId`, not the bare topic key, so a racing user inbound
|
|
364
|
+
// can't mis-adopt), consume it. A card-bearing adoption seeds
|
|
365
|
+
// `activityMessageId` + `activityEverOpened` so `renderActivityFeed`
|
|
366
|
+
// EDITS the existing card instead of opening a second one, and so the
|
|
367
|
+
// turn's own end-of-turn `clearActivitySummary` finalizes it (lever 3).
|
|
368
|
+
// The handback turn also gets the turn-long typing loop it never had —
|
|
369
|
+
// whether or not a card was painted (the debounce may not have fired) —
|
|
370
|
+
// stopped by the canonical turn-end (`purgeReactionTracking →
|
|
371
|
+
// stopTurnTypingLoop`).
|
|
372
|
+
if (HANDBACK_PRETURN_ENABLED) {
|
|
373
|
+
const handbackAdoption = handbackPreturnSignal.tryAdopt(turnId)
|
|
374
|
+
if (handbackAdoption != null) {
|
|
375
|
+
if (handbackAdoption.activityMessageId != null) {
|
|
376
|
+
next.activityMessageId = handbackAdoption.activityMessageId
|
|
377
|
+
next.activityEverOpened = true
|
|
378
|
+
}
|
|
379
|
+
startTurnTypingLoop(ev.chatId, enqThreadIdNum ?? null)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// PR-4e — route the turn-SET through the keyed accessor: flag-OFF assigns
|
|
383
|
+
// the singleton (byte-identical to `currentTurn = next`); flag-ON sets the
|
|
384
|
+
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
385
|
+
// the SAME statusKey the ctor's façade was constructed with just above.
|
|
386
|
+
setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
|
|
387
|
+
// (turn start already stamped the idle clock at the top of
|
|
388
|
+
// handleSessionEvent, along with every other session event — see the
|
|
389
|
+
// idle-clear block there.)
|
|
390
|
+
// Early-open the "Working…" liveness card at turn start so narration /
|
|
391
|
+
// thinking emitted BEFORE the first tool surfaces within ~a second
|
|
392
|
+
// instead of after the old 12 s threshold (the dead-air gap). Fires the
|
|
393
|
+
// SAME `openLivenessFeedIfDue` the 6 s heartbeat uses — a no-op if a
|
|
394
|
+
// tool/narrative already opened the card, and gated by `mayOpenActivityCard`
|
|
395
|
+
// (lever 1/4) so it never opens below a delivered answer. Scoped to real
|
|
396
|
+
// turns by construction: only the `enqueue` lifecycle event reaches here,
|
|
397
|
+
// and anonymous one-shot hook clients (recall.py) never emit it.
|
|
398
|
+
scheduleEarlyLivenessOpen(next)
|
|
399
|
+
// Status-surface observability: one line at every turn SET so a later
|
|
400
|
+
// dark card is traceable to which turn/topic key it belonged to.
|
|
401
|
+
process.stderr.write(
|
|
402
|
+
`telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
|
|
403
|
+
)
|
|
404
|
+
// Component 3 — retain in the bounded recently-ended registry so a
|
|
405
|
+
// LATE reply (landing after currentTurn flips to a successor) can
|
|
406
|
+
// still resolve THIS turn's origin thread by its turnId.
|
|
407
|
+
rememberRecentTurn(next)
|
|
408
|
+
// Component 5 (Hook B) — this turn's topic had a queued placeholder
|
|
409
|
+
// from Hook A; promote it to "On it — replying now." (deleted later
|
|
410
|
+
// when the answer lands). No-op when there's no placeholder / DM.
|
|
411
|
+
promoteQueuedStatus(ev.chatId, enqThreadIdNum)
|
|
412
|
+
// Ack inbound delivery (the marko drop-wedge): claude actually started
|
|
413
|
+
// this turn, so its delivered inbound landed — stop tracking it for
|
|
414
|
+
// re-delivery. `enqueue` carries the same chat/thread the inbound was
|
|
415
|
+
// keyed on, so the key matches.
|
|
416
|
+
if (DELIVERY_CONFIRM_ENABLED) {
|
|
417
|
+
// Match on the source message id: `enqueue` fires for EVERY turn
|
|
418
|
+
// start (cron / subagent-handback / vault-resume / restart-marker
|
|
419
|
+
// too — see comment below), so a key-only ack would let a synthetic
|
|
420
|
+
// turn clear a real user message still waiting under the same key.
|
|
421
|
+
ackDelivery(
|
|
422
|
+
deliveryQueue,
|
|
423
|
+
chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
|
|
424
|
+
ev.messageId,
|
|
425
|
+
// #2786 — pass the raw enqueue envelope so the ack survives the
|
|
426
|
+
// composer merging/reordering inbound wrappers (the single
|
|
427
|
+
// re-parsed `ev.messageId` can then belong to a sibling, not our
|
|
428
|
+
// tracked message). The tolerant match scans all ids in this
|
|
429
|
+
// content; a synthetic-source turn still lacks the user id, so the
|
|
430
|
+
// cross-source false-ack guard holds.
|
|
431
|
+
ev.rawContent,
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
435
|
+
// machine. `enqueue` fires for EVERY turn atom regardless of
|
|
436
|
+
// source — inbound, cron, subagent-handback, vault-resume,
|
|
437
|
+
// restart-marker — so it is the single chokepoint that captures
|
|
438
|
+
// the non-inbound turns the machine's own `inbound` event never
|
|
439
|
+
// sees (those bypass handleInbound). Without it the machine reads
|
|
440
|
+
// idle during a cron/handback turn and the gate would mis-deliver
|
|
441
|
+
// a concurrent inbound mid-turn (the #1556 composer wedge).
|
|
442
|
+
// Idempotent when already in_turn (turnStart only sets perKey).
|
|
443
|
+
shadowEmit({
|
|
444
|
+
kind: 'turnStart',
|
|
445
|
+
key: statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : undefined) as _ChatKey,
|
|
446
|
+
at: startedAt,
|
|
447
|
+
})
|
|
448
|
+
// #549 fix — fresh turn, reset preamble-suppression state.
|
|
449
|
+
preambleSuppressor.reset()
|
|
450
|
+
// Reset the silent-end retry budget for this chat. The stored
|
|
451
|
+
// turnKey is `chat:thread` shape (no per-instance suffix), so
|
|
452
|
+
// without an explicit per-turn clear, `writeSilentEndState`
|
|
453
|
+
// (silent-end.ts:114) inherits `retryCount` across turns
|
|
454
|
+
// whenever a prior turn for the same chat hit retryCount=1.
|
|
455
|
+
// The Stop hook then sees `retryCount >= MAX_RETRIES=1` on the
|
|
456
|
+
// very first silent-end of every subsequent turn and bails
|
|
457
|
+
// without re-prompting. finn hit this on 2026-05-25 with a
|
|
458
|
+
// stuck retryCount=1 file. A new turn invalidates any prior
|
|
459
|
+
// turn's retry budget by definition; clear it eagerly here.
|
|
460
|
+
// ev.threadId is `string | null` (Telegram's wire shape);
|
|
461
|
+
// statusKey wants `number | null` — same conversion as the
|
|
462
|
+
// registry-key branch a few lines down.
|
|
463
|
+
clearSilentEndState(statusKey(
|
|
464
|
+
ev.chatId,
|
|
465
|
+
ev.threadId != null ? Number(ev.threadId) : null,
|
|
466
|
+
))
|
|
467
|
+
// Stage 3b: stamp turn-start in the registry. turn_key is
|
|
468
|
+
// chat:thread:startTs — unique per turn, distinct from the
|
|
469
|
+
// progress-card-driver's per-chat sequence number (these are two
|
|
470
|
+
// independent identifier schemes and don't need to align).
|
|
471
|
+
if (turnsDb != null) {
|
|
472
|
+
// ev.threadId is `string | null` (Telegram emits as string); convert
|
|
473
|
+
// to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
|
|
474
|
+
// to '_' — same as the explicit `null` branch below.
|
|
475
|
+
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
|
|
476
|
+
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
|
|
477
|
+
next.registryKey = turnKey
|
|
478
|
+
// Phase 1 of #332: capture first ~200 chars of the user's message.
|
|
479
|
+
const userPromptPreview = extractUserPromptPreview(ev.rawContent)
|
|
480
|
+
// Closes #472 finding #11. Pre-fix: this write was scheduled
|
|
481
|
+
// via setImmediate to "avoid stalling the turn handler" — but
|
|
482
|
+
// SQLite local writes are sub-millisecond, and the deferral
|
|
483
|
+
// opened a SIGTERM race window: a kill landing in the gap
|
|
484
|
+
// between scheduling and firing left a turn with no start
|
|
485
|
+
// row, invisible to the resume protocol (the user sent a
|
|
486
|
+
// message, the gateway lost it, no SWITCHROOM_PENDING_TURN
|
|
487
|
+
// env on next boot). Sibling writeTurnActiveMarker has always
|
|
488
|
+
// been synchronous here; this matches it.
|
|
489
|
+
try {
|
|
490
|
+
recordTurnStart(turnsDb, {
|
|
491
|
+
turnKey,
|
|
492
|
+
chatId: String(ev.chatId),
|
|
493
|
+
threadId: ev.threadId != null ? String(ev.threadId) : null,
|
|
494
|
+
lastUserMsgId: ev.messageId != null ? String(ev.messageId) : null,
|
|
495
|
+
userPromptPreview,
|
|
496
|
+
})
|
|
497
|
+
} catch (err) {
|
|
498
|
+
process.stderr.write(`telegram gateway: recordTurnStart failed turnKey=${turnKey}: ${(err as Error).message}\n`)
|
|
499
|
+
}
|
|
500
|
+
// #412: turn-active marker for the bridge-watchdog. File exists
|
|
501
|
+
// for the duration of the in-flight turn; mtime advances on
|
|
502
|
+
// every tool_use; deleted on turn_complete. The watchdog
|
|
503
|
+
// distinguishes wedged-mid-turn from healthy-idle by checking
|
|
504
|
+
// for this file's presence + mtime staleness.
|
|
505
|
+
writeTurnActiveMarker(STATE_DIR, {
|
|
506
|
+
turnKey,
|
|
507
|
+
chatId: String(ev.chatId),
|
|
508
|
+
threadId: ev.threadId != null ? String(ev.threadId) : null,
|
|
509
|
+
startedAt,
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
// (accessor-narrowing spelling: the pre-move body guarded the
|
|
513
|
+
// `pendingPtyPartial` variable then re-read it into `pending`; the
|
|
514
|
+
// injected accessor is a call expression TS can't narrow across, so
|
|
515
|
+
// capture once then guard the local — equivalent, no await between.)
|
|
516
|
+
const pending = getPendingPtyPartial()
|
|
517
|
+
if (pending != null) {
|
|
518
|
+
setPendingPtyPartial(null)
|
|
519
|
+
handlePtyPartial(pending)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
case 'dequeue': return
|
|
525
|
+
case 'model': {
|
|
526
|
+
// Live model capture for the main turn. The session-tail projection
|
|
527
|
+
// already filtered sentinels (`<synthetic>` compaction lines), so any
|
|
528
|
+
// value reaching here is a real resolved model id. Record it on the turn
|
|
529
|
+
// (update-on-change) so the activity/liveness card header and /status
|
|
530
|
+
// render the model actually serving this turn's API calls — transcript-
|
|
531
|
+
// sourced, never config. Also note it on the freshness-aware session-model
|
|
532
|
+
// source so a /status query between turns still reflects the last model
|
|
533
|
+
// (and a fresh assistant line reclaims the source from a /model override).
|
|
534
|
+
const turn = getCurrentTurn()
|
|
535
|
+
if (turn != null) {
|
|
536
|
+
turn.currentModel = ev.model
|
|
537
|
+
}
|
|
538
|
+
sessionModelSource.noteTranscriptModel(ev.model)
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
case 'usage': {
|
|
542
|
+
// Fold the parent agent's OWN per-message token usage into the turn's
|
|
543
|
+
// running total, deduped by message.id (one logical assistant message can
|
|
544
|
+
// land as several JSONL lines sharing one id + usage block). Rendered on
|
|
545
|
+
// the 🤖 turn-activity card's metrics line. Sub-agent tokens are NOT
|
|
546
|
+
// folded here — they surface on their own worker-feed rows; summing them
|
|
547
|
+
// would double-count. A null messageId is un-dedupable → always counted.
|
|
548
|
+
const turn = getCurrentTurn()
|
|
549
|
+
if (turn == null) return
|
|
550
|
+
if (ev.messageId != null) {
|
|
551
|
+
if (turn.seenUsageMessageIds.has(ev.messageId)) return
|
|
552
|
+
turn.seenUsageMessageIds.add(ev.messageId)
|
|
553
|
+
}
|
|
554
|
+
turn.totalTokens += ev.totalTokens
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
case 'thinking': {
|
|
558
|
+
// #1067: snapshot the turn atom at handler entry. Even though this
|
|
559
|
+
// handler is sync, the principle is uniform across all event arms
|
|
560
|
+
// — read `turn` once, don't re-read currentTurn after any await.
|
|
561
|
+
const turn = getCurrentTurn()
|
|
562
|
+
if (turn == null) return
|
|
563
|
+
// S2 fix (fable red-team 2026-07-17) — a thinking block means the model
|
|
564
|
+
// is still working, not quiescent. Without this, "prose → >1s thinking
|
|
565
|
+
// pause → trailing NO_REPLY" let the answer-ready quiescence timer fire
|
|
566
|
+
// mid-pause and deliver a turn the model was about to mark silent
|
|
567
|
+
// (#2053 in miniature). Re-arm (not just clear): `reset()` re-verifies
|
|
568
|
+
// via `decideTurnFlush` and pushes the debounce out by a fresh window,
|
|
569
|
+
// so the trailing sentinel gets to land before any fire; if no further
|
|
570
|
+
// text arrives, the flush still fires one window after the LAST
|
|
571
|
+
// thinking event — the fast path is deferred, never lost.
|
|
572
|
+
resetAnswerReadyFlushTimeout()
|
|
573
|
+
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
574
|
+
if (ctrl) ctrl.setThinking()
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
case 'tool_use': {
|
|
578
|
+
const turn = getCurrentTurn()
|
|
579
|
+
if (turn == null) return
|
|
580
|
+
// PR A — the model resumed work (surface or otherwise). Cancel any pending
|
|
581
|
+
// answer-ready quiescence flush: the turn is no longer quiescent. (Fire-time
|
|
582
|
+
// re-verification would also catch this, but disarming here avoids a wasted
|
|
583
|
+
// wakeup and matches the design's disarm-on-tool requirement.)
|
|
584
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
585
|
+
// Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
|
|
586
|
+
// narrative block was pending; this tool_use is the lookahead event
|
|
587
|
+
// that decides it. reply/stream_reply with near-identical text ⇒
|
|
588
|
+
// draft-then-send ⇒ SUPPRESS (the reply prints the canonical answer);
|
|
589
|
+
// anything else ⇒ SHOW as a transient liveness step. Runs BEFORE the
|
|
590
|
+
// normal tool handling so a working preamble surfaces just ahead of
|
|
591
|
+
// its tool step.
|
|
592
|
+
resolvePendingNarrativeOnTool(turn, ev.toolName, ev.input)
|
|
593
|
+
// Phase 1 of #332: count every tool_use in the current turn.
|
|
594
|
+
turn.toolCallCount++
|
|
595
|
+
// #412: bump turn-active marker mtime so the watchdog sees this
|
|
596
|
+
// turn is making forward progress. Stop-hook deadlocks (the
|
|
597
|
+
// failure mode #116 originally tracked) emit no more tool_use
|
|
598
|
+
// events, so the marker mtime stops advancing → watchdog acts.
|
|
599
|
+
touchTurnActiveMarker(STATE_DIR)
|
|
600
|
+
// #549 fix: a tool_use immediately following text events makes
|
|
601
|
+
// those texts "preamble" — the progress card already captured
|
|
602
|
+
// them as a narrative for this tool. Drop the pending answer-
|
|
603
|
+
// stream buffer so the same text doesn't also land in chat as a
|
|
604
|
+
// standalone message. Telegram-surface tools (reply / stream_reply)
|
|
605
|
+
// are EXCEPTIONS: their text IS the answer, so we flush instead
|
|
606
|
+
// of dropping. The answer-stream's own dedup handles overlap
|
|
607
|
+
// with the reply tool's payload.
|
|
608
|
+
preambleSuppressor.onTool({ isReplyTool: isTelegramSurfaceTool(ev.toolName) })
|
|
609
|
+
// #2849 Phase 4 — sparse chat-legible memory. Surface ONE terse line in
|
|
610
|
+
// the originating chat/topic when this tool call materially changes what
|
|
611
|
+
// the agent remembers (create_directive / invalidate / demote). Fires
|
|
612
|
+
// BEFORE the `if (!ctrl) return` status-reaction gate below so it works
|
|
613
|
+
// on turns with no active status-reaction controller. Deterministic
|
|
614
|
+
// tool-call observation — no model call, no polling; ordinary recall and
|
|
615
|
+
// routine consolidation never reach here (they aren't material tools).
|
|
616
|
+
surfaceMemoryLegibility(turn, ev.toolName, ev.toolUseId, ev.input)
|
|
617
|
+
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
618
|
+
const name = ev.toolName
|
|
619
|
+
// Phase tracking removed in #553 PR 5 — phases only fed the
|
|
620
|
+
// placeholder-heartbeat label, which has been retired.
|
|
621
|
+
if (isTelegramReplyTool(name)) {
|
|
622
|
+
turn.replyCalled = true
|
|
623
|
+
// NIT 2 (reply-proxy precision): capture the ACTUAL delivered reply
|
|
624
|
+
// text so flushPendingNarrativeAtTurnEnd compares a trailing
|
|
625
|
+
// narrative block against the real answer surface, not
|
|
626
|
+
// capturedText.join('') (which mis-suppresses when the model emits
|
|
627
|
+
// the same short string twice in a turn). Reply tools ('reply',
|
|
628
|
+
// 'stream_reply') carry the answer in input.text; only those count.
|
|
629
|
+
// Prefix-aware: prod jsonl carries the mcp__…__stream_reply form.
|
|
630
|
+
if (isReplyTool(name) && typeof ev.input?.text === 'string') {
|
|
631
|
+
turn.lastReplyText = ev.input.text as string
|
|
632
|
+
}
|
|
633
|
+
if (turn.orphanedReplyTimeoutId != null) {
|
|
634
|
+
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
635
|
+
turn.orphanedReplyTimeoutId = null
|
|
636
|
+
}
|
|
637
|
+
// Delete the activity feed only when the FINAL answer has landed —
|
|
638
|
+
// NOT on an ack-first interim reply ("On it"). Gating on the first
|
|
639
|
+
// reply deleted the feed on the ack, so the post-ack work
|
|
640
|
+
// (sub-agents/tools) rendered into nothing — the "agent went silent
|
|
641
|
+
// after On it" gap. `finalAnswerDelivered` is set by executeReply
|
|
642
|
+
// (isFinalAnswerReply) before this tool_use event fires; turn_end
|
|
643
|
+
// (below) clears unconditionally as the idempotent no-reply / race net.
|
|
644
|
+
if (turn.finalAnswerDelivered) {
|
|
645
|
+
clearActivitySummary(turn)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// The live activity feed is driven by the real-time `tool_label`
|
|
649
|
+
// event (PreToolUse sidecar) rather than this flush-gated tool_use
|
|
650
|
+
// path — see `case 'tool_label'`. The sidecar fires at tool-call
|
|
651
|
+
// time regardless of when claude flushes the transcript, which is
|
|
652
|
+
// the determinism fix: on a fast/clustered-tool turn the JSONL
|
|
653
|
+
// tool_use rows aren't on disk until ~turn-end, so sourcing the
|
|
654
|
+
// feed here would lose them.
|
|
655
|
+
if (!ctrl) return
|
|
656
|
+
if (isTelegramSurfaceTool(name)) return
|
|
657
|
+
ctrl.setTool(name)
|
|
658
|
+
if (ev.toolUseId) {
|
|
659
|
+
typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, name, turn.sessionThreadId ?? null)
|
|
660
|
+
}
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
case 'tool_label': {
|
|
664
|
+
// Real-time activity-feed driver. The PreToolUse hook wrote this
|
|
665
|
+
// label synchronously at tool-call time; the sidecar surfaced it
|
|
666
|
+
// here (~250ms) independent of the transcript flush. Accumulate it
|
|
667
|
+
// into the live feed and edit the activity message in place — this
|
|
668
|
+
// is what makes the feed deterministic on fast/clustered-tool turns
|
|
669
|
+
// where the JSONL tool_use rows arrive too late.
|
|
670
|
+
const turn = getCurrentTurn()
|
|
671
|
+
if (turn == null) return
|
|
672
|
+
// PR A — a tool_label (real-time, ~250 ms) means the model is producing
|
|
673
|
+
// work right now: cancel any pending answer-ready quiescence flush (the
|
|
674
|
+
// turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
|
|
675
|
+
// the timer at the earliest deterministic point.
|
|
676
|
+
clearAnswerReadyFlushTimeout(turn)
|
|
677
|
+
// SECONDARY FIX: an active tool_label means the model is producing work
|
|
678
|
+
// right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
|
|
679
|
+
// (write → compile → test → fix) that regularly emits labels doesn't let
|
|
680
|
+
// the 30 s timer run down between labels. Mirrors how `case 'text':` calls
|
|
681
|
+
// resetOrphanedReplyTimeout() at ~line 10786.
|
|
682
|
+
resetOrphanedReplyTimeout()
|
|
683
|
+
// Surface tools (reply/stream_reply/react) are the conversation, not
|
|
684
|
+
// activity — the hook labels them ("Replying"), so filter by name.
|
|
685
|
+
if (isTelegramSurfaceTool(ev.toolName)) return
|
|
686
|
+
// Stop feeding once the FINAL answer has landed — the hand-off where
|
|
687
|
+
// `clearActivitySummary` deletes the feed so the answer is the
|
|
688
|
+
// authoritative surface. Gating on `replyCalled` (any reply) killed the
|
|
689
|
+
// feed on an ack-first interim "On it", so the post-ack work had no live
|
|
690
|
+
// surface; gate on `finalAnswerDelivered` so the feed keeps narrating
|
|
691
|
+
// between the ack and the real answer. Without this a tool called after
|
|
692
|
+
// the FINAL answer would re-`sendMessage` a fresh feed below it (flicker).
|
|
693
|
+
// Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
|
|
694
|
+
// `finalAnswerDelivered` is set from executeReply on the final answer.
|
|
695
|
+
//
|
|
696
|
+
// Feed-reopen-after-ack: a tool label here means the model is STILL
|
|
697
|
+
// working. If the turn was already marked finalAnswerDelivered, the
|
|
698
|
+
// "final" reply MIGHT have been an interim ACK ("on it, checking
|
|
699
|
+
// Brevo…" pings, classified final by isFinalAnswerReply), so the
|
|
700
|
+
// post-ack work had no live feed — the gate above dropped every label.
|
|
701
|
+
//
|
|
702
|
+
// ACK-ONLY refinement: finalAnswerDelivered latches true for BOTH a
|
|
703
|
+
// short pinging ack AND a substantive answer. Reopening unconditionally
|
|
704
|
+
// is harmful after a GENUINE final answer — routine post-answer
|
|
705
|
+
// housekeeping (memory write / TodoWrite / Bash; non-surface tools that
|
|
706
|
+
// reach here) would reset finalAnswerDelivered=false and trip the
|
|
707
|
+
// silent-end re-prompt (NOT zero-outbound gated) → duplicate answer. So
|
|
708
|
+
// reopen ONLY when the prior final was a short ack
|
|
709
|
+
// (finalAnswerSubstantive=false). When it was substantive, drop the
|
|
710
|
+
// label (legacy gate) so the genuine final stays delivered.
|
|
711
|
+
//
|
|
712
|
+
// On reopen: reclassify the interim ack — the turn has NOT delivered its
|
|
713
|
+
// final answer while still doing tool work. Reset the flag and clear
|
|
714
|
+
// activityMessageId so a FRESH feed message opens below the ack, then
|
|
715
|
+
// proceed normally. When the model's REAL final answer lands,
|
|
716
|
+
// executeReply / stream_reply re-set finalAnswerDelivered=true (and
|
|
717
|
+
// finalAnswerSubstantive) and the feed gates off again. The reset keeps
|
|
718
|
+
// the #2137 serialize gate HOLDING the next topic mid-work (next-topic
|
|
719
|
+
// liveness is the bounded no-reply timer's job) and lets the silent-end
|
|
720
|
+
// re-prompt fire if the turn ends on only an ack.
|
|
721
|
+
// Kill switch SWITCHROOM_FEED_REOPEN_AFTER_ACK=0 → legacy `return`.
|
|
722
|
+
if (turn.finalAnswerDelivered) {
|
|
723
|
+
// decideFeedReopen returns dropLabel (legacy return) or the reset
|
|
724
|
+
// deltas: finalAnswerDelivered→false (the turn has NOT delivered its
|
|
725
|
+
// final answer while still doing tool work), activityMessageId→null
|
|
726
|
+
// (a FRESH feed message opens below the ack), activityLastSentRender
|
|
727
|
+
// →null (so the drain loop's `pending !== lastSent` guard never
|
|
728
|
+
// mistakes the fresh render for the ack's finalized one and skips it).
|
|
729
|
+
const reopen = decideFeedReopen({
|
|
730
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
731
|
+
// ACK-ONLY: reopen only when the prior final was a short ack, not a
|
|
732
|
+
// substantive answer — otherwise post-answer housekeeping would
|
|
733
|
+
// reset finalAnswerDelivered and trip the silent-end re-prompt.
|
|
734
|
+
finalAnswerSubstantive: turn.finalAnswerSubstantive,
|
|
735
|
+
enabled: FEED_REOPEN_AFTER_ACK_ENABLED,
|
|
736
|
+
})
|
|
737
|
+
if (reopen.dropLabel) return
|
|
738
|
+
turn.finalAnswerDelivered = reopen.reset!.finalAnswerDelivered
|
|
739
|
+
turn.activityMessageId = reopen.reset!.activityMessageId
|
|
740
|
+
turn.activityLastSentRender = reopen.reset!.activityLastSentRender
|
|
741
|
+
}
|
|
742
|
+
const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
|
|
743
|
+
if (rendered != null) {
|
|
744
|
+
// Count surfaced tool steps — the single source of truth for the `tools=`
|
|
745
|
+
// lifecycle field and the `✓ N steps` total. Incremented HERE (not at the
|
|
746
|
+
// top of the case) so the count stays consistent with what the feed
|
|
747
|
+
// actually surfaces: an empty label (appendActivityLabel → null) or a
|
|
748
|
+
// label dropped by the post-final-answer reopen guard never inflates it.
|
|
749
|
+
// Surface tools (reply/react) returned earlier; send_typing/sync_retain
|
|
750
|
+
// are suppressed at the hook (computeLabel → null) so they never arrive.
|
|
751
|
+
turn.labeledToolCount++
|
|
752
|
+
// A new tool label = a new live step → re-anchor the heartbeat clock so
|
|
753
|
+
// the " · Ns" elapsed restarts from this step (and the feed itself just
|
|
754
|
+
// advanced, so it isn't stale).
|
|
755
|
+
turn.lastToolLabelAt = Date.now()
|
|
756
|
+
// Production-liveness: a NEW model-driven activity label is genuine
|
|
757
|
+
// liveness (the model emitted a new step), so reset the silence-poke
|
|
758
|
+
// clock — this is the safe site, NOT drainActivitySummary, because the
|
|
759
|
+
// framework feedHeartbeatTick also drains (climbing-elapsed re-renders)
|
|
760
|
+
// and would falsely reset the clock forever on a hung-mid-tool turn,
|
|
761
|
+
// reintroducing the #1556 dangling-turn wedge. Only the model emitting a
|
|
762
|
+
// fresh label reaches here.
|
|
763
|
+
// PR-4e — keyed liveness under the flag. Flag-OFF keeps the literal
|
|
764
|
+
// `currentTurn === turn` (a late tool-label for topic A must reset A's
|
|
765
|
+
// silence clock, not topic B's); flag-ON resolves A by ITS OWN key so a
|
|
766
|
+
// flip to B doesn't falsify A's liveness here.
|
|
767
|
+
if (
|
|
768
|
+
SILENCE_LIVENESS_PRODUCTION &&
|
|
769
|
+
(EMISSION_AUTHORITY_ENABLED ? turnLiveForItsTopic(turn) : getCurrentTurn() === turn)
|
|
770
|
+
) {
|
|
771
|
+
silencePoke.noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now())
|
|
772
|
+
}
|
|
773
|
+
// Recompose so any active foreground sub-agent's nested block (Model A)
|
|
774
|
+
// is preserved when the parent appends its own step. composeTurnActivity
|
|
775
|
+
// == the flat render when no foreground sub-agent is active.
|
|
776
|
+
turn.activityPendingRender = composeTurnActivity(turn) ?? rendered
|
|
777
|
+
const ea = emissionAuthorityFor(turn)
|
|
778
|
+
// PR-4d: route through the centralized chatLock-serialized card-drain gate.
|
|
779
|
+
cardDrainGate(turn, ea, () => {
|
|
780
|
+
if (ea.mayDrain(turn)) {
|
|
781
|
+
// Producer B (tool label): always OPEN-eligible (labeledToolCount was
|
|
782
|
+
// incremented just above). A turn that started conversational and now
|
|
783
|
+
// dispatches a tool opens here, rendering any narration accumulated
|
|
784
|
+
// by the suppressed narrative-SHOW drains (design §9 lever 5 / R4).
|
|
785
|
+
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
786
|
+
ea.openOrEditCard('tool', () => {
|
|
787
|
+
turn.activityInFlight = drainActivitySummary(turn, 'tool')
|
|
788
|
+
})
|
|
789
|
+
}
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
case 'text': {
|
|
795
|
+
// #1067: snapshot at entry. The answer-stream creation closures
|
|
796
|
+
// below also read `turn` instead of currentTurn so they pin to
|
|
797
|
+
// this turn's chat for the stream's lifetime.
|
|
798
|
+
//
|
|
799
|
+
// #1664 ordering note: a `text` event can arrive AFTER turn_end has
|
|
800
|
+
// nulled currentTurn (the issue observed `answer_lane_update
|
|
801
|
+
// transport:"draft"` firing post-turn_end). Such a late event is
|
|
802
|
+
// dropped here by the `turn != null` guard — it is NOT folded back
|
|
803
|
+
// into the just-ended turn. That is deliberate and safe: by the
|
|
804
|
+
// time this fires, the turn atom has been handed to
|
|
805
|
+
// endCurrentTurnAtomic and turn_end has already run its flush /
|
|
806
|
+
// silent-end decision; re-opening a closed turn (re-creating an
|
|
807
|
+
// answer stream, re-evaluating decideTurnFlush) would be a large,
|
|
808
|
+
// race-prone change. The #1664 safety net does not depend on
|
|
809
|
+
// catching the late text: a turn whose real answer lost the race
|
|
810
|
+
// ends with finalAnswerDelivered=false, so recordUndeliveredTurnEnd
|
|
811
|
+
// engages the Stop-hook re-prompt and the model re-delivers the
|
|
812
|
+
// answer through the reply tool. The dropped draft text is
|
|
813
|
+
// recovered by re-prompt, not by post-hoc materialization.
|
|
814
|
+
const turn = getCurrentTurn()
|
|
815
|
+
if (turn != null) {
|
|
816
|
+
turn.capturedText.push(ev.text)
|
|
817
|
+
// #3237 — accumulate the block's structural provenance in LOCKSTEP with
|
|
818
|
+
// the text (same push, same index). `ev.lastInMessage` is true when NO
|
|
819
|
+
// tool_use follows this text block in its assistant message; its
|
|
820
|
+
// negation is the draft-then-send narration signal the turn-flush strip
|
|
821
|
+
// uses (`selectFlushDeliveryText`) to keep a real answer intact instead
|
|
822
|
+
// of truncating a paragraph that merely opens with "Let me explain…".
|
|
823
|
+
turn.capturedBlockMeta.push(!ev.lastInMessage)
|
|
824
|
+
// Narrative-dedup gate step 1 (JSONL-text-narrative primitive):
|
|
825
|
+
// stage this text block for one lookahead step. If a previous block
|
|
826
|
+
// was pending with nothing reply-shaped after it, it flushes here as
|
|
827
|
+
// a SHOWN transient liveness step. The eventual SHOW/SUPPRESS of THIS
|
|
828
|
+
// block is decided by the next tool_use / turn_end. Invariant
|
|
829
|
+
// `chat-is-the-single-source-of-truth` (reference/invariants.md): a
|
|
830
|
+
// SHOWN line rides the same renderStepFeed path as a tool step —
|
|
831
|
+
// transient + clipped, never a persisted parallel mirror. This is a
|
|
832
|
+
// separate lane from the answer-stream wiring below (which owns the
|
|
833
|
+
// canonical reply), so the two never fight over the same text.
|
|
834
|
+
stagePendingNarrative(turn, ev.text)
|
|
835
|
+
// Issue #195: feed the answer-lane stream. The stream itself
|
|
836
|
+
// gates on minInitialChars and throttles edits — short replies
|
|
837
|
+
// stay below the threshold and never spawn a message.
|
|
838
|
+
if (turn.answerStream == null) {
|
|
839
|
+
turn.answerStream = createAnswerStream({
|
|
840
|
+
chatId: turn.sessionChatId,
|
|
841
|
+
threadId: turn.sessionThreadId,
|
|
842
|
+
// VISIBLE on (opt-in, SWITCHROOM_VISIBLE_ANSWER_STREAM=1) →
|
|
843
|
+
// minInitialChars:1 opens a user-visible edit-in-place preview on the
|
|
844
|
+
// first text chunk. At turn_end the preview is materialized as a pinged
|
|
845
|
+
// final answer (materialize()) when the model never called reply.
|
|
846
|
+
// VISIBLE off (default) → minInitialChars:MAX so NO visible preview ever
|
|
847
|
+
// opens; the reply tool is the single canonical formatted message
|
|
848
|
+
// (no flash). The draft transport is permanently retired — both modes
|
|
849
|
+
// use sendMessage + editMessageText for any message that does open.
|
|
850
|
+
minInitialChars: ANSWER_LANE.minInitialChars,
|
|
851
|
+
// #2669: the answer-stream ships the RAW transcript markdown; the
|
|
852
|
+
// sendMessage/editMessageText wrappers below send it via the
|
|
853
|
+
// rich-message path (`sendRichMessage` / `editMessageText({ markdown })`),
|
|
854
|
+
// matching every other outbound lane. No render step needed.
|
|
855
|
+
// #1075: route through robustApiCall so flood-wait,
|
|
856
|
+
// benign-400, and THREAD_NOT_FOUND are handled uniformly
|
|
857
|
+
// instead of crashing the answer-stream loop on a deleted
|
|
858
|
+
// forum topic. answer-stream's own try/catch already
|
|
859
|
+
// tolerates undefined returns from editMessageText.
|
|
860
|
+
//
|
|
861
|
+
// disable_notification gating by purpose (2026-05-25):
|
|
862
|
+
//
|
|
863
|
+
// - purpose='stream' (the live edit-in-place preview): SILENT.
|
|
864
|
+
// Without disable_notification, the first text chunk that
|
|
865
|
+
// opens the visible message device-pings, and then when the
|
|
866
|
+
// model later calls the reply MCP tool, that reply pings
|
|
867
|
+
// AGAIN (the over-ping safety net at gateway.ts:~4452 only
|
|
868
|
+
// sees executeReply paths, not this direct sendMessage). Two
|
|
869
|
+
// device pings per multi-step turn — the original Bug A.
|
|
870
|
+
// Edits in place don't notify regardless (Telegram semantics).
|
|
871
|
+
//
|
|
872
|
+
// - purpose='materialize' (turn-end final-answer fresh send,
|
|
873
|
+
// only fires for text-only turns where the stream IS the
|
|
874
|
+
// answer): PING. The user reached for the agent and the
|
|
875
|
+
// model produced an answer; per beat 5 of
|
|
876
|
+
// `reference/rfcs/conversational-pacing.md` the final answer MUST
|
|
877
|
+
// ping the device exactly once. Without this carve-out, a
|
|
878
|
+
// short text-only turn ("on it" being the whole response)
|
|
879
|
+
// lands silently and the user has no notification to know
|
|
880
|
+
// the answer arrived — the original over-correction.
|
|
881
|
+
//
|
|
882
|
+
// - purpose unset (defensive default): SILENT. Treat as
|
|
883
|
+
// stream-purpose so we never accidentally fire a stray ping
|
|
884
|
+
// from an unrecognised sendMessage callsite.
|
|
885
|
+
sendMessage: async (chatId, text, params) => {
|
|
886
|
+
const tid = params?.message_thread_id
|
|
887
|
+
const silent = params?.purpose !== 'materialize'
|
|
888
|
+
const msg = await robustApiCall(
|
|
889
|
+
() =>
|
|
890
|
+
// allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; answer-stream tolerates failures via its own try/catch
|
|
891
|
+
// sendRichMessage doesn't accept link_preview_options — omit it.
|
|
892
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
893
|
+
disable_notification: silent,
|
|
894
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
895
|
+
...(params?.reply_parameters != null
|
|
896
|
+
? { reply_parameters: params.reply_parameters }
|
|
897
|
+
: {}),
|
|
898
|
+
}),
|
|
899
|
+
{
|
|
900
|
+
chat_id: chatId,
|
|
901
|
+
verb: `answer-stream.sendMessage(${params?.purpose ?? 'stream'})`,
|
|
902
|
+
...(tid != null ? { threadId: tid } : {}),
|
|
903
|
+
},
|
|
904
|
+
)
|
|
905
|
+
return { message_id: msg.message_id }
|
|
906
|
+
},
|
|
907
|
+
editMessageText: (chatId, messageId, text, params) => {
|
|
908
|
+
const tid = params?.message_thread_id
|
|
909
|
+
return robustApiCall(
|
|
910
|
+
() =>
|
|
911
|
+
bot.api.editMessageText(chatId, messageId, richMessage(text), {
|
|
912
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
913
|
+
...(params?.link_preview_options != null
|
|
914
|
+
? { link_preview_options: params.link_preview_options }
|
|
915
|
+
: {}),
|
|
916
|
+
}),
|
|
917
|
+
{
|
|
918
|
+
chat_id: chatId,
|
|
919
|
+
verb: 'answer-stream.editMessageText',
|
|
920
|
+
...(tid != null ? { threadId: tid } : {}),
|
|
921
|
+
// #3084 PR 2 / L1: answer-stream edits are COSMETIC — a
|
|
922
|
+
// dropped stream tick costs nothing (the next carries full
|
|
923
|
+
// text). messageId/editPayload engage the per-message floor +
|
|
924
|
+
// coalescing + no-op skip so rapid stream edits don't storm
|
|
925
|
+
// the same message (the top ban trigger).
|
|
926
|
+
priorityClass: 'cosmetic',
|
|
927
|
+
messageId,
|
|
928
|
+
editPayload: richMessage(text),
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
},
|
|
932
|
+
deleteMessage: (chatId, messageId) =>
|
|
933
|
+
robustApiCall(
|
|
934
|
+
() => bot.api.deleteMessage(chatId, messageId),
|
|
935
|
+
{ chat_id: chatId, verb: 'answer-stream.deleteMessage' },
|
|
936
|
+
),
|
|
937
|
+
log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
938
|
+
warn: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
939
|
+
// Issue #203: route answer-lane events through the streaming
|
|
940
|
+
// metrics sink. Each successful update/edit/draft and the final
|
|
941
|
+
// materialize emit one event. Also tick the silent-gap tracker
|
|
942
|
+
// so answer-lane activity doesn't count as silent.
|
|
943
|
+
//
|
|
944
|
+
// #1067: the closure captures `turn` so the signal tracker
|
|
945
|
+
// ticks against THIS turn's chat key. If a new turn took over,
|
|
946
|
+
// the captured `turn` no longer matches `currentTurn` and we
|
|
947
|
+
// skip the tick (the new turn has its own answer stream).
|
|
948
|
+
onMetric: (metricEv) => {
|
|
949
|
+
logStreamingEvent(metricEv)
|
|
950
|
+
// PR-4e — keyed liveness under the flag. Flag-OFF keeps the literal
|
|
951
|
+
// `currentTurn === turn` (a draft-update metric for topic A's stream
|
|
952
|
+
// must tick A's signal/silence clock); flag-ON resolves A by its own
|
|
953
|
+
// key so a flip to B doesn't skip A's tick.
|
|
954
|
+
if (EMISSION_AUTHORITY_ENABLED ? turnLiveForItsTopic(turn) : getCurrentTurn() === turn) {
|
|
955
|
+
signalTracker.noteSignal(
|
|
956
|
+
statusKey(turn.sessionChatId, turn.sessionThreadId),
|
|
957
|
+
Date.now(),
|
|
958
|
+
)
|
|
959
|
+
// Production-liveness: a draft update is the agent visibly
|
|
960
|
+
// composing — reset the silence-poke clock so a long
|
|
961
|
+
// compose-only turn (no tools, no reply yet) isn't torn down.
|
|
962
|
+
if (SILENCE_LIVENESS_PRODUCTION) {
|
|
963
|
+
silencePoke.noteProduction(
|
|
964
|
+
statusKey(turn.sessionChatId, turn.sessionThreadId),
|
|
965
|
+
Date.now(),
|
|
966
|
+
)
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
// #646 — wire the shared outboundDedup into the answer-stream
|
|
971
|
+
// materialize path so it participates in the same dedup window
|
|
972
|
+
// as turn-flush and reply/stream_reply. Closured chatId /
|
|
973
|
+
// threadId come from the captured `turn` snapshot, stable for
|
|
974
|
+
// the lifetime of the stream.
|
|
975
|
+
checkDedup: (text: string) => {
|
|
976
|
+
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null
|
|
977
|
+
},
|
|
978
|
+
recordDedup: (text: string) => {
|
|
979
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
|
|
980
|
+
},
|
|
981
|
+
// #648 — write answer-stream materializations into the SQLite
|
|
982
|
+
// history buffer so get_recent_messages can surface them. Guard
|
|
983
|
+
// with HISTORY_ENABLED, matching the turn-flush pattern at ~3783.
|
|
984
|
+
recordOutbound: ({ messageId, text }: { messageId: number; text: string }) => {
|
|
985
|
+
if (!HISTORY_ENABLED) return
|
|
986
|
+
try {
|
|
987
|
+
recordOutbound({
|
|
988
|
+
chat_id: turn.sessionChatId,
|
|
989
|
+
thread_id: turn.sessionThreadId ?? null,
|
|
990
|
+
message_ids: [messageId],
|
|
991
|
+
texts: [text],
|
|
992
|
+
})
|
|
993
|
+
} catch {}
|
|
994
|
+
},
|
|
995
|
+
})
|
|
996
|
+
}
|
|
997
|
+
// #549 fix: route the chunk through the preamble suppressor
|
|
998
|
+
// instead of immediately updating the answer stream. If a
|
|
999
|
+
// tool_use arrives within the buffer window, the suppressor
|
|
1000
|
+
// drops the chunk (the card owns it). Otherwise it flushes as
|
|
1001
|
+
// answer text. `turn.capturedText` is unchanged — it remains
|
|
1002
|
+
// the safety-net source for turn-flush prose recovery.
|
|
1003
|
+
preambleSuppressor.onText(ev.text)
|
|
1004
|
+
}
|
|
1005
|
+
resetOrphanedReplyTimeout()
|
|
1006
|
+
// PR A — (re)arm the deterministic answer-ready quiescence flush. Each
|
|
1007
|
+
// text chunk debounces the timer; it fires only after ~1 s of no new
|
|
1008
|
+
// stream events, delivering a composed toolless answer without waiting on
|
|
1009
|
+
// the unreliable turn_duration signal or the ~150 s orphaned backstop.
|
|
1010
|
+
resetAnswerReadyFlushTimeout()
|
|
1011
|
+
|
|
1012
|
+
if (isContextExhaustionText(ev.text) && turn != null) {
|
|
1013
|
+
const chatId = turn.sessionChatId
|
|
1014
|
+
const threadId = turn.sessionThreadId
|
|
1015
|
+
const now = Date.now()
|
|
1016
|
+
if (now - getLastContextExhaustionWarningAt() < CONTEXT_EXHAUSTION_COOLDOWN_MS) return
|
|
1017
|
+
setLastContextExhaustionWarningAt(now)
|
|
1018
|
+
process.stderr.write(`telegram gateway: context exhaustion detected — notifying user\n`)
|
|
1019
|
+
const warnOpts = {
|
|
1020
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
1021
|
+
}
|
|
1022
|
+
// #1075: thread-id-bearing, fire-and-forget — swallow on
|
|
1023
|
+
// THREAD_NOT_FOUND so a deleted topic doesn't crash the gateway.
|
|
1024
|
+
void swallowingApiCall(
|
|
1025
|
+
() =>
|
|
1026
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
1027
|
+
bot.api.sendRichMessage(
|
|
1028
|
+
chatId,
|
|
1029
|
+
richMessage('⚠️ **Context window full** — send \`/restart\` to start a fresh session.'),
|
|
1030
|
+
warnOpts,
|
|
1031
|
+
),
|
|
1032
|
+
{
|
|
1033
|
+
chat_id: chatId,
|
|
1034
|
+
verb: 'context-exhaust-warning',
|
|
1035
|
+
...(threadId != null ? { threadId } : {}),
|
|
1036
|
+
},
|
|
1037
|
+
)
|
|
1038
|
+
// #1713: context-exhaustion is a terminal failure path — paint 😱
|
|
1039
|
+
// and finalize the controller. `setError` alone is non-terminal
|
|
1040
|
+
// (recovery permitted); since this turn is genuinely ending, route
|
|
1041
|
+
// through `finalize('error')` so the emoji lands and the controller
|
|
1042
|
+
// stops accepting further transitions.
|
|
1043
|
+
finalizeStatusReaction(chatId, threadId, 'error')
|
|
1044
|
+
// Surfaced during CC-5 investigation (`docs/status-ask-cause-classes.md`):
|
|
1045
|
+
// the context-exhaust bail path teardown was missing
|
|
1046
|
+
// `silencePoke.endTurn(key)`. Without it, the silence-poke state for
|
|
1047
|
+
// this turn lingers in the Map. Once 300s of clock-time passes from
|
|
1048
|
+
// the turn's original start, the framework fallback fires and the
|
|
1049
|
+
// gateway sends a user-visible "still working… (no update from agent
|
|
1050
|
+
// in 5 min)" message — for a turn the gateway internally considers
|
|
1051
|
+
// dead and has already told the user is over (the ⚠️ Context window
|
|
1052
|
+
// full message above). Match the pattern used at the regular
|
|
1053
|
+
// turn-end path (line ~5039) and the wedged-turn path (~5290).
|
|
1054
|
+
const ceKey = statusKey(chatId, threadId)
|
|
1055
|
+
silencePoke.endTurn(ceKey)
|
|
1056
|
+
pendingProgress.noteTurnEnd(ceKey)
|
|
1057
|
+
// Issue #195: tear down the answer-lane stream on context-exhaustion
|
|
1058
|
+
// bail-out. The user is being told the session needs /restart, so any
|
|
1059
|
+
// partially-streamed answer would be misleading.
|
|
1060
|
+
if (turn.answerStream != null) {
|
|
1061
|
+
turn.answerStream.stop()
|
|
1062
|
+
turn.answerStream = null
|
|
1063
|
+
}
|
|
1064
|
+
// Null the atom — this turn is being abandoned.
|
|
1065
|
+
endCurrentTurnAtomic(turn)
|
|
1066
|
+
// #549 fix — context-exhaustion teardown also resets preamble state.
|
|
1067
|
+
preambleSuppressor.reset()
|
|
1068
|
+
}
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
case 'tool_result': {
|
|
1072
|
+
if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
|
|
1073
|
+
// Fix 1.3 (#2903): flush a staged 📌/✂️ memory-legibility line only on a
|
|
1074
|
+
// CONFIRMED-successful write; a failed write (ev.isError) drops it.
|
|
1075
|
+
confirmMemoryLegibility(ev.toolUseId, ev.isError)
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
case 'sub_agent_tool_use': {
|
|
1079
|
+
const turn = getCurrentTurn()
|
|
1080
|
+
if (turn == null) return
|
|
1081
|
+
if (!ev.toolUseId) return
|
|
1082
|
+
typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, ev.toolName, turn.sessionThreadId ?? null)
|
|
1083
|
+
return
|
|
1084
|
+
}
|
|
1085
|
+
case 'sub_agent_tool_result': {
|
|
1086
|
+
if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
case 'turn_end': {
|
|
1090
|
+
// DEFENSIVE FIX: belt-and-braces guard against the synthetic backstop
|
|
1091
|
+
// (`durationMs: -1`) racing live work. durationMs >= 0 is the
|
|
1092
|
+
// authoritative signal from system/turn_duration; -1 is ONLY ever set
|
|
1093
|
+
// by the orphaned-reply backstop. Reject the synthetic event here so that
|
|
1094
|
+
// even if the PRIMARY fix's re-arm logic is bypassed (e.g. a very fast
|
|
1095
|
+
// fire before isLegitimatelyWorking() is sampled) we still don't tear
|
|
1096
|
+
// down a live feed mid-work. Extended from the original isMidToolCall()
|
|
1097
|
+
// check to the full isLegitimatelyWorking predicate so detached background
|
|
1098
|
+
// work and human-wait tools (ask_user) are also protected.
|
|
1099
|
+
// INVARIANT: a REAL turn_end (durationMs >= 0) is NEVER suppressed.
|
|
1100
|
+
// PR A carve-out: the answer-ready quiescence flush also uses
|
|
1101
|
+
// `durationMs:-1`, but it is a POSITIVE "streaming has settled" signal
|
|
1102
|
+
// fired only after ~1 s of no stream events AND no in-flight tool — the
|
|
1103
|
+
// exact opposite of a hung turn. It must NOT be suppressed by
|
|
1104
|
+
// recentlyStreaming (the terminal answer text itself stamps that window,
|
|
1105
|
+
// which is the whole bug). Its own arm/fire predicate already re-verified
|
|
1106
|
+
// quiescence, so let it through to deliver.
|
|
1107
|
+
if (ev.durationMs === -1 && ev.reason !== 'answer-ready-quiescence') {
|
|
1108
|
+
const turn = getCurrentTurn()
|
|
1109
|
+
const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
|
|
1110
|
+
// Widened to also suppress while the turn is RECENTLY STREAMING — a
|
|
1111
|
+
// model reasoning pause emits no tool/text events (so
|
|
1112
|
+
// isLegitimatelyWorking is false) but a genuine stream landed within
|
|
1113
|
+
// the window, so the turn is alive and must not be torn down.
|
|
1114
|
+
// ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
|
|
1115
|
+
// THIS backstop path grows from ~30 s to ~120-150 s, because the
|
|
1116
|
+
// "Prompt is too long" marker is itself a genuine `text` event that
|
|
1117
|
+
// stamps recentlyStreaming. This is acceptable — the primary
|
|
1118
|
+
// context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
|
|
1119
|
+
// in `case 'text'` (isContextExhaustionText) above; this backstop only
|
|
1120
|
+
// matters if that path is missed, and it still COMPLETES once the
|
|
1121
|
+
// window lapses. Recovery is delayed, never suppressed forever.
|
|
1122
|
+
const recentlyStreaming =
|
|
1123
|
+
turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
1124
|
+
if (isLegitimatelyWorking(key) || recentlyStreaming) {
|
|
1125
|
+
process.stderr.write(
|
|
1126
|
+
`telegram gateway: synthetic turn_end suppressed — legitimately working` +
|
|
1127
|
+
` (in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
1128
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
1129
|
+
` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
|
|
1130
|
+
)
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
|
|
1135
|
+
// BEFORE the body runs (the body re-reads currentTurn as `turn`, then
|
|
1136
|
+
// nulls it via endCurrentTurnAtomic on every clean branch). The guarded
|
|
1137
|
+
// finally in withTurnEndGateBackstop forces the canonical purge iff a
|
|
1138
|
+
// throw in a pre-purge op (redactOutboundText, progressDriver?.
|
|
1139
|
+
// takeOverCard, narrative dedup, answer-stream finalize, …) skipped
|
|
1140
|
+
// endCurrentTurnAtomic → purgeReactionTracking, which would otherwise
|
|
1141
|
+
// leave activeTurnStartedAt populated (and the machine un-turnEnded)
|
|
1142
|
+
// and wedge the #1556 inbound gate closed until the TTL tick. No-op on
|
|
1143
|
+
// the happy path (key already gone).
|
|
1144
|
+
const turnEndBackstopTurn = getCurrentTurn()
|
|
1145
|
+
const turnEndBackstopKey =
|
|
1146
|
+
turnEndBackstopTurn != null
|
|
1147
|
+
? statusKey(turnEndBackstopTurn.sessionChatId, turnEndBackstopTurn.sessionThreadId)
|
|
1148
|
+
: null
|
|
1149
|
+
withTurnEndGateBackstop(
|
|
1150
|
+
turnEndBackstopKey,
|
|
1151
|
+
turnEndBackstopTurn,
|
|
1152
|
+
() => {
|
|
1153
|
+
// Drain any still-pending tool dispatch typing entries — covers
|
|
1154
|
+
// transcript truncation or a Claude Code crash mid-tool.
|
|
1155
|
+
typingWrapper.drainAll()
|
|
1156
|
+
// Forum-topic placeholder cleanup removed in #553 PR 5 — the
|
|
1157
|
+
// forum-topic placeholder send is also gone, so there is
|
|
1158
|
+
// nothing to clear at turn_end.
|
|
1159
|
+
//
|
|
1160
|
+
// #1067: capture the turn atom at handler entry. The IIFE that
|
|
1161
|
+
// runs turn-flush below is async, so every read after the first
|
|
1162
|
+
// await would be subject to the reattribution race we're fixing.
|
|
1163
|
+
// All downstream code reads `turn.*` or its captured locals.
|
|
1164
|
+
const turn = getCurrentTurn()
|
|
1165
|
+
if (turn?.orphanedReplyTimeoutId != null) {
|
|
1166
|
+
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
1167
|
+
turn.orphanedReplyTimeoutId = null
|
|
1168
|
+
}
|
|
1169
|
+
// Narrative-dedup gate step 3 (JSONL-text-narrative primitive): a
|
|
1170
|
+
// trailing narrative block with nothing after it. When the turn
|
|
1171
|
+
// delivered its answer via reply (replyCalled) the trailing text is
|
|
1172
|
+
// almost always a draft of that answer — compare against the ACTUAL
|
|
1173
|
+
// delivered reply text and SUPPRESS the duplicate; otherwise SHOW
|
|
1174
|
+
// genuine trailing narration ("Done — all green."). Must run BEFORE
|
|
1175
|
+
// clearActivitySummary so a SHOWN line lands in the feed's final
|
|
1176
|
+
// render. Always clears the gate's parked block (and disarms its
|
|
1177
|
+
// early-paint timer) so nothing can leak across turns.
|
|
1178
|
+
//
|
|
1179
|
+
// NIT 2 (reply-proxy precision): use `turn.lastReplyText` (the
|
|
1180
|
+
// most-recent reply/stream_reply input.text) rather than
|
|
1181
|
+
// `capturedText.join('')`. The old proxy concatenated every captured
|
|
1182
|
+
// text block, so a turn that emitted the same short string twice
|
|
1183
|
+
// (e.g. "Done." as working narration, then "Done." as the reply) would
|
|
1184
|
+
// compare the trailing narration against a doubled "DoneDone" — still
|
|
1185
|
+
// a high-prefix match — and wrongly suppress genuine trailing
|
|
1186
|
+
// narration. Comparing against the actual reply text is exact. When
|
|
1187
|
+
// the turn delivered WITHOUT a reply tool (turn-flush emits
|
|
1188
|
+
// capturedText as the answer), fall back to capturedText.join('') so
|
|
1189
|
+
// that path's trailing-draft suppression is preserved.
|
|
1190
|
+
if (turn != null) {
|
|
1191
|
+
const deliveredText = turn.lastReplyText.length > 0
|
|
1192
|
+
? turn.lastReplyText
|
|
1193
|
+
: (turn.replyCalled ? turn.capturedText.join('') : '')
|
|
1194
|
+
flushPendingNarrativeAtTurnEnd(turn, deliveredText)
|
|
1195
|
+
}
|
|
1196
|
+
// Clear the activity feed at the real end of the turn. This is the
|
|
1197
|
+
// no-reply safety net — a turn that ends without ever calling reply
|
|
1198
|
+
// (the answer is delivered by turn-flush / silent-end) still has its
|
|
1199
|
+
// feed removed. On a normal turn the feed was already cleared at the
|
|
1200
|
+
// first reply (the hand-off); clearActivitySummary is idempotent, so
|
|
1201
|
+
// the second call is a no-op.
|
|
1202
|
+
if (turn != null) {
|
|
1203
|
+
clearActivitySummary(turn)
|
|
1204
|
+
}
|
|
1205
|
+
// #549 fix — flush any pending preamble BEFORE the answer stream is
|
|
1206
|
+
// nulled below. Text emitted immediately before turn_end (no tool
|
|
1207
|
+
// followed) is the answer; the suppressor's emitAnswer callback
|
|
1208
|
+
// would no-op against a nulled stream, silently dropping the text
|
|
1209
|
+
// (regression for short no-tool replies). Order matters here: this
|
|
1210
|
+
// call must come before the retract/null block.
|
|
1211
|
+
preambleSuppressor.flushNow()
|
|
1212
|
+
// #656: by default we ALWAYS retract the answer-lane stream at
|
|
1213
|
+
// turn_end. Turn-flush is the canonical emitter for no-reply
|
|
1214
|
+
// turns; materialising here would race it and post raw model
|
|
1215
|
+
// text (no HTML conv).
|
|
1216
|
+
//
|
|
1217
|
+
// #869-Phase1 override: when `ANSWER_STREAM_VISIBLE_ENABLED` is
|
|
1218
|
+
// on, the stream is rendering a USER-VISIBLE message in the
|
|
1219
|
+
// chat timeline. When the stream is the de-facto final answer
|
|
1220
|
+
// (model never called reply, captured text is substantive), we
|
|
1221
|
+
// need to:
|
|
1222
|
+
// 1. Send a FRESH pinged message via `stream.materialize()`
|
|
1223
|
+
// so the user gets a device notification — beat 5 of the
|
|
1224
|
+
// conversational-pacing contract requires exactly one
|
|
1225
|
+
// ping per turn for the final answer. Without this,
|
|
1226
|
+
// text-only short turns ("on it" being the whole reply)
|
|
1227
|
+
// land silently and the user has no notification to know
|
|
1228
|
+
// the answer arrived (the failure caught by the
|
|
1229
|
+
// midturn-silent-dm UAT, 2026-05-25).
|
|
1230
|
+
// 2. Delete the silent streamed preview message so the user
|
|
1231
|
+
// doesn't see a duplicate (the streamed message in place
|
|
1232
|
+
// + the fresh materialized ping). materialize() handles
|
|
1233
|
+
// the fresh send but leaves the old streamed message_id
|
|
1234
|
+
// orphaned by design — we delete it explicitly here.
|
|
1235
|
+
//
|
|
1236
|
+
// The previous behavior (just `stream.stop()` to freeze the
|
|
1237
|
+
// streamed message in place) avoided the duplicate but also
|
|
1238
|
+
// skipped the ping. Materialize-and-delete trades a brief
|
|
1239
|
+
// visual "the streamed message is replaced by a fresh one"
|
|
1240
|
+
// (often imperceptible for short turns where the streaming
|
|
1241
|
+
// barely had time to register; mildly visible for longer
|
|
1242
|
+
// turns) in exchange for an always-correct turn-end ping.
|
|
1243
|
+
let streamFinalizedAsAnswer = false
|
|
1244
|
+
if (turn?.answerStream != null) {
|
|
1245
|
+
const stream = turn.answerStream
|
|
1246
|
+
const streamedMsgId = stream.messageId()
|
|
1247
|
+
const streamedFinalText = turn.capturedText.join('').trim()
|
|
1248
|
+
if (
|
|
1249
|
+
// Only when a VISIBLE preview actually opened (visible flag on): a
|
|
1250
|
+
// text-only no-reply turn that streamed a visible preview must
|
|
1251
|
+
// materialize a pinged final answer + delete the preview, NOT fall into
|
|
1252
|
+
// the else-branch retract() which would delete the user's only copy of
|
|
1253
|
+
// the answer (a lost-answer bug). Gated on the visible flag alone (the
|
|
1254
|
+
// flash-regression decoupling): with the visible stream OFF (default)
|
|
1255
|
+
// no preview opens (minInitialChars:MAX), so streamedMsgId is null and
|
|
1256
|
+
// this branch is unreachable — the no-reply answer is delivered by the
|
|
1257
|
+
// turn-flush backstop below instead, the pre-v0.14.68 path. The
|
|
1258
|
+
// reply-tool branch hits retract() on a non-opened lane (a no-op), so
|
|
1259
|
+
// there is no preliminary to flash.
|
|
1260
|
+
ANSWER_LANE.opensVisiblePreview
|
|
1261
|
+
&& !turn.replyCalled
|
|
1262
|
+
&& streamedMsgId != null
|
|
1263
|
+
&& streamedFinalText.length > 0
|
|
1264
|
+
) {
|
|
1265
|
+
turn.answerStream = null
|
|
1266
|
+
streamFinalizedAsAnswer = true
|
|
1267
|
+
turn.finalAnswerDelivered = true
|
|
1268
|
+
// Feed-reopen refinement: the stream is being finalized as the
|
|
1269
|
+
// turn's answer (the model's terminal text), i.e. done=true by
|
|
1270
|
+
// construction → substantive. Post-answer housekeeping must NOT
|
|
1271
|
+
// re-open the feed.
|
|
1272
|
+
turn.finalAnswerSubstantive = true
|
|
1273
|
+
// Capture the old streamed message_id BEFORE materialize so
|
|
1274
|
+
// we can delete it after the fresh ping send. materialize()
|
|
1275
|
+
// overwrites `streamMsgId` internally with the new send's id;
|
|
1276
|
+
// without capturing here we'd lose the reference.
|
|
1277
|
+
const oldStreamedMsgId = streamedMsgId
|
|
1278
|
+
// Fire-and-forget materialize-and-delete sequence.
|
|
1279
|
+
//
|
|
1280
|
+
// Bookkeeping (dedup + history): handled inside
|
|
1281
|
+
// `materialize()` itself — see `answer-stream.ts:~548-549`
|
|
1282
|
+
// which calls the injected `recordDedup` + `recordOutbound`
|
|
1283
|
+
// callbacks with the NEW (fresh-send) message_id only after a
|
|
1284
|
+
// successful send. We deliberately do NOT pre-record here —
|
|
1285
|
+
// doing so populates the same `outboundDedup` store that
|
|
1286
|
+
// materialize's internal `checkDedup` consults at
|
|
1287
|
+
// `answer-stream.ts:~510`, causing materialize to dedup-
|
|
1288
|
+
// suppress its own send (return undefined, no ping fires) —
|
|
1289
|
+
// the exact failure mode this PR exists to fix. Let
|
|
1290
|
+
// materialize own the bookkeeping; gateway only sequences the
|
|
1291
|
+
// operations.
|
|
1292
|
+
//
|
|
1293
|
+
// Delete gating: only run the cleanup `deleteMessage` if
|
|
1294
|
+
// materialize actually sent (returned a numeric sentId). If
|
|
1295
|
+
// it dedup-suppressed or threw, the streamed preview is the
|
|
1296
|
+
// user's only copy of the answer and MUST be preserved.
|
|
1297
|
+
void (async () => {
|
|
1298
|
+
let materializedId: number | undefined
|
|
1299
|
+
try {
|
|
1300
|
+
materializedId = await stream.materialize()
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
process.stderr.write(
|
|
1303
|
+
`telegram gateway: answer-stream materialize failed: ${
|
|
1304
|
+
err instanceof Error ? err.message : String(err)
|
|
1305
|
+
}\n`,
|
|
1306
|
+
)
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
if (typeof materializedId !== 'number' || !Number.isFinite(materializedId)) {
|
|
1310
|
+
// materialize() returned undefined — either pendingText
|
|
1311
|
+
// was empty, the body was a silent marker (NO_REPLY /
|
|
1312
|
+
// HEARTBEAT_OK), or `checkDedup` suppressed it. In every
|
|
1313
|
+
// such case the streamed preview is the user's only copy
|
|
1314
|
+
// of the content; don't delete it.
|
|
1315
|
+
process.stderr.write(
|
|
1316
|
+
`telegram gateway: answer-stream materialize returned no msgId ` +
|
|
1317
|
+
`chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} — ` +
|
|
1318
|
+
`preserving silent preview as the user's only copy\n`,
|
|
1319
|
+
)
|
|
1320
|
+
return
|
|
1321
|
+
}
|
|
1322
|
+
// Materialize sent a fresh pinged message at materializedId.
|
|
1323
|
+
// Delete the silent streamed preview so the chat shows one
|
|
1324
|
+
// canonical message (the fresh pinged one) and not two with
|
|
1325
|
+
// duplicate content. Best-effort; failures (already gone,
|
|
1326
|
+
// permission denied) leave a brief visible duplicate which
|
|
1327
|
+
// we accept rather than retry-storming.
|
|
1328
|
+
try {
|
|
1329
|
+
// allow-raw-bot-api: cleanup delete of silent streamed preview
|
|
1330
|
+
await bot.api.deleteMessage(turn.sessionChatId, oldStreamedMsgId)
|
|
1331
|
+
} catch (delErr) {
|
|
1332
|
+
process.stderr.write(
|
|
1333
|
+
`telegram gateway: answer-stream materialize-cleanup ` +
|
|
1334
|
+
`delete failed for msgId=${oldStreamedMsgId}: ${
|
|
1335
|
+
delErr instanceof Error ? delErr.message : String(delErr)
|
|
1336
|
+
}\n`,
|
|
1337
|
+
)
|
|
1338
|
+
}
|
|
1339
|
+
process.stderr.write(
|
|
1340
|
+
`telegram gateway: answer-stream materialized as answer ` +
|
|
1341
|
+
`chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} ` +
|
|
1342
|
+
`newMsg=${materializedId} chars=${streamedFinalText.length}\n`,
|
|
1343
|
+
)
|
|
1344
|
+
})()
|
|
1345
|
+
} else {
|
|
1346
|
+
turn.answerStream = null
|
|
1347
|
+
void stream.retract().catch((err) => {
|
|
1348
|
+
process.stderr.write(
|
|
1349
|
+
`telegram gateway: answer-stream retract failed: ${
|
|
1350
|
+
err instanceof Error ? err.message : String(err)
|
|
1351
|
+
}\n`,
|
|
1352
|
+
)
|
|
1353
|
+
})
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
if (turn == null) return
|
|
1357
|
+
const chatId = turn.sessionChatId
|
|
1358
|
+
const threadId = turn.sessionThreadId
|
|
1359
|
+
const ctrl = activeStatusReactions.get(statusKey(chatId, threadId))
|
|
1360
|
+
|
|
1361
|
+
// #1122 PR3: #51 prose-as-step recovery removed with the
|
|
1362
|
+
// progress card. Without the card there's no narrative-steps
|
|
1363
|
+
// surface to recover from. The decideTurnFlush 'empty-text'
|
|
1364
|
+
// path now relies on capturedText alone.
|
|
1365
|
+
|
|
1366
|
+
// #869-Phase1: when the answer-stream finalised as the answer
|
|
1367
|
+
// above, skip the turn-flush IIFE entirely — its job (deliver
|
|
1368
|
+
// captured text) is already done by the visible stream, and
|
|
1369
|
+
// running it would race a duplicate fresh-sendMessage against
|
|
1370
|
+
// the user-visible edited message.
|
|
1371
|
+
const flushDecision = streamFinalizedAsAnswer
|
|
1372
|
+
? ({ kind: 'skip', reason: 'reply-called' } as ReturnType<typeof decideTurnFlush>)
|
|
1373
|
+
: decideTurnFlush({
|
|
1374
|
+
chatId: turn.sessionChatId,
|
|
1375
|
+
replyCalled: turn.replyCalled,
|
|
1376
|
+
capturedText: turn.capturedText,
|
|
1377
|
+
capturedBlockMeta: turn.capturedBlockMeta,
|
|
1378
|
+
flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
|
|
1379
|
+
})
|
|
1380
|
+
// #1667 — resolve the turn_end answer-delivery gate once, here, via the
|
|
1381
|
+
// pure decision core. The three dispositions below (silent-marker,
|
|
1382
|
+
// turn-flush, #1664 re-prompt) delegate to this so the gateway runs the
|
|
1383
|
+
// exact code the regression test exercises. `finalAnswerDelivered` is read
|
|
1384
|
+
// at its tail value: the answer-stream materialize branch above has
|
|
1385
|
+
// already run (and may have set it true); the flush branch, which also
|
|
1386
|
+
// sets it, is not yet entered and does not affect the gate's own outcome.
|
|
1387
|
+
const turnEndDecision = decideTurnEndGate({
|
|
1388
|
+
flushDecision,
|
|
1389
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
1390
|
+
})
|
|
1391
|
+
if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
|
|
1392
|
+
process.stderr.write(
|
|
1393
|
+
`telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
|
|
1394
|
+
)
|
|
1395
|
+
// Ghost-reply detection (#45): the model ended a Telegram-inbound turn
|
|
1396
|
+
// without calling reply/stream_reply AND without emitting any assistant
|
|
1397
|
+
// text that the turn-flush could forward. The user will see only the
|
|
1398
|
+
// progress card disappear — no visible output. Log a prominent warning
|
|
1399
|
+
// so this silent-drop pattern is immediately visible in the logs.
|
|
1400
|
+
if (
|
|
1401
|
+
flushDecision.reason === 'empty-text' &&
|
|
1402
|
+
!turn.replyCalled
|
|
1403
|
+
) {
|
|
1404
|
+
process.stderr.write(
|
|
1405
|
+
`telegram gateway: WARN ghost-reply detected — turn ended with zero outbound messages` +
|
|
1406
|
+
` chat=${chatId} turnStartedAt=${turn.startedAt} replyCalled=false capturedText=empty` +
|
|
1407
|
+
` — the progress card steps were the only thing the user saw (#45)\n`,
|
|
1408
|
+
)
|
|
1409
|
+
// #2527: emit structured WARN so the reaction-only failure mode is
|
|
1410
|
+
// machine-readable in the streaming-metrics channel.
|
|
1411
|
+
const tKey = statusKey(chatId, threadId)
|
|
1412
|
+
logStreamingEvent({
|
|
1413
|
+
kind: 'turn_no_reply_warn',
|
|
1414
|
+
chatId,
|
|
1415
|
+
threadId,
|
|
1416
|
+
turnId: turn.turnId,
|
|
1417
|
+
turnDurationMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
|
|
1418
|
+
reactionCount: reactionTransitionCounts.get(tKey) ?? 0,
|
|
1419
|
+
})
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// ── Sentinel suppression (NO_REPLY / HEARTBEAT_OK) ──────────────────
|
|
1424
|
+
// When the model's only output is a silent-turn sentinel we must:
|
|
1425
|
+
// 1. NOT finalise the progress card (that would push a "Done" edit).
|
|
1426
|
+
// 2. NOT send any reply message to the user.
|
|
1427
|
+
// 3. Unpin the progress card so no orphaned ⚙️ Working… lingers.
|
|
1428
|
+
// 4. Log at debug level and fall through to normal state cleanup.
|
|
1429
|
+
if (turnEndDecision === 'silent_end') {
|
|
1430
|
+
// Don't try to distinguish NO_REPLY vs HEARTBEAT_OK in the log line:
|
|
1431
|
+
// `isSilentFlushMarker` accepts trailing punctuation (e.g. "NO_REPLY.")
|
|
1432
|
+
// and case variants, so a strict equality check would print the wrong
|
|
1433
|
+
// reason. The flushDecision.reason is the source of truth.
|
|
1434
|
+
process.stderr.write(
|
|
1435
|
+
`telegram gateway: silent-turn-suppression: chat=${chatId} turnKey=${turn.startedAt} reason=silent-marker\n`,
|
|
1436
|
+
)
|
|
1437
|
+
// Drop progress-card streams without finalising — the normal
|
|
1438
|
+
// closeProgressLane call below would call stream.finalize() which
|
|
1439
|
+
// sends a final "Done" edit to Telegram. Skip that for silent turns.
|
|
1440
|
+
const suppressPrefix = chatKeyWithSuffix(chatId, threadId, 'progress')
|
|
1441
|
+
for (const [key] of activeDraftStreams) {
|
|
1442
|
+
if (key.startsWith(suppressPrefix)) {
|
|
1443
|
+
activeDraftStreams.delete(key)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
// Unpin without editing the message so no orphaned card lingers.
|
|
1447
|
+
unpinProgressCardForChat?.(chatId, threadId)
|
|
1448
|
+
// Fall through to normal state cleanup (finalize, purge, etc.)
|
|
1449
|
+
// but skip the regular closeProgressLane so we don't re-finalize.
|
|
1450
|
+
// #1713: silent-marker turns still finalize to 👍 — turn_end is
|
|
1451
|
+
// the terminal trigger regardless of whether a reply landed.
|
|
1452
|
+
finalizeStatusReaction(chatId, threadId, 'done')
|
|
1453
|
+
// Match the normal turn_end path's telemetry so silent-marker turns
|
|
1454
|
+
// still appear in turn-duration graphs.
|
|
1455
|
+
{
|
|
1456
|
+
const sKey = streamKey(chatId, threadId)
|
|
1457
|
+
const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
1458
|
+
logStreamingEvent({
|
|
1459
|
+
kind: 'turn_end',
|
|
1460
|
+
chatId,
|
|
1461
|
+
durationMs: turnDurationMs,
|
|
1462
|
+
suppressClearedCount: suppressPtyPreview.has(sKey) ? 1 : 0,
|
|
1463
|
+
})
|
|
1464
|
+
// #203: compute trailing gap (last signal → turn_end) then emit.
|
|
1465
|
+
const tKey = statusKey(chatId, threadId)
|
|
1466
|
+
signalTracker.noteSignal(tKey, Date.now())
|
|
1467
|
+
logStreamingEvent({ kind: 'turn_signal_gap', chatId, longestGapMs: signalTracker.getLongestGap(tKey), turnDurationMs })
|
|
1468
|
+
// #1122 KPI: emit turn_ended (silent-marker path) with TTFO +
|
|
1469
|
+
// outbound-gap metrics for the conversational-pacing dashboard.
|
|
1470
|
+
const outboundMetrics = signalTracker.getOutboundMetrics(tKey)
|
|
1471
|
+
emitRuntimeMetric({
|
|
1472
|
+
kind: 'turn_ended',
|
|
1473
|
+
chat_id: chatId,
|
|
1474
|
+
thread_id: threadId ?? null,
|
|
1475
|
+
duration_ms: turnDurationMs,
|
|
1476
|
+
ttfo_ms: outboundMetrics.ttfoMs,
|
|
1477
|
+
outbound_count: outboundMetrics.outboundCount,
|
|
1478
|
+
longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
|
|
1479
|
+
ended_via: 'silent',
|
|
1480
|
+
})
|
|
1481
|
+
// #1122 PR4 fix: deterministic silent-end detection for the
|
|
1482
|
+
// Stop hook. PR3 deleted the writer with the progress card;
|
|
1483
|
+
// this restores it. If the user-message turn ended without
|
|
1484
|
+
// any outbound, write the state file so silent-end-interrupt
|
|
1485
|
+
// -stop.mjs blocks the stop and re-prompts the agent.
|
|
1486
|
+
if (outboundMetrics.outboundCount === 0) {
|
|
1487
|
+
writeSilentEndState({
|
|
1488
|
+
chatId,
|
|
1489
|
+
threadId: threadId ?? null,
|
|
1490
|
+
turnKey: tKey,
|
|
1491
|
+
})
|
|
1492
|
+
}
|
|
1493
|
+
// #1122 PR4 fix: PR3 removed the progressDriver.onTurnComplete
|
|
1494
|
+
// callback that cleared the turn-active marker on silent-marker
|
|
1495
|
+
// turns. The main turn-end path at ~line 5180 has its own
|
|
1496
|
+
// cleanup (#550 defence-in-depth) but the silent-marker path
|
|
1497
|
+
// relied solely on the driver callback. Without this the
|
|
1498
|
+
// bridge-watchdog (#412) reads a stale marker and could
|
|
1499
|
+
// false-positive wedge-detection across silent turns.
|
|
1500
|
+
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
1501
|
+
signalTracker.clear(tKey)
|
|
1502
|
+
silencePoke.endTurn(tKey)
|
|
1503
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
1504
|
+
}
|
|
1505
|
+
lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
|
|
1506
|
+
setPendingPtyPartial(null)
|
|
1507
|
+
closeActivityLane(chatId, threadId)
|
|
1508
|
+
// NOTE: closeProgressLane intentionally skipped — streams already dropped above.
|
|
1509
|
+
// #1067: null the atom so any late-arriving event for THIS turn
|
|
1510
|
+
// returns early at handler entry. A new `enqueue` swaps in a
|
|
1511
|
+
// fresh atom; the silent-turn teardown doesn't need to preserve
|
|
1512
|
+
// any of the prior turn's state.
|
|
1513
|
+
endCurrentTurnAtomic(turn)
|
|
1514
|
+
// #549 fix — silent-marker teardown drops any pending preamble.
|
|
1515
|
+
preambleSuppressor.dropNow()
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (turnEndDecision === 'flush' && flushDecision.kind === 'flush') {
|
|
1520
|
+
let capturedText = flushDecision.text
|
|
1521
|
+
// #2798 — turn-flush delivers the model's terminal prose when it
|
|
1522
|
+
// skipped reply/stream_reply, but historically bypassed the reply
|
|
1523
|
+
// path's markdown normalization entirely. Mirror executeReply's front
|
|
1524
|
+
// of pipeline here so the backstop renders identically: repair LLM
|
|
1525
|
+
// JSON-escape bungles (literal `\n`), then promote lone prose paragraph
|
|
1526
|
+
// breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
|
|
1527
|
+
// collapse them (lists/tables/code left untouched). Runs BEFORE the
|
|
1528
|
+
// redact/scrub below, exactly as reply orders it (repair → normalize →
|
|
1529
|
+
// redact → scrub), so masking sees the repaired text. Paragraph gaps
|
|
1530
|
+
// are the plain `\n\n` normalizeParagraphBreaks guarantees — no spacer
|
|
1531
|
+
// pass runs on the send side any more (removed in the #2669 follow-up).
|
|
1532
|
+
capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
|
|
1533
|
+
// Component 3 — origin-thread backstop. `chatId`/`threadId` are
|
|
1534
|
+
// captured from the turn atom (turn.sessionChatId/sessionThreadId)
|
|
1535
|
+
// at the top of this turn_end handler, NOT from the live
|
|
1536
|
+
// currentTurn and NEVER from chatThreadMap. So the turn-flush
|
|
1537
|
+
// answer always lands in the thread the turn originated from, even
|
|
1538
|
+
// if currentTurn has flipped — the same guarantee the reply path
|
|
1539
|
+
// gets via origin_turn_id.
|
|
1540
|
+
const backstopChatId = chatId
|
|
1541
|
+
const backstopThreadId = threadId
|
|
1542
|
+
const backstopCtrl = ctrl
|
|
1543
|
+
|
|
1544
|
+
// Outbound secret scrub (#2044). Turn-flush delivers the model's
|
|
1545
|
+
// terminal answer prose when it skipped reply/stream_reply — that
|
|
1546
|
+
// is arbitrary agent free-text, sent via sendMessage/editMessageText
|
|
1547
|
+
// and previewed to stderr below, so it needs the same mask as the
|
|
1548
|
+
// three reply tools. Mirror the voice scrub: mask before the send,
|
|
1549
|
+
// the preview, and recordOutbound.
|
|
1550
|
+
capturedText = redactOutboundText(capturedText, 'turn_flush')
|
|
1551
|
+
|
|
1552
|
+
// #2798 reply-parity — normalize dashes/bullets and trip the over-bold
|
|
1553
|
+
// guard deterministically, on code-masked text. This is the SAME chain
|
|
1554
|
+
// the reply path applies (`stripExcessBold(normalizePunctuation(text))`)
|
|
1555
|
+
// in the SAME order: after redact, before scrubVoice. Without it the
|
|
1556
|
+
// turn-flush backstop rendered punctuation/bold differently from an
|
|
1557
|
+
// identical reply. Kept inline to mirror reply exactly — a shared helper
|
|
1558
|
+
// is a deliberate future refactor, not this change.
|
|
1559
|
+
capturedText = stripExcessBold(normalizePunctuation(capturedText))
|
|
1560
|
+
|
|
1561
|
+
// Voice scrub (PR #1683 follow-up). Turn-flush is the path
|
|
1562
|
+
// that fires when the model emits raw transcript text WITHOUT
|
|
1563
|
+
// calling reply / stream_reply. That captured text bypasses
|
|
1564
|
+
// PR #1683's executeReply scrub site entirely and is delivered
|
|
1565
|
+
// via the rich-message path directly. Scrub the capturedText on the
|
|
1566
|
+
// raw markdown so em-dashes never reach the wire. Kill switch:
|
|
1567
|
+
// SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
1568
|
+
{
|
|
1569
|
+
const scrub = scrubVoice(capturedText)
|
|
1570
|
+
if (scrub.replaced > 0) {
|
|
1571
|
+
capturedText = scrub.scrubbed
|
|
1572
|
+
emitRuntimeMetric({
|
|
1573
|
+
kind: 'voice_scrub_applied',
|
|
1574
|
+
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
1575
|
+
replaced: scrub.replaced,
|
|
1576
|
+
site: 'turn_flush',
|
|
1577
|
+
})
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
|
|
1582
|
+
// returns 'reply-called' otherwise). It legitimately delivers the
|
|
1583
|
+
// model's terminal text as the answer, so the turn IS answered.
|
|
1584
|
+
// Mark it now so the early-return below skips the silent-end
|
|
1585
|
+
// re-prompt for a turn whose answer is genuinely on its way out.
|
|
1586
|
+
// (The IIFE that actually sends runs after this branch's `return`;
|
|
1587
|
+
// since the silent-end block is on the sibling reply-called path
|
|
1588
|
+
// that this branch never reaches, this set is belt-and-braces —
|
|
1589
|
+
// it keeps the captured `turn` atom internally consistent for any
|
|
1590
|
+
// future reader.)
|
|
1591
|
+
// PR B (Fix 4 — intentional record/ledger inconsistency, out of scope).
|
|
1592
|
+
// We keep setting finalAnswerDelivered=true HERE (before the async send)
|
|
1593
|
+
// so endCurrentTurnAtomic's obligation CLOSE (decideObligationTurnEnd,
|
|
1594
|
+
// ~4818) fires unchanged at turn end. PR B only makes the turns.jsonl
|
|
1595
|
+
// *record* honest (status send_failed when the send later throws) — it
|
|
1596
|
+
// deliberately does NOT change obligation behavior. Consequence: a
|
|
1597
|
+
// send_failed turn still CLOSES its obligation, so a flood-dropped
|
|
1598
|
+
// answer is NOT re-presented — an honest record without honest recovery.
|
|
1599
|
+
// Re-delivery on send_failed (drive obligation close from real send
|
|
1600
|
+
// success, leave it open on failure) is a separate change — see the
|
|
1601
|
+
// PR-B handback FOLLOW-UP note; do NOT touch the ledger in this PR.
|
|
1602
|
+
turn.finalAnswerDelivered = true
|
|
1603
|
+
// Feed-reopen refinement: turn-flush delivers the model's terminal
|
|
1604
|
+
// transcript text as the genuine answer (not an ack). Default to
|
|
1605
|
+
// substantive so a late tool label does NOT re-open the feed / trip
|
|
1606
|
+
// the silent-end re-prompt. (Belt-and-braces, like the set above —
|
|
1607
|
+
// this branch returns before any further tool_label can arrive.)
|
|
1608
|
+
turn.finalAnswerSubstantive = true
|
|
1609
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — arm the answer-delivered
|
|
1610
|
+
// race latch NOW, synchronously, BEFORE the ~500 ms async send below and
|
|
1611
|
+
// BEFORE `flushedTurnSupersede.record`. A late reply that lands in the
|
|
1612
|
+
// post-fire pre-record window resolves this turn (via the unified owner
|
|
1613
|
+
// resolver, reading the atom preserved in `recentTurnsById`) and
|
|
1614
|
+
// suppresses itself against this latch — closing the residual race Part
|
|
1615
|
+
// 1's supersede cannot reach. `capturedText` here is the selected,
|
|
1616
|
+
// normalized flush delivery text.
|
|
1617
|
+
//
|
|
1618
|
+
// #3276 guard 2/5 — the arm is now UNCONDITIONAL (dropped the former
|
|
1619
|
+
// ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` gate). A short terminal answer ("yes,
|
|
1620
|
+
// done") is a genuine answer this backstop is about to deliver, so a
|
|
1621
|
+
// late reply carrying the same short answer MUST supersede/suppress
|
|
1622
|
+
// rather than post a duplicate — the real-id supersede recorded below
|
|
1623
|
+
// corrects it in place.
|
|
1624
|
+
//
|
|
1625
|
+
// TWO distinct arbiters set synchronously here, before any `await`:
|
|
1626
|
+
// (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY signal the
|
|
1627
|
+
// reply path already reads (`decideAnswerLatchSuppression` +
|
|
1628
|
+
// `flushedTurnSupersede`), exactly as on `main`.
|
|
1629
|
+
// (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
|
|
1630
|
+
// double-fire latch: `claim` returning false means this turn
|
|
1631
|
+
// already fired a backstop (answer-ready quiescence, then the
|
|
1632
|
+
// turn-end backstop), so this fire is a no-op. It does NOT
|
|
1633
|
+
// arbitrate the late reply (that is (a)); it is redundant-but-
|
|
1634
|
+
// cheap with the `currentTurn == null` bail below.
|
|
1635
|
+
turn.answerDelivered = true
|
|
1636
|
+
const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
|
|
1637
|
+
|
|
1638
|
+
// #654 deterministic double-message fix. Hand off the pinned
|
|
1639
|
+
// progress card BEFORE state reset so the driver doesn't keep
|
|
1640
|
+
// editing it while turn-flush is rewriting it with the answer.
|
|
1641
|
+
// `wasEmitted` tells us whether the card has already been
|
|
1642
|
+
// published; `turnKey` lets us look up the pinned messageId
|
|
1643
|
+
// via pinMgr below. Idempotent — calling later in the IIFE
|
|
1644
|
+
// would be no-op against the same chatState.
|
|
1645
|
+
const cardTakeover = progressDriver?.takeOverCard({
|
|
1646
|
+
chatId: backstopChatId,
|
|
1647
|
+
threadId: backstopThreadId != null ? String(backstopThreadId) : undefined,
|
|
1648
|
+
}) ?? { wasEmitted: false, turnKey: null }
|
|
1649
|
+
const backstopCardMessageId =
|
|
1650
|
+
cardTakeover.wasEmitted && cardTakeover.turnKey != null
|
|
1651
|
+
? (getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null)
|
|
1652
|
+
: null
|
|
1653
|
+
const backstopCardTurnKey = cardTakeover.turnKey
|
|
1654
|
+
|
|
1655
|
+
// #1067: null the atom BEFORE the async IIFE starts. Any event
|
|
1656
|
+
// that arrives during the 500ms-suppression window or the
|
|
1657
|
+
// sendMessage await for this turn will see currentTurn == null
|
|
1658
|
+
// and bail; a new enqueue will swap in a fresh atom. The
|
|
1659
|
+
// `backstop*` locals above hold everything the IIFE needs.
|
|
1660
|
+
//
|
|
1661
|
+
// PR B — defer the turns.jsonl record write to the send IIFE below so
|
|
1662
|
+
// the recorded `status` reflects the REAL send outcome, not the
|
|
1663
|
+
// speculative `finalAnswerDelivered=true` set just above. Everything
|
|
1664
|
+
// else in endCurrentTurnAtomic (atom null, gate release, obligation
|
|
1665
|
+
// bookkeeping, purge) still runs synchronously here for the #1067 /
|
|
1666
|
+
// #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
|
|
1667
|
+
// atom was already torn down elsewhere (no record to emit).
|
|
1668
|
+
const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
|
|
1669
|
+
// #549 fix — turn-flush takes ownership of the captured-text
|
|
1670
|
+
// backup; reset the preamble buffer (its content is already in
|
|
1671
|
+
// the captured `capturedText`, which turn-flush is about to send).
|
|
1672
|
+
preambleSuppressor.dropNow()
|
|
1673
|
+
// #1289 fix — drain silence-poke + signal-tracker state for this
|
|
1674
|
+
// turn. The three sibling turn_end exit branches (context-exhaust
|
|
1675
|
+
// at ~5098, silent-marker at ~5097-5098, default reply-called tail
|
|
1676
|
+
// at ~5348-5349) all call signalTracker.clear + silencePoke.endTurn.
|
|
1677
|
+
// The flush-backstop branch was retrofitted in #1067 to null
|
|
1678
|
+
// currentTurn early but never had this cleanup added — leaving the
|
|
1679
|
+
// silence-poke state in the Map, so 300s after the original turn
|
|
1680
|
+
// start the framework fallback fires and the user sees
|
|
1681
|
+
// "still working… (no update from agent in 5 min)" on a turn the
|
|
1682
|
+
// gateway already considers over.
|
|
1683
|
+
{
|
|
1684
|
+
const tKey = statusKey(chatId, threadId)
|
|
1685
|
+
signalTracker.clear(tKey)
|
|
1686
|
+
silencePoke.endTurn(tKey)
|
|
1687
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
void (async () => {
|
|
1691
|
+
await new Promise<void>(resolve => setTimeout(resolve, 500))
|
|
1692
|
+
if (HISTORY_ENABLED) {
|
|
1693
|
+
try {
|
|
1694
|
+
// S1 fix (fable red-team 2026-07-17) — the old predicate here,
|
|
1695
|
+
// `getRecentOutboundCount(chatId, 2) > 0`, counted ANY assistant
|
|
1696
|
+
// row in the WHOLE chat: a worker `progress_update`, a command
|
|
1697
|
+
// ack / restart notice, or a reply in a DIFFERENT forum topic all
|
|
1698
|
+
// suppressed the flush and (worse) CLOSED the obligation below —
|
|
1699
|
+
// silently dropping the user's real answer. The scoped predicate
|
|
1700
|
+
// (same thread, length ≥ min(answerLength, 200)) lives in
|
|
1701
|
+
// `turn-flush-suppression.ts`; `hasOutboundDeliveredSince` is the
|
|
1702
|
+
// durable oracle whose thread/length semantics history tests pin.
|
|
1703
|
+
const { hasOutboundDeliveredSince } = await import('../history.js')
|
|
1704
|
+
const { shouldSuppressTurnFlush } = await import('./turn-flush-suppression.js')
|
|
1705
|
+
const suppress = shouldSuppressTurnFlush(
|
|
1706
|
+
{ hasSubstantiveOutbound: hasOutboundDeliveredSince },
|
|
1707
|
+
{
|
|
1708
|
+
chatId: backstopChatId,
|
|
1709
|
+
threadId: backstopThreadId ?? null,
|
|
1710
|
+
answerLength: capturedText.length,
|
|
1711
|
+
nowMs: Date.now(),
|
|
1712
|
+
},
|
|
1713
|
+
)
|
|
1714
|
+
if (suppress) {
|
|
1715
|
+
process.stderr.write(`telegram gateway: turn-flush suppressed — a substantive same-thread outbound landed within 2s\n`)
|
|
1716
|
+
// Do NOT finalize the status reaction here. As of #1713
|
|
1717
|
+
// the reaction is only finalized by the `turn_end` IPC
|
|
1718
|
+
// handler — mid-turn delivery proofs (local history,
|
|
1719
|
+
// stream finalize callbacks, executeReply post-send) no
|
|
1720
|
+
// longer transition the emoji. This branch just returns.
|
|
1721
|
+
// #2094 cosmetic: the per-turn reaction tracking was ALREADY
|
|
1722
|
+
// purged synchronously by endCurrentTurnAtomic (before this
|
|
1723
|
+
// async IIFE ran). The old redundant purgeReactionTracking
|
|
1724
|
+
// here re-fired on an already-cleared key WITHOUT `endingTurn`,
|
|
1725
|
+
// emitting an inconsistent shadow trace. Removed.
|
|
1726
|
+
//
|
|
1727
|
+
// PR B — a substantive same-thread outbound just delivered this
|
|
1728
|
+
// turn's answer, so the flush was legitimately suppressed. Emit
|
|
1729
|
+
// the deferred record as 'suppressed'. Not a failure.
|
|
1730
|
+
if (backstopTurnEndedAt != null) {
|
|
1731
|
+
turn.deliveryOutcome = 'suppressed'
|
|
1732
|
+
// S1 fix — do NOT close the obligation here. When the recent
|
|
1733
|
+
// outbound is genuinely this turn's answer (a raced reply /
|
|
1734
|
+
// stream materialization), the reply path closes its own
|
|
1735
|
+
// obligation idempotently; when the suppression is a false
|
|
1736
|
+
// positive (a long same-thread non-answer inside the 2s
|
|
1737
|
+
// window — the residual the scoped predicate can't
|
|
1738
|
+
// discriminate), closing here made the drop PERMANENT.
|
|
1739
|
+
// Leaving it open lets the obligation sweep arbitrate: it
|
|
1740
|
+
// stands down silently if a substantive outbound answered
|
|
1741
|
+
// the user, and re-presents otherwise. `noteTurnEnded` arms
|
|
1742
|
+
// the liveness floor exactly as the send-failed path does.
|
|
1743
|
+
if (OBLIGATION_LEDGER_ENABLED) obligationLedger.noteTurnEnded(turn.turnId, Date.now())
|
|
1744
|
+
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
1745
|
+
}
|
|
1746
|
+
return
|
|
1747
|
+
}
|
|
1748
|
+
} catch {}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// #3276 guard 5 — double-fire guard. If this turn already claimed the
|
|
1752
|
+
// delivery latch (a prior backstop fire — e.g. answer-ready quiescence
|
|
1753
|
+
// followed by the turn-end backstop for the same turn), do NOT deliver
|
|
1754
|
+
// again. The first fire owns delivery; this fire is a cosmetic no-op.
|
|
1755
|
+
if (!backstopLatchClaimed) {
|
|
1756
|
+
process.stderr.write(
|
|
1757
|
+
`telegram gateway: turn-flush skipped — turn ${turn.turnId} already claimed the delivery latch\n`,
|
|
1758
|
+
)
|
|
1759
|
+
return
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
process.stderr.write(
|
|
1763
|
+
`telegram gateway: turn-flush firing — ${capturedText.length} chars without reply tool ` +
|
|
1764
|
+
`(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
|
|
1765
|
+
)
|
|
1766
|
+
// PR B (Fix 1) — send accounting declared OUTSIDE the try so the
|
|
1767
|
+
// single-record `finally` below reads it on EVERY in-process exit.
|
|
1768
|
+
// `delivered` is the RECEIPT-gated truth (>=1 fresh non-card id AND
|
|
1769
|
+
// all chunks landed), computed by the retry orchestrator — NOT a
|
|
1770
|
+
// blanket outer-catch flag, so a throw in the post-delivery
|
|
1771
|
+
// bookkeeping below (dedup / supersede record) can never demote a
|
|
1772
|
+
// genuinely delivered turn to `send_failed` (finding 4).
|
|
1773
|
+
let sentIds: number[] = []
|
|
1774
|
+
let chunkCount = 0
|
|
1775
|
+
let delivered = false
|
|
1776
|
+
try {
|
|
1777
|
+
// #3276 — the ONE delivery primitive. deliverAnswer routes through
|
|
1778
|
+
// `sendReplyChunks` (the same send core executeReply uses) and posts
|
|
1779
|
+
// a FRESH chat message; it NEVER edits the progress card, so a
|
|
1780
|
+
// "delivery" can never be a card mutation the marker-sweep GC's
|
|
1781
|
+
// ~60-90s later. It returns the REAL fresh chat message ids and
|
|
1782
|
+
// retries mid-chunk (bounded) before giving up — the per-chunk
|
|
1783
|
+
// ledger resumes at the first unsent chunk, never re-sending chunk 0
|
|
1784
|
+
// (guard 6).
|
|
1785
|
+
const delivery = await deliverAnswer({
|
|
1786
|
+
chatId: backstopChatId,
|
|
1787
|
+
threadId: backstopThreadId,
|
|
1788
|
+
text: capturedText,
|
|
1789
|
+
turnId: turn.turnId,
|
|
1790
|
+
cardMessageId: backstopCardMessageId,
|
|
1791
|
+
// S4 — anchor the flushed answer to the inbound it answers
|
|
1792
|
+
// (null for synthesized turns, which send bare as before).
|
|
1793
|
+
replyToMessageId: turn.sourceMessageId,
|
|
1794
|
+
})
|
|
1795
|
+
sentIds = delivery.sentIds
|
|
1796
|
+
chunkCount = delivery.chunkCount
|
|
1797
|
+
delivered = delivery.delivered
|
|
1798
|
+
|
|
1799
|
+
// #546 dedup: record what turn-flush just sent so a late-arriving
|
|
1800
|
+
// reply / stream_reply with the same content gets suppressed.
|
|
1801
|
+
outboundDedup.record(
|
|
1802
|
+
backstopChatId,
|
|
1803
|
+
backstopThreadId,
|
|
1804
|
+
capturedText,
|
|
1805
|
+
Date.now(),
|
|
1806
|
+
getCurrentTurn()?.registryKey ?? null,
|
|
1807
|
+
)
|
|
1808
|
+
// #3276 guard 3 — feed the REAL fresh chat ids into the supersede
|
|
1809
|
+
// record so a late `reply` for the same turn corrects them in place
|
|
1810
|
+
// (edit / delete+resend) instead of shipping a second bubble. These
|
|
1811
|
+
// are genuine chat message ids now, never a card-edit id.
|
|
1812
|
+
if (sentIds.length > 0) {
|
|
1813
|
+
flushedTurnSupersede.record(
|
|
1814
|
+
backstopChatId,
|
|
1815
|
+
backstopThreadId,
|
|
1816
|
+
{ turnId: turn.turnId, messageIds: sentIds, text: capturedText },
|
|
1817
|
+
Date.now(),
|
|
1818
|
+
)
|
|
1819
|
+
}
|
|
1820
|
+
// #3276 guard 4 — collapse the taken-over card to a NON-answer
|
|
1821
|
+
// state. The answer flowed ONLY through deliverAnswer (a fresh
|
|
1822
|
+
// bubble); here we just unpin/complete the card so no orphaned
|
|
1823
|
+
// ⚙️ Working… lingers. The card carries NO answer text, so a
|
|
1824
|
+
// card-collapse failure and a fresh-send failure can never leave
|
|
1825
|
+
// BOTH an answer-card AND an answer-bubble visible.
|
|
1826
|
+
if (!delivered) {
|
|
1827
|
+
// Retries exhausted with nothing durable delivered — finalize the
|
|
1828
|
+
// reaction as error and reset the latch so a genuine late reply is
|
|
1829
|
+
// NOT suppressed.
|
|
1830
|
+
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
1831
|
+
backstopDeliveryLedger.release(turn.turnId)
|
|
1832
|
+
turn.answerDelivered = false
|
|
1833
|
+
} else if (backstopCtrl) {
|
|
1834
|
+
backstopCtrl.finalize('done')
|
|
1835
|
+
}
|
|
1836
|
+
// Unpin the card either way (cosmetic). completeTurn cleans up
|
|
1837
|
+
// pinMgr's per-turn state and unpins; fall back to the legacy
|
|
1838
|
+
// unpinForChat sweep when we didn't take over a turn.
|
|
1839
|
+
if (backstopCardTurnKey != null) {
|
|
1840
|
+
completeProgressCardTurn?.({
|
|
1841
|
+
chatId: backstopChatId,
|
|
1842
|
+
threadId: backstopThreadId,
|
|
1843
|
+
turnKey: backstopCardTurnKey,
|
|
1844
|
+
})
|
|
1845
|
+
} else {
|
|
1846
|
+
unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
|
|
1847
|
+
}
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
// Only reachable via a throw in the post-delivery bookkeeping (the
|
|
1850
|
+
// delivery itself is retry-wrapped inside deliverAnswer and never
|
|
1851
|
+
// throws out). `delivered` already reflects the receipt-gated truth;
|
|
1852
|
+
// do NOT flip it here (finding 4). If nothing landed, reset the
|
|
1853
|
+
// latch so a genuine late reply is not suppressed.
|
|
1854
|
+
process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
|
|
1855
|
+
if (!delivered) {
|
|
1856
|
+
turn.answerDelivered = false
|
|
1857
|
+
backstopDeliveryLedger.release(turn.turnId)
|
|
1858
|
+
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
1859
|
+
}
|
|
1860
|
+
} finally {
|
|
1861
|
+
// #3276 guard 7 + finding 1 — honest record AND honest recovery.
|
|
1862
|
+
// Status is derived from the RECEIPT gate: `complete` IFF a fresh
|
|
1863
|
+
// non-card id landed for every chunk; otherwise `send_failed`.
|
|
1864
|
+
//
|
|
1865
|
+
// The delivery-obligation close was DEFERRED out of
|
|
1866
|
+
// endCurrentTurnAtomic (deferObligationClose) so it reflects the
|
|
1867
|
+
// REAL send outcome here, not the speculative fire-time flag:
|
|
1868
|
+
// delivered → close the obligation (answered).
|
|
1869
|
+
// NOT deliv. → leave it OPEN + noteTurnEnded, so the ~150s
|
|
1870
|
+
// liveness floor re-presents the answer instead of
|
|
1871
|
+
// the old silent `send_failed` drop.
|
|
1872
|
+
if (backstopTurnEndedAt != null) {
|
|
1873
|
+
finalizeBackstopSendGated(turn, {
|
|
1874
|
+
threw: !delivered,
|
|
1875
|
+
sentIds,
|
|
1876
|
+
chunkCount,
|
|
1877
|
+
cardMessageId: backstopCardMessageId,
|
|
1878
|
+
})
|
|
1879
|
+
if (OBLIGATION_LEDGER_ENABLED) {
|
|
1880
|
+
if (delivered) {
|
|
1881
|
+
obligationLedger.close(turn.turnId)
|
|
1882
|
+
} else {
|
|
1883
|
+
// Terminal fail — do NOT mark the obligation satisfied.
|
|
1884
|
+
turn.finalAnswerDelivered = false
|
|
1885
|
+
obligationLedger.noteTurnEnded(turn.turnId, Date.now())
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
1889
|
+
}
|
|
1890
|
+
// GC the IN-MEMORY ledger now; on a partial, deliverAnswer already
|
|
1891
|
+
// persisted the snapshot to the durable obligation (#3282) to resume.
|
|
1892
|
+
backstopDeliveryLedger.clear(turn.turnId)
|
|
1893
|
+
}
|
|
1894
|
+
// #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
|
|
1895
|
+
// was removed. endCurrentTurnAtomic already ran the canonical purge
|
|
1896
|
+
// (with the authoritative `endingTurn`) synchronously before this
|
|
1897
|
+
// async IIFE started, so re-purging here only re-fired on an
|
|
1898
|
+
// already-cleared key without `endingTurn` — an inconsistent shadow
|
|
1899
|
+
// trace. The #2094 finding-1 backstop covers any pre-purge throw.
|
|
1900
|
+
})()
|
|
1901
|
+
return
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// #1713: turn_end is THE terminal trigger. Finalize via the
|
|
1905
|
+
// single terminal path. Any prior intermediate states pending in
|
|
1906
|
+
// the debounce window are flushed by `finalize()` before the
|
|
1907
|
+
// terminal emoji emits.
|
|
1908
|
+
//
|
|
1909
|
+
// #2527 — role-aware terminal honesty: a USER turn that ends without
|
|
1910
|
+
// a delivered answer must NOT paint 👍 (the operator's "thumbs up so
|
|
1911
|
+
// it feels like you're done" report). It finalizes to the gentle
|
|
1912
|
+
// 'undelivered' terminal (😐) instead; the silent-end fallback below
|
|
1913
|
+
// carries the apology text. system/cron turns and NO_REPLY/HEARTBEAT_OK
|
|
1914
|
+
// turns (which return earlier) keep 👍 — their silence is legitimate.
|
|
1915
|
+
let terminalReason = decideTerminalReason({
|
|
1916
|
+
enabled: LIVENESS_TERMINAL_HONESTY,
|
|
1917
|
+
role: turn.role,
|
|
1918
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
1919
|
+
})
|
|
1920
|
+
// #2527 review note 1 — worker-hold carve-out: if the turn is STILL
|
|
1921
|
+
// legitimately working at turn_end (a background sub-agent the parent
|
|
1922
|
+
// dispatched is running on), don't prematurely paint 😐. Fall back to
|
|
1923
|
+
// 'done' so the existing deferred-done path holds ✍️ until the worker
|
|
1924
|
+
// completes (then 👍) — the worker-activity feed carries the progress.
|
|
1925
|
+
// Only a turn that genuinely ended undelivered AND is not still working
|
|
1926
|
+
// gets the honest 😐.
|
|
1927
|
+
if (terminalReason === 'undelivered' && isLegitimatelyWorking(statusKey(chatId, threadId))) {
|
|
1928
|
+
terminalReason = 'done'
|
|
1929
|
+
}
|
|
1930
|
+
if (terminalReason === 'undelivered') {
|
|
1931
|
+
process.stderr.write(
|
|
1932
|
+
`telegram gateway: WARN turn_no_reply — user turn ended with an ` +
|
|
1933
|
+
`ambient ack but no delivered answer; painting 😐 not 👍 ` +
|
|
1934
|
+
`chat=${chatId} thread=${threadId ?? '-'} turnId=${turn.turnId} (#2527)\n`,
|
|
1935
|
+
)
|
|
1936
|
+
}
|
|
1937
|
+
finalizeStatusReaction(chatId, threadId, terminalReason)
|
|
1938
|
+
{
|
|
1939
|
+
const sKey = streamKey(chatId, threadId)
|
|
1940
|
+
const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
1941
|
+
logStreamingEvent({
|
|
1942
|
+
kind: 'turn_end',
|
|
1943
|
+
chatId,
|
|
1944
|
+
durationMs: turnDurationMs,
|
|
1945
|
+
suppressClearedCount: suppressPtyPreview.has(sKey) ? 1 : 0,
|
|
1946
|
+
})
|
|
1947
|
+
// #203: compute trailing gap (last signal → turn_end) then emit.
|
|
1948
|
+
const tKey = statusKey(chatId, threadId)
|
|
1949
|
+
signalTracker.noteSignal(tKey, Date.now())
|
|
1950
|
+
logStreamingEvent({ kind: 'turn_signal_gap', chatId, longestGapMs: signalTracker.getLongestGap(tKey), turnDurationMs })
|
|
1951
|
+
// #1122 KPI: emit turn_ended with TTFO + outbound-gap metrics so
|
|
1952
|
+
// the dashboard can compute outbound silence p95 and TTFO p95
|
|
1953
|
+
// without per-event reconstruction.
|
|
1954
|
+
const outboundMetrics = signalTracker.getOutboundMetrics(tKey)
|
|
1955
|
+
emitRuntimeMetric({
|
|
1956
|
+
kind: 'turn_ended',
|
|
1957
|
+
chat_id: chatId,
|
|
1958
|
+
thread_id: threadId ?? null,
|
|
1959
|
+
duration_ms: turnDurationMs,
|
|
1960
|
+
ttfo_ms: outboundMetrics.ttfoMs,
|
|
1961
|
+
outbound_count: outboundMetrics.outboundCount,
|
|
1962
|
+
longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
|
|
1963
|
+
ended_via: outboundMetrics.outboundCount > 0 ? 'reply' : 'silent',
|
|
1964
|
+
})
|
|
1965
|
+
// #1122 PR4 / #1161 / #1664: deterministic undelivered-turn
|
|
1966
|
+
// handling (see the silent-marker path above for the rationale).
|
|
1967
|
+
// - first undelivered turn-end → recordSilentTurnEnd writes the
|
|
1968
|
+
// state file so the Stop hook (silent-end-interrupt-stop.mjs)
|
|
1969
|
+
// blocks the session-end and re-prompts the agent to deliver.
|
|
1970
|
+
// - the Stop-hook re-prompt is already spent and the agent is
|
|
1971
|
+
// STILL undelivered → recordSilentTurnEnd returns
|
|
1972
|
+
// exhausted:true; deliver a user-facing fallback so the turn
|
|
1973
|
+
// never just vanishes (the user otherwise only sees the card
|
|
1974
|
+
// disappear).
|
|
1975
|
+
//
|
|
1976
|
+
// #1664 — the trigger is "no final answer delivered", not "zero
|
|
1977
|
+
// outbound". `outboundCount === 0` is now just the special case
|
|
1978
|
+
// where nothing landed at all. The added case: the model sent an
|
|
1979
|
+
// interim ack via reply/stream_reply (outboundCount > 0,
|
|
1980
|
+
// replyCalled = true) but ended the turn with its real answer as
|
|
1981
|
+
// plain transcript text — rendered into an ephemeral answer-lane
|
|
1982
|
+
// draft and retracted at turn_end, never finalized. finalAnswer-
|
|
1983
|
+
// Delivered stays false there, so the re-prompt engages and the
|
|
1984
|
+
// model re-delivers the answer through the reply tool. NO_REPLY /
|
|
1985
|
+
// HEARTBEAT_OK silent-marker turns return earlier and never reach
|
|
1986
|
+
// this path. The turn-flush 'flush' branch also returns earlier
|
|
1987
|
+
// (and sets finalAnswerDelivered=true defensively).
|
|
1988
|
+
// #1667 — this is the reply-called tail; `turnEndDecision === 'reprompt'`
|
|
1989
|
+
// is exactly `turn.finalAnswerDelivered === false` here (silent-marker
|
|
1990
|
+
// and flush both returned earlier), delegated to the pure gate core.
|
|
1991
|
+
if (turnEndDecision === 'reprompt') {
|
|
1992
|
+
// Option A transcript-prose bridge (#3227). Before falling through to
|
|
1993
|
+
// the re-prompt / represent safety nets, check whether the Stop hook
|
|
1994
|
+
// already isolated this turn's real answer from the transcript and
|
|
1995
|
+
// persisted it in silent-end-pending.json (`pendingText`). That file
|
|
1996
|
+
// lands BEFORE this turn_end handler runs (the hook fires upstream of
|
|
1997
|
+
// the gateway's own state write — see silent-end-interrupt-stop.mjs).
|
|
1998
|
+
// If a substantive answer is waiting, deliver it directly via the
|
|
1999
|
+
// normal send path NOW, instead of leaning on the (unreliable)
|
|
2000
|
+
// Stop-hook re-prompt or waiting ~2-5 min for the obligation
|
|
2001
|
+
// represent. The delivery closes the obligation + records dedup, so
|
|
2002
|
+
// the represent and a late reply-tool retry are both suppressed —
|
|
2003
|
+
// captured-prose delivery and represent are mutually exclusive.
|
|
2004
|
+
const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
|
|
2005
|
+
? decideCapturedProseDelivery({
|
|
2006
|
+
turnKey: tKey,
|
|
2007
|
+
// Per-turn nonce (#3228 Finding 3) — the persisted record must
|
|
2008
|
+
// belong to THIS turn, not a stale carryover from a prior turn
|
|
2009
|
+
// on the same chat/thread (tKey is not per-turn unique).
|
|
2010
|
+
turnId: turn.turnId,
|
|
2011
|
+
minChars: CAPTURED_PROSE_MIN_CHARS,
|
|
2012
|
+
})
|
|
2013
|
+
: { deliver: false as const, reason: 'no-state' as const }
|
|
2014
|
+
if (proseDecision.deliver && proseDecision.text != null) {
|
|
2015
|
+
// Deliver the recovered answer directly. This runs async and owns
|
|
2016
|
+
// its own bookkeeping — on success it closes the obligation +
|
|
2017
|
+
// records dedup + clears the silent-end state; on send FAILURE it
|
|
2018
|
+
// arms the recovery net itself (see below) instead of leaving the
|
|
2019
|
+
// answer lost.
|
|
2020
|
+
//
|
|
2021
|
+
// We DELIBERATELY skip recordUndeliveredTurnEnd on the HAPPY path
|
|
2022
|
+
// here: re-arming the Stop-hook re-prompt for an answer that just
|
|
2023
|
+
// went out is exactly what this bridge exists to avoid.
|
|
2024
|
+
//
|
|
2025
|
+
// #3228 Finding 1 — the send-failure net can NOT rely on the shared
|
|
2026
|
+
// turn-end teardown "leaving the obligation open". That teardown
|
|
2027
|
+
// (endCurrentTurnAtomic → decideObligationTurnEnd) closes the
|
|
2028
|
+
// obligation whenever `replyCalled === true`, which is EXACTLY the
|
|
2029
|
+
// interim-ack case that reaches this branch. So a thrown send would
|
|
2030
|
+
// otherwise leave the answer permanently lost (obligation already
|
|
2031
|
+
// closed by teardown, recordUndeliveredTurnEnd skipped). The real
|
|
2032
|
+
// net lives INSIDE deliverCapturedProse's catch: it calls
|
|
2033
|
+
// recordUndeliveredTurnEnd, arming the deterministic Stop-hook
|
|
2034
|
+
// re-prompt (parity with the non-captured path below).
|
|
2035
|
+
process.stderr.write(
|
|
2036
|
+
`telegram gateway: captured-prose delivery engaged on first silent-end ` +
|
|
2037
|
+
`chat=${chatId} turnKey=${tKey} (#3227)\n`,
|
|
2038
|
+
)
|
|
2039
|
+
void deliverCapturedProse({
|
|
2040
|
+
chatId,
|
|
2041
|
+
threadId,
|
|
2042
|
+
statusKeyStr: tKey,
|
|
2043
|
+
registryKey: turn.registryKey ?? null,
|
|
2044
|
+
originTurnId: turn.turnId,
|
|
2045
|
+
text: proseDecision.text,
|
|
2046
|
+
// For the honest "(waited Ns)" clause if the exhaustion-boundary
|
|
2047
|
+
// apology fallback fires (#3228).
|
|
2048
|
+
turnDurationMs,
|
|
2049
|
+
})
|
|
2050
|
+
} else {
|
|
2051
|
+
// PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
|
|
2052
|
+
// wire the represent-guard-style staleness
|
|
2053
|
+
// check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
|
|
2054
|
+
// an exhausted-looking record left over from a PRIOR, already-
|
|
2055
|
+
// answered turn on this same chat/thread (statusKey is not a
|
|
2056
|
+
// per-turn nonce) can never be misread as this turn's spent
|
|
2057
|
+
// re-prompt budget. Falls back to the pre-existing turnKey/
|
|
2058
|
+
// retryCount-only check when history is unavailable.
|
|
2059
|
+
const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
|
|
2060
|
+
? {
|
|
2061
|
+
hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
|
|
2062
|
+
hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
|
|
2063
|
+
}
|
|
2064
|
+
: undefined
|
|
2065
|
+
const silentEnd = recordUndeliveredTurnEnd(
|
|
2066
|
+
{
|
|
2067
|
+
chatId,
|
|
2068
|
+
threadId: threadId ?? null,
|
|
2069
|
+
turnKey: tKey,
|
|
2070
|
+
},
|
|
2071
|
+
silentEndDeps,
|
|
2072
|
+
)
|
|
2073
|
+
if (silentEnd.exhausted) {
|
|
2074
|
+
process.stderr.write(
|
|
2075
|
+
`telegram gateway: WARN silent-end fallback — agent stayed ` +
|
|
2076
|
+
`silent after the Stop-hook re-prompt; delivering fallback ` +
|
|
2077
|
+
`message chat=${chatId} turnKey=${tKey} (#1161)\n`,
|
|
2078
|
+
)
|
|
2079
|
+
void retryWithThreadFallback(
|
|
2080
|
+
robustApiCall,
|
|
2081
|
+
(tid) =>
|
|
2082
|
+
bot.api.sendMessage(
|
|
2083
|
+
chatId,
|
|
2084
|
+
silentEndFallbackText(turnDurationMs),
|
|
2085
|
+
tid != null ? { message_thread_id: tid } : {},
|
|
2086
|
+
),
|
|
2087
|
+
{ threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
|
|
2088
|
+
).catch((err) => {
|
|
2089
|
+
process.stderr.write(
|
|
2090
|
+
`telegram gateway: silent-end fallback send failed: ${
|
|
2091
|
+
err instanceof Error ? err.message : String(err)
|
|
2092
|
+
}\n`,
|
|
2093
|
+
)
|
|
2094
|
+
})
|
|
2095
|
+
}
|
|
2096
|
+
} // end else (no captured-prose to deliver)
|
|
2097
|
+
}
|
|
2098
|
+
signalTracker.clear(tKey)
|
|
2099
|
+
silencePoke.endTurn(tKey)
|
|
2100
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
2101
|
+
}
|
|
2102
|
+
lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
|
|
2103
|
+
setPendingPtyPartial(null)
|
|
2104
|
+
closeActivityLane(chatId, threadId)
|
|
2105
|
+
closeProgressLane(chatId, threadId)
|
|
2106
|
+
// Pre-allocated draft orphan-cleanup removed in #553 PR 5 — the
|
|
2107
|
+
// gateway no longer pre-allocates drafts on inbound, so there
|
|
2108
|
+
// is nothing to clean up at turn_end.
|
|
2109
|
+
// Stage 3b: stamp turn-end in the registry as endedVia='stop' (clean
|
|
2110
|
+
// turn_end emit). The kill paths (schedule_restart / SIGTERM) handle
|
|
2111
|
+
// the 'restart' / 'sigterm' cases separately in 3c.
|
|
2112
|
+
if (turnsDb != null && turn.registryKey != null) {
|
|
2113
|
+
// Phase 1 of #332: capture first ~200 chars of the assistant's reply.
|
|
2114
|
+
const capturedJoined = turn.capturedText.join('')
|
|
2115
|
+
const assistantReplyPreview = capturedJoined
|
|
2116
|
+
? capturedJoined.slice(0, TURN_PREVIEW_MAX)
|
|
2117
|
+
: null
|
|
2118
|
+
// Closes #472 finding #11 — same SIGTERM race as recordTurnStart
|
|
2119
|
+
// above. Sub-ms SQLite write; the setImmediate deferral wasn't
|
|
2120
|
+
// saving anything observable but was opening a window where a
|
|
2121
|
+
// SIGTERM between turn_end and the microtask losing the end row
|
|
2122
|
+
// (turn appears in DB as still-running, then 3c relabels it as
|
|
2123
|
+
// 'sigterm' on shutdown — false negative for clean completion).
|
|
2124
|
+
const _turnKey = turn.registryKey
|
|
2125
|
+
try {
|
|
2126
|
+
recordTurnEnd(turnsDb, {
|
|
2127
|
+
turnKey: _turnKey,
|
|
2128
|
+
endedVia: 'stop' as const,
|
|
2129
|
+
lastAssistantMsgId: turn.lastAssistantMsgId,
|
|
2130
|
+
lastAssistantDone: turn.lastAssistantDone,
|
|
2131
|
+
assistantReplyPreview,
|
|
2132
|
+
toolCallCount: turn.toolCallCount,
|
|
2133
|
+
})
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
process.stderr.write(`telegram gateway: recordTurnEnd(stop) failed turnKey=${_turnKey}: ${(err as Error).message}\n`)
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
// #550: symmetric cleanup with the writeTurnActiveMarker call at
|
|
2139
|
+
// the enqueue arm (line ~2810). Pre-fix, removal was single-pathed
|
|
2140
|
+
// through the (now-retired, #1122/#1126) progressDriver.onTurnComplete
|
|
2141
|
+
// callback, which silently no-op'd when forceCompleteTurn found no
|
|
2142
|
+
// active card — leaking the marker across restarts and triggering
|
|
2143
|
+
// watchdog false-positive restarts. That driver callback no longer
|
|
2144
|
+
// exists (progressDriver is permanently null); this explicit
|
|
2145
|
+
// removeTurnActiveMarker is now the sole cleanup path (idempotent —
|
|
2146
|
+
// unlinkSync swallows ENOENT).
|
|
2147
|
+
removeTurnActiveMarker(STATE_DIR)
|
|
2148
|
+
// #1067: null the atom in one assignment, replacing the seven
|
|
2149
|
+
// field clears the pre-refactor version did. Any late-arriving
|
|
2150
|
+
// event for this turn will see currentTurn == null and bail.
|
|
2151
|
+
endCurrentTurnAtomic(turn)
|
|
2152
|
+
// #549 fix — preamble flush already happened at the TOP of this
|
|
2153
|
+
// turn_end handler (before turn.answerStream is nulled). See
|
|
2154
|
+
// comment near line 3431.
|
|
2155
|
+
return
|
|
2156
|
+
}, // end withTurnEndGateBackstop body (#2094 finding 1)
|
|
2157
|
+
{
|
|
2158
|
+
hasActiveTurn: (k) => activeTurnStartedAt.has(k),
|
|
2159
|
+
purge: (k, endingTurn) => purgeReactionTracking(k, endingTurn),
|
|
2160
|
+
log: (m) => process.stderr.write(m + '\n'),
|
|
2161
|
+
},
|
|
2162
|
+
)
|
|
2163
|
+
return
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|