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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  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/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -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
@@ -3,10 +3,14 @@
3
3
  * opt-in only on a truthy value. Guards against an accidental flip back to
4
4
  * default-on (which would reintroduce the unformatted-preliminary flash +
5
5
  * delete-on-every-reply — see the gateway gate comment).
6
+ *
7
+ * The draft transport (sendMessageDraft) is permanently retired — the lane is
8
+ * now either VISIBLE (opt-in) or DORMANT (the unconditional default). The
9
+ * resolveAnswerLaneConfig 2-state enumeration is the regression guard.
6
10
  */
7
11
 
8
12
  import { describe, it, expect } from 'vitest'
9
- import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
13
+ import { parseVisibleAnswerStreamEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
10
14
 
11
15
  describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
12
16
  it('defaults OFF when unset', () => {
@@ -26,47 +30,24 @@ describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
26
30
  })
27
31
  })
28
32
 
29
- describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-switch off', () => {
30
- it('defaults to RETIRED (true) when unset — the draft lane is gone by default', () => {
31
- expect(parseDraftLaneRetiredEnabled(undefined)).toBe(true)
32
- })
33
-
34
- it('stays RETIRED for any non-disable value (including unrecognized)', () => {
35
- for (const v of ['1', 'true', 'on', 'yes', '', ' ', 'whatever', 'retired']) {
36
- expect(parseDraftLaneRetiredEnabled(v)).toBe(true)
37
- }
38
- })
39
-
40
- it('restores the legacy draft (false) ONLY on an explicit disable (case/space-insensitive)', () => {
41
- for (const v of ['0', 'false', 'off', 'no', ' FALSE ', 'Off', 'NO']) {
42
- expect(parseDraftLaneRetiredEnabled(v)).toBe(false)
43
- }
44
- })
45
- })
46
-
47
33
  // ── resolveAnswerLaneConfig — TOTAL-ENUMERATION REGRESSION PROOF ─────────────
48
34
  //
49
- // This is the behavioural guard for the flash regression (the gateway IIFE is
50
- // not importable, so the decision lives in this pure function and the gateway
51
- // delegates to it). The input space is finite visibleEnabled × draftFnAvailable
52
- // = 4 so we enumerate ALL of it and assert the full decision table plus the
53
- // load-bearing INVARIANT: opensVisiblePreview === visibleEnabled, ALWAYS. That
54
- // invariant is exactly what v0.14.68 broke (it made the preview depend on the
55
- // draft flag), so a future change that re-conflates them fails here, not in prod.
35
+ // The draft transport is permanently retired. The input space is now a single
36
+ // boolean (visibleEnabled), yielding exactly 2 states: visible or dormant.
37
+ // We enumerate ALL of it and assert the full decision table plus the load-bearing
38
+ // INVARIANT: opensVisiblePreview === visibleEnabled, ALWAYS.
56
39
  describe('resolveAnswerLaneConfig — total enumeration (flash-regression proof)', () => {
57
40
  const MAX = Number.MAX_SAFE_INTEGER
58
41
  const ALL = [
59
- { visibleEnabled: false, draftFnAvailable: false }, // the DEFAULT (visible off, draft retired)
60
- { visibleEnabled: false, draftFnAvailable: true }, // draft kill switch on
61
- { visibleEnabled: true, draftFnAvailable: false }, // opt-in visible
62
- { visibleEnabled: true, draftFnAvailable: true }, // visible wins over draft
42
+ { visibleEnabled: false }, // the DEFAULT (visible off, draft permanently retired → dormant)
43
+ { visibleEnabled: true }, // opt-in visible
63
44
  ]
64
45
 
65
- it('the input space is exactly 4 rows (2×2)', () => {
66
- expect(ALL.length).toBe(4)
46
+ it('the input space is exactly 2 rows', () => {
47
+ expect(ALL.length).toBe(2)
67
48
  })
68
49
 
69
- it('INVARIANT (the regression guard): opensVisiblePreview === visibleEnabled for EVERY draftFnAvailable', () => {
50
+ it('INVARIANT (the regression guard): opensVisiblePreview === visibleEnabled for EVERY input', () => {
70
51
  for (const input of ALL) {
71
52
  expect(resolveAnswerLaneConfig(input).opensVisiblePreview).toBe(input.visibleEnabled)
72
53
  }
@@ -79,39 +60,25 @@ describe('resolveAnswerLaneConfig — total enumeration (flash-regression proof)
79
60
  }
80
61
  })
81
62
 
82
- it('DEFAULT (visible off, draft retired) → DORMANT: no preview, no draft, MAX gate (no flash)', () => {
83
- expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false })).toEqual({
63
+ it('DEFAULT (visible off) → DORMANT: no preview, MAX gate (no flash)', () => {
64
+ expect(resolveAnswerLaneConfig({ visibleEnabled: false })).toEqual({
84
65
  minInitialChars: MAX,
85
- usesDraftTransport: false,
86
66
  opensVisiblePreview: false,
87
67
  state: 'dormant',
88
68
  })
89
69
  })
90
70
 
91
- it('visible off + draft transport available DRAFT: no visible preview, draft renders', () => {
92
- expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: true })).toEqual({
93
- minInitialChars: MAX,
94
- usesDraftTransport: true,
95
- opensVisiblePreview: false,
96
- state: 'draft',
71
+ it('visible on VISIBLE: preview opens on the first chunk (minChars 1)', () => {
72
+ expect(resolveAnswerLaneConfig({ visibleEnabled: true })).toEqual({
73
+ minInitialChars: 1,
74
+ opensVisiblePreview: true,
75
+ state: 'visible',
97
76
  })
98
77
  })
99
78
 
100
- it('visible on VISIBLE: preview opens on the first chunk (minChars 1), no draft', () => {
101
- for (const draftFnAvailable of [false, true]) {
102
- expect(resolveAnswerLaneConfig({ visibleEnabled: true, draftFnAvailable })).toEqual({
103
- minInitialChars: 1,
104
- usesDraftTransport: false,
105
- opensVisiblePreview: true,
106
- state: 'visible',
107
- })
108
- }
109
- })
110
-
111
- it('a visible preview NEVER opens unless explicitly enabled (no draftFnAvailable forces it on)', () => {
112
- // The exact v0.14.68 failure shape: retiring the draft (draftFnAvailable=false)
113
- // must NOT open a visible preview.
114
- expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).opensVisiblePreview).toBe(false)
115
- expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).minInitialChars).toBe(MAX)
79
+ it('a visible preview NEVER opens unless explicitly enabled', () => {
80
+ // The exact v0.14.68 failure shape: retiring the draft must NOT open a preview.
81
+ expect(resolveAnswerLaneConfig({ visibleEnabled: false }).opensVisiblePreview).toBe(false)
82
+ expect(resolveAnswerLaneConfig({ visibleEnabled: false }).minInitialChars).toBe(MAX)
116
83
  })
117
84
  })
@@ -1,10 +1,22 @@
1
- import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
2
2
 
3
3
  import {
4
4
  createAnswerStream,
5
- __resetDraftIdForTests,
6
5
  } from '../answer-stream.js'
7
6
 
7
+ // Throttle window anchored one hour ahead of "now". With lastSentAt=0 and a
8
+ // real Date.now(), update() computes sinceLast = Date.now() - 0, which is
9
+ // always < this anchor, so update() schedules a (cancelled-before-it-fires)
10
+ // timer and buffers pendingText instead of sending immediately. materialize()
11
+ // then cancels the scheduled timer and applies the silent-marker guard to the
12
+ // buffered text. This replaces the old draft-transport path (which bypassed the
13
+ // length gate). Anchoring to now+1h (rather than MAX_SAFE_INTEGER) keeps the
14
+ // scheduled timer's delay within 32-bit range, so it stays runner-agnostic:
15
+ // no vi.setSystemTime (which bun's vitest shim lacks) and no Node
16
+ // TimeoutOverflowWarning under runners whose fake-timer shim is a no-op.
17
+ const HOUR_MS = 60 * 60 * 1000
18
+ let throttleAnchorMs = 0
19
+
8
20
  // ─── Helpers ──────────────────────────────────────────────────────────────────
9
21
 
10
22
  type SendMessageFn = (
@@ -29,13 +41,6 @@ type EditMessageTextFn = (
29
41
  },
30
42
  ) => Promise<unknown>
31
43
 
32
- type SendMessageDraftFn = (
33
- chatId: string,
34
- draftId: number,
35
- text: string,
36
- params?: { message_thread_id?: number },
37
- ) => Promise<unknown>
38
-
39
44
  let nextMessageId = 9000
40
45
 
41
46
  function makeSendMessage(): ReturnType<typeof vi.fn> & SendMessageFn {
@@ -49,18 +54,9 @@ function makeEditMessageText(): ReturnType<typeof vi.fn> & EditMessageTextFn {
49
54
  return vi.fn(async () => {}) as unknown as ReturnType<typeof vi.fn> & EditMessageTextFn
50
55
  }
51
56
 
52
- function makeSendMessageDraft(): ReturnType<typeof vi.fn> & SendMessageDraftFn {
53
- return vi.fn(async () => {}) as unknown as ReturnType<typeof vi.fn> & SendMessageDraftFn
54
- }
55
-
56
57
  beforeEach(() => {
57
- __resetDraftIdForTests()
58
58
  nextMessageId = 9000
59
- vi.useFakeTimers()
60
- })
61
-
62
- afterEach(() => {
63
- vi.useRealTimers()
59
+ throttleAnchorMs = Date.now() + HOUR_MS
64
60
  })
65
61
 
66
62
  // ─── Tests ────────────────────────────────────────────────────────────────────
@@ -70,9 +66,16 @@ afterEach(() => {
70
66
  * NO_REPLY / HEARTBEAT_OK silent markers and suppress outbound Telegram
71
67
  * messages when the whole turn body is one of those tokens.
72
68
  *
73
- * Root cause: in private chats (DMs), usesDraftTransport=true bypasses the
74
- * minInitialChars length gate in update(), so even short markers like "NO_REPLY"
75
- * (8 chars) reach pendingText and then materialize() sends them as real messages.
69
+ * Previously in DMs the draft transport bypassed the minInitialChars length
70
+ * gate in update(), so short markers like "NO_REPLY" (8 chars) reached
71
+ * pendingText; then materialize() would send them as real messages.
72
+ *
73
+ * The draft transport is permanently retired. These tests now use:
74
+ * - minInitialChars: 0 — bypasses the length gate so update() sets pendingText
75
+ * - throttleMs: throttleAnchorMs (now + 1h) — with lastSentAt=0 and a real
76
+ * Date.now(), sinceLast is always < the throttle window, so update() schedules
77
+ * a timer rather than firing sendMessage immediately. materialize() cancels that
78
+ * timer and applies the silent-marker guard to pendingText before any send.
76
79
  *
77
80
  * Mirrors the sentinel suppression already present in:
78
81
  * - server.ts (reply/stream_reply MCP tool handlers)
@@ -81,26 +84,23 @@ afterEach(() => {
81
84
  */
82
85
  describe('answer-stream — silent-marker suppression at materialize()', () => {
83
86
  it('NO_REPLY as the sole chunk — no outbound message, suppression log line emitted', async () => {
84
- // Use isPrivateChat: true + sendMessageDraft to replicate the exact repro
85
- // conditions from #299: DM chat bypasses the minInitialChars length gate
86
- // in update(), so "NO_REPLY" (8 chars) reaches pendingText and materialize()
87
- // would previously send it as a real Telegram message.
88
87
  const sendMessage = makeSendMessage()
89
88
  const editMessageText = makeEditMessageText()
90
- const sendMessageDraft = makeSendMessageDraft()
91
89
  const logs: string[] = []
92
90
  const stream = createAnswerStream({
93
91
  chatId: 'chat42',
94
- isPrivateChat: true,
95
- throttleMs: 250,
92
+ // minInitialChars: 0 bypasses the length gate so short markers like
93
+ // "NO_REPLY" (8 chars) set pendingText. Combined with vi.setSystemTime(0),
94
+ // the throttle keeps update() from firing sendMessage immediately, so
95
+ // materialize() can apply the silent-marker guard.
96
+ minInitialChars: 0,
97
+ throttleMs: throttleAnchorMs,
96
98
  sendMessage,
97
99
  editMessageText,
98
- sendMessageDraft,
99
100
  log: (msg) => logs.push(msg),
100
101
  })
101
102
 
102
103
  // Simulate: model emits exactly NO_REPLY, no reply/stream_reply call.
103
- // In a DM, update() bypasses the length gate and sets pendingText.
104
104
  stream.update('NO_REPLY')
105
105
  // materialize() is what gateway.ts calls at turn_end when no tool reply
106
106
  // was made — this is the path that was broken in #299 (msg id=8268).
@@ -115,14 +115,12 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
115
115
  it('HEARTBEAT_OK as the sole chunk — no outbound message', async () => {
116
116
  const sendMessage = makeSendMessage()
117
117
  const editMessageText = makeEditMessageText()
118
- const sendMessageDraft = makeSendMessageDraft()
119
118
  const stream = createAnswerStream({
120
119
  chatId: 'chat43',
121
- isPrivateChat: true,
122
- throttleMs: 250,
120
+ minInitialChars: 0,
121
+ throttleMs: throttleAnchorMs,
123
122
  sendMessage,
124
123
  editMessageText,
125
- sendMessageDraft,
126
124
  })
127
125
 
128
126
  stream.update('HEARTBEAT_OK')
@@ -135,15 +133,13 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
135
133
  it('NO_REPLY. (trailing period) — suppressed by trailing-punctuation tolerance', async () => {
136
134
  const sendMessage = makeSendMessage()
137
135
  const editMessageText = makeEditMessageText()
138
- const sendMessageDraft = makeSendMessageDraft()
139
136
  const logs: string[] = []
140
137
  const stream = createAnswerStream({
141
138
  chatId: 'chat44',
142
- isPrivateChat: true,
143
- throttleMs: 250,
139
+ minInitialChars: 0,
140
+ throttleMs: throttleAnchorMs,
144
141
  sendMessage,
145
142
  editMessageText,
146
- sendMessageDraft,
147
143
  log: (msg) => logs.push(msg),
148
144
  })
149
145
 
@@ -158,22 +154,19 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
158
154
  it('substring match ("the agent suggested NO_REPLY earlier") — NOT suppressed, materialises normally', async () => {
159
155
  const sendMessage = makeSendMessage()
160
156
  const editMessageText = makeEditMessageText()
161
- const sendMessageDraft = makeSendMessageDraft()
162
157
  const stream = createAnswerStream({
163
158
  chatId: 'chat45',
164
- isPrivateChat: true,
165
- throttleMs: 250,
159
+ minInitialChars: 0,
160
+ throttleMs: throttleAnchorMs,
166
161
  sendMessage,
167
162
  editMessageText,
168
- sendMessageDraft,
169
163
  })
170
164
 
171
165
  const prose = 'the agent suggested NO_REPLY earlier'
172
166
  stream.update(prose)
173
- // materialize() should send a fresh message — only 1 call, from materialize
174
- // itself (update() in draft mode sends a draft, not a sendMessage call).
175
167
  const msgId = await stream.materialize()
176
168
 
169
+ // materialize() sends a fresh message — prose is not a silent marker
177
170
  expect(sendMessage).toHaveBeenCalledTimes(1)
178
171
  expect(sendMessage).toHaveBeenCalledWith(
179
172
  'chat45',
@@ -190,15 +183,13 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
190
183
  // not on any intermediate chunk.
191
184
  const sendMessage = makeSendMessage()
192
185
  const editMessageText = makeEditMessageText()
193
- const sendMessageDraft = makeSendMessageDraft()
194
186
  const logs: string[] = []
195
187
  const stream = createAnswerStream({
196
188
  chatId: 'chat47',
197
- isPrivateChat: true,
198
- throttleMs: 250,
189
+ minInitialChars: 0,
190
+ throttleMs: throttleAnchorMs,
199
191
  sendMessage,
200
192
  editMessageText,
201
- sendMessageDraft,
202
193
  log: (msg) => logs.push(msg),
203
194
  })
204
195
 
@@ -218,8 +209,7 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
218
209
  const logs: string[] = []
219
210
  const stream = createAnswerStream({
220
211
  chatId: 'chat46',
221
- isPrivateChat: false,
222
- throttleMs: 250,
212
+ throttleMs: throttleAnchorMs,
223
213
  sendMessage,
224
214
  editMessageText,
225
215
  log: (msg) => logs.push(msg),