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.
- package/dist/agent-scheduler/index.js +3 -0
- package/dist/auth-broker/index.js +3 -0
- package/dist/cli/notion-write-pretool.mjs +3 -0
- package/dist/cli/switchroom.js +8 -2
- package/dist/host-control/main.js +3 -0
- package/dist/vault/approvals/kernel-server.js +3 -0
- package/dist/vault/broker/server.js +3 -0
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +6 -5
- package/telegram-plugin/dist/gateway/gateway.js +178 -48
- package/telegram-plugin/gateway/gateway.ts +89 -56
- package/telegram-plugin/gateway/inbound-coalesce.ts +8 -7
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +100 -9
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +37 -0
- package/telegram-plugin/subagent-watcher.ts +10 -1
- package/telegram-plugin/tests/inbound-coalesce.test.ts +21 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +285 -1
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +140 -0
|
@@ -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
|
-
//
|
|
3000
|
-
//
|
|
3001
|
-
//
|
|
3002
|
-
//
|
|
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
|
|
3012
|
-
//
|
|
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.
|
|
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:
|
|
3024
|
-
attachment:
|
|
3037
|
+
downloadImage: withAttachment?.downloadImage,
|
|
3038
|
+
attachment: withAttachment?.attachment,
|
|
3025
3039
|
}
|
|
3026
3040
|
},
|
|
3027
|
-
onFlush: (
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
150
|
-
* intent ("user sends 3 sentences as one thought") — applying
|
|
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
|
-
|
|
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(
|
|
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
|
|
105
|
-
// boot-replayed again.
|
|
106
|
-
//
|
|
107
|
-
spool?.ack(
|
|
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
|
|
110
|
-
|
|
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
|
-
/**
|
|
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
|
})
|