switchroom 0.15.45 → 0.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -11,69 +11,37 @@
|
|
|
11
11
|
*
|
|
12
12
|
* This is what makes the experience feel responsive without burning
|
|
13
13
|
* Telegram's 1-edit-per-second-per-message rate limit. The latest delta
|
|
14
|
-
* always lands within ~1s, with at most one outstanding
|
|
14
|
+
* always lands within ~1s (or ~400ms in DMs), with at most one outstanding
|
|
15
|
+
* API call.
|
|
15
16
|
*
|
|
16
17
|
* In our model-driven architecture (no inference hooks), the controller
|
|
17
18
|
* is driven by the model calling stream_reply(text, done) multiple times
|
|
18
|
-
* during a long task. First call → sendMessage
|
|
19
|
-
*
|
|
20
|
-
* → flush, materialize as a fresh sendMessage (push notification), clear draft.
|
|
19
|
+
* during a long task. First call → sendMessage. Subsequent calls →
|
|
20
|
+
* throttled editMessageText. done=true → flush, finalize.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* - previewTransport: "message" — always use sendMessage/editMessageText
|
|
26
|
-
*
|
|
27
|
-
* Forum topics (message_thread_id set) force message transport because
|
|
28
|
-
* sendMessageDraft does not support threads. The caller (stream-controller.ts)
|
|
29
|
-
* handles this by passing previewTransport: "message" for threaded chats.
|
|
22
|
+
* The draft transport (sendMessageDraft) has been permanently retired —
|
|
23
|
+
* all streams use sendMessage + editMessageText (the in-place engine).
|
|
24
|
+
* See PR fix/retire-draft-transport for the removal rationale.
|
|
30
25
|
*/
|
|
31
26
|
|
|
32
|
-
import {
|
|
33
|
-
shouldFallbackFromDraftTransport,
|
|
34
|
-
allocateDraftId,
|
|
35
|
-
isDraft429,
|
|
36
|
-
extractDraft429RetryAfterSecs,
|
|
37
|
-
} from './draft-transport.js'
|
|
38
|
-
|
|
39
27
|
const TELEGRAM_MAX_CHARS = 4096
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
28
|
+
|
|
29
|
+
// Throttle defaults for the in-place engine.
|
|
30
|
+
// DM chats: 400 ms — slightly more responsive than groups while staying
|
|
31
|
+
// well under Telegram's practical ~1 edit/sec/message ceiling. This
|
|
32
|
+
// replaces the legacy 300 ms draft default: drafts were ephemeral and
|
|
33
|
+
// didn't share the editMessageText rate cap, but in-place edits do, so
|
|
34
|
+
// 300 ms would routinely hit the limit. 400 ms keeps DM streaming
|
|
35
|
+
// noticeably snappier than the group default without rate-limit pressure.
|
|
36
|
+
// Group/forum chats: 1000 ms — must respect Telegram's
|
|
37
|
+
// "1 edit/sec/message" practical ceiling.
|
|
46
38
|
// Both defaults can be overridden per-stream via `config.throttleMs` (which
|
|
47
39
|
// is itself wired from `channels.telegram.stream_throttle_ms` in the agent
|
|
48
40
|
// yaml, via the SWITCHROOM_TG_STREAM_THROTTLE_MS env var the gateway reads).
|
|
49
|
-
const
|
|
50
|
-
const
|
|
41
|
+
const DEFAULT_DM_THROTTLE_MS = 400
|
|
42
|
+
const DEFAULT_GROUP_THROTTLE_MS = 1000
|
|
51
43
|
const MIN_THROTTLE_MS = 250
|
|
52
44
|
|
|
53
|
-
// PR C — sendMessageDraft 30-second ephemeral persist-chain.
|
|
54
|
-
//
|
|
55
|
-
// Telegram's sendMessageDraft preview expires after 30 seconds. Long
|
|
56
|
-
// LLM turns blow past that, leaving the user staring at a stale draft.
|
|
57
|
-
// To stay live for arbitrary-length turns: at ~25s of accumulated
|
|
58
|
-
// draft streaming (or when the unpersisted chunk approaches 4000 chars
|
|
59
|
-
// — the per-message length cap with safety margin), fire a real
|
|
60
|
-
// sendMessage with the current chunk. This persists what the user has
|
|
61
|
-
// seen so far as a real message (with push notification). Then we
|
|
62
|
-
// allocate a fresh draft_id and continue streaming the next chunk
|
|
63
|
-
// into a new ephemeral preview. The model still sees a single
|
|
64
|
-
// continuous turn; the user sees a CHAIN of persisted messages, each
|
|
65
|
-
// up to ~25s / ~4000 chars, separated by live previews.
|
|
66
|
-
//
|
|
67
|
-
// At done=true / finalize(), the LAST unpersisted chunk is fired via
|
|
68
|
-
// sendMessage so the final state of the response is durable.
|
|
69
|
-
//
|
|
70
|
-
// These triggers fire on top of the normal throttle loop — i.e., the
|
|
71
|
-
// persist boundary is checked just before each draft fire, not on a
|
|
72
|
-
// separate timer. This keeps the loop simple and avoids fighting with
|
|
73
|
-
// the in-flight promise.
|
|
74
|
-
const PERSIST_INTERVAL_MS = 25_000
|
|
75
|
-
const PERSIST_SAFETY_CHAR_LIMIT = 4000
|
|
76
|
-
|
|
77
45
|
/**
|
|
78
46
|
* Send the first message in a stream. Receives the rendered text plus a
|
|
79
47
|
* thread_id (forum topic) and returns the new Telegram message_id.
|
|
@@ -85,20 +53,8 @@ export type StreamSendFn = (text: string) => Promise<number>
|
|
|
85
53
|
*/
|
|
86
54
|
export type StreamEditFn = (messageId: number, text: string) => Promise<void>
|
|
87
55
|
|
|
88
|
-
/**
|
|
89
|
-
* Optional sendMessageDraft callback. When present and the transport is
|
|
90
|
-
* "draft", this is called instead of sendMessage/editMessageText.
|
|
91
|
-
* Signature mirrors Telegram's sendMessageDraft Bot API method.
|
|
92
|
-
*/
|
|
93
|
-
export type StreamDraftFn = (
|
|
94
|
-
chatId: string,
|
|
95
|
-
draftId: number,
|
|
96
|
-
text: string,
|
|
97
|
-
params?: { message_thread_id?: number },
|
|
98
|
-
) => Promise<unknown>
|
|
99
|
-
|
|
100
56
|
export interface DraftStreamConfig {
|
|
101
|
-
/** Throttle window in ms. Floored at 250. Default 1000. */
|
|
57
|
+
/** Throttle window in ms. Floored at 250. Default 400 for DMs, 1000 for groups. */
|
|
102
58
|
throttleMs?: number
|
|
103
59
|
/**
|
|
104
60
|
* Maximum total characters before hard-stopping the stream. Default 4096
|
|
@@ -116,51 +72,21 @@ export interface DraftStreamConfig {
|
|
|
116
72
|
*
|
|
117
73
|
* Default 0 (no pre-send debounce — first update fires immediately).
|
|
118
74
|
* Only affects the first send; subsequent edits use throttleMs.
|
|
119
|
-
*
|
|
120
|
-
* NOTE: This debounce only applies to message transport. Draft transport
|
|
121
|
-
* fires immediately on the first update because drafts are ephemeral —
|
|
122
|
-
* the throttle/flush loop already collapses bursts into 1 API call/sec
|
|
123
|
-
* via throttleMs.
|
|
124
75
|
*/
|
|
125
76
|
idleMs?: number
|
|
126
77
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* - "draft": always prefer draft (falls back to message if sendMessageDraft absent).
|
|
131
|
-
* - "message": always use sendMessage/editMessageText.
|
|
132
|
-
*/
|
|
133
|
-
previewTransport?: 'auto' | 'message' | 'draft'
|
|
134
|
-
/**
|
|
135
|
-
* True if the current chat is a private DM. Used by "auto" transport to
|
|
136
|
-
* decide whether to activate draft. Has no effect when previewTransport
|
|
137
|
-
* is "draft" or "message".
|
|
78
|
+
* True if the current chat is a private DM. Used to select the throttle
|
|
79
|
+
* default (400 ms for DMs vs 1000 ms for groups) when `throttleMs` is
|
|
80
|
+
* not explicitly provided. Has no effect when `throttleMs` is set.
|
|
138
81
|
*/
|
|
139
82
|
isPrivateChat?: boolean
|
|
140
83
|
/**
|
|
141
|
-
*
|
|
142
|
-
* sendMessage/editMessageText regardless of previewTransport.
|
|
143
|
-
*/
|
|
144
|
-
sendMessageDraft?: StreamDraftFn
|
|
145
|
-
/**
|
|
146
|
-
* The Telegram chat id string — required when sendMessageDraft is provided,
|
|
147
|
-
* so the draft can be cleared on finalize.
|
|
84
|
+
* The Telegram chat id string — used for diagnostic traces.
|
|
148
85
|
*/
|
|
149
86
|
chatId?: string
|
|
150
|
-
/**
|
|
151
|
-
* PR C — persist-chain interval override. Default 25_000 ms. Lower
|
|
152
|
-
* for tests; production should leave default.
|
|
153
|
-
*/
|
|
154
|
-
persistIntervalMs?: number
|
|
155
|
-
/**
|
|
156
|
-
* PR C — persist-chain size threshold override (chars). Default 4000.
|
|
157
|
-
* Lower for tests so the size-trigger can fire on small text without
|
|
158
|
-
* colliding with the 4096-char maxChars hard-stop.
|
|
159
|
-
*/
|
|
160
|
-
persistSizeLimit?: number
|
|
161
87
|
/** Optional logger for debugging. Receives one string per event. */
|
|
162
88
|
log?: (msg: string) => void
|
|
163
|
-
/** Optional warning logger. Used for
|
|
89
|
+
/** Optional warning logger. Used for fallback notices. */
|
|
164
90
|
warn?: (msg: string) => void
|
|
165
91
|
/**
|
|
166
92
|
* If set, the stream is initialized as if a previous send had landed
|
|
@@ -172,8 +98,8 @@ export interface DraftStreamConfig {
|
|
|
172
98
|
* sendMessage. This closes the "done=true → activeDraftStreams entry
|
|
173
99
|
* deleted → next emit creates fresh sendMessage" duplicate-message
|
|
174
100
|
* class (issue #626). The not-found fallback at the edit site
|
|
175
|
-
* (
|
|
176
|
-
*
|
|
101
|
+
* (re-send on `MESSAGE_ID_INVALID`) gracefully handles a stale id —
|
|
102
|
+
* the bad edit fails once, then a fresh send fires.
|
|
177
103
|
*/
|
|
178
104
|
initialMessageId?: number | null
|
|
179
105
|
}
|
|
@@ -205,92 +131,31 @@ export interface DraftStreamHandle {
|
|
|
205
131
|
*
|
|
206
132
|
* The first update() call invokes `send` to create the message. All
|
|
207
133
|
* subsequent calls invoke `edit` against the captured message_id.
|
|
208
|
-
*
|
|
209
|
-
* When sendMessageDraft is provided (and transport allows it), intermediate
|
|
210
|
-
* updates use the draft API instead of sendMessage/editMessageText. On
|
|
211
|
-
* finalize(), a real sendMessage is sent for push notification, then the
|
|
212
|
-
* draft is cleared best-effort.
|
|
134
|
+
* All streaming uses the sendMessage + editMessageText in-place engine.
|
|
213
135
|
*/
|
|
214
136
|
export function createDraftStream(
|
|
215
137
|
send: StreamSendFn,
|
|
216
138
|
edit: StreamEditFn,
|
|
217
139
|
config: DraftStreamConfig = {},
|
|
218
140
|
): DraftStreamHandle {
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
((config.previewTransport ?? 'auto') === 'auto' && config.isPrivateChat === true)
|
|
226
|
-
const _defaultForTransport = _willPreferDraft && config.sendMessageDraft != null
|
|
227
|
-
? DEFAULT_DRAFT_THROTTLE_MS
|
|
228
|
-
: DEFAULT_MESSAGE_THROTTLE_MS
|
|
229
|
-
const throttleMs = Math.max(MIN_THROTTLE_MS, config.throttleMs ?? _defaultForTransport)
|
|
230
|
-
// PR C: persist-chain config overrides (testability — production
|
|
231
|
-
// leaves defaults at 25 s / 4000 chars).
|
|
232
|
-
const persistIntervalMs = config.persistIntervalMs ?? PERSIST_INTERVAL_MS
|
|
233
|
-
const persistSizeLimit = config.persistSizeLimit ?? PERSIST_SAFETY_CHAR_LIMIT
|
|
141
|
+
// Select throttle default: DMs get 400 ms (more responsive), groups get 1000 ms.
|
|
142
|
+
// An explicit `config.throttleMs` (from the operator yaml or the caller) always wins.
|
|
143
|
+
const _defaultThrottle = config.isPrivateChat === true
|
|
144
|
+
? DEFAULT_DM_THROTTLE_MS
|
|
145
|
+
: DEFAULT_GROUP_THROTTLE_MS
|
|
146
|
+
const throttleMs = Math.max(MIN_THROTTLE_MS, config.throttleMs ?? _defaultThrottle)
|
|
234
147
|
const maxChars = config.maxChars ?? TELEGRAM_MAX_CHARS
|
|
235
148
|
const idleMs = Math.max(0, config.idleMs ?? 0)
|
|
236
149
|
const log = config.log
|
|
237
150
|
const warn = config.warn
|
|
238
|
-
const draftApi = config.sendMessageDraft
|
|
239
151
|
const chatId = config.chatId ?? ''
|
|
240
152
|
|
|
241
|
-
// Resolve transport
|
|
242
|
-
const requestedTransport = config.previewTransport ?? 'auto'
|
|
243
|
-
const prefersDraft =
|
|
244
|
-
requestedTransport === 'draft'
|
|
245
|
-
? true
|
|
246
|
-
: requestedTransport === 'message'
|
|
247
|
-
? false
|
|
248
|
-
: (config.isPrivateChat === true) // 'auto': DM only
|
|
249
|
-
|
|
250
|
-
// Footgun guard: caller asked for "auto" + provided sendMessageDraft but
|
|
251
|
-
// forgot isPrivateChat. They almost certainly wanted draft in DMs but will
|
|
252
|
-
// silently get message transport everywhere. Warn so the bug is visible.
|
|
253
|
-
if (
|
|
254
|
-
requestedTransport === 'auto'
|
|
255
|
-
&& draftApi != null
|
|
256
|
-
&& config.isPrivateChat === undefined
|
|
257
|
-
) {
|
|
258
|
-
warn?.('draft-stream: previewTransport="auto" with sendMessageDraft but isPrivateChat undefined — defaulting to message transport')
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Use draft transport only if we have the API
|
|
262
|
-
let usesDraftTransport = prefersDraft && draftApi != null
|
|
263
|
-
let draftId: number | undefined = usesDraftTransport
|
|
264
|
-
? allocateDraftId()
|
|
265
|
-
: undefined
|
|
266
|
-
|
|
267
|
-
if (prefersDraft && !usesDraftTransport) {
|
|
268
|
-
warn?.('draft-stream: sendMessageDraft unavailable; falling back to sendMessage/editMessageText')
|
|
269
|
-
}
|
|
270
|
-
|
|
271
153
|
// Stream-start trace — always-on, structured for grep + aggregation.
|
|
272
|
-
// Resolves WHY the chosen transport landed (req=auto|draft|message;
|
|
273
|
-
// dm=true|false|undef; api=available|absent). Gates the rest of the
|
|
274
|
-
// sendMessageDraft alignment PR sequence: without this we can't tell
|
|
275
|
-
// a draft-routing regression from a config-toggle change.
|
|
276
|
-
// Kill switch: SWITCHROOM_STREAM_TRACES=0.
|
|
277
154
|
if (process.env.SWITCHROOM_STREAM_TRACES !== '0') {
|
|
278
|
-
const reason = usesDraftTransport
|
|
279
|
-
? 'draft'
|
|
280
|
-
: requestedTransport === 'message'
|
|
281
|
-
? 'explicit-message'
|
|
282
|
-
: requestedTransport === 'draft' && draftApi == null
|
|
283
|
-
? 'draft-requested-but-no-api'
|
|
284
|
-
: !prefersDraft
|
|
285
|
-
? 'auto-non-dm'
|
|
286
|
-
: 'fallback'
|
|
287
|
-
const draftIdPart = draftId != null ? ` draftId=${draftId}` : ''
|
|
288
155
|
process.stderr.write(
|
|
289
|
-
`gw-trace stream-start transport
|
|
290
|
-
`reason=${reason} req=${requestedTransport} ` +
|
|
156
|
+
`gw-trace stream-start transport=message ` +
|
|
291
157
|
`dm=${config.isPrivateChat === undefined ? 'undef' : String(config.isPrivateChat)} ` +
|
|
292
|
-
`
|
|
293
|
-
`throttleMs=${throttleMs}${draftIdPart} ` +
|
|
158
|
+
`throttleMs=${throttleMs} ` +
|
|
294
159
|
`chatId=${chatId || '-'}\n`,
|
|
295
160
|
)
|
|
296
161
|
}
|
|
@@ -300,29 +165,11 @@ export function createDraftStream(
|
|
|
300
165
|
let lastSentText: string | null = null
|
|
301
166
|
let lastSentAt = 0
|
|
302
167
|
let inFlight: Promise<void> | null = null
|
|
303
|
-
//
|
|
304
|
-
// trace. draftFires/editFires/sendFires let the aggregator distinguish
|
|
305
|
-
// "stream used 80% draft + 20% edit fallback" vs "all edits, draft
|
|
306
|
-
// never fired". `firstFireAtMs` is the latency from stream-start to
|
|
307
|
-
// first wire send (matches TTFO sub-component for a single stream).
|
|
168
|
+
// Observability — per-stream fire counters for the stream-end trace.
|
|
308
169
|
const streamStartedAt = Date.now()
|
|
309
170
|
let firstFireAtMs: number | null = null
|
|
310
|
-
let draftFires = 0
|
|
311
171
|
let editFires = 0
|
|
312
172
|
let sendFires = 0
|
|
313
|
-
let fallbackFires = 0
|
|
314
|
-
// PR C — persist-chain state. `persistedTextLen` is the offset into
|
|
315
|
-
// the full cumulative model text that has already been committed to
|
|
316
|
-
// a real Telegram message via `sendMessage`. Subsequent draft fires
|
|
317
|
-
// send only the slice from `persistedTextLen` onward (the
|
|
318
|
-
// unpersisted tail). `currentChunkStartedAt` is when the CURRENT
|
|
319
|
-
// chunk (since last persist boundary) started streaming — drives
|
|
320
|
-
// the 25-second persist trigger. `persistChainFires` counts how
|
|
321
|
-
// many chunks have been persisted in this stream (always 0 for
|
|
322
|
-
// message-transport streams, only ticks for draft-transport).
|
|
323
|
-
let persistedTextLen = 0
|
|
324
|
-
let currentChunkStartedAt: number | null = null
|
|
325
|
-
let persistChainFires = 0
|
|
326
173
|
let scheduledTimer: ReturnType<typeof setTimeout> | null = null
|
|
327
174
|
let final = false
|
|
328
175
|
let stopped = false
|
|
@@ -339,84 +186,6 @@ export function createDraftStream(
|
|
|
339
186
|
}
|
|
340
187
|
}
|
|
341
188
|
|
|
342
|
-
async function sendViaDraft(textToSend: string): Promise<boolean> {
|
|
343
|
-
if (!draftApi || draftId == null) return false
|
|
344
|
-
// PR C: draft sees only the unpersisted tail. If the model produced
|
|
345
|
-
// text BEYOND what's already been committed to a real sendMessage,
|
|
346
|
-
// that tail is what the user sees in the live preview. When the
|
|
347
|
-
// tail is empty (model hasn't added anything new since persist),
|
|
348
|
-
// there's nothing to draft — the draft was cleared at persist time.
|
|
349
|
-
const draftText = textToSend.slice(persistedTextLen)
|
|
350
|
-
if (draftText.length === 0) {
|
|
351
|
-
// Treat as success — no work to do, dedup will skip on next call.
|
|
352
|
-
return true
|
|
353
|
-
}
|
|
354
|
-
try {
|
|
355
|
-
const result = await draftApi(chatId, draftId, draftText)
|
|
356
|
-
// PR D: sendMessageDraft is documented to return `true` on success.
|
|
357
|
-
// A non-true (or missing) return is a soft failure — Telegram
|
|
358
|
-
// accepted the call but the draft didn't land. Fall back to
|
|
359
|
-
// message transport for the rest of this stream so the user still
|
|
360
|
-
// sees the content. This catches API surface changes + edge cases
|
|
361
|
-
// not covered by `shouldFallbackFromDraftTransport`'s regex.
|
|
362
|
-
if (result !== true && result !== undefined) {
|
|
363
|
-
// Some grammY wrappers strip the bool and return undefined on
|
|
364
|
-
// success; treat ONLY explicitly-falsy returns as failure to
|
|
365
|
-
// avoid false-positive fallback. true / undefined → success.
|
|
366
|
-
if (result === false || result === null) {
|
|
367
|
-
warn?.(
|
|
368
|
-
`draft-stream: sendMessageDraft returned non-true (${JSON.stringify(result)}) — falling back to message transport`,
|
|
369
|
-
)
|
|
370
|
-
fallbackFires++
|
|
371
|
-
usesDraftTransport = false
|
|
372
|
-
draftId = undefined
|
|
373
|
-
return false
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
if (firstFireAtMs == null) firstFireAtMs = Date.now() - streamStartedAt
|
|
377
|
-
// Mark the start of THIS chunk's persist window on first fire of
|
|
378
|
-
// each chunk (after the previous persist boundary).
|
|
379
|
-
if (currentChunkStartedAt == null) currentChunkStartedAt = Date.now()
|
|
380
|
-
draftFires++
|
|
381
|
-
log?.(`stream → draft (id: ${draftId}, ${draftText.length} chars tail)`)
|
|
382
|
-
return true
|
|
383
|
-
} catch (err) {
|
|
384
|
-
// PR D: dedicated 429 path. Telegram rate-limits sendMessageDraft
|
|
385
|
-
// independently from sendMessage/editMessageText. On 429:
|
|
386
|
-
// - extract `retry_after`
|
|
387
|
-
// - fall back to message transport for the rest of this stream
|
|
388
|
-
// - bump `lastSentAt` so the throttle window absorbs the
|
|
389
|
-
// retry_after delay — prevents the message-transport
|
|
390
|
-
// fallback from immediately firing and getting 429'd too
|
|
391
|
-
// (Telegram's per-chat rate cap is shared across methods).
|
|
392
|
-
const retryAfterSecs = extractDraft429RetryAfterSecs(err)
|
|
393
|
-
if (retryAfterSecs != null && isDraft429(err)) {
|
|
394
|
-
warn?.(
|
|
395
|
-
`draft-stream: sendMessageDraft 429 (retry_after=${retryAfterSecs}s) — falling back to message transport + backoff`,
|
|
396
|
-
)
|
|
397
|
-
fallbackFires++
|
|
398
|
-
usesDraftTransport = false
|
|
399
|
-
draftId = undefined
|
|
400
|
-
// Push lastSentAt forward so the NEXT flush waits at least
|
|
401
|
-
// `retry_after` seconds before the message-transport send.
|
|
402
|
-
// The throttle math at update() / schedule() compares
|
|
403
|
-
// `Date.now() - lastSentAt >= throttleMs`, so by moving
|
|
404
|
-
// lastSentAt forward we delay the next fire.
|
|
405
|
-
lastSentAt = Date.now() + retryAfterSecs * 1000 - throttleMs
|
|
406
|
-
return false
|
|
407
|
-
}
|
|
408
|
-
if (shouldFallbackFromDraftTransport(err)) {
|
|
409
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
410
|
-
warn?.(`draft-stream: sendMessageDraft rejected — falling back to sendMessage/editMessageText (${msg})`)
|
|
411
|
-
fallbackFires++
|
|
412
|
-
usesDraftTransport = false
|
|
413
|
-
draftId = undefined
|
|
414
|
-
return false
|
|
415
|
-
}
|
|
416
|
-
throw err
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
189
|
async function flush(): Promise<void> {
|
|
421
190
|
if (stopped) {
|
|
422
191
|
notifyWaiters()
|
|
@@ -435,99 +204,16 @@ export function createDraftStream(
|
|
|
435
204
|
return
|
|
436
205
|
}
|
|
437
206
|
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
// path needs this; message transport edits the same id forever
|
|
442
|
-
// and the 4096-char cap is a real terminal stop there.
|
|
443
|
-
//
|
|
444
|
-
// The trigger fires when EITHER the current chunk has been
|
|
445
|
-
// streaming for ≥25s OR the unpersisted tail is approaching the
|
|
446
|
-
// 4000-char message length cap. On fire: send the chunk via
|
|
447
|
-
// real sendMessage, bump persistedTextLen, allocate a fresh
|
|
448
|
-
// draftId, reset the chunk window. The subsequent normal-flow
|
|
449
|
-
// draft fire below sends only the (now-empty or post-persist) tail.
|
|
450
|
-
if (usesDraftTransport && currentChunkStartedAt != null) {
|
|
451
|
-
const elapsed = Date.now() - currentChunkStartedAt
|
|
452
|
-
const tailLen = textToSend.length - persistedTextLen
|
|
453
|
-
const sizeApproaching = tailLen >= persistSizeLimit
|
|
454
|
-
const timeElapsed = elapsed >= persistIntervalMs
|
|
455
|
-
if ((timeElapsed || sizeApproaching) && tailLen > 0) {
|
|
456
|
-
const chunk = textToSend.slice(persistedTextLen)
|
|
457
|
-
try {
|
|
458
|
-
const newMsgId = await send(chunk)
|
|
459
|
-
messageId = newMsgId
|
|
460
|
-
persistedTextLen = textToSend.length
|
|
461
|
-
draftId = allocateDraftId()
|
|
462
|
-
currentChunkStartedAt = null
|
|
463
|
-
persistChainFires++
|
|
464
|
-
// PR follow-up: persist-chain's bare send() bypasses
|
|
465
|
-
// sendViaMessage's increment, same shape as the finalize-
|
|
466
|
-
// materialize bug. Without this, streams that cross the
|
|
467
|
-
// 25s / 4000-char boundary would under-report `sends` by
|
|
468
|
-
// the chain count in stream-end.
|
|
469
|
-
sendFires++
|
|
470
|
-
if (process.env.SWITCHROOM_STREAM_TRACES !== '0') {
|
|
471
|
-
process.stderr.write(
|
|
472
|
-
`gw-trace stream-persist chunk_chars=${chunk.length} ` +
|
|
473
|
-
`elapsed=${elapsed} reason=${timeElapsed ? 'time' : 'size'} ` +
|
|
474
|
-
`newMsgId=${newMsgId} newDraftId=${draftId} ` +
|
|
475
|
-
`chatId=${chatId || '-'}\n`,
|
|
476
|
-
)
|
|
477
|
-
}
|
|
478
|
-
log?.(`stream → persisted chunk (id: ${newMsgId}, ${chunk.length} chars, reason=${timeElapsed ? 'time' : 'size'})`)
|
|
479
|
-
} catch (err) {
|
|
480
|
-
// Persist failed — log and continue. The next flush re-
|
|
481
|
-
// evaluates the trigger and re-fires.
|
|
482
|
-
//
|
|
483
|
-
// Edge case (accepted as v1 ceiling): if `send(chunk)`
|
|
484
|
-
// actually LANDED on Telegram but the response/ack was lost
|
|
485
|
-
// (network blip), the retry will double-persist — the user
|
|
486
|
-
// sees the same chunk twice as two separate sendMessages.
|
|
487
|
-
// Telegram doesn't expose a sendMessage idempotency key. The
|
|
488
|
-
// user-visible artifact is "duplicate chunk", not data loss,
|
|
489
|
-
// and observed rate of lost-ACK is rare. PR D follow-up
|
|
490
|
-
// could add a per-chunk hash dedup on retry.
|
|
491
|
-
warn?.(
|
|
492
|
-
`draft-stream: persist sendMessage failed — chunk stays in draft (${err instanceof Error ? err.message : String(err)})`,
|
|
493
|
-
)
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Edge case: if the model RETRACTS cumulative text (rare — most
|
|
499
|
-
// LLM streams are strict-extension), `textToSend.length` may be
|
|
500
|
-
// less than `persistedTextLen`. `slice(persistedTextLen)` returns
|
|
501
|
-
// "" and the persist trigger's `tailLen > 0` guard short-circuits,
|
|
502
|
-
// so we silently skip. The live preview goes stale until the model
|
|
503
|
-
// re-extends past `persistedTextLen`. No crash, no double-send.
|
|
504
|
-
// Tolerated as the failure mode is benign and the cause is upstream.
|
|
505
|
-
|
|
506
|
-
// Hard-stop check — applies to the sendable size (full text for
|
|
507
|
-
// message transport, post-persist tail for draft transport). After
|
|
508
|
-
// a successful persist, the tail resets so this won't fire even
|
|
509
|
-
// for huge cumulative texts in the draft path.
|
|
510
|
-
const sendableLen = usesDraftTransport
|
|
511
|
-
? textToSend.length - persistedTextLen
|
|
512
|
-
: textToSend.length
|
|
513
|
-
if (sendableLen > maxChars) {
|
|
514
|
-
log?.(`stream stopped: ${usesDraftTransport ? 'tail' : 'text'} exceeds ${maxChars} chars`)
|
|
207
|
+
// Hard-stop check
|
|
208
|
+
if (textToSend.length > maxChars) {
|
|
209
|
+
log?.(`stream stopped: text exceeds ${maxChars} chars`)
|
|
515
210
|
stopped = true
|
|
516
211
|
notifyWaiters()
|
|
517
212
|
return
|
|
518
213
|
}
|
|
519
214
|
|
|
520
215
|
try {
|
|
521
|
-
|
|
522
|
-
const ok = await sendViaDraft(textToSend)
|
|
523
|
-
if (!ok) {
|
|
524
|
-
// Draft failed with a permanent error → fell back to message transport.
|
|
525
|
-
// Replay this text via message transport.
|
|
526
|
-
await sendViaMessage(textToSend)
|
|
527
|
-
}
|
|
528
|
-
} else {
|
|
529
|
-
await sendViaMessage(textToSend)
|
|
530
|
-
}
|
|
216
|
+
await sendViaMessage(textToSend)
|
|
531
217
|
lastSentText = textToSend
|
|
532
218
|
lastSentAt = Date.now()
|
|
533
219
|
} catch (err) {
|
|
@@ -601,9 +287,9 @@ export function createDraftStream(
|
|
|
601
287
|
// Pre-send idle debounce: for the FIRST send of a stream, optionally
|
|
602
288
|
// defer by idleMs so a burst of update() calls collapses into one
|
|
603
289
|
// send. Each incoming update resets the timer. Once the initial
|
|
604
|
-
// send has landed (messageId != null
|
|
605
|
-
//
|
|
606
|
-
if (idleMs > 0 && messageId == null &&
|
|
290
|
+
// send has landed (messageId != null), this path is skipped and
|
|
291
|
+
// the regular throttle kicks in.
|
|
292
|
+
if (idleMs > 0 && messageId == null && inFlight == null) {
|
|
607
293
|
if (scheduledTimer != null) clearTimeout(scheduledTimer)
|
|
608
294
|
scheduledTimer = setTimeout(() => {
|
|
609
295
|
scheduledTimer = null
|
|
@@ -656,63 +342,14 @@ export function createDraftStream(
|
|
|
656
342
|
await flush()
|
|
657
343
|
}
|
|
658
344
|
|
|
659
|
-
// Draft transport: materialize as a real sendMessage for push
|
|
660
|
-
// notification, then clear the draft best-effort.
|
|
661
|
-
//
|
|
662
|
-
// PR C: with the persist-chain in play, earlier chunks may
|
|
663
|
-
// already be persisted as their own sendMessages. We materialize
|
|
664
|
-
// ONLY the unpersisted tail here — otherwise the user gets a
|
|
665
|
-
// duplicate of the prior chunks at turn end.
|
|
666
|
-
if (usesDraftTransport && draftApi != null) {
|
|
667
|
-
const fullText = lastSentText ?? ''
|
|
668
|
-
const textToMaterialize = fullText.slice(persistedTextLen)
|
|
669
|
-
if (textToMaterialize.length > 0) {
|
|
670
|
-
try {
|
|
671
|
-
messageId = await send(textToMaterialize)
|
|
672
|
-
persistedTextLen = fullText.length
|
|
673
|
-
// PR follow-up: bump sendFires so the stream-end trace
|
|
674
|
-
// reflects the finalize-materialize sendMessage call. Pre-
|
|
675
|
-
// this fix, the counter under-reported by 1 for every
|
|
676
|
-
// draft-transport stream that produced a non-empty reply:
|
|
677
|
-
// gw-trace stream-end showed `drafts=N sends=0` even
|
|
678
|
-
// though sendMessage HAD fired (visible in tg-post lines).
|
|
679
|
-
sendFires++
|
|
680
|
-
log?.(`stream → materialized tail (id: ${messageId}, ${textToMaterialize.length} chars)`)
|
|
681
|
-
} catch (err) {
|
|
682
|
-
warn?.(`draft-stream: materialize sendMessage failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
683
|
-
}
|
|
684
|
-
// Clear draft best-effort (cosmetic — Telegram input area cleanup)
|
|
685
|
-
if (draftId != null) {
|
|
686
|
-
try {
|
|
687
|
-
await draftApi(chatId, draftId, '')
|
|
688
|
-
} catch {
|
|
689
|
-
// Best-effort — ignore failures
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
} else if (draftId != null) {
|
|
693
|
-
// Whole text already persisted via the chain — just clear the
|
|
694
|
-
// current draft so the input area isn't left with stale
|
|
695
|
-
// preview content.
|
|
696
|
-
try {
|
|
697
|
-
await draftApi(chatId, draftId, '')
|
|
698
|
-
} catch {
|
|
699
|
-
// Best-effort — ignore
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
345
|
log?.(`stream finalized (id: ${messageId})`)
|
|
705
346
|
|
|
706
|
-
// Stream-end trace — pairs with stream-start.
|
|
707
|
-
// `sends` lets the aggregator see the transport ratio per stream;
|
|
708
|
-
// `firstFireMs` is the per-stream send latency component of TTFO;
|
|
709
|
-
// `chars` is the final committed text length.
|
|
347
|
+
// Stream-end trace — pairs with stream-start.
|
|
710
348
|
if (process.env.SWITCHROOM_STREAM_TRACES !== '0') {
|
|
711
349
|
const durationMs = Date.now() - streamStartedAt
|
|
712
350
|
process.stderr.write(
|
|
713
|
-
`gw-trace stream-end transport
|
|
714
|
-
`
|
|
715
|
-
`fallbacks=${fallbackFires} persists=${persistChainFires} ` +
|
|
351
|
+
`gw-trace stream-end transport=message ` +
|
|
352
|
+
`sends=${sendFires} edits=${editFires} ` +
|
|
716
353
|
`firstFireMs=${firstFireAtMs ?? -1} durationMs=${durationMs} ` +
|
|
717
354
|
`chars=${(lastSentText ?? '').length} ` +
|
|
718
355
|
`chatId=${chatId || '-'}\n`,
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Background. An agent often ends a turn with its real answer as plain
|
|
5
5
|
* assistant transcript text instead of a `reply` / `stream_reply` tool
|
|
6
|
-
* call. The gateway renders that transcript
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* call. The gateway renders that transcript via the answer-lane stream
|
|
7
|
+
* and, at turn_end, retracts the preview — so the answer is never
|
|
8
|
+
* finalized and the user watches it vanish (#1664).
|
|
9
9
|
*
|
|
10
10
|
* The gateway's `replyCalled` flag flips on the FIRST reply / stream_reply
|
|
11
11
|
* tool use and stays true for the rest of the turn. It cannot distinguish
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
* gateway is a multi-thousand-line module that's expensive to import in a
|
|
24
24
|
* test. See `telegram-plugin/tests/final-answer-detect.test.ts`.
|
|
25
25
|
*
|
|
26
|
-
* The fix re-prompts the model; it never
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* The fix re-prompts the model; it never silently drops the answer
|
|
27
|
+
* (`reference/principles.md`: the model communicates, the framework is
|
|
28
|
+
* the safety net). So a false "interim" classification is
|
|
29
29
|
* cheap (one extra re-prompt) and a false "final" classification is the
|
|
30
30
|
* dangerous one (a real answer left undelivered) — the length backstop
|
|
31
31
|
* exists to make the dangerous miss rare.
|
|
@@ -103,12 +103,17 @@ export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
|
|
|
103
103
|
* otherwise the silent-end re-prompt would spuriously fire and the agent
|
|
104
104
|
* would re-deliver a duplicate / garbled answer.
|
|
105
105
|
*
|
|
106
|
-
* Residual
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
106
|
+
* Residual (pre-existing, predates PR-2 — a conscious accept, no regression):
|
|
107
|
+
* a reply that is genuinely the final answer yet is BOTH short (<200 chars)
|
|
108
|
+
* AND pinging (e.g. "Done!") is indistinguishable here from an ack. So when
|
|
109
|
+
* such an answer arrives AFTER an ack has already pinged this turn, it
|
|
110
|
+
* classifies as an ack and its ping is suppressed (and post-answer
|
|
111
|
+
* housekeeping after it still re-opens the feed). PR-2's slot-ownership
|
|
112
|
+
* upgrade does NOT rescue this case — the upgrade only fires for a
|
|
113
|
+
* *substantive* answer, and this answer reads as non-substantive by the
|
|
114
|
+
* ≥200-char test. That is much rarer than the housekeeping-after-long-answer
|
|
115
|
+
* case this predicate protects, and the feed-reopen half is kill-switchable
|
|
116
|
+
* via `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0`.
|
|
112
117
|
*/
|
|
113
118
|
export function isSubstantiveFinalReply(input: FinalAnswerReplyInput): boolean {
|
|
114
119
|
if (input.done === true) return true
|