switchroom 0.13.10 → 0.13.12

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 (25) hide show
  1. package/dist/cli/switchroom.js +2 -2
  2. package/package.json +1 -1
  3. package/telegram-plugin/dist/bridge/bridge.js +23 -4
  4. package/telegram-plugin/dist/gateway/gateway.js +51 -74
  5. package/telegram-plugin/dist/server.js +23 -4
  6. package/telegram-plugin/gateway/gateway.ts +44 -78
  7. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +82 -0
  8. package/telegram-plugin/model-unavailable.ts +11 -1
  9. package/telegram-plugin/operator-events.fixtures.json +14 -24
  10. package/telegram-plugin/operator-events.ts +11 -2
  11. package/telegram-plugin/session-tail.ts +71 -4
  12. package/telegram-plugin/subagent-watcher.ts +13 -20
  13. package/telegram-plugin/tests/fleet-state-watcher.test.ts +0 -1
  14. package/telegram-plugin/tests/model-unavailable.test.ts +15 -2
  15. package/telegram-plugin/tests/operator-events-session-tail.test.ts +53 -2
  16. package/telegram-plugin/tests/operator-events.test.ts +14 -7
  17. package/telegram-plugin/tests/subagent-handback-decision.test.ts +112 -0
  18. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +1 -3
  19. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +0 -1
  20. package/telegram-plugin/tests/subagent-watcher-parent-marker.test.ts +0 -1
  21. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +1 -4
  22. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +0 -1
  23. package/telegram-plugin/tests/subagent-watcher.test.ts +15 -5
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +29 -81
  25. package/telegram-plugin/turn-flush-safety.ts +23 -53
@@ -198,7 +198,10 @@ function makeHarness(opts: {
198
198
 
199
199
  const watcher = startSubagentWatcher({
200
200
  agentDir,
201
- sendNotification: (text) => notifications.push(text),
201
+ // Card retired (#1122): completion surfaces via onFinish, not a
202
+ // user-facing message. Capture it so the completion assertions still
203
+ // verify the terminal-transition + de-dup behaviour.
204
+ onFinish: (info) => notifications.push(`✓ Worker done: ${info.description}`),
202
205
  stallThresholdMs,
203
206
  // Mirror the active-loop threshold so existing fixtures (which have
204
207
  // toolCount=0 and use the simple "advance past N" model) keep
@@ -382,8 +385,13 @@ describe('startSubagentWatcher', () => {
382
385
  let nextRef = 1
383
386
  const watcher = startSubagentWatcher({
384
387
  agentDir: opts.agentDir,
385
- sendNotification: (text) => notifications.push(text),
386
- ...(opts.onFinish ? { onFinish: opts.onFinish } : {}),
388
+ // Card retired (#1122): completion surfaces via onFinish. Capture
389
+ // it for the completion assertions and still delegate to any
390
+ // test-supplied onFinish.
391
+ onFinish: (info) => {
392
+ notifications.push(`✓ Worker done: ${info.description}`)
393
+ opts.onFinish?.(info)
394
+ },
387
395
  stallThresholdMs: 60_000,
388
396
  rescanMs: 500,
389
397
  now: () => Date.now(),
@@ -994,7 +1002,8 @@ describe('startSubagentWatcher', () => {
994
1002
  const watcher = startSubagentWatcher({
995
1003
  agentDir: opts.agentDir,
996
1004
  ...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
997
- sendNotification: (text) => notifications.push(text),
1005
+ // Card retired (#1122): completion surfaces via onFinish.
1006
+ onFinish: (info) => notifications.push(`✓ Worker done: ${info.description}`),
998
1007
  stallThresholdMs: 60_000,
999
1008
  rescanMs: 500,
1000
1009
  now: () => Date.now(),
@@ -1133,7 +1142,8 @@ describe('startSubagentWatcher', () => {
1133
1142
  let nextRef = 1
1134
1143
  const watcher = startSubagentWatcher({
1135
1144
  agentDir,
1136
- sendNotification: (text) => notifications.push(text),
1145
+ // Card retired (#1122): completion surfaces via onFinish.
1146
+ onFinish: (info) => notifications.push(`✓ Worker done: ${info.description}`),
1137
1147
  stallThresholdMs: 60_000,
1138
1148
  rescanMs: 500,
1139
1149
  now: () => Date.now(),
@@ -138,112 +138,60 @@ describe('decideTurnFlush', () => {
138
138
  ).toEqual({ kind: 'skip', reason: 'reply-called' })
139
139
  })
140
140
 
141
- // #1291 when the model emits a soft-commit reply ("on it, back in a
142
- // few") and then composes the real substantive answer in terminal text
143
- // only, the pre-#1291 behaviour skipped flush entirely because
144
- // replyCalled was true. The fix: track capturedTextLenAtLastReply and
145
- // flush the post-reply tail when it meets the substantive threshold.
146
- describe('#1291 post-reply tail flush', () => {
147
- it('flushes the post-reply tail when it meets the substantive threshold', () => {
141
+ // The turn-flush safety net covers exactly one failure mode: a turn that
142
+ // ended with the model never having said anything. Once the model has
143
+ // called reply / stream_reply the turn is served any assistant text it
144
+ // emits afterwards is its own end-of-turn wrap-up (a closing summary,
145
+ // narration to itself), NOT a message it chose to send. The framework
146
+ // must never promote that terminal text into a second Telegram bubble.
147
+ //
148
+ // Regression guard for the redundant-follow-up-message fix: this reverts
149
+ // the #1291 post-reply-tail flush, which posted a duplicate recap on
150
+ // essentially every turn because the model habitually writes a closing
151
+ // summary after its final reply. See reference/conversational-pacing.md
152
+ // — "the framework owns the beat; the model authors the words".
153
+ describe('reply-called turns never flush trailing terminal text', () => {
154
+ it('skips even when a long substantive tail follows the reply', () => {
148
155
  const decision = decideTurnFlush({
149
156
  chatId: '700',
150
157
  replyCalled: true,
151
- // Index 0 = the captured text BEFORE the reply tool was called
152
- // (some thinking-as-text). Indices 1..2 are post-reply.
153
158
  capturedText: [
154
159
  'thinking out loud before the reply',
155
- 'Now here is the actual substantive answer the model composed ',
156
- 'in terminal text only after the interim reply call.',
160
+ 'Answered the Playwright question and acked the calendar ' +
161
+ 'diagnosis is still in flight. Will surface the root cause ' +
162
+ 'when the worker returns.',
157
163
  ],
158
- capturedTextLenAtLastReply: 1,
159
- })
160
- expect(decision).toEqual({
161
- kind: 'flush',
162
- text:
163
- 'Now here is the actual substantive answer the model composed ' +
164
- '\nin terminal text only after the interim reply call.',
165
- })
166
- })
167
-
168
- it('skips with reply-called-no-new-text when post-reply tail is below threshold', () => {
169
- const decision = decideTurnFlush({
170
- chatId: '701',
171
- replyCalled: true,
172
- capturedText: ['the pre-reply scratch', 'ok.'], // tail = "ok." (3 chars)
173
- capturedTextLenAtLastReply: 1,
174
- })
175
- expect(decision).toEqual({
176
- kind: 'skip',
177
- reason: 'reply-called-no-new-text',
178
- })
179
- })
180
-
181
- it('skips with reply-called when there is no post-reply text at all', () => {
182
- const decision = decideTurnFlush({
183
- chatId: '702',
184
- replyCalled: true,
185
- capturedText: ['everything-was-before-the-reply'],
186
- capturedTextLenAtLastReply: 1, // tail slice is empty
187
164
  })
188
165
  expect(decision).toEqual({ kind: 'skip', reason: 'reply-called' })
189
166
  })
190
167
 
191
- it('post-reply tail honors a silent marker (skip)', () => {
168
+ it('skips regardless of how many text blocks trail the reply', () => {
192
169
  const decision = decideTurnFlush({
193
- chatId: '703',
194
- replyCalled: true,
195
- capturedText: ['real answer pre-reply', 'NO_REPLY'],
196
- capturedTextLenAtLastReply: 1,
197
- replyCalledTailMinChars: 1, // force the marker check
198
- })
199
- expect(decision).toEqual({ kind: 'skip', reason: 'silent-marker' })
200
- })
201
-
202
- it('post-reply tail with null chatId still skips (no-inbound-chat)', () => {
203
- const decision = decideTurnFlush({
204
- chatId: null,
170
+ chatId: '701',
205
171
  replyCalled: true,
206
172
  capturedText: [
207
- 'pre',
208
- 'this tail would have been substantive enough to flush normally',
173
+ 'a substantive paragraph the model wrote as terminal text',
174
+ 'and another one, each well over any old length threshold',
175
+ 'and a third closing summary block for good measure',
209
176
  ],
210
- capturedTextLenAtLastReply: 1,
211
- })
212
- expect(decision).toEqual({ kind: 'skip', reason: 'no-inbound-chat' })
213
- })
214
-
215
- it('preserves pre-#1291 behaviour when capturedTextLenAtLastReply is omitted', () => {
216
- // Legacy caller doesn't track the marker — defaults to
217
- // capturedText.length, so the tail slice is empty and we skip
218
- // with reason 'reply-called' (the original behaviour).
219
- const decision = decideTurnFlush({
220
- chatId: '704',
221
- replyCalled: true,
222
- capturedText: ['some answer the model emitted'],
223
177
  })
224
178
  expect(decision).toEqual({ kind: 'skip', reason: 'reply-called' })
225
179
  })
226
180
 
227
- it('respects a custom replyCalledTailMinChars threshold', () => {
181
+ it('skips with reply-called when capturedText is empty', () => {
228
182
  const decision = decideTurnFlush({
229
- chatId: '705',
183
+ chatId: '702',
230
184
  replyCalled: true,
231
- capturedText: ['pre-reply', 'short but substantive in this test'],
232
- capturedTextLenAtLastReply: 1,
233
- replyCalledTailMinChars: 10,
185
+ capturedText: [],
234
186
  })
235
- expect(decision.kind).toBe('flush')
187
+ expect(decision).toEqual({ kind: 'skip', reason: 'reply-called' })
236
188
  })
237
189
 
238
- it('feature flag off still wins over post-reply tail flush', () => {
190
+ it('feature flag off still wins over a reply-called turn', () => {
239
191
  const decision = decideTurnFlush({
240
- chatId: '706',
192
+ chatId: '703',
241
193
  replyCalled: true,
242
- capturedText: [
243
- 'pre',
244
- 'a long substantive post-reply tail that would otherwise flush',
245
- ],
246
- capturedTextLenAtLastReply: 1,
194
+ capturedText: ['a long substantive tail that pre-fix would flush'],
247
195
  flushEnabled: false,
248
196
  })
249
197
  expect(decision).toEqual({ kind: 'skip', reason: 'flag-disabled' })
@@ -57,7 +57,6 @@ export type FlushDecision =
57
57
  export type FlushSkipReason =
58
58
  | 'flag-disabled'
59
59
  | 'reply-called'
60
- | 'reply-called-no-new-text'
61
60
  | 'no-inbound-chat'
62
61
  | 'empty-text'
63
62
  | 'silent-marker'
@@ -70,35 +69,14 @@ export interface FlushDecisionInput {
70
69
  * this turn. */
71
70
  replyCalled: boolean
72
71
  /** Raw text content blocks accumulated from assistant events across the
73
- * turn. Joined + trimmed internally. */
72
+ * turn. Joined + trimmed internally. Only consulted when `replyCalled`
73
+ * is false — once the model has called reply / stream_reply the turn is
74
+ * served and trailing terminal text is dropped (see `decideTurnFlush`). */
74
75
  capturedText: string[]
75
- /** Snapshot of `capturedText.length` at the moment of the most recent
76
- * reply / stream_reply tool call in this turn. Indices `[capturedText
77
- * length-at-last-reply, capturedText.length)` are the post-reply tail
78
- * — substantive content the model emitted AFTER the reply (e.g. soft
79
- * commit "on it, back in a few" followed by the real answer in
80
- * terminal text only, the #1291 repro). When the tail meets
81
- * `replyCalledTailMinChars` we flush it; otherwise we skip.
82
- *
83
- * Defaults to `capturedText.length` (treat all captured text as
84
- * pre-reply, preserve the pre-#1291 behaviour where any reply tool
85
- * call suppressed flush entirely) so callers that don't track the
86
- * marker keep the old contract. */
87
- capturedTextLenAtLastReply?: number
88
- /** Minimum trimmed-tail length to qualify a post-reply tail flush.
89
- * Defaults to `REPLY_CALLED_TAIL_MIN_CHARS` (40). Below this we skip
90
- * with `reply-called-no-new-text` — typical for trailing markdown
91
- * artifacts or a one-word afterthought. */
92
- replyCalledTailMinChars?: number
93
76
  /** Feature flag — defaults to true. Pass `false` to force skip everywhere. */
94
77
  flushEnabled?: boolean
95
78
  }
96
79
 
97
- /** Default minimum trimmed length for the post-reply tail to be flushed
98
- * as a follow-up message. Below this we treat the tail as noise / artifact
99
- * and skip silently. */
100
- export const REPLY_CALLED_TAIL_MIN_CHARS = 40
101
-
102
80
  /**
103
81
  * Pure decision: should the gateway deterministically send the model's
104
82
  * captured assistant text at turn_end? Returns `{kind: 'flush', text}` with
@@ -107,39 +85,31 @@ export const REPLY_CALLED_TAIL_MIN_CHARS = 40
107
85
  * Ordering of checks is deliberate: cheapest/strongest first so logs
108
86
  * attribute a skip to the most specific cause.
109
87
  *
110
- * #1291 when `replyCalled` is true we no longer suppress unconditionally.
111
- * The model may have emitted a soft-commit reply ("on it, back in a few")
112
- * followed by the real substantive answer in terminal text only. Using
113
- * `capturedTextLenAtLastReply` we isolate the post-reply tail and flush
114
- * it if it's substantive enough; otherwise we skip with
115
- * `reply-called-no-new-text` (logged) or `reply-called` (silent, no tail).
88
+ * The safety net has exactly one job: a turn that ended with the model
89
+ * having said *nothing* to the user. Once `replyCalled` is true the model
90
+ * has communicated through the proper channel and the decision is always
91
+ * `skip` assistant text emitted after a reply is the model's own
92
+ * end-of-turn wrap-up (a closing summary, narration to itself), not a
93
+ * message it chose to send. Promoting that terminal text into a Telegram
94
+ * message second-guesses an explicit reply and posts a redundant duplicate
95
+ * on essentially every turn, because the model habitually writes a closing
96
+ * summary. The framework owns the *beat*; the model authors the *words*
97
+ * and emits them via reply (`reference/conversational-pacing.md`).
98
+ *
99
+ * (This reverts the #1291 post-reply-tail flush. Its intent — catch a
100
+ * soft-commit reply followed by the real answer in terminal text only —
101
+ * could not be told apart from the habitual wrap-up by length, so it
102
+ * misfired constantly. A model that soft-commits and never delivers is a
103
+ * pacing failure caught by the silence-poke ladder, not papered over here.)
116
104
  */
117
105
  export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
118
106
  const flushEnabled = input.flushEnabled !== false
119
107
  if (!flushEnabled) return { kind: 'skip', reason: 'flag-disabled' }
120
108
 
121
- if (input.replyCalled) {
122
- const tailIdx = input.capturedTextLenAtLastReply ?? input.capturedText.length
123
- const tail = input.capturedText.slice(tailIdx).join('\n').trim()
124
- const minChars = input.replyCalledTailMinChars ?? REPLY_CALLED_TAIL_MIN_CHARS
125
- if (tail.length === 0) {
126
- // The reply tool was called and nothing of substance came after —
127
- // the turn is fully served by the reply. Skip silently (the gateway
128
- // WARN gate excludes this reason from logs).
129
- return { kind: 'skip', reason: 'reply-called' }
130
- }
131
- if (tail.length < minChars) {
132
- // Post-reply tail exists but is below the substantive-content
133
- // threshold — typically trailing markdown artifacts or a one-word
134
- // afterthought. Skip but with a distinct reason so this case IS
135
- // logged (auditable for #1291 regressions, vs the silent
136
- // 'reply-called' which is the expected steady state).
137
- return { kind: 'skip', reason: 'reply-called-no-new-text' }
138
- }
139
- if (input.chatId == null) return { kind: 'skip', reason: 'no-inbound-chat' }
140
- if (isSilentFlushMarker(tail)) return { kind: 'skip', reason: 'silent-marker' }
141
- return { kind: 'flush', text: tail }
142
- }
109
+ // The model communicated through the proper channel — trust it. Any
110
+ // assistant text it emitted as terminal text afterwards is its own
111
+ // end-of-turn wrap-up, never a second Telegram message.
112
+ if (input.replyCalled) return { kind: 'skip', reason: 'reply-called' }
143
113
 
144
114
  if (input.chatId == null) return { kind: 'skip', reason: 'no-inbound-chat' }
145
115
  const joined = input.capturedText.join('\n').trim()