switchroom 0.14.41 → 0.14.43
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 +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +357 -357
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +396 -212
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +126 -29
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +22 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +44 -0
- package/telegram-plugin/tests/subagent-handback-decision.test.ts +32 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +35 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +56 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +42 -0
- package/telegram-plugin/uat/driver.ts +41 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +17 -10
- package/telegram-plugin/uat/scenarios/fuzz-supergroup-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +9 -7
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +102 -0
|
@@ -251,7 +251,7 @@ import { handleInjectCommand } from './inject-handler.js'
|
|
|
251
251
|
import { type BannerState } from '../slot-banner.js'
|
|
252
252
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
253
253
|
import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
254
|
-
import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
|
|
254
|
+
import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
|
|
255
255
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
256
256
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
257
257
|
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
@@ -2273,14 +2273,20 @@ function postPermissionResumeMessage(opts: {
|
|
|
2273
2273
|
const targets: Array<{ chatId: string; threadId: number | undefined }> =
|
|
2274
2274
|
turn != null
|
|
2275
2275
|
? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
|
|
2276
|
-
:
|
|
2277
|
-
|
|
2278
|
-
|
|
2276
|
+
: (() => {
|
|
2277
|
+
const sg = resolveAgentSupergroupChatId()
|
|
2278
|
+
const topic = resolveAgentOutboundTopic({
|
|
2279
2279
|
kind: 'permission',
|
|
2280
2280
|
turnInitiated: false,
|
|
2281
2281
|
originThreadId: undefined,
|
|
2282
|
-
})
|
|
2283
|
-
|
|
2282
|
+
})
|
|
2283
|
+
// allowFrom is normally operator DMs — attach the topic only to a
|
|
2284
|
+
// recipient that owns it (the supergroup), never a DM (marko wedge).
|
|
2285
|
+
return loadAccess().allowFrom.map(chatId => ({
|
|
2286
|
+
chatId,
|
|
2287
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
|
|
2288
|
+
}))
|
|
2289
|
+
})()
|
|
2284
2290
|
for (const { chatId, threadId } of targets) {
|
|
2285
2291
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
|
|
2286
2292
|
void swallowingApiCall(
|
|
@@ -4664,16 +4670,20 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4664
4670
|
turnInitiated: activeTurn != null,
|
|
4665
4671
|
originThreadId: activeTurn?.sessionThreadId,
|
|
4666
4672
|
})
|
|
4673
|
+
const permSupergroup = resolveAgentSupergroupChatId()
|
|
4667
4674
|
for (const chat_id of access.allowFrom) {
|
|
4668
4675
|
// parse_mode=HTML pairs with formatPermissionCardBody (#1790)
|
|
4669
4676
|
// so the <b>/<i> tags render as formatting.
|
|
4670
|
-
//
|
|
4671
|
-
//
|
|
4677
|
+
// The resolved topic is valid only in the agent's supergroup — attach
|
|
4678
|
+
// it ONLY when this recipient IS that supergroup. allowFrom DMs get the
|
|
4679
|
+
// card thread-less; attaching a topic to a DM yields 400 "message thread
|
|
4680
|
+
// not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
|
|
4681
|
+
const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
|
|
4672
4682
|
// allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
|
|
4673
4683
|
void bot.api.sendMessage(chat_id, text, {
|
|
4674
4684
|
parse_mode: 'HTML',
|
|
4675
4685
|
reply_markup: keyboard,
|
|
4676
|
-
...(
|
|
4686
|
+
...(permThread != null ? { message_thread_id: permThread } : {}),
|
|
4677
4687
|
}).catch(e => {
|
|
4678
4688
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
4679
4689
|
})
|
|
@@ -4811,9 +4821,15 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4811
4821
|
// topic. Drive approval cards follow the originating turn
|
|
4812
4822
|
// (operator-initiated tool call), admin alias fallback.
|
|
4813
4823
|
const activeTurn = currentTurn
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4824
|
+
// Attach the topic only when `operator` IS the agent's supergroup —
|
|
4825
|
+
// operator DMs have no topics (marko brevo wedge, 2026-06-02).
|
|
4826
|
+
const driveTopic = topicForRecipient({
|
|
4827
|
+
recipientChatId: operator,
|
|
4828
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
4829
|
+
kind: 'hostd-approval',
|
|
4830
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4831
|
+
}),
|
|
4832
|
+
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
4817
4833
|
})
|
|
4818
4834
|
return {
|
|
4819
4835
|
chatId: operator,
|
|
@@ -4884,9 +4900,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4884
4900
|
// alias fallback for background cases. Same shape as hostd /
|
|
4885
4901
|
// drive approvals below.
|
|
4886
4902
|
const activeTurn = currentTurn
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4903
|
+
// Topic valid only in the agent's supergroup — never on the operator DM.
|
|
4904
|
+
const ms365Topic = topicForRecipient({
|
|
4905
|
+
recipientChatId: operator,
|
|
4906
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
4907
|
+
kind: 'hostd-approval',
|
|
4908
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4909
|
+
}),
|
|
4910
|
+
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
4890
4911
|
})
|
|
4891
4912
|
return {
|
|
4892
4913
|
chatId: operator,
|
|
@@ -4963,9 +4984,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4963
4984
|
// returns undefined for non-supergroup agents → behavior
|
|
4964
4985
|
// unchanged.
|
|
4965
4986
|
const activeTurn = currentTurn
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4987
|
+
// Topic valid only in the agent's supergroup — never on the operator DM.
|
|
4988
|
+
const cfgTopic = topicForRecipient({
|
|
4989
|
+
recipientChatId: operator,
|
|
4990
|
+
resolvedTopic: resolveAgentOutboundTopic({
|
|
4991
|
+
kind: 'hostd-approval',
|
|
4992
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4993
|
+
}),
|
|
4994
|
+
supergroupChatId: resolveAgentSupergroupChatId(),
|
|
4969
4995
|
})
|
|
4970
4996
|
return {
|
|
4971
4997
|
chatId: operator,
|
|
@@ -5577,7 +5603,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
5577
5603
|
|
|
5578
5604
|
assertAllowedChat(chat_id)
|
|
5579
5605
|
|
|
5580
|
-
|
|
5606
|
+
// Thread resolution precedence: (1) an explicit message_thread_id the
|
|
5607
|
+
// model passed, else (2) THIS turn's own originating topic
|
|
5608
|
+
// (turn-pinned, #1664), else (3) the chat's last-seen topic
|
|
5609
|
+
// (chatThreadMap). Preferring the turn's own thread over the chat
|
|
5610
|
+
// last-seen heuristic fixes synthetic turns (subagent handback/progress,
|
|
5611
|
+
// cron) — whose topic the model is never told and which never write
|
|
5612
|
+
// chatThreadMap — and is strictly more correct under multi-topic
|
|
5613
|
+
// concurrency (a reply lands in the topic the turn came from, not
|
|
5614
|
+
// whichever topic most recently received a message). DM: both are
|
|
5615
|
+
// undefined → unchanged.
|
|
5616
|
+
let threadId = resolveThreadId(
|
|
5617
|
+
chat_id,
|
|
5618
|
+
(args.message_thread_id as string | undefined) ??
|
|
5619
|
+
(turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
|
|
5620
|
+
)
|
|
5581
5621
|
|
|
5582
5622
|
if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
|
|
5583
5623
|
try {
|
|
@@ -6176,6 +6216,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
6176
6216
|
const turn = currentTurn
|
|
6177
6217
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
6178
6218
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
6219
|
+
// Thread precedence (matches executeReply): when the model passes no
|
|
6220
|
+
// explicit message_thread_id, fall back to THIS turn's originating
|
|
6221
|
+
// topic before handleStreamReply's chatThreadMap last-seen heuristic.
|
|
6222
|
+
// Injecting here threads every downstream consumer consistently — the
|
|
6223
|
+
// dedup key, the voice-scrub metric, the draft transport, and the send
|
|
6224
|
+
// — so a streamed handback/synthetic-turn reply lands in the right
|
|
6225
|
+
// supergroup topic. DM: sessionThreadId undefined → unchanged.
|
|
6226
|
+
if (args.message_thread_id == null && turn?.sessionThreadId != null) {
|
|
6227
|
+
args.message_thread_id = String(turn.sessionThreadId)
|
|
6228
|
+
}
|
|
6179
6229
|
|
|
6180
6230
|
// Outbound secret scrub (#2044): mask before the dedup key, the draft
|
|
6181
6231
|
// stream sends, and the history record. stream_reply carries the FULL
|
|
@@ -11067,6 +11117,30 @@ function resolveAgentOutboundTopic(
|
|
|
11067
11117
|
}
|
|
11068
11118
|
}
|
|
11069
11119
|
|
|
11120
|
+
/**
|
|
11121
|
+
* The agent's supergroup chat id (`channels.telegram.chat_id`) when it is in
|
|
11122
|
+
* supergroup-owned mode, else undefined. A forum topic id resolved by
|
|
11123
|
+
* {@link resolveAgentOutboundTopic} is valid ONLY in this chat — used by
|
|
11124
|
+
* {@link topicForRecipient} to decide whether an approval/permission card sent
|
|
11125
|
+
* to a given recipient (operator DMs vs the supergroup itself) may carry a
|
|
11126
|
+
* `message_thread_id`. Attaching a topic to a DM is the marko brevo wedge
|
|
11127
|
+
* (2026-06-02): the card fails with "message thread not found" and auto-denies.
|
|
11128
|
+
*/
|
|
11129
|
+
function resolveAgentSupergroupChatId(): string | undefined {
|
|
11130
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
11131
|
+
if (!agentName) return undefined
|
|
11132
|
+
try {
|
|
11133
|
+
const cfg = loadSwitchroomConfig()
|
|
11134
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
11135
|
+
if (!rawAgent) return undefined
|
|
11136
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
11137
|
+
const tg = resolved.channels?.telegram as { chat_id?: string | number } | undefined
|
|
11138
|
+
return tg?.chat_id != null ? String(tg.chat_id) : undefined
|
|
11139
|
+
} catch {
|
|
11140
|
+
return undefined
|
|
11141
|
+
}
|
|
11142
|
+
}
|
|
11143
|
+
|
|
11070
11144
|
/**
|
|
11071
11145
|
* Stamp a user-facing restart reason into the clean-shutdown marker
|
|
11072
11146
|
* (same file the SIGTERM handler writes to and the next session greeting
|
|
@@ -18581,6 +18655,7 @@ void (async () => {
|
|
|
18581
18655
|
})
|
|
18582
18656
|
}
|
|
18583
18657
|
|
|
18658
|
+
const handbackOrigin = resolveSubagentOriginChat(agentId)
|
|
18584
18659
|
const decision = decideSubagentHandback({
|
|
18585
18660
|
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
18586
18661
|
outcome,
|
|
@@ -18589,11 +18664,18 @@ void (async () => {
|
|
|
18589
18664
|
// turn) back to the conversation the Task was dispatched
|
|
18590
18665
|
// from, so the result lands where the user asked — not the
|
|
18591
18666
|
// agent's DM. Falls back to fleetChatId/ownerChatId.
|
|
18592
|
-
fleetChatId:
|
|
18667
|
+
fleetChatId: handbackOrigin?.chatId || fleetChatId,
|
|
18668
|
+
// Supergroup topic the Task was dispatched from. Plumbed
|
|
18669
|
+
// through so the handback turn (and the model's in-voice
|
|
18670
|
+
// "here's what the worker found" reply) land in the
|
|
18671
|
+
// originating topic — not the chat's last-seen topic.
|
|
18672
|
+
// Applied only when the origin chat resolved (DM fallback
|
|
18673
|
+
// is topic-less).
|
|
18674
|
+
...(handbackOrigin?.threadId != null
|
|
18675
|
+
? { originThreadId: handbackOrigin.threadId }
|
|
18676
|
+
: {}),
|
|
18593
18677
|
// Owner-chat fallback: if the parent-turn chat can't be
|
|
18594
|
-
// resolved, route to the owner chat.
|
|
18595
|
-
// agent is DM-shaped, so allowFrom[0] is the conversation
|
|
18596
|
-
// that dispatched.
|
|
18678
|
+
// resolved, route to the owner chat.
|
|
18597
18679
|
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
18598
18680
|
taskDescription: description,
|
|
18599
18681
|
resultText,
|
|
@@ -18654,7 +18736,7 @@ void (async () => {
|
|
|
18654
18736
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
18655
18737
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
18656
18738
|
// lives in the `onFinish` block just above.
|
|
18657
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
|
|
18739
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
|
|
18658
18740
|
let fleetChatId = ''
|
|
18659
18741
|
try {
|
|
18660
18742
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -18694,7 +18776,15 @@ void (async () => {
|
|
|
18694
18776
|
nestingEnabled: foregroundNestingEnabled,
|
|
18695
18777
|
replyCalled: turn.replyCalled,
|
|
18696
18778
|
})) return
|
|
18697
|
-
|
|
18779
|
+
// Prefer the tick's own display line: `progressLine` (a
|
|
18780
|
+
// friendly tool-step label) on tool ticks, else the
|
|
18781
|
+
// worker's narrative (`latestSummary`) on text ticks. This
|
|
18782
|
+
// lets a foreground sub-agent that runs tools without
|
|
18783
|
+
// emitting prose still nest its steps under the parent
|
|
18784
|
+
// feed (the foreground blindspot) — mirroring the
|
|
18785
|
+
// main-turn activity feed, which surfaces both tool labels
|
|
18786
|
+
// and prose.
|
|
18787
|
+
const child = (progressLine ?? latestSummary).trim().slice(0, 120)
|
|
18698
18788
|
if (child.length === 0) return
|
|
18699
18789
|
let narrative = turn.foregroundSubAgents.get(agentId)
|
|
18700
18790
|
if (narrative == null) {
|
|
@@ -18746,12 +18836,18 @@ void (async () => {
|
|
|
18746
18836
|
return
|
|
18747
18837
|
}
|
|
18748
18838
|
|
|
18839
|
+
const progressOrigin = resolveSubagentOriginChat(agentId)
|
|
18749
18840
|
const decision = decideSubagentProgress({
|
|
18750
18841
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
18751
18842
|
isBackground,
|
|
18752
18843
|
// Prefer the conversation the Task was dispatched from over
|
|
18753
18844
|
// the owner DM (see resolveSubagentOriginChat).
|
|
18754
|
-
fleetChatId:
|
|
18845
|
+
fleetChatId: progressOrigin?.chatId || fleetChatId,
|
|
18846
|
+
// Carry the dispatching topic so the progress wake lands in
|
|
18847
|
+
// it (applied only when the origin chat resolved).
|
|
18848
|
+
...(progressOrigin?.threadId != null
|
|
18849
|
+
? { originThreadId: progressOrigin.threadId }
|
|
18850
|
+
: {}),
|
|
18755
18851
|
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
18756
18852
|
subagentJsonlId: agentId,
|
|
18757
18853
|
taskDescription: description,
|
|
@@ -18769,10 +18865,11 @@ void (async () => {
|
|
|
18769
18865
|
// model is about to compose an explicit in-voice
|
|
18770
18866
|
// progress line — letting the "— still working (Nm)"
|
|
18771
18867
|
// edit fire in parallel would double-surface the
|
|
18772
|
-
// signal.
|
|
18773
|
-
// (
|
|
18868
|
+
// signal. Key the clear on the topic the envelope lands
|
|
18869
|
+
// in (origin thread) so the right lane is yielded in a
|
|
18870
|
+
// supergroup; chat-level for DM-shaped agents.
|
|
18774
18871
|
pendingProgress.clearPending(
|
|
18775
|
-
statusKey(decision.chatId,
|
|
18872
|
+
statusKey(decision.chatId, progressOrigin?.threadId),
|
|
18776
18873
|
'progress',
|
|
18777
18874
|
)
|
|
18778
18875
|
process.stderr.write(
|
|
@@ -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,
|
|
@@ -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)
|
|
@@ -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:
|