switchroom 0.14.16 → 0.14.18

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.
@@ -418,7 +418,8 @@ import {
418
418
  findMostRecentInterruptedTurn,
419
419
  findRecentTurnsForChat,
420
420
  } from '../registry/turns-schema.js'
421
- import { applySubagentsSchema } from '../registry/subagents-schema.js'
421
+ import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
422
+ import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
422
423
  import { formatIdleFooter } from '../idle-footer.js'
423
424
  import { resolveCallingSubagent } from './resolve-calling-subagent.js'
424
425
 
@@ -2996,10 +2997,12 @@ type AttachmentMeta = {
2996
2997
  // `ctx` must be the *latest* message's context (latest message_id, etc.) so
2997
2998
  // the merge function picks the last entry's ctx.
2998
2999
  //
2999
- // Image/attachment-bearing messages bypass the coalescer entirely (see
3000
- // handleInboundCoalesced), so those fields stay optional and unused on the
3001
- // coalesce path; preserved for future use if we ever want to coalesce
3002
- // image+text bursts.
3000
+ // A single attachment-bearing message may ride along in a coalesce window
3001
+ // (so a [text][photo] forward becomes one turn). The handleInboundCoalesced
3002
+ // guards ensure AT MOST ONE attachment per window albums (media_group_id)
3003
+ // and a second attachment both bypass to their own turn — so the single
3004
+ // `downloadImage`/`attachment` slot is never silently overwritten. Folding a
3005
+ // whole album into one multi-attachment turn is the A2 follow-on.
3003
3006
  type CoalescePayload = {
3004
3007
  text: string
3005
3008
  ctx: Context
@@ -3007,24 +3010,36 @@ type CoalescePayload = {
3007
3010
  attachment?: AttachmentMeta
3008
3011
  }
3009
3012
 
3013
+ // Coalesce keys whose open window already holds an attachment-bearing entry.
3014
+ // A second attachment for the same key bypasses coalescing (see
3015
+ // handleInboundCoalesced) so the single-attachment merge can't drop a photo.
3016
+ // Cleared on flush (below) and on the synchronous bypass path.
3017
+ const bufferedAttachmentKeys = new Set<string>()
3018
+
3010
3019
  const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
3011
- // Read per-call from the access file so `/access set-coalesce N` takes
3012
- // effect on the next message without restarting the gateway.
3020
+ // Read per-call from the access file so an operator-tuned
3021
+ // channels.telegram.coalesce.window_ms (projected to coalescingGapMs by
3022
+ // scaffold) takes effect on the next message after apply+restart.
3013
3023
  //
3014
3024
  // Default lowered 1500 → 500 in #553 PR 3 to shrink the gateway-side
3015
- // contribution to first-real-text latency. Operators can still tune
3016
- // higher via `/access set-coalesce N` or the access file.
3025
+ // contribution to first-real-text latency.
3017
3026
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
3018
3027
  merge: (entries) => {
3019
3028
  const last = entries[entries.length - 1]
3029
+ // At most one entry carries an attachment (guarded upstream), so pick
3030
+ // whichever entry has it rather than blindly taking `last` — a
3031
+ // [photo][text] burst keeps its image even though the last entry is
3032
+ // text-only.
3033
+ const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null)
3020
3034
  return {
3021
3035
  text: entries.map((e) => e.text).join('\n'),
3022
3036
  ctx: last.ctx,
3023
- downloadImage: last.downloadImage,
3024
- attachment: last.attachment,
3037
+ downloadImage: withAttachment?.downloadImage,
3038
+ attachment: withAttachment?.attachment,
3025
3039
  }
3026
3040
  },
3027
- onFlush: (_key, merged) => {
3041
+ onFlush: (key, merged) => {
3042
+ bufferedAttachmentKeys.delete(key)
3028
3043
  void handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment)
3029
3044
  },
3030
3045
  })
@@ -8533,24 +8548,46 @@ async function handleInboundCoalesced(
8533
8548
  downloadImage: (() => Promise<string | undefined>) | undefined,
8534
8549
  attachment?: AttachmentMeta,
8535
8550
  ): Promise<void> {
8536
- // Image/attachment-bearing messages bypass coalescing preserves the
8537
- // legacy invariant that media never gets merged with sibling text.
8538
- if (downloadImage || attachment) return handleInbound(ctx, text, downloadImage, attachment)
8539
-
8540
- // `!`-prefix interrupt (#575) ALSO bypasses coalescing. If we let an
8551
+ // `!`-prefix interrupt (#575) bypasses coalescing. If we let an
8541
8552
  // interrupt sit in the coalesce window, an earlier non-`!` message
8542
8553
  // arriving in the same window would prepend itself and the marker
8543
8554
  // would no longer be at position 0 — handleInbound's parser would
8544
8555
  // miss it and the user's interrupt would silently get merged into a
8545
8556
  // normal turn. Bypass to handleInbound directly so the marker
8546
- // stays at the start of the text.
8557
+ // stays at the start of the text. Checked first so a `!`-prefixed
8558
+ // media caption still interrupts.
8547
8559
  if (parseInterruptMarker(text).isInterrupt) {
8548
- return handleInbound(ctx, text, undefined, undefined)
8560
+ return handleInbound(ctx, text, downloadImage, attachment)
8561
+ }
8562
+
8563
+ const hasAttachment = downloadImage != null || attachment != null
8564
+
8565
+ // Albums (media_group_id) are NOT coalesced in A1 — each part keeps its
8566
+ // own turn exactly as before. The single-attachment merge can carry only
8567
+ // one image, so folding a 3-photo album into one turn requires the
8568
+ // multi-attachment inbound payload (the A2 follow-on). Bypass to preserve
8569
+ // current per-part behavior and avoid dropping sibling photos.
8570
+ if (hasAttachment && ctx.message?.media_group_id != null) {
8571
+ return handleInbound(ctx, text, downloadImage, attachment)
8549
8572
  }
8550
8573
 
8551
8574
  const from = ctx.from
8552
8575
  if (!from) return
8553
8576
 
8577
+ // A second attachment landing in an already-open window would clobber the
8578
+ // first under the single-attachment merge. Bypass it to its own turn so no
8579
+ // media is silently dropped; A2's multi-attachment payload lifts this.
8580
+ if (hasAttachment) {
8581
+ const probeKey = inboundCoalesceKey(
8582
+ String(ctx.chat!.id),
8583
+ ctx.message?.message_thread_id,
8584
+ String(from.id),
8585
+ )
8586
+ if (bufferedAttachmentKeys.has(probeKey)) {
8587
+ return handleInbound(ctx, text, downloadImage, attachment)
8588
+ }
8589
+ }
8590
+
8554
8591
  // F2 fix (#553): fire 👀 reaction on RAW arrival, before the coalesce
8555
8592
  // wait blocks first paint. Pre-fix, the controller's setQueued() inside
8556
8593
  // handleInbound only ran AFTER the coalesce flush (default gapMs=1500),
@@ -8580,7 +8617,12 @@ async function handleInboundCoalesced(
8580
8617
  String(from.id),
8581
8618
  )
8582
8619
  const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment })
8583
- if (result.bypass) return handleInbound(ctx, text, undefined, undefined)
8620
+ // Coalescing disabled (window <= 0): flush immediately, preserving any
8621
+ // media this message carried.
8622
+ if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
8623
+ // Mark the open window as holding an attachment so a second attachment for
8624
+ // this key bypasses rather than clobbers (cleared in onFlush).
8625
+ if (hasAttachment) bufferedAttachmentKeys.add(key)
8584
8626
  }
8585
8627
 
8586
8628
  /**
@@ -15559,7 +15601,7 @@ bot.on('message:text', async ctx => {
15559
15601
 
15560
15602
  bot.on('message:photo', async ctx => {
15561
15603
  const caption = ctx.message.caption ?? '(photo)'
15562
- await handleInbound(ctx, caption, async () => {
15604
+ await handleInboundCoalesced(ctx, caption, async () => {
15563
15605
  const photos = ctx.message.photo
15564
15606
  const best = photos[photos.length - 1]
15565
15607
  try {
@@ -15602,7 +15644,7 @@ bot.on('message:photo', async ctx => {
15602
15644
  bot.on('message:document', async ctx => {
15603
15645
  const doc = ctx.message.document
15604
15646
  const name = safeName(doc.file_name)
15605
- await handleInbound(ctx, ctx.message.caption ?? `(document: ${name ?? 'file'})`, undefined, { kind: 'document', file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name })
15647
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(document: ${name ?? 'file'})`, undefined, { kind: 'document', file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name })
15606
15648
  })
15607
15649
 
15608
15650
  bot.on('message:voice', async ctx => {
@@ -15625,7 +15667,7 @@ bot.on('message:voice', async ctx => {
15625
15667
  const text = ctx.message.caption
15626
15668
  ? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
15627
15669
  : `[voice transcript] ${transcript}`
15628
- await handleInbound(ctx, text, undefined, {
15670
+ await handleInboundCoalesced(ctx, text, undefined, {
15629
15671
  kind: 'voice',
15630
15672
  file_id: voice.file_id,
15631
15673
  size: voice.file_size,
@@ -15635,7 +15677,7 @@ bot.on('message:voice', async ctx => {
15635
15677
  }
15636
15678
  // Fall through to the legacy path on transcription failure.
15637
15679
  }
15638
- await handleInbound(ctx, ctx.message.caption ?? '(voice message)', undefined, { kind: 'voice', file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type })
15680
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? '(voice message)', undefined, { kind: 'voice', file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type })
15639
15681
  })
15640
15682
 
15641
15683
  /**
@@ -15727,17 +15769,17 @@ async function maybeTranscribeVoice(
15727
15769
  bot.on('message:audio', async ctx => {
15728
15770
  const audio = ctx.message.audio
15729
15771
  const name = safeName(audio.file_name)
15730
- await handleInbound(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? 'audio'})`, undefined, { kind: 'audio', file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name })
15772
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? 'audio'})`, undefined, { kind: 'audio', file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name })
15731
15773
  })
15732
15774
 
15733
15775
  bot.on('message:video', async ctx => {
15734
15776
  const video = ctx.message.video
15735
- await handleInbound(ctx, ctx.message.caption ?? '(video)', undefined, { kind: 'video', file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) })
15777
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? '(video)', undefined, { kind: 'video', file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) })
15736
15778
  })
15737
15779
 
15738
15780
  bot.on('message:video_note', async ctx => {
15739
15781
  const vn = ctx.message.video_note
15740
- await handleInbound(ctx, '(video note)', undefined, { kind: 'video_note', file_id: vn.file_id, size: vn.file_size })
15782
+ await handleInboundCoalesced(ctx, '(video note)', undefined, { kind: 'video_note', file_id: vn.file_id, size: vn.file_size })
15741
15783
  })
15742
15784
 
15743
15785
  bot.on('message:sticker', async ctx => {
@@ -15752,7 +15794,7 @@ bot.on('message:sticker', async ctx => {
15752
15794
  if (sticker.emoji) parts.push(sticker.emoji)
15753
15795
  if (sticker.set_name) parts.push(`from "${sticker.set_name}"`)
15754
15796
  const text = parts.length > 0 ? `(sticker — ${parts.join(' ')})` : '(sticker)'
15755
- await handleInbound(ctx, text, undefined, { kind: 'sticker', file_id: sticker.file_id, size: sticker.file_size })
15797
+ await handleInboundCoalesced(ctx, text, undefined, { kind: 'sticker', file_id: sticker.file_id, size: sticker.file_size })
15756
15798
  })
15757
15799
 
15758
15800
  bot.on('message:animation', async ctx => {
@@ -15765,7 +15807,7 @@ bot.on('message:animation', async ctx => {
15765
15807
  const animation = ctx.message.animation
15766
15808
  const caption = ctx.message.caption
15767
15809
  const text = caption ? `(gif) ${caption}` : '(gif)'
15768
- await handleInbound(ctx, text, undefined, {
15810
+ await handleInboundCoalesced(ctx, text, undefined, {
15769
15811
  kind: 'animation',
15770
15812
  file_id: animation.file_id,
15771
15813
  size: animation.file_size,
@@ -17402,11 +17444,6 @@ void (async () => {
17402
17444
  // independent of the gateway — see
17403
17445
  // `subagent-handback-decision.test.ts`.
17404
17446
  let fleetChatId = ''
17405
- let isBackground = false
17406
- // Dispatch-time task description from the registry row —
17407
- // see the onProgress note; used for the feed's terminal recap
17408
- // header so it matches the running header ("· <real task>").
17409
- let dispatchDesc = ''
17410
17447
  try {
17411
17448
  const fleets = progressDriver?.peekAllFleets() ?? []
17412
17449
  for (const f of fleets) {
@@ -17419,17 +17456,18 @@ void (async () => {
17419
17456
  // peek failures are non-fatal — fall through to the
17420
17457
  // owner-chat fallback inside decideSubagentHandback.
17421
17458
  }
17459
+ // Background flag + feed header description, both derived from
17460
+ // the registry row via the pure resolveWorkerFeedDispatch
17461
+ // (worker-feed-dispatch.ts, pinned by its test). Best-effort:
17462
+ // a DB hiccup keeps the watcher's generic label rather than
17463
+ // throwing out of the terminal handler.
17464
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
17422
17465
  if (turnsDb != null) {
17423
17466
  try {
17424
- const row = turnsDb
17425
- .prepare('SELECT background, description FROM subagents WHERE jsonl_agent_id = ?')
17426
- .get(agentId) as { background: number; description: string | null } | undefined
17427
- if (row != null) {
17428
- isBackground = row.background === 1
17429
- dispatchDesc = row.description ?? ''
17430
- }
17467
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
17431
17468
  } catch { /* best-effort */ }
17432
17469
  }
17470
+ const isBackground = dispatch.isBackground
17433
17471
  // #PR2 live worker-feed: force the terminal recap edit on
17434
17472
  // the worker's live message. No-op when no message was ever
17435
17473
  // posted (trivial workers stay silent; handback covers them).
@@ -17437,7 +17475,7 @@ void (async () => {
17437
17475
  // it to 'done' so an already-posted message still finalizes.
17438
17476
  if (workerFeedEnabled) {
17439
17477
  void workerActivityFeed.finish(agentId, {
17440
- description: dispatchDesc || description,
17478
+ description: dispatch.feedDescription,
17441
17479
  lastTool: null,
17442
17480
  toolCount,
17443
17481
  latestSummary: resultText,
@@ -17517,12 +17555,6 @@ void (async () => {
17517
17555
  // lives in the `onFinish` block just above.
17518
17556
  onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
17519
17557
  let fleetChatId = ''
17520
- let isBackground = false
17521
- // The watcher's `description` is its 'sub-agent' default (it
17522
- // never reassigns it from the worker jsonl). The dispatch-time
17523
- // task description lives in the registry row — prefer it so the
17524
- // feed header reads "🔧 Worker · <real task>" not "· sub-agent".
17525
- let dispatchDesc = ''
17526
17558
  try {
17527
17559
  const fleets = progressDriver?.peekAllFleets() ?? []
17528
17560
  for (const f of fleets) {
@@ -17532,17 +17564,18 @@ void (async () => {
17532
17564
  }
17533
17565
  }
17534
17566
  } catch { /* peek failures non-fatal */ }
17567
+ // The watcher's `description` is its 'sub-agent' default (it
17568
+ // never reassigns it from the worker jsonl). The dispatch-time
17569
+ // task description lives in the registry row — resolveWorkerFeedDispatch
17570
+ // prefers it so the header reads "🔧 Worker · <real task>" not
17571
+ // "· sub-agent" (worker-feed-dispatch.ts, pinned by its test).
17572
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
17535
17573
  if (turnsDb != null) {
17536
17574
  try {
17537
- const row = turnsDb
17538
- .prepare('SELECT background, description FROM subagents WHERE jsonl_agent_id = ?')
17539
- .get(agentId) as { background: number; description: string | null } | undefined
17540
- if (row != null) {
17541
- isBackground = row.background === 1
17542
- dispatchDesc = row.description ?? ''
17543
- }
17575
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
17544
17576
  } catch { /* best-effort */ }
17545
17577
  }
17578
+ const isBackground = dispatch.isBackground
17546
17579
  if (!isBackground) return // skip overhead for foreground
17547
17580
 
17548
17581
  // #PR2 live worker-feed: when ON, the worker's live chat
@@ -17556,7 +17589,7 @@ void (async () => {
17556
17589
  agentId,
17557
17590
  fleetChatId || (loadAccess().allowFrom[0] ?? ''),
17558
17591
  {
17559
- description: dispatchDesc || description,
17592
+ description: dispatch.feedDescription,
17560
17593
  lastTool,
17561
17594
  toolCount,
17562
17595
  latestSummary,
@@ -34,10 +34,11 @@ export interface InboundCoalescerOptions<T> {
34
34
  * `{ bypass: true }` and the caller should flush immediately).
35
35
  *
36
36
  * Pass a function (`() => number`) instead of a number when the
37
- * window is config-driven and the operator can change it at runtime
38
- * gateway.ts reads it per-call from the access file so a
39
- * `/access set-coalesce 500` takes effect on the next message
40
- * without restarting the gateway.
37
+ * window is config-driven: gateway.ts reads it per-call from the
38
+ * access file (projected there from
39
+ * `channels.telegram.coalesce.window_ms` by the scaffold) so an
40
+ * operator-tuned window takes effect on the next message after
41
+ * apply + restart.
41
42
  */
42
43
  gapMs: number | (() => number)
43
44
  /**
@@ -146,9 +147,9 @@ export function createInboundCoalescer<T>(opts: InboundCoalescerOptions<T>): Inb
146
147
  * CPO decision #9 ratified 2026-05-27)
147
148
  *
148
149
  * `threadId` collapses `null`/`undefined`/`0` to `_` via the same
149
- * convention as `chatKey()`. The 1.5s coalesce window is per-topic
150
- * intent ("user sends 3 sentences as one thought") — applying it
151
- * cross-topic merges genuinely separate conversations.
150
+ * convention as `chatKey()`. The coalesce window (default 500ms) is
151
+ * per-topic intent ("user sends 3 sentences as one thought") — applying
152
+ * it cross-topic merges genuinely separate conversations.
152
153
  */
153
154
  export function inboundCoalesceKey(
154
155
  chatId: string,
@@ -91,28 +91,119 @@ export function redeliverBufferedInbound(
91
91
  const pending = buffer.drain(agent)
92
92
  let redelivered = 0
93
93
  let rebuffered = 0
94
- for (const msg of pending) {
94
+ // Collapse consecutive same-sender Telegram user messages into one turn
95
+ // (see planBufferedRedelivery) so a forwarded burst that spanned a turn
96
+ // boundary doesn't fan out into N sequential replies. System inbounds
97
+ // (vault grants, approvals, cron, handbacks — anything with meta.source)
98
+ // are never merged and are delivered individually exactly as before.
99
+ for (const { merged, originals } of planBufferedRedelivery(pending)) {
95
100
  let delivered = false
96
101
  try {
97
- delivered = send(msg)
102
+ delivered = send(merged)
98
103
  } catch {
99
104
  delivered = false
100
105
  }
101
106
  if (delivered) {
102
- redelivered++
103
107
  // Confirmed delivery to a live registered bridge → the durable
104
- // promise is kept; tombstone the spool entry so it is NOT
105
- // boot-replayed again. A miss leaves it spooled (re-pushed below
106
- // AND still live in the spool) for the next drain / escalation.
107
- spool?.ack(msg)
108
+ // promise is kept; tombstone EVERY original's spool entry so none is
109
+ // boot-replayed again. The merged message isn't itself spooled the
110
+ // originals are, so we ack by original identity.
111
+ for (const o of originals) spool?.ack(o)
112
+ redelivered += originals.length
108
113
  } else {
109
- buffer.push(agent, msg)
110
- rebuffered++
114
+ // Re-buffer the originals (not the merged synthetic) so the spool
115
+ // identity is preserved and the next drain re-merges them losslessly.
116
+ for (const o of originals) buffer.push(agent, o)
117
+ rebuffered += originals.length
111
118
  }
112
119
  }
113
120
  return { drained: pending.length, redelivered, rebuffered }
114
121
  }
115
122
 
123
+ /** True when `msg` is an ordinary Telegram user message eligible to be
124
+ * merged with adjacent siblings. System inbounds (cron, vault grants,
125
+ * approvals, subagent handbacks, warmup, reaction triggers) all tag a
126
+ * `meta.source`; the user-message inbound built in gateway.ts sets none.
127
+ * Restricting to source-less inbounds keeps merge-on-drain away from the
128
+ * #1150 wake-up class entirely. */
129
+ function isMergeableUserInbound(msg: InboundMessage): boolean {
130
+ return msg.type === 'inbound' && (msg.meta == null || msg.meta.source == null)
131
+ }
132
+
133
+ function inboundHasMedia(msg: InboundMessage): boolean {
134
+ return msg.imagePath != null || msg.attachment != null
135
+ }
136
+
137
+ /**
138
+ * Plan how a drained buffer is re-delivered. Walks `pending` in arrival
139
+ * order and groups runs of consecutive messages that:
140
+ * - are both ordinary Telegram user messages (no meta.source), AND
141
+ * - share the same (chatId, threadId, userId), AND
142
+ * - would not put two attachments in one turn (A1 carries a single
143
+ * attachment; a second media starts a new run so nothing is dropped).
144
+ *
145
+ * Each run collapses to one merged InboundMessage (texts joined by '\n',
146
+ * the run's single attachment carried, the LAST message's identity/meta
147
+ * kept as the turn anchor). A run of one passes through unchanged. The
148
+ * returned `originals` preserve spool identity for ack / re-buffer.
149
+ *
150
+ * Pure + deterministic so it can be exhaustively fuzzed.
151
+ */
152
+ export function planBufferedRedelivery(
153
+ pending: InboundMessage[],
154
+ ): { merged: InboundMessage; originals: InboundMessage[] }[] {
155
+ const out: { merged: InboundMessage; originals: InboundMessage[] }[] = []
156
+ let run: InboundMessage[] = []
157
+ let runHasMedia = false
158
+
159
+ const sameTarget = (a: InboundMessage, b: InboundMessage): boolean =>
160
+ a.chatId === b.chatId &&
161
+ (a.threadId ?? null) === (b.threadId ?? null) &&
162
+ a.userId === b.userId
163
+
164
+ const flush = (): void => {
165
+ if (run.length === 0) return
166
+ out.push({ merged: run.length === 1 ? run[0]! : mergeRun(run), originals: run })
167
+ run = []
168
+ runHasMedia = false
169
+ }
170
+
171
+ for (const msg of pending) {
172
+ const msgHasMedia = inboundHasMedia(msg)
173
+ const canJoin =
174
+ run.length > 0 &&
175
+ isMergeableUserInbound(msg) &&
176
+ isMergeableUserInbound(run[run.length - 1]!) &&
177
+ sameTarget(run[run.length - 1]!, msg) &&
178
+ !(runHasMedia && msgHasMedia)
179
+ if (!canJoin) flush()
180
+ run.push(msg)
181
+ runHasMedia = runHasMedia || msgHasMedia
182
+ }
183
+ flush()
184
+ return out
185
+ }
186
+
187
+ /** Collapse a >1 run into a single turn. The newest message anchors the
188
+ * turn (its messageId/ts/user/meta); texts join in arrival order; the
189
+ * single attachment (if any) rides along from whichever message carried
190
+ * it. Caller guarantees the run is mergeable + has at most one media. */
191
+ function mergeRun(run: InboundMessage[]): InboundMessage {
192
+ const last = run[run.length - 1]!
193
+ const mediaEntry = run.find(inboundHasMedia)
194
+ const merged: InboundMessage = {
195
+ ...last,
196
+ text: run.map((m) => m.text).join('\n'),
197
+ }
198
+ // Re-seat the single attachment/imagePath from the entry that owns it
199
+ // (which may not be `last`), or strip them if the run is text-only.
200
+ delete merged.imagePath
201
+ delete merged.attachment
202
+ if (mediaEntry?.imagePath != null) merged.imagePath = mediaEntry.imagePath
203
+ if (mediaEntry?.attachment != null) merged.attachment = mediaEntry.attachment
204
+ return merged
205
+ }
206
+
116
207
  /**
117
208
  * One opportunistic idle-drain tick. The third drain trigger, beside
118
209
  * `onClientRegistered` (bridge re-register) and the silence-poke
@@ -0,0 +1,37 @@
1
+ import type { Subagent } from '../registry/subagents-schema.js'
2
+
3
+ export interface WorkerFeedDispatch {
4
+ /** True when the sub-agent was dispatched with `run_in_background: true`. */
5
+ isBackground: boolean
6
+ /**
7
+ * The human-readable task to render in the feed header
8
+ * ("🔧 Worker · <feedDescription>").
9
+ */
10
+ feedDescription: string
11
+ }
12
+
13
+ /**
14
+ * Resolve the two registry-derived inputs the worker-activity feed needs:
15
+ * whether the sub-agent was a background dispatch, and the task description
16
+ * to show in the feed header.
17
+ *
18
+ * The live watcher only carries a generic 'sub-agent' label — it never
19
+ * reassigns `description` from the worker jsonl. The real dispatch-time
20
+ * description lives in the registry `subagents` row (written by the pretool
21
+ * hook from the `Agent(description:)` input). Prefer it; fall back to the
22
+ * watcher's label only when the row is missing or its description is empty.
23
+ *
24
+ * Pure + DB-free so it pins the #2002 behavior under both vitest and bun —
25
+ * see worker-feed-dispatch.test.ts. The gateway must never inline this
26
+ * decision again: a regression here silently reverts the feed header to
27
+ * "· sub-agent".
28
+ */
29
+ export function resolveWorkerFeedDispatch(
30
+ sub: Subagent | null,
31
+ watcherDescription: string,
32
+ ): WorkerFeedDispatch {
33
+ return {
34
+ isBackground: sub?.background ?? false,
35
+ feedDescription: (sub?.description ?? '') || watcherDescription,
36
+ }
37
+ }
@@ -73,7 +73,13 @@ export interface WorkerEntry {
73
73
  readonly agentId: string
74
74
  /** File path of the JSONL. */
75
75
  readonly filePath: string
76
- /** Short description — from the sub-agent's first text/narrative line. */
76
+ /**
77
+ * Generic 'sub-agent' placeholder — the watcher deliberately does NOT
78
+ * reassign this from the worker jsonl (see the init at construction and
79
+ * the "Do NOT overwrite" note in the line-handler). The real dispatch-time
80
+ * task description lives in the registry `subagents` row; the gateway reads
81
+ * it there via resolveWorkerFeedDispatch for the worker-feed header.
82
+ */
77
83
  description: string
78
84
  /** Current lifecycle state. */
79
85
  state: WorkerState
@@ -864,6 +870,9 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
864
870
  const entry: WorkerEntry = {
865
871
  agentId,
866
872
  filePath,
873
+ // Generic placeholder only — never overwritten from the jsonl. The
874
+ // gateway substitutes the real registry description for the worker
875
+ // feed (resolveWorkerFeedDispatch). See the WorkerEntry.description doc.
867
876
  description: 'sub-agent',
868
877
  state: 'running',
869
878
  dispatchedAt: n,
@@ -140,4 +140,25 @@ describe('createInboundCoalescer', () => {
140
140
  expect(flushed).toEqual([])
141
141
  expect(c.size()).toBe(0)
142
142
  })
143
+
144
+ it('hands merge ALL entries in arrival order so the attachment can ride from a non-last entry', () => {
145
+ // The gateway merge picks the single attachment via entries.find(...),
146
+ // NOT entries[last]. Pin that the coalescer preserves arrival order and
147
+ // passes every buffered entry, so a [photo][text] burst keeps the photo.
148
+ interface MediaPayload { text: string; attachment?: string }
149
+ const mediaMerge = (entries: MediaPayload[]): MediaPayload => ({
150
+ text: entries.map((e) => e.text).join('\n'),
151
+ attachment: entries.find((e) => e.attachment != null)?.attachment,
152
+ })
153
+ const flushed: MediaPayload[] = []
154
+ const c = createInboundCoalescer<MediaPayload>({
155
+ gapMs: 1500,
156
+ merge: mediaMerge,
157
+ onFlush: (_key, merged) => flushed.push(merged),
158
+ })
159
+ c.enqueue('c1:u1', { text: 'look', attachment: 'photo-1' }) // media FIRST
160
+ c.enqueue('c1:u1', { text: 'at this' }) // text second
161
+ vi.advanceTimersByTime(1500)
162
+ expect(flushed).toEqual([{ text: 'look\nat this', attachment: 'photo-1' }])
163
+ })
143
164
  })