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