switchroom 0.16.15 → 0.16.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +38 -7
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +37 -3
- package/telegram-plugin/dist/gateway/gateway.js +255 -71
- package/telegram-plugin/gateway/feed-open-gate.ts +54 -1
- package/telegram-plugin/gateway/gateway.ts +176 -86
- package/telegram-plugin/gateway/turn-typing-loop.ts +102 -0
- package/telegram-plugin/tests/answer-stream.test.ts +118 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +21 -6
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +46 -40
- package/telegram-plugin/tests/feed-open-gate.test.ts +75 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +20 -0
- package/telegram-plugin/tests/turn-typing-loop.test.ts +124 -0
|
@@ -80,10 +80,63 @@
|
|
|
80
80
|
* already received.
|
|
81
81
|
*/
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Inputs for the liveness early-open WHEN-decision (`shouldEarlyOpenLiveness`).
|
|
85
|
+
* Separate from the OPEN-gate levers above: those answer *may* a card open at
|
|
86
|
+
* all (reply-is-last / cross-turn); this answers *is it time yet* for the
|
|
87
|
+
* minimal "Working…" placeholder on a 0-label turn. Both must say yes — the
|
|
88
|
+
* caller routes the actual open through `mayOpenActivityCard` after this clears.
|
|
89
|
+
*/
|
|
90
|
+
export interface EarlyLivenessOpenInput {
|
|
91
|
+
/** Feature flag (`SWITCHROOM_FEED_LIVENESS_OPEN`). Off ⇒ never early-open. */
|
|
92
|
+
enabled: boolean
|
|
93
|
+
/** Turn age in ms (`now - turn.startedAt`). Must be ≥ `thresholdMs`. */
|
|
94
|
+
ageMs: number
|
|
95
|
+
/** The early-open threshold (`FEED_LIVENESS_OPEN_MS`). */
|
|
96
|
+
thresholdMs: number
|
|
97
|
+
/** Count of surfaced tool steps this turn (`turn.mirrorLines.length`). >0 ⇒ a
|
|
98
|
+
* real label already drives the labelled-feed heartbeat; the placeholder must
|
|
99
|
+
* not fight it, so it never opens (unless `forceNarrative` — see below). */
|
|
100
|
+
mirrorLineCount: number
|
|
101
|
+
/** Single in-place card transport id. Non-null ⇒ a card is already OPEN, so
|
|
102
|
+
* this is a maintain/no-op, not a fresh OPEN. */
|
|
103
|
+
activityMessageId: number | null
|
|
104
|
+
/** The session chat id. `null` ⇒ no surface to open on. */
|
|
105
|
+
sessionChatId: string | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pure: is the minimal "Working…" liveness placeholder due to OPEN for a 0-label
|
|
110
|
+
* turn? True iff the feature is on, the turn has a target chat, it has been alive
|
|
111
|
+
* past the threshold, and NO card is already open. Once `mirrorLineCount > 0` a
|
|
112
|
+
* real tool label drives the feed, EXCEPT the edge where narration staged
|
|
113
|
+
* `mirrorLines` but no card opened yet (`activityMessageId == null`) — there the
|
|
114
|
+
* accumulated narration should still render on the early open, so it is allowed.
|
|
115
|
+
*
|
|
116
|
+
* This is the WHEN gate. The caller still routes the OPEN through
|
|
117
|
+
* `mayOpenActivityCard` (lever 1/4) so a card never opens below a delivered
|
|
118
|
+
* answer or on a cross-turn synthetic surface. Two callers consult this — the
|
|
119
|
+
* enqueue-time early-open timer and the 6 s heartbeat — and because an already-
|
|
120
|
+
* open card returns false here (and the drain EDITs rather than re-OPENs), they
|
|
121
|
+
* can never double-open.
|
|
122
|
+
*/
|
|
123
|
+
export function shouldEarlyOpenLiveness(input: EarlyLivenessOpenInput): boolean {
|
|
124
|
+
if (!input.enabled) return false
|
|
125
|
+
if (input.sessionChatId == null) return false
|
|
126
|
+
// A card is already open → maintain via the drain's EDIT path, not a fresh
|
|
127
|
+
// OPEN. Never double-open. (Reaching past here implies `activityMessageId ==
|
|
128
|
+
// null`, so a non-zero `mirrorLineCount` is the "narration staged but no card
|
|
129
|
+
// opened yet" edge — allowed below so the accumulated narration renders.)
|
|
130
|
+
if (input.activityMessageId != null) return false
|
|
131
|
+
if (input.ageMs < input.thresholdMs) return false
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
83
135
|
/** Which producer triggered this drain — determines lever-5 OPEN eligibility. */
|
|
84
136
|
export type FeedOpenProducer =
|
|
85
137
|
/** Narrative SHOW (producer A): plain assistant text, no tool, no time
|
|
86
|
-
* threshold.
|
|
138
|
+
* threshold. Pre-answer it is OPEN-eligible (lever 5 INERT); after a
|
|
139
|
+
* substantive final answer it is blocked by lever 1. */
|
|
87
140
|
| 'narrative'
|
|
88
141
|
/** Tool label (producer B): the model dispatched a tool. OPEN-eligible unless a
|
|
89
142
|
* substantive final already landed (lever 1).
|
|
@@ -79,6 +79,7 @@ import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type
|
|
|
79
79
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
80
80
|
import { toolLabel } from '../tool-labels.js'
|
|
81
81
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
82
|
+
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
82
83
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
83
84
|
import { handlePtyPartialPure, type PtyHandlerState } from '../pty-partial-handler.js'
|
|
84
85
|
import { handleStreamReply } from '../stream-reply-handler.js'
|
|
@@ -189,6 +190,7 @@ const SILENT_END_FALLBACK_TEXT =
|
|
|
189
190
|
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
190
191
|
'message may not have been answered. Please try asking again.'
|
|
191
192
|
import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
|
|
193
|
+
import { sanitizeTelegramHtml } from '../html-sanitize.js'
|
|
192
194
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
193
195
|
import {
|
|
194
196
|
validateInlineKeyboard,
|
|
@@ -343,6 +345,7 @@ import { decideFeedReopen } from './feed-reopen-gate.js'
|
|
|
343
345
|
import {
|
|
344
346
|
mayOpenActivityCard,
|
|
345
347
|
computeCrossTurnAnswerDelivered,
|
|
348
|
+
shouldEarlyOpenLiveness,
|
|
346
349
|
type FeedOpenProducer,
|
|
347
350
|
type FeedOpenGateDeps,
|
|
348
351
|
} from './feed-open-gate.js'
|
|
@@ -1767,14 +1770,25 @@ const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
|
|
|
1767
1770
|
// the 300s silence-poke (the #680 dark-turn). When a turn has been alive >=
|
|
1768
1771
|
// FEED_LIVENESS_OPEN_MS with no feed yet, open a minimal "Working…" feed so the
|
|
1769
1772
|
// user always has a live indicator; the first real tool label edits it with
|
|
1770
|
-
// real content.
|
|
1771
|
-
//
|
|
1773
|
+
// real content.
|
|
1774
|
+
//
|
|
1775
|
+
// Two callers reach the SAME open path (`openLivenessFeedIfDue`):
|
|
1776
|
+
// 1. an enqueue-time one-shot timer (`scheduleEarlyLivenessOpen`) fires at
|
|
1777
|
+
// `FEED_LIVENESS_OPEN_MS` after turn start — this is the early-open that
|
|
1778
|
+
// kills the dead-air gap (narration emitted before the first tool now
|
|
1779
|
+
// surfaces ~`FEED_LIVENESS_OPEN_MS` after enqueue, not after a 6 s
|
|
1780
|
+
// heartbeat phase + the old 12 s threshold);
|
|
1781
|
+
// 2. the 6 s heartbeat tick, which maintains/climbs the card and is the
|
|
1782
|
+
// backstop if the one-shot ever missed (e.g. a clock skew).
|
|
1783
|
+
// The threshold dropped from 12 s → ~1.2 s so the "something is happening"
|
|
1784
|
+
// signal lands within a second or two of the inbound, matching the JTBD
|
|
1785
|
+
// `know-what-my-agent-is-doing` p95 ≤ ~1 s ambient-ack bar. Kill switch:
|
|
1772
1786
|
// SWITCHROOM_FEED_LIVENESS_OPEN=0. Default on.
|
|
1773
1787
|
const FEED_LIVENESS_OPEN_ENABLED = process.env.SWITCHROOM_FEED_LIVENESS_OPEN !== '0'
|
|
1774
1788
|
const FEED_LIVENESS_OPEN_MS = (() => {
|
|
1775
1789
|
const raw = process.env.SWITCHROOM_FEED_LIVENESS_OPEN_MS
|
|
1776
1790
|
const n = raw ? Number(raw) : NaN
|
|
1777
|
-
return Number.isFinite(n) && n > 0 ? n :
|
|
1791
|
+
return Number.isFinite(n) && n > 0 ? n : 1_200
|
|
1778
1792
|
})()
|
|
1779
1793
|
|
|
1780
1794
|
// Post-answer background-agent liveness STALENESS CAP (Fix 2 / #2587 supersede,
|
|
@@ -3146,6 +3160,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3146
3160
|
const threadId = threadPart === '_' || threadPart === '' ? null : Number(threadPart)
|
|
3147
3161
|
stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null)
|
|
3148
3162
|
}
|
|
3163
|
+
// Cancel the enqueue-time early-open timer (paired with
|
|
3164
|
+
// `scheduleEarlyLivenessOpen` at turn start). `key` is the same status-key the
|
|
3165
|
+
// timer was registered under, so this is the single owner of the cancel. A
|
|
3166
|
+
// leaked timer would otherwise fire its `openLivenessFeedIfDue` against a
|
|
3167
|
+
// successor turn; the timer's own turnId match is the second guard, this is
|
|
3168
|
+
// the first. Idempotent — a no-op when no timer is registered.
|
|
3169
|
+
stopEarlyLivenessOpen(key as string)
|
|
3149
3170
|
if (msgInfo) {
|
|
3150
3171
|
const agentDir = resolveAgentDirFromEnv()
|
|
3151
3172
|
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
@@ -3603,7 +3624,6 @@ function maybeIdleClear(): void {
|
|
|
3603
3624
|
// could arrive between the gate check and the tmux send; /clear then lands in
|
|
3604
3625
|
// claude's prompt buffer and runs at the next idle prompt (inject.ts FUTURE-GAP).
|
|
3605
3626
|
void injectSlashCommandImpl(agentName, '/clear')
|
|
3606
|
-
.then(() => { void postIdleClearNotice(idleClearMs); })
|
|
3607
3627
|
.catch((err: unknown) => {
|
|
3608
3628
|
process.stderr.write(
|
|
3609
3629
|
`telegram gateway: idle /clear inject failed for ` +
|
|
@@ -3613,36 +3633,6 @@ function maybeIdleClear(): void {
|
|
|
3613
3633
|
.finally(() => { idleClearDispatching = false; });
|
|
3614
3634
|
}
|
|
3615
3635
|
|
|
3616
|
-
/** Subtle one-line notice so the operator knows the session was auto-cleared. */
|
|
3617
|
-
async function postIdleClearNotice(idleClearMs: number): Promise<void> {
|
|
3618
|
-
try {
|
|
3619
|
-
const chatId = loadAccess().allowFrom[0];
|
|
3620
|
-
if (!chatId) return;
|
|
3621
|
-
const threadId = topicForRecipient({
|
|
3622
|
-
recipientChatId: chatId,
|
|
3623
|
-
resolvedTopic:
|
|
3624
|
-
resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
|
|
3625
|
-
?? chatThreadMap.get(chatId),
|
|
3626
|
-
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
3627
|
-
});
|
|
3628
|
-
const hrs = Math.round((idleClearMs / 3_600_000) * 10) / 10;
|
|
3629
|
-
const text =
|
|
3630
|
-
`🧹 <b>Cleared after ${hrs}h idle</b> — fresh slate next message; ` +
|
|
3631
|
-
`long-term memory is in Hindsight.`;
|
|
3632
|
-
await swallowingApiCall(
|
|
3633
|
-
() =>
|
|
3634
|
-
bot.api.sendMessage(chatId, text, {
|
|
3635
|
-
parse_mode: 'HTML',
|
|
3636
|
-
disable_notification: true,
|
|
3637
|
-
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
3638
|
-
}),
|
|
3639
|
-
{ chat_id: chatId, verb: 'idleAutoClear.notice' },
|
|
3640
|
-
);
|
|
3641
|
-
} catch {
|
|
3642
|
-
/* best-effort notice — the /clear itself still happened */
|
|
3643
|
-
}
|
|
3644
|
-
}
|
|
3645
|
-
|
|
3646
3636
|
/**
|
|
3647
3637
|
* Post the START card for a proactive compaction. Best-effort: a failed
|
|
3648
3638
|
* send just means no card (the compaction itself still happens). The
|
|
@@ -4131,33 +4121,31 @@ function stopTypingLoop(chat_id: string, thread_id: number | null = null): void
|
|
|
4131
4121
|
if (retry) { clearTimeout(retry); typingRetryTimers.delete(key) }
|
|
4132
4122
|
}
|
|
4133
4123
|
|
|
4134
|
-
// Turn-level `typing…` indicator.
|
|
4135
|
-
//
|
|
4136
|
-
//
|
|
4137
|
-
//
|
|
4138
|
-
//
|
|
4139
|
-
//
|
|
4140
|
-
//
|
|
4141
|
-
//
|
|
4142
|
-
//
|
|
4143
|
-
//
|
|
4144
|
-
|
|
4124
|
+
// Turn-level `typing…` indicator. The lifecycle (immediate fire, 4 s refresh,
|
|
4125
|
+
// stop-at-turn-end, no leak) lives in the testable `turn-typing-loop.ts`
|
|
4126
|
+
// factory; the gateway just injects the real `sendChatAction` + `chatKey`.
|
|
4127
|
+
// Deliberately a SEPARATE interval map from `typingIntervals` (the reply handler
|
|
4128
|
+
// + tool-use typing wrapper share that one and freely stop it). If the turn loop
|
|
4129
|
+
// lived in the shared map, a mid-turn reply's `finally { stopTypingLoop }` would
|
|
4130
|
+
// kill it and the chat would go dark for the rest of the turn — the exact
|
|
4131
|
+
// black-box gap this closes. The dedicated map (private to the factory) makes
|
|
4132
|
+
// the turn loop structurally immune to those stops: only the canonical turn-end
|
|
4133
|
+
// stop clears it. The redundant `typing` pings while a reply is mid-flight are
|
|
4134
|
+
// harmless — same action, and sendChatAction is cheap.
|
|
4135
|
+
const turnTypingLoop = createTurnTypingLoop({
|
|
4136
|
+
sendChatAction: (chat_id, thread_id) => {
|
|
4137
|
+
const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
|
|
4138
|
+
void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
|
|
4139
|
+
},
|
|
4140
|
+
chatKey: (chat_id, thread_id) => chatKey(chat_id, thread_id) as string,
|
|
4141
|
+
})
|
|
4145
4142
|
|
|
4146
4143
|
function startTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
4147
|
-
|
|
4148
|
-
const key = chatKey(chat_id, thread_id) as string
|
|
4149
|
-
const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
|
|
4150
|
-
const send = () => {
|
|
4151
|
-
void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
|
|
4152
|
-
}
|
|
4153
|
-
send()
|
|
4154
|
-
turnTypingIntervals.set(key, setInterval(send, 4000))
|
|
4144
|
+
turnTypingLoop.start(chat_id, thread_id)
|
|
4155
4145
|
}
|
|
4156
4146
|
|
|
4157
4147
|
function stopTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
4158
|
-
|
|
4159
|
-
const iv = turnTypingIntervals.get(key)
|
|
4160
|
-
if (iv) { clearInterval(iv); turnTypingIntervals.delete(key) }
|
|
4148
|
+
turnTypingLoop.stop(chat_id, thread_id)
|
|
4161
4149
|
}
|
|
4162
4150
|
|
|
4163
4151
|
const typingWrapper = createTypingWrapper({
|
|
@@ -10782,11 +10770,12 @@ function showNarrativeStep(turn: CurrentTurn, text: string): void {
|
|
|
10782
10770
|
// card-drain gate (chatLock-serialized under the flag; verbatim block OFF).
|
|
10783
10771
|
cardDrainGate(turn, ea, () => {
|
|
10784
10772
|
if (ea.mayDrain(turn)) {
|
|
10785
|
-
// Producer A (narrative SHOW): may
|
|
10786
|
-
//
|
|
10787
|
-
//
|
|
10788
|
-
// into mirrorLines still happens so
|
|
10789
|
-
//
|
|
10773
|
+
// Producer A (narrative SHOW): pre-answer narrative may now OPEN a card,
|
|
10774
|
+
// not just EDIT one — lever 5 is INERT (see feed-open-gate.ts), and
|
|
10775
|
+
// Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead.
|
|
10776
|
+
// Accumulation into mirrorLines still happens, so any narration staged
|
|
10777
|
+
// before the card opened renders on the first OPEN (whichever producer
|
|
10778
|
+
// wins the race — narrative here, or the enqueue/liveness timer).
|
|
10790
10779
|
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
10791
10780
|
ea.openOrEditCard('narrative', () => {
|
|
10792
10781
|
turn.activityInFlight = drainActivitySummary(turn, 'narrative')
|
|
@@ -10986,6 +10975,105 @@ async function drainActivitySummary(
|
|
|
10986
10975
|
}
|
|
10987
10976
|
}
|
|
10988
10977
|
|
|
10978
|
+
/**
|
|
10979
|
+
* Open (or climb) the minimal "Working…" liveness card for a 0-label turn once
|
|
10980
|
+
* it has been alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card
|
|
10981
|
+
* may OPEN — both the enqueue-time early-open timer
|
|
10982
|
+
* (`scheduleEarlyLivenessOpen`) and the 6 s heartbeat call through here, so a
|
|
10983
|
+
* card opened by one caller is a clean no-op for the other:
|
|
10984
|
+
* - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
|
|
10985
|
+
* once it is set, so a second call after an open just maintains the card;
|
|
10986
|
+
* - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
|
|
10987
|
+
* drain's own gate) means once a real tool label lands this path is skipped
|
|
10988
|
+
* and the labelled-feed heartbeat takes over;
|
|
10989
|
+
* - the OPEN itself is still gated by `mayOpenActivityCard` (lever 1 / 4) via
|
|
10990
|
+
* `ea.openOrEditCard('liveness', …)`, so a card never opens below a
|
|
10991
|
+
* delivered answer or on a cross-turn synthetic surface.
|
|
10992
|
+
*
|
|
10993
|
+
* Renders the turn's accumulated narration when present (the §3 case: narration
|
|
10994
|
+
* staged before the first tool via `mirrorLines`) so the early open is not a
|
|
10995
|
+
* bare placeholder when there is real text to show; falls back to "Working…"
|
|
10996
|
+
* for a genuinely silent thinking turn.
|
|
10997
|
+
*/
|
|
10998
|
+
function openLivenessFeedIfDue(turn: CurrentTurn): void {
|
|
10999
|
+
const age = Date.now() - turn.startedAt
|
|
11000
|
+
// The WHEN decision (pure, `feed-open-gate.ts`): feature on, target chat,
|
|
11001
|
+
// past threshold, no card already open. Returns false once a card is open
|
|
11002
|
+
// (the drain EDITs instead) so the two callers can never double-open.
|
|
11003
|
+
if (!shouldEarlyOpenLiveness({
|
|
11004
|
+
enabled: FEED_LIVENESS_OPEN_ENABLED,
|
|
11005
|
+
ageMs: age,
|
|
11006
|
+
thresholdMs: FEED_LIVENESS_OPEN_MS,
|
|
11007
|
+
mirrorLineCount: turn.mirrorLines.length,
|
|
11008
|
+
activityMessageId: turn.activityMessageId,
|
|
11009
|
+
sessionChatId: turn.sessionChatId,
|
|
11010
|
+
})) return
|
|
11011
|
+
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
|
|
11012
|
+
const livenessHeader: SessionActivityHeader = {
|
|
11013
|
+
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
11014
|
+
}
|
|
11015
|
+
const rendered = renderActivityFeedWithNested(lines, [], false, ` · ${formatFeedElapsed(age)}`, undefined, livenessHeader)
|
|
11016
|
+
if (rendered == null) return
|
|
11017
|
+
turn.activityPendingRender = rendered
|
|
11018
|
+
const ea = emissionAuthorityFor(turn)
|
|
11019
|
+
// PR-4d: route through the centralized chatLock-serialized card-drain gate.
|
|
11020
|
+
cardDrainGate(turn, ea, () => {
|
|
11021
|
+
if (ea.mayDrain(turn)) {
|
|
11022
|
+
// Producer C (liveness timer): the thinking-gap / early-open. Now that
|
|
11023
|
+
// Lever 5 is inert (narrative may open pre-answer — #2588), liveness
|
|
11024
|
+
// remains the natural open for 0-tool pre-answer turns that are silent.
|
|
11025
|
+
// The sticky-latch (lever 1) still gates it in the drain.
|
|
11026
|
+
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
11027
|
+
ea.openOrEditCard('liveness', () => {
|
|
11028
|
+
turn.activityInFlight = drainActivitySummary(turn, 'liveness')
|
|
11029
|
+
})
|
|
11030
|
+
}
|
|
11031
|
+
})
|
|
11032
|
+
}
|
|
11033
|
+
|
|
11034
|
+
// Enqueue-time early-open timers, keyed by status-key. One per in-flight turn;
|
|
11035
|
+
// cleared at turn-end (`stopEarlyLivenessOpen`) so a leaked timer can never fire
|
|
11036
|
+
// against a successor turn. `unref()` so it never holds the process alive.
|
|
11037
|
+
const earlyLivenessOpenTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
11038
|
+
|
|
11039
|
+
/**
|
|
11040
|
+
* Schedule the enqueue-time early-open of the "Working…" liveness card. Called
|
|
11041
|
+
* once per fresh turn at the `enqueue` lifecycle event (the single chokepoint
|
|
11042
|
+
* every real turn atom passes through — inbound, cron, subagent-handback,
|
|
11043
|
+
* vault-resume, restart-marker; anonymous one-shot hook clients never emit
|
|
11044
|
+
* `enqueue`, so they are excluded by construction). Fires `openLivenessFeedIfDue`
|
|
11045
|
+
* once at `FEED_LIVENESS_OPEN_MS` after turn start so narration / thinking that
|
|
11046
|
+
* happens BEFORE the first tool surfaces a card within ~a second — no more dead
|
|
11047
|
+
* air until a tool label or the old 12 s threshold. A no-op if a tool/narrative
|
|
11048
|
+
* already opened the card (the helper's own guards). The 6 s heartbeat remains
|
|
11049
|
+
* the backstop + the climb.
|
|
11050
|
+
*/
|
|
11051
|
+
function scheduleEarlyLivenessOpen(turn: CurrentTurn): void {
|
|
11052
|
+
if (STATIC || !FEED_HEARTBEAT_ENABLED || !FEED_LIVENESS_OPEN_ENABLED) return
|
|
11053
|
+
if (turn.sessionChatId == null) return
|
|
11054
|
+
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
11055
|
+
stopEarlyLivenessOpen(key)
|
|
11056
|
+
const t = setTimeout(() => {
|
|
11057
|
+
earlyLivenessOpenTimers.delete(key)
|
|
11058
|
+
// Re-resolve the live turn for this key: only open if THIS turn is still the
|
|
11059
|
+
// live one for its topic (a successor turn would carry its own timer). Under
|
|
11060
|
+
// flag OFF `get(key)` returns the singleton — same turn unless a successor
|
|
11061
|
+
// already replaced it, which the turnId match below also guards.
|
|
11062
|
+
const live = currentTurnMap.get(key)
|
|
11063
|
+
if (live == null || live.turnId !== turn.turnId) return
|
|
11064
|
+
openLivenessFeedIfDue(live)
|
|
11065
|
+
}, FEED_LIVENESS_OPEN_MS)
|
|
11066
|
+
t.unref?.()
|
|
11067
|
+
earlyLivenessOpenTimers.set(key, t)
|
|
11068
|
+
}
|
|
11069
|
+
|
|
11070
|
+
/** Cancel the enqueue-time early-open timer for a status-key (turn-end teardown
|
|
11071
|
+
* + re-arm guard). Idempotent. */
|
|
11072
|
+
function stopEarlyLivenessOpen(key: string): void {
|
|
11073
|
+
const t = earlyLivenessOpenTimers.get(key)
|
|
11074
|
+
if (t != null) { clearTimeout(t); earlyLivenessOpenTimers.delete(key) }
|
|
11075
|
+
}
|
|
11076
|
+
|
|
10989
11077
|
/**
|
|
10990
11078
|
* Heartbeat tick (PR1): keep the live activity feed visibly advancing during a
|
|
10991
11079
|
* long single step that emits no new tool_label. Re-renders the feed with a
|
|
@@ -11071,30 +11159,13 @@ function feedHeartbeatTick(): void {
|
|
|
11071
11159
|
// over and its edit cleanly replaces the placeholder. drainActivitySummary
|
|
11072
11160
|
// sends (opens) when activityMessageId is null and edits (maintains) once set
|
|
11073
11161
|
// — so this one branch handles both the open and the climb.
|
|
11162
|
+
//
|
|
11163
|
+
// The open/climb logic lives in ONE place (`openLivenessFeedIfDue`) so the
|
|
11164
|
+
// enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
|
|
11165
|
+
// heartbeat both reach the same drain — there is exactly one path that can
|
|
11166
|
+
// OPEN the liveness card, so the two callers can never double-open or race.
|
|
11074
11167
|
if (turn.mirrorLines.length === 0) {
|
|
11075
|
-
|
|
11076
|
-
const age = Date.now() - turn.startedAt
|
|
11077
|
-
if (age < FEED_LIVENESS_OPEN_MS) return
|
|
11078
|
-
const livenessHeader: SessionActivityHeader = {
|
|
11079
|
-
label: 'Agent', elapsedMs: age, toolCount: 0, state: 'running',
|
|
11080
|
-
}
|
|
11081
|
-
const rendered = renderActivityFeedWithNested(['Working…'], [], false, ` · ${formatFeedElapsed(age)}`, undefined, livenessHeader)
|
|
11082
|
-
if (rendered == null) return
|
|
11083
|
-
turn.activityPendingRender = rendered
|
|
11084
|
-
const ea = emissionAuthorityFor(turn)
|
|
11085
|
-
// PR-4d: route through the centralized chatLock-serialized card-drain gate.
|
|
11086
|
-
cardDrainGate(turn, ea, () => {
|
|
11087
|
-
if (ea.mayDrain(turn)) {
|
|
11088
|
-
// Producer C (liveness timer): the genuine ≥12s thinking-gap open. Now
|
|
11089
|
-
// that Lever 5 is inert (narrative may open pre-answer — #2588), liveness
|
|
11090
|
-
// remains the natural open for 0-tool pre-answer turns that are silent.
|
|
11091
|
-
// The sticky-latch (lever 1) still gates it in the drain.
|
|
11092
|
-
// PR-4a: routed through the emission-authority façade (no-op delegate).
|
|
11093
|
-
ea.openOrEditCard('liveness', () => {
|
|
11094
|
-
turn.activityInFlight = drainActivitySummary(turn, 'liveness')
|
|
11095
|
-
})
|
|
11096
|
-
}
|
|
11097
|
-
})
|
|
11168
|
+
openLivenessFeedIfDue(turn)
|
|
11098
11169
|
return
|
|
11099
11170
|
}
|
|
11100
11171
|
|
|
@@ -11328,6 +11399,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
11328
11399
|
// the SAME statusKey the ctor's façade was constructed with just above.
|
|
11329
11400
|
setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
|
|
11330
11401
|
markIdleActivity() // any turn start (main session) is activity — re-arm idle clear
|
|
11402
|
+
// Early-open the "Working…" liveness card at turn start so narration /
|
|
11403
|
+
// thinking emitted BEFORE the first tool surfaces within ~a second
|
|
11404
|
+
// instead of after the old 12 s threshold (the dead-air gap). Fires the
|
|
11405
|
+
// SAME `openLivenessFeedIfDue` the 6 s heartbeat uses — a no-op if a
|
|
11406
|
+
// tool/narrative already opened the card, and gated by `mayOpenActivityCard`
|
|
11407
|
+
// (lever 1/4) so it never opens below a delivered answer. Scoped to real
|
|
11408
|
+
// turns by construction: only the `enqueue` lifecycle event reaches here,
|
|
11409
|
+
// and anonymous one-shot hook clients (recall.py) never emit it.
|
|
11410
|
+
scheduleEarlyLivenessOpen(next)
|
|
11331
11411
|
// Status-surface observability: one line at every turn SET so a later
|
|
11332
11412
|
// dark card is traceable to which turn/topic key it belonged to.
|
|
11333
11413
|
process.stderr.write(
|
|
@@ -11701,6 +11781,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
11701
11781
|
// (no flash). The draft transport is permanently retired — both modes
|
|
11702
11782
|
// use sendMessage + editMessageText for any message that does open.
|
|
11703
11783
|
minInitialChars: ANSWER_LANE.minInitialChars,
|
|
11784
|
+
// Render raw assistant transcript markdown → Telegram HTML before
|
|
11785
|
+
// any parse_mode:'HTML' send/edit, matching every other outbound
|
|
11786
|
+
// lane (handleStreamReply, the reply handler, the turn-flush
|
|
11787
|
+
// backstop, handlePtyPartial). Without this the answer-stream lane
|
|
11788
|
+
// shipped raw text — `**bold**` arrived as literal asterisks and
|
|
11789
|
+
// agent narration read unformatted (the answer-stream-raw-markdown
|
|
11790
|
+
// bug). Injected as a dependency (same pattern as the PTY partial
|
|
11791
|
+
// handler's `renderText`) so answer-stream.ts stays format-free.
|
|
11792
|
+
renderText: (text: string) => sanitizeTelegramHtml(markdownToHtml(text)),
|
|
11704
11793
|
// #1075: route through robustApiCall so flood-wait,
|
|
11705
11794
|
// benign-400, and THREAD_NOT_FOUND are handled uniformly
|
|
11706
11795
|
// instead of crashing the answer-stream loop on a deleted
|
|
@@ -22819,8 +22908,9 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
22819
22908
|
|
|
22820
22909
|
for (const iv of [...typingIntervals.values()]) clearInterval(iv)
|
|
22821
22910
|
typingIntervals.clear()
|
|
22822
|
-
|
|
22823
|
-
|
|
22911
|
+
turnTypingLoop.stopAll()
|
|
22912
|
+
for (const t of [...earlyLivenessOpenTimers.values()]) clearTimeout(t)
|
|
22913
|
+
earlyLivenessOpenTimers.clear()
|
|
22824
22914
|
for (const t of [...typingRetryTimers.values()]) clearTimeout(t)
|
|
22825
22915
|
typingRetryTimers.clear()
|
|
22826
22916
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-level `typing…` indicator — the second "something is happening" signal.
|
|
3
|
+
*
|
|
4
|
+
* A person you message shows as typing the WHOLE time they compose, not just
|
|
5
|
+
* the split-second a message is transmitted. Telegram's `sendChatAction('typing')`
|
|
6
|
+
* auto-expires after ~5 s, so a single one-shot ping on inbound leaves the chat
|
|
7
|
+
* dark after 5 s for any turn that thinks or reads a file before replying — the
|
|
8
|
+
* exact black-box gap this closes. We hold a continuous `typing…` for the whole
|
|
9
|
+
* turn by re-firing the action every ~4 s, and stop cleanly at the canonical
|
|
10
|
+
* turn-end. It runs ALONGSIDE the activity card (the early-card-open is the
|
|
11
|
+
* other signal): both start at turn enqueue and stop at turn-end, so the two
|
|
12
|
+
* "alive" signals start and stop together.
|
|
13
|
+
*
|
|
14
|
+
* This is the SAME mechanism as the tool-use `typingIntervals` (`typing-wrap.ts`)
|
|
15
|
+
* but on a DELIBERATELY SEPARATE interval map: if the turn loop shared that map,
|
|
16
|
+
* a mid-turn reply's `finally { stopTypingLoop }` would kill it and the chat
|
|
17
|
+
* would go dark for the rest of the turn. A dedicated map makes the turn loop
|
|
18
|
+
* structurally immune to those stops — only `stop` (the canonical turn-end)
|
|
19
|
+
* clears it. The redundant `typing` pings while a reply is mid-flight are
|
|
20
|
+
* harmless (same action, and `sendChatAction` is cheap).
|
|
21
|
+
*
|
|
22
|
+
* Extracted into a factory so the lifecycle (fires on start, refreshes on the
|
|
23
|
+
* interval, stops on turn-end, NEVER leaks a refresh interval after the turn
|
|
24
|
+
* completes) has its own unit test (`tests/turn-typing-loop.test.ts`) without
|
|
25
|
+
* spinning up the whole gateway or the Telegram bot API. The gateway injects
|
|
26
|
+
* the real `sendChatAction` + the chat-key function; tests inject spies + fake
|
|
27
|
+
* timers.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface TurnTypingLoopDeps {
|
|
31
|
+
/** Fire one `typing` chat action for (chatId, threadId). Errors are the
|
|
32
|
+
* caller's concern (the gateway swallows them); the loop never throws. */
|
|
33
|
+
sendChatAction: (chatId: string, threadId: number | null) => void
|
|
34
|
+
/** Canonical chat:thread key (the gateway's `chatKey`), so a per-topic turn
|
|
35
|
+
* loop is isolated from a sibling topic on the same chat. */
|
|
36
|
+
chatKey: (chatId: string, threadId: number | null) => string
|
|
37
|
+
/** Refresh cadence in ms. Telegram's `typing` auto-expires after ~5 s, so the
|
|
38
|
+
* default 4 s keeps it continuously lit. Override for tests. */
|
|
39
|
+
refreshMs?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TurnTypingLoop {
|
|
43
|
+
/** Start (or restart) the turn-long `typing…` loop for a chat/thread. Fires
|
|
44
|
+
* one action immediately, then every `refreshMs`. Idempotent re-start: a
|
|
45
|
+
* prior loop on the same key is stopped first, so this never leaks. */
|
|
46
|
+
start: (chatId: string, threadId?: number | null) => void
|
|
47
|
+
/** Stop the loop for a chat/thread and clear its interval. Idempotent — a
|
|
48
|
+
* no-op when no loop is registered. The single owner of the stop is the
|
|
49
|
+
* gateway's canonical turn-end. */
|
|
50
|
+
stop: (chatId: string, threadId?: number | null) => void
|
|
51
|
+
/** Stop EVERY live loop and clear the map — the gateway's shutdown-drain
|
|
52
|
+
* cleanup (mirrors the other interval-map clears there). */
|
|
53
|
+
stopAll: () => void
|
|
54
|
+
/** Test/observability: how many loops are currently live (0 ⇒ none leaked). */
|
|
55
|
+
activeCount: () => number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a turn-typing loop over injected deps. The interval map is private to
|
|
60
|
+
* the returned closure — the SEPARATE map that makes the turn loop immune to
|
|
61
|
+
* the tool-use typing stops.
|
|
62
|
+
*/
|
|
63
|
+
export function createTurnTypingLoop(deps: TurnTypingLoopDeps): TurnTypingLoop {
|
|
64
|
+
const refreshMs = deps.refreshMs ?? 4000
|
|
65
|
+
const intervals = new Map<string, ReturnType<typeof setInterval>>()
|
|
66
|
+
|
|
67
|
+
function stop(chatId: string, threadId: number | null = null): void {
|
|
68
|
+
const key = deps.chatKey(chatId, threadId)
|
|
69
|
+
const iv = intervals.get(key)
|
|
70
|
+
if (iv != null) {
|
|
71
|
+
clearInterval(iv)
|
|
72
|
+
intervals.delete(key)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function start(chatId: string, threadId: number | null = null): void {
|
|
77
|
+
// Restart-safe: stop any prior loop on this key first so a re-start never
|
|
78
|
+
// leaks a second interval (the self-heal when an abnormal abort skipped the
|
|
79
|
+
// turn-end stop — the next turn's start clears the stray loop).
|
|
80
|
+
stop(chatId, threadId)
|
|
81
|
+
const key = deps.chatKey(chatId, threadId)
|
|
82
|
+
const send = () => deps.sendChatAction(chatId, threadId)
|
|
83
|
+
send() // fire immediately so "typing…" lands ~instantly, not after refreshMs
|
|
84
|
+
const iv = setInterval(send, refreshMs)
|
|
85
|
+
// unref so a live loop never holds the process open (mirrors the gateway's
|
|
86
|
+
// other interval timers). Guarded for environments without unref.
|
|
87
|
+
;(iv as { unref?: () => void }).unref?.()
|
|
88
|
+
intervals.set(key, iv)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stopAll(): void {
|
|
92
|
+
for (const iv of [...intervals.values()]) clearInterval(iv)
|
|
93
|
+
intervals.clear()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
start,
|
|
98
|
+
stop,
|
|
99
|
+
stopAll,
|
|
100
|
+
activeCount: () => intervals.size,
|
|
101
|
+
}
|
|
102
|
+
}
|