switchroom 0.14.42 → 0.14.44

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/gateway/gateway.js +183 -17
  4. package/telegram-plugin/gateway/gateway.ts +100 -29
  5. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +22 -0
  6. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +13 -0
  7. package/telegram-plugin/gateway/turn-state-purge.ts +14 -0
  8. package/telegram-plugin/silence-poke.ts +26 -0
  9. package/telegram-plugin/status-reactions.ts +14 -0
  10. package/telegram-plugin/subagent-watcher.ts +44 -0
  11. package/telegram-plugin/tests/silence-poke.test.ts +36 -0
  12. package/telegram-plugin/tests/status-reactions.test.ts +16 -0
  13. package/telegram-plugin/tests/subagent-handback-decision.test.ts +32 -0
  14. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +35 -0
  15. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +56 -0
  16. package/telegram-plugin/tests/subagent-watcher.test.ts +42 -0
  17. package/telegram-plugin/tests/turn-state-purge.test.ts +28 -0
  18. package/telegram-plugin/uat/driver.ts +41 -0
  19. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +17 -10
  20. package/telegram-plugin/uat/scenarios/fuzz-supergroup-channel.test.ts +141 -0
  21. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-channel.test.ts +104 -0
  22. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +9 -7
  23. package/telegram-plugin/uat/scenarios/jtbd-supergroup-handback-channel.test.ts +77 -0
  24. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +102 -0
  25. package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-channel.test.ts +114 -0
@@ -40,6 +40,12 @@ export interface SubagentHandbackContext {
40
40
  /** Telegram chat the work was dispatched from — the synthesized
41
41
  * handback turn lands here so it stays with the conversation. */
42
42
  chatId: string
43
+ /** Supergroup topic (message_thread_id) the work was dispatched from.
44
+ * Carried so the synthesized handback turn — and the model's
45
+ * in-voice "here's what the worker found" reply — land in the
46
+ * originating topic, not the chat's last-seen topic. Omitted for
47
+ * DM-shaped chats (no topics). See `gateway.ts:resolveSubagentOriginChat`. */
48
+ threadId?: number
43
49
  /** Dispatch-time task description (the sub-agent's `description`). */
44
50
  taskDescription: string
45
51
  /** The worker's final result text — its last narrative emission
@@ -98,6 +104,9 @@ export function buildSubagentHandbackInbound(opts: {
98
104
  return {
99
105
  type: 'inbound',
100
106
  chatId: opts.ctx.chatId,
107
+ // Top-level threadId → the enqueued turn's sessionThreadId, so the
108
+ // handback turn's live activity feed routes to the originating topic.
109
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
101
110
  messageId: ts, // synthetic — no Telegram message id exists
102
111
  user: 'subagent-watcher',
103
112
  userId: 0,
@@ -106,6 +115,10 @@ export function buildSubagentHandbackInbound(opts: {
106
115
  meta: {
107
116
  source: 'subagent_handback',
108
117
  outcome: opts.ctx.outcome,
118
+ // meta.message_thread_id is the model-visible channel attribute
119
+ // (mirrors the real-inbound shape) so the model's reply targets
120
+ // the dispatching topic. Mirrors gateway.ts:10557.
121
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
109
122
  ...(opts.ctx.jsonlAgentId ? { subagent_jsonl_id: opts.ctx.jsonlAgentId } : {}),
110
123
  },
111
124
  }
@@ -135,6 +148,10 @@ export interface SubagentHandbackDecisionInput {
135
148
  fleetChatId: string
136
149
  /** Owner chat fallback (access.json allowFrom[0]); '' if none. */
137
150
  ownerChatId: string
151
+ /** Supergroup topic the work was dispatched from (from the parent
152
+ * turn). Applied ONLY when `fleetChatId` resolved (the origin chat
153
+ * won) — the `ownerChatId` DM fallback has no topic. */
154
+ originThreadId?: number
138
155
  taskDescription: string
139
156
  resultText: string
140
157
  /** JSONL filename stem for this Claude Code spawn — forwarded into
@@ -185,9 +202,14 @@ export function decideSubagentHandback(
185
202
  if (!chatId) {
186
203
  return { deliver: false, reason: 'no-chat' }
187
204
  }
205
+ // Thread only when the origin chat (fleetChatId) won — the ownerChatId
206
+ // DM fallback is topic-less, so a stray thread id would mis-address it.
207
+ const threadId =
208
+ input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined
188
209
  const inbound = buildSubagentHandbackInbound({
189
210
  ctx: {
190
211
  chatId,
212
+ ...(threadId != null ? { threadId } : {}),
191
213
  taskDescription: input.taskDescription,
192
214
  resultText: input.resultText,
193
215
  outcome: input.outcome,
@@ -62,6 +62,10 @@ export const DEFAULT_PROGRESS_INTERVAL_MS = 5 * 60 * 1000
62
62
  export interface SubagentProgressContext {
63
63
  /** Telegram chat the work was dispatched from. */
64
64
  chatId: string
65
+ /** Supergroup topic (message_thread_id) the work was dispatched from,
66
+ * so the progress wake-up turn and the model's reply land in the
67
+ * originating topic. Omitted for DM-shaped chats. */
68
+ threadId?: number
65
69
  /** JSONL-derived sub-agent id (stable per Claude Code spawn). Pinned
66
70
  * into the spool id so envelopes for the same worker dedup across
67
71
  * buckets cleanly and survive gateway restarts. */
@@ -125,6 +129,7 @@ export function buildSubagentProgressInbound(opts: {
125
129
  return {
126
130
  type: 'inbound',
127
131
  chatId: opts.ctx.chatId,
132
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
128
133
  messageId: ts, // synthetic — no Telegram message id exists
129
134
  user: 'subagent-watcher',
130
135
  userId: 0,
@@ -132,6 +137,7 @@ export function buildSubagentProgressInbound(opts: {
132
137
  text,
133
138
  meta: {
134
139
  source: 'subagent_progress',
140
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
135
141
  subagent_jsonl_id: opts.ctx.subagentJsonlId,
136
142
  bucket_idx: String(opts.ctx.bucketIdx),
137
143
  expiresAt: String(expiresAt),
@@ -155,6 +161,10 @@ export interface SubagentProgressDecisionInput {
155
161
  fleetChatId: string
156
162
  /** Owner chat fallback (access.json allowFrom[0]); '' if none. */
157
163
  ownerChatId: string
164
+ /** Supergroup topic the work was dispatched from. Applied ONLY when
165
+ * `fleetChatId` resolved (the origin chat won); the DM fallback is
166
+ * topic-less. */
167
+ originThreadId?: number
158
168
  subagentJsonlId: string
159
169
  taskDescription: string
160
170
  latestSummary: string
@@ -240,9 +250,12 @@ export function decideSubagentProgress(
240
250
  if (input.lastBucketIdx != null && bucketIdx <= input.lastBucketIdx) {
241
251
  return { deliver: false, reason: 'bucket-already-fired' }
242
252
  }
253
+ const threadId =
254
+ input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined
243
255
  const inbound = buildSubagentProgressInbound({
244
256
  ctx: {
245
257
  chatId,
258
+ ...(threadId != null ? { threadId } : {}),
246
259
  subagentJsonlId: input.subagentJsonlId,
247
260
  taskDescription: input.taskDescription,
248
261
  latestSummary: input.latestSummary,
@@ -50,6 +50,19 @@ export function purgeStaleTurnsForChat(
50
50
  chatId: string,
51
51
  keys: Iterable<string>,
52
52
  purger: (key: string) => void,
53
+ /**
54
+ * Per-sibling staleness gate. A sibling key for `chatId` is purged only when
55
+ * this returns true. CRITICAL for one-agent-owns-supergroup: all of an
56
+ * agent's forum topics share the SAME chatId, so a chatId-only match would
57
+ * purge a LIVE sibling topic's reaction controller + typing loop when ANOTHER
58
+ * topic's 300s silence-poke fires (the gymbro/klanker wedge class). The
59
+ * caller passes a predicate true only for siblings themselves silent ≥ the
60
+ * fallback threshold (their own poke would also fire) — preserving the #1556
61
+ * dangling-key cleanup while sparing live siblings. Defaults to always-stale
62
+ * for back-compat (DM / single-topic callers, where every sibling is
63
+ * genuinely dangling).
64
+ */
65
+ isStale: (key: string) => boolean = () => true,
53
66
  ): PurgeStaleTurnsResult {
54
67
  if (!chatId) return { purged: [] }
55
68
  const purged: string[] = []
@@ -64,6 +77,7 @@ export function purgeStaleTurnsForChat(
64
77
  if (sep < 0) continue // malformed / non-statusKey shape — skip
65
78
  const keyChat = key.slice(0, sep)
66
79
  if (keyChat !== chatId) continue
80
+ if (!isStale(key)) continue // live sibling topic — leave its turn state intact
67
81
  purger(key)
68
82
  purged.push(key)
69
83
  }
@@ -244,6 +244,23 @@ export function endTurn(key: string): void {
244
244
  state.delete(key)
245
245
  }
246
246
 
247
+ /**
248
+ * Current silence duration (ms) for a key — `now - (lastOutboundAt ??
249
+ * turnStartedAt)`, the same clock `tick()` uses to decide the 300s fallback —
250
+ * or null when no turn state exists for the key. Lets the sibling-topic purge
251
+ * distinguish a STALE/wedged sibling (silent ≥ the fallback threshold, so its
252
+ * own poke would also fire) from a LIVE one mid-turn (recent outbound, low
253
+ * silence), so a silence-poke on one supergroup topic doesn't purge a live
254
+ * sibling topic's reaction controller + typing loop. NB: this is silence, NOT
255
+ * turn-start age — a long but actively-narrating turn has low silence and must
256
+ * not be treated as stale.
257
+ */
258
+ export function silenceMsForKey(key: string, now: number): number | null {
259
+ const s = state.get(key)
260
+ if (s == null) return null
261
+ return now - (s.lastOutboundAt ?? s.turnStartedAt)
262
+ }
263
+
247
264
  /**
248
265
  * Verbatim framework-fallback text — the user-visible "still working / still
249
266
  * thinking" message the gateway sends at the 300s threshold when the model
@@ -264,8 +281,17 @@ export function formatFrameworkFallbackText(
264
281
  fallbackKind: 'working' | 'thinking',
265
282
  silenceMs: number,
266
283
  inFlightTools: ToolSnapshot[] = [],
284
+ blockedOnApproval = false,
267
285
  ): string {
268
286
  const minutes = Math.max(1, Math.round(silenceMs / 60_000))
287
+ // The turn isn't stalled — it's parked on an approval card waiting for YOUR
288
+ // tap (the dominant live "wedge" class is benign approval-latency, not a
289
+ // hang). Saying "still working…" here actively lies; name the real blocker so
290
+ // the operator knows the ball is in their court. Takes precedence over the
291
+ // in-flight-tool framing (a tool awaiting approval isn't "running").
292
+ if (blockedOnApproval) {
293
+ return `waiting for your approval — tap Approve or Deny on the card above (${minutes} min)`
294
+ }
269
295
  const suffix = `(no update from agent in ${minutes} min)`
270
296
  // #1292 case (a): tools in flight. Name the longest-running one
271
297
  // (entry[0] — caller pre-sorts by startedAt ascending). Avoid the
@@ -144,6 +144,10 @@ export class StatusReactionController {
144
144
  private stallHardTimer: ReturnType<typeof setTimeout> | null = null
145
145
  private finished = false
146
146
  private held = false
147
+ // True while parked on the awaiting-approval state (🙏): the turn is blocked
148
+ // on the operator's tap, not stalled. Read by the silence-poke fallback so it
149
+ // says "waiting for your approval" instead of the dishonest "still working…".
150
+ private awaitingApproval = false
147
151
  private readonly debounceMs: number
148
152
  private readonly stallSoftMs: number
149
153
  private readonly stallHardMs: number
@@ -272,11 +276,21 @@ export class StatusReactionController {
272
276
 
273
277
  // ──────────────────────────────────────────────────────────────────────
274
278
 
279
+ /** True while the turn is parked awaiting the operator's approval tap (🙏).
280
+ * The silence-poke fallback reads this to phrase its 300s message honestly
281
+ * ("waiting for your approval") instead of "still working…". */
282
+ isAwaiting(): boolean {
283
+ return this.awaitingApproval && !this.finished
284
+ }
285
+
275
286
  private scheduleState(
276
287
  state: ReactionState,
277
288
  opts: { immediate?: boolean; skipStallReset?: boolean } = {},
278
289
  ): void {
279
290
  if (this.finished) return
291
+ // Track the awaiting-approval state for isAwaiting(). Any non-awaiting
292
+ // state transition (setThinking/setTool/… on verdict resume) clears it.
293
+ this.awaitingApproval = state === 'awaiting'
280
294
  const emoji = this.resolveEmoji(state)
281
295
  if (emoji == null) {
282
296
  if (!opts.skipStallReset) this.resetStallTimers()
@@ -42,6 +42,7 @@ import { basename, join } from 'path'
42
42
  import { homedir } from 'os'
43
43
  import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
44
44
  import { sanitiseToolArg } from './fleet-state.js'
45
+ import { describeToolUse } from './tool-activity-summary.js'
45
46
  import { escapeHtml, truncate } from './card-format.js'
46
47
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
47
48
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
@@ -348,6 +349,13 @@ export interface SubagentWatcherConfig {
348
349
  lastTool: { name: string; sanitisedArg: string } | null
349
350
  /** Tool-use count observed so far. */
350
351
  toolCount: number
352
+ /** Friendly display line for THIS tick. Set on `sub_agent_tool_use`
353
+ * events to a `describeToolUse` label ("Reading X", "Running a
354
+ * command") so a foreground sub-agent that runs tools without
355
+ * emitting prose still surfaces its steps in the parent's nested
356
+ * feed. Undefined on `sub_agent_text` ticks — the gateway falls back
357
+ * to `latestSummary` (the narrative line), preserving prior behavior. */
358
+ progressLine?: string
351
359
  }) => void
352
360
  /** `Date.now` override for tests. */
353
361
  now?: () => number
@@ -645,6 +653,9 @@ export function readSubTail(
645
653
  lastTool: { name: string; sanitisedArg: string } | null
646
654
  /** Tool-use count observed so far. */
647
655
  toolCount: number
656
+ /** Friendly display line for THIS tick (set on tool ticks; see the
657
+ * SubagentWatcherConfig.onProgress doc). */
658
+ progressLine?: string
648
659
  }) => void,
649
660
  ): void {
650
661
  try {
@@ -781,6 +792,39 @@ export function readSubTail(
781
792
  name: ev.toolName,
782
793
  sanitisedArg: sanitiseToolArg(ev.toolName, ev.input ?? {}),
783
794
  }
795
+ // Surface a tool-step progress cue. A foreground sub-agent that
796
+ // runs tools WITHOUT emitting prose (e.g. a researcher reading
797
+ // files) previously produced no onProgress tick at all — only
798
+ // `sub_agent_text` fired it — so its steps never nested under the
799
+ // parent's activity feed (the named foreground blindspot). Fire
800
+ // here too, carrying a friendly `describeToolUse` label as
801
+ // `progressLine` so the gateway can render "Reading X" / "Running
802
+ // a command" the same way the main-turn feed does. `latestSummary`
803
+ // stays the worker's narrative result (never polluted with tool
804
+ // labels — the handback payload depends on it). Pure jsonl-tail →
805
+ // render, no model call.
806
+ if (onProgress != null && entry.state === 'running' && !entry.historical) {
807
+ const toolLine = describeToolUse(ev.toolName, ev.input ?? {})
808
+ if (toolLine != null && toolLine.length > 0) {
809
+ try {
810
+ onProgress({
811
+ agentId: entry.agentId,
812
+ description: entry.description,
813
+ latestSummary: entry.lastResultText,
814
+ elapsedMs: now - entry.dispatchedAt,
815
+ prevBucketIdx: entry.lastProgressBucketIdx,
816
+ setBucketIdx: (b: number) => {
817
+ entry.lastProgressBucketIdx = b
818
+ },
819
+ lastTool: entry.lastTool,
820
+ toolCount: entry.toolCount,
821
+ progressLine: toolLine,
822
+ })
823
+ } catch (cbErr) {
824
+ log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
825
+ }
826
+ }
827
+ }
784
828
  } else if (ev.kind === 'sub_agent_text') {
785
829
  // Do NOT overwrite description with narrative text — description is
786
830
  // set at dispatch time (from the parent Agent/Task tool_use input)
@@ -7,6 +7,7 @@ import {
7
7
  noteToolEnd,
8
8
  noteToolLabel,
9
9
  endTurn,
10
+ silenceMsForKey,
10
11
  silencePokeEnabled,
11
12
  formatFrameworkFallbackText,
12
13
  __tickForTests,
@@ -275,6 +276,26 @@ describe('silence-poke — #1292 tool-aware framework fallback', () => {
275
276
  ).toBe('still working… (no update from agent in 5 min)')
276
277
  })
277
278
 
279
+ it('blockedOnApproval names the real blocker instead of the dishonest "still working…"', () => {
280
+ expect(
281
+ formatFrameworkFallbackText('working', 305_000, [], true),
282
+ ).toBe('waiting for your approval — tap Approve or Deny on the card above (5 min)')
283
+ })
284
+
285
+ it('blockedOnApproval takes precedence over an in-flight tool (a tool awaiting approval is not "running")', () => {
286
+ expect(
287
+ formatFrameworkFallbackText('working', 305_000, [
288
+ { name: 'Bash', label: 'rm -rf build', durationMs: 305_000 },
289
+ ], true),
290
+ ).toBe('waiting for your approval — tap Approve or Deny on the card above (5 min)')
291
+ })
292
+
293
+ it('blockedOnApproval=false keeps the existing wording (default, back-compat)', () => {
294
+ expect(
295
+ formatFrameworkFallbackText('working', 305_000, [], false),
296
+ ).toBe('still working… (no update from agent in 5 min)')
297
+ })
298
+
278
299
  it('tool-aware wording wins over "thinking" — the actual observable beats the inferred kind', () => {
279
300
  const text = formatFrameworkFallbackText('thinking', 305_000, [
280
301
  { name: 'Grep', label: '"foo"', durationMs: 305_000 },
@@ -340,6 +361,21 @@ describe('silence-poke — #1292 tool-aware framework fallback', () => {
340
361
  expect(fx.fallbacks).toHaveLength(1)
341
362
  })
342
363
 
364
+ it('silenceMsForKey reports silence from last outbound (or turn start), null when unknown', () => {
365
+ setupDeps()
366
+ startTurn('k', 1_000)
367
+ // No outbound yet → silence measured from turnStartedAt.
368
+ expect(silenceMsForKey('k', 1_000 + 120_000)).toBe(120_000)
369
+ noteOutbound('k', 1_000 + 50_000)
370
+ // After an outbound → silence measured from lastOutboundAt.
371
+ expect(silenceMsForKey('k', 1_000 + 120_000)).toBe(70_000)
372
+ // Unknown key / ended turn → null (used by the sibling purge to treat a
373
+ // dangling key as stale).
374
+ expect(silenceMsForKey('never-started', 999_999)).toBeNull()
375
+ endTurn('k')
376
+ expect(silenceMsForKey('k', 999_999)).toBeNull()
377
+ })
378
+
343
379
  it('Task tool populates inFlightTools so the fallback names it as the observable', () => {
344
380
  const fx = setupDeps()
345
381
  startTurn('k', 0)
@@ -94,6 +94,22 @@ describe('StatusReactionController', () => {
94
94
  expect(calls).toEqual(['👀'])
95
95
  })
96
96
 
97
+ it('isAwaiting() tracks the awaiting-approval state (for the honest silence-poke copy)', async () => {
98
+ const { emit } = makeEmitter()
99
+ const ctrl = new StatusReactionController(emit)
100
+ expect(ctrl.isAwaiting()).toBe(false)
101
+ ctrl.setAwaiting()
102
+ expect(ctrl.isAwaiting()).toBe(true)
103
+ // The verdict resume (setThinking) un-parks → no longer awaiting.
104
+ ctrl.setThinking()
105
+ expect(ctrl.isAwaiting()).toBe(false)
106
+ // Re-park, then finish → isAwaiting is false once the turn ends.
107
+ ctrl.setAwaiting()
108
+ expect(ctrl.isAwaiting()).toBe(true)
109
+ ctrl.finalize()
110
+ expect(ctrl.isAwaiting()).toBe(false)
111
+ })
112
+
97
113
  it('setThinking is debounced by 3500ms (#1713)', async () => {
98
114
  const { emit, calls } = makeEmitter()
99
115
  const ctrl = new StatusReactionController(emit)
@@ -109,4 +109,36 @@ describe('decideSubagentHandback', () => {
109
109
  expect(d.inbound.text).toContain('Applied 3 migrations')
110
110
  }
111
111
  })
112
+
113
+ // Supergroup topic routing (#status-channel-routing).
114
+ it('threads the inbound to the origin topic when the origin (fleet) chat won', () => {
115
+ const d = decideSubagentHandback({ ...base, fleetChatId: '-100777', originThreadId: 42 })
116
+ expect(d.deliver).toBe(true)
117
+ if (d.deliver) {
118
+ expect(d.chatId).toBe('-100777')
119
+ expect(d.inbound.threadId).toBe(42)
120
+ expect(d.inbound.meta.message_thread_id).toBe('42')
121
+ }
122
+ })
123
+
124
+ it('does NOT thread when falling back to the owner DM (topic-less)', () => {
125
+ // fleetChatId empty → owner DM wins; a stray originThreadId must not
126
+ // be applied to a DM chat that has no topics.
127
+ const d = decideSubagentHandback({ ...base, fleetChatId: '', originThreadId: 42 })
128
+ expect(d.deliver).toBe(true)
129
+ if (d.deliver) {
130
+ expect(d.chatId).toBe('999')
131
+ expect(d.inbound.threadId).toBeUndefined()
132
+ expect(d.inbound.meta.message_thread_id).toBeUndefined()
133
+ }
134
+ })
135
+
136
+ it('omits thread carriers when no originThreadId is supplied (DM-shaped agent)', () => {
137
+ const d = decideSubagentHandback({ ...base, fleetChatId: '777' })
138
+ expect(d.deliver).toBe(true)
139
+ if (d.deliver) {
140
+ expect(d.inbound.threadId).toBeUndefined()
141
+ expect(d.inbound.meta.message_thread_id).toBeUndefined()
142
+ }
143
+ })
112
144
  })
@@ -124,4 +124,39 @@ describe('buildSubagentHandbackInbound', () => {
124
124
  })
125
125
  expect(inbound.text).toContain('(no description)')
126
126
  })
127
+
128
+ // Supergroup topic routing (#status-channel-routing). The handback turn
129
+ // and the model's in-voice reply must land in the topic the work was
130
+ // dispatched from — not the chat's last-seen topic. The carriers are the
131
+ // top-level threadId (→ turn.sessionThreadId, routes the activity feed)
132
+ // and meta.message_thread_id (the model-visible channel attribute,
133
+ // mirrors the real-inbound shape at gateway.ts:10557).
134
+ it('carries top-level threadId AND meta.message_thread_id when ctx.threadId is set', () => {
135
+ const inbound = buildSubagentHandbackInbound({
136
+ ctx: {
137
+ chatId: '-1001234567890',
138
+ threadId: 42,
139
+ taskDescription: 'Research competitors',
140
+ resultText: 'Found 3 relevant comps.',
141
+ outcome: 'completed',
142
+ },
143
+ nowMs: FIXED_NOW,
144
+ })
145
+ expect(inbound.threadId).toBe(42)
146
+ expect(inbound.meta.message_thread_id).toBe('42')
147
+ })
148
+
149
+ it('omits both thread carriers when ctx.threadId is absent (DM-shaped chat)', () => {
150
+ const inbound = buildSubagentHandbackInbound({
151
+ ctx: {
152
+ chatId: '12345',
153
+ taskDescription: 'x',
154
+ resultText: 'y',
155
+ outcome: 'completed',
156
+ },
157
+ nowMs: FIXED_NOW,
158
+ })
159
+ expect(inbound.threadId).toBeUndefined()
160
+ expect(inbound.meta.message_thread_id).toBeUndefined()
161
+ })
127
162
  })
@@ -158,6 +158,42 @@ describe('buildSubagentProgressInbound', () => {
158
158
  })
159
159
  expect(spoolId(bucket1)).not.toBe(spoolId(bucket2))
160
160
  })
161
+
162
+ // Supergroup topic routing (#status-channel-routing).
163
+ it('carries top-level threadId AND meta.message_thread_id when ctx.threadId is set', () => {
164
+ const inbound = buildSubagentProgressInbound({
165
+ ctx: {
166
+ chatId: '-100999',
167
+ threadId: 7,
168
+ subagentJsonlId: 'jsonl-abc',
169
+ taskDescription: 'x',
170
+ latestSummary: 'still going',
171
+ elapsedMs: 7 * 60 * 1000,
172
+ bucketIdx: 1,
173
+ progressIntervalMs: INTERVAL_MS,
174
+ },
175
+ nowMs: FIXED_NOW,
176
+ })
177
+ expect(inbound.threadId).toBe(7)
178
+ expect(inbound.meta.message_thread_id).toBe('7')
179
+ })
180
+
181
+ it('omits both thread carriers when ctx.threadId is absent (DM-shaped chat)', () => {
182
+ const inbound = buildSubagentProgressInbound({
183
+ ctx: {
184
+ chatId: '12345',
185
+ subagentJsonlId: 'jsonl-abc',
186
+ taskDescription: 'x',
187
+ latestSummary: 'y',
188
+ elapsedMs: 7 * 60 * 1000,
189
+ bucketIdx: 1,
190
+ progressIntervalMs: INTERVAL_MS,
191
+ },
192
+ nowMs: FIXED_NOW,
193
+ })
194
+ expect(inbound.threadId).toBeUndefined()
195
+ expect(inbound.meta.message_thread_id).toBeUndefined()
196
+ })
161
197
  })
162
198
 
163
199
  describe('isEnvFlagOn — bool env parser', () => {
@@ -266,4 +302,24 @@ describe('decideSubagentProgress', () => {
266
302
  expect(d.deliver).toBe(false)
267
303
  if (!d.deliver) expect(d.reason).toBe('missing-jsonl-id')
268
304
  })
305
+
306
+ // Supergroup topic routing (#status-channel-routing).
307
+ it('threads to the origin topic when the origin (fleet) chat won', () => {
308
+ const d = decideSubagentProgress(baseInput({ fleetChatId: '-100abc', originThreadId: 7 }))
309
+ expect(d.deliver).toBe(true)
310
+ if (d.deliver) {
311
+ expect(d.inbound.threadId).toBe(7)
312
+ expect(d.inbound.meta.message_thread_id).toBe('7')
313
+ }
314
+ })
315
+
316
+ it('does NOT thread when falling back to the owner DM', () => {
317
+ const d = decideSubagentProgress(baseInput({ fleetChatId: '', originThreadId: 7 }))
318
+ expect(d.deliver).toBe(true)
319
+ if (d.deliver) {
320
+ expect(d.chatId).toBe('999')
321
+ expect(d.inbound.threadId).toBeUndefined()
322
+ expect(d.inbound.meta.message_thread_id).toBeUndefined()
323
+ }
324
+ })
269
325
  })
@@ -373,6 +373,7 @@ describe('startSubagentWatcher', () => {
373
373
  function startWatcherSync(opts: {
374
374
  agentDir: string
375
375
  onFinish?: Parameters<typeof startSubagentWatcher>[0]['onFinish']
376
+ onProgress?: Parameters<typeof startSubagentWatcher>[0]['onProgress']
376
377
  }): {
377
378
  notifications: string[]
378
379
  poll: () => void
@@ -392,6 +393,7 @@ describe('startSubagentWatcher', () => {
392
393
  notifications.push(`✓ Worker done: ${info.description}`)
393
394
  opts.onFinish?.(info)
394
395
  },
396
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
395
397
  stallThresholdMs: 60_000,
396
398
  rescanMs: 500,
397
399
  now: () => Date.now(),
@@ -477,6 +479,46 @@ describe('startSubagentWatcher', () => {
477
479
  expect(entry?.toolCount).toBe(3)
478
480
  })
479
481
 
482
+ it('fires onProgress with a friendly tool-step progressLine on a tool_use tick (foreground visibility)', () => {
483
+ // A foreground sub-agent that runs tools WITHOUT emitting prose used
484
+ // to fire no onProgress cue at all — only `sub_agent_text` did — so
485
+ // its steps never nested under the parent's activity feed (the named
486
+ // foreground blindspot). The tool_use branch now fires onProgress
487
+ // carrying a `describeToolUse` label so the gateway can render
488
+ // "Reading X" the same way the main-turn feed does.
489
+ const progress: Array<{ progressLine?: string; toolCount: number; latestSummary: string }> = []
490
+ const agentDir = join(tmpRoot, 'agent')
491
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
492
+ mkdirSync(subagentsDir, { recursive: true })
493
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
494
+
495
+ const h = startWatcherSync({
496
+ agentDir,
497
+ onProgress: ({ progressLine, toolCount, latestSummary }) => {
498
+ progress.push({ progressLine, toolCount, latestSummary })
499
+ },
500
+ })
501
+ // Register running, post-boot (same pattern as the onFinish test).
502
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research the competitors')))
503
+ h.poll()
504
+ expect(h.watcher.getRegistry().get('deadbeef')?.state).toBe('running')
505
+
506
+ // The sub-agent reads a file — a tool_use with no accompanying prose.
507
+ appendFileSync(jsonlPath, buildJSONL({
508
+ type: 'assistant',
509
+ message: { content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: { file_path: '/x/CLAUDE.md' } }] },
510
+ }))
511
+ h.poll()
512
+
513
+ const toolTick = progress.find((p) => p.progressLine != null)
514
+ expect(toolTick).toBeDefined()
515
+ // Friendly label, matching the main-turn activity feed's renderer.
516
+ expect(toolTick?.progressLine).toBe('Reading CLAUDE.md')
517
+ // latestSummary stays the (empty) narrative result — never polluted
518
+ // with the tool label, so the handback payload is unaffected.
519
+ expect(toolTick?.latestSummary).toBe('')
520
+ })
521
+
480
522
  it('captures the full last narrative line into lastResultText (handback)', () => {
481
523
  // lastSummaryLine keeps only the first line, 120 chars — a progress
482
524
  // preview. lastResultText keeps the full last narrative emission:
@@ -106,4 +106,32 @@ describe('purgeStaleTurnsForChat', () => {
106
106
  expect(r.purged.sort()).toEqual(['123:7', '123:_'])
107
107
  expect([...map.keys()]).toEqual(['999:_']) // multi-chat safety preserved
108
108
  })
109
+
110
+ // #2 supergroup sibling-topic fix: one agent owns the supergroup, so all
111
+ // forum topics share the chatId. A 300s poke on topic A must NOT purge a
112
+ // LIVE sibling topic B's turn state — only siblings that are themselves stale.
113
+ it('isStale predicate spares live sibling topics (the supergroup fix)', () => {
114
+ const purged: string[] = []
115
+ const live = new Set(['-100:7']) // topic 7 is actively mid-turn
116
+ const r = purgeStaleTurnsForChat(
117
+ '-100',
118
+ ['-100:4', '-100:7', '999:_'],
119
+ (k) => purged.push(k),
120
+ (k) => !live.has(k), // stale iff not live
121
+ )
122
+ expect(r.purged).toEqual(['-100:4']) // only the stale topic purged
123
+ expect(purged).toEqual(['-100:4']) // live topic 7 + other chat untouched
124
+ })
125
+
126
+ it('isStale=false for every sibling purges nothing (all topics live)', () => {
127
+ const purged: string[] = []
128
+ purgeStaleTurnsForChat('-100', ['-100:4', '-100:7'], (k) => purged.push(k), () => false)
129
+ expect(purged).toEqual([])
130
+ })
131
+
132
+ it('default isStale (omitted) purges every chatId match — back-compat', () => {
133
+ const purged: string[] = []
134
+ const r = purgeStaleTurnsForChat('123', ['123:_', '123:7', '999:_'], (k) => purged.push(k))
135
+ expect(r.purged.sort()).toEqual(['123:7', '123:_'])
136
+ })
109
137
  })
@@ -156,6 +156,47 @@ export class Driver {
156
156
  this.client = null;
157
157
  }
158
158
 
159
+ /**
160
+ * Populate the local peer cache with the account's dialogs so a
161
+ * supergroup referenced by its marked id (e.g. `-100…`) becomes
162
+ * resolvable. The driver runs on `MemoryStorage`, which starts EMPTY
163
+ * every connect — a bot username resolves on demand (server lookup),
164
+ * but a supergroup with no public username has no resolution path
165
+ * until mtcute has seen it via the dialog list (which carries the
166
+ * channel's `access_hash`). Call this once before sending to /
167
+ * observing a supergroup. Best-effort: drains up to `limit` dialogs.
168
+ * Requires the driver account to be a MEMBER of the supergroup — if a
169
+ * later `sendText` still throws "Peer … not found in local cache",
170
+ * the account isn't in the group.
171
+ */
172
+ async primeDialogs(limit = 200): Promise<void> {
173
+ const c = this.requireClient();
174
+ let seen = 0;
175
+ for await (const _dialog of c.iterDialogs({ limit })) {
176
+ void _dialog; // draining caches each peer's access_hash as a side effect
177
+ if (++seen >= limit) break;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * True if `chatId` is resolvable (its access_hash is known) — i.e. a
183
+ * peer the account can address. Call after {@link primeDialogs}.
184
+ * Non-intrusive: sends nothing. A forum supergroup the driver account
185
+ * is in resolves true; a chat referenced by a wrong/foreign marked id
186
+ * (e.g. a BASIC group given a supergroup-style `-100…` id, or a chat
187
+ * the driver isn't a member of) resolves false. Used to skip supergroup
188
+ * scenarios cleanly when the test forum isn't wired.
189
+ */
190
+ async canResolve(chatId: number): Promise<boolean> {
191
+ const c = this.requireClient();
192
+ try {
193
+ await c.resolvePeer(chatId);
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
159
200
  async sendText(
160
201
  chatId: number,
161
202
  text: string,