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,10 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import {
3
3
  createAnswerStream,
4
- __resetDraftIdForTests,
5
4
  MIN_INITIAL_CHARS,
6
- DRAFT_METHOD_UNAVAILABLE_RE,
7
- DRAFT_CHAT_UNSUPPORTED_RE,
8
5
  } from '../answer-stream.js'
9
6
  import { resolveAnswerLaneConfig, ANSWER_LANE_NEVER_OPENS } from '../answer-stream-flag.js'
10
7
 
@@ -39,13 +36,6 @@ type EditMessageTextFn = (
39
36
  },
40
37
  ) => Promise<unknown>
41
38
 
42
- type SendMessageDraftFn = (
43
- chatId: string,
44
- draftId: number,
45
- text: string,
46
- params?: { message_thread_id?: number },
47
- ) => Promise<unknown>
48
-
49
39
  let nextMessageId = 1000
50
40
 
51
41
  function makeSendMessage(): ReturnType<typeof vi.fn> & SendMessageFn {
@@ -59,14 +49,9 @@ function makeEditMessageText(): ReturnType<typeof vi.fn> & EditMessageTextFn {
59
49
  return vi.fn(async () => {}) as unknown as ReturnType<typeof vi.fn> & EditMessageTextFn
60
50
  }
61
51
 
62
- function makeSendMessageDraft(): ReturnType<typeof vi.fn> & SendMessageDraftFn {
63
- return vi.fn(async () => {}) as unknown as ReturnType<typeof vi.fn> & SendMessageDraftFn
64
- }
65
-
66
52
  // ─── Tests ────────────────────────────────────────────────────────────────────
67
53
 
68
54
  beforeEach(() => {
69
- __resetDraftIdForTests()
70
55
  nextMessageId = 1000
71
56
  vi.useFakeTimers()
72
57
  })
@@ -81,7 +66,6 @@ describe('answer-stream — minInitialChars threshold', () => {
81
66
  const editMessageText = makeEditMessageText()
82
67
  const stream = createAnswerStream({
83
68
  chatId: 'chat1',
84
- isPrivateChat: false,
85
69
  minInitialChars: 400,
86
70
  throttleMs: 250,
87
71
  sendMessage,
@@ -101,7 +85,7 @@ describe('answer-stream — minInitialChars threshold', () => {
101
85
  // End-to-end proof that the resolved default config produces no visible
102
86
  // preview → nothing to retract → no flash. Wires the ACTUAL resolver output
103
87
  // (not a hand-picked threshold) into the stream.
104
- const lane = resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false })
88
+ const lane = resolveAnswerLaneConfig({ visibleEnabled: false })
105
89
  expect(lane.state).toBe('dormant')
106
90
  expect(lane.minInitialChars).toBe(ANSWER_LANE_NEVER_OPENS)
107
91
 
@@ -109,12 +93,10 @@ describe('answer-stream — minInitialChars threshold', () => {
109
93
  const editMessageText = makeEditMessageText()
110
94
  const stream = createAnswerStream({
111
95
  chatId: 'chat1',
112
- isPrivateChat: false,
113
96
  minInitialChars: lane.minInitialChars,
114
97
  throttleMs: 250,
115
98
  sendMessage,
116
99
  editMessageText,
117
- // no sendMessageDraft — dormant lane has no transport
118
100
  })
119
101
 
120
102
  // A realistic full answer — 2000 chars, far above any normal threshold.
@@ -131,7 +113,6 @@ describe('answer-stream — minInitialChars threshold', () => {
131
113
  const editMessageText = makeEditMessageText()
132
114
  const stream = createAnswerStream({
133
115
  chatId: 'chat1',
134
- isPrivateChat: false,
135
116
  minInitialChars: 400,
136
117
  throttleMs: 250,
137
118
  sendMessage,
@@ -153,120 +134,6 @@ describe('answer-stream — minInitialChars threshold', () => {
153
134
  })
154
135
  })
155
136
 
156
- describe('answer-stream — draft transport selection', () => {
157
- it('uses sendMessageDraft for DMs (isPrivateChat: true)', async () => {
158
- const sendMessage = makeSendMessage()
159
- const editMessageText = makeEditMessageText()
160
- const sendMessageDraft = makeSendMessageDraft()
161
- const stream = createAnswerStream({
162
- chatId: 'chat1',
163
- isPrivateChat: true,
164
- throttleMs: 250,
165
- sendMessage,
166
- editMessageText,
167
- sendMessageDraft,
168
- })
169
-
170
- // Draft transport bypasses minInitialChars gate — any non-empty text goes
171
- stream.update('Hello from DM!')
172
- await flushMicrotasks()
173
-
174
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
175
- expect(sendMessageDraft).toHaveBeenCalledWith(
176
- 'chat1',
177
- expect.any(Number),
178
- 'Hello from DM!',
179
- undefined,
180
- )
181
- expect(sendMessage).not.toHaveBeenCalled()
182
- })
183
-
184
- it('uses sendMessage for non-DM chats even when sendMessageDraft is provided', async () => {
185
- const sendMessage = makeSendMessage()
186
- const editMessageText = makeEditMessageText()
187
- const sendMessageDraft = makeSendMessageDraft()
188
- const stream = createAnswerStream({
189
- chatId: 'chat1',
190
- isPrivateChat: false,
191
- minInitialChars: 10,
192
- throttleMs: 250,
193
- sendMessage,
194
- editMessageText,
195
- sendMessageDraft,
196
- })
197
-
198
- stream.update('x'.repeat(50))
199
- await flushMicrotasks()
200
-
201
- expect(sendMessage).toHaveBeenCalledTimes(1)
202
- expect(sendMessageDraft).not.toHaveBeenCalled()
203
- })
204
- })
205
-
206
- describe('answer-stream — runtime fallback when sendMessageDraft rejects', () => {
207
- it('falls back to sendMessage when sendMessageDraft throws DRAFT_METHOD_UNAVAILABLE_RE pattern', async () => {
208
- const sendMessage = makeSendMessage()
209
- const editMessageText = makeEditMessageText()
210
- // The shouldFallbackFromDraftTransport helper checks for "sendMessageDraft" in the
211
- // error message plus the regex pattern.
212
- const sendMessageDraft = vi.fn(async () => {
213
- throw new Error('sendMessageDraft: unknown method')
214
- })
215
-
216
- const stream = createAnswerStream({
217
- chatId: 'chat1',
218
- isPrivateChat: true,
219
- throttleMs: 250,
220
- sendMessage,
221
- editMessageText,
222
- sendMessageDraft,
223
- })
224
-
225
- // First update — draft throws, falls back
226
- stream.update('Hello DM!')
227
- await flushMicrotasks()
228
-
229
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
230
- // After fallback, sendMessage should have been called with the same text
231
- expect(sendMessage).toHaveBeenCalledTimes(1)
232
- expect(sendMessage).toHaveBeenCalledWith('chat1', 'Hello DM!', expect.any(Object))
233
-
234
- // Subsequent update should use sendMessage+editMessageText, not draft
235
- sendMessageDraft.mockClear()
236
- sendMessage.mockClear()
237
- vi.advanceTimersByTime(1000)
238
- stream.update('Follow-up!')
239
- await flushMicrotasks()
240
-
241
- expect(sendMessageDraft).not.toHaveBeenCalled()
242
- expect(editMessageText).toHaveBeenCalledTimes(1)
243
- })
244
-
245
- it('falls back when sendMessageDraft throws DRAFT_CHAT_UNSUPPORTED_RE pattern', async () => {
246
- const sendMessage = makeSendMessage()
247
- const editMessageText = makeEditMessageText()
248
- const sendMessageDraft = vi.fn(async () => {
249
- throw new Error("sendMessageDraft can't be used in this chat")
250
- })
251
-
252
- const stream = createAnswerStream({
253
- chatId: 'chat1',
254
- isPrivateChat: true,
255
- throttleMs: 250,
256
- sendMessage,
257
- editMessageText,
258
- sendMessageDraft,
259
- })
260
-
261
- stream.update('Hello!')
262
- await flushMicrotasks()
263
-
264
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
265
- expect(sendMessage).toHaveBeenCalledTimes(1)
266
- expect(sendMessage).toHaveBeenCalledWith('chat1', 'Hello!', expect.any(Object))
267
- })
268
- })
269
-
270
137
  describe('answer-stream — throttling', () => {
271
138
  it('three rapid updates within throttleMs result in at most two transport calls', async () => {
272
139
  const sendMessage = makeSendMessage()
@@ -274,7 +141,6 @@ describe('answer-stream — throttling', () => {
274
141
  const THROTTLE = 1000
275
142
  const stream = createAnswerStream({
276
143
  chatId: 'chat1',
277
- isPrivateChat: false,
278
144
  minInitialChars: 10,
279
145
  throttleMs: THROTTLE,
280
146
  sendMessage,
@@ -317,7 +183,6 @@ describe('answer-stream — materialize()', () => {
317
183
  const THROTTLE = 1000
318
184
  const stream = createAnswerStream({
319
185
  chatId: 'chat1',
320
- isPrivateChat: false,
321
186
  minInitialChars: 10,
322
187
  throttleMs: THROTTLE,
323
188
  sendMessage,
@@ -348,7 +213,6 @@ describe('answer-stream — materialize()', () => {
348
213
  const editMessageText = makeEditMessageText()
349
214
  const stream = createAnswerStream({
350
215
  chatId: 'chat1',
351
- isPrivateChat: false,
352
216
  minInitialChars: 10,
353
217
  throttleMs: 250,
354
218
  sendMessage,
@@ -376,7 +240,6 @@ describe('answer-stream — forceNewMessage() supersession', () => {
376
240
  const THROTTLE = 1000
377
241
  const stream = createAnswerStream({
378
242
  chatId: 'chat1',
379
- isPrivateChat: false,
380
243
  minInitialChars: 10,
381
244
  throttleMs: THROTTLE,
382
245
  sendMessage,
@@ -387,7 +250,6 @@ describe('answer-stream — forceNewMessage() supersession', () => {
387
250
  stream.update('x'.repeat(50))
388
251
  await flushMicrotasks()
389
252
  expect(sendMessage).toHaveBeenCalledTimes(1)
390
- const firstMsgId = sendMessage.mock.calls[0]
391
253
 
392
254
  // Advance past throttle so a new update is ready to send
393
255
  vi.advanceTimersByTime(THROTTLE)
@@ -414,7 +276,6 @@ describe('answer-stream — stop() cancels pending throttled edits', () => {
414
276
  const THROTTLE = 1000
415
277
  const stream = createAnswerStream({
416
278
  chatId: 'chat1',
417
- isPrivateChat: false,
418
279
  minInitialChars: 10,
419
280
  throttleMs: THROTTLE,
420
281
  sendMessage,
@@ -447,209 +308,12 @@ describe('answer-stream — stop() cancels pending throttled edits', () => {
447
308
  })
448
309
  })
449
310
 
450
- // ─── #1704 regression — clear the sendMessageDraft on every terminal path ──
451
- //
452
- // In DMs the answer-stream uses sendMessageDraft, which renders inside the
453
- // user's compose box. Telegram Desktop blocks the user from typing while
454
- // the bot's draft is live — so stop() / retract() / materialize() must
455
- // all clear the draft. Without these tests the bug class slips back in
456
- // the next time someone tweaks the lifecycle.
457
-
458
- describe('answer-stream — clears sendMessageDraft on terminal paths (#1704)', () => {
459
- it('stop() clears the draft when draft transport was in use', async () => {
460
- const sendMessage = makeSendMessage()
461
- const editMessageText = makeEditMessageText()
462
- const sendMessageDraft = makeSendMessageDraft()
463
- const stream = createAnswerStream({
464
- chatId: 'chat1',
465
- isPrivateChat: true,
466
- throttleMs: 250,
467
- sendMessage,
468
- editMessageText,
469
- sendMessageDraft,
470
- })
471
-
472
- stream.update('mid-turn thought')
473
- await flushMicrotasks()
474
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
475
-
476
- stream.stop()
477
- // stop() is sync but the clear fires fire-and-forget — drain microtasks.
478
- await flushMicrotasks()
479
-
480
- // A second draft call must have landed with empty text, clearing the
481
- // compose-box preview. The draft id matches the in-flight stream's.
482
- const draftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
483
- expect(sendMessageDraft).toHaveBeenCalledWith('chat1', draftId, '', undefined)
484
- })
485
-
486
- it('retract() clears the draft when draft transport was in use', async () => {
487
- const sendMessage = makeSendMessage()
488
- const editMessageText = makeEditMessageText()
489
- const sendMessageDraft = makeSendMessageDraft()
490
- const stream = createAnswerStream({
491
- chatId: 'chat1',
492
- isPrivateChat: true,
493
- throttleMs: 250,
494
- sendMessage,
495
- editMessageText,
496
- sendMessageDraft,
497
- })
498
-
499
- stream.update('mid-turn thought')
500
- await flushMicrotasks()
501
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
502
-
503
- await stream.retract()
504
-
505
- const draftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
506
- expect(sendMessageDraft).toHaveBeenCalledWith('chat1', draftId, '', undefined)
507
- })
508
-
509
- it('stop() is a no-op on the draft API when message transport was in use', async () => {
510
- const sendMessage = makeSendMessage()
511
- const editMessageText = makeEditMessageText()
512
- const sendMessageDraft = makeSendMessageDraft()
513
- const stream = createAnswerStream({
514
- chatId: 'chat1',
515
- isPrivateChat: false, // forces message transport
516
- minInitialChars: 0,
517
- throttleMs: 250,
518
- sendMessage,
519
- editMessageText,
520
- sendMessageDraft,
521
- })
522
-
523
- stream.update('mid-turn thought')
524
- await flushMicrotasks()
525
- expect(sendMessage).toHaveBeenCalledTimes(1)
526
- expect(sendMessageDraft).not.toHaveBeenCalled()
527
-
528
- stream.stop()
529
- await flushMicrotasks()
530
-
531
- // Never touched the draft API at all.
532
- expect(sendMessageDraft).not.toHaveBeenCalled()
533
- })
534
-
535
- it('forwards message_thread_id to the draft-clear call', async () => {
536
- const sendMessage = makeSendMessage()
537
- const editMessageText = makeEditMessageText()
538
- const sendMessageDraft = makeSendMessageDraft()
539
- const stream = createAnswerStream({
540
- chatId: 'chat1',
541
- isPrivateChat: true,
542
- threadId: 42,
543
- throttleMs: 250,
544
- sendMessage,
545
- editMessageText,
546
- sendMessageDraft,
547
- })
548
-
549
- stream.update('mid-turn thought')
550
- await flushMicrotasks()
551
-
552
- await stream.retract()
553
-
554
- const lastCall = sendMessageDraft.mock.calls[sendMessageDraft.mock.calls.length - 1] as unknown as [string, number, string, { message_thread_id?: number } | undefined]
555
- expect(lastCall[2]).toBe('')
556
- expect(lastCall[3]).toEqual({ message_thread_id: 42 })
557
- })
558
- })
559
-
560
- // ─── #1792 — forceNewMessage clears the stale draftId before rotating ───
561
- //
562
- // Background: `forceNewMessage()` rotates `draftId` to a fresh allocation
563
- // so the stream can be re-used for a new turn (typical caller: gateway
564
- // rapid-steer path in `handleSessionEvent` enqueue branch — calls
565
- // `forceNewMessage(); stop()` on the prior turn's stream before opening
566
- // the new turn). Pre-#1792, the rotation orphaned the prior turn's
567
- // draft content in the user's compose box until Telegram's 30 s draft
568
- // expiry — `stop()`'s fire-and-forget clear closed over the (now-new)
569
- // `draftId`, so the clear targeted the unused id, not the stale one.
570
- //
571
- // Post-fix: `forceNewMessage` itself clears the stale draftId BEFORE
572
- // rotating. `stop()` continues to clear whatever draftId is current
573
- // at the time it runs (defensive, also fine: clearing an unused id
574
- // is a harmless no-op for the user).
575
-
576
- describe('answer-stream — forceNewMessage clears the stale draft before rotating (#1792)', () => {
577
- it('clears the pre-rotation draftId when forceNewMessage rotates', async () => {
578
- const sendMessage = makeSendMessage()
579
- const editMessageText = makeEditMessageText()
580
- const sendMessageDraft = makeSendMessageDraft()
581
- const stream = createAnswerStream({
582
- chatId: 'chat1',
583
- isPrivateChat: true,
584
- throttleMs: 250,
585
- sendMessage,
586
- editMessageText,
587
- sendMessageDraft,
588
- })
589
-
590
- // Open the stream — this allocates draftId N and fires sendDraft(N).
591
- stream.update('first turn thought')
592
- await flushMicrotasks()
593
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
594
- const staleDraftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
595
- sendMessageDraft.mockClear()
596
-
597
- // Rotate. forceNewMessage must enqueue a clear against the OLD
598
- // draftId before bumping to the new allocation — pre-fix the
599
- // stale content stayed in the compose box for 30 s.
600
- stream.forceNewMessage()
601
- await flushMicrotasks()
602
-
603
- expect(sendMessageDraft).toHaveBeenCalledTimes(1)
604
- const clearedId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
605
- const clearedText = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[2]
606
- expect(clearedId).toBe(staleDraftId)
607
- expect(clearedText).toBe('')
608
- })
609
-
610
- it('the gateway sequence forceNewMessage(); stop() clears the stale draftId', async () => {
611
- // Mirrors the only production caller — telegram-plugin/gateway/
612
- // gateway.ts:6476-6477 cleans up the prior turn's answer-stream
613
- // before opening a new turn (rapid steer / queue path).
614
- const sendMessage = makeSendMessage()
615
- const editMessageText = makeEditMessageText()
616
- const sendMessageDraft = makeSendMessageDraft()
617
- const stream = createAnswerStream({
618
- chatId: 'chat1',
619
- isPrivateChat: true,
620
- throttleMs: 250,
621
- sendMessage,
622
- editMessageText,
623
- sendMessageDraft,
624
- })
625
-
626
- stream.update('prior turn thought')
627
- await flushMicrotasks()
628
- const staleDraftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
629
- sendMessageDraft.mockClear()
630
-
631
- stream.forceNewMessage()
632
- stream.stop()
633
- await flushMicrotasks()
634
-
635
- // The stale id must have been cleared by ONE of the two calls
636
- // (forceNewMessage in this design); the new unused id may also
637
- // be cleared by stop() — harmless. The load-bearing invariant
638
- // is "the stale id reaches sendMessageDraft('') somewhere".
639
- const clearedIds = (sendMessageDraft.mock.calls as unknown as Array<[string, number, string, unknown]>)
640
- .filter(c => c[2] === '')
641
- .map(c => c[1])
642
- expect(clearedIds).toContain(staleDraftId)
643
- })
644
- })
645
-
646
311
  describe('answer-stream — empty / whitespace-only text is a no-op', () => {
647
312
  it('update("") does not trigger any transport call', async () => {
648
313
  const sendMessage = makeSendMessage()
649
314
  const editMessageText = makeEditMessageText()
650
315
  const stream = createAnswerStream({
651
316
  chatId: 'chat1',
652
- isPrivateChat: false,
653
317
  minInitialChars: 0,
654
318
  throttleMs: 250,
655
319
  sendMessage,
@@ -669,7 +333,6 @@ describe('answer-stream — empty / whitespace-only text is a no-op', () => {
669
333
  const editMessageText = makeEditMessageText()
670
334
  const stream = createAnswerStream({
671
335
  chatId: 'chat1',
672
- isPrivateChat: false,
673
336
  minInitialChars: 0,
674
337
  throttleMs: 250,
675
338
  sendMessage,
@@ -691,7 +354,6 @@ describe('answer-stream — maxChars guard', () => {
691
354
  const editMessageText = makeEditMessageText()
692
355
  const stream = createAnswerStream({
693
356
  chatId: 'chat1',
694
- isPrivateChat: false,
695
357
  minInitialChars: 0,
696
358
  throttleMs: 250,
697
359
  sendMessage,
@@ -708,40 +370,6 @@ describe('answer-stream — maxChars guard', () => {
708
370
  })
709
371
  })
710
372
 
711
- describe('answer-stream — materialize() on draft transport', () => {
712
- it('clears the draft and sends a fresh sendMessage on materialize', async () => {
713
- const sendMessage = makeSendMessage()
714
- const editMessageText = makeEditMessageText()
715
- const sendMessageDraft = makeSendMessageDraft()
716
- const stream = createAnswerStream({
717
- chatId: 'chat1',
718
- isPrivateChat: true,
719
- minInitialChars: 0,
720
- throttleMs: 250,
721
- sendMessage,
722
- editMessageText,
723
- sendMessageDraft,
724
- })
725
-
726
- stream.update('hello world')
727
- vi.advanceTimersByTime(500)
728
- await flushMicrotasks()
729
-
730
- expect(sendMessageDraft).toHaveBeenCalled()
731
- const draftCallsBeforeMaterialize = sendMessageDraft.mock.calls.length
732
-
733
- const finalId = await stream.materialize()
734
-
735
- expect(sendMessage).toHaveBeenCalledTimes(1)
736
- expect(sendMessage.mock.calls[0][1]).toBe('hello world')
737
- expect(typeof finalId).toBe('number')
738
-
739
- expect(sendMessageDraft.mock.calls.length).toBeGreaterThan(draftCallsBeforeMaterialize)
740
- const lastDraftCall = sendMessageDraft.mock.calls[sendMessageDraft.mock.calls.length - 1]
741
- expect(lastDraftCall[2]).toBe('')
742
- })
743
- })
744
-
745
373
  describe('answer-stream — onSuperseded callback', () => {
746
374
  it('invokes onSuperseded when a send resolves after forceNewMessage', async () => {
747
375
  const onSuperseded = vi.fn()
@@ -757,7 +385,6 @@ describe('answer-stream — onSuperseded callback', () => {
757
385
  const editMessageText = makeEditMessageText()
758
386
  const stream = createAnswerStream({
759
387
  chatId: 'chat1',
760
- isPrivateChat: false,
761
388
  minInitialChars: 0,
762
389
  throttleMs: 250,
763
390
  sendMessage,
@@ -793,7 +420,6 @@ describe('answer-stream — materialize() max-chars guard', () => {
793
420
  const warn = vi.fn()
794
421
  const stream = createAnswerStream({
795
422
  chatId: 'chat1',
796
- isPrivateChat: false,
797
423
  minInitialChars: 0,
798
424
  throttleMs: 250,
799
425
  sendMessage,
@@ -824,7 +450,6 @@ describe('answer-stream — retract() (#251)', () => {
824
450
  const deleteMessage = vi.fn(async () => {})
825
451
  const stream = createAnswerStream({
826
452
  chatId: 'chat251',
827
- isPrivateChat: false,
828
453
  minInitialChars: 0,
829
454
  throttleMs: 250,
830
455
  sendMessage,
@@ -858,7 +483,6 @@ describe('answer-stream — retract() (#251)', () => {
858
483
  const deleteMessage = vi.fn(async () => {})
859
484
  const stream = createAnswerStream({
860
485
  chatId: 'chat251',
861
- isPrivateChat: false,
862
486
  // High threshold so the text below never triggers a send
863
487
  minInitialChars: 5000,
864
488
  throttleMs: 250,
@@ -887,7 +511,6 @@ describe('answer-stream — retract() (#251)', () => {
887
511
  const deleteMessage = vi.fn(async () => {})
888
512
  const stream = createAnswerStream({
889
513
  chatId: 'chat251',
890
- isPrivateChat: false,
891
514
  minInitialChars: 0,
892
515
  throttleMs: 250,
893
516
  sendMessage,
@@ -923,7 +546,6 @@ describe('answer-stream — retract() (#251)', () => {
923
546
  const warn = vi.fn()
924
547
  const stream = createAnswerStream({
925
548
  chatId: 'chat251',
926
- isPrivateChat: false,
927
549
  minInitialChars: 0,
928
550
  throttleMs: 250,
929
551
  sendMessage,
@@ -949,7 +571,6 @@ describe('answer-stream — retract() (#251)', () => {
949
571
  const editMessageText = makeEditMessageText()
950
572
  const stream = createAnswerStream({
951
573
  chatId: 'chat251',
952
- isPrivateChat: false,
953
574
  minInitialChars: 0,
954
575
  throttleMs: 250,
955
576
  sendMessage,
@@ -968,13 +589,12 @@ describe('answer-stream — retract() (#251)', () => {
968
589
 
969
590
  // ─── Issue #203: onMetric callback ──────────────────────────────────────────
970
591
  describe('answer-stream — onMetric callback (#203)', () => {
971
- it('fires answer_lane_update on first sendMessage (non-DM, message transport)', async () => {
592
+ it('fires answer_lane_update on first sendMessage (message transport)', async () => {
972
593
  const onMetric = vi.fn()
973
594
  const sendMessage = makeSendMessage()
974
595
  const editMessageText = makeEditMessageText()
975
596
  const stream = createAnswerStream({
976
597
  chatId: 'chatX',
977
- isPrivateChat: false,
978
598
  minInitialChars: 0,
979
599
  throttleMs: 250,
980
600
  sendMessage,
@@ -1001,7 +621,6 @@ describe('answer-stream — onMetric callback (#203)', () => {
1001
621
  const editMessageText = makeEditMessageText()
1002
622
  const stream = createAnswerStream({
1003
623
  chatId: 'chatX',
1004
- isPrivateChat: false,
1005
624
  minInitialChars: 0,
1006
625
  throttleMs: 250,
1007
626
  sendMessage,
@@ -1022,39 +641,12 @@ describe('answer-stream — onMetric callback (#203)', () => {
1022
641
  expect(transports).toContain('edit')
1023
642
  })
1024
643
 
1025
- it('fires answer_lane_update on draft transport for DMs', async () => {
1026
- const onMetric = vi.fn()
1027
- const sendMessage = makeSendMessage()
1028
- const editMessageText = makeEditMessageText()
1029
- const sendMessageDraft = makeSendMessageDraft()
1030
- const stream = createAnswerStream({
1031
- chatId: 'chatX',
1032
- isPrivateChat: true,
1033
- minInitialChars: 0,
1034
- throttleMs: 250,
1035
- sendMessage,
1036
- editMessageText,
1037
- sendMessageDraft,
1038
- onMetric,
1039
- })
1040
-
1041
- stream.update('streaming via draft')
1042
- vi.advanceTimersByTime(500)
1043
- await flushMicrotasks()
1044
-
1045
- const draftEvents = onMetric.mock.calls
1046
- .map((c) => c[0] as { kind: string; transport?: string })
1047
- .filter((ev) => ev.kind === 'answer_lane_update' && ev.transport === 'draft')
1048
- expect(draftEvents.length).toBeGreaterThan(0)
1049
- })
1050
-
1051
644
  it('fires answer_lane_materialized on materialize success', async () => {
1052
645
  const onMetric = vi.fn()
1053
646
  const sendMessage = makeSendMessage()
1054
647
  const editMessageText = makeEditMessageText()
1055
648
  const stream = createAnswerStream({
1056
649
  chatId: 'chatX',
1057
- isPrivateChat: false,
1058
650
  minInitialChars: 0,
1059
651
  throttleMs: 250,
1060
652
  sendMessage,
@@ -1083,7 +675,6 @@ describe('answer-stream — onMetric callback (#203)', () => {
1083
675
  const editMessageText = makeEditMessageText()
1084
676
  const stream = createAnswerStream({
1085
677
  chatId: 'chatX',
1086
- isPrivateChat: false,
1087
678
  minInitialChars: 0,
1088
679
  throttleMs: 250,
1089
680
  sendMessage,