switchroom 0.13.26 → 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.
Files changed (30) hide show
  1. package/dist/cli/switchroom.js +2 -2
  2. package/package.json +1 -1
  3. package/telegram-plugin/active-reactions-sweep.ts +4 -4
  4. package/telegram-plugin/dist/gateway/gateway.js +239 -64
  5. package/telegram-plugin/docs/waiting-ux-spec.md +17 -1
  6. package/telegram-plugin/gateway/disconnect-flush.ts +10 -6
  7. package/telegram-plugin/gateway/gateway.ts +166 -51
  8. package/telegram-plugin/gateway/inbound-spool.ts +69 -2
  9. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +14 -0
  10. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +256 -0
  11. package/telegram-plugin/pending-work-progress.ts +5 -1
  12. package/telegram-plugin/status-reactions.ts +70 -58
  13. package/telegram-plugin/stream-reply-handler.ts +7 -36
  14. package/telegram-plugin/subagent-watcher.ts +64 -3
  15. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -3
  16. package/telegram-plugin/tests/inbound-spool-progress.test.ts +213 -0
  17. package/telegram-plugin/tests/inbound-spool.test.ts +62 -0
  18. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  19. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  20. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  21. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +78 -135
  22. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  23. package/telegram-plugin/tests/status-reactions.test.ts +56 -27
  24. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  25. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -25
  26. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  27. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  28. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +22 -0
  29. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +269 -0
  30. 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
- * Ports the pattern from openclaw's src/channels/status-reactions.ts +
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
- * Three load-bearing properties (all from openclaw research):
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
- * 1. Debounce intermediate transitions by 700ms so a model that flashes
12
- * thinking tool thinking tool doesn't burn the rate limit.
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
- * 2. Terminal states (queued / done / error) bypass the debounce.
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 👍/💯/🎉 = definitively done; a reply landed
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: ['🤔', '🤓', '👀'], // unchanged
76
+ thinking: ['🤔', '🤓', '👀'],
73
77
  tool: ['✍', '⚡', '👌'], // WORKING: actively using a tool
74
78
  coding: ['👨‍💻', '✍', '⚡'], // WORKING: writing / running code
75
- web: ['⚡', '🤔', '👌'], // WORKING: lookup in motion (👀 reserved for READ)
76
- compacting:['✍', '🤔', '👀'], // unchanged
77
- done: ['👍', '💯', '🎉'], // FINISHED: reply landed
78
- // 🙊 turn ended without producing a user-visible reply. Distinct from
79
- // 'done' (which means "reply landed") so the user doesn't read 👍 as
80
- // "agent acknowledged" when actually nothing was sent. See issue #132.
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 700. */
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
- * → setDone() / setError() to terminate. After termination, all further
130
- * setX calls are no-ops.
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 ?? 700
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
- /** 👍 — final reply delivered. Terminal, bypasses debounce. */
179
- setDone(): void {
180
- this.finishWithState('done')
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
- * 🙊turn ended without producing a user-visible reply.
193
+ * Terminalsets 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
- * Distinct from `setDone()` so the user doesn't read 👍 as "agent
187
- * acknowledged but stayed silent on purpose" when in fact nothing was
188
- * actually sent. Common case (#132): agent ran a long Bash chain to
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
- setSilent(): void {
194
- this.finishWithState('silent')
201
+ finalize(reason: FinalizeReason = 'done'): void {
202
+ const state: ReactionState = reason === 'error' ? 'error' : 'done'
203
+ this.finishWithState(state)
195
204
  }
196
205
 
197
- /** 😱 — generation failed. Terminal, bypasses debounce. */
198
- setError(): void {
199
- this.finishWithState('error')
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. Pre-fix, `clearDebounceTimer()` here silently
249
- // dropped the pending state every Class B turn that completed in
250
- // under `debounceMs` (default 700ms) collapsed to 👀 → 👍 with no
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. Only debounced-but-not-
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
- // Bug Z fix: fire the terminal 👍 here, gated to the default
572
- // (unnamed) lane and only after finalize() resolves so the emoji is
573
- // tied to the final edit actually landing in Telegram.
574
- //
575
- // History (see prior comment removed in this commit): we previously
576
- // deferred the 👍 to turn_end on the theory that a turn might call
577
- // stream_reply(done=true) mid-flight and continue working. In
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