switchroom 0.13.25 → 0.13.27
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 +132 -10
- package/dist/vault/broker/server.js +32 -4
- package/package.json +1 -1
- package/telegram-plugin/active-reactions-sweep.ts +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +239 -64
- package/telegram-plugin/docs/waiting-ux-spec.md +17 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +10 -6
- package/telegram-plugin/gateway/gateway.ts +166 -51
- package/telegram-plugin/gateway/inbound-spool.ts +69 -2
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +14 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +256 -0
- package/telegram-plugin/pending-work-progress.ts +5 -1
- package/telegram-plugin/status-reactions.ts +70 -58
- package/telegram-plugin/stream-reply-handler.ts +7 -36
- package/telegram-plugin/subagent-watcher.ts +64 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -3
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +213 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +62 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +78 -135
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/status-reactions.test.ts +56 -27
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -25
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +22 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +269 -0
- package/telegram-plugin/uat/scenarios/jtbd-reflective-status-reaction-dm.test.ts +204 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure builder for the synthetic `subagent_progress` inbound the
|
|
3
|
+
* gateway injects when a *background* sub-agent emits a narrative line
|
|
4
|
+
* (`sub_agent_text`) past a bucket boundary while still running.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (conversational-pacing beat 3 — mid-flight progress
|
|
7
|
+
* for background workers):
|
|
8
|
+
*
|
|
9
|
+
* - Foreground sub-agents stream their narrative into the parent's
|
|
10
|
+
* turn natively; the parent can surface progress in its own voice
|
|
11
|
+
* as the work unfolds.
|
|
12
|
+
* - Background sub-agents are decoupled from any turn boundary. The
|
|
13
|
+
* parent is typically idle while the worker runs. Without a wake,
|
|
14
|
+
* the user sees nothing between dispatch and the eventual handback
|
|
15
|
+
* (beat 4, #1719). The cross-turn ambient ticker in
|
|
16
|
+
* `pending-work-progress.ts` keeps the parent's last reply alive
|
|
17
|
+
* ("— still working (Nm)") but says NOTHING about *what's
|
|
18
|
+
* happening* — it's liveness, not content.
|
|
19
|
+
*
|
|
20
|
+
* The watcher already captures `lastResultText` on every
|
|
21
|
+
* `sub_agent_text` emission (`subagent-watcher.ts:607-622`). We
|
|
22
|
+
* piggyback on that event stream: every `progressIntervalMs` worth of
|
|
23
|
+
* elapsed time, the next narrative line minted by the worker becomes
|
|
24
|
+
* a synthetic `<channel source="subagent_progress">` inbound. The
|
|
25
|
+
* parent wakes, sees the cue, and surfaces one user-facing line of
|
|
26
|
+
* progress in its own voice. No timer-driven polling, no TaskOutput
|
|
27
|
+
* sampling, no `expected_duration` parameter.
|
|
28
|
+
*
|
|
29
|
+
* Two divergences from the #1719 handback envelope:
|
|
30
|
+
*
|
|
31
|
+
* 1. Determinism by BUCKET, not by jsonl id alone. The spool dedup
|
|
32
|
+
* id is `s:progress:<jsonl_agent_id>:<bucketIdx>` where
|
|
33
|
+
* `bucketIdx = floor(elapsedMs / progressIntervalMs)`. A worker
|
|
34
|
+
* that emits 10 narrative lines inside a single bucket window
|
|
35
|
+
* still produces exactly one envelope; the next bucket gets its
|
|
36
|
+
* own envelope. Monotonic, idempotent across restarts.
|
|
37
|
+
*
|
|
38
|
+
* 2. TTL — every progress envelope sets `meta.expiresAt` to
|
|
39
|
+
* `nowMs + 2 × progressIntervalMs`. A 4-hour-stale
|
|
40
|
+
* "still working (5m)" delivered after a long downtime would be a
|
|
41
|
+
* lie; handback envelopes don't have this asymmetry (they're
|
|
42
|
+
* "deliver-eventually OK"). The spool's `liveEntries()` skips
|
|
43
|
+
* expired entries — see `inbound-spool.ts`.
|
|
44
|
+
*
|
|
45
|
+
* Shape contract mirrors `subagent-handback-inbound-builder.ts`: the
|
|
46
|
+
* `meta.source` string is load-bearing for the channel wrapper.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
50
|
+
|
|
51
|
+
/** Cap on the latest-summary text carried in the inbound. The model
|
|
52
|
+
* synthesises a one-line update from it; the full transcript is never
|
|
53
|
+
* needed and an unbounded paste bloats the parent's context. */
|
|
54
|
+
export const PROGRESS_RESULT_MAX = 800
|
|
55
|
+
/** Cap on the dispatch-time task description echoed back for context. */
|
|
56
|
+
export const PROGRESS_DESC_MAX = 200
|
|
57
|
+
/** Default bucket size — how often a fresh progress envelope may fire
|
|
58
|
+
* for a given sub-agent. 5 min is well past the conversational-pacing
|
|
59
|
+
* beat 2/3 boundary and matches the refined RFC. */
|
|
60
|
+
export const DEFAULT_PROGRESS_INTERVAL_MS = 5 * 60 * 1000
|
|
61
|
+
|
|
62
|
+
export interface SubagentProgressContext {
|
|
63
|
+
/** Telegram chat the work was dispatched from. */
|
|
64
|
+
chatId: string
|
|
65
|
+
/** JSONL-derived sub-agent id (stable per Claude Code spawn). Pinned
|
|
66
|
+
* into the spool id so envelopes for the same worker dedup across
|
|
67
|
+
* buckets cleanly and survive gateway restarts. */
|
|
68
|
+
subagentJsonlId: string
|
|
69
|
+
/** Dispatch-time task description (the sub-agent's `description`). */
|
|
70
|
+
taskDescription: string
|
|
71
|
+
/** Worker's most recent narrative line — the cue payload. May be
|
|
72
|
+
* empty when the worker emits a tool-use without prose; the
|
|
73
|
+
* envelope is still useful (it tells the parent "still alive"). */
|
|
74
|
+
latestSummary: string
|
|
75
|
+
/** ms elapsed since the worker was dispatched. */
|
|
76
|
+
elapsedMs: number
|
|
77
|
+
/** Bucket index = floor(elapsedMs / progressIntervalMs). The
|
|
78
|
+
* determinism axis. */
|
|
79
|
+
bucketIdx: number
|
|
80
|
+
/** Window size (ms) used to compute `bucketIdx`. The envelope's TTL
|
|
81
|
+
* is `2 × progressIntervalMs` past `nowMs`. */
|
|
82
|
+
progressIntervalMs: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncate(s: string, max: number): string {
|
|
86
|
+
const t = s.trim()
|
|
87
|
+
return t.length > max ? t.slice(0, max) + '…' : t
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatElapsed(ms: number): string {
|
|
91
|
+
const totalMin = Math.floor(ms / 60_000)
|
|
92
|
+
if (totalMin < 60) return `${Math.max(1, totalMin)}m`
|
|
93
|
+
const h = Math.floor(totalMin / 60)
|
|
94
|
+
const m = totalMin % 60
|
|
95
|
+
return m === 0 ? `${h}h` : `${h}h${m}m`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the synthetic InboundMessage for a mid-flight background
|
|
100
|
+
* sub-agent. Deterministic under a fixed `nowMs` for tests.
|
|
101
|
+
*/
|
|
102
|
+
export function buildSubagentProgressInbound(opts: {
|
|
103
|
+
ctx: SubagentProgressContext
|
|
104
|
+
nowMs?: number
|
|
105
|
+
}): InboundMessage {
|
|
106
|
+
const ts = opts.nowMs ?? Date.now()
|
|
107
|
+
const desc = truncate(opts.ctx.taskDescription, PROGRESS_DESC_MAX) || '(no description)'
|
|
108
|
+
const summary = truncate(opts.ctx.latestSummary, PROGRESS_RESULT_MAX)
|
|
109
|
+
const elapsed = formatElapsed(opts.ctx.elapsedMs)
|
|
110
|
+
const expiresAt = ts + 2 * opts.ctx.progressIntervalMs
|
|
111
|
+
|
|
112
|
+
const text =
|
|
113
|
+
`🔄 A background worker you dispatched is still running.\n\n` +
|
|
114
|
+
`Task: ${desc}\n` +
|
|
115
|
+
`Elapsed: ${elapsed}\n\n` +
|
|
116
|
+
(summary
|
|
117
|
+
? `Latest activity:\n${summary}\n\n`
|
|
118
|
+
: `(no narrative line yet — worker has been tool-only)\n\n`) +
|
|
119
|
+
`This is beat 3 — mid-flight progress. Surface ONE short line to ` +
|
|
120
|
+
`the user in your own voice about what the worker is up to. Do ` +
|
|
121
|
+
`NOT paste this raw, do NOT repeat the elapsed time verbatim, do ` +
|
|
122
|
+
`NOT promise completion. The handback (beat 4) will come ` +
|
|
123
|
+
`separately when the worker finishes.`
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
type: 'inbound',
|
|
127
|
+
chatId: opts.ctx.chatId,
|
|
128
|
+
messageId: ts, // synthetic — no Telegram message id exists
|
|
129
|
+
user: 'subagent-watcher',
|
|
130
|
+
userId: 0,
|
|
131
|
+
ts,
|
|
132
|
+
text,
|
|
133
|
+
meta: {
|
|
134
|
+
source: 'subagent_progress',
|
|
135
|
+
subagent_jsonl_id: opts.ctx.subagentJsonlId,
|
|
136
|
+
bucket_idx: String(opts.ctx.bucketIdx),
|
|
137
|
+
expiresAt: String(expiresAt),
|
|
138
|
+
elapsed_ms: String(opts.ctx.elapsedMs),
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
144
|
+
// Progress decision (pure — unit-testable gate for the gateway onProgress path)
|
|
145
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export interface SubagentProgressDecisionInput {
|
|
148
|
+
/** `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS` env var value (any non-empty
|
|
149
|
+
* value other than '0' disables progress envelopes entirely). */
|
|
150
|
+
disableEnvValue: string | undefined
|
|
151
|
+
/** Whether the sub-agent was a background dispatch. Foreground
|
|
152
|
+
* sub-agents already surface narrative inside the parent's turn. */
|
|
153
|
+
isBackground: boolean
|
|
154
|
+
/** Chat id from the progress-driver fleet entry; '' if not found. */
|
|
155
|
+
fleetChatId: string
|
|
156
|
+
/** Owner chat fallback (access.json allowFrom[0]); '' if none. */
|
|
157
|
+
ownerChatId: string
|
|
158
|
+
subagentJsonlId: string
|
|
159
|
+
taskDescription: string
|
|
160
|
+
latestSummary: string
|
|
161
|
+
elapsedMs: number
|
|
162
|
+
/** Window size (ms). */
|
|
163
|
+
progressIntervalMs: number
|
|
164
|
+
/** Last bucket idx already emitted for this sub-agent — null if no
|
|
165
|
+
* envelope has fired yet. The gateway tracks this per-agent and
|
|
166
|
+
* passes it in; the decision returns the new bucket idx on
|
|
167
|
+
* `deliver: true` so the caller can update its tracker. */
|
|
168
|
+
lastBucketIdx: number | null
|
|
169
|
+
/** Deterministic clock for tests. */
|
|
170
|
+
nowMs?: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type SubagentProgressSkipReason =
|
|
174
|
+
| 'env-disabled'
|
|
175
|
+
| 'foreground'
|
|
176
|
+
| 'no-chat'
|
|
177
|
+
| 'bucket-already-fired'
|
|
178
|
+
| 'first-bucket-suppressed'
|
|
179
|
+
| 'missing-jsonl-id'
|
|
180
|
+
|
|
181
|
+
export type SubagentProgressDecision =
|
|
182
|
+
| { deliver: false; reason: SubagentProgressSkipReason }
|
|
183
|
+
| { deliver: true; chatId: string; bucketIdx: number; inbound: InboundMessage }
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Decide whether to deliver a mid-flight progress envelope. Pure — all
|
|
187
|
+
* IO (registry lookup, fleet peek, access.json read) is the caller's
|
|
188
|
+
* job.
|
|
189
|
+
*
|
|
190
|
+
* Gates, in order:
|
|
191
|
+
* 1. kill-switch — `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1` disables.
|
|
192
|
+
* 2. foreground — foreground sub-agents stream natively.
|
|
193
|
+
* 3. no-chat — nowhere to deliver.
|
|
194
|
+
* 4. missing-jsonl-id — the dedup key. Without it we'd lose
|
|
195
|
+
* deterministic bucketing across restarts; refuse rather than
|
|
196
|
+
* degrade silently.
|
|
197
|
+
* 5. first-bucket-suppressed — bucket 0 covers the first
|
|
198
|
+
* `progressIntervalMs` window when ambient liveness is plenty.
|
|
199
|
+
* The earliest envelope is bucket 1 (≥ one full window in).
|
|
200
|
+
* 6. bucket-already-fired — same bucket → same spoolId → no-op.
|
|
201
|
+
*/
|
|
202
|
+
/**
|
|
203
|
+
* Parse a boolean-ish env var. Treats `undefined`, empty string, `'0'`,
|
|
204
|
+
* `'false'`, `'no'`, `'off'` (case-insensitive, trimmed) as OFF. Any
|
|
205
|
+
* other non-empty value is ON.
|
|
206
|
+
*
|
|
207
|
+
* The original gate used `value !== '0'`, which foot-gunned on
|
|
208
|
+
* `'false'` / `'no'` / `'off'` — treating those as enabled instead of
|
|
209
|
+
* disabled. Centralising the parse here keeps the kill-switch
|
|
210
|
+
* interpretation a single hop from the var-read site.
|
|
211
|
+
*/
|
|
212
|
+
export function isEnvFlagOn(value: string | undefined): boolean {
|
|
213
|
+
if (value == null) return false
|
|
214
|
+
const v = value.trim().toLowerCase()
|
|
215
|
+
if (v === '') return false
|
|
216
|
+
if (v === '0' || v === 'false' || v === 'no' || v === 'off') return false
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function decideSubagentProgress(
|
|
221
|
+
input: SubagentProgressDecisionInput,
|
|
222
|
+
): SubagentProgressDecision {
|
|
223
|
+
if (isEnvFlagOn(input.disableEnvValue)) {
|
|
224
|
+
return { deliver: false, reason: 'env-disabled' }
|
|
225
|
+
}
|
|
226
|
+
if (!input.isBackground) {
|
|
227
|
+
return { deliver: false, reason: 'foreground' }
|
|
228
|
+
}
|
|
229
|
+
const chatId = input.fleetChatId || input.ownerChatId
|
|
230
|
+
if (!chatId) {
|
|
231
|
+
return { deliver: false, reason: 'no-chat' }
|
|
232
|
+
}
|
|
233
|
+
if (!input.subagentJsonlId) {
|
|
234
|
+
return { deliver: false, reason: 'missing-jsonl-id' }
|
|
235
|
+
}
|
|
236
|
+
const bucketIdx = Math.floor(input.elapsedMs / input.progressIntervalMs)
|
|
237
|
+
if (bucketIdx < 1) {
|
|
238
|
+
return { deliver: false, reason: 'first-bucket-suppressed' }
|
|
239
|
+
}
|
|
240
|
+
if (input.lastBucketIdx != null && bucketIdx <= input.lastBucketIdx) {
|
|
241
|
+
return { deliver: false, reason: 'bucket-already-fired' }
|
|
242
|
+
}
|
|
243
|
+
const inbound = buildSubagentProgressInbound({
|
|
244
|
+
ctx: {
|
|
245
|
+
chatId,
|
|
246
|
+
subagentJsonlId: input.subagentJsonlId,
|
|
247
|
+
taskDescription: input.taskDescription,
|
|
248
|
+
latestSummary: input.latestSummary,
|
|
249
|
+
elapsedMs: input.elapsedMs,
|
|
250
|
+
bucketIdx,
|
|
251
|
+
progressIntervalMs: input.progressIntervalMs,
|
|
252
|
+
},
|
|
253
|
+
...(input.nowMs !== undefined ? { nowMs: input.nowMs } : {}),
|
|
254
|
+
})
|
|
255
|
+
return { deliver: true, chatId, bucketIdx, inbound }
|
|
256
|
+
}
|
|
@@ -267,12 +267,16 @@ export function noteTurnEnd(key: string): void {
|
|
|
267
267
|
* Clear pending-progress for a chat — reasons:
|
|
268
268
|
* 'inbound' — user sent a new message, they're re-engaged
|
|
269
269
|
* 'handback' — switchroom injected a subagent_handback channel turn
|
|
270
|
+
* 'progress' — switchroom injected a subagent_progress channel turn
|
|
271
|
+
* (#1720) — the model is about to compose an explicit
|
|
272
|
+
* in-voice reply about the worker's status, so the
|
|
273
|
+
* ambient "— still working (Nm)" edit should yield
|
|
270
274
|
* 'timeout' — exceeded MAX_LIFETIME_MS
|
|
271
275
|
* 'manual' — test / debug
|
|
272
276
|
*/
|
|
273
277
|
export function clearPending(
|
|
274
278
|
key: string,
|
|
275
|
-
reason: 'inbound' | 'handback' | 'timeout' | 'manual',
|
|
279
|
+
reason: 'inbound' | 'handback' | 'progress' | 'timeout' | 'manual',
|
|
276
280
|
): void {
|
|
277
281
|
if (!stateByKey.has(key)) return
|
|
278
282
|
const s = stateByKey.get(key)!
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Status reaction state machine for Telegram bot messages.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* extensions/telegram/src/status-reaction-variants.ts. The goal is to give
|
|
6
|
-
* the user a glanceable, non-spammy progress signal on their inbound
|
|
7
|
-
* message: 👀 received → 🤔 thinking → ✍/👨💻/⚡ working → 👍 done → 😱 error.
|
|
4
|
+
* Reflective state machine — see issue #1713 for the canonical contract.
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* The reaction on a user's inbound message represents CURRENT TURN
|
|
7
|
+
* ACTIVITY, not delivery state. Working states (`thinking`, `tool`,
|
|
8
|
+
* `coding`, `web`, `compacting`) cycle freely and bidirectionally —
|
|
9
|
+
* the same state can re-enter multiple times within one turn. No state
|
|
10
|
+
* is "higher" than another. Mid-turn replies (plain or streamed) are
|
|
11
|
+
* non-events for the reaction.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* Only ONE method terminates the controller: `finalize(reason)`. The
|
|
14
|
+
* Stop hook (delivered to the gateway as the `turn_end` IPC event) is
|
|
15
|
+
* the single terminal trigger. `setError()` paints 😱 but is NON-
|
|
16
|
+
* terminal — recovery to a working state is allowed.
|
|
13
17
|
*
|
|
14
|
-
*
|
|
18
|
+
* Three load-bearing properties (kept from the openclaw port):
|
|
19
|
+
*
|
|
20
|
+
* 1. Debounce intermediate transitions (3500ms by default — see #1713
|
|
21
|
+
* decision: 3-5s) so a model that flashes thinking → tool →
|
|
22
|
+
* thinking → tool doesn't burn the Telegram rate limit. Coalesce
|
|
23
|
+
* same-state-within-window into a single emit.
|
|
24
|
+
*
|
|
25
|
+
* 2. The terminal `finalize()` bypasses debounce.
|
|
15
26
|
*
|
|
16
27
|
* 3. Serialize all API calls through a single chain promise so two
|
|
17
28
|
* concurrent transitions never race the Telegram API. Telegram's
|
|
@@ -20,12 +31,6 @@
|
|
|
20
31
|
* Stall watchdogs auto-promote to 🥱 / 😨 if no transition arrives within
|
|
21
32
|
* 30s / 90s, so a stuck/dead inference loop is visually distinguishable
|
|
22
33
|
* from a long but healthy one.
|
|
23
|
-
*
|
|
24
|
-
* Emoji choices use Telegram's bot reaction whitelist
|
|
25
|
-
* (https://core.telegram.org/bots/api#reactiontype). The fallback chain
|
|
26
|
-
* tries each variant in order — if a chat restricts available reactions
|
|
27
|
-
* (admin-configured in some groups) the controller silently no-ops the
|
|
28
|
-
* unsupported choice instead of throwing.
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
/** Telegram allows only this fixed set of emoji as bot reactions. */
|
|
@@ -49,7 +54,6 @@ export type ReactionState =
|
|
|
49
54
|
| 'tool'
|
|
50
55
|
| 'compacting'
|
|
51
56
|
| 'done'
|
|
52
|
-
| 'silent'
|
|
53
57
|
| 'error'
|
|
54
58
|
| 'stallSoft'
|
|
55
59
|
| 'stallHard'
|
|
@@ -62,26 +66,22 @@ export type ReactionState =
|
|
|
62
66
|
* Semantic split — do not conflate these three tiers:
|
|
63
67
|
* READ 👀 = "I have seen your message" (acknowledgement only)
|
|
64
68
|
* WORKING ✍ = actively doing something (tools, coding, compacting)
|
|
65
|
-
* FINISHED 👍/💯/🎉 =
|
|
69
|
+
* FINISHED 👍/💯/🎉 = turn over (turn_end fired)
|
|
66
70
|
*
|
|
67
71
|
* 🔥 is reserved for genuine 5xx server errors (operator-events.ts).
|
|
68
72
|
* It reads as "on fire / broken" — keep it out of normal active-work states.
|
|
69
73
|
*/
|
|
70
74
|
export const REACTION_VARIANTS: Record<ReactionState, string[]> = {
|
|
71
75
|
queued: ['👀', '🤔', '🤓'], // READ: I see your message
|
|
72
|
-
thinking: ['🤔', '🤓', '👀'],
|
|
76
|
+
thinking: ['🤔', '🤓', '👀'],
|
|
73
77
|
tool: ['✍', '⚡', '👌'], // WORKING: actively using a tool
|
|
74
78
|
coding: ['👨💻', '✍', '⚡'], // WORKING: writing / running code
|
|
75
|
-
web: ['⚡', '🤔', '👌'], // WORKING: lookup in motion
|
|
76
|
-
compacting:['✍', '🤔', '👀'],
|
|
77
|
-
done: ['👍', '💯', '🎉'], // FINISHED:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
silent: ['🙊', '🤔', '😐'], // unchanged
|
|
82
|
-
error: ['😱', '😨', '🤯'], // unchanged (genuine alarm)
|
|
83
|
-
stallSoft: ['🥱', '😴', '🤔'], // unchanged
|
|
84
|
-
stallHard: ['😨', '🤯', '😱'], // unchanged
|
|
79
|
+
web: ['⚡', '🤔', '👌'], // WORKING: lookup in motion
|
|
80
|
+
compacting:['✍', '🤔', '👀'],
|
|
81
|
+
done: ['👍', '💯', '🎉'], // FINISHED: turn_end fired
|
|
82
|
+
error: ['😱', '😨', '🤯'], // NON-TERMINAL — recovery allowed
|
|
83
|
+
stallSoft: ['🥱', '😴', '🤔'],
|
|
84
|
+
stallHard: ['😨', '🤯', '😱'],
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
@@ -100,9 +100,12 @@ export function resolveToolReactionState(toolName: string): ReactionState {
|
|
|
100
100
|
return 'tool'
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Reason passed to `finalize()` — selects the terminal emoji. */
|
|
104
|
+
export type FinalizeReason = 'done' | 'error'
|
|
105
|
+
|
|
103
106
|
/** Configuration knobs the controller respects. */
|
|
104
107
|
export interface StatusReactionConfig {
|
|
105
|
-
/** Milliseconds to wait before applying a non-immediate transition. Default
|
|
108
|
+
/** Milliseconds to wait before applying a non-immediate transition. Default 3500 (#1713). */
|
|
106
109
|
debounceMs?: number
|
|
107
110
|
/** Milliseconds without progress before promoting to stallSoft. Default 30000. */
|
|
108
111
|
stallSoftMs?: number
|
|
@@ -126,8 +129,9 @@ export type ReactionEmitter = (emoji: string) => Promise<void>
|
|
|
126
129
|
* Controller managing the reaction lifecycle for a single inbound message.
|
|
127
130
|
*
|
|
128
131
|
* Lifecycle: construct → setQueued() → arbitrary intermediate transitions
|
|
129
|
-
*
|
|
130
|
-
*
|
|
132
|
+
* (setThinking / setTool / setCompacting / setError — all bidirectional
|
|
133
|
+
* and non-terminal) → finalize() to terminate. Only `finalize()` ends
|
|
134
|
+
* the controller; after termination, all further calls are no-ops.
|
|
131
135
|
*/
|
|
132
136
|
export class StatusReactionController {
|
|
133
137
|
private currentEmoji: string | null = null
|
|
@@ -148,7 +152,7 @@ export class StatusReactionController {
|
|
|
148
152
|
private readonly allowedReactions: Set<string> | null = null,
|
|
149
153
|
config: StatusReactionConfig = {},
|
|
150
154
|
) {
|
|
151
|
-
this.debounceMs = config.debounceMs ??
|
|
155
|
+
this.debounceMs = config.debounceMs ?? 3500
|
|
152
156
|
this.stallSoftMs = config.stallSoftMs ?? 30000
|
|
153
157
|
this.stallHardMs = config.stallHardMs ?? 90000
|
|
154
158
|
this.log = config.log
|
|
@@ -159,44 +163,56 @@ export class StatusReactionController {
|
|
|
159
163
|
this.scheduleState('queued', { immediate: true })
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
/** 🤔 — model is generating. Debounced. */
|
|
166
|
+
/** 🤔 — model is generating. Debounced, non-terminal, bidirectional. */
|
|
163
167
|
setThinking(): void {
|
|
164
168
|
this.scheduleState('thinking')
|
|
165
169
|
}
|
|
166
170
|
|
|
167
|
-
/** Tool-specific reaction. Debounced. */
|
|
171
|
+
/** Tool-specific reaction. Debounced, non-terminal, bidirectional. */
|
|
168
172
|
setTool(toolName?: string): void {
|
|
169
173
|
const state = toolName ? resolveToolReactionState(toolName) : 'tool'
|
|
170
174
|
this.scheduleState(state)
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
/** ✍ — context compaction in progress. */
|
|
177
|
+
/** ✍ — context compaction in progress. Debounced, non-terminal, bidirectional. */
|
|
174
178
|
setCompacting(): void {
|
|
175
179
|
this.scheduleState('compacting')
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
/**
|
|
183
|
+
* 😱 — non-terminal error indicator. Paints the error emoji but does
|
|
184
|
+
* NOT end the controller — recovery to a working state is permitted
|
|
185
|
+
* (see #1713). Use `finalize('error')` to actually terminate with
|
|
186
|
+
* the error emoji as the final state.
|
|
187
|
+
*/
|
|
188
|
+
setError(): void {
|
|
189
|
+
this.scheduleState('error')
|
|
181
190
|
}
|
|
182
191
|
|
|
183
192
|
/**
|
|
184
|
-
*
|
|
193
|
+
* Terminal — sets the controller to `finished` and emits the final
|
|
194
|
+
* emoji (👍 for 'done', 😱 for 'error'). This is the ONLY method
|
|
195
|
+
* that ends the controller; all other setX calls are non-terminal.
|
|
185
196
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* answer a question, never called `reply` / `stream_reply`, and the
|
|
190
|
-
* orphaned-reply backstop had no captured text to forward either.
|
|
191
|
-
* Terminal, bypasses debounce.
|
|
197
|
+
* Driven by the `turn_end` IPC event (Stop hook) and the disconnect
|
|
198
|
+
* / boot-sweep backstops. Bypasses debounce so the terminal emoji
|
|
199
|
+
* lands promptly. Subsequent calls are no-ops.
|
|
192
200
|
*/
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
finalize(reason: FinalizeReason = 'done'): void {
|
|
202
|
+
const state: ReactionState = reason === 'error' ? 'error' : 'done'
|
|
203
|
+
this.finishWithState(state)
|
|
195
204
|
}
|
|
196
205
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Back-compat shim — equivalent to `finalize('done')`. Prefer
|
|
208
|
+
* `finalize()` in new call sites so the terminal contract is
|
|
209
|
+
* explicit at the call site.
|
|
210
|
+
*
|
|
211
|
+
* @deprecated Use `finalize('done')` — kept so external callers don't
|
|
212
|
+
* break mid-rollout. Will be removed once all callers migrate.
|
|
213
|
+
*/
|
|
214
|
+
setDone(): void {
|
|
215
|
+
this.finalize('done')
|
|
200
216
|
}
|
|
201
217
|
|
|
202
218
|
/** Stop the controller without applying any new reaction. Terminal. */
|
|
@@ -216,13 +232,11 @@ export class StatusReactionController {
|
|
|
216
232
|
if (this.finished) return
|
|
217
233
|
const emoji = this.resolveEmoji(state)
|
|
218
234
|
if (emoji == null) {
|
|
219
|
-
// No allowed variant for this chat — silently skip rather than fall
|
|
220
|
-
// through to the chain. We still reset stall timers so that progress
|
|
221
|
-
// signals continue to keep the watchdogs at bay.
|
|
222
235
|
if (!opts.skipStallReset) this.resetStallTimers()
|
|
223
236
|
return
|
|
224
237
|
}
|
|
225
238
|
if (emoji === this.currentEmoji || emoji === this.pendingEmoji) {
|
|
239
|
+
// Coalesce same-state-within-window into a single emit.
|
|
226
240
|
if (!opts.skipStallReset) this.resetStallTimers()
|
|
227
241
|
return
|
|
228
242
|
}
|
|
@@ -245,15 +259,13 @@ export class StatusReactionController {
|
|
|
245
259
|
this.clearStallTimers()
|
|
246
260
|
// F1 fix (#553): if a non-terminal reaction is sitting in the
|
|
247
261
|
// debounce window when the turn ends, flush it BEFORE the terminal
|
|
248
|
-
// emoji emits.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// intermediate signal that the agent did any work.
|
|
262
|
+
// emoji emits. Without this, every Class B turn that completed in
|
|
263
|
+
// under `debounceMs` would collapse straight to 👀 → terminal with
|
|
264
|
+
// no intermediate working-state signal.
|
|
252
265
|
//
|
|
253
266
|
// Gate on `debounceTimer != null`: a `pendingEmoji` with no timer
|
|
254
267
|
// is already enqueued (immediate emit waiting on chainPromise) and
|
|
255
|
-
// re-enqueuing would produce a duplicate.
|
|
256
|
-
// yet-enqueued emojis need to be flushed here.
|
|
268
|
+
// re-enqueuing would produce a duplicate.
|
|
257
269
|
const flushPending = this.debounceTimer != null && this.pendingEmoji != null
|
|
258
270
|
? this.pendingEmoji
|
|
259
271
|
: null
|
|
@@ -207,8 +207,6 @@ export interface StreamReplyDeps {
|
|
|
207
207
|
charCount: number
|
|
208
208
|
sameAsLast: boolean
|
|
209
209
|
}) => void
|
|
210
|
-
/** Called on done=true to transition the status reaction controller. */
|
|
211
|
-
endStatusReaction: (chatId: string, threadId: number | undefined, verdict: 'done') => void
|
|
212
210
|
/**
|
|
213
211
|
* Optional: progress-card driver completion hook. Wired by the gateway
|
|
214
212
|
* to `progressDriver.forceCompleteTurn(...)`. Invoked after a
|
|
@@ -568,40 +566,13 @@ export async function handleStreamReply(
|
|
|
568
566
|
await stream.finalize()
|
|
569
567
|
state.activeDraftStreams.delete(sKey)
|
|
570
568
|
state.activeDraftParseModes?.delete(sKey)
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
// practice the dedup-suppress branch in turn_end was firing setDone
|
|
579
|
-
// off a 500ms-lagged read of local history rather than from a real
|
|
580
|
-
// delivery confirmation, and the disconnect-flush path was leaving
|
|
581
|
-
// 👍 firing on disconnect even when the final edit had failed.
|
|
582
|
-
// Wiring endStatusReaction at the post-finalize callback keeps the
|
|
583
|
-
// emoji honest — it now means "the final draft edit hit Telegram".
|
|
584
|
-
//
|
|
585
|
-
// Gated to the default lane: named lanes (lane:'progress',
|
|
586
|
-
// lane:'thinking', lane:'activity', etc.) are internal driver
|
|
587
|
-
// emits, not user-visible answers — they must not be allowed to
|
|
588
|
-
// claim turn-completion. A lane:'progress' emit firing setDone
|
|
589
|
-
// would race the actual answer.
|
|
590
|
-
//
|
|
591
|
-
// Mid-turn done=true tradeoff: a turn that calls
|
|
592
|
-
// stream_reply(done=true) on the default lane and then continues
|
|
593
|
-
// working will fire 👍 early; subsequent intermediate emojis become
|
|
594
|
-
// no-ops (setDone is terminal). This is the contract: done=true on
|
|
595
|
-
// the default lane is "the answer is delivered". Agents that need
|
|
596
|
-
// to continue should use additional `reply` calls or named lanes.
|
|
597
|
-
const isDefaultLaneForCompletion = args.lane == null || args.lane.length === 0
|
|
598
|
-
if (isDefaultLaneForCompletion && stream.getMessageId() != null) {
|
|
599
|
-
try {
|
|
600
|
-
deps.endStatusReaction(chat_id, threadId, 'done')
|
|
601
|
-
} catch (err) {
|
|
602
|
-
deps.writeError(`telegram channel: stream_reply: endStatusReaction hook threw: ${err}\n`)
|
|
603
|
-
}
|
|
604
|
-
}
|
|
569
|
+
// #1713: stream_reply done=true is a NON-EVENT for the status
|
|
570
|
+
// reaction. The reaction reflects current turn activity, not
|
|
571
|
+
// delivery state — only the `turn_end` IPC handler finalizes (👍).
|
|
572
|
+
// Stream completion is "I'm done speaking", not "turn over"; the
|
|
573
|
+
// model may continue with post-stream tool work. This is a
|
|
574
|
+
// deliberate revert of the Bug Z fix (PR #602 follow-up) — see
|
|
575
|
+
// the #1713 issue body for the rationale.
|
|
605
576
|
|
|
606
577
|
// Hard-fail surface: if the stream finalized without ever assigning
|
|
607
578
|
// a message id, the initial send never landed (4096+ chars hits
|