switchroom 0.15.44 → 0.16.4

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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3249 -1241
  10. package/dist/cli/ui/index.html +1 -1
  11. package/dist/host-control/main.js +2833 -355
  12. package/dist/vault/approvals/kernel-server.js +7482 -7439
  13. package/dist/vault/broker/server.js +11315 -11272
  14. package/examples/minimal.yaml +1 -0
  15. package/examples/switchroom.yaml +1 -0
  16. package/package.json +3 -3
  17. package/profiles/_base/start.sh.hbs +88 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  19. package/profiles/default/CLAUDE.md.hbs +3 -22
  20. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  21. package/telegram-plugin/answer-stream-flag.ts +12 -49
  22. package/telegram-plugin/answer-stream.ts +5 -150
  23. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  24. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  25. package/telegram-plugin/context-exhaustion.ts +12 -0
  26. package/telegram-plugin/demo-mask.ts +154 -0
  27. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  28. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  29. package/telegram-plugin/dist/server.js +215 -172
  30. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  31. package/telegram-plugin/draft-stream.ts +47 -410
  32. package/telegram-plugin/final-answer-detect.ts +17 -12
  33. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  34. package/telegram-plugin/format.ts +56 -19
  35. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  36. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  37. package/telegram-plugin/gateway/auth-command.ts +70 -14
  38. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  39. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  40. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  41. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  42. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  43. package/telegram-plugin/gateway/effort-command.ts +8 -3
  44. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  45. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  46. package/telegram-plugin/gateway/gateway.ts +1837 -291
  47. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  48. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  49. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  50. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  51. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  52. package/telegram-plugin/history.ts +33 -11
  53. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  54. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  55. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  56. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  57. package/telegram-plugin/issues-card.ts +4 -0
  58. package/telegram-plugin/model-unavailable.ts +124 -0
  59. package/telegram-plugin/narrative-dedup.ts +69 -0
  60. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  61. package/telegram-plugin/package.json +3 -3
  62. package/telegram-plugin/pending-work-progress.ts +12 -0
  63. package/telegram-plugin/permission-rule.ts +32 -5
  64. package/telegram-plugin/permission-title.ts +152 -9
  65. package/telegram-plugin/quota-check.ts +13 -0
  66. package/telegram-plugin/quota-watch.ts +135 -7
  67. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  68. package/telegram-plugin/registry/turns-schema.ts +9 -0
  69. package/telegram-plugin/runtime-metrics.ts +13 -0
  70. package/telegram-plugin/session-tail.ts +96 -11
  71. package/telegram-plugin/silence-poke.ts +170 -24
  72. package/telegram-plugin/slot-banner-driver.ts +3 -0
  73. package/telegram-plugin/status-no-truncate.ts +44 -0
  74. package/telegram-plugin/status-reactions.ts +20 -3
  75. package/telegram-plugin/stream-controller.ts +4 -23
  76. package/telegram-plugin/stream-reply-handler.ts +6 -24
  77. package/telegram-plugin/streaming-metrics.ts +91 -0
  78. package/telegram-plugin/subagent-watcher.ts +212 -66
  79. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  80. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  81. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  82. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  83. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  84. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  85. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  86. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  87. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  88. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  89. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  90. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  91. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  92. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  93. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  94. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  95. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  96. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  97. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  98. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  99. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  100. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  101. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  102. package/telegram-plugin/tests/history.test.ts +60 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -1,7 +1,7 @@
1
1
  # Waiting-for-reply UX — v2 spec (three-class contract)
2
2
 
3
- Tracks: [#545](https://github.com/mekenthompson/switchroom/issues/545),
4
- [#553](https://github.com/mekenthompson/switchroom/issues/553) (PR series),
3
+ Tracks: [#545](https://github.com/switchroom/switchroom/issues/545),
4
+ [#553](https://github.com/switchroom/switchroom/issues/553) (PR series),
5
5
  [#1713](https://github.com/switchroom/switchroom/issues/1713)
6
6
  (reflective status-reaction restoration)
7
7
 
@@ -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 API call.
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 (or sendMessageDraft in DMs).
19
- * Subsequent calls → throttled editMessageText (or sendMessageDraft). done=true
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
- * Transport selection:
23
- * - previewTransport: "auto" (default) use draft in DMs only
24
- * - previewTransport: "draft" — always use draft (if API available)
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
- // PR B: transport-aware defaults.
41
- // Draft transport (DMs): 300 ms drafts are ephemeral and don't share
42
- // editMessageText's per-message rate cap, so we can refresh much faster.
43
- // 300 ms feels live without burning bandwidth.
44
- // Message transport (groups / forums / draft API absent): 1000 ms must
45
- // respect Telegram's "1 edit/sec/message" practical ceiling.
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 DEFAULT_DRAFT_THROTTLE_MS = 300
50
- const DEFAULT_MESSAGE_THROTTLE_MS = 1000
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
- * Transport selector.
128
- * - "auto" (default): use draft transport when isPrivateChat=true AND
129
- * sendMessageDraft is provided; otherwise use message transport.
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
- * sendMessageDraft callback. When absent, the stream falls back to
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 transport fallback notices. */
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
- * (line ~280: re-send on `MESSAGE_ID_INVALID`) gracefully handles a
176
- * stale id — the bad edit fails once, then a fresh send fires.
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
- // PR B: transport-aware default the actual transport resolves a few
220
- // lines below, so we replicate the prefersDraft check here. An
221
- // explicit `config.throttleMs` (from the operator yaml or the
222
- // caller) wins.
223
- const _willPreferDraft =
224
- (config.previewTransport ?? 'auto') === 'draft' ||
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=${usesDraftTransport ? 'draft' : 'message'} ` +
290
- `reason=${reason} req=${requestedTransport} ` +
156
+ `gw-trace stream-start transport=message ` +
291
157
  `dm=${config.isPrivateChat === undefined ? 'undef' : String(config.isPrivateChat)} ` +
292
- `api=${draftApi != null ? 'available' : 'absent'} ` +
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
- // PR A observability — per-stream fire counters for the stream-end
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
- // PR C — persist-chain trigger check. Runs BEFORE the maxChars
439
- // hard-stop so we can chunk large outputs across multiple
440
- // sendMessage calls instead of dropping them. Only the draft
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
- if (usesDraftTransport) {
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 OR draft has fired), this path
605
- // is skipped and the regular throttle kicks in.
606
- if (idleMs > 0 && messageId == null && !usesDraftTransport && inFlight == 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. `drafts`/`edits`/
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=${usesDraftTransport ? 'draft' : 'message'} ` +
714
- `drafts=${draftFires} sends=${sendFires} edits=${editFires} ` +
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 as a live Telegram draft
7
- * (`sendMessageDraft`) and, at turn_end, retracts the draft — so the
8
- * answer is never finalized and the user watches it vanish (#1664).
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 materializes the draft into a
27
- * message (`reference/principles.md`: the model communicates, the
28
- * framework is the safety net). So a false "interim" classification is
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: a reply that is genuinely the final answer yet is BOTH short
107
- * (<200 chars) AND pinging (e.g. "Done!") is indistinguishable here from
108
- * an ack, so post-answer housekeeping after it still re-opens the feed.
109
- * That is much rarer than the housekeeping-after-long-answer case this
110
- * predicate protects, and is kill-switchable via
111
- * `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0`.
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